Initial commit
Build and Push Multi-Arch Docker Image / build (push) Has been cancelled

This commit is contained in:
dido committed 2026-09-03 11:19:41 +03:00
commit 8ce01cb00a
34 files changed
+8346

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
# Git
.git
.gitignore
# Python cache
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
# Local data directory (mounted as volume, not in image)
data/
*.db
*.sqlite
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Old/backup files
xterm_webssh_client.html
*.bak
*.backup
# Docker
.dockerignore
docker-compose*.yml
Dockerfile*
# Documentation
README.md
*.md
# Logs
*.log
logs/
+166
View File
@@ -0,0 +1,166 @@
name: Build and Publish WebSSH
on:
push:
tags:
- 'v*'
- 'main'
pull_request:
branches: [ main ]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
deployments: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract version
id: version
run: |
if [[ "${{ github.ref }}" == "refs/heads/main" ]]; then
echo "version=latest" >> $GITHUB_OUTPUT
echo "tag=latest" >> $GITHUB_OUTPUT
else
VERSION=$(echo "${{ github.ref }}" | sed 's|refs/tags/v||')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$VERSION" >> $GITHUB_OUTPUT
fi
- name: Build and push AMD64
uses: docker/build-push-action@v5
with:
context: .
file: deployment/Dockerfile
platforms: linux/amd64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-amd64
labels: |
org.opencontainers.image.title=WebSSH
org.opencontainers.image.description=Browser-based SSH client with multi-connection support
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Build and push ARM64
uses: docker/build-push-action@v5
with:
context: .
file: deployment/Dockerfile
platforms: linux/arm64
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest-arm64
labels: |
org.opencontainers.image.title=WebSSH
org.opencontainers.image.description=Browser-based SSH client with multi-connection support
org.opencontainers.image.version=${{ steps.version.outputs.version }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
org.opencontainers.image.source=https://github.com/${{ github.repository }}
- name: Create and push manifest
run: |
# Create and push multi-architecture manifest
docker buildx imagetool create \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} \
--tag ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64 \
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64
docker buildx imagetool push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
docker buildx imagetool push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Generate release notes
if: startsWith(github.ref, 'refs/tags/')
id: release_notes
run: |
cat << 'EOF' > release_notes.md
## 🚀 WebSSH ${{ steps.version.outputs.version }} Release Notes
### ✨ New Features
- 📚 **Enhanced Help System**: Added comprehensive help documentation with modal interface
- 🎨 **Improved UI**: Wider help modal for better readability
- 🐳 **Multi-Architecture Support**: AMD64 and ARM64 builds
- 🔧 **Configuration**: MAX_UPLOAD_SIZE support with human-readable formats
- 🛡️ **Security**: Enhanced authentication and encryption
### 🐛 Bug Fixes
- Fixed upload size inconsistency issues
- Improved error handling and user feedback
- Enhanced file transfer reliability
### 🏗️ Technical Updates
- Updated to WebSSH v${{ steps.version.outputs.version }}
- Multi-architecture Docker images
- Optimized build process
- Enhanced CI/CD pipeline
### 📋 Installation
```bash
docker pull ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
docker run -p 8080:8080 ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}
```
### 🔗 Links
- 📖 [Documentation](https://github.com/${{ github.repository }}/blob/main/help.html)
- 🐳 [Docker Hub](https://github.com/${{ github.repository }}/pkgs/container/${{ env.IMAGE_NAME }})
- 📋 [Releases](https://github.com/${{ github.repository }}/releases)
EOF
- name: Create Release
if: startsWith(github.ref, 'refs/tags/')
uses: softprops/action-gh-release@v1
with:
files: |
release_notes.md
body_path: release_notes.md
name: WebSSH ${{ steps.version.outputs.version }}
tag_name: ${{ steps.version.outputs.version }}
draft: false
prerelease: false
- name: Update latest tag
if: startsWith(github.ref, 'refs/tags/')
run: |
# Update latest tag to point to new version
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
git tag -f latest ${{ steps.version.outputs.version }}
git push origin latest --force
- name: Summary
run: |
echo "## 🎉 Build and Publish Complete!"
echo "### 📦 Images Built and Pushed:"
echo "- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }} (multi-arch)"
echo "- ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest (multi-arch)"
echo ""
echo "### 🏷️ Architectures:"
echo "- AMD64: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-amd64"
echo "- ARM64: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}-arm64"
echo ""
echo "### 📋 Manifests:"
echo "- Version: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.version.outputs.version }}"
echo "- Latest: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
+61
View File
@@ -0,0 +1,61 @@
name: Build and Push Multi-Arch Docker Image
on:
push:
branches: [ main, master ]
tags: [ 'v*' ]
pull_request:
branches: [ main, master ]
env:
REGISTRY: docker.io
IMAGE_NAME: ${{ secrets.DOCKERHUB_USERNAME }}/webssh
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: deployment/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+37
View File
@@ -0,0 +1,37 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
*.egg-info/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
.venv/
venv/
# Environment
.env
# Project data
/data/
data/
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# IDE
.vscode/
!.vscode/extensions.json
.idea/
# OS files
.DS_Store
Thumbs.db
+258
View File
@@ -0,0 +1,258 @@
# StrixWebSSH - Browser-based SSH Client
Multi-connection SSH client that runs in your browser with JWT authentication.
## Features
- **Multi-connection support**: Manage multiple SSH connections simultaneously
- **JWT Authentication**: Secure login system with token-based authentication
- **Password & SSH Key auth**: Support for both password and SSH key authentication
- **SFTP File Manager**: Built-in file manager with upload/download capabilities
- **Terminal emulator**: Uses xterm.js for a full terminal experience
- **Connection management**: Sidebar with connection list, status indicators, and quick actions
- **Session Recording**: Automatic recording of SSH sessions in asciinema format with playback and configurable retention
- **Dark theme**: Modern dark UI design
- **Single port**: All services run through port 8080 (HTTP API + WebSocket)
## Quick Start
### 1. Start the Backend
```bash
docker-compose -f deployment/docker-compose.yml up -d --build
```
### 2. Open the Client
Open http://localhost:8080 in your browser. The application is served via a Caddy reverse proxy.
### 3. Login
Default credentials:
- Username: `admin`
- Password: `admin`
## Configuration
### Users
Set users via environment variable in `deployment/docker-compose.yml`:
```yaml
environment:
- STRIXWEBSSH_USERS=user1:pass1,user2:pass2
```
### JWT Secret
Set custom JWT secret:
```yaml
environment:
- JWT_SECRET=your-secret-key-here
```
If not set, a random secret is generated on startup.
### Max Upload Size
Set maximum file upload size (default: 100MB):
```yaml
environment:
- MAX_UPLOAD_SIZE=50M
```
Supported formats: `10M`, `100MB`, `1G`, `1GB`, or bytes as integer.
### Session Recording
Configure automatic session recording (default: enabled, 30 days retention):
```yaml
environment:
- RECORDING_ENABLED=true
- RECORDING_RETENTION_DAYS=30
```
- `RECORDING_ENABLED`: Enable/disable session recording (`true` or `false`)
- `RECORDING_RETENTION_DAYS`: Number of days to keep recordings before automatic deletion
Recordings are stored in `/data/recordings/` in asciinema v2 format (gzip compressed).
## Ports
- `8080` - HTTP API and WebSocket (Caddy reverse proxy)
All traffic goes through port 8080. Caddy routes:
- `/ws` → WebSocket SSH server (internal port 8081)
- `/api/` → HTTP API endpoints (internal port 8082)
- Everything else → Static files (served from the repository `index.html` + `static/`)
## File Structure
```
web-ssh/
├── src/ # Application source code
│ ├── run.py # Backend entry point
│ ├── serve_static.py # Static file server
│ ├── index.html # Main client interface
│ ├── help.html # Help documentation
│ ├── static/ # Frontend assets
│ └── webssh_backend/ # Backend package
├── deployment/ # Docker and deployment files
│ ├── docker-compose.yml
│ ├── Dockerfile
│ ├── Caddyfile
│ ├── docker-entrypoint.sh
│ └── scripts/
├── requirements.txt # Python dependencies
├── run.py # Compatibility wrapper from repo root
├── serve_static.py # Compatibility wrapper from repo root
├── README.md # This file
└── .github/workflows/ # CI/CD
```
## API Endpoints
### POST /api/login
Login and get JWT token.
Request:
```json
{
"username": "admin",
"password": "admin"
}
```
Response:
```json
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGc...",
"username": "admin",
"expires": "2024-01-20T12:00:00+00:00"
}
```
### POST /api/verify
Verify JWT token validity.
Request:
```json
{
"token": "eyJ0eXAiOiJKV1QiLCJhbGc..."
}
```
Response:
```json
{
"valid": true,
"username": "admin"
}
```
## WebSocket Protocol
Connect to `ws://localhost:8080/ws?token=<JWT_TOKEN>`
After connection, send SSH credentials:
```json
{
"host": "192.168.1.100",
"port": 22,
"username": "root",
"password": "secret",
"key": "-----BEGIN OPENSSH PRIVATE KEY-----..."
}
```
## Reverse Proxy Configuration
### Caddy
```caddyfile
your-domain.com {
reverse_proxy localhost:8080
}
```
### Caddy
```caddyfile
{
auto_https off
}
:8080 {
handle_path /ws* {
reverse_proxy strixwebssh:8081
}
handle_path /api/* {
reverse_proxy strixwebssh:8082
}
route {
root * /srv
try_files {path} /index.html
file_server
}
}
```
## Docker Hub
Pre-built multi-arch images available:
```bash
docker pull danaildr/strixwebssh:latest
docker pull danaildr/strixwebssh:0.3.1
```
Supported architectures: `linux/amd64`, `linux/arm64`
## Security Notes
- Always use HTTPS in production (configure reverse proxy with SSL)
- Change default credentials
- Set a strong JWT_SECRET
- The backend uses `AutoAddPolicy()` for SSH host keys (testing only - change for production)
- Token expiry: 8 hours
- File upload limit: configurable via MAX_UPLOAD_SIZE (default 100MB)
## Building and Publishing
### Local Build
```bash
docker-compose up -d --build
```
### Build for Docker Hub (Multi-arch)
```bash
./docker-build-and-push.sh --build
```
This builds and pushes both `linux/amd64` and `linux/arm64` images with tags `latest` and version tag.
### Version Management
```bash
./version-manager.sh
```
Interactive script to update version numbers across all files.
## Development
```bash
# Install dependencies
pip install -r requirements.txt
# Run backend directly (for development)
python start_backend.py
# Note: When running directly, services use internal ports:
# - WebSocket SSH: port 8081
# - HTTP API: port 8082
# - WebSocket SFTP: port 8083
#
# Use docker-compose for production deployment with Caddy on port 8080
```
+25
View File
@@ -0,0 +1,25 @@
{
# Disable automatic HTTPS for local development
auto_https off
}
:8080 {
encode gzip
# WebSocket SSH endpoint (keep full path)
handle /ws* {
reverse_proxy 127.0.0.1:8081
}
# HTTP API endpoints (preserve /api/* path)
handle /api/* {
reverse_proxy 127.0.0.1:8082
}
# Static files and SPA fallback
route {
root * /srv
try_files {path} /index.html
file_server
}
}
+148
View File
@@ -0,0 +1,148 @@
# Multi-stage Dockerfile for WebSSH - Optimized for small image size
# Stage 1: Builder - compiles Python packages with build dependencies
FROM python:3.11-slim AS builder
# Install build dependencies needed for compiling cryptography/pynacl on arm/v7
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
make \
python3-dev \
libffi-dev \
libssl-dev \
libsodium-dev \
curl \
ca-certificates \
binutils \
&& rm -rf /var/lib/apt/lists/*
# Install Python dependencies into a virtual environment
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt /tmp/
COPY src/ /tmp/src/
# Prefer prebuilt wheels when available to avoid building larger compiled artifacts
RUN pip install --no-cache-dir --prefer-binary -r /tmp/requirements.txt
# Strip compiled extensions and remove tests/pycache in venv to reduce size
RUN set -eux; \
# Strip native extensions to reduce size when possible
if command -v strip >/dev/null 2>&1; then \
find /opt/venv -type f -name '*.so' -print0 | xargs -0 -r strip --strip-unneeded || true; \
find /opt/venv -type f -executable -print0 | xargs -0 -r file | awk -F: '/ELF/{print $1}' | xargs -0 -r strip --strip-unneeded || true; \
fi; \
# Remove Python caches and compiled bytecode
find /opt/venv -type d -name '__pycache__' -print0 | xargs -0 -r rm -rf || true; \
find /opt/venv -type f -name '*.pyc' -print0 | xargs -0 -r rm -f || true; \
# Remove test folders from packages
find /opt/venv -type d \( -name 'tests' -o -name 'test' \) -print0 | xargs -0 -r rm -rf || true; \
# Remove static docs and unnecessary package metadata that are not needed at runtime
find /opt/venv -type d -name 'doc' -print0 | xargs -0 -r rm -rf || true; \
find /opt/venv -type d -name 'docs' -print0 | xargs -0 -r rm -rf || true; \
# Remove pip/setuptools/wheel from the venv to save space (not needed at runtime)
rm -rf /opt/venv/bin/pip* || true; \
find /opt/venv -path '*/site-packages/pip*' -print0 | xargs -0 -r rm -rf || true; \
find /opt/venv -path '*/site-packages/setuptools*' -print0 | xargs -0 -r rm -rf || true; \
find /opt/venv -path '*/site-packages/wheel*' -print0 | xargs -0 -r rm -rf || true; \
# Remove static metadata not required for runtime (careful: keep runtime metadata intact)
find /opt/venv -type d -name '*.dist-info' -prune -print0 | xargs -0 -r rm -rf || true || true
# Download Caddy binary for the build target (multi-arch aware)
ARG TARGETARCH
ARG TARGETVARIANT
RUN set -eux; \
DARCH="${TARGETARCH:-$(dpkg --print-architecture)}"; \
VARIANT="${TARGETVARIANT:-}"; \
case "$DARCH" in \
amd64) ARCH=amd64 ;; \
arm64) ARCH=arm64 ;; \
arm) if [ "${VARIANT}" = "v7" ]; then ARCH=armv7; else ARCH=arm; fi ;; \
*) ARCH=amd64 ;; \
esac; \
echo "Downloading Caddy for arch=${ARCH}"; \
# Try multiple candidate arch names for robustness (especially for arm variants)
candidates="${ARCH}"; \
if [ "${ARCH}" = "armv7" ]; then candidates="armv7 arm armv6"; fi; \
if [ "${ARCH}" = "arm" ]; then candidates="arm armv7 armv6"; fi; \
downloaded=0; \
for a in $candidates; do \
echo "Trying caddy download arch=$a"; \
if curl -fsSL "https://caddyserver.com/api/download?os=linux&arch=$a" -o /tmp/caddy_dl; then \
echo "Downloaded caddy for arch=$a"; downloaded=1; break; \
else \
echo "No binary for arch=$a"; fi; \
done; \
if [ "$downloaded" -ne 1 ]; then echo "Failed to download Caddy for any candidate: $candidates" >&2; exit 1; fi; \
# If it's a tar.gz archive, extract it; otherwise treat as raw binary
if tar -tzf /tmp/caddy_dl >/dev/null 2>&1; then \
tar -xzf /tmp/caddy_dl -C /tmp; \
else \
mv /tmp/caddy_dl /tmp/caddy_raw || true; \
fi; \
mkdir -p /tmp/caddy_bin_dir; \
# Preferred locations
if [ -f /tmp/caddy ]; then mv /tmp/caddy /tmp/caddy_bin_dir/caddy; fi; \
if [ -f /tmp/caddy_linux_${ARCH} ]; then mv /tmp/caddy_linux_${ARCH} /tmp/caddy_bin_dir/caddy; fi; \
if [ -f /tmp/caddy_raw ]; then mv /tmp/caddy_raw /tmp/caddy_bin_dir/caddy; fi; \
# Fallback: find any executable named 'caddy' under /tmp
if [ ! -f /tmp/caddy_bin_dir/caddy ]; then \
found=$(find /tmp -type f -name caddy -perm /111 -print -quit || true); \
if [ -n "$found" ]; then mv "$found" /tmp/caddy_bin_dir/caddy; fi; \
fi; \
if [ ! -f /tmp/caddy_bin_dir/caddy ]; then echo "Caddy binary not found after download" >&2; exit 1; fi; \
chmod +x /tmp/caddy_bin_dir/caddy; \
# Strip Caddy to reduce size
if command -v strip >/dev/null 2>&1; then strip --strip-unneeded /tmp/caddy_bin_dir/caddy || true; fi
# Stage 2: Final - minimal runtime image
FROM python:3.11-slim
# Install only runtime dependencies (no build tools)
RUN apt-get update && apt-get install -y --no-install-recommends \
gosu \
libsodium23 \
libffi8 \
&& rm -rf /var/lib/apt/lists/*
# Copy Python packages from builder
COPY --from=builder /opt/venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
# Reinstall runtime dependencies explicitly in the final image to avoid missing package metadata
COPY requirements.txt /tmp/requirements.txt
RUN /opt/venv/bin/python -m ensurepip --upgrade && /opt/venv/bin/python -m pip install --no-cache-dir --prefer-binary -r /tmp/requirements.txt && rm -f /tmp/requirements.txt
# Security: Run as non-root user
RUN groupadd -r strixwebssh && useradd -r -g strixwebssh strixwebssh
WORKDIR /app/src
# Copy application and static files
COPY src/ /app/src/
COPY deployment/docker-entrypoint.sh /usr/local/bin/
# Create .ssh directory for host keys
RUN mkdir -p /app/src/.ssh && chown -R strixwebssh:strixwebssh /app/src
# Copy entrypoint
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Copy the Caddy binary prepared in the builder stage
COPY --from=builder /tmp/caddy_bin_dir/caddy /usr/local/bin/caddy
RUN chmod +x /usr/local/bin/caddy || true
# Copy Caddyfile and static assets after package installation to avoid dpkg conffile prompts
COPY deployment/Caddyfile /etc/caddy/Caddyfile
COPY src/index.html /srv/
COPY src/help.html /srv/
COPY src/static/ /srv/static/
# Expose host-facing port for Caddy and internal service ports
EXPOSE 8080 8081 8082 8083
# Health check via Caddy (serving root) using Python (no curl required)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import sys,urllib.request as u;\ntry:\n r=u.urlopen('http://127.0.0.1:8080/', timeout=5);\n code=getattr(r,'status',None) or r.getcode();\n sys.exit(0 if code==200 else 1)\nexcept Exception:\n sys.exit(1)"
# Use shell form to see entrypoint errors
ENTRYPOINT /usr/local/bin/docker-entrypoint.sh
+49
View File
@@ -0,0 +1,49 @@
services:
strixwebssh:
build:
context: ..
dockerfile: deployment/Dockerfile
container_name: strixwebssh-backend
restart: unless-stopped
# StrixWebSSH runs internal services; integrated Caddy will expose port 8080 to the host
ports:
- "8080:8080"
environment:
- TZ=Europe/Sofia
- PYTHONUNBUFFERED=1
- LOG_LEVEL=INFO
# Configure users: format is username:password,username2:password2
- STRIXWEBSSH_USERS=admin:admin
# Max upload size (supports format like "100M" for 100MB or "50M" for 50MB)
- MAX_UPLOAD_SIZE=30M
# Session Recording Configuration
- RECORDING_ENABLED=true
- RECORDING_RETENTION_DAYS=30
# Security hardening
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:noexec,nosuid,size=100m
# Resource limits
deploy:
resources:
limits:
cpus: '1.0'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
volumes:
- strixwebssh_data:/data
# Optionally expose code to other services (read-only)
# - ./:/srv/strixwebssh:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
strixwebssh_data:
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -x
# Check if running as root
if [ "$(id -u)" != "0" ]; then
echo "Not running as root, starting application directly..."
exec python /app/src/run.py
fi
# Create data directory with correct permissions for the StrixWebSSH user
if [ ! -d "/data" ]; then
echo "Creating /data directory..."
mkdir -p /data
fi
echo "Current /data permissions:"
ls -la /data || true
ls -la / | grep data || true
# Ensure /data is writable and owned by the StrixWebSSH user (UID 999)
echo "Setting permissions on /data..."
chmod 755 /data
chown strixwebssh:strixwebssh /data
# Check if chown worked
if [ "$(stat -c %U /data)" != "strixwebssh" ]; then
echo "WARNING: Could not change ownership of /data to strixwebssh"
echo "Current owner: $(stat -c %U /data)"
echo "This may happen with bind mounts. Ensure host directory is writable."
fi
echo "Final /data permissions:"
ls -la /data || true
# Configure nginx max upload size from environment
MAX_UPLOAD_SIZE="${MAX_UPLOAD_SIZE:-100M}"
# Start Caddy in background (serves static files and reverse-proxies to local services)
if command -v caddy >/dev/null 2>&1; then
echo "Starting Caddy..."
caddy run --config /etc/caddy/Caddyfile &
else
echo "Caddy binary not found; continuing without reverse proxy"
fi
# Start the application (run as the StrixWebSSH user)
exec gosu strixwebssh python /app/src/run.py
+88
View File
@@ -0,0 +1,88 @@
#!/bin/bash
# Build and Push Script for WebSSH
# Builds Docker image and pushes to Docker Hub
set -e
# Configuration
IMAGE_NAME="danaildr/webssh"
VERSION="0.3.1"
PLATFORMS="linux/amd64,linux/arm64,linux/arm/v7"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🐳 Docker Hub Build & Push Script${NC}"
echo -e "${BLUE}=================================${NC}"
ensure_buildx_builder() {
if ! docker buildx inspect >/dev/null 2>&1; then
docker buildx create --use --name webssh-builder >/dev/null
fi
docker buildx inspect --bootstrap >/dev/null
}
# Function to build and push to Docker Hub
build_and_push() {
# Check if user is logged in to Docker Hub
if ! docker info | grep -q "Username"; then
echo -e "${YELLOW}⚠️ You're not logged in to Docker Hub${NC}"
echo -e "${BLUE}ℹ️ Please run: docker login${NC}"
return 1
fi
ensure_buildx_builder
echo -e "${GREEN}🏗 Building & pushing multi-arch image ${IMAGE_NAME}:${VERSION} (${PLATFORMS})...${NC}"
docker buildx build \
--platform "${PLATFORMS}" \
--tag "${IMAGE_NAME}:${VERSION}" \
--tag "${IMAGE_NAME}:latest" \
--push \
--file deployment/Dockerfile \
.
echo -e "${GREEN}✅ Docker build & push successful${NC}"
echo ""
echo -e "${YELLOW}⚠️ Don't forget to create a GitHub release!${NC}"
}
# Function to show usage
show_usage() {
echo "Usage: $0 [options]"
echo ""
echo "Options:"
echo " -v, --version Show configured version tag"
echo " -b, --build Build and push to Docker Hub"
echo " -h, --help Show this help message"
echo ""
echo "Examples:"
echo " $0 --version # Show version tag"
echo " $0 --build # Build and push multi-arch image"
}
# Parse command line arguments
case "${1:-}" in
-v|--version)
echo "${VERSION}"
;;
-b|--build)
build_and_push
;;
-h|--help|"")
show_usage
;;
*)
echo -e "${RED}❌ Unknown option: $1${NC}"
show_usage
exit 1
;;
esac
+212
View File
@@ -0,0 +1,212 @@
#!/bin/bash
# Version Management Script for WebSSH
# Automatically updates version numbers in help.html and build scripts
set -e
# Configuration
ROOT_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
VERSION_FILE="$ROOT_DIR/src/help.html"
BUILD_SCRIPT="$ROOT_DIR/deployment/scripts/docker-build-and-push.sh"
GITHUB_WORKFLOW="$ROOT_DIR/.github/workflows/build-and-publish.yml"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
echo -e "${BLUE}🔧 WebSSH Version Management${NC}"
echo -e "${BLUE}=================================${NC}"
# Function to get current version
get_current_version() {
if [[ -f "$VERSION_FILE" ]]; then
grep -o 'WebSSH Multi-Connection Terminal v' "$VERSION_FILE" | sed 's/.*v\([0-9.]\+\).*/\1/'
else
echo "0.0.0"
fi
}
# Function to update version in help.html
update_help_version() {
local new_version=$1
echo -e "${GREEN}📝 Updating $VERSION_FILE to v$new_version${NC}"
# Update version in help.html
sed -i "s/WebSSH Multi-Connection Terminal v[0-9.]\+\.[0-9]\+/WebSSH Multi-Connection Terminal v$new_version/" "$VERSION_FILE"
echo -e "${GREEN}✅ $VERSION_FILE updated to v$new_version${NC}"
}
# Function to update version in build script
update_build_version() {
local new_version=$1
echo -e "${GREEN}🔨 Updating $BUILD_SCRIPT to v$new_version${NC}"
# Update version in build script
sed -i "s/VERSION=\"[0-9.]\+\.[0-9]\+\"/VERSION=\"$new_version\"/" "$BUILD_SCRIPT"
echo -e "${GREEN}✅ $BUILD_SCRIPT updated to v$new_version${NC}"
}
# Function to update version in GitHub workflow
update_github_workflow() {
local new_version=$1
echo -e "${GREEN}🐙 Updating $GITHUB_WORKFLOW to v$new_version${NC}"
# Update version in GitHub workflow
sed -i "s/WebSSH Multi-Connection Terminal v[0-9.]\+\.[0-9]\+/WebSSH Multi-Connection Terminal v$new_version/" "$GITHUB_WORKFLOW"
echo -e "${GREEN}✅ $GITHUB_WORKFLOW updated to v$new_version${NC}"
}
# Function to update version in Dockerfile (if needed)
update_dockerfile_version() {
local new_version=$1
echo -e "${GREEN}🐳 Checking if Dockerfile needs version update...${NC}"
# Check if Dockerfile has version comment
if [[ -f "Dockerfile" ]] && grep -q "# Production-ready Dockerfile for WebSSH" "Dockerfile"; then
echo -e "${YELLOW}ℹ️ Dockerfile already has version comment${NC}"
else
echo -e "${YELLOW}⚠️ Dockerfile missing version comment${NC}"
echo -e "${BLUE}ℹ️ Consider adding version comment to Dockerfile${NC}"
fi
}
# Function to validate version format
validate_version() {
local version=$1
if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo -e "${RED}❌ Invalid version format: $version. Use format: X.Y.Z${NC}"
return 1
fi
return 0
}
# Main menu
show_menu() {
echo -e "${YELLOW}Current version: $(get_current_version)${NC}"
echo ""
echo -e "${BLUE}Choose an option:${NC}"
echo "1) Set new version"
echo "2) Patch version (increment last digit)"
echo "3) Minor version (increment middle digit)"
echo "4) Major version (increment first digit)"
echo "5) Show current files"
echo "6) Exit"
echo ""
read -p "Enter choice [1-6]: " choice
}
# Function to show current files
show_current_files() {
echo -e "${BLUE}📁 Current version information:${NC}"
echo -e "${GREEN} help.html: v$(get_current_version)${NC}"
echo -e "${GREEN} ${BUILD_SCRIPT}: v$(grep -o 'VERSION="[0-9.]\\+"' "${BUILD_SCRIPT}" | head -1 | sed 's/VERSION="\([0-9.]\+\)"/\1/')${NC}"
echo -e "${GREEN} ${GITHUB_WORKFLOW}: v$(grep -o 'WebSSH Multi-Connection Terminal v[0-9.]\\+' "${GITHUB_WORKFLOW}" | sed 's/.*v\([0-9.]\+\)/\1/')${NC}"
echo ""
}
# Main logic
main() {
while true; do
show_menu
case $choice in
1)
read -p "Enter new version (X.Y.Z): " new_version
if validate_version "$new_version"; then
update_help_version "$new_version"
update_build_version "$new_version"
update_github_workflow "$new_version"
update_dockerfile_version "$new_version"
echo -e "${GREEN}🎉 All files updated to v$new_version${NC}"
fi
;;
2)
current=$(get_current_version)
if [[ "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=${BASH_REMATCH[2]}
patch=$((${BASH_REMATCH[3]} + 1))
new_version="${major}.${minor}.${patch}"
update_help_version "$new_version"
update_build_version "$new_version"
update_github_workflow "$new_version"
update_dockerfile_version "$new_version"
echo -e "${GREEN}🎉 Patch version updated to v$new_version${NC}"
else
echo -e "${RED}❌ Could not parse current version${NC}"
fi
;;
3)
current=$(get_current_version)
if [[ "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=${BASH_REMATCH[1]}
minor=$((${BASH_REMATCH[2]} + 1))
patch=0
new_version="${major}.${minor}.${patch}"
update_help_version "$new_version"
update_build_version "$new_version"
update_github_workflow "$new_version"
update_dockerfile_version "$new_version"
echo -e "${GREEN}🎉 Minor version updated to v$new_version${NC}"
else
echo -e "${RED}❌ Could not parse current version${NC}"
fi
;;
4)
current=$(get_current_version)
if [[ "$current" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
major=$((${BASH_REMATCH[1]} + 1))
minor=0
patch=0
new_version="${major}.${minor}.${patch}"
update_help_version "$new_version"
update_build_version "$new_version"
update_github_workflow "$new_version"
update_dockerfile_version "$new_version"
echo -e "${GREEN}🎉 Major version updated to v$new_version${NC}"
else
echo -e "${RED}❌ Could not parse current version${NC}"
fi
;;
5)
show_current_files
;;
6)
echo -e "${GREEN}👋 Goodbye!${NC}"
exit 0
;;
*)
echo -e "${RED}❌ Invalid choice. Please try again.${NC}"
;;
esac
echo ""
read -p "Press Enter to continue..." -r
done
}
# Check if files exist
if [[ ! -f "$VERSION_FILE" ]]; then
echo -e "${RED}❌ $VERSION_FILE not found${NC}"
exit 1
fi
if [[ ! -f "$BUILD_SCRIPT" ]]; then
echo -e "${RED}❌ $BUILD_SCRIPT not found${NC}"
exit 1
fi
if [[ ! -f "$GITHUB_WORKFLOW" ]]; then
echo -e "${RED}❌ $GITHUB_WORKFLOW not found${NC}"
exit 1
fi
# Run main function
main
+135
View File
@@ -0,0 +1,135 @@
# WebSSH - Browser-based SSH Client
[![Docker Pulls](https://img.shields.io/docker/pulls/danaildr/webssh)](https://hub.docker.com/r/danaildr/webssh)
[![Docker Image Size](https://img.shields.io/docker/image-size/danaildr/webssh/latest)](https://hub.docker.com/r/danaildr/webssh)
Multi-connection SSH client that runs entirely in your browser with JWT authentication, SFTP file manager, and automatic session recording. Built with Python, Paramiko, WebSockets, and xterm.js.
**Perfect for:** System administrators, DevOps engineers, developers who need quick SSH access from any browser without installing clients.
## Features
- **Multi-connection support** - Manage multiple SSH connections simultaneously through a sidebar interface
- **JWT Authentication** - Secure token-based authentication (configurable users, 8-hour token expiry)
- **Password & SSH Key authentication** - Support for both password and SSH key (RSA, Ed25519, ECDSA)
- **SFTP File Manager** - Built-in file manager with drag-and-drop upload/download, directory navigation, and file operations
- **Terminal emulator** - Full xterm.js terminal experience with proper scrolling and colors
- **Session Recording** - Automatic recording of SSH sessions in asciinema format with playback and configurable retention
- **Auto-Lock & Auto-Disconnect** - Automatic session locking after inactivity (configurable per connection) and auto-disconnect for extended inactivity
- **Single port deployment** - All services (HTTP API, WebSocket SSH, WebSocket SFTP) run through one port
- **Multi-architecture** - Images available for `linux/amd64` and `linux/arm64`
- **Lightweight** - Images with integrated Caddy reverse proxy included
## Quick Start
### Single Container
```bash
docker run -d \
-p 8080:8080 \
-e WEBSSH_USERS=admin:admin \
-e MAX_UPLOAD_SIZE=100M \
--name webssh \
--restart unless-stopped \
danaildr/webssh:latest
```
Then open http://localhost:8080 in your browser.
**Default credentials:** `admin` / `admin`
### With Persistent Data & Auto-Lock
```bash
docker run -d \
-p 8080:8080 \
-v webssh_data:/data \
-e WEBSSH_USERS=user1:pass1,user2:pass2 \
-e JWT_SECRET=your-256-bit-secret-here \
-e MAX_UPLOAD_SIZE=500M \
-e RECORDING_ENABLED=true \
-e RECORDING_RETENTION_DAYS=30 \
--name webssh \
--restart unless-stopped \
danaildr/webssh:latest
```
**Note:** Auto-lock and auto-disconnect settings are configured per connection in the connection settings modal.
## Environment Variables
| Variable | Description | Default | Required |
|----------|-------------|---------|----------|
| `WEBSSH_USERS` | Comma-separated user:pass pairs | `admin:admin` | No |
| `JWT_SECRET` | Secret for token signing | Auto-generated | Recommended |
| `MAX_UPLOAD_SIZE` | Max file upload (e.g., `10M`, `1G`) | `100M` | No |
| `RECORDING_ENABLED` | Enable session recording | `true` | No |
| `RECORDING_RETENTION_DAYS` | Days to keep recordings | `30` | No |
| `TZ` | Timezone | `UTC` | No |
## Architecture
This container uses Caddy as a reverse proxy to route traffic:
- Port `8080` - Main entry point
- `/ws` → WebSocket SSH server
- `/api/*` → HTTP API (login, verify, SFTP operations)
- Everything else → Static files (UI)
Internal services (not exposed):
- WebSocket SSH: port 8081
- HTTP API: port 8082
- WebSocket SFTP: port 8083
## Reverse Proxy Setup
### Caddy
```caddyfile
your-domain.com {
reverse_proxy localhost:8080
}
```
## Docker Compose
```yaml
services:
webssh:
image: danaildr/webssh:latest
container_name: webssh
restart: unless-stopped
ports:
- "8080:8080"
environment:
- WEBSSH_USERS=user1:pass1,user2:pass2
- JWT_SECRET=your-secret-key-here
- MAX_UPLOAD_SIZE=100M
```
## Security Notes
⚠️ **Important for Production:**
- **Change default credentials** - Set strong passwords via `WEBSSH_USERS`
- **Set a strong `JWT_SECRET`** - Use at least 32 random characters
- **Use HTTPS** - Configure reverse proxy with SSL/TLS (container handles HTTP internally)
- **SSH host keys** - Uses `AutoAddPolicy()` by default; configure strict host key checking for production
- **Session recordings** - Stored in `/data/recordings/` with metadata in SQLite database
- **File uploads** - Limited by `MAX_UPLOAD_SIZE` (default 100MB)
- **Volumes** - Mount `/data` for persistent storage of credentials and recordings
## API
- `POST /api/login` - Authenticate and get JWT token
- `POST /api/verify` - Verify token validity
- `GET /api/connections` - List saved connections
- `POST /api/connections` - Save a new connection
- `GET /api/config` - Get server configuration
- WebSocket: `/ws?token=<JWT>` - SSH terminal connection
## Source
https://github.com/danaildr/web-ssh
## Tags
- `latest` - Most recent stable build
- `0.3.1` - Version-specific tag (Grouped recordings + Bulk operations + Multi-arch support)
- `0.1.9` - Previous version
Multi-arch: All tags support both `linux/amd64` and `linux/arm64`.
+4
View File
@@ -0,0 +1,4 @@
paramiko>=3.0.0
websockets>=12.0
PyJWT>=2.8.0
cryptography>=42.0.0
View File
Whitespace-only changes.
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
StrixWebSSH Backend - Modular SSH and SFTP server.
This file is a compatibility wrapper around the StrixWebSSH backend package.
The actual implementation has been split into modular components in webssh_backend/:
- config.py: Configuration and constants
- models.py: SSHConfig dataclass
- database.py: CredentialsManager for SQLite operations
- recording.py: SessionRecorder for asciinema format
- auth.py: JWT authentication
- http_api.py: HTTP handlers (LoginHandler)
- ssh_handler.py: WebSocket to SSH bridge
- sftp_handler.py: WebSocket SFTP operations
- utils.py: Helper functions
- main.py: Entry point
For new development, use: python src/run.py
"""
import sys
# Import from the new modular package
from webssh_backend.main import main
from webssh_backend.config import logger
if __name__ == "__main__":
try:
import asyncio
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
+388
View File
@@ -0,0 +1,388 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>StrixWebSSH - Help</title>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
background-color: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
text-align: center;
margin-bottom: 30px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
}
.header h1 {
margin: 0;
font-size: 2.5em;
font-weight: 300;
}
.header p {
margin: 10px 0 0 0 0;
font-size: 1.1em;
opacity: 0.9;
}
.section {
background: white;
margin-bottom: 25px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.section-header {
background: #f8f9fa;
padding: 15px 20px;
border-bottom: 1px solid #e9ecef;
border-left: 4px solid #007bff;
}
.section-header h2 {
margin: 0;
color: #007bff;
font-size: 1.3em;
font-weight: 600;
}
.section-content {
padding: 20px;
}
.feature-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 15px;
margin-top: 15px;
}
.feature {
padding: 15px;
border: 1px solid #e9ecef;
border-radius: 6px;
background: #f8f9fa;
}
.feature h3 {
margin: 0 0 10px 0;
color: #495057;
font-size: 1.1em;
}
.feature p {
margin: 0;
color: #6c757d;
font-size: 0.9em;
}
.step-list {
counter-reset: step-counter;
list-style: none;
padding: 0;
}
.step-list li {
counter-increment: step-counter;
margin-bottom: 15px;
position: relative;
padding-left: 50px;
}
.step-list li:before {
content: counter(step-counter);
position: absolute;
left: 0;
top: 0;
background: #007bff;
color: white;
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: bold;
font-size: 0.9em;
}
.keyboard-shortcuts {
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
}
.keyboard-shortcuts h4 {
margin: 0 0 10px 0;
color: #856404;
}
.shortcut-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 10px;
}
.shortcut {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: white;
border-radius: 4px;
border: 1px solid #ddd;
}
.shortcut kbd {
background: #f1f3f4;
border: 1px solid #d6d8dc;
border-radius: 3px;
padding: 2px 6px;
font-family: monospace;
font-size: 0.8em;
}
.troubleshooting {
background: #f8d7da;
border: 1px solid #f5c6cb;
border-radius: 6px;
padding: 15px;
margin-top: 15px;
}
.troubleshooting h4 {
margin: 0 0 10px 0;
color: #721c24;
}
.troubleshooting ul {
margin: 10px 0 0 0 20px;
color: #721c24;
}
.footer {
text-align: center;
margin-top: 40px;
padding: 20px;
color: #6c757d;
border-top: 1px solid #e9ecef;
}
.icon {
display: inline-block;
width: 20px;
height: 20px;
margin-right: 8px;
vertical-align: middle;
}
</style>
</head>
<body>
<div class="header">
<h1>🔐 StrixWebSSH</h1>
<p>Secure SSH client that runs entirely in your browser</p>
</div>
<div class="section">
<div class="section-header">
<h2>🌟 Key Features</h2>
</div>
<div class="section-content">
<div class="feature-grid">
<div class="feature">
<h3>🔐 Secure Authentication</h3>
<p>JWT-based login with configurable session timeout and encrypted storage of passwords and SSH keys.</p>
</div>
<div class="feature">
<h3>🌐 Multiple Connections</h3>
<p>Connect to multiple SSH servers simultaneously with easy switching between them.</p>
</div>
<div class="feature">
<h3>📁 SFTP File Manager</h3>
<p>Full-featured file manager with drag & drop upload, download, and file operations.</p>
</div>
<div class="feature">
<h3>💾 Persistent Data</h3>
<p>Encrypted storage of credentials across container restarts.</p>
</div>
<div class="feature">
<h3>📊 Upload Limits</h3>
<p>Configurable maximum file size (supports formats like "30M", "100M", "1G").</p>
</div>
<div class="feature">
<h3>🎥 Session Recording</h3>
<p>Automatic recording of SSH sessions in asciinema format with playback and configurable retention period.</p>
</div>
<div class="feature">
<h3>🎨 Modern Interface</h3>
<p>Responsive design with dark/light theme and intuitive controls.</p>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🚀 Quick Start</h2>
</div>
<div class="section-content">
<ol class="step-list">
<li><strong>Open</strong> your browser and go to <code>http://localhost:8080</code></li>
<li><strong>Login</strong> with username and password (default: admin/admin)</li>
<li><strong>Create a connection</strong> by clicking "+ New Connection" and entering SSH server details</li>
<li><strong>Connect</strong> by clicking on the created connection</li>
<li><strong>Open terminal</strong> and start working with the remote server</li>
<li><strong>Use SFTP</strong> for file operations by clicking the "Files" tab</li>
</ol>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🎥 Session Recordings</h2>
</div>
<div class="section-content">
<p>All SSH sessions are automatically recorded in asciinema format. To view recordings:</p>
<ol class="step-list">
<li><strong>Click "Recordings"</strong> button in the left sidebar</li>
<li><strong>Browse the list</strong> of recorded sessions (shows connection name, date, and duration)</li>
<li><strong>Click ▶ Play</strong> to replay the recording in the embedded player</li>
<li><strong>Download</strong> the recording as .cast.gz file for local storage</li>
<li><strong>Delete</strong> old recordings to free up space</li>
</ol>
<p class="text-muted">Recordings are kept for a configurable period (default 30 days) and automatically deleted after that.</p>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>⌨️ Keyboard Shortcuts</h2>
</div>
<div class="section-content">
<div class="keyboard-shortcuts">
<h4>🖥️ Terminal:</h4>
<div class="shortcut-list">
<div class="shortcut">
<span>Copy</span>
<kbd>Ctrl+C</kbd>
</div>
<div class="shortcut">
<span>Paste</span>
<kbd>Ctrl+V</kbd>
</div>
<div class="shortcut">
<span>New Terminal</span>
<kbd>Ctrl+T</kbd>
</div>
<div class="shortcut">
<span>Close Tab</span>
<kbd>Ctrl+W</kbd>
</div>
</div>
<h4>📁 SFTP File Manager:</h4>
<div class="shortcut-list">
<div class="shortcut">
<span>Refresh</span>
<kbd>F5</kbd>
</div>
<div class="shortcut">
<span>Delete</span>
<kbd>Delete</kbd>
</div>
<div class="shortcut">
<span>Rename</span>
<kbd>F2</kbd>
</div>
<div class="shortcut">
<span>Upload</span>
<kbd>Ctrl+U</kbd>
</div>
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🔧 Configuration</h2>
</div>
<div class="section-content">
<p><strong>MAX_UPLOAD_SIZE:</strong> Maximum file size (supports formats):</p>
<ul>
<li><code>"50M"</code> - 50 megabytes</li>
<li><code>"100M"</code> - 100 megabytes</li>
<li><code>"1G"</code> - 1 gigabyte</li>
<li><code>"500K"</code> - 500 kilobytes</li>
</ul>
<p><strong>STRIXWEBSSH_USERS:</strong> Users in format <code>username:password,username2:password2</code></p>
<p><strong>JWT_SECRET:</strong> Secret key for JWT tokens (auto-generated if not set)</p>
<p><strong>RECORDING_ENABLED:</strong> Enable/disable session recording (<code>true</code> or <code>false</code>)</p>
<p><strong>RECORDING_RETENTION_DAYS:</strong> Number of days to keep recordings (default: 30)</p>
<h3>🔒 Auto-Lock & Auto-Disconnect</h3>
<p>StrixWebSSH can automatically lock sessions after a period of inactivity and require your StrixWebSSH password to unlock. Sessions can also be automatically disconnected after a longer period.</p>
<ul>
<li><strong>Lock after:</strong> Period of inactivity before locking (5, 15, 30, 60 minutes)</li>
<li><strong>Disconnect after:</strong> Period of inactivity before disconnecting (30, 60 minutes, or never)</li>
<li><strong>Configuration:</strong> Set per connection in the connection settings</li>
<li><strong>Security:</strong> Lock requires StrixWebSSH password only (not SSH password)</li>
</ul>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🛠️ Troubleshooting</h2>
</div>
<div class="section-content">
<div class="troubleshooting">
<h4>⚠️ Common Issues:</h4>
<ul>
<li><strong>Connection refused:</strong> Check if SSH server is running and port is open</li>
<li><strong>Authentication failed:</strong> Check username and password</li>
<li><strong>File too large:</strong> Increase MAX_UPLOAD_SIZE in docker-compose.yml</li>
<li><strong>401 Unauthorized:</strong> Refresh the page and login again</li>
<li><strong>Slow connection:</strong> Check internet connection and network settings</li>
</ul>
</div>
</div>
</div>
<div class="section">
<div class="section-header">
<h2>🔒 Security</h2>
</div>
<div class="section-content">
<p><strong>Encryption:</strong> All passwords and SSH keys are encrypted with AES-128 (Fernet).</p>
<p><strong>JWT Tokens:</strong> Sessions are managed with JWT tokens with configurable timeout.</p>
<p><strong>Docker Isolation:</strong> The application runs in an isolated Docker environment.</p>
<p><strong>HTTPS:</strong> Recommended to use HTTPS in production environment.</p>
</div>
</div>
<div class="footer">
<p>📧 <strong>Support:</strong> For more information and help, check the project documentation.</p>
<p>🔄 <strong>Version:</strong> StrixWebSSH Multi-Connection Terminal v0.3.1</p>
<p>⚡ <strong>Technologies:</strong> Python, Paramiko, WebSockets, HTML5, CSS3, JavaScript</p>
</div>
</body>
</html>
+341
View File
@@ -0,0 +1,341 @@
<!DOCTYPE html>
<html lang="bg">
<head>
<meta charset="UTF-8">
<title>StrixWebSSH - Multi-Connection Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm/css/xterm.css" />
<script src="https://cdn.jsdelivr.net/npm/xterm/lib/xterm.js"></script>
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit/lib/xterm-addon-fit.js"></script>
<!-- Asciinema Player -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/asciinema-player@3/dist/bundle/asciinema-player.css" />
<script src="https://cdn.jsdelivr.net/npm/asciinema-player@3/dist/bundle/asciinema-player.min.js"></script>
<!-- Pako for gzip decompression -->
<script src="https://cdn.jsdelivr.net/npm/pako@2/dist/pako.min.js"></script>
<link rel="stylesheet" href="static/webssh.css" />
</head>
<body>
<!-- Login Modal -->
<div class="modal-overlay active" id="loginModal">
<div class="modal">
<h3>StrixWebSSH Login</h3>
<div class="form-group">
<label>Username</label>
<input id="loginUsername" placeholder="admin">
</div>
<div class="form-group">
<label>Password</label>
<input type="password" id="loginPassword" placeholder="Password">
</div>
<div id="loginError" style="color: #ef4444; font-size: 13px; margin-bottom: 10px;"></div>
<div class="modal-actions">
<button onclick="login()">Login</button>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="sidebar">
<div class="sidebar-header">
<h2>Connections</h2>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<span id="loggedInUser" style="font-size: 12px; color: #10b981;"></span>
<button class="btn-small" onclick="logout()" style="background: #374151; color: #9ca3af;">Logout</button>
</div>
<button class="btn-add" onclick="showNewConnectionModal()">+ New Connection</button>
<button class="btn-recordings" onclick="showRecordings()" title="Session Recordings">🎥 Recordings</button>
</div>
<div class="connection-list" id="connectionList">
</div>
</div>
<!-- Main Content -->
<div class="main-content">
<!-- Terminal View -->
<div id="terminalView" class="terminal-container">
<div class="terminal-header">
<span class="terminal-title" id="terminalTitle">Terminal</span>
<span class="terminal-status disconnected" id="terminalStatus">Disconnected</span>
</div>
<div id="terminal"></div>
</div>
<!-- SFTP View (File Manager) - Hidden by default -->
<div id="sftpView" class="sftp-container-main" style="display: none;">
<!-- Quick Connect Bar -->
<div class="sftp-quick-connect">
<div class="sftp-connect-field">
<label>Local:</label>
<span id="sftpLocalPathDisplay">Local Files</span>
</div>
<div class="sftp-connect-buttons">
<button class="btn-small" onclick="sftpRefresh()">🔄 Refresh</button>
<button class="btn-small" onclick="sftpMkdir()">📂 New Folder</button>
<button class="btn-small" onclick="sftpRename()">✏️ Rename</button>
<button class="btn-small btn-danger" onclick="sftpDelete()">🗑 Delete</button>
</div>
</div>
<!-- Dual Pane Container -->
<div class="sftp-dual-pane">
<!-- Local Panel (Left) -->
<div class="sftp-panel local-panel">
<div class="sftp-panel-header">
<div class="sftp-panel-title">💻 Local Site</div>
<div class="sftp-panel-path" id="sftpLocalPath">
📁 <input type="text" id="sftpLocalPathInput" value="Click here to select folder" readonly onclick="sftpChooseLocalFolder()">
</div>
</div>
<div class="sftp-file-list local-file-list" id="sftpLocalFiles"
ondrop="sftpHandleLocalFolderDrop(event)"
ondragover="sftpHandleLocalFolderDragOver(event)">
<div class="sftp-empty">
<div style="text-align: center;">
<div style="font-size: 32px; margin-bottom: 10px;">📂</div>
<div>Click to select a local folder</div>
<div style="font-size: 12px; color: #6b7280; margin-top: 8px;">or drop folder here to browse</div>
</div>
</div>
</div>
</div>
<!-- Transfer Arrows (Middle) -->
<div class="sftp-transfer-arrows">
<button class="sftp-arrow-btn" onclick="sftpUploadSelected()" title="Upload to remote">➡️</button>
<button class="sftp-arrow-btn" onclick="sftpDownloadSelected()" title="Download to local">⬅️</button>
</div>
<!-- Remote Panel (Right) -->
<div class="sftp-panel remote-panel">
<div class="sftp-panel-header">
<div class="sftp-panel-title">🖥️ Remote Site</div>
<div class="sftp-panel-path" id="sftpRemotePath">
<button class="sftp-nav-btn" onclick="sftpNavigateUp()">⬆️</button>
📁 <input type="text" id="sftpRemotePathInput" value="/" onkeydown="sftpHandlePathKey(event)">
</div>
</div>
<div class="sftp-file-list remote-file-list" id="sftpRemoteFiles" ondrop="sftpHandleDrop(event)" ondragover="sftpHandleDragOver(event)">
<div class="sftp-empty">Select a connection to browse files</div>
</div>
</div>
</div>
<!-- Transfer Queue (Bottom) -->
<div class="sftp-queue-section">
<div class="sftp-queue-header">
<span>📋 Transfer Queue</span>
<button class="btn-small" onclick="sftpClearCompleted()">Clear Completed</button>
</div>
<div class="sftp-queue-list" id="sftpQueueList">
<div class="sftp-queue-empty">No transfers in queue</div>
</div>
</div>
</div>
</div>
<!-- New Connection Modal -->
<div class="modal-overlay" id="newConnectionModal">
<div class="modal">
<h3>New Connection</h3>
<div class="form-group">
<label>Name</label>
<input id="modalName" placeholder="My Server">
</div>
<div class="form-group">
<label>Host</label>
<input id="modalHost" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input id="modalPort" value="22">
</div>
<div class="form-group">
<label>Username</label>
<input id="modalUser" placeholder="root">
</div>
<div class="form-group">
<label>Authentication</label>
<select id="modalAuthType" onchange="toggleModalAuth()">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<div class="form-group" id="modalPasswordGroup">
<label>Password</label>
<input type="password" id="modalPassword">
</div>
<div class="form-group hidden" id="modalKeyGroup">
<label>SSH Private Key</label>
<textarea id="modalKey" rows="4"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label>Lock after (minutes of inactivity)</label>
<select id="modalLockTimeout">
<option value="5">5 minutes</option>
<option value="15" selected>15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">60 minutes</option>
</select>
</div>
<div class="form-group" style="flex: 1;">
<label>Disconnect after (minutes of inactivity)</label>
<select id="modalDisconnectTimeout">
<option value="30">30 minutes</option>
<option value="60" selected>60 minutes</option>
<option value="0">Never</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="hideModal()">Cancel</button>
<button onclick="createConnection()">Create</button>
</div>
</div>
</div>
<!-- Edit Connection Modal -->
<div class="modal-overlay" id="editConnectionModal">
<div class="modal">
<h3>Edit Connection</h3>
<div class="form-group">
<label>Name</label>
<input id="editModalName" placeholder="My Server">
</div>
<div class="form-group">
<label>Host</label>
<input id="editModalHost" placeholder="192.168.1.100">
</div>
<div class="form-group">
<label>Port</label>
<input id="editModalPort" value="22">
</div>
<div class="form-group">
<label>Username</label>
<input id="editModalUser" placeholder="root">
</div>
<div class="form-group">
<label>Authentication</label>
<select id="editModalAuthType" onchange="toggleEditModalAuth()">
<option value="password">Password</option>
<option value="key">SSH Key</option>
</select>
</div>
<div class="form-group" id="editModalPasswordGroup">
<label>Password</label>
<input type="password" id="editModalPassword">
</div>
<div class="form-group hidden" id="editModalKeyGroup">
<label>SSH Private Key</label>
<textarea id="editModalKey" rows="4"></textarea>
</div>
<div class="form-row">
<div class="form-group" style="flex: 1;">
<label>Lock after (minutes of inactivity)</label>
<select id="editModalLockTimeout">
<option value="5">5 minutes</option>
<option value="15" selected>15 minutes</option>
<option value="30">30 minutes</option>
<option value="60">60 minutes</option>
</select>
</div>
<div class="form-group" style="flex: 1;">
<label>Disconnect after (minutes of inactivity)</label>
<select id="editModalDisconnectTimeout">
<option value="30">30 minutes</option>
<option value="60" selected>60 minutes</option>
<option value="0">Never</option>
</select>
</div>
</div>
<div class="modal-actions">
<button class="btn-secondary" onclick="hideEditConnectionModal()">Cancel</button>
<button onclick="saveEditedConnection()">Save</button>
</div>
</div>
</div>
<!-- Lock Screen Modal -->
<div class="modal-overlay" id="lockScreenModal" style="z-index: 10000; display: none;">
<div class="modal" style="text-align: center; max-width: 400px;">
<div class="lock-icon" style="font-size: 48px; margin-bottom: 20px;">🔒</div>
<h3>Session Locked</h3>
<p class="text-muted">Your session has been locked due to inactivity.</p>
<div class="form-group" style="margin-top: 20px;">
<label>Enter your StrixWebSSH password to unlock</label>
<input type="password" id="unlockPassword" placeholder="StrixWebSSH password" onkeydown="if(event.key==='Enter')unlockSession()">
</div>
<div id="unlockError" style="color: #ef4444; margin-top: 10px; display: none;">Incorrect password</div>
<div class="modal-actions" style="margin-top: 20px;">
<button onclick="logout()" class="btn-secondary">Logout</button>
<button onclick="unlockSession()">Unlock</button>
</div>
</div>
</div>
<!-- Help Modal -->
<!-- Recordings Modal -->
<div class="modal-overlay" id="recordingsModal">
<div class="modal" style="max-width: 900px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>🎥 Session Recordings</h3>
<button class="modal-close" onclick="hideRecordings()">&times;</button>
</div>
<div class="modal-body">
<div id="recordingsList" class="recordings-list">
<p class="text-muted">Loading recordings...</p>
</div>
</div>
</div>
</div>
<!-- Recording Player Modal -->
<div class="modal-overlay" id="playerModal">
<div class="modal" style="max-width: 95%; max-height: 95%; overflow-y: auto;">
<div class="modal-header">
<h3>▶️ Recording Player</h3>
<button class="modal-close" onclick="hidePlayer()">&times;</button>
</div>
<div class="modal-body">
<div id="recordingInfo" class="recording-info"></div>
<div id="asciinemaPlayer" style="margin-top: 15px;"></div>
</div>
</div>
</div>
<!-- Lock Screen Modal -->
<div class="modal-overlay" id="lockScreenModal" style="z-index: 10000; display: none;">
<div class="modal" style="text-align: center; max-width: 400px;">
<div class="lock-icon" style="font-size: 48px; margin-bottom: 20px;">🔒</div>
<h3>Session Locked</h3>
<p class="text-muted">Your session has been locked due to inactivity.</p>
<div class="form-group" style="margin-top: 20px;">
<label>Enter your StrixWebSSH password to unlock</label>
<input type="password" id="unlockPassword" placeholder="StrixWebSSH password" onkeydown="if(event.key==='Enter')unlockSession()">
</div>
<div id="unlockError" style="color: #ef4444; margin-top: 10px; display: none;">Incorrect password</div>
<div class="modal-actions" style="margin-top: 20px;">
<button onclick="logout()" class="btn-secondary">Logout</button>
<button onclick="unlockSession()">Unlock</button>
</div>
</div>
</div>
<div class="modal-overlay" id="helpModal">
<div class="modal" style="max-width: 90%; max-height: 90%; overflow-y: auto;">
<div class="modal-header">
<h3>📚 StrixWebSSH Help</h3>
<button class="modal-close" onclick="hideHelp()">&times;</button>
</div>
<div class="modal-body">
<iframe src="help.html" style="width: 100%; height: 70vh; border: none; border-radius: 8px;"></iframe>
</div>
</div>
</div>
<script src="static/webssh.js"></script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env python3
"""
Entry point for WebSSH Backend.
This script starts the WebSSH server with all its components:
- HTTP API server (login, connections, SFTP REST endpoints)
- WebSocket SSH server (terminal access)
- WebSocket SFTP server (file operations)
- Periodic cleanup task for session recordings
Usage:
python run.py
Environment variables:
WEBSSH_USERS - Comma-separated user:pass pairs (default: admin:admin)
JWT_SECRET - Secret key for JWT tokens (auto-generated if not set)
MAX_UPLOAD_SIZE - Maximum file upload size (default: 100M)
RECORDING_ENABLED - Enable session recording (default: true)
RECORDING_RETENTION_DAYS - Days to keep recordings (default: 30)
STORAGE_DIR - Directory for data storage (default: /data)
"""
import sys
import os
# Add current directory to path for imports
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from webssh_backend.main import main
from webssh_backend.config import logger
if __name__ == "__main__":
try:
import asyncio
asyncio.run(main())
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Fatal error: {e}")
sys.exit(1)
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Simple HTTP server for static files + proxy to backend.
This serves the static files (index.html, help.html) and proxies API/WebSocket
requests to the backend servers.
Usage:
# Terminal 1: Start backend
python3 run.py
# Terminal 2: Start static server
python3 serve_static.py
# Then open http://localhost:8080/
"""
import http.server
import socketserver
import urllib.request
import urllib.error
import sys
import os
import json
import threading
import socket
PORT = int(os.environ.get("FRONTEND_PORT", "8080"))
BACKEND_API = "http://localhost:8082"
WS_HOST = "localhost"
WS_PORT = 8081
SFTP_WS_PORT = 8083
def load_backend_ports():
"""Read the backend-selected ports from the state file, if present."""
candidates = []
env_file = os.environ.get("STRIXWEBSSH_PORTS_FILE") or os.environ.get("WEBSSH_PORTS_FILE")
if env_file:
candidates.append(env_file)
candidates.extend([
os.path.join(os.path.dirname(os.path.abspath(__file__)), "data", ".ports.json"),
os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", ".ports.json"),
"/data/.ports.json",
])
for state_file in candidates:
if not state_file or not os.path.exists(state_file):
continue
try:
with open(state_file, "r") as handle:
data = json.load(handle)
return data
except Exception:
continue
return None
PORTS = load_backend_ports() or {}
if PORTS.get("http_port"):
BACKEND_API = f"http://localhost:{PORTS['http_port']}"
if PORTS.get("ws_port"):
WS_PORT = PORTS["ws_port"]
if PORTS.get("sftp_ws_port"):
SFTP_WS_PORT = PORTS["sftp_ws_port"]
def find_available_port(start_port: int) -> int:
"""Return the first available TCP port starting from start_port."""
for port in range(start_port, start_port + 100):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("", port))
return port
except OSError:
continue
raise OSError(f"No available port found in range {start_port}-{start_port + 99}")
class ProxyHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
# Serve files from current directory
super().__init__(*args, directory=os.path.dirname(os.path.abspath(__file__)), **kwargs)
def do_GET(self):
if self.path.startswith('/api/'):
self._proxy_request('GET')
return
if self.path.startswith('/ws'):
self._proxy_websocket()
return
super().do_GET()
def do_POST(self):
if self.path.startswith('/api/'):
self._proxy_request('POST')
return
self.send_error(404)
def do_OPTIONS(self):
if self.path.startswith('/api/'):
self._proxy_request('OPTIONS')
return
self.send_response(200)
self.end_headers()
def _proxy_websocket(self):
try:
host = WS_HOST
port = WS_PORT
self.send_response(101)
self.send_header('Upgrade', 'websocket')
self.send_header('Connection', 'Upgrade')
self.send_header('Sec-WebSocket-Accept', 'dummy')
self.end_headers()
import websocket
ws_client = websocket.create_connection(f"ws://{host}:{port}{self.path}", timeout=5)
while True:
msg = ws_client.recv()
if msg is None:
break
self.wfile.write(msg)
except Exception as exc:
print(f'WebSocket proxy error: {exc}')
def _proxy_request(self, method):
try:
url = BACKEND_API + self.path
# Read request body if any
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length) if content_length > 0 else None
# Create request
req = urllib.request.Request(url, method=method, data=body)
# Copy headers
for header, value in self.headers.items():
if header.lower() not in ('host', 'content-length'):
req.add_header(header, value)
# Send request to backend
with urllib.request.urlopen(req) as response:
body = response.read()
self.send_response(response.status)
for header, value in response.headers.items():
if header.lower() not in ('transfer-encoding', 'content-length'):
self.send_header(header, value)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except urllib.error.HTTPError as e:
body = e.read()
self.send_response(e.code)
for header, value in e.headers.items():
if header.lower() not in ('transfer-encoding', 'content-length'):
self.send_header(header, value)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
except Exception as e:
body = f'Proxy error: {e}'.encode()
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
print(f"[{self.log_date_time_string()}] {format % args}")
if __name__ == "__main__":
try:
actual_port = find_available_port(PORT)
except OSError as exc:
print(f"Failed to find an available port: {exc}")
sys.exit(1)
if actual_port != PORT:
print(f"Port {PORT} is busy; using port {actual_port} instead")
print(f"Starting static file server on http://localhost:{actual_port}/")
print(f"API requests will be proxied to {BACKEND_API}")
print(f"\nOpen http://localhost:{actual_port}/ in your browser")
print("Note: Make sure the backend is running (python3 start_backend.py)")
print("\nPress Ctrl+C to stop")
with socketserver.TCPServer(("", actual_port), ProxyHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("\nServer stopped")
sys.exit(0)
File diff suppressed because it is too large. Load diff
+2510
View File
File diff suppressed because it is too large. Load diff
+28
View File
@@ -0,0 +1,28 @@
"""
StrixWebSSH Backend Package - Modular SSH and SFTP server.
This package provides a modular SSH and SFTP server with the following components:
- config: Configuration and constants (HOST, PORT, JWT settings, etc.)
- models: SSHConfig dataclass for SSH connection configuration
- database: CredentialsManager for SQLite operations
- recording: SessionRecorder for asciinema format session recording
- auth: JWT authentication functions
- http_api: HTTP handlers (LoginHandler)
- ssh_handler: WebSocket to SSH bridge (handle_client)
- sftp_handler: WebSocket SFTP operations (handle_sftp_client)
- utils: Helper functions (parse_size, is_rate_limited, get_client_ip)
- main: Entry point (main function)
Usage:
from webssh_backend.main import main
asyncio.run(main())
"""
__version__ = "0.3.1"
# Note: Specific imports are intentionally NOT done at package level
# to avoid loading all dependencies when only specific modules are needed.
# Import directly from the relevant module, e.g.:
# from webssh_backend.config import logger
# from webssh_backend.models import SSHConfig
# from webssh_backend.main import main
+69
View File
@@ -0,0 +1,69 @@
"""
Auth module - JWT authentication and user management.
"""
from datetime import datetime, timedelta, timezone
from typing import Optional
from urllib.parse import parse_qs, urlparse
import jwt
from websockets.server import WebSocketServerProtocol
from .config import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, logger
__all__ = ['verify_token', 'verify_token_ws', 'verify_token_http', 'generate_token', 'JWT_ALGORITHM', 'JWT_EXPIRE_HOURS', 'JWT_SECRET', 'USERS']
from .utils import get_client_ip
async def verify_token_ws(ws: WebSocketServerProtocol) -> Optional[str]:
"""Verify JWT token from WebSocket query parameters. Returns username or None."""
try:
# Get token from query string
path = ws.request.path if hasattr(ws.request, 'path') else str(ws.request)
parsed = urlparse(path)
params = parse_qs(parsed.query)
token = params.get('token', [''])[0]
if not token:
logger.warning(f"No token provided from {get_client_ip(ws)}")
return None
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload['sub']
except jwt.ExpiredSignatureError:
logger.warning(f"Expired token from {get_client_ip(ws)}")
return None
except jwt.InvalidTokenError as e:
logger.warning(f"Invalid token from {get_client_ip(ws)}: {e}")
return None
except Exception as e:
logger.error(f"Token verification error: {e}")
return None
def generate_token(username: str) -> str:
"""Generate a new JWT token for the user."""
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
token = jwt.encode(
{'sub': username, 'exp': expire, 'iat': datetime.now(timezone.utc)},
JWT_SECRET,
algorithm=JWT_ALGORITHM
)
# Handle both PyJWT v1 (bytes) and v2 (str)
if isinstance(token, bytes):
token = token.decode('utf-8')
return token
def verify_token_http(token: str) -> Optional[str]:
"""Verify JWT token from HTTP request. Returns username or None."""
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
return payload.get('sub')
except jwt.ExpiredSignatureError:
return None
except jwt.InvalidTokenError:
return None
# For backward compatibility, keep the original function name
verify_token = verify_token_ws
+206
View File
@@ -0,0 +1,206 @@
"""
Configuration module - all constants and environment variables.
"""
import hashlib
import json
import logging
import os
import secrets
import socket
from cryptography.fernet import Fernet
# Configure logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
# Network Configuration
HOST = "0.0.0.0"
def _is_port_available(port):
"""Return True when the given TCP port can be bound."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
try:
sock.bind(("", port))
return True
except OSError:
return False
def _get_port_from_env(env_names, default_port):
for env_name in env_names:
value = os.environ.get(env_name)
if value:
try:
return int(value)
except ValueError:
logger.warning(f"Invalid port value for {env_name}: {value}")
return default_port
def _find_available_port(start_port, reserved_ports=None):
reserved = set(reserved_ports or [])
for port in range(start_port, start_port + 100):
if port in reserved:
continue
if _is_port_available(port):
return port
raise OSError(f"No available port found in range {start_port}-{start_port + 99}")
def _resolve_port(env_names, default_port, reserved_ports=None):
requested_port = _get_port_from_env(env_names, default_port)
if requested_port not in (reserved_ports or []) and _is_port_available(requested_port):
return requested_port
return _find_available_port(requested_port, reserved_ports)
# SSH Configuration
SSH_TIMEOUT = 30 # seconds
MAX_MESSAGE_SIZE = 10 * 1024 * 1024 # 10MB
# Rate Limiting
RATE_LIMIT_WINDOW = 60 # seconds
RATE_LIMIT_MAX = 10 # max connections per IP per window
# SFTP Configuration
SFTP_CHUNK_SIZE = 64 * 1024 # 64KB chunks for transfers
def parse_size(size_str):
"""Parse size string like '50M' or '100MB' to bytes."""
size_str = size_str.upper().strip()
logger.info(f"parse_size called with: '{size_str}'")
if size_str.endswith('M') or size_str.endswith('MB'):
# Remove 'M' or 'MB' and convert to bytes
result = int(float(size_str[:-1]) * 1024 * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
elif size_str.endswith('K') or size_str.endswith('KB'):
# Remove 'K' or 'KB' and convert to bytes
result = int(float(size_str[:-1]) * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
elif size_str.endswith('G') or size_str.endswith('GB'):
# Remove 'G' or 'GB' and convert to bytes
result = int(float(size_str[:-1]) * 1024 * 1024 * 1024)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
else:
# Assume bytes if no suffix
result = int(size_str)
logger.info(f"parse_size result: {result} bytes (from {size_str})")
return result
MAX_UPLOAD_SIZE = parse_size(os.environ.get("MAX_UPLOAD_SIZE", "100M")) # 100MB default
logger.info(f"MAX_UPLOAD_SIZE loaded: {MAX_UPLOAD_SIZE} bytes ({MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB)")
# Storage Configuration
STORAGE_DIR = os.environ.get("STORAGE_DIR", "/data")
STORAGE_FILE = os.path.join(STORAGE_DIR, "credentials.db")
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
# Session Recording Configuration
RECORDING_ENABLED = os.environ.get("RECORDING_ENABLED", "true").lower() in ("true", "1", "yes")
RECORDING_RETENTION_DAYS = int(os.environ.get("RECORDING_RETENTION_DAYS", "30"))
RECORDINGS_PATH = os.environ.get("RECORDINGS_PATH", os.path.join(STORAGE_DIR, "recordings"))
logger.info(f"Session recording: enabled={RECORDING_ENABLED}, retention={RECORDING_RETENTION_DAYS}days, path={RECORDINGS_PATH}")
# JWT Configuration
JWT_SECRET = os.environ.get("JWT_SECRET", secrets.token_hex(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRE_HOURS = 8
# Encryption Configuration for Credentials Storage
ENCRYPTION_KEY_FILE = os.path.join(STORAGE_DIR, ".encryption_key")
ENCRYPTION_KEY = os.environ.get("ENCRYPTION_KEY")
if not ENCRYPTION_KEY:
# Try to load existing key from file
if os.path.exists(ENCRYPTION_KEY_FILE):
try:
with open(ENCRYPTION_KEY_FILE, 'r') as f:
ENCRYPTION_KEY = f.read().strip()
logger.info("Loaded encryption key from file")
except Exception as e:
logger.warning(f"Failed to load encryption key from file: {e}")
if not ENCRYPTION_KEY:
# Generate a new random key
ENCRYPTION_KEY = Fernet.generate_key().decode()
try:
with open(ENCRYPTION_KEY_FILE, 'w') as f:
f.write(ENCRYPTION_KEY)
logger.info("Generated and saved new encryption key")
except Exception as e:
logger.error(f"Failed to save encryption key: {e}")
# Ensure storage directory exists (with fallback to local dir if no permission)
def ensure_storage_dir():
"""Try to create storage directory, fallback to local if no permission."""
global STORAGE_DIR, STORAGE_FILE, ENCRYPTION_KEY_FILE, RECORDINGS_PATH, PORT_STATE_FILE
try:
os.makedirs(STORAGE_DIR, exist_ok=True)
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
return
except (PermissionError, OSError) as e:
logger.warning(f"Cannot create storage dir {STORAGE_DIR}: {e}")
# Fallback to local directory
fallback_dir = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'data')
try:
os.makedirs(fallback_dir, exist_ok=True)
STORAGE_DIR = fallback_dir
STORAGE_FILE = os.path.join(STORAGE_DIR, "credentials.db")
ENCRYPTION_KEY_FILE = os.path.join(STORAGE_DIR, ".encryption_key")
RECORDINGS_PATH = os.path.join(STORAGE_DIR, "recordings")
PORT_STATE_FILE = os.path.join(STORAGE_DIR, ".ports.json")
logger.info(f"Using fallback storage directory: {STORAGE_DIR}")
except (PermissionError, OSError) as e:
logger.error(f"Cannot create fallback dir {fallback_dir}: {e}")
raise
ensure_storage_dir()
# Resolve backend ports, falling back to the next free port when defaults are busy.
WS_PORT = _resolve_port(("STRIXWEBSSH_WS_PORT", "WEBSSH_WS_PORT"), 8081)
HTTP_PORT = _resolve_port(("STRIXWEBSSH_HTTP_PORT", "WEBSSH_HTTP_PORT"), 8082, reserved_ports={WS_PORT})
SFTP_WS_PORT = _resolve_port(("STRIXWEBSSH_SFTP_PORT", "WEBSSH_SFTP_PORT"), 8083, reserved_ports={WS_PORT, HTTP_PORT})
try:
with open(PORT_STATE_FILE, "w") as handle:
json.dump({
"ws_port": WS_PORT,
"http_port": HTTP_PORT,
"sftp_ws_port": SFTP_WS_PORT,
}, handle)
except Exception as exc:
logger.warning(f"Could not save selected ports to {PORT_STATE_FILE}: {exc}")
logger.info(f"Resolved backend ports: ws={WS_PORT}, http={HTTP_PORT}, sftp={SFTP_WS_PORT}")
# User Configuration (username: password_hash)
# Set via environment variable: STRIXWEBSSH_USERS=user1:pass1,user2:pass2
USERS = {}
users_env = os.environ.get("STRIXWEBSSH_USERS", os.environ.get("WEBSSH_USERS", "admin:admin"))
for user_cred in users_env.split(","):
if ":" in user_cred:
u, p = user_cred.split(":", 1)
USERS[u.strip()] = hashlib.sha256(p.strip().encode()).hexdigest()
logger.info(f"Loaded {len(USERS)} users: {list(USERS.keys())}")
# Log storage directory status
logger.info(f"Using storage directory: {STORAGE_DIR}")
try:
import stat as stat_module
stat_info = os.stat(STORAGE_DIR)
logger.info(f"Directory permissions: uid={stat_info.st_uid}, gid={stat_info.st_gid}, mode={oct(stat_info.st_mode)}")
except Exception as e:
logger.warning(f"Could not stat storage directory: {e}")
+458
View File
@@ -0,0 +1,458 @@
"""
Database module - CredentialsManager for SQLite operations.
"""
import os
import sqlite3
from typing import Any, Dict, List, Optional
from cryptography.fernet import Fernet
from .config import ENCRYPTION_KEY, STORAGE_FILE, logger
class CredentialsManager:
"""Manages encrypted storage of SSH credentials per user."""
def __init__(self, db_path: str, encryption_key: str):
self.db_path = db_path
self.cipher = Fernet(encryption_key.encode() if isinstance(encryption_key, str) else encryption_key)
# Ensure directory exists before initializing DB
db_dir = os.path.dirname(db_path)
if db_dir:
os.makedirs(db_dir, exist_ok=True)
self._init_db()
def _init_db(self):
"""Initialize SQLite database."""
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
CREATE TABLE IF NOT EXISTS connections (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER NOT NULL,
ssh_username TEXT NOT NULL,
auth_type TEXT NOT NULL,
password_encrypted BLOB,
key_encrypted BLOB,
ws_url TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
conn.commit()
# Migration: Create recordings table if not exists
cursor.execute('''
CREATE TABLE IF NOT EXISTS recordings (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
connection_id TEXT NOT NULL,
connection_name TEXT,
session_id TEXT NOT NULL,
host TEXT NOT NULL,
ssh_username TEXT NOT NULL,
started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
ended_at TIMESTAMP,
duration_seconds INTEGER,
file_size_bytes INTEGER,
file_path TEXT NOT NULL,
width INTEGER DEFAULT 80,
height INTEGER DEFAULT 24,
FOREIGN KEY (connection_id) REFERENCES connections(id)
)
''')
# Migration: Add connection_name column if it doesn't exist (for existing databases)
try:
cursor.execute('ALTER TABLE recordings ADD COLUMN connection_name TEXT')
logger.info("Added connection_name column to recordings table")
except sqlite3.OperationalError:
# Column already exists
pass
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_username ON recordings(username)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_connection ON recordings(connection_id)')
cursor.execute('CREATE INDEX IF NOT EXISTS idx_recordings_started_at ON recordings(started_at)')
# Migration: Add lock_timeout and disconnect_timeout columns if they don't exist
try:
cursor.execute('ALTER TABLE connections ADD COLUMN lock_timeout INTEGER DEFAULT 15')
logger.info("Added lock_timeout column to connections table")
except sqlite3.OperationalError:
pass
try:
cursor.execute('ALTER TABLE connections ADD COLUMN disconnect_timeout INTEGER DEFAULT 60')
logger.info("Added disconnect_timeout column to connections table")
except sqlite3.OperationalError:
pass
conn.commit()
conn.close()
logger.info(f"Credentials database initialized at {self.db_path}")
def _encrypt(self, data: str) -> bytes:
"""Encrypt string data."""
if not data:
return None
return self.cipher.encrypt(data.encode())
def _decrypt(self, data: bytes) -> str:
"""Decrypt bytes to string."""
if not data:
return None
try:
return self.cipher.decrypt(data).decode()
except Exception as e:
logger.warning(f"Failed to decrypt data: {e}")
return None
def save_connection(self, username: str, conn_data: Dict[str, Any]) -> bool:
"""Save or update a connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
conn_id = conn_data.get('id')
name = conn_data.get('name', 'Unnamed')
host = conn_data.get('host', '')
port = conn_data.get('port', 22)
ssh_username = conn_data.get('username', '')
auth_type = conn_data.get('authType', 'password')
ws_url = conn_data.get('wsUrl', 'ws://localhost:2222')
lock_timeout = conn_data.get('lockTimeout', 15)
disconnect_timeout = conn_data.get('disconnectTimeout', 60)
# Encrypt sensitive data
password = conn_data.get('password', '')
key = conn_data.get('key', '')
password_encrypted = self._encrypt(password) if password else None
key_encrypted = self._encrypt(key) if key else None
cursor.execute('''
INSERT OR REPLACE INTO connections
(id, username, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
''', (conn_id, username, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout))
conn.commit()
conn.close()
logger.info(f"Saved connection {name} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to save connection: {e}")
return False
def get_connections(self, username: str) -> list:
"""Get all connections for a user."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
logger.info(f"Querying connections for username: '{username}'") # DEBUG
# First, let's see all connections to debug
cursor.execute('SELECT DISTINCT username FROM connections')
all_users = cursor.fetchall()
logger.info(f"All users in DB: {all_users}") # DEBUG
cursor.execute('''
SELECT id, name, host, port, ssh_username, auth_type,
password_encrypted, key_encrypted, ws_url, lock_timeout, disconnect_timeout
FROM connections WHERE username = ?
''', (username,))
rows = cursor.fetchall()
logger.info(f"Found {len(rows)} rows for username '{username}'") # DEBUG
conn.close()
connections = []
for i, row in enumerate(rows):
try:
logger.info(f"Processing row {i}: id={row[0]}, name={row[1]}") # DEBUG
conn_dict = {
'id': row[0],
'name': row[1],
'host': row[2],
'port': row[3],
'username': row[4],
'authType': row[5],
'password': self._decrypt(row[6]) if row[6] else '',
'key': self._decrypt(row[7]) if row[7] else '',
'wsUrl': row[8] if row[8] else 'ws://localhost:2222',
'lockTimeout': row[9] if row[9] is not None else 15,
'disconnectTimeout': row[10] if row[10] is not None else 60,
}
connections.append(conn_dict)
logger.info(f"Successfully processed row {i}") # DEBUG
except Exception as row_error:
logger.error(f"Error processing row {i}: {row_error}")
import traceback
logger.error(traceback.format_exc())
return connections
except Exception as e:
logger.error(f"Failed to get connections: {e}")
import traceback
logger.error(traceback.format_exc())
return []
def delete_connection(self, username: str, conn_id: str) -> bool:
"""Delete a connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"DELETE FROM connections WHERE id = ? AND username = ?",
(conn_id, username)
)
conn.commit()
conn.close()
logger.info(f"Deleted connection {conn_id} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to delete connection: {e}")
return False
# Session Recording Methods
def save_recording(self, recording_data: Dict[str, Any]) -> bool:
"""Save recording metadata to database."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
INSERT INTO recordings
(id, username, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
recording_data['id'],
recording_data['username'],
recording_data['connection_id'],
recording_data.get('connection_name', recording_data['host']),
recording_data['session_id'],
recording_data['host'],
recording_data['ssh_username'],
recording_data.get('started_at'),
recording_data.get('ended_at'),
recording_data.get('duration_seconds'),
recording_data.get('file_size_bytes'),
recording_data['file_path'],
recording_data.get('width', 80),
recording_data.get('height', 24)
))
conn.commit()
conn.close()
logger.info(f"Saved recording metadata: {recording_data['id']}")
return True
except Exception as e:
logger.error(f"Failed to save recording: {e}")
return False
def update_recording_end(self, recording_id: str, ended_at: str, duration: int, file_size: int) -> bool:
"""Update recording with end time and file info."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
UPDATE recordings
SET ended_at = ?, duration_seconds = ?, file_size_bytes = ?
WHERE id = ?
''', (ended_at, duration, file_size, recording_id))
conn.commit()
conn.close()
logger.info(f"Updated recording end: {recording_id}")
return True
except Exception as e:
logger.error(f"Failed to update recording: {e}")
return False
def get_recordings(self, username: str, connection_id: str = None) -> list:
"""Get all recordings for a user, optionally filtered by connection."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
if connection_id:
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE username = ? AND connection_id = ?
ORDER BY started_at DESC
''', (username, connection_id))
else:
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE username = ?
ORDER BY started_at DESC
''', (username,))
rows = cursor.fetchall()
conn.close()
recordings = []
for row in rows:
recordings.append({
'id': row[0],
'connection_id': row[1],
'connection_name': row[2],
'session_id': row[3],
'host': row[4],
'ssh_username': row[5],
'started_at': row[6],
'ended_at': row[7],
'duration_seconds': row[8],
'file_size_bytes': row[9],
'file_path': row[10],
'width': row[11],
'height': row[12]
})
return recordings
except Exception as e:
logger.error(f"Failed to get recordings: {e}")
return []
def get_recording_by_id(self, recording_id: str, username: str) -> Optional[Dict]:
"""Get a specific recording by ID."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT id, connection_id, connection_name, session_id, host, ssh_username,
started_at, ended_at, duration_seconds, file_size_bytes, file_path, width, height
FROM recordings WHERE id = ? AND username = ?
''', (recording_id, username))
row = cursor.fetchone()
conn.close()
if row:
return {
'id': row[0],
'connection_id': row[1],
'connection_name': row[2],
'session_id': row[3],
'host': row[4],
'ssh_username': row[5],
'started_at': row[6],
'ended_at': row[7],
'duration_seconds': row[8],
'file_size_bytes': row[9],
'file_path': row[10],
'width': row[11],
'height': row[12]
}
return None
except Exception as e:
logger.error(f"Failed to get recording: {e}")
return None
def delete_recording(self, recording_id: str, username: str) -> bool:
"""Delete a recording from database."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute(
"DELETE FROM recordings WHERE id = ? AND username = ?",
(recording_id, username)
)
conn.commit()
conn.close()
logger.info(f"Deleted recording {recording_id} for user {username}")
return True
except Exception as e:
logger.error(f"Failed to delete recording: {e}")
return False
def get_connections_with_recordings(self, username: str) -> list:
"""Get unique connections that have recordings for a user."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT DISTINCT connection_id, connection_name, host, ssh_username,
COUNT(*) as recording_count,
SUM(file_size_bytes) as total_size,
MAX(started_at) as last_recording
FROM recordings
WHERE username = ? AND connection_id IS NOT NULL
GROUP BY connection_id, connection_name, host, ssh_username
ORDER BY last_recording DESC
''', (username,))
rows = cursor.fetchall()
conn.close()
return [{
'connection_id': row[0],
'connection_name': row[1],
'host': row[2],
'ssh_username': row[3],
'recording_count': row[4],
'total_size_bytes': row[5] or 0,
'last_recording': row[6]
} for row in rows]
except Exception as e:
logger.error(f"Failed to get connections with recordings: {e}")
return []
def delete_recordings_by_connection(self, username: str, connection_id: str) -> tuple:
"""Delete all recordings for a specific connection. Returns (success_count, failed_count)."""
try:
# First get all recordings to delete files
recordings = self.get_recordings(username, connection_id)
success_count = 0
failed_count = 0
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
for rec in recordings:
try:
# Delete file if exists
file_path = rec.get('file_path')
if file_path and os.path.exists(file_path):
os.remove(file_path)
# Delete from database
cursor.execute(
"DELETE FROM recordings WHERE id = ? AND username = ?",
(rec['id'], username)
)
success_count += 1
except Exception as e:
logger.error(f"Failed to delete recording {rec.get('id')}: {e}")
failed_count += 1
conn.commit()
conn.close()
logger.info(f"Deleted {success_count} recordings for connection {connection_id}, failed: {failed_count}")
return (success_count, failed_count)
except Exception as e:
logger.error(f"Failed to delete recordings by connection: {e}")
return (0, 0)
def get_old_recordings(self, before_date: str) -> list:
"""Get recordings older than a specific date for cleanup."""
try:
conn = sqlite3.connect(self.db_path)
cursor = conn.cursor()
cursor.execute('''
SELECT id, username, file_path FROM recordings
WHERE started_at < ?
''', (before_date,))
rows = cursor.fetchall()
conn.close()
return [{'id': row[0], 'username': row[1], 'file_path': row[2]} for row in rows]
except Exception as e:
logger.error(f"Failed to get old recordings: {e}")
return []
# Initialize credentials manager (singleton)
try:
creds_manager = CredentialsManager(STORAGE_FILE, ENCRYPTION_KEY)
logger.info("Credentials manager initialized successfully")
except Exception as e:
logger.error(f"Failed to initialize credentials manager: {e}")
import traceback
logger.error(traceback.format_exc())
creds_manager = None # Will fail later when used
+899
View File
@@ -0,0 +1,899 @@
"""
HTTP API module - LoginHandler with all HTTP endpoints.
"""
import hashlib
import io
import json
import os
import stat
from datetime import datetime, timedelta, timezone
from http.server import BaseHTTPRequestHandler
from typing import Optional
from urllib.parse import parse_qs, urlparse
import jwt
import paramiko
from .auth import JWT_ALGORITHM, JWT_EXPIRE_HOURS, JWT_SECRET, USERS, generate_token, verify_token_http
from .config import HTTP_PORT, MAX_UPLOAD_SIZE, SFTP_CHUNK_SIZE, WS_PORT, logger
from .database import creds_manager
APP_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_DIR = os.path.join(APP_ROOT, 'static')
INDEX_PATH = os.path.join(APP_ROOT, 'index.html')
HELP_PATH = os.path.join(APP_ROOT, 'help.html')
class LoginHandler(BaseHTTPRequestHandler):
def log_message(self, format, *args):
logger.info(f"HTTP {args[0]} {args[1]}")
def do_OPTIONS(self):
self.send_response(200)
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Content-Type, Authorization')
self.end_headers()
def do_POST(self):
parsed = urlparse(self.path)
if parsed.path == '/api/login':
self.handle_login()
elif parsed.path == '/api/verify':
self.handle_verify()
elif parsed.path == '/api/connections':
self.handle_save_connection()
elif parsed.path == '/api/config':
self.handle_config()
elif parsed.path == '/api/sftp/list':
self.handle_sftp_list()
elif parsed.path == '/api/sftp/download':
self.handle_sftp_download()
elif parsed.path == '/api/sftp/upload':
self.handle_sftp_upload()
elif parsed.path == '/api/sftp/mkdir':
self.handle_sftp_mkdir()
elif parsed.path == '/api/sftp/rm':
self.handle_sftp_rm()
elif parsed.path == '/api/sftp/mv':
self.handle_sftp_mv()
elif parsed.path == '/api/recordings/delete':
self.handle_delete_recording()
elif parsed.path == '/api/recordings/delete-all':
self.handle_delete_all_recordings()
else:
self.send_error(404)
def do_GET(self):
parsed = urlparse(self.path)
# API endpoint for connections
if parsed.path == '/api/connections':
self.handle_get_connections()
return
# API endpoint for config
if parsed.path == '/api/config':
self.handle_config()
return
# API endpoint for recordings list
if parsed.path == '/api/recordings':
self.handle_get_recordings()
return
# API endpoint for connections with recordings
if parsed.path == '/api/recordings/connections':
self.handle_get_connections_with_recordings()
return
# API endpoint for download all recordings for a connection
if parsed.path == '/api/recordings/download-all':
self.handle_download_all_recordings()
return
# API endpoint for recording details or download
if parsed.path.startswith('/api/recordings/'):
parts = parsed.path.split('/')
if len(parts) >= 4:
recording_id = parts[3]
if len(parts) >= 5 and parts[4] == 'download':
self.handle_download_recording(recording_id)
else:
self.handle_get_recording(recording_id)
return
# Static file serving
file_path = parsed.path
# Default to index.html for root
if file_path == '/' or file_path == '/index.html':
full_path = INDEX_PATH
elif file_path == '/help.html':
full_path = HELP_PATH
elif file_path.startswith('/static/'):
relative_path = file_path[len('/static/'):] # Remove '/static/' prefix
full_path = os.path.join(STATIC_DIR, relative_path)
else:
self.send_error(404)
return
# Security check - prevent directory traversal
full_path = os.path.realpath(full_path)
app_root = os.path.realpath(APP_ROOT)
if os.path.commonpath([app_root, full_path]) != app_root:
self.send_error(403)
return
# Check if file exists
if not os.path.exists(full_path) or not os.path.isfile(full_path):
self.send_error(404)
return
# Determine content type
content_type = 'application/octet-stream'
if full_path.endswith('.html'):
content_type = 'text/html'
elif full_path.endswith('.js'):
content_type = 'application/javascript'
elif full_path.endswith('.css'):
content_type = 'text/css'
# Serve the file
try:
with open(full_path, 'rb') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', len(content))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(content)
except Exception as e:
logger.error(f"Error serving file {full_path}: {e}")
self.send_error(500)
def _get_username_from_token(self) -> Optional[str]:
"""Extract username from JWT token in Authorization header."""
auth_header = self.headers.get('Authorization', '')
if auth_header.startswith('Bearer '):
token = auth_header[7:]
return verify_token_http(token)
return None
def handle_save_connection(self):
"""Save or update a connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
if creds_manager.save_connection(username, data):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to save connection'})
except Exception as e:
logger.error(f"Save connection error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_get_connections(self):
"""Get all connections for the user."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
connections = creds_manager.get_connections(username)
logger.info(f"Retrieved {len(connections)} connections for user {username}")
self.send_json_response(200, {'connections': connections})
except Exception as e:
logger.error(f"Get connections error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_config(self):
"""Get configuration including max upload size."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
config = {
'max_upload_size': MAX_UPLOAD_SIZE,
'max_upload_size_mb': MAX_UPLOAD_SIZE / 1024 / 1024,
'stored_users': USERS, # Include hashed passwords for client-side verification
'current_user': username,
'ws_port': WS_PORT,
'http_port': HTTP_PORT,
'sftp_ws_port': SFTP_CHUNK_SIZE
}
self.send_json_response(200, {'config': config})
except Exception as e:
logger.error(f"Get config error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_delete_connection(self):
"""Delete a connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('id')
if not conn_id:
self.send_json_response(400, {'error': 'Connection ID required'})
return
if creds_manager.delete_connection(username, conn_id):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to delete connection'})
except Exception as e:
logger.error(f"Delete connection error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_login(self):
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
self.send_json_response(400, {'error': 'Username and password required'})
return
# Check credentials
password_hash = hashlib.sha256(password.encode()).hexdigest()
if USERS.get(username) != password_hash:
logger.warning(f"Failed login attempt for user: {username}")
self.send_json_response(401, {'error': 'Invalid credentials'})
return
# Generate JWT token
token = generate_token(username)
expire = datetime.now(timezone.utc) + timedelta(hours=JWT_EXPIRE_HOURS)
logger.info(f"Successful login for user: {username}")
self.send_json_response(200, {'token': token, 'username': username, 'expires': expire.isoformat()})
except json.JSONDecodeError:
self.send_json_response(400, {'error': 'Invalid JSON'})
except Exception as e:
logger.error(f"Login error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def handle_verify(self):
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
token = data.get('token', '')
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
self.send_json_response(200, {'valid': True, 'username': payload['sub']})
except jwt.ExpiredSignatureError:
self.send_json_response(401, {'valid': False, 'error': 'Token expired'})
except jwt.InvalidTokenError:
self.send_json_response(401, {'valid': False, 'error': 'Invalid token'})
except Exception as e:
logger.error(f"Verify error: {e}")
self.send_json_response(500, {'error': 'Internal error'})
def send_json_response(self, status, data):
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(json.dumps(data).encode())
def _get_ssh_connection(self, conn_data: dict) -> paramiko.SSHClient:
"""Establish SSH connection from connection data."""
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": conn_data['host'],
"port": conn_data.get('port', 22),
"username": conn_data['username'],
"timeout": 30, # SSH_TIMEOUT
"look_for_keys": False,
"allow_agent": False
}
if conn_data.get('key'):
pkey = None
key_data = conn_data['key']
for key_type in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
pkey = key_type.from_private_key(io.StringIO(key_data))
break
except:
continue
if pkey:
connect_kwargs["pkey"] = pkey
else:
raise ValueError("Invalid SSH key")
elif conn_data.get('password'):
connect_kwargs["password"] = conn_data['password']
else:
raise ValueError("No authentication method provided")
ssh.connect(**connect_kwargs)
return ssh
def handle_sftp_list(self):
"""List files in remote directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path', '.')
# Get connection details from stored connections
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP connection
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# List directory
entries = sftp.listdir_attr(remote_path)
files = []
for entry in entries:
files.append({
'name': entry.filename,
'size': entry.st_size,
'is_dir': stat.S_ISDIR(entry.st_mode),
'mtime': entry.st_mtime,
'mode': oct(entry.st_mode)[-3:]
})
# Sort: directories first, then files
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
self.send_json_response(200, {'files': files, 'path': remote_path})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP list error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_download(self):
"""Stream file download via SFTP without caching."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
# Get connection details
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# Get file info
stat_info = sftp.stat(remote_path)
if stat_info.st_mode & 0o170000 == 0o040000: # Check if it's a directory
self.send_json_response(400, {'error': 'Path is a directory'})
return
file_size = stat_info.st_size
filename = os.path.basename(remote_path)
# Send headers for streaming download
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Disposition', f'attachment; filename="{filename}"')
self.send_header('Content-Length', str(file_size))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
# Stream file in chunks
with sftp.file(remote_path, 'rb') as f:
while True:
chunk = f.read(SFTP_CHUNK_SIZE)
if not chunk:
break
self.wfile.write(chunk)
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP download error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_upload(self):
"""Handle file upload via HTTP POST."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Parse multipart form data
content_type = self.headers.get('Content-Type', '')
logger.info(f"Upload content-type: {content_type}")
if not content_type.startswith('multipart/form-data'):
self.send_json_response(400, {'error': 'multipart/form-data required'})
return
# Extract boundary and parse form data
boundary = content_type.split('boundary=')[1].encode()
content_length = int(self.headers.get('Content-Length', 0))
logger.info(f"Upload content-length: {content_length}")
post_data = self.rfile.read(content_length)
logger.info(f"Read {len(post_data)} bytes of post data")
# Parse multipart data
parts = post_data.split(b'--' + boundary)
logger.info(f"Found {len(parts)} parts in multipart data")
conn_id = None
remote_path = None
file_data = None
filename = None
for i, part in enumerate(parts):
if b'Content-Disposition: form-data' in part:
lines = part.split(b'\r\n')
logger.info(f"Part {i} has {len(lines)} lines")
if len(lines) > 1:
disposition_line = lines[1].decode()
logger.info(f"Disposition: {disposition_line}")
if 'name="conn_id"' in disposition_line:
if len(lines) > 3:
conn_id = lines[3].decode().strip()
logger.info(f"Found conn_id: {conn_id}")
elif 'name="remote_path"' in disposition_line:
if len(lines) > 3:
remote_path = lines[3].decode().strip()
logger.info(f"Found remote_path: {remote_path}")
elif 'name="file"' in disposition_line:
# Extract filename from disposition
if 'filename=' in disposition_line:
filename = disposition_line.split('filename="')[1].split('"')[0]
logger.info(f"Found filename: {filename}")
# Find file data (skip headers)
file_start = 2 # Skip first two lines
while file_start < len(lines) and lines[file_start]:
file_start += 1
file_start += 1 # Skip empty line
if file_start < len(lines):
# Join remaining lines as file data
file_data = b'\r\n'.join(lines[file_start:-1]) # Exclude ending boundary
logger.info(f"Found file data: {len(file_data)} bytes")
logger.info(f"Final values: conn_id={conn_id}, remote_path={remote_path}, filename={filename}, file_data_len={len(file_data) if file_data else 0}")
if not conn_id or not remote_path or not file_data:
self.send_json_response(400, {'error': f'Missing required fields: conn_id={conn_id}, remote_path={remote_path}, file_data={len(file_data) if file_data else None}'})
return
# Check file size limit
if len(file_data) > MAX_UPLOAD_SIZE:
self.send_json_response(413, {'error': f'File too large. Max size: {MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB'})
return
# Get connection details
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
# Establish SSH and SFTP
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
# Upload file
with sftp.file(remote_path, 'wb') as f:
f.write(file_data)
self.send_json_response(200, {'success': True, 'filename': filename})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP upload error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_mkdir(self):
"""Create directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
sftp.mkdir(remote_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP mkdir error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_rm(self):
"""Remove file or directory via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
remote_path = data.get('path')
is_dir = data.get('is_dir', False)
if not remote_path:
self.send_json_response(400, {'error': 'Path required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
if is_dir:
sftp.rmdir(remote_path)
else:
sftp.remove(remote_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP rm error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_sftp_mv(self):
"""Rename/move file via SFTP."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
conn_id = data.get('conn_id')
src_path = data.get('src')
dst_path = data.get('dst')
if not src_path or not dst_path:
self.send_json_response(400, {'error': 'Source and destination paths required'})
return
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
self.send_json_response(404, {'error': 'Connection not found'})
return
ssh = self._get_ssh_connection(conn_data)
sftp = ssh.open_sftp()
try:
sftp.rename(src_path, dst_path)
self.send_json_response(200, {'success': True})
finally:
sftp.close()
ssh.close()
except Exception as e:
logger.error(f"SFTP mv error: {e}")
self.send_json_response(500, {'error': str(e)})
# Session Recording Handlers
def handle_get_recordings(self):
"""Get list of recordings for the authenticated user."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Get optional connection_id filter from query params
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
connection_id = params.get('connection_id', [None])[0]
recordings = creds_manager.get_recordings(username, connection_id)
self.send_json_response(200, {'recordings': recordings})
except Exception as e:
logger.error(f"Get recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_get_recording(self, recording_id: str):
"""Get details of a specific recording."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
self.send_json_response(200, {'recording': recording})
except Exception as e:
logger.error(f"Get recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_download_recording(self, recording_id: str):
"""Download a recording file."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
file_path = recording.get('file_path')
if not file_path or not os.path.exists(file_path):
self.send_json_response(404, {'error': 'Recording file not found'})
return
# Send file
self.send_response(200)
self.send_header('Content-Type', 'application/gzip')
self.send_header('Content-Disposition', f'attachment; filename="{os.path.basename(file_path)}"')
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
with open(file_path, 'rb') as f:
self.wfile.write(f.read())
except Exception as e:
logger.error(f"Download recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_delete_recording(self):
"""Delete a recording."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
recording_id = data.get('recording_id')
if not recording_id:
self.send_json_response(400, {'error': 'recording_id required'})
return
# Get recording to find file path
recording = creds_manager.get_recording_by_id(recording_id, username)
if not recording:
self.send_json_response(404, {'error': 'Recording not found'})
return
# Delete file
file_path = recording.get('file_path')
if file_path and os.path.exists(file_path):
os.remove(file_path)
logger.info(f"Deleted recording file: {file_path}")
# Delete from database
if creds_manager.delete_recording(recording_id, username):
self.send_json_response(200, {'success': True})
else:
self.send_json_response(500, {'error': 'Failed to delete recording'})
except Exception as e:
logger.error(f"Delete recording error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_get_connections_with_recordings(self):
"""Get list of connections that have recordings."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
connections = creds_manager.get_connections_with_recordings(username)
self.send_json_response(200, {'connections': connections})
except Exception as e:
logger.error(f"Get connections with recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_delete_all_recordings(self):
"""Delete all recordings for a specific connection."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
content_length = int(self.headers.get('Content-Length', 0))
body = self.rfile.read(content_length).decode('utf-8')
data = json.loads(body)
connection_id = data.get('connection_id')
if not connection_id:
self.send_json_response(400, {'error': 'connection_id required'})
return
success_count, failed_count = creds_manager.delete_recordings_by_connection(username, connection_id)
if success_count > 0 or failed_count == 0:
self.send_json_response(200, {
'success': True,
'deleted_count': success_count,
'failed_count': failed_count
})
else:
self.send_json_response(500, {'error': f'Failed to delete {failed_count} recordings'})
except Exception as e:
logger.error(f"Delete all recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
def handle_download_all_recordings(self):
"""Download all recordings for a connection as a zip file."""
username = self._get_username_from_token()
if not username:
self.send_json_response(401, {'error': 'Authentication required'})
return
try:
# Get connection_id from query params
parsed = urlparse(self.path)
params = parse_qs(parsed.query)
connection_id = params.get('connection_id', [None])[0]
if not connection_id:
self.send_json_response(400, {'error': 'connection_id required'})
return
# Get all recordings for this connection
recordings = creds_manager.get_recordings(username, connection_id)
if not recordings:
self.send_json_response(404, {'error': 'No recordings found for this connection'})
return
# Get connection info for filename
connections = creds_manager.get_connections_with_recordings(username)
conn_info = next((c for c in connections if c['connection_id'] == connection_id), None)
conn_name = conn_info['connection_name'] if conn_info else connection_id
# Create zip file in memory
import zipfile
import io
zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for rec in recordings:
file_path = rec.get('file_path')
if file_path and os.path.exists(file_path):
# Add file to zip with a descriptive name
filename = f"{rec['connection_name']}_{rec['started_at'].replace(':', '-')}.cast.gz"
zip_file.write(file_path, filename)
zip_buffer.seek(0)
zip_data = zip_buffer.read()
# Send zip file
self.send_response(200)
self.send_header('Content-Type', 'application/zip')
self.send_header('Content-Disposition', f'attachment; filename="{conn_name}_recordings.zip"')
self.send_header('Content-Length', len(zip_data))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(zip_data)
logger.info(f"Downloaded {len(recordings)} recordings for connection {connection_id}")
except Exception as e:
logger.error(f"Download all recordings error: {e}")
self.send_json_response(500, {'error': str(e)})
+52
View File
@@ -0,0 +1,52 @@
"""
Main entry point - starts all servers.
"""
import asyncio
import threading
from http.server import HTTPServer
import websockets
from .config import HTTP_PORT, HOST, MAX_MESSAGE_SIZE, MAX_UPLOAD_SIZE, RECORDING_ENABLED, RECORDING_RETENTION_DAYS, SFTP_WS_PORT, WS_PORT, logger
from .http_api import LoginHandler
from .recording import cleanup_old_recordings
from .sftp_handler import handle_sftp_client
from .ssh_handler import handle_client
async def periodic_cleanup():
"""Run cleanup of old recordings periodically."""
while True:
try:
await asyncio.sleep(86400) # Run once per day (24 hours)
cleanup_old_recordings()
except Exception as e:
logger.error(f"Periodic cleanup error: {e}")
async def main():
logger.info(f"StrixWebSSH server starting on ws://{HOST}:{WS_PORT}")
logger.info(f"SFTP server starting on ws://{HOST}:{SFTP_WS_PORT}")
logger.info(f"Login API available on http://{HOST}:{HTTP_PORT}")
# Start HTTP server for login API in a separate thread
http_server = HTTPServer((HOST, HTTP_PORT), LoginHandler)
http_thread = threading.Thread(target=http_server.serve_forever, daemon=True)
http_thread.start()
logger.info(f"HTTP login server started on port {HTTP_PORT}")
# Start cleanup task if recording is enabled
if RECORDING_ENABLED and RECORDING_RETENTION_DAYS > 0:
cleanup_task = asyncio.create_task(periodic_cleanup())
logger.info(f"Started periodic cleanup task (retention: {RECORDING_RETENTION_DAYS} days)")
# Start both WebSocket servers
async with websockets.serve(handle_client, HOST, WS_PORT, max_size=MAX_MESSAGE_SIZE, ping_interval=20, ping_timeout=10), \
websockets.serve(handle_sftp_client, HOST, SFTP_WS_PORT, max_size=MAX_UPLOAD_SIZE + 1024*1024, ping_interval=20, ping_timeout=10):
logger.info(f"WebSocket SSH server started on port {WS_PORT}")
logger.info(f"WebSocket SFTP server started on port {SFTP_WS_PORT}")
await asyncio.Future()
if __name__ == "__main__":
asyncio.run(main())
+31
View File
@@ -0,0 +1,31 @@
"""
Models module - SSHConfig dataclass with validation.
"""
import re
from dataclasses import dataclass
from typing import Optional
@dataclass
class SSHConfig:
host: str
port: int
username: str
password: Optional[str] = None
key_data: Optional[str] = None
def validate(self) -> None:
if not self.host or not isinstance(self.host, str):
raise ValueError("Invalid host")
if not re.match(r'^[a-zA-Z0-9_.-]+$', self.host):
raise ValueError("Host contains invalid characters")
if not 1 <= self.port <= 65535:
raise ValueError("Port must be between 1 and 65535")
if not self.username or len(self.username) > 32:
raise ValueError("Invalid username")
if not self.password and not self.key_data:
raise ValueError("Password or SSH key required")
if self.password and len(self.password) > 1024:
raise ValueError("Password too long")
if self.key_data and len(self.key_data) > 50000:
raise ValueError("SSH key too large")
+163
View File
@@ -0,0 +1,163 @@
"""
Recording module - SessionRecorder and cleanup functions.
"""
import gzip
import json
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any, Dict
from .config import RECORDING_ENABLED, RECORDING_RETENTION_DAYS, RECORDINGS_PATH, logger
from .database import creds_manager
class SessionRecorder:
"""Records SSH session output in asciinema v2 format."""
def __init__(self, username: str, connection_id: str, session_id: str,
host: str, ssh_username: str, connection_name: str = None,
width: int = 80, height: int = 24):
self.username = username
self.connection_id = connection_id
self.session_id = session_id
self.host = host
self.ssh_username = ssh_username
self.connection_name = connection_name or host # Use host as fallback
self.width = width
self.height = height
self.recording_id = str(uuid.uuid4())
self.start_time = time.time()
self.last_event_time = self.start_time
self.events = []
self.file_path = None
self.file_handle = None
self.gz_handle = None
# Ensure recordings directory exists
self.recordings_dir = os.path.join(RECORDINGS_PATH, username, connection_id)
os.makedirs(self.recordings_dir, exist_ok=True)
# Create file path: {session_id}_{timestamp}.cast.gz
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{session_id}_{timestamp}.cast.gz"
self.file_path = os.path.join(self.recordings_dir, filename)
# Open compressed file and write header
self._open_file()
logger.info(f"Started recording session {session_id} for user {username}")
def _open_file(self):
"""Open the recording file and write asciinema v2 header."""
try:
self.gz_handle = gzip.open(self.file_path, 'wt', encoding='utf-8')
# Write asciinema v2 header
header = {
"version": 2,
"width": self.width,
"height": self.height,
"timestamp": int(self.start_time),
"env": {"TERM": "xterm-256color", "SHELL": "/bin/bash"}
}
self.gz_handle.write(json.dumps(header) + "\n")
except Exception as e:
logger.error(f"Failed to open recording file: {e}")
raise
def record_output(self, data: str):
"""Record stdout output with timestamp."""
if not self.gz_handle:
return
try:
# Calculate time delta from start
current_time = time.time()
time_delta = current_time - self.start_time
# asciinema format: [time, "o", "output"]
# "o" = output (stdout)
event = [time_delta, "o", data]
self.events.append(event)
# Write immediately to file
self.gz_handle.write(json.dumps(event) + "\n")
self.gz_handle.flush()
self.last_event_time = current_time
except Exception as e:
logger.error(f"Failed to record output: {e}")
def finalize(self) -> Dict[str, Any]:
"""Finalize recording and return metadata."""
if self.gz_handle:
try:
self.gz_handle.close()
except Exception as e:
logger.error(f"Failed to close recording file: {e}")
end_time = time.time()
duration = int(end_time - self.start_time)
# Get file size
file_size = 0
if os.path.exists(self.file_path):
file_size = os.path.getsize(self.file_path)
metadata = {
'id': self.recording_id,
'username': self.username,
'connection_id': self.connection_id,
'connection_name': self.connection_name,
'session_id': self.session_id,
'host': self.host,
'ssh_username': self.ssh_username,
'started_at': datetime.fromtimestamp(self.start_time, tz=timezone.utc).isoformat(),
'ended_at': datetime.fromtimestamp(end_time, tz=timezone.utc).isoformat(),
'duration_seconds': duration,
'file_size_bytes': file_size,
'file_path': self.file_path,
'width': self.width,
'height': self.height
}
logger.info(f"Finalized recording {self.recording_id}: {duration}s, {file_size} bytes")
return metadata
def cleanup(self):
"""Clean up recording file if needed."""
if self.gz_handle:
try:
self.gz_handle.close()
except:
pass
def cleanup_old_recordings():
"""Remove recordings older than RECORDING_RETENTION_DAYS."""
if not RECORDING_ENABLED or RECORDING_RETENTION_DAYS <= 0:
return
try:
cutoff_date = (datetime.now() - timedelta(days=RECORDING_RETENTION_DAYS)).isoformat()
old_recordings = creds_manager.get_old_recordings(cutoff_date)
deleted_count = 0
for rec in old_recordings:
try:
# Delete file
if os.path.exists(rec['file_path']):
os.remove(rec['file_path'])
logger.info(f"Deleted old recording file: {rec['file_path']}")
# Delete from database
creds_manager.delete_recording(rec['id'], rec['username'])
deleted_count += 1
except Exception as e:
logger.error(f"Failed to cleanup recording {rec['id']}: {e}")
if deleted_count > 0:
logger.info(f"Cleaned up {deleted_count} old recordings")
except Exception as e:
logger.error(f"Failed to cleanup old recordings: {e}")
+214
View File
@@ -0,0 +1,214 @@
"""
SFTP Handler module - WebSocket SFTP operations.
"""
import asyncio
import base64
import io
import json
import stat
from typing import Optional
import paramiko
import websockets
from websockets.server import WebSocketServerProtocol
from .auth import verify_token
from .config import HOST, MAX_UPLOAD_SIZE, SFTP_WS_PORT, SSH_TIMEOUT, logger
from .database import creds_manager
from .utils import get_client_ip
async def handle_sftp_client(ws: WebSocketServerProtocol):
"""Handle SFTP file operations via WebSocket with progress tracking."""
client_ip = get_client_ip(ws)
# Verify JWT token first
username = await verify_token(ws)
if not username:
logger.warning(f"SFTP authentication failed for {client_ip}")
await ws.close(1008, "Authentication required")
return
logger.info(f"SFTP client {username} connected from {client_ip}")
ssh = None
sftp = None
current_file = None
upload_state = {'bytes_received': 0, 'total_size': 0, 'remote_path': ''}
try:
async for message in ws:
try:
data = json.loads(message)
msg_type = data.get('type')
if msg_type == 'init':
# Initialize SFTP connection
conn_id = data.get('conn_id')
connections = creds_manager.get_connections(username)
conn_data = next((c for c in connections if c['id'] == conn_id), None)
if not conn_data:
await ws.send(json.dumps({'type': 'error', 'message': 'Connection not found'}))
continue
# Connect SSH
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
connect_kwargs = {
"hostname": conn_data['host'],
"port": conn_data.get('port', 22),
"username": conn_data['username'],
"timeout": SSH_TIMEOUT,
"look_for_keys": False,
"allow_agent": False
}
if conn_data.get('key'):
for key_type in [paramiko.RSAKey, paramiko.Ed25519Key, paramiko.ECDSAKey]:
try:
connect_kwargs["pkey"] = key_type.from_private_key(io.StringIO(conn_data['key']))
break
except:
continue
elif conn_data.get('password'):
connect_kwargs["password"] = conn_data['password']
await asyncio.get_event_loop().run_in_executor(None, lambda: ssh.connect(**connect_kwargs))
sftp = ssh.open_sftp()
await ws.send(json.dumps({'type': 'ready'}))
logger.info(f"SFTP ready for {conn_data['host']}")
elif msg_type == 'upload_start':
# Start file upload
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
upload_state['remote_path'] = data.get('remote_path')
upload_state['total_size'] = data.get('total_size', 0)
upload_state['bytes_received'] = 0
# Check file size limit
if upload_state['total_size'] > MAX_UPLOAD_SIZE:
await ws.send(json.dumps({
'type': 'error',
'message': f'File too large. Max size: {MAX_UPLOAD_SIZE / 1024 / 1024:.1f}MB'
}))
continue
# Open file for writing
current_file = sftp.file(upload_state['remote_path'], 'wb')
await ws.send(json.dumps({'type': 'upload_ready'}))
elif msg_type == 'upload_chunk':
# Receive file chunk
if not current_file:
await ws.send(json.dumps({'type': 'error', 'message': 'No upload in progress'}))
continue
chunk_data = base64.b64decode(data.get('data', ''))
current_file.write(chunk_data)
upload_state['bytes_received'] += len(chunk_data)
# Send progress
progress = int((upload_state['bytes_received'] / upload_state['total_size']) * 100) if upload_state['total_size'] > 0 else 0
await ws.send(json.dumps({
'type': 'progress',
'bytes_received': upload_state['bytes_received'],
'total_size': upload_state['total_size'],
'progress': progress
}))
elif msg_type == 'upload_complete':
# Finish upload
if current_file:
current_file.close()
current_file = None
await ws.send(json.dumps({
'type': 'upload_done',
'success': True,
'bytes_received': upload_state['bytes_received']
}))
logger.info(f"Upload complete: {upload_state['remote_path']} ({upload_state['bytes_received']} bytes)")
elif msg_type == 'list':
# List directory
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
path = data.get('path', '.')
entries = sftp.listdir_attr(path)
files = []
for entry in entries:
files.append({
'name': entry.filename,
'size': entry.st_size,
'is_dir': stat.S_ISDIR(entry.st_mode),
'mtime': entry.st_mtime,
'mode': oct(entry.st_mode)[-3:]
})
files.sort(key=lambda x: (not x['is_dir'], x['name'].lower()))
await ws.send(json.dumps({'type': 'file_list', 'files': files, 'path': path}))
elif msg_type == 'mkdir':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
sftp.mkdir(data.get('path'))
await ws.send(json.dumps({'type': 'success', 'action': 'mkdir'}))
elif msg_type == 'rm':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
path = data.get('path')
is_dir = data.get('is_dir', False)
if is_dir:
sftp.rmdir(path)
else:
sftp.remove(path)
await ws.send(json.dumps({'type': 'success', 'action': 'rm'}))
elif msg_type == 'mv':
if not sftp:
await ws.send(json.dumps({'type': 'error', 'message': 'SFTP not connected'}))
continue
sftp.rename(data.get('src'), data.get('dst'))
await ws.send(json.dumps({'type': 'success', 'action': 'mv'}))
except Exception as e:
logger.error(f"SFTP error for {client_ip}: {e}")
try:
await ws.send(json.dumps({'type': 'error', 'message': str(e)}))
except:
pass
except websockets.exceptions.ConnectionClosed:
pass
finally:
if current_file:
try:
current_file.close()
except:
pass
if sftp:
try:
sftp.close()
except:
pass
if ssh:
try:
ssh.close()
except:
pass
logger.info(f"SFTP client disconnected: {client_ip}")
+270
View File
@@ -0,0 +1,270 @@
"""
SSH Handler module - WebSocket to SSH bridge.
"""
import asyncio
import io
import json
import socket
import traceback
import uuid
from typing import Optional
import paramiko
import websockets
from websockets.server import WebSocketServerProtocol
from .auth import verify_token
from .config import HOST, MAX_MESSAGE_SIZE, RECORDING_ENABLED, SSH_TIMEOUT, WS_PORT, logger
from .database import creds_manager
from .models import SSHConfig
from .recording import SessionRecorder
from .utils import get_client_ip, is_rate_limited
async def handle_client(ws: WebSocketServerProtocol):
"""Handle WebSocket client and bridge to SSH server."""
client_ip = get_client_ip(ws)
# Verify JWT token first
username = await verify_token(ws)
if not username:
logger.warning(f"Authentication failed for {client_ip}")
await ws.close(1008, "Authentication required")
return
logger.info(f"Authenticated user {username} connected from {client_ip}")
if is_rate_limited(client_ip):
logger.warning(f"Rate limit exceeded for {client_ip}")
await ws.close(1013, "Rate limit exceeded")
return
ssh: Optional[paramiko.SSHClient] = None
chan: Optional[paramiko.Channel] = None
recorder = None # Initialize recorder variable before try block
try:
# Receive and validate initialization
try:
init = await asyncio.wait_for(ws.recv(), timeout=10)
except asyncio.TimeoutError:
raise ValueError("Initialization timeout")
if len(init) > MAX_MESSAGE_SIZE:
raise ValueError("Message too large")
data = json.loads(init)
config = SSHConfig(
host=data.get("host", "").strip(),
port=int(data.get("port", 22)),
username=data.get("username", "").strip(),
password=data.get("password"),
key_data=data.get("key")
)
config.validate()
logger.info(f"Connecting to {config.username}@{config.host}:{config.port} from {client_ip}")
logger.info(f"Auth type: {'key' if config.key_data else 'password'}")
# Establish SSH connection
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) # For testing only!
connect_kwargs = {
"hostname": config.host,
"port": config.port,
"username": config.username,
"timeout": SSH_TIMEOUT,
"look_for_keys": False,
"allow_agent": False
}
if config.key_data:
logger.info(f"Key data length: {len(config.key_data)} chars")
pkey = None
key_errors = []
# Try RSA first
try:
pkey = paramiko.RSAKey.from_private_key(io.StringIO(config.key_data))
logger.info("Using RSA key authentication")
except paramiko.SSHException as e:
key_errors.append(f"RSA: {e}")
# Try Ed25519
try:
pkey = paramiko.Ed25519Key.from_private_key(io.StringIO(config.key_data))
logger.info("Using Ed25519 key authentication")
except paramiko.SSHException as e2:
key_errors.append(f"Ed25519: {e2}")
# Try ECDSA
try:
pkey = paramiko.ECDSAKey.from_private_key(io.StringIO(config.key_data))
logger.info("Using ECDSA key authentication")
except paramiko.SSHException as e3:
key_errors.append(f"ECDSA: {e3}")
# Try DSS (deprecated in newer paramiko)
pass
if pkey is None:
logger.error(f"All key types failed: {key_errors}")
raise ValueError(f"Invalid SSH key format. Tried: {', '.join([k.split(':')[0] for k in key_errors])}")
connect_kwargs["pkey"] = pkey
else:
connect_kwargs["password"] = config.password
logger.info("Using password authentication")
# Connect with timeout
logger.info(f"Attempting SSH connection to {config.host}:{config.port}...")
await asyncio.get_event_loop().run_in_executor(
None, lambda: ssh.connect(**connect_kwargs)
)
logger.info("SSH transport established")
logger.info("Invoking shell...")
chan = ssh.invoke_shell(term='xterm')
chan.setblocking(False)
chan.settimeout(SSH_TIMEOUT)
logger.info("Shell channel created successfully")
logger.info(f"SSH session established for {client_ip}")
await ws.send(json.dumps({"type": "connected"}))
# Initialize session recording if enabled
session_id = data.get("session_id", str(uuid.uuid4()))
connection_id = data.get("connection_id", "unknown")
connection_name = data.get("connection_name", config.host) # Get connection name from frontend
if RECORDING_ENABLED:
try:
# Get terminal size from data or use defaults
width = data.get("cols", 80)
height = data.get("rows", 24)
recorder = SessionRecorder(
username=username,
connection_id=connection_id,
session_id=session_id,
host=config.host,
ssh_username=config.username,
connection_name=connection_name,
width=width,
height=height
)
logger.info(f"Session recording started: {session_id} ({connection_name})")
except Exception as e:
logger.error(f"Failed to start session recording: {e}")
recorder = None
# Flag to signal when WebSocket is closed
ws_closed = asyncio.Event()
async def ws_to_ssh():
try:
async for msg in ws:
if len(msg) > MAX_MESSAGE_SIZE:
logger.warning(f"Large message from {client_ip}")
continue
# Handle resize message
try:
if msg.startswith('{'):
j = json.loads(msg)
if j.get("type") == "resize":
cols = max(1, min(j.get("cols", 80), 500))
rows = max(1, min(j.get("rows", 24), 500))
chan.resize_pty(width=cols, height=rows)
continue
except json.JSONDecodeError:
pass
# Send to SSH
if chan.active:
chan.send(msg)
except websockets.exceptions.ConnectionClosed:
pass
finally:
# Signal that WebSocket is closed
ws_closed.set()
logger.info(f"ws_to_ssh finished for {client_ip}")
async def ssh_to_ws():
try:
while chan.active and not ws_closed.is_set():
await asyncio.sleep(0.01)
if chan.recv_ready():
data = chan.recv(4096)
if not data:
break
decoded_data = data.decode(errors="ignore")
# Try to send to WebSocket, stop if closed
try:
await ws.send(decoded_data)
except websockets.exceptions.ConnectionClosed:
logger.info(f"WebSocket closed, stopping ssh_to_ws for {client_ip}")
break
# Record output if recorder is active
if recorder:
recorder.record_output(decoded_data)
if chan.exit_status_ready():
break
except Exception as e:
logger.error(f"SSH read error for {client_ip}: {e}")
finally:
logger.info(f"ssh_to_ws finished for {client_ip}")
await asyncio.gather(ws_to_ssh(), ssh_to_ws())
logger.info(f"Session ended for {client_ip}")
except json.JSONDecodeError as e:
logger.error(f"Invalid JSON from {client_ip}: {e}")
await ws.send(json.dumps({"type": "error", "message": "Invalid JSON"}))
except ValueError as e:
logger.warning(f"Validation error from {client_ip}: {e}")
await ws.send(json.dumps({"type": "error", "message": str(e)}))
except paramiko.AuthenticationException as e:
logger.warning(f"Authentication failed for {client_ip}: {e}")
try:
await ws.send(json.dumps({"type": "error", "message": f"Authentication failed: {e}"}))
except:
pass
except (paramiko.SSHException, socket.error) as e:
logger.error(f"SSH error for {client_ip}: {type(e).__name__}: {e}")
try:
await ws.send(json.dumps({"type": "error", "message": f"SSH connection failed: {e}"}))
except:
pass
except Exception as e:
logger.error(f"Unexpected error for {client_ip}: {type(e).__name__}: {e}")
logger.error(traceback.format_exc())
try:
await ws.send(json.dumps({"type": "error", "message": f"Internal error: {type(e).__name__}"}))
except:
pass
finally:
logger.info(f"Entering finally block for {client_ip}, recorder={'exists' if recorder else 'none'}")
# Finalize session recording in finally block to ensure it always runs
if recorder:
try:
logger.info(f"Finalizing recording for session {recorder.session_id}")
metadata = recorder.finalize()
logger.info(f"Got metadata, saving to DB: {metadata['id']}")
result = creds_manager.save_recording(metadata)
logger.info(f"DB save result: {result}")
except Exception as e:
logger.error(f"Failed to finalize session recording: {e}")
recorder.cleanup()
if chan:
try:
chan.close()
except:
pass
if ssh:
try:
ssh.close()
except:
pass
logger.info(f"Connection closed for {client_ip}")
+34
View File
@@ -0,0 +1,34 @@
"""
Utils module - helper functions.
"""
import time
from typing import Dict, List
from .config import RATE_LIMIT_MAX, RATE_LIMIT_WINDOW, logger
# Rate limiting storage
connection_attempts: Dict[str, List[float]] = {}
def is_rate_limited(client_ip: str) -> bool:
"""Check if client IP has exceeded rate limit."""
now = time.time()
attempts = connection_attempts.get(client_ip, [])
attempts = [t for t in attempts if now - t < RATE_LIMIT_WINDOW]
connection_attempts[client_ip] = attempts
if len(attempts) >= RATE_LIMIT_MAX:
return True
attempts.append(now)
return False
def get_client_ip(ws) -> str:
"""Extract client IP from WebSocket connection."""
# New websockets API uses request.headers instead of request_headers
try:
forwarded = ws.request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
except AttributeError:
pass
return ws.remote_address[0] if ws.remote_address else "unknown"
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Compatibility wrapper for launching the backend from the repository root."""
import os
import runpy
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(ROOT, "src")
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
if __name__ == "__main__":
runpy.run_path(os.path.join(SRC_DIR, "run.py"), run_name="__main__")
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env python3
"""Compatibility wrapper for serving the frontend from the repository root."""
import os
import runpy
import sys
ROOT = os.path.dirname(os.path.abspath(__file__))
SRC_DIR = os.path.join(ROOT, "src")
if SRC_DIR not in sys.path:
sys.path.insert(0, SRC_DIR)
if __name__ == "__main__":
runpy.run_path(os.path.join(SRC_DIR, "serve_static.py"), run_name="__main__")