+
+
+
+
+
+
+
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..b6ec287
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,123 @@
+---
+layout: default
+title: Architecture
+description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
+---
+
+# Architecture
+
+The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
+as both an SSH **server** and **client**) with two faces: the SSH front door
+(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
+own — identity, authorization, and onward credentials all come from the shared
+directory.
+
+```
+ ┌────────────────────── jump host ──────────────────────┐
+ ssh │ ssh2 Server (:2222) │ ssh2 Client
+ ─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
+ user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
+ │ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
+ │ 4. bridge channels ◀───────────────────────────────▶ │
+ │ web UI/API (:3002) ──▶ audit + metrics (redis) │
+ └───────────────────────────────────────────────────────┘
+```
+
+## 1. Inbound authentication
+
+When a user connects, the jump host authenticates them against LDAP:
+
+- **Public key** — it looks up the user's `sshPublicKey` values in the directory
+ and matches the offered key (handling ssh2's probe-then-sign two-phase
+ publickey auth). The jump host's *own* injected key (identified by its comment
+ marker) is deliberately excluded from this match — only the jump host may hold
+ that private key, so accepting it inbound would be a bypass.
+- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
+ `off` (keys only — recommended for a public host), `local` (passwords only
+ from loopback/RFC1918 clients, keys-only from the internet), or `all`.
+
+Every attempt — success or failure, with method and reason — is audited.
+
+## 2. Access & target resolution
+
+The hosts a user may reach are computed from the directory, not a local list:
+
+1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
+2. For each group, the SSO's
+ `GET /api/discovery/resources?group=` (authenticated with an API token),
+ unioned and filtered to `kind: host`.
+
+Each host's dial address is `metadata.ip` (or the hostname from
+`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
+briefly per user and shared by both the grammar path and the TUI picker.
+
+Target matching tries, in order: exact slug → `host_`-prefixed slug → display
+name → IP → address hostname. A raw IP that isn't an accessible directory host
+is refused unless explicitly allowed.
+
+> The directory auto-creates `_access` / `_admin` groups for every
+> host and service (see the SSO's
+> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
+> docs), which is exactly what this authorization reads.
+
+## 3. Per-user key injection {#per-user-key-injection}
+
+The jump host holds **one** keypair. To connect downstream *as the user*
+without asking them for anything, it must present a key the downstream `sshd`
+will accept for that user. Downstream hosts (joined via
+[ldap-client](https://github.com/theta42/ldap-client)) serve authorized keys
+straight from LDAP via `AuthorizedKeysCommand`. So on a user's first connection,
+the jump host appends its own public key to that user's `sshPublicKey` attribute
+in LDAP — comment-marked so it's recognizable — then connects downstream with
+its private key.
+
+- Idempotent: the key is added once; a redis flag skips the LDAP round-trip
+ afterwards.
+- The jump host's bind account therefore needs **write access to the
+ `sshPublicKey` attribute** on user entries (an OpenLDAP ACL — see the README).
+ In the bundled theta-env deployment this is handled for you.
+- Because the marker key is excluded from inbound auth (step 1), it grants only
+ the jump host's onward path, never inbound impersonation.
+
+## 4. Bridging
+
+Once the upstream connection is ready, the jump host splices SSH channels
+between the two connections:
+
+- **shell / exec** — piped both ways, with window-change and exit-status
+ forwarded.
+- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
+ no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
+ unchanged.
+- Channel requests that arrive before the upstream is ready are buffered and
+ replayed, so nothing is dropped during the connect.
+- The downstream host key's SHA256 fingerprint is recorded in the audit event
+ (trust-on-use in v1).
+
+Byte counts per direction are tallied cheaply for the audit record.
+
+## Web UI, API & audit
+
+A small Express app on `:3002` (admin login via LDAP, gated by
+`auth.adminGroups`) exposes:
+
+- `GET /health` — open; `{status, activeSessions, version}`
+- `GET /api/sessions` — active sessions
+- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
+- `GET /api/metrics` — counters (total, failures, top users/hosts)
+
+Audit events and counters live in redis. Each event captures: user, auth method,
+mode (grammar/picker), target slug/address/port, channel type, client IP,
+success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
+
+## Where it sits in the stack
+
+- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
+ OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
+ jump host reads.
+- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
+ downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
+ connects into.
+- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
+ under TLS.
+- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
diff --git a/docs/assets/css/style.css b/docs/assets/css/style.css
new file mode 100644
index 0000000..e24a5de
--- /dev/null
+++ b/docs/assets/css/style.css
@@ -0,0 +1,116 @@
+/* theta42 docs site — shares the in-app dark navbar/footer + card look
+ (Bootstrap 5 + Font Awesome, same as the running apps) rather than a
+ generic Jekyll theme. */
+
+body {
+ background-color: #f4f5f6;
+}
+
+.navbar-brand img {
+ filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
+}
+
+.navbar-nav .nav-link.active {
+ color: #fff;
+ font-weight: 600;
+}
+
+/* Markdown content typography, scoped to the card body so it doesn't leak
+ into the nav/footer. */
+.site-content h1:first-child {
+ margin-top: 0;
+}
+
+.site-content h1,
+.site-content h2,
+.site-content h3 {
+ font-weight: 700;
+}
+
+.site-content h2 {
+ margin-top: 2.5rem;
+ padding-bottom: .4rem;
+ border-bottom: 1px solid #e9ecef;
+}
+
+.site-content h3 {
+ margin-top: 1.75rem;
+}
+
+.site-content a {
+ color: #a3671f;
+ text-decoration-color: rgba(163, 103, 31, .35);
+}
+
+.site-content a:hover {
+ color: #8a5a16;
+}
+
+.site-content pre {
+ background-color: #212529;
+ color: #f8f9fa;
+ padding: 1rem 1.25rem;
+ border-radius: .375rem;
+ overflow-x: auto;
+}
+
+.site-content code {
+ color: #a3671f;
+ background-color: #f4f0e8;
+ padding: .15em .4em;
+ border-radius: .25rem;
+ font-size: .875em;
+}
+
+.site-content pre code {
+ color: inherit;
+ background: none;
+ padding: 0;
+}
+
+.site-content table {
+ display: block;
+ overflow-x: auto;
+ width: 100%;
+ border-collapse: collapse;
+ margin: 1.25rem 0;
+}
+
+.site-content table th,
+.site-content table td {
+ border: 1px solid #dee2e6;
+ padding: .5rem .75rem;
+ text-align: left;
+}
+
+.site-content table th {
+ background-color: #f8f9fa;
+}
+
+.site-content blockquote {
+ border-left: 4px solid #C59341;
+ padding: .5rem 1rem;
+ margin: 1.25rem 0;
+ background-color: #f8f6f1;
+ color: #495057;
+}
+
+.site-content img {
+ max-width: 100%;
+ height: auto;
+}
+
+/* Screenshot grids in the markdown use width="49%" inline attrs for a
+ two-up desktop layout -- stack them on narrow screens instead of
+ squeezing to illegibility. */
+@media (max-width: 576px) {
+ .site-content img[width] {
+ width: 100% !important;
+ margin-bottom: .75rem;
+ }
+}
+
+.site-content hr {
+ margin: 2rem 0;
+ border-top: 1px solid #e9ecef;
+}
diff --git a/docs/assets/img/favicon.svg b/docs/assets/img/favicon.svg
new file mode 100644
index 0000000..35e1881
--- /dev/null
+++ b/docs/assets/img/favicon.svg
@@ -0,0 +1,17 @@
+
diff --git a/docs/assets/img/theta42.svg b/docs/assets/img/theta42.svg
new file mode 100644
index 0000000..e598305
--- /dev/null
+++ b/docs/assets/img/theta42.svg
@@ -0,0 +1,51 @@
+
diff --git a/docs/connecting.md b/docs/connecting.md
new file mode 100644
index 0000000..19eedfe
--- /dev/null
+++ b/docs/connecting.md
@@ -0,0 +1,101 @@
+---
+layout: default
+title: Connecting
+description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
+---
+
+# Connecting
+
+You reach a downstream host two ways: name the target in your username, or log
+in plain and pick it from a menu. Either way you authenticate **once**, to the
+jump host, with your directory credentials.
+
+## The username grammar
+
+```
+{uid}_-_{target}
+```
+
+- `{uid}` — your directory username.
+- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
+- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
+ `web01`), the host's **display name**, its **IP**, or the hostname in its
+ directory `address`.
+
+```bash
+ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
+ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
+```
+
+If the target matches a host your directory groups grant, you're bridged
+straight to its `sshd` — same as if you'd SSH'd directly, but through the
+audited jump host.
+
+## SFTP / WinSCP / scp
+
+Because the whole route is encoded in the username, file transfer tools that
+only take one connection string work with no extra configuration:
+
+```bash
+sftp -P 2222 alice_-_web01@jump.example.com
+scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
+```
+
+**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
+to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
+(browse, upload, download, rename) work normally.
+
+## The interactive picker
+
+Log in with just your username and you get a TUI list of every host you can
+reach:
+
+```bash
+ssh alice@jump.example.com
+```
+
+- **↑ / ↓** move the selection
+- **type** to filter the list incrementally
+- **Enter** connect to the highlighted host
+- **number keys** jump straight to that row
+- **q** or **Ctrl-C** to quit
+
+Pick a host and you're bridged into it. The picker only ever lists hosts your
+directory access allows — it doubles as "what can I reach from here?"
+
+## What you can reach
+
+The set of hosts is computed per login: your LDAP group memberships intersected
+with the SSO directory's hosts (via the `host__access` groups the
+directory auto-creates for each machine). To get access to a new host, an admin
+adds you to that host's access group in the SSO — nothing on the jump host
+changes.
+
+Targets that don't resolve to a host you're allowed to reach are refused (and
+audited). Raw IPs that aren't a known directory host are denied by default.
+
+## Authentication
+
+The jump host authenticates **you** against the directory:
+
+- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
+ normal SSH key; the client picks it automatically.
+- **Password** — your directory password (LDAP bind). Password auth is often
+ restricted to local networks or disabled entirely on a public jump host
+ (keys-only) — check with your operator.
+
+You never manage a separate credential for the downstream host: the jump host
+handles onward authentication for you (see
+[Architecture](architecture.html#per-user-key-injection)).
+
+## First connection to a host
+
+The very first time you reach a given downstream host, the jump host provisions
+its access key for you behind the scenes. If that first attempt races the
+directory's key-cache refresh you may see a brief
+
+```
+jump-host: first-time key propagation, retrying…
+```
+
+and it reconnects automatically. Subsequent connections are immediate.
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 0000000..6c3bd45
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,104 @@
+---
+layout: default
+title: Home
+description: An SSH jump host for the theta42 stack — one public host, LDAP login, and directory-driven access to every downstream machine you're entitled to.
+---
+
+# Jump Host
+
+An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
+stack. Users SSH into **one** public host and land on any downstream host
+they're entitled to — authenticated against the shared LDAP directory,
+authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
+inventory graph, and audited end to end.
+
+No per-host accounts, no distributing keys, no VPN. The same people who log in
+to your SSO are the people who can reach your machines — and only the machines
+their directory groups grant.
+
+Part of the theta42 self-hosted identity stack, alongside
+[SSO Manager](https://theta42.github.io/sso-manager-node/) and
+[Proxy](https://theta42.github.io/proxy/), composable with one command via
+[theta-env](https://theta42.github.io/theta-env/).
+
+## Two ways to connect
+
+**Direct (WinSCP/SFTP-friendly):**
+
+```bash
+ssh alice_-_web01@jump.example.com
+sftp -P 2222 alice_-_web01@jump.example.com
+```
+
+The username grammar is `{uid}_-_{target}` — `target` is a directory host slug
+(with or without the `host_` prefix), a bare hostname, or an IP. One username
+string, no interactive step, so it works cleanly in WinSCP and scripts.
+
+**Interactive picker:**
+
+```bash
+ssh alice@jump.example.com
+```
+
+A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
+filter, Enter to connect.
+
+See **[Connecting](connecting.html)** for the full usage guide.
+
+## Why a jump host (and why this one)
+
+A bastion/jump host is the standard way to give SSH access to internal machines
+through a single audited entry point. What's usually painful is *authorization*
+and *credentials*: who may reach which host, and how the bastion authenticates
+onward without you copying keys everywhere.
+
+This jump host answers both from your directory:
+
+- **Authorization is your directory graph.** The hosts you can reach are the
+ union of your LDAP groups × the SSO's inventory (the `host__access`
+ groups the directory already auto-creates). Add someone to a group; they can
+ reach the host. No bastion-side allow-list to maintain.
+- **Onward auth is automatic.** The jump host holds one key and injects its
+ public half into your `sshPublicKey` on first use, then connects downstream
+ **as you**. Downstream hosts already serve keys from LDAP (via
+ [ldap-client](https://github.com/theta42/ldap-client)'s
+ `AuthorizedKeysCommand`), so nothing downstream needs configuring.
+
+## Features
+
+- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
+ SFTP included (WinSCP works)
+- **Interactive TUI host picker** on plain login, scoped to your access
+- **LDAP inbound auth** — public key or password (keys-only policy recommended
+ for a public host)
+- **Directory-driven access** — reachable hosts come from the SSO inventory, not
+ a static list
+- **Per-user key injection** — no downstream changes, no key distribution
+- **Shell, exec, and SFTP** bridging
+- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
+ audit log, per-user/per-host counters
+- **Full audit trail** — who, target, method, result, bytes, duration, and the
+ downstream host-key fingerprint
+- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
+ installer, or bundled in theta-env
+
+## Get it
+
+```bash
+git clone https://github.com/theta42/jump-host.git
+cd jump-host
+cp secrets.js.example config/jump-secrets.js # then edit it
+docker compose up -d --build
+```
+
+That's the standalone quick start. For bare-metal and the bundled theta-env
+option, see **[Installation](installation.html)**.
+
+## Related projects
+
+- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
+ directory + OIDC provider + the inventory graph this jump host reads.
+- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
+ same identity; fronts this jump host's web UI.
+- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
+ jump host included, with one command.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..417a453
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,109 @@
+---
+layout: default
+title: Installation
+description: Install the jump host three ways — bundled in the theta-env stack, standalone Docker, or bare metal — plus the required LDAP write-ACL and port-22 options.
+---
+
+# Installation
+
+Three ways to run the jump host, in increasing manual effort. All read their
+config through [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf)
+(`conf/base.js` < `conf/.js` < the `CONF_SECRETS` file < `app_*` env).
+
+## Requirements
+
+- The [SSO Manager](https://theta42.github.io/sso-manager-node/) (OpenLDAP
+ directory + `/api/discovery`), v1.3.0 or newer.
+- Downstream hosts joined via
+ [ldap-client](https://github.com/theta42/ldap-client) (SSSD +
+ `AuthorizedKeysCommand`).
+- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
+ user entries (see below).
+- An SSO API token (`sso_…`) for the directory queries.
+
+## 1. Unified theta-env stack (recommended)
+
+Enable it in `theta-env/setup.env`:
+
+```bash
+CFG_JUMP_HOST_ENABLED=true
+CFG_JUMP_HOST=jump.example.com
+JUMP_SSH_PORT=2222
+```
+
+Re-run `./setup.sh`. The stack builds the submodule (behind the `jump-host`
+compose profile), mints the directory API token, writes
+`./config/jump-secrets.js`, grants the `sshPublicKey` write-ACL, registers the
+jump host in the proxy, and seeds a directory entry. Forward the public host's
+`:22` (or `:2222`) to the container's published `JUMP_SSH_PORT`.
+
+## 2. Standalone Docker
+
+```bash
+cp secrets.js.example config/jump-secrets.js
+$EDITOR config/jump-secrets.js # LDAP bind (+ sshPublicKey write ACL), SSO url + token
+docker compose up -d --build
+```
+
+Host keys persist in the `jump-data` volume. The web UI is on `:3002`; front it
+with your own TLS/proxy.
+
+## 3. Bare metal
+
+```bash
+curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
+sudo $EDITOR /etc/jump-host/secrets.js
+sudo systemctl restart jump-host
+journalctl -u jump-host -f
+```
+
+`ops/install.sh` installs Node 22 + Redis, hard-resets the checkout at
+`/opt/theta42/jump-host` to the remote branch, symlinks the systemd unit, and
+runs `npm ci`. Idempotent — re-run to update. Overridable via `REPO_DIR=`,
+`BRANCH=`, `SECRETS_FILE=`.
+
+## The LDAP write-ACL (required)
+
+The jump host injects its public key into each user's `sshPublicKey`, so its
+bind account must be able to **write** that attribute. In the bundled OpenLDAP:
+
+```
+access to attrs=sshPublicKey
+ by dn.exact="cn=ldapclient,ou=people,dc=example,dc=com" write
+ by self write
+ by * read
+```
+
+In the theta-env bundle this is handled for you (the jump host binds as the LDAP
+admin). For a hardened standalone deployment, use a dedicated bind account with
+exactly this attribute-scoped ACL. Without write access, key injection fails and
+every bridge attempt is audited `key-inject-failed`.
+
+## Listening on port 22
+
+The default SSH port is **2222** so the service needs no privilege. To listen on
+22, set `ssh.listenPort: 22` and either:
+
+- **systemd:** uncomment `AmbientCapabilities=CAP_NET_BIND_SERVICE` in the unit;
+- **Docker:** publish `22:22`; or
+- **firewall:** DNAT `22 → 2222`.
+
+## Configuration reference
+
+Every key is documented in
+[`secrets.js.example`](https://github.com/theta42/jump-host/blob/master/secrets.js.example):
+`ldap` (bind + bases + TLS), `sso` (url + apiToken), `ssh`
+(`listenPort`, `passwordAuth`, `allowRawIPs`, `keyComment`, timeouts,
+`maxSessions`), `web.port`, `auth.adminGroups`, and `redis`.
+
+## Verifying
+
+```bash
+ssh -p 2222 youruid@jump.example.com # TUI picker
+ssh -p 2222 youruid_-_somehost@jump.example.com # direct
+sftp -P 2222 youruid_-_somehost@jump.example.com # WinSCP path
+curl -s http://localhost:3002/health
+```
+
+Watch `journalctl -u jump-host -f` (or `docker logs -f jump-host`) and the audit
+log at `/audit` in the web UI.
diff --git a/docs/robots.txt b/docs/robots.txt
new file mode 100644
index 0000000..bd503ab
--- /dev/null
+++ b/docs/robots.txt
@@ -0,0 +1,4 @@
+User-agent: *
+Allow: /
+
+Sitemap: https://theta42.github.io/proxy/sitemap.xml
diff --git a/nodejs/package.json b/nodejs/package.json
index 15f2540..903d2b3 100644
--- a/nodejs/package.json
+++ b/nodejs/package.json
@@ -14,9 +14,9 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
- "test": "NODE_ENV=test node --test --test-force-exit 'test/**/*.test.js'",
- "test:unit": "NODE_ENV=test node --test --test-force-exit 'test/unit/**/*.test.js'",
- "test:integration": "NODE_ENV=test node --test --test-force-exit 'test/integration/**/*.test.js'"
+ "test": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js test/integration/*.test.js",
+ "test:unit": "NODE_ENV=test node --test --test-force-exit test/unit/*.test.js",
+ "test:integration": "NODE_ENV=test node --test --test-force-exit test/integration/*.test.js"
},
"dependencies": {
"@simpleworkjs/conf": "^1.2.0",