A reference implementation of the SSH-Web protocol — an authenticated, encrypted transport layer for the web built on SSH.
SSH-Web replaces HTTP as the browser-to-server transport with a constrained SSH-based protocol. Instead of bolting encryption, authentication, and privacy onto a stateless plaintext protocol, SSH-Web starts from SSH's encrypted, authenticated foundation and builds web semantics on top.
Key properties:
- Always encrypted — no opt-in TLS, no certificate authorities
- Identity via keypair — no passwords, no OAuth, no third-party identity providers
- Structural privacy — third parties never see the client; external resources are server-proxied
- Unified interface — website, API, and agent interface are one thing, discoverable from a single
capabilitieshandshake - Efficient delivery — Git packfile format with delta compression and incremental updates
sshttpd is the role of Caddy/nginx for the SSH-Web protocol: a deployable daemon you put in front of an existing application backend.
- Static content is served directly from a configured
rootas Git packfiles. - Dynamic API requests are forwarded as plain HTTP to a configured
backend(api-call POST /api/items↔POST http://localhost:8080/api/items). - MCP tool invocations are forwarded the same way (
mcp submit_story title=... url=...↔POST http://localhost:8080/mcp/submit_story). - External resource fetches are proxied through an allowlisted, TTL-cached fetcher so third parties never see the client.
- Identity is supplied by the user's SSH keypair; tiers (anonymous/identified/trusted) are enforced per command.
- Discovery (RSS feeds, sitemap, robots policy) is structured JSON/Atom served by sshttpd directly, surfaced in the
capabilitiesmanifest. - Multi-site hosting: one
sshttpdinstance can serve any number ofsite { … }blocks, each on its own port, with its own host key, content root, and auth.
Phase 1: Proof of Concept — substantially complete.
- SSH transport with constrained command execution
-
capabilitiesmanifest discovery (now surfaces all configured surfaces) -
receive-packstatic site delivery via real PACK v2 binary -
api-calldynamic request handling — forwarded to a configured HTTP backend -
proxy-callexternal resource proxying with allowlist + cache - Configuration file parsing (with
backend,authorized-keys, multi-site) - Authentication tiers (anonymous, identified, trusted) — enforced
-
authorized-keysfile withtier=trusted/tier=identifiedoverrides - Rate limiting per tier (token-bucket)
-
mcptool dispatch with parameter validation + backend forwarding -
rss-feed,sitemap,robotsdiscovery commands - Multi-site listeners on distinct ports
- Identity wiring: browser identity selection → SSH publickey auth end-to-end
- Key registration flow with dynamic
authorized-keysreload - Server-side redirects (302) with URL bar update in browser
- Pubkey + display name forwarding to backend via
X-SSHWeb-PubKey - Tier-gated content (per-post access control in demo backend)
- SSH connection pool clear on page reload (fresh auth on navigate)
-
stdiocommand handlers — spawn processes with stdin/stdout wired to SSH channel, bypassing HTTP framing entirely (CGI-over-SSH) - File downloads —
Content-Disposition: attachmenthandling in browser, save dialog - Incremental
receive-pack(--haveover the wire) - On-disk
proxy-cachestorage (currently in-memory) - SNI-style routing of multiple sites on a shared port
- Transport obfuscation (
transport obfs-tls) — wrap SSH handshake to look like TLS traffic, defeating DPI (inspired by Tor pluggable transports / Shadowsocks) - Client-side command aliases and scripts (
~/.config/ssh-web/commands.conf) — spec §6.1 - Per-site config overrides (identity, aliases, watch) — spec §6.2
- Hardware-bound keys (YubiKey/TPM) and key revocation lists — spec §6.3
See CHANGELOG.md for a complete change log.
cmd/sshttpd/ Entry point for the daemon
internal/
server/ Multi-site listener, ssh handshake, exec/shell sessions
config/ Configuration parser
commands/ Command handlers (capabilities, receive-pack, api-call,
proxy-call, rss-feed, sitemap, robots, mcp)
packfile/ Git PACK v2 generation + delta computation
auth/ Tier classification, authorized-keys parser,
command-allowed matching (wildcards, qualifiers)
proxy/ External-resource fetcher with allowlist + TTL cache
backend/ HTTP forwarder for api-call and mcp
ratelimit/ Token-bucket per auth tier
spec/ Protocol specification
docs/browser/ SSH-Web browser (Ladybird fork) architecture and design
docs/server/ sshttpd server documentation
examples/ Example site configuration
go build -o sshttpd ./cmd/sshttpd
# With version stamp:
go build -ldflags "-X main.version=0.1.0" -o sshttpd ./cmd/sshttpdTests:
go test ./internal/...# (Optional) generate a host key — sshttpd will auto-generate one if missing.
ssh-keygen -t ed25519 -f /etc/sshttpd/keys/host_ed25519 -N ""
# Start the daemon.
./sshttpd -config examples/sshttpd.confBy default sshttpd listens on the port from each site block (22443 unless overridden). Connect with any SSH client to verify:
ssh -p 22443 localhost capabilities
ssh -p 22443 localhost "receive-pack /" | xxd | head # raw PACK bytes
ssh -p 22443 localhost sitemap
ssh -p 22443 localhost robots
ssh -p 22443 localhost "rss-feed /feeds/posts"The full annotated example lives at examples/sshttpd.conf. Minimal example:
site example.com {
port 22443
host-key /path/to/host_ed25519
root /var/www/example
backend http://localhost:8080 # forwards api-call + mcp here
commands {
receive-pack /
api-call GET /api/items
api-call POST /api/items
}
auth {
anonymous [receive-pack, api-call GET]
identified [api-call POST]
trusted [admin-*]
}
limits {
anonymous 60/min
identified 300/min
trusted unlimited
}
}
site example.com {
authorized-keys /etc/sshttpd/authorized_keys
...
}
authorized_keys format (one entry per line, optional tier= prefix):
# Default tier for any presented key is `identified`.
ssh-ed25519 AAAAC3Nz... regular-user@example
# Override to put a key in the trusted tier.
tier=trusted ssh-ed25519 AAAAC3Nz... admin@example
When backend http://... is set, sshttpd forwards SSH-Web commands to the upstream as plain HTTP. The application doesn't need to know anything about SSH-Web.
api-call POST /api/items {"title":"hi"}
↓
POST http://backend/api/items
Content-Type: application/json
X-SSHWeb-Identity: <key fingerprint or "">
{"title":"hi"}
mcp submit_story title=hi url=https://x
↓
POST http://backend/mcp/submit_story
Content-Type: application/json
{"title":"hi","url":"https://x"}
This is the path that lets you wrap an existing Rails/Express/Django/etc. app and serve it over SSH-Web with no changes to the application code.
A reference SSH-Web browser (a fork of Ladybird) is included as a git submodule. Architecture and design docs live in docs/browser/.
The full protocol specification is at spec/SSH-WEB-SPEC.md.
Apache-2.0 — see LICENSE.