## Implementation Plan: DevTunnel SSH Reverse Tunneling Service ### Overview Implement the devtunnel feature within the existing Python/FastAPI codebase, using `asyncssh` for the SSH server, the existing nginx for TLS termination (with dynamic subdomain config reload), and the existing DevPlace auth API. This adapts the ticket’s Swift/Caddy requirements to the proven project stack while preserving all described user‑facing behavior. ### Files to Create / Modify #### 1. New package: `devplacepy/services/tunnel/` - `__init__.py` – empty - `server.py` – `start_ssh_server()` using `asyncssh`; handles password auth, interactive prompt for port/subdomain, generates the final `ssh -R` command, manages per‑session tunnel records. - `session_manager.py` – `TunnelManager` class holding active sessions (user, remote port, subdomain, local port, start time); methods `create_session()`, `get_by_subdomain()`, `list_all()`, `list_by_user()`, `kill_session()`. - `subdomain_registry.py` – `SubdomainRegistry` class; stores claimed subdomains (unique) mapped to session IDs. Uses an in‑memory dict + on‑disk JSON file (persist to `DEVPLACE_TUNNEL_SUBDOMAIN_REGISTRY` path). - `ssh_auth.py` – `authenticate_user(username, password)` -> `bool` by calling `POST /api/auth/login` or internal auth validate endpoint. Caches successful results for `AUTH_CACHE_TTL` seconds (config). On failure, adds 3‑second delay before returning. - `nginx_updater.py` – `update_nginx_config(subdomain, remote_port)` writes an nginx snippet under `/etc/nginx/tunnel.d/.conf` and reloads nginx gracefully (`nginx -s reload`). Uses a lockfile to prevent races. - `metrics.py` – `get_metrics()` returns dict of active tunnels, total requests (from nginx logs or custom counter), bandwidth estimates, per‑user aggregation. #### 2. New file: `devplacepy/ssh_server_main.py` Entry point for the SSH server process (runs as separate container or sidecar). Contains: ```python import asyncio from devplacepy.services.tunnel.server import start_ssh_server if __name__ == "__main__": asyncio.run(start_ssh_server()) ``` #### 3. Modify `devplacepy/config.py` Add new configuration keys, read from environment (with `.env` defaults): ```python TUNNEL_SSH_PORT: int = 4242 TUNNEL_SSH_HOST_KEY: str = "/etc/ssh/devtunnel_host_key" TUNNEL_MIN_PORT: int = 40000 TUNNEL_MAX_PORT: int = 49999 TUNNEL_DOMAIN: str = "tunnel.devplace.net" TUNNEL_DOMAIN_WILDCARD: str = "*.tunnel.devplace.net" TUNNEL_AUTH_CACHE_TTL: int = 300 TUNNEL_MAX_BANDWIDTH_PER_CLIENT: int = 10_000_000 # bytes/sec TUNNEL_RATE_LIMIT_PER_USER: int = 5 # concurrent sessions per user TUNNEL_SUBDOMAIN_REGISTRY_PATH: str = "/data/tunnel_subdomains.json" ``` #### 4. Modify `devplacepy/main.py` - Register tunnel router (if API endpoints needed) – `app.include_router(tunnel_router, prefix="/api/tunnel")` - Optionally start SSH server in the same process via a lifespan hook. Simpler: run as separate container (see docker‑compose). #### 5. New file: `devplacepy/routers/tunnel.py` FastAPI router for: - `GET /api/tunnel/sessions` – list active sessions (admin only, role check) - `GET /api/tunnel/sessions/{session_id}` – session details - `DELETE /api/tunnel/sessions/{session_id}` – kill session - `GET /api/tunnel/metrics` – metrics endpoint - `POST /api/tunnel/subdomains` – claim subdomain (reserved for future CLI/API use) Uses existing `Depends(get_current_user)` with admin role check. #### 6. New migration: `devplacepy/database/migrations/XXXX_tunnel_sessions.py` Create table `tunnel_sessions`: ```sql CREATE TABLE tunnel_sessions ( id TEXT PRIMARY KEY, user_id TEXT NOT NULL, subdomain TEXT UNIQUE, local_port INTEGER, remote_port INTEGER, started_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, ended_at TIMESTAMP ); ``` Also table `tunnel_subdomains` for uniqueness guarantee (backed by DB later, but initial in‑memory works). #### 7. Modify `nginx/nginx.conf.template` Add a new server block for `*.tunnel.devplace.net`: ```nginx server { listen 443 ssl; server_name ~^(?.+)\.tunnel\.devplace\.net$; ssl_certificate /etc/ssl/certs/tunnel.devplace.net.pem; ssl_certificate_key /etc/ssl/private/tunnel.devplace.net.key; location / { # Dynamic proxy to SSH tunnel – resolved via subdomain set $upstream "127.0.0.1:TUNNEL_PORT_PLACEHOLDER"; proxy_pass http://$upstream; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` `nginx_updater.py` will replace `TUNNEL_PORT_PLACEHOLDER` per subdomain snippet (see step 1). #### 8. Modify `docker-compose.yml` Add service: ```yaml ssh-server: build: dockerfile: Dockerfile.ssh ports: - "4242:4242" volumes: - ./data/tunnel:/data environment: - DEVPLACE_TUNNEL_SSH_PORT=4242 - DEVPLACE_AUTH_API_URL=http://app:8000/api/auth/login depends_on: - app ``` Create `Dockerfile.ssh` (based on main `Dockerfile` but entrypoint `python -m devplacepy.ssh_server_main`). #### 9. New file: `devplacepy/cli/tunnel.py` Add Click commands: - `devplace tunnel list` – list user’s tunnels - `devplace tunnel kill ` - `devplace tunnel metrics` – show admin metrics #### 10. Modify `pyproject.toml` Add dependency: `asyncssh>=2.14.0` #### 11. Modify `.env.example` Add tunnel‑related defaults. ### Detailed Implementation Steps (ordered) 1. **Add asyncssh dependency** to `pyproject.toml`. 2. **Create `devplacepy/services/tunnel/`** with all sub‑modules as described. 3. **Create `devplacepy/ssh_server_main.py`**. 4. **Create `devplacepy/routers/tunnel.py`** with API endpoints. 5. **Create database migration** for `tunnel_sessions` table. 6. **Add config keys** to `devplacepy/config.py`. 7. **Register router** in `main.py`. 8. **Update `nginx/nginx.conf.template`** with wildcard server block. 9. **Create `Dockerfile.ssh`**. 10. **Add `ssh-server` service** to `docker-compose.yml`. 11. **Create `devplacepy/cli/tunnel.py`** and register in CLI group. 12. **Add new environment variables** to `.env.example`. 13. **Check all new code passes `ruff check .`**. 14. **Run `pip install -e '.[dev]' -q && make test-unit`** – all existing tests must pass. Write unit tests for tunnel services (mock SSH server, auth API). ### Definition of Done - [ ] SSH server starts on port 4242 and accepts connections. - [ ] `ssh dummy@tunnel.devplace.net -p 4242` prompts for password, authenticates via DevPlace API (test with valid/invalid creds). - [ ] Interactive session asks for local port and subdomain choice, then prints the correct `ssh -R` command. - [ ] Executing the suggested command opens a reverse tunnel; the remote port is allocated and linked to the subdomain. - [ ] `curl https://.tunnel.devplace.net/` returns content from the local service (test with a simple HTTP server on the local port). - [ ] Claiming the same subdomain twice fails (uniqueness enforced). - [ ] Admin API `GET /api/tunnel/sessions` returns active sessions; `DELETE` kills a session and removes nginx snippet. - [ ] Rate limit: more than `TUNNEL_RATE_LIMIT_PER_USER` concurrent sessions from same user fails. - [ ] Bandwidth limit: configurable; if exceeded, SSH server closes the channel. - [ ] `pip install -e '.[dev]' -q && make test-unit` passes (all existing + new tunnel unit tests). - [ ] `pip install -q ruff && ruff check .` passes (no lint errors in new or modified files).