A developer productivity tool that provides a browser-based file system explorer and terminal for remote machines accessible via SSH.
Primary feature - enhances traditional SSH terminal experience with possibility to browse and preview remote files in single workspace.
To make SSH session experience complete - it also adds local console to the workspace.
Thus users have in hands all that they usually need while working with SSH quickly accesible - remote terminal, file browser and preview,
plus local console.
It leverages existing SSH configurations (including complex ProxyJump setups) without requiring any agent software on the remote machine.
- File browsing — Lazy-loaded directory tree with depth and entry caps
- File viewing — Text files with syntax highlighting, Markdown with relative image resolution, HTML in sandboxed iframe
- Remote terminal — Interactive PTY terminal via WebSocket (xterm.js)
- Local console — Local PTY terminal (bash/zsh/sh on Unix, PowerShell/CMD/WSL on Windows) via WebSocket
- Liveness tracking — Server-initiated ping/pong heartbeat; a background reaper closes SSH connections for dead clients
- Auto-exit — Daemon shuts down automatically when the browser disconnects (opt out with
--no-auto-exit) - Daemon mode — Run without a specific host for LaunchDaemon/systemd; clients pick a host from the UI
- Security — Local-only binding, session token → HttpOnly cookie, path traversal prevention, SSRF mitigation via CSP
- 2FA (daemon mode) — Optional TOTP challenge for direct browser access; CLI path (
surf-ssh url) unchanged - Zero config — Reuses
~/.ssh/confighost aliases and keys - Ad-hoc hosts — Connect to hosts without
~/.ssh/configentries via password auth (browser prompt or CLIuser@host); passwords are held in daemon memory only, never written to disk - Free-form commands — Paste an
ssh/scpcommand string:surf-ssh open --cmd "ssh root@host -i ~/.ssh/key -p 2222"— parsed into a config entry under a memorable name; ephemeral by default,--save <name>makes it permanent - Cross-platform — Works on macOS and Windows
# Install
pip install -e .
# Build the UI
cd ui && npm install && npm run build && cd ..
# Open a remote host (auto-exit on, opens browser)
surf-ssh open my-server
# From an ssh command string (ephemeral — removed on exit)
surf-ssh open --cmd "ssh root@10.0.0.5 -i ~/.ssh/key -p 2222"
# From an ssh command string (persistent — saved in ~/.ssh/config)
surf-ssh open --cmd "ssh root@10.0.0.5 -i ~/.ssh/key -p 2222" --save prod-jump
# Without opening a browser
surf-ssh open my-server --no-browser
# Keep daemon running after browser closes
surf-ssh open my-server --no-auto-exit
# Daemon mode — no host, clients pick from UI (for LaunchDaemon/systemd)
# Prints an authenticated URL — open it to establish a session
surf-ssh daemon
# Enable 2FA for direct browser access (one-time)
surf-ssh setup-2fa
# Daemon mode with browser
surf-ssh daemon --browser
# Daemon on a custom port
surf-ssh daemon -p 9443
# List available hosts from ~/.ssh/config
surf-ssh hostsThe daemon starts on https://localhost:8443. surf-ssh open opens a browser automatically; surf-ssh daemon does not. A self-signed CA certificate is generated on first run and stored in ~/.surf-ssh/.
Browser (React SPA) ←HTTPS→ Local Python Daemon ←SSH/SFTP→ Remote Machine
(FastAPI + AsyncSSH)
- Backend: Python 3.10+, FastAPI, Uvicorn, AsyncSSH
- Frontend: React, Vite, xterm.js, react-markdown
- CLI: Typer + Rich
See SURF-SSH-ARCH.md for the full architecture document and SURF-SSH-CODER.md for coding guidelines.
surf-ssh/
├── src/
│ ├── main.py # CLI entry point
│ ├── daemon/ # FastAPI server, TLS, static serving
│ ├── ssh/ # Config parser, connection pool, SFTP client
│ ├── api/ # REST + WebSocket endpoints
│ └── security/ # Path validation, session auth
├── ui/ # React SPA
│ └── src/
│ ├── components/ # FileTree, FileViewer, TerminalView, etc.
│ ├── hooks/ # useConnection, useFileSystem, useTerminal
│ └── api/ # API client
├── tests/
│ ├── unit/ # Path validator, session auth, config parser
│ └── integration/ # API endpoint tests
└── pyproject.toml
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/hosts |
List connected/available hosts |
| GET | /api/v1/hosts/{host}/status |
Get connection status and platform |
| GET | /api/v1/hosts/{host}/home |
Resolve the host's home directory |
| GET | /api/v1/hosts/{host}/tree |
Get directory tree (lazy, depth-limited) |
| GET | /api/v1/hosts/{host}/file |
Stream file content |
| GET | /api/v1/hosts/{host}/stat |
Get file metadata |
| GET | /api/v1/hosts/{host}/download |
Download binary file |
| WS | /api/v1/hosts/{host}/terminal |
Interactive remote terminal session |
| WS | /api/v1/local/terminal |
Interactive local terminal session |
| GET | /api/v1/local/shells |
List available local shells |
| WS | /api/v1/liveness |
Server-initiated ping/pong heartbeat |
| GET | /api/v1/auth/exchange |
Exchange URL token for cookie |
| GET | /api/v1/health |
Health check |
- Local only: Daemon binds to
127.0.0.1exclusively - Session auth: Token passed via URL on first load, exchanged for
HttpOnly+Secure+SameSite=Strictcookie; sessions expire after 24h and are stored as sha256-hashed filenames with0600permissions - WebSocket auth: All WS endpoints (remote terminal, local console, liveness) validate the session cookie before accepting the handshake
- Path traversal: Rejects
..components, resolves symlinks viasftp.realpath()with re-validation, and contains static asset serving insideui/dist - SSRF: CSP
img-src 'self'blocks external image loads from rendered content - HTML sandbox:
sandbox=""iframe prevents script execution and API access - Liveness reaper: A background task periodically closes SSH connections whose clients have stopped responding to heartbeats, preventing connection leaks — including frozen terminal sessions
- TLS keys: Private keys are written with
0600permissions - Docs off by default: OpenAPI/Swagger UI disabled unless
SURF_SSH_DEV=1 - Update check: At most one outbound GitHub request per 24h; disable with
--no-update-check - 2FA (daemon mode):
surf-ssh setup-2faenables a TOTP challenge for direct browser access (https://localhost:8443→ 6-digit code → session). Rate-limited (5 attempts/min, exponential lockout). Recovery: backup codes orsurf-ssh disable-2fa. See dev-docs/2FA-ARCH.md - Ad-hoc password auth: Hosts without config entries connect via password (browser prompt or
surf-ssh open user@host). Passwords live in daemon memory only — never on disk, never in logs. Host keys use TOFU with explicit fingerprint confirmation, stored in~/.surf-ssh/known_hosts(not your~/.ssh). Auth attempts are rate-limited per host (5/min). See dev-docs/PASSWORD-AUTH-ARCH.md
surf-ssh daemon starts the server without a specific host. The browser shows a host picker listing all aliases from ~/.ssh/config. This is designed for persistent deployment via LaunchDaemon (macOS) or systemd (Linux).
Port behavior: The daemon uses a strict port — if a healthy daemon is already running, it exits cleanly (exit 0). If the port is occupied by another process, it exits with an error (exit 1). This prevents duplicate daemons and works correctly with service managers' restart policies.
Auto-exit: Always off in daemon mode. Use surf-ssh open if you want the daemon to shut down when the browser disconnects.
If port 8443 is occupied by a stale process, find and kill it:
macOS / Linux:
lsof -ti :8443 | xargs kill -9
# or
pkill -9 -f surf-sshWindows (CMD):
for /f "tokens=5" %a in ('netstat -ano ^| findstr :8443 ^| findstr LISTENING') do taskkill /PID %a /FWindows (PowerShell):
Get-Process -Id (Get-NetTCPConnection -LocalPort 8443).OwningProcess | Stop-Process -Force# Python tests
pytest tests/unit -v
pytest tests/integration -v
# React tests
cd ui && npm testConfiguration and certificates are stored in ~/.surf-ssh/:
~/.surf-ssh/
├── ca.pem # Self-signed CA certificate
├── ca-key.pem # CA private key
├── localhost.pem # Localhost certificate
├── localhost-key.pem
├── config.json # User preferences
└── sessions/ # Active session tokens
We welcome feedback, bug reports, and suggestions:
- Telegram: @MartiAi_Feedback_bot
- GitHub Issues: surf-ssh/issues
Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International (CC BY-NC-SA 4.0)
Copyright © 2026 Andrei Suvorov
You are free to:
- Share — copy and redistribute the material in any medium or format
- Adapt — remix, transform, and build upon the material
Under the following terms:
- Attribution — You must give appropriate credit, provide a link to the license, and indicate if changes were made.
- NonCommercial — You may not use the material for commercial purposes.
- ShareAlike — If you remix, transform, or build upon the material, you must distribute your contributions under the same license as the original.
- No additional restrictions — You may not apply legal terms or technological measures that legally restrict others from doing anything the license permits.
Full license text: https://creativecommons.org/licenses/by-nc-sa/4.0/legalcode
THIS WORK IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT, OR OTHERWISE, ARISING FROM, OUT OF, OR IN CONNECTION WITH THE WORK OR THE USE OR OTHER DEALINGS IN THE WORK.