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
190 lines
7.0 KiB
Markdown
190 lines
7.0 KiB
Markdown
# Theta42 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 — audited end to end.
|
|
|
|
Two backends, same SSH front door and audit trail: the default mode
|
|
authenticates against the shared LDAP directory and authorizes from the
|
|
[SSO Manager](https://github.com/theta42/sso-manager-node)'s inventory graph;
|
|
**standalone mode** (below) runs with no LDAP or SSO at all, storing users and
|
|
hosts in a local SQL database instead.
|
|
|
|
## Two ways to connect
|
|
|
|
**Direct (WinSCP/SFTP-friendly):**
|
|
|
|
```
|
|
ssh alice_-_web01@jump.example.com # -> host slug 'web01' / 'host_web01'
|
|
sftp -P 2222 alice_-_web01@jump.example.com # SFTP passes through unchanged
|
|
```
|
|
|
|
The username grammar is `{uid}_-_{target}`. `target` is a directory host slug
|
|
(with or without the `host_` prefix), a bare hostname, or an IP.
|
|
|
|
**Interactive picker:**
|
|
|
|
```
|
|
ssh alice@jump.example.com
|
|
```
|
|
|
|
Plain login shows a TUI list of the hosts you can reach; pick one and you're
|
|
bridged straight in.
|
|
|
|
## How it works
|
|
|
|
1. **Inbound auth** — LDAP. Public key (matched against your `sshPublicKey`, the
|
|
jump host's own injected key excluded) or password (LDAP bind; the
|
|
`ssh.passwordAuth` policy can restrict passwords to local clients or disable
|
|
them — keys-only is recommended for a public host).
|
|
2. **Authorization** — the jump host calls the SSO Manager's
|
|
`GET /api/discovery/access/:uid` once per user; the SSO evaluates the
|
|
user's LDAP group memberships server-side and returns their full access
|
|
projection in one response. No directory entry, no access.
|
|
3. **Key injection** — on first use the jump host appends its own public key to
|
|
your `sshPublicKey` in LDAP (comment-marked), then connects downstream **as
|
|
you** using its private key. Downstream hosts already serve keys from LDAP
|
|
via [ldap-client](https://github.com/theta42/ldap-client)'s
|
|
`AuthorizedKeysCommand`, so nothing downstream needs changing.
|
|
4. **Bridge** — shell, exec, and the SFTP subsystem are spliced to the
|
|
downstream sshd. Every session is audited.
|
|
|
|
## Standalone mode
|
|
|
|
Run without LDAP or the SSO Manager at all. Set `standalone.enabled: true` and
|
|
the jump host stores users and hosts itself, via
|
|
[@simpleworkjs/orm](https://www.npmjs.com/package/@simpleworkjs/orm)
|
|
(Sequelize under the hood — defaults to a local SQLite file, but any
|
|
Sequelize-supported dialect works via `conf.orm`):
|
|
|
|
```js
|
|
standalone: { enabled: true },
|
|
orm: { dialect: 'sqlite', storage: './data/standalone.sqlite', logging: false },
|
|
```
|
|
|
|
Everything else — the SSH front door, key injection, bridging, the web UI and
|
|
audit trail — is unchanged; only where users/hosts live and how passwords are
|
|
checked differs. There's no admin UI for standalone users/hosts yet — add them
|
|
with the ORM models directly:
|
|
|
|
```js
|
|
const StandaloneUser = require('./models/standalone_user');
|
|
const StandaloneHost = require('./models/standalone_host');
|
|
const bcrypt = require('bcrypt');
|
|
|
|
await StandaloneUser.create({
|
|
uid: 'alice',
|
|
passwordHash: await bcrypt.hash('a real password', 10),
|
|
sshPublicKeys: ['ssh-ed25519 AAAA... alice@laptop'],
|
|
groups: [],
|
|
});
|
|
|
|
await StandaloneHost.create({
|
|
slug: 'host_web01',
|
|
displayName: 'web01',
|
|
kind: 'host',
|
|
metadata: { ip: '10.0.0.5', sshPort: 22 },
|
|
});
|
|
```
|
|
|
|
In standalone mode every stored host is reachable by every stored user — there
|
|
is no group-based authorization (the `groups` field on `StandaloneUser` is
|
|
accepted for interface parity but not yet enforced).
|
|
|
|
## Requirements
|
|
|
|
*(default LDAP + SSO mode — see [Standalone mode](#standalone-mode) to skip
|
|
all of this)*
|
|
|
|
- The SSO Manager (OpenLDAP directory + `/api/discovery`).
|
|
- Downstream hosts joined via ldap-client (SSSD + `AuthorizedKeysCommand`).
|
|
- An LDAP bind account with **write access to the `sshPublicKey` attribute** on
|
|
user entries (see the ACL note in `secrets.js.example`).
|
|
- An SSO API token (`sso_…`) for the directory queries.
|
|
|
|
## Install
|
|
|
|
### Unified theta-env stack (recommended)
|
|
|
|
Enable it in `theta-env/setup.env` (`CFG_JUMP_HOST_ENABLED=true`) and re-run
|
|
`./setup.sh`. The stack wires the LDAP bind account, the write-ACL, the API
|
|
token, and a directory entry automatically.
|
|
|
|
### Standalone Docker
|
|
|
|
```
|
|
cp secrets.js.example config/jump-secrets.js # then edit it
|
|
docker compose up -d --build
|
|
```
|
|
|
|
### Bare metal
|
|
|
|
```
|
|
curl -fsSL https://raw.githubusercontent.com/theta42/jump-host/master/ops/install.sh | sudo bash
|
|
sudo $EDITOR /etc/jump-host/secrets.js # fill in LDAP + SSO
|
|
sudo systemctl restart jump-host
|
|
```
|
|
|
|
Installs to `/opt/theta42/jump-host`; idempotent (re-run to update).
|
|
|
|
## Ports
|
|
|
|
| Port | Purpose |
|
|
|------|---------|
|
|
| 2222 | SSH front door (default; see below for :22) |
|
|
| 3002 | Web UI + HTTP API (audit, metrics) |
|
|
|
|
The default SSH port is **2222** so the service needs no privilege. To listen on
|
|
22, set `ssh.listenPort: 22` in your secrets and either uncomment
|
|
`AmbientCapabilities=CAP_NET_BIND_SERVICE` in the systemd unit, or DNAT
|
|
22 → 2222 at the firewall.
|
|
|
|
## Web UI / API
|
|
|
|
`https://jump.example.com/` (behind the proxy) — built on the same
|
|
Express + EJS + Bootstrap stack as the [SSO Manager](https://theta42.github.io/sso-manager-node/)
|
|
and [Proxy](https://theta42.github.io/proxy/), so it looks and behaves like the
|
|
rest of the stack. Login is **OIDC against the SSO** (the "Log in with SSO"
|
|
button) plus a **local anti-lockout admin** that works even if the SSO is
|
|
unreachable. Admin access requires membership in `auth.adminGroups` (default
|
|
`app_sso_admin`) or being the local `auth.adminUsers` account.
|
|
|
|
- `GET /health` — open; `{status, activeSessions, version}`
|
|
- `GET /api/sessions` — active sessions
|
|
- `GET /api/audit?page=&uid=&target=&status=` — paged audit log
|
|
- `GET /api/metrics` — counters (total, failures, top users/hosts)
|
|
|
|
## Configuration
|
|
|
|
Config layers via [@simpleworkjs/conf](https://www.npmjs.com/package/@simpleworkjs/conf):
|
|
`conf/base.js` < `conf/<NODE_ENV>.js` < the `CONF_SECRETS` file < `app_*` env.
|
|
See `secrets.js.example` for every key.
|
|
|
|
## Secrets
|
|
|
|
At boot, [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
|
|
deep-merges `secret/jump-host/conf` from **OpenBao** over the file-loaded
|
|
config. The jump host's OIDC `clientSecret` is captured at require time
|
|
(inside `createOidcClient` during `require('../models')`), so `bin/www` runs
|
|
`bao-conf.init()` **before** `require('../models')`. Fail-soft: if OpenBao is
|
|
unreachable, boot continues from `CONF_SECRETS`. The jump host authenticates to
|
|
OpenBao with the scoped `VAULT_TOKEN` (env, policy `jump-host` — read only
|
|
`secret/jump-host/conf`), never the root token.
|
|
|
|
The `config/jump-secrets.js` file is an operator-edit seed artifact
|
|
(gitignored); the bootstrap writes the generated API token + OAuth client
|
|
into OpenBao, which is authoritative. For the full architecture see
|
|
theta-env's **[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
|
|
|
## Development
|
|
|
|
```
|
|
cd nodejs && npm install
|
|
npm test # unit + integration (node --test)
|
|
NODE_ENV=development npm run dev
|
|
```
|
|
|
|
## License
|
|
|
|
MIT
|