24a6a718e0
README.md and docs/architecture.md described authorization as a client-side loop over each of a user's LDAP groups, calling the SSO's GET /api/discovery/resources?group=<cn> once per group. The actual code (utils/access.js, accessibleHosts()) makes a single call to the SSO's GET /api/discovery/access/:uid, which resolves the user's groups server-side and returns the full access projection in one response. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
150 lines
7.7 KiB
Markdown
150 lines
7.7 KiB
Markdown
---
|
|
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:
|
|
the jump host calls the SSO's `GET /api/discovery/access/:uid` (authenticated
|
|
with an API token) once per user; the SSO evaluates the user's LDAP group
|
|
memberships server-side and returns the full access projection in one
|
|
response, already filtered to `kind: host`. (The jump host also has an
|
|
admin-only `allHosts()` path, used for the unfiltered catalog listing, which
|
|
does call `GET /api/discovery/resources?group=<cn>` per group — but that's
|
|
not the per-user authorization path.)
|
|
|
|
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. 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
|
|
|
|
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.
|
|
|
|
## Standalone mode
|
|
|
|
Everything above describes the default backend. Set `standalone.enabled: true`
|
|
and two modules become conditional facades, swapping their entire
|
|
implementation at `require` time based on that flag — nothing else in the
|
|
codebase (`ssh_server.js`, `bridge.js`, `key_inject.js`, `tui_picker.js`, the
|
|
web UI) changes or even knows which mode it's running in:
|
|
|
|
- **`models/user_ldap.js`** — LDAP client, or `models/user_file.js` (an
|
|
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)-backed
|
|
store implementing the same `getUser` / `getGroups` / `checkPassword` /
|
|
`addSshKey` interface).
|
|
- **`utils/access.js`** — LDAP groups + SSO `/api/discovery`, or
|
|
`utils/hosts_file.js` (same ORM package, same `accessibleHosts()` interface).
|
|
In standalone mode there's no group-based authorization: every stored host
|
|
is accessible to every stored user.
|
|
|
|
The ORM is Sequelize underneath, defaulting to a local SQLite file but
|
|
accepting any Sequelize-supported dialect via `conf.orm`. See
|
|
[Installation](installation.html#standalone-mode) for config and how to add
|
|
users/hosts (there's no admin UI for standalone data yet).
|
|
|
|
## 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.
|