Browser-native SSH, SCP, and SFTP over a TLS WebSocket relay, gated by an
identity provider of your choice. Implements the nassh [email protected]
protocol, so the existing Google-published Secure Shell extension for
Chrome/Chromium works without modification.
If you run a Zero Trust program, arbitrary SSH to a corporate fleet is the last
workflow that keeps dragging users onto a VPN or the legacy office network.
This relay lets you publish SSH behind the same identity-aware proxy you
already use for HTTP apps (Cloudflare Access, GCP IAP, or anything else that
terminates TLS and attaches a JWT). The endpoint is dynamic: a user types
any user@host:port into the browser extension and the relay dials it, so you
don't have to pre-register every target host.
[email protected]protocol —/cookie,/v4/connect,/v4/reconnect, with session resumption and a replay buffer.- Pluggable identity — Cloudflare Access (RS256 JWT), GCP IAP (ES256 JWT), or none (when an upstream proxy already enforces auth).
- Structured audit logging —
session.start,session.reconnect,session.closeevents, fanned out to stderr / rotating JSONL / Splunk HEC / Palo Alto Networks User-ID in parallel. - Source-port pinning — optional per-session bind to a fixed port range so
PAN can map
(relay_ip, source_port) → userwithout IP-per-user hacks. - Single-process async Python — FastAPI + uvicorn, no external state store.
make install
make dev # http://127.0.0.1:8080, no authPoint the Chrome extension at 127.0.0.1:8080:
- Relay Server Options:
--proxy-host=127.0.0.1 --proxy-port=8080 [email protected] --use-ssl=false
Browsers increasingly refuse plaintext — if Chrome's HTTPS-First Mode insists on TLS, use the locally-trusted mkcert flow:
make dev-tls # https://127.0.0.1:8443…and set --proxy-port=8443 --use-ssl=true instead.
For a tunnel exposed to your real browser-side extension:
make dev-tunnel # uvicorn + cloudflared trycloudflareTests:
make testThe shortest path from "I have a VM" to "I can SSH in the browser". Assumes you'll add cloudflared + a Cloudflare Access Application later.
# On your laptop: push the source to the VM.
rsync -av --exclude .venv --exclude .git --exclude .certs --exclude .pytest_cache \
./ user@vm:~/ssh-relay/
# On the VM:
cd ~/ssh-relay
sudo ./deploy/systemd/install.sh
sudoedit /etc/ssh-relay/env
sudo systemctl enable --now ssh-relay
journalctl -u ssh-relay -fThe installer creates a ssh-relay system user, a venv at
/opt/ssh-relay/.venv, copies source to /opt/ssh-relay/src, installs the
systemd unit, and seeds /etc/ssh-relay/env from .env.example.
Minimum env to smoke-test before cloudflared is wired up (no auth):
RELAY_PUBLIC_HOST=ssh-relay.example.com
RELAY_PUBLIC_PORT=443
RELAY_IDENTITY_PROVIDER=none
RELAY_AUTH_REQUIRED=false
RELAY_LOG_SINKS=stderrVerify on the VM: curl http://127.0.0.1:8080/healthz → {"ok":true}.
When you add cloudflared + CF Access, flip four lines and restart:
RELAY_IDENTITY_PROVIDER=cloudflare-access
RELAY_CF_TEAM_DOMAIN=yourteam
RELAY_CF_AUDIENCE=<aud-from-access-app>
RELAY_AUTH_REQUIRED=truesudo systemctl restart ssh-relayFull cloudflared walkthrough: docs/deploy/cloudflare-access.md.
With Cloudflare Tunnel you don't need one — cloudflared dials the CF edge
over its own authenticated mTLS tunnel and talks to the relay on
127.0.0.1:8080 as plain HTTP (see
deploy/cloudflared/config.yml). A 10-year
Origin cert matters only if you later switch to a proxied DNS record
pointing at the VM's public IP (no tunnel), in which case front the relay
with nginx using deploy/nginx/ssh-relay.conf
and hand it the Origin cert + key.
- Installation — venv, Docker, systemd.
- Configuration — all
RELAY_*env vars. - Logging and audit events — event schema and sinks.
- Debugging — common failures on both sides of the wire.
Deployment guides:
- Cloudflare Access — Cloudflare Tunnel with an Access Application enforcing the JWT.
- GCP IAP — Managed Instance Group behind an HTTPS Load Balancer with Identity-Aware Proxy.
- nginx — generic reverse proxy with WebSocket upgrade.
- Apache —
mod_proxy_wstunnelfront door.
Example configs and scripts live in deploy/.
Browser Identity-aware proxy Relay Target
(Secure Shell) (CF Access / GCP IAP / (this repo) (sshd)
nginx / apache)
───HTTPS──────▶ ─────HTTPS w/ JWT─────▶ ────TCP────▶
WebSocket upgrade preserved end-to-end
The relay treats the WebSocket payload as opaque SSH bytes and forwards them to
a TCP dial of host:port supplied by the client. Identity is asserted by the
upstream proxy via a signed JWT header; the relay verifies it using the
provider's JWKS.
- Identity is the perimeter. Any authenticated user can SSH to any host reachable from the relay's network. That's usually what you want (replace VPN); if not, place network ACLs between the relay and sensitive targets.
- No destination allowlist. By design. Mix in firewall rules if you need one.
- Session ownership. Reconnect is gated on
identity.principalmatching the original session. A stolen session-id without the same JWT subject cannot resume. - Audit completeness.
session.startis emitted before any bytes flow, so orphan sessions (e.g., a crashing worker) still leave a trail.session.closeis guaranteed on every exit path, including grace-expiry.