A personal homelab web-based SSH terminal. Open a browser, unlock with your master password, and SSH into any machine — no client software required.
- Browser-based SSH terminal — full xterm.js terminal emulator with color, resize, and hyperlink support
- Connection manager — save named connections; click to open an instant terminal tab; edit or delete from the sidebar
- Credential manager — save reusable credentials (username + auth + secret) and link them to multiple connections; update once, applies everywhere
- Multiple tabs — open several SSH sessions simultaneously, switch between them freely
- Encrypted storage — SSH secrets and credentials are AES-256-GCM encrypted at rest in SQLite; master password never stored
- Master password — Argon2id key derivation; the master password is never stored, only an HMAC verification token
- Web unlock page — container starts locked; unlock via browser on first visit
- Network scanner — scan a subnet for SSH servers (port 22); results stream live with reverse-DNS hostnames; one click to add a discovered host as a saved connection
- Admin panel — view active sessions, kill sessions, lock the server, monitor uptime
- Docker deployment — single container, multi-stage image, non-root runtime user
- Nginx Proxy Manager ready — plain HTTP inside the container; SSL/TLS terminates at NPM
- Makefile operations —
make start,make logs,make backup,make maintain
- Docker + Docker Compose
- (Optional) Nginx Proxy Manager for SSL/TLS
git clone https://github.com/hornbech/sshweb.git
cd sshwebcp .env.example .envEdit .env if needed (defaults are fine for most homelab setups):
PORT=3000
DATA_DIR=/data
SESSION_TIMEOUT_MINUTES=60
MAX_SESSIONS=10
LOG_LEVEL=infomake start
# or: docker compose up -dVisit http://localhost:3000. You will see the unlock page.
First run: The page shows a "Set master password" form with a confirm field. Choose a password — this becomes your master password. It initialises the encrypted store and is required on every subsequent server start.
Subsequent runs: The page shows the standard unlock form. Enter the same master password you chose on first run.
The master password is never stored. If you forget it, the only recovery is to delete
data/saltanddata/verify(this wipes all saved connections) and start fresh.
Click the magnifying glass button in the sidebar header to open the scanner.
- Enter your subnet in CIDR notation — e.g.
10.0.0.0/24 - Click Scan — results appear live as hosts with port 22 open are found
- Each result shows the IP and reverse-DNS hostname (where available)
- Click Add on any result to pre-populate the new connection form
Note: The scanner runs inside the Docker container. On Linux it can reach your LAN through the Docker bridge. The subnet auto-fill is empty when the container's bridge network is wider than /22 — just type your subnet manually.
Accepted subnet sizes: /22 to /30 (up to 1022 hosts). Scans are rate-limited to 5 per IP per 5 minutes.
Browse internal admin UIs (Pi-hole, Portainer, router pages, NAS dashboards) directly inside sshweb without opening a separate browser tab.
A built-in HTTP proxy (/proxy/<url>) fetches upstream pages and rewrites URLs so links, images, and stylesheets route back through the proxy. Pages render inside an iframe with a URL bar and back/forward/reload controls.
Click the + button in the Web sidebar section. Enter a label and the full URL (e.g. http://192.168.1.5/admin). Bookmarks are saved in SQLite and appear in the sidebar — click one to open a web tab.
- Private IPs only — the proxy enforces RFC 1918, loopback, and link-local addresses. Public IPs and hostnames that resolve to public addresses are blocked (403). DNS is resolved and pinned per request to prevent rebinding attacks.
- HTML/CSS admin UIs — URL rewriting covers HTML links, CSS
url(), and standard redirects. An injected client script interceptsXMLHttpRequest,fetch, andcreateElementto rewrite dynamically constructed URLs. JavaScript-heavy SPAs (e.g. Synology DSM) generally work, though deeply dynamic single-page apps with client-side routing may have edge cases. - No WebSocket proxying — upstream WebSocket connections are rejected (501). Most admin UIs don't need them.
- No file downloads through the iframe — use a direct browser tab for large file downloads.
- Cookies are browser-managed — unblocker rewrites upstream
Set-Cookiepaths to/proxy/http://host:port/, naturally scoping cookies per target site. Upstream JavaScript can readdocument.cookiefor session tokens and CSRF tokens. Cookies persist in the browser for the duration of the sshweb browser session.
The proxy accepts self-signed and expired certificates automatically. Since all targets are restricted to private IPs, the network boundary is the trust model — not TLS certificate validation. This means HTTPS admin UIs on homelab gear (routers, NAS, Proxmox, etc.) work out of the box without any certificate configuration.
- Page doesn't load in the iframe — some servers send
X-Frame-Options: DENYor restrictiveContent-Security-Policy frame-ancestors. The proxy strips these headers (and the entire upstream CSP) automatically. - Login doesn't persist across reloads — cookies are stored in the browser scoped to
/proxy/http://host:port/. They persist across page reloads but are cleared when you close the browser or clear cookies. - Links go to the wrong place — some deeply dynamic single-page apps may compute URLs that bypass the proxy's rewriter. Most admin UIs (Pi-hole, Synology DSM, router pages) work well.
- Add a Proxy Host pointing to
http://<host-ip>:3000 - In the Advanced tab, enable WebSocket Support
- Add your SSL certificate on the SSL tab
The app handles HTTP internally; NPM provides HTTPS and the WSS upgrade.
All configuration is via environment variables (.env file):
| Variable | Default | Description |
|---|---|---|
PORT |
3000 |
HTTP port the server listens on |
DATA_DIR |
/data |
Directory for encrypted DB and key files |
SESSION_TIMEOUT_MINUTES |
60 |
Idle session expiry; activity slides the window |
MAX_SESSIONS |
10 |
Maximum concurrent SSH sessions |
SSH_KEEPALIVE_INTERVAL |
15 |
Seconds between SSH keepalive packets; prevents idle disconnects from the server or NAT/firewall |
LOG_LEVEL |
info |
Pino log level: trace, debug, info, warn, error, fatal, silent |
make start # Start container (docker compose up -d)
make stop # Stop container
make logs # Tail container logs
make restart # Restart container
make shell # Open shell inside containermake backup # Copies ./data to ./data.backup-YYYYMMDD-HHMMSSThe data/ directory contains everything needed to restore: the encrypted connections database, the Argon2id salt, and the HMAC verification token. Back this directory up regularly.
make update # Rebuild image and redeploy (docker compose build && up -d)make maintain # npm audit + check outdated packages + rebuild with latest base imageThis single command:
- Runs
npm auditto check for vulnerabilities - Shows outdated npm packages (
npm-check-updates) - Rebuilds the Docker image pulling the latest
node:24-alpinepatch
To apply package updates:
make upgrade-deps # Bumps package.json versions + npm install + npm audit
make update # Rebuild container with updated packages- Node.js 24+
npm install
cp .env.example .env
# Edit DATA_DIR=./data in .env for local devmake dev # Starts Node server (--watch) + Vite dev server concurrentlyThe Vite dev server proxies /api and /ws to the Node backend.
make test # Runs all unit tests (node --test)Tests cover: config validation, AES-256-GCM crypto, Argon2id master key lifecycle, and HTTP server routes.
make build # Vite production build → dist/SSH passwords and private keys are encrypted individually using AES-256-GCM before being written to the SQLite database. Each secret gets a unique random IV. The authentication tag prevents tampering.
The master password is processed through Argon2id (memory: 64 MB, iterations: 3, parallelism: 1) to derive a 256-bit encryption key. Parameters are intentionally expensive to resist brute-force attacks against a stolen database.
The derived key is held only in memory for the lifetime of the server process. On container restart, the key is gone — the server starts locked and requires the master password to be re-entered via the browser.
What is stored on disk:
data/salt— random 32-byte Argon2id salt (not secret; required to reproduce the key)data/verify— HMAC-SHA256 of a fixed string keyed by the derived key (used to verify the correct password without storing the key)data/connections.db— SQLite database with AES-256-GCM encrypted secrets
The master password itself is never written to disk.
Every non-proxy response includes:
| Header | Value |
|---|---|
Content-Security-Policy |
Restricts scripts/styles/workers to 'self'; blocks frames |
X-Content-Type-Options |
nosniff |
X-Frame-Options |
SAMEORIGIN |
Strict-Transport-Security |
max-age=31536000; includeSubDomains |
Referrer-Policy |
no-referrer |
Permissions-Policy |
Disables camera, microphone, geolocation |
X-Powered-By is suppressed. Proxied responses (/proxy/*) skip helmet entirely — upstream pages need eval(), inline scripts, and other features that the strict CSP would block. The private-IP guard is the trust boundary for proxied content.
Unlocking the server issues a session cookie (HttpOnly, Secure, SameSite=Strict, 64-char hex token derived from 32 random bytes). Every subsequent HTTP request and WebSocket upgrade is validated against an in-memory session store. Sessions use a sliding expiry window — each validated request resets the clock. Sessions are invalidated on lock or password change.
Unauthenticated API requests receive 401 Unauthorized. Unauthenticated browser navigation is redirected to /unlock.
POST /api/unlock is rate-limited to 10 failed attempts per IP per 15 minutes. Subsequent attempts receive 429 Too Many Requests. The real client IP is resolved via X-Forwarded-For when running behind a reverse proxy.
POST /api/lock rejects requests with a cross-origin Origin header. This prevents a malicious page from locking the server via a browser-initiated request. Direct API calls without an Origin header are unaffected.
POST /api/lockhas no password requirement — a direct (non-browser) caller can lock the server. Accepted trade-off for a single-user homelab; add network-level access control if this is a concern.GET /api/connectionsreturns saved host labels and IPs while the server is unlocked. No secrets are included.
The app speaks plain HTTP internally. TLS is expected to be terminated by a reverse proxy (Nginx Proxy Manager, Traefik, Caddy). Do not expose port 3000 directly to the internet without TLS.
Browser (xterm.js)
↕ WebSocket (ws:// or wss:// via NPM)
Express + ws server (Node.js)
↕ SSH2 stream (PTY)
Remote SSH server
sshweb/
├── server/
│ ├── config.js # dotenv config with validation
│ ├── crypto.js # AES-256-GCM encrypt/decrypt
│ ├── masterkey.js # Argon2id key derivation + lock/unlock
│ ├── store.js # Encrypted SQLite connection store
│ ├── bookmarks.js # SQLite bookmark store for web tabs
│ ├── cookiejars.js # Per-session cookie jar isolation
│ ├── netguard.js # Private-IP guard with DNS classification
│ ├── webproxy.js # Unblocker-backed HTTP proxy pipeline
│ ├── ssh.js # SSH session manager (ssh2)
│ └── index.js # Express server + WebSocket handler
├── client/
│ ├── index.html # Main app shell
│ ├── unlock.html # Master password unlock page
│ ├── main.js # xterm.js, tabs, connection manager, admin panel
│ └── style.css # Dark theme
├── data/ # Persistent volume (mount here)
├── tests/ # Unit tests
├── docs/plans/ # Design and implementation documents
├── Dockerfile # Multi-stage build
├── docker-compose.yml
├── Makefile
└── .env.example
The container entrypoint automatically chowns /data to the sshweb user before starting. If you see a 500 error instead of a successful first-run unlock, check container logs:
make logsA permission error on /data will appear in the log. Ensure the bind-mount path is accessible and restart with make start.
There is no recovery. Delete data/salt and data/verify to reset. This also deletes all saved connections (the encrypted database is now unreadable without the original salt).
# Change PORT in .env
PORT=3001
make restartThe health check has a 15-second start period. If the container is still unhealthy after 15 seconds, check logs:
make logsEnsure WebSocket Support is enabled in the Nginx Proxy Manager proxy host Advanced tab.
MIT
