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 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 - Dark and light themes (persisted)
- 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 Persistent sessions (tmux).
- 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.
- 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 included.
- 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 files via FTP, open in browser.
A typical shared hosting directory structure:
/home/user/
example.com/ <- site root
websh.json <- config (OUTSIDE www — not accessible via HTTP)
www/ <- web root (public)
console/
index.html <- frontend
websh.js <- frontend logic
api.php <- PHP proxy
server.py <- backend (auto-started by api.php)
assets/ <- brand SVGs (logo, light/dark variants)
Steps:
- Create a folder in your web root (e.g.
www/console/) - Upload
index.html,websh.js,api.php,server.py, and theassets/folder there - Open
https://your-host/console/in a browser
That's it. api.php starts server.py automatically on the first request.
Path details:
api.phplooks forwebsh.jsontwo directories up from itself (i.e. the site root, abovewww/). This works for most hosting providers. If your layout is different, set theWEBSH_CONFIGenvironment variable or edit the path inapi.phpline 34.
"Backend unavailable" or blank page:
- Check that Python 3 is installed:
python3 --version - Check that
sshis available:which ssh - Some shared hosts disable
exec()in PHP — ask your hosting provider or checkphpinfo()
Config not loading:
- Verify
websh.jsonpath —api.phplooks two directories up by default - Set
WEBSH_CONFIG=/full/path/to/websh.jsonenvironment variable if your layout differs - Check JSON syntax:
python3 -c "import json; json.load(open('websh.json'))"
Port already in use:
- Another instance of
server.pymay be running:ps aux | grep server.py - Change the port:
PORT=8766 python3 server.py
Pre-configure connections so users just click to connect — no passwords
on the client. Create websh.json in your site root (not in www/):
{
"restrict_hosts": false,
"connections": [
{
"name": "Production",
"host": "server.example.com",
"port": 22,
"username": "deploy",
"password": "secret"
}
]
}See websh.json.example for a full example including SSH key auth and custom SSH options.
This file contains passwords — keep it outside the web root. It must not be accessible via HTTP. If your hosting layout doesn't match the diagram above, set the
WEBSH_CONFIGenvironment variable.
Override default SSH behavior for specific connections:
{
"name": "Strict server",
"host": "secure.example.com",
"username": "admin",
"password": "secret",
"ssh_options": {
"StrictHostKeyChecking": "yes",
"ProxyJump": "bastion.example.com"
}
}Each connections[] entry is one of two kinds, auto-detected by whether
a password or key is present:
- Ready — credentials (
passwordorkey) are stored server-side. The user clicks the card and connects. The browser never sees the credentials. - Prompt — no
passwordand nokey. The entry acts as an allowlisted target: the user clicks the card, the manual form appears pre-filled (host/port locked, username locked if fixed) and the user types their own password or key.
Prompt entries may carry optional allowed_users (whitelist) or
denied_users (blacklist) to restrict which usernames may connect.
allowed_users wins if both are set. These rules are ignored when the
entry has a fixed username (there's no choice to police). Saving the
typed credentials locally via the "Save this connection" checkbox works
the same as with the free manual form.
{
"name": "Shared DB",
"host": "db.example.com",
"port": 2222,
"allowed_users": ["alice", "bob"]
}Set "restrict_hosts": true to hide the free-form manual connection form
entirely. Users can only go through a configured connection card. Raw
manual-path POSTs to /api/connect (bypassing the UI) are also rejected.
With a single connection, the UI auto-selects it on load — Ready connects
immediately, Prompt surfaces the locked form ready for a password.
allowed_users / denied_users apply only inside the named connection
flow ({connection: "<name>"} on /api/connect). When restrict_hosts
is off, the free manual form and raw manual-path POSTs are not bound by
those lists — they're a UX-guided allowlist for your team, not a hardening
boundary against a determined caller. Combine with restrict_hosts: true
if you need the rules to be enforced against direct API access too.
When restrict_hosts is off (the default), visitors can target any host
they like. To stop the proxy from reaching internal infrastructure or
your own boxes, add a denied_hosts array:
{
"restrict_hosts": false,
"denied_hosts": [
"127.0.0.0/8",
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"169.254.0.0/16",
"fe80::/10",
"evil.example",
"your-internal-jumpbox.example.com"
]
}Each entry is parsed as an IP address or CIDR network when possible (IPv4 and IPv6 both supported); otherwise it's matched as an exact hostname (case-insensitive). At connect time websh resolves the target hostname via the system resolver and rejects the request if any of the returned addresses fall inside a denied range — so a public-looking domain whose A record points into RFC1918 is also blocked.
DNS resolution failures fail open (the request goes through; ssh's own
resolver will then fail with a clear error). Hosts you've put in
connections bypass the deny-list — explicit configuration wins.
Link directly to a server-side connection:
https://your-host/console/#connect=Production
This auto-connects on page load — useful for bookmarks and support links.
Tick Persistent session on the connect form and the remote shell is
wrapped in a tmux session on the target host
(tmux new-session -A -D -s websh-<slot>). Close the tab, refresh the
page, or restart server.py — the pane re-attaches to the same session
with scrollback and running processes intact.
Requirements. tmux must be installed on the target (any recent
version). If it isn't, the connect flow surfaces a popup offering to
fall back to a short-lived (non-persistent) session instead.
How reattach works. Each persistent pane stores its slot id in
browser localStorage alongside the connection record. On refresh, the
frontend re-opens the pane with the same slot id and tmux re-attaches
you to the existing session. Slot ids are per pane instance — closing
a pane with [x] does not free the slot for reuse.
Terminating a session. Clicking [x] on a persistent pane pops a
confirm modal (Cancel / Terminate session / Terminate and never ask
again). "Terminate" sends tmux kill-session on the target before the
pane closes. If you just close the browser tab without terminating,
the session stays alive on the target and you can re-attach later.
Idle-TTL watchdog. At session creation, a detached POSIX-sh
watchdog is spawned alongside the shell. It polls tmux and kills the
session once it has been unattached for WEBSH_TMUX_IDLE_TTL seconds
(default 72 h; 0 disables). The watchdog reparents to init via
nohup and survives server.py restarts. Active (attached) sessions
refresh the clock each poll, so long-running work doesn't get reaped
just because you had a brief disconnect.
Per-connect tmux options. Every persistent connect runs
tmux new-session … \; set -g … so a small set of tmux options is
applied uniformly regardless of what's on the target host. Mouse mode
is baked in unconditionally; two toggles in the Options panel are
user-configurable and also pushed into running panes the moment you
change them, so the new behaviour takes effect without a reconnect:
- Mouse (always on, no toggle) —
set -g mouse on. Wheel scrolls tmux scrollback in shell; alt-screen apps (vim, less, htop) get raw mouse events. Hold Shift to bypass tmux selection and use the browser's native text selection instead. - Auto-copy (toggle) —
set-clipboard on. tmux copy-mode selections are pushed to the system clipboard via OSC52 (xterm.js ships them on). - Scrollback (number) —
history-limit(default 100 000). How many lines per pane tmux retains.
The server accepts user-configurable options only via a fixed
allow-list (set-clipboard, history-limit clamped to 100..10 M);
anything else — including a legacy tmux_mouse field from older
clients — is silently dropped, so an out-of-date or hostile client
can't inject extra set -g lines. Mouse stays on regardless.
The set -g lines run after the target's own ~/.tmux.conf and
therefore override matching options there.
tmux status bar is hidden by default. websh runs set -g status off on every persistent attach. Multi-pane is handled by websh's
own splits (each pane is a separate SSH connection), not by tmux
windows, so the default status bar — slot-id session name, empty
window list, and a clock — is visual noise that just steals a row of
terminal real estate. To re-enable for a single session: Ctrl+B :set -g status on (resets on next reconnect).
Environment variables for server.py:
| Variable | Default | Description |
|---|---|---|
PORT |
8765 |
Listen port |
HOST |
127.0.0.1 |
Bind address |
SESSION_TIMEOUT |
300 |
Idle timeout in seconds |
MAX_SESSIONS |
50 |
Max concurrent SSH sessions |
MAX_SESSIONS_PER_IP |
0 |
Max concurrent sessions per source IP (0 disables; counts foreground + background together) |
WEBSH_CONFIG |
(auto-detected) | Path to websh.json config file |
TRUSTED_PROXIES |
127.0.0.1 |
Comma-separated IPs to trust X-Forwarded-For from |
MAX_BG_SESSIONS |
50 |
Max background SSH sessions (file upload/download) |
RATE_LIMIT_MAX |
50 |
Max /api/connect attempts per IP per window |
RATE_LIMIT_WINDOW |
60 |
Rate-limit window in seconds |
SCAN_PATTERN_THRESHOLD |
0 |
One IP that probes at least N distinct deny-listed targets in SCAN_PATTERN_WINDOW seconds gets result=scan_pattern events emitted starting on the Nth probe; 0 disables. ANY successful connect from the same IP clears state, so legitimate users never accumulate. |
SCAN_PATTERN_WINDOW |
300 |
Sliding window for scan-pattern detection, in seconds |
WEBSH_TMUX_IDLE_TTL |
259200 |
Seconds a detached persistent tmux session may idle on the target before it's reaped (default 72h, 0 disables) |
WEBSH_TMUX_WATCHDOG_POLL |
300 |
Seconds between idle-TTL watchdog checks on the target |
WEBSH_ACCESS_LOG |
(unset) | Path to a JSON-line access log; when unset, no access log is written. See Access log below. |
The PHP proxy reads WEBSH_PORT (default 8765) to find the backend.
Upload the four files (index.html, websh.js, api.php, server.py)
and the assets/ folder (brand SVGs) to your web directory. The backend
starts automatically.
For manual control (e.g. custom config path):
WEBSH_CONFIG=/path/to/websh.json nohup python3 server.py &The backend can serve the frontend directly — no PHP or separate web server needed:
HOST=0.0.0.0 python3 server.pyOpen http://your-host:8765/ in a browser. The backend serves the static
files (index.html, websh.js, assets/*.svg) from the same directory as
server.py, and handles API requests on the same port. See
HTTPS via reverse proxy below.
docker build -t websh .
docker run -d -p 8765:8765 -e HOST=0.0.0.0 webshOpen http://localhost:8765/ — the backend serves the frontend directly. See
HTTPS via reverse proxy below.
# Create a dedicated user
useradd -r -s /bin/false websh
mkdir -p /opt/websh
cp server.py index.html websh.js /opt/websh/
cp websh.service /etc/systemd/system/
systemctl enable --now webshPut nginx or Caddy in front for TLS termination:
server {
listen 443 ssl;
server_name ssh.example.com;
location / {
proxy_pass http://127.0.0.1:8765;
proxy_read_timeout 60s;
# OVERWRITE the client-IP header with the real peer. Do not
# append — a client can pre-populate X-Forwarded-For and bypass
# per-IP rate limiting and the per-IP session cap if you
# `proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;`
# (which appends). websh trusts only the first token.
proxy_set_header X-Forwarded-For $remote_addr;
}
}proxy_read_timeout must comfortably exceed both the long-poll window
(10 s) and the SSE keep-alive interval (15 s) — 60 s leaves plenty of
headroom. The backend sets X-Accel-Buffering: no on the SSE response,
so nginx flushes each event immediately without further configuration.
If the proxy runs on a different host, add its IP to TRUSTED_PROXIES
so rate limiting uses the real client IP — see
Rate limiting & proxies.
websh does not include its own authentication layer by design. It is meant to be lightweight — add access control at the web server level:
- Apache:
.htaccesswithAuthType Basic+AuthUserFile - nginx:
auth_basicdirective - Cloudflare Access, Tailscale Funnel, or similar zero-trust tools
- IP allowlisting via firewall rules
The backend connects with StrictHostKeyChecking=no by default to avoid
interactive prompts. This makes the first connection to any host vulnerable
to man-in-the-middle attacks — the server identity is not verified.
This is acceptable when:
- You are connecting to your own servers on a trusted network
- The connection goes over an encrypted tunnel (VPN, Tailscale, etc.)
To enable host key verification for specific connections, use ssh_options
in websh.json:
"ssh_options": {"StrictHostKeyChecking": "yes"}Saved connections in the browser are stored in localStorage in plaintext,
including passwords. Any JavaScript running on the same origin (including XSS
vulnerabilities) could read them.
If this is unacceptable for your use case:
- Use server-side connections (
websh.json) — passwords stay on the server, never reach the browser - Don't save connections in the browser — use SSH keys instead
- Restrict access to the websh URL to trusted networks
Connection attempts are rate-limited to 50 per IP per minute by default
(configurable via RATE_LIMIT_MAX and RATE_LIMIT_WINDOW). The client IP is
determined from X-Forwarded-For only when the request comes from an IP
listed in TRUSTED_PROXIES (default: 127.0.0.1). Direct connections always
use the TCP peer address.
Requirement when running behind a reverse proxy: the proxy MUST overwrite
(not append) the client-IP header before forwarding. websh reads the first
X-Forwarded-For token, so a proxy that appends (the default
$proxy_add_x_forwarded_for recipe in many tutorials) lets a client supply
their own first token and bypass both per-IP rate limiting and the
MAX_SESSIONS_PER_IP cap. Use one of:
# nginx — overwrite (good)
proxy_set_header X-Forwarded-For $remote_addr;
# OR use X-Real-IP, also overwrite by default:
proxy_set_header X-Real-IP $remote_addr;websh validates the token via ipaddress.ip_address() and silently falls
back to the TCP peer if it doesn't parse, so non-IP garbage cannot end up as
the rate-limit / session-cap key — but a valid IP forged by an appending
proxy will still be honored. The only defense there is correct proxy config.
If your reverse proxy runs on a different host, add its IP:
TRUSTED_PROXIES=127.0.0.1,10.0.0.5 python3 server.pySet WEBSH_ACCESS_LOG=/path/to/access.log to emit one JSON record per
abuse-relevant event. Records are stable single-line JSON suitable for
fail2ban filters and ad-hoc jq pipelines. The value is normalised
at startup: ~ expands and a relative path resolves against the
server's cwd. The resolved path is logged once at startup
(access log: <abs-path>).
{"ts":"2026-05-07T12:34:56.789012Z","event":"connect","ip":"203.0.113.7","result":"deny_blocked","target_host":"10.5.6.7","target_user":"root"}
{"ts":"2026-05-07T12:35:01.123456Z","event":"connect","ip":"203.0.113.7","result":"rate_limited"}
{"ts":"2026-05-07T12:35:42.999999Z","event":"connect","ip":"198.51.100.4","result":"ok","sid":"…","target_host":"prod.example","target_user":"deploy","persistent":false,"latency_ms":612}
{"ts":"2026-05-07T12:40:11.000000Z","event":"disconnect","ip":"198.51.100.4","sid":"…","terminate":false,"target_host":"prod.example","result":"closed"}Common result values on connect events:
result |
Meaning |
|---|---|
ok |
Session created. Record includes sid, target_host, target_user, persistent, latency_ms. |
rate_limited |
Caller exceeded RATE_LIMIT_MAX for the window. |
deny_blocked |
Target host (or its resolved IP) is on denied_hosts. |
session_cap_per_ip |
The per-source-IP active session cap (MAX_SESSIONS_PER_IP) was at the limit. |
session_cap_global |
Global cap (MAX_SESSIONS for foreground, MAX_BG_SESSIONS for background) was at the limit. The classification field tells which. |
scan_pattern |
The IP has reached SCAN_PATTERN_THRESHOLD distinct deny-listed targets inside the window. Emitted in addition to the original deny_blocked record, starting on the Nth probe and on every probe after. ANY successful connect from the same IP clears state, so a power user touching many real servers never accumulates here. |
error |
Internal failure during session creation. The error field carries up to 200 Unicode characters of the exception (~800 UTF-8 bytes for non-ASCII text). |
Common result values on disconnect events:
result |
Meaning |
|---|---|
closed |
Disconnect with terminate=false; the persistent session (if any) is left alive on the target. |
terminated |
Disconnect with terminate=true; the persistent tmux session was killed on the target before close. |
close_error |
session.close() (or terminate_remote_tmux()) raised. The record still appears, with error set to the exception text. |
fail2ban filter sketch — drop into /etc/fail2ban/filter.d/websh-abuse.conf:
[Definition]
failregex = ^.*"ip":\s*"<HOST>".*"result":\s*"(rate_limited|session_cap_per_ip|scan_pattern)".*$
ignoreregex =Note that deny_blocked is deliberately not in the recommended
filter. A one-off deny_blocked is just as likely a fat-fingered
hostname or a stale UI link as it is an attacker — banning on a single
event would burn legitimate users. The scan_pattern event is the
curated signal for "this IP is probing the deny-list": it only fires
once SCAN_PATTERN_THRESHOLD distinct deny-listed targets are reached
inside the window, and any successful connect from the same IP
forgives the accumulation. So deny_blocked records stay in the log
for operator visibility (you want to see misconfigured clients) but
fail2ban acts only on the scan_pattern aggregate.
If SCAN_PATTERN_THRESHOLD=0 (the default — disabled), deny_blocked
events are still recorded but no scan_pattern events are ever
emitted — the operator hasn't opted in to automatic banning, so
nothing in this filter triggers on a typo. Set a positive
SCAN_PATTERN_THRESHOLD to enable the curated signal.
The file is opened-and-closed per write, so logrotate(8) works without
any signal-based reopen plumbing — copytruncate is fine. Each record
is committed with a single write(2) on an O_APPEND fd: on Linux the
kernel adjusts the file offset and commits the buffer atomically against
other O_APPEND writers, so concurrent threads do not interleave bytes
within one record. To keep that guarantee real, every attacker-
controlled string field is hard-capped before serialisation
(target_host 253, target_user 64, sid 36, error 200, server-
controlled status fields 32) and ASCII C0/C1 + Unicode bidi/format
control codepoints are scrubbed to ?, so a single record always fits
in one write(2) call and stays safe to view in a terminal.
- Host and username values starting with
-are rejected (prevents SSH flag injection) - Session IDs are validated as UUID format
- Terminal dimensions are clamped to safe ranges
MAX_SESSIONSlimits concurrent user sessions;MAX_BG_SESSIONSlimits file transfer sessions separatelyMAX_SESSIONS_PER_IP(off by default) caps how many sessions a single source IP can hold at once — useful when running a public-facing instance where one abuser shouldn't be able to fill all the global slots
index.html Frontend — xterm.js terminal + connection UI
websh.js Frontend logic — pane management, file transfer, themes
api.php PHP proxy — forwards browser requests to backend (optional)
server.py Python backend — manages SSH sessions via PTY, serves frontend
assets/ Brand SVGs (logo light/dark variants) 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 (e.g. auth-fail-detection.md, sse-transport.md)
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
