---
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=<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](https://theta42.github.io/sso-manager-node/directory.html)
> docs), which is exactly what this authorization reads.

## 3. Upstream Authentication (PKI or LDAP Keys) {#upstream-authentication}

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](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.
