Architecture

The jump host is a Node.js service (using 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=<cn> (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 <slug>_access / <slug>_admin groups for every host and service (see the SSO’s Directory & Inventory docs), which is exactly what this authorization reads.

3. Upstream Authentication (PKI or LDAP Keys)

To connect downstream as the user without asking them for a password, the jump host uses one of two methods (configured in conf.ssh):

Option A: PKI Certificates (Recommended) The jump host securely calls the OpenBao (Vault) SSH Secrets Engine API to request a short-lived (e.g. 5-minute), signed SSH certificate for the target user.

  • Zero Touch on Target: The target host simply trusts the OpenBao CA (TrustedUserCAKeys /etc/ssh/ca.pub). No public keys are synced or managed.
  • Ephemeral: The certificate expires automatically.
  • Transparent: The jump host passes cert: signedCert to ssh2.Client, authenticating instantly.

Option B: Legacy LDAP Key Injection If PKI is not configured, the jump host falls back to its legacy method: it holds one keypair. 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 the downstream host’s AuthorizedKeysCommand will accept it, then connects 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 needs write access to the sshPublicKey attribute.
  • Because the marker key is excluded from inbound auth (step 1), it grants only the jump host’s onward path.

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

An Express + EJS + Bootstrap app on :3002 — the same front-end stack and look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a local anti-lockout admin (auth.adminUsers), with admin access gated by auth.adminGroups. It 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 — provides the OpenLDAP directory (users, groups, sshPublicKey) and the inventory API this jump host reads.
  • ldap-client — enrolls the downstream Linux hosts (SSSD/PAM + AuthorizedKeysCommand) that the jump host connects into.
  • Proxy — fronts the jump host’s web UI under TLS.
  • theta-env — wires it all together.