English | Русский
Browser-based SSH terminal. Plain HTTP, no build, no extra services.
- 📦 No npm, no pip — drop the files in and run
- 🌐 Corporate networks with only HTTPS open: works without WebSocket
- ⭐ Sessions survive tab close, reboot, and backend restart for up to 72 h (via tmux on the target host)
┌─ Your browser ─┐ HTTPS ┌── websh host ──┐ SSH ┌──── Remote ────┐
│ │ │ │ │ │
│ xterm.js │─── POST ────►│ server.py │◄────────────►│ bash │
│ │◄─── SSE ─────│ (Python) │ │ + tmux │
│ │ │ │ │ │
└────────────────┘ └────────────────┘ └────────────────┘
Three pieces:
- Browser. xterm.js renders the terminal. Each keystroke goes up as a POST to
/api/input. - websh host.
server.pyruns each SSH connection as a PTY subprocess and streams output back over Server-Sent Events on/api/stream. The same process serves the frontend, so you don't need a separate web server. - Remote. The host you SSH into. Optionally wrapped in tmux so the session survives reconnects.
If a proxy buffers SSE (some shared hosts do), the client falls back to long-polling on /api/output for that session. Slower, but works.
Shared hosting that doesn't allow long-lived processes? Ship api.php next to server.py. The PHP shim starts the backend on the first request and proxies the API to it.
Why not WebSocket? Many shared-hosting PHP setups don't proxy it — websh has to drop in there too. SSE gives the same low latency on plain HTTP and tunnels through any HTTPS proxy without a protocol upgrade.
For deeper internals — buffer-detection probe, lost-byte handling on disconnect, selectors-based wait — see docs/sse-transport.md.
- Backend. Python 3.5+ with
sshin PATH. Stdlib only — no pip dependencies. - Browser. Any modern browser. xterm.js is loaded from a CDN.
- Optional persistent sessions.
tmuxon the target host — any reasonably recent version. Seedocs/persistent-sessions.md. - Optional shared-hosting proxy. PHP 5.3+ with the
curlextension. - Optional reverse proxy. nginx, Caddy, or Apache.
Real xterm.js — copy-on-select, right-click paste, scrollback search (Ctrl+Shift+F), zoom (Ctrl+±), fullscreen (F11).
- Split panes, horizontal or vertical, with draggable dividers
- Pane switching with
Ctrl+Tab/Ctrl+Shift+Tab - Font picker (⚙) with live preview — JetBrains Mono, Fira Code, IBM Plex Mono, Roboto Mono, Source Code Pro, Inconsolata, or system default. Custom size, line-height, weight
Tick Persistent session at connect — websh wraps the shell in a tmux session on the target host. Close the tab, reboot, restart server.py: the pane re-attaches to the same tmux session with scrollback and running processes intact. See docs/persistent-sessions.md.
- Reconnect button on disconnect; red banner on auth failure
- URL anchors (
#connect=Production) for direct links and bookmarks - Saved connections in browser
localStorage
Upload and download without scp.
- Upload. Pick files; the browser streams the bytes through a piggybacked SSH ControlMaster channel (
cat > $HOME/<tmp>, no PTY, no base64, one HTTP POST per file). On persistent (tmux) panes the file is moved intopane_current_pathautomatically — vim/less/htop in the foreground stay untouched. Non-persistent panes type themvinto the foreground shell with an alt-screen guard. Auto-increment on name conflicts. Native xhr.upload progress, multi-file queue, cancel mid-flight. - Download. Select a filename in the terminal, click Download.
- Export scrollback. Save the current buffer as a text file. Persistent panes pull the real tmux scrollback via
tmux capture-pane.
From free-form "type a host and go" to strictly allowlisted click-to-connect. See docs/server-side-connections.md.
- Password and SSH key auth
- Server-side profiles in
websh.json— credentials stay on the server; the browser never sees them - Ready (saved creds) and Prompt (allowlisted target, user types own password) profile kinds
allowed_users/denied_usersper profile- Per-profile SSH options (
ProxyJump,StrictHostKeyChecking, …) restrict_hosts: truehides the free-form form entirely
- Shared hosting. Upload 4 files +
assets/via FTP;api.phpstarts the backend on demand. No SSH access to the host needed. - Python only. The backend serves the frontend itself — zero extras.
- Docker, systemd, reverse proxy. Recipes in
docs/deployment.md. - Plain HTTP transport with automatic long-poll fallback for hosts that buffer SSE.
- Corporate firewalls — SSH port blocked, only HTTPS open. websh tunnels through standard HTTPS.
- No native terminal — Chromebooks, iPads, kiosks. Any browser becomes a terminal.
- Customer access — give a customer a browser link to their own server. URL anchors (
#connect=ServerName) for direct links. - Bastion UI — install websh on a jump host, reach internal servers from any browser.
- Recovery from a foreign machine — open a URL, you're in.
- Workshops — students don't install anything locally.
git clone https://github.com/dolonet/websh.git
cd websh
python3 server.pyOpen http://localhost:8765 — that's it. No pip install, no npm, no build step.
Requires Python 3.5+ and ssh in your PATH. The server binds to
127.0.0.1 by default; set HOST=0.0.0.0 to expose it on the LAN.
No SSH access required. Upload index.html, websh.js, api.php,
server.py, and the assets/ folder into a folder in your web root.
Open it in a browser. api.php starts server.py automatically on the
first request.
websh.json (optional, for server-side connections) lives outside
the web root — api.php looks two directories up from itself by
default. Override with WEBSH_CONFIG=/path/to/websh.json.
Full directory layout, troubleshooting, and config-path notes:
docs/deployment.md.
The only things most deployments need to change:
PORT=8765 # listen port
HOST=127.0.0.1 # bind address (use 0.0.0.0 for LAN)
WEBSH_CONFIG=... # path to websh.json (auto-detected by api.php)Full table of environment variables (rate limits, session caps, tmux
idle-TTL, access log path, …): docs/configuration.md.
- Shared hosting (PHP + Python) — FTP-drop the 4 files +
assets/. Seedocs/deployment.md. - Python only —
HOST=0.0.0.0 python3 server.py. Seedocs/deployment.md. - Docker —
docker build -t websh . && docker run -d -p 8765:8765 -e HOST=0.0.0.0 websh. Seedocs/deployment.md. - systemd —
websh.serviceunit included. Seedocs/deployment.md. - HTTPS via reverse proxy — nginx / Caddy in front for TLS. See
docs/deployment.md.
websh has no built-in authentication — add it at the web-server layer
(auth_basic, Cloudflare Access, Tailscale Funnel, IP allowlists, etc.).
For threat-model details, rate limiting, the JSON access log, fail2ban
integration, and host-key handling see docs/security.md.
For the encrypted credential vault that closes the saved-password
plaintext caveat see docs/encryption.md.
| Topic | File |
|---|---|
| Server-side connection profiles | docs/server-side-connections.md |
| Persistent sessions (tmux) | docs/persistent-sessions.md |
| Authentication & security | docs/security.md |
| Encrypted credential vault | docs/encryption.md |
| Configuration reference | docs/configuration.md |
| Deployment recipes | docs/deployment.md |
| SSE transport design | docs/sse-transport.md |
| Auth-failure detection | docs/auth-fail-detection.md |
index.html Frontend — xterm.js terminal + connection UI
websh.js Frontend logic — pane management, file transfer
api.php PHP proxy — forwards browser requests to backend (optional)
server.py Python backend — manages SSH sessions via PTY, serves frontend
assets/ Brand SVG (logo) loaded by index.html
websh.json.example Example server-side config
test_server.py Backend tests (unit + integration)
tests/frontend/ jsdom-based frontend tests
docs/ Design notes & reference docs
Dockerfile Container deployment
websh.service systemd unit file
LICENSE MIT license
# Backend (Python, stdlib only — unittest)
python3 test_server.py -v
# Frontend (Node 20 + jsdom)
cd tests/frontend && npm install && npm testBoth suites also run on every PR via GitHub Actions.
MIT
