Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea5d2350a4 | |||
| 050ff87a0b | |||
| bc87d4e381 | |||
| 7efc271938 | |||
| 4bf875e841 | |||
| 6479d35fb8 | |||
| 3ca4802075 | |||
| 86026e90e7 | |||
| 3354407f04 | |||
| 84d7c96c17 | |||
| 72046a8b29 | |||
| 73e1cc807a |
+167
@@ -8,6 +8,173 @@ orchestration code; see each submodule's own `CHANGELOG.md`
|
||||
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md))
|
||||
for what changed inside the apps it composes.
|
||||
|
||||
## [v1.44.0] - 2026-08-06
|
||||
|
||||
Rolls up **sso-manager-node v1.30.1**.
|
||||
|
||||
### sso-manager-node v1.30.1
|
||||
|
||||
**Test Email and Test SMS could never have worked, and all SMS delivery was broken.**
|
||||
|
||||
- Test Email threw `Email.send is not a function`: `models/email.js` exports `{Mail}`, and the handler required the module and called `.send` on it directly.
|
||||
- Test SMS threw `Unexpected token '<', "<!DOCTYPE "...`: it POSTed to `https://api.voip.ms/v1.0/sms/send`, an endpoint that does not exist. VoIP.ms's REST API is a GET against `voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw.
|
||||
- **Every SMS was broken, not just the test.** `models/sms.js` called `PluginInstance.find({…})`, but the ORM has no `find` — the query method is `list({where})`. It threw on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||
- Both test endpoints now send through the same senders every real message uses. A test that reimplements delivery proves nothing about whether real delivery works — which is how two independently broken paths went unnoticed. Failures report as `400` with the underlying reason instead of an opaque `500`.
|
||||
- New guard suite fails the build on any call to a non-existent ORM static, on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||
|
||||
**Install Agent offers the join-key flow.** v1.43.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow. It now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself.
|
||||
|
||||
### Release note
|
||||
|
||||
Tagged with GitHub Actions in a major outage. CI could not run (every job failed at *Set up job* with `Failed to resolve action download info: Service Unavailable`, before reaching any test). Verified locally instead, on the exact merged commit: the full Docker suite — same LDAP + Redis service containers CI uses — passed **299/299**, plus proxy 176/176, jump-host 43/43 and theta-agent green. The Node 18/20/22 matrix was not exercised.
|
||||
|
||||
## [v1.43.0] - 2026-08-06
|
||||
|
||||
Rolls up **sso-manager-node v1.30.0**, **theta-agent v1.5.1**, **proxy v1.35.0**. Fixes what a fresh `setup.sh` install actually produced under v1.42.0.
|
||||
|
||||
> **No manual step to re-enroll agents.** v1.42.0 required an admin to pre-register every host. `setup.sh` now mints a **join key** and the agent enrolls itself, so installing the agent is once again all it takes to add a host.
|
||||
|
||||
### Fixed — theta-suite orchestration
|
||||
|
||||
- **The stack's own theta-agent could never connect.** `setup.sh` generated a random token locally and wrote it into `agent.yml`. The SSO only accepts credentials it issued, so that token was rejected on every attempt and the agent looped on `close 4001: Unauthorized` forever. It now writes a join key the SSO minted; the agent exchanges it for its own token and the SSO's public key on first connect and rewrites its own config.
|
||||
- **`agent.yml` was left holding literal placeholders.** The `REPLACE_WITH_ISSUED_AGENT_TOKEN` / `REPLACE_WITH_SSO_PUBLIC_KEY` strings were shipped as-is when the seds no longer matched the renamed fields, so the file on a fresh install contained no credential at all. The file is also `chmod 600` now that it holds one.
|
||||
- **A fresh install presented its own five containers as unmanaged discoveries** (`theta-proxy`, `theta-jump`, `sso-manager`, `bao-renewer`, `openbao`). The compose project name is now passed to the Docker discovery plugin, which recognises them as ours and links each to the service it implements.
|
||||
- **`openbao` and `bao-renewer` had no directory entries**, so their containers had nothing to attach to and appeared as parentless roots. Both are seeded as services now — they are part of what the stack deploys and belong in the directory like every other component.
|
||||
|
||||
### Added
|
||||
|
||||
- The bootstrap mints a theta-agent join key and hands it to `setup.sh` (`AGENT_JOIN_KEY`), reusing the `setup`-labelled key across runs.
|
||||
|
||||
---
|
||||
|
||||
### sso-manager-node v1.30.0
|
||||
|
||||
**Join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out; a host presenting it is enrolled automatically and immediately issued its **own** per-agent token plus the public key to pin. The join key is a bootstrap credential, never the host's identity — one key stays convenient without becoming a fleet-wide skeleton key, every host remains individually revocable, and revoking a key stops new hosts joining without touching enrolled ones.
|
||||
|
||||
**Collapsing the Directory tree did nothing.** `applyTreeCollapse` found the caret with `.tree-caret i` and returned early when absent — Font Awesome's SVG-with-JS mode rewrites `<i>` to `<svg>`, so that selector matched nothing and the early return skipped setting `hideBelowDepth`, meaning no row was ever hidden. State now lives on the caret button, rotated by CSS.
|
||||
|
||||
**Discovery Plugins.** The delete button called `deleteDiscoveryPlugin()`, which was never defined. The pane also had no `.actionMessage`, and confirmations render into one — without it the promise never settles, so an awaited confirmation hangs forever and the gated action silently never happens. Instances can now be edited (secrets shown blank rather than prefilled with the mask).
|
||||
|
||||
**Discovery.** Docker container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one; they now derive from compose project + service.
|
||||
|
||||
**Docs.** `/docs/discovery` 404'd; new `docs/discovery.md`. The `agents` slug pointed at `plugins.md`, leaving `docs/agents.md` unreachable in-app.
|
||||
|
||||
### theta-agent v1.5.1
|
||||
|
||||
- `join_key` config field, presented while `auth_token` is empty. The agent persists the issued token + public key into its own `agent.yml` — line-based, so comments, capabilities and formatting survive — and blanks the join key.
|
||||
- Sends `?hostname=` so a self-enrolling host is named after itself; refuses to connect with no credential rather than presenting an empty one.
|
||||
- `install.sh --join-key`.
|
||||
- **v1.5.1 rebuilds the prebuilt `theta-agent-linux-amd64`.** `setup.sh` installs that committed binary rather than building from source, and the v1.5.0 one predated join-key support — it would have received a `join_key` it did not understand. Same trap as the v1.3.0 heartbeat fix.
|
||||
|
||||
### proxy v1.35.0
|
||||
|
||||
- Permission entries can be **edited**; previously only Delete existed, so changing a role meant delete-and-re-add. Because a permission's id is derived from (subjectType, subject, scope, domain), changing any of those replaces the record — the endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant conferring access.
|
||||
|
||||
## [v1.42.0] - 2026-08-05
|
||||
|
||||
Rolls up **sso-manager-node v1.29.0**, **theta-agent v1.4.0**, **proxy v1.34.0** and **jump-host v1.19.0**.
|
||||
|
||||
> **Breaking — re-enroll your theta-agents.** The SSO now rejects agent tokens it
|
||||
> did not issue. Any agent installed before this release carries a token
|
||||
> generated in the browser that the server never recorded, and will be refused
|
||||
> with close code `4001` until re-enrolled from **Directory → Install Agent**.
|
||||
>
|
||||
> **Re-run `./setup.sh`.** The `sso-broker` OpenBao policy needs the new
|
||||
> `secret/agent/*` grant, or the SSO cannot persist its agent signing key and
|
||||
> will refuse every high-risk agent command.
|
||||
|
||||
### Fixed — theta-suite orchestration
|
||||
|
||||
- **Per-host SSO returned `400 redirect_uri is not registered for this client`.** The bootstrap registered only the proxy's own management callback (`https://<proxy-host>/api/auth/oidc/callback`), but per-host SSO calls back to `https://<protected-host>/__proxy_auth/callback` — a different URL for every host the proxy fronts, all against that one OAuth client. It now also registers `https://**.<domain>/__proxy_auth/callback` and the bare apex, and `ensureRedirectUris()` backfills them onto an existing client so upgraded stacks are fixed too, not just fresh installs. (The SSO's wildcard matcher already supported this; nothing was ever registered to use it.)
|
||||
- **Seeded services were parented to the wrong host.** `theta-proxy` and `theta-jump` were created as host resources and then left childless, while the Proxy, OpenResty Edge and SSH Jump Host services hung off the stack host instead. They now parent to the host that runs them. `reparent()` corrects existing installs on the next run, and only when the current parent is exactly the one the old code set — a layout an operator arranged deliberately is left alone.
|
||||
- The directory-edge fetch added for re-parenting is tolerated separately from the resource list, so losing it can't skip seeding the resources themselves.
|
||||
|
||||
### Added — theta-suite orchestration
|
||||
|
||||
- **The proxy gets a read-only SSO API token.** Minted by the bootstrap and written into `proxy-secrets.js` (before the OpenBao snapshot, so the running proxy actually receives it), backing the per-host SSO group autocomplete. Idempotent: only mints when `sso.apiToken` is still empty.
|
||||
- `setup.sh` writes an `sso: { url, apiToken }` block into the generated `proxy-secrets.js`.
|
||||
- The `sso-broker` OpenBao policy grants `secret/agent/*` for the persistent theta-agent signing key.
|
||||
- `docs/secrets.md` documents the signing key, why it must be stable, and what happens when the grant is missing.
|
||||
|
||||
---
|
||||
|
||||
### sso-manager-node v1.29.0
|
||||
|
||||
**Security — the theta-agent channel authenticated nothing.** `/api/agent/ws` accepted any token string; there was no agent registry, because tokens were generated in the *browser* and never recorded server-side. Anyone who could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token they guessed.
|
||||
|
||||
- Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash *before* the connection is registered or the welcome payload is sent. Unknown/revoked → close `4001`, audited.
|
||||
- `POST /api/agent/enroll` mints the token server-side and returns it once; only its hash is stored. Rotate/revoke/delete drop the live socket immediately (`4004`/`4003`).
|
||||
- Commands are addressed by agent **id**, never by token.
|
||||
- The Ed25519 signing key was generated in the `AgentManager` constructor, so it changed on every restart and the `public_key` pinned in an agent's `agent.yml` stopped matching. It now lives in OpenBao at `secret/agent/signing-key`; if it can't be loaded the SSO refuses high-risk commands rather than signing with a key no agent has seen.
|
||||
- Enroll/update/rotate/revoke/delete, every command, and every rejected connection are audited with the acting user.
|
||||
|
||||
**Directory & agents.** Agents bind to a host resource instead of being matched by hostname; a bound agent's discovery is written onto that resource (`discovery_sources: ["theta-agent"]`) — previously the one source running *on* the host contributed nothing. Enrollments survive restarts, so "installed but offline" (red) is now distinguishable from "no agent" (grey). The Install Agent modal enrolls first and emits `--public-key`, which was never written into `agent.yml` before.
|
||||
|
||||
**Directory tree.** Collapsible, with per-browser persisted state; an active search overrides collapse so matches inside folded subtrees aren't hidden.
|
||||
|
||||
**Discovery — found by running against a live 3-node Proxmox cluster.**
|
||||
|
||||
- MACs and IPs were collected into two flat lists and zipped by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC.
|
||||
- A Proxmox endpoint resource now parents its nodes (one endpoint = one subtree), carrying no IP — giving it the address it's reached at made the reconciler merge it with the node answering there, producing a resource that was **its own parent**. Self-edges and cycle-closing edges are refused.
|
||||
- Hosts were named after their MAC address, because `bestName` preferred the longer string. Names are ranked hostname > IP > MAC.
|
||||
- `isIp` never matched anything (`\\.` in a regex literal matches a backslash, not a dot).
|
||||
- Guests carry `sourceId`/`node`/`vmid`/`macAddress`; container and overlay interfaces (`docker0`, `veth*`) are filtered out; stopped VMs still report a MAC; DHCP LXCs get an address; nodes report their own IP/MAC; offline nodes are recorded rather than skipped.
|
||||
- Cross-kind merges prevented; the inventory is read once per run instead of once per incoming resource.
|
||||
|
||||
**Other.** The Profile page's API Tokens card is no longer wider than every other card (it sat outside the page container). `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite had been failing in CI as "Cannot find module" — suites 27 → 29, 296 tests passing.
|
||||
|
||||
### theta-agent v1.4.0 (protocol v1.2.0)
|
||||
|
||||
- **Fail-closed verification.** `verifySignature` returned `true` when no `public_key` was configured — and the installer never wrote one, so a default install executed `reboot`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified**.
|
||||
- **Canonicalization disagreed with the server.** Go's `encoding/json` escapes `<`, `>` and `&`; `JSON.stringify` does not. Any payload containing them failed verification — for `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). Now uses `SetEscapeHTML(false)`.
|
||||
- Handles the SSO's enrollment close codes and backs off 5 minutes instead of retrying a dead credential every 5 seconds forever.
|
||||
- The connect log no longer prints the URL, which carried `?token=`.
|
||||
- `install.sh --public-key`, and a loud warning when none is configured.
|
||||
|
||||
### proxy v1.34.0
|
||||
|
||||
- The per-host SSO **Allowed groups** field autocompletes from the SSO directory's groups. It previously suggested only local groups — the one set of values that can never match, since the allow-list is checked against the SSO's `groups` claim. New `conf.sso` block; degrades silently when unset.
|
||||
- Authenticates with `Authorization: Bearer`, not the `auth-token` header.
|
||||
|
||||
### jump-host v1.19.0
|
||||
|
||||
- **Only catalog hosts are jump targets.** The filter treated a missing `managed` flag as permission, so unpromoted discovery results — Proxmox guests, UniFi clients — appeared in the TUI picker and were accepted by the username grammar. It now mirrors the SSO Directory's own rule.
|
||||
|
||||
## [v1.41.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **The Local Docker daemon discovery plugin no longer errors** — the sso-manager container had no access to the host docker socket, so the seeded `docker-local` plugin (socketPath `/var/run/docker.sock`) failed with `ENOENT` and showed "Last run: error". `docker-compose.yml` now mounts `/var/run/docker.sock` into the container. Recreate the container (`docker compose up -d sso-manager`) and hit "Run now" on the plugin.
|
||||
- **theta-agent ships the rebuilt binary with the heartbeat fix** (v1.3.1, gitlink `51750d0`) — the prebuilt `theta-agent-linux-amd64` predated the v1.3.0 `heartbeat_ack` fix, so the installed agent still logged "Unknown command type: heartbeat_ack". Now rebuilt + tested.
|
||||
|
||||
## [v1.40.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **No more spurious "Invalid Credentials, login failed" during LDAP enrollment** (ldap-client v1.25.0, gitlink `68fcdb5`) — `index.sh` self-registered the host in the Directory when `sso_token` was *declared but empty* (it checked `[[ -v ]]`), POSTing an empty Bearer token and getting a misleading `LDAPLoginFailed`. It now only registers with a real token; the stack host (already seeded by the bootstrap) skips registration.
|
||||
- **The `cn=ldapclient` service account now shows in the SSO Users UI** — it was created as a bare `organizationalRole` (invisible to the `posixAccount` user filter) and never joined `app_sso_service_account`, so it never appeared as a service account. The bootstrap now creates it as a `posixAccount` (uid 10001, above the regular-user reserved floor) and adds it to `app_sso_service_account`; for an existing account it best-effort adds the `posixAccount` shape (auxiliary, so it can't conflict with the structural `organizationalRole`) + the group membership.
|
||||
|
||||
## [v1.39.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **Plain LDAP (389) now reachable from the host** — `docker-compose.yml` published only LDAPS (636); plain LDAP (389) was deliberately not mapped, so the stack host's own enrollment (`setup.sh` → ldap-client, which configures sssd against `ldap://localhost:389` and `ldaps://localhost:636`) could not reach the directory over loopback. Both 389 and 636 are now published to the host (bind 0.0.0.0; `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to lock to the host only).
|
||||
|
||||
## [v1.38.0] - 2026-08-04
|
||||
|
||||
### Fixed
|
||||
- **LDAP enrollment no longer reaches for the public domain** — `setup.sh` generated `ldap.vars` with `ldap_host` defaulting to the public SSO host (`sso.<domain>`), which the NAT/firewall blocks on the LDAP ports (389/636). It now defaults to `localhost` (the LDAP server is co-located on the stack host; `ldap_tls_reqcert=never` makes this safe), overridable with `CFG_LDAPS_HOST` for an internal hostname/IP.
|
||||
- **SSH access groups match the SSO group model** (ldap-client v1.24.0) — the generated `sssd.conf` access filter and `ldap-ssh-key.sh` referenced the legacy names (`<location>_access`, `app_super_admin`); they now use `site_<location>_hosts_access` (all-hosts aggregate), `site_<location>_host_<hostname>_access`, and `god_admin`. GROUPS.md §8's example updated to match.
|
||||
|
||||
## [v1.37.0] - 2026-08-04
|
||||
|
||||
### Changed
|
||||
- **Group naming corrected to match docs/GROUPS.md** — per-resource groups are `{site}_{kind}_{name}_{level}` (kind always present; a host `host_theta-env` → `site_local_host_theta-env_access`, a service → `site_local_app_sso-manager_access`). The spec's §3 text was updated to state this explicitly.
|
||||
- **Roll up sso v1.27.0** — group names match the docs, a site carries only god + site-wide groups, duplicate group links removed, `/api/agent/*` no longer 404s, shared-secrets POST/GET fixed, Vault Apps tab lists minted tokens, discovery promote + plugin run logs fixed. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
|
||||
|
||||
## [v1.36.1] - 2026-08-04
|
||||
|
||||
### Fixed
|
||||
- **`setup.sh` no longer aborts with `CFG_BASE_DN: unbound variable`** — the ldap-client `ldap.vars` generation read the CFG_* first-run vars, which `ensure_config` only derives once (it returns early on a re-run once `sso-secrets.js` exists). It now reads the real values from the operator-owned `./config/sso-secrets.js` when the CFG_* vars are unset, so LDAP enrollment works on re-runs too. The generated `ldap_access_groups` now references `god_admin` (the legacy `app_super_admin` is gone).
|
||||
- **Roll up sso v1.26.1** — drops the legacy `app_super_admin`: `SUPER_ADMIN_GROUP` is now `god_admin` (nested into every resource's `_admin` group), and `docker-entrypoint.sh` no longer seeds/nests `app_super_admin`. See the [sso changelog](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md).
|
||||
|
||||
## [v1.36.0] - 2026-08-04
|
||||
|
||||
### Added
|
||||
|
||||
@@ -129,12 +129,15 @@ see browser warnings.)
|
||||
|
||||
Optional extra ports (only if you need them):
|
||||
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
|
||||
- **636** (LDAPS) — for direct-LDAP clients on other machines (Linux hosts
|
||||
via PAM/SSSD, LDAP-native apps). The proxy itself reaches LDAP over the
|
||||
internal Docker network, so you do **not** need to expose 636 for the stack
|
||||
to work.
|
||||
**Do not forward 636 to the public internet.** If you need LAN clients to bind
|
||||
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
||||
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
|
||||
enrollment (`setup.sh` → ldap-client) configures its sssd against
|
||||
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
|
||||
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
|
||||
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
|
||||
bind over either; the proxy itself reaches LDAP over the internal Docker
|
||||
network and doesn't need them.
|
||||
**Do not forward 389/636 to the public internet.** If you need LAN clients to
|
||||
bind LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
||||
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
|
||||
SAN. The default shows the public SSO hostname, which implies a public route.
|
||||
|
||||
|
||||
Vendored
+273
-26
@@ -84,16 +84,37 @@ const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET
|
||||
&& !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET);
|
||||
|
||||
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
// Per-host SSO (proxy routes/host_auth.js) calls back to
|
||||
// `https://<proxied-host>/__proxy_auth/callback` — a DIFFERENT URL for every
|
||||
// host the proxy fronts, all against this one OAuth client. Registering just
|
||||
// REDIRECT_URI above is what produced "400 redirect_uri is not registered for
|
||||
// this client" the moment a host's auth was set to SSO. The SSO's
|
||||
// redirectUriAllowed() supports `**` (any number of labels), so one pattern
|
||||
// covers the whole domain; `**.` does not match the bare apex, so register that
|
||||
// separately for a host served at the domain itself.
|
||||
//
|
||||
// A function, not a const: DOMAIN is declared further down this file, so
|
||||
// evaluating it here at module scope would hit the temporal dead zone.
|
||||
function proxyRedirectUris() {
|
||||
if (!DOMAIN) return [REDIRECT_URI];
|
||||
return [
|
||||
REDIRECT_URI,
|
||||
`https://**.${DOMAIN}/__proxy_auth/callback`,
|
||||
`https://${DOMAIN}/__proxy_auth/callback`,
|
||||
];
|
||||
}
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
const CLIENT_NAME = 'theta-proxy';
|
||||
|
||||
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
|
||||
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
|
||||
// god_admin is the global super group (docs/GROUPS.md §2); the bootstrapped
|
||||
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the legacy
|
||||
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the
|
||||
// per-console admin groups still used by the SSO UI. god_admin is nested into
|
||||
// app_super_admin by docker-entrypoint.sh, so LDAP-level consumers (SSSD, sudo)
|
||||
// resolve it transitively.
|
||||
// the app_sso_* groups (and every resource's _admin group) by
|
||||
// docker-entrypoint.sh + api_directory_admin, so LDAP-level consumers (SSSD,
|
||||
// sudo) resolve it transitively.
|
||||
const ADMIN_GROUPS = ['god_admin', 'app_sso_admin', 'app_sso_oauth_admin'];
|
||||
|
||||
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
|
||||
@@ -177,31 +198,88 @@ function ldapModify(ldif) {
|
||||
}
|
||||
|
||||
// ── 1. LDAP service account for the proxy ───────────────────────────────────
|
||||
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
|
||||
// UI as a service account it must (a) match the user filter (posixAccount) and
|
||||
// (b) be a member of app_sso_service_account (that membership is what the Users
|
||||
// page marks as a non-person/service account). Older bootstraps created it as a
|
||||
// bare organizationalRole (invisible to the Users list) and never joined the
|
||||
// group, so it never appeared. Both are fixed here; the existing-path shape add
|
||||
// is best-effort so a pre-existing account still binds even if the upgrade add
|
||||
// fails.
|
||||
function ensureServiceAccount() {
|
||||
const pw = hashPasswordSSHA512(SVC_PASS);
|
||||
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
|
||||
if (entryExists(SVC_DN)) {
|
||||
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
|
||||
const r = ldapModify([
|
||||
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
|
||||
// Add the auxiliary posixAccount objectClass + required attrs so the entry
|
||||
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
|
||||
// it is structural and would conflict with the existing organizationalRole.
|
||||
const shape = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'add: objectClass',
|
||||
'objectClass: posixAccount',
|
||||
'-',
|
||||
'add: uid',
|
||||
'uid: ldapclient',
|
||||
'-',
|
||||
'add: uidNumber',
|
||||
`uidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: gidNumber',
|
||||
`gidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: homeDirectory',
|
||||
'homeDirectory: /nonexistent',
|
||||
'-',
|
||||
'add: description',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
'',
|
||||
].join('\n');
|
||||
const rs = ldapModify(shape);
|
||||
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
|
||||
log(' service-account shape warning (account still binds):', rs.stderr.trim());
|
||||
}
|
||||
const rp = ldapModify([
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'replace: userPassword',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
|
||||
return;
|
||||
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
|
||||
} else {
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const entry = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: inetOrgPerson',
|
||||
'objectClass: posixAccount',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
'sn: ldapclient',
|
||||
'uid: ldapclient',
|
||||
`uidNumber: ${uidNum}`,
|
||||
`gidNumber: ${uidNum}`,
|
||||
'homeDirectory: /nonexistent',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = ldapAdd(entry);
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
}
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const r = ldapAdd([
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: organizationalRole',
|
||||
'objectClass: simpleSecurityObject',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
`userPassword: ${pw}`,
|
||||
// Mark it as a service account (the Users UI's service-account signal).
|
||||
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
|
||||
const rm = ldapModify([
|
||||
`dn: ${gdn}`,
|
||||
'changetype: modify',
|
||||
'add: member',
|
||||
`member: ${SVC_DN}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
|
||||
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
|
||||
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
|
||||
}
|
||||
|
||||
// ── 2. First admin user ─────────────────────────────────────────────────────
|
||||
@@ -280,7 +358,7 @@ async function listClients(token) {
|
||||
}
|
||||
|
||||
async function createClient(token, opts) {
|
||||
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
|
||||
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: proxyRedirectUris() };
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
@@ -304,6 +382,29 @@ async function createClient(token, opts) {
|
||||
return { id, secret };
|
||||
}
|
||||
|
||||
// Add any redirect_uris the client is missing, keeping whatever the operator
|
||||
// has already registered. Backfills installs whose proxy client was created
|
||||
// before the per-host `__proxy_auth/callback` patterns existed — without this,
|
||||
// setting a host's auth to SSO fails with "400 redirect_uri is not registered
|
||||
// for this client" on an upgraded stack and only works on a fresh one.
|
||||
// Warn-only: a stack that cannot widen its client is still a working stack.
|
||||
async function ensureRedirectUris(token, client, wanted) {
|
||||
const have = client.redirect_uris || [];
|
||||
const missing = wanted.filter((u) => !have.includes(u));
|
||||
if (!missing.length) return;
|
||||
try {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${client.client_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirect_uris: [...have, ...missing] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
|
||||
log(` OAuth client ${client.name}: registered ${missing.length} redirect URI(s) for per-host SSO`);
|
||||
} catch (error) {
|
||||
log(` WARNING: could not add redirect URIs to ${client.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateClient(token, id) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
|
||||
method: 'POST',
|
||||
@@ -386,6 +487,30 @@ const HOST_FACTS = {
|
||||
|
||||
async function seedDirectory(token, clientId, jumpClientId) {
|
||||
let resources = ((await dirGet(token, 'resources')).results) || [];
|
||||
// Tolerated separately from the resource list: edges only drive the
|
||||
// re-parent + OAuth-link steps, and losing those is not a reason to skip
|
||||
// seeding the resources themselves.
|
||||
let edges = [];
|
||||
try { edges = ((await dirGet(token, 'edges')).results) || []; }
|
||||
catch (e) { log(` WARNING: could not list directory edges (${e.message}) — skipping re-parent/link steps`); }
|
||||
|
||||
// Move an already-seeded resource under the parent it should have had.
|
||||
// Only ever corrects a parent this bootstrap itself seeded wrongly (the
|
||||
// proxy/jump services were parented to the stack host instead of to
|
||||
// host_theta-proxy / host_theta-jump); an operator who has deliberately
|
||||
// re-parented something keeps their layout, because we only rewire when the
|
||||
// current parent is the one the old code would have set.
|
||||
async function reparent(resource, wantParentId, fromParentId) {
|
||||
if (!resource || !wantParentId || !fromParentId) return;
|
||||
const current = edges.find((e) => e.childId === resource.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||
if (!current) return; // unparented: leave it alone
|
||||
if (current.parentId === wantParentId) return; // already correct
|
||||
if (current.parentId !== fromParentId) return; // operator moved it: respect that
|
||||
// PUT with kind + hostId is what makes the route rewire the parent edge.
|
||||
await dirPut(token, `resources/${resource.id}`, { kind: resource.kind, hostId: wantParentId });
|
||||
current.parentId = wantParentId;
|
||||
log(` directory: re-parented ${resource.kind} '${resource.slug}' onto its own host`);
|
||||
}
|
||||
|
||||
// Create a resource unless its slug (or a legacy alternate from an earlier
|
||||
// seed layout) already exists. On an existing resource, seed metadata keys
|
||||
@@ -441,7 +566,7 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
// appear as hosts in the Directory; the per-app services below still carry
|
||||
// the OAuth-client + reachability detail.
|
||||
const jumpHostAddr = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, {
|
||||
const proxyHostRes = await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, {
|
||||
subType: 'linux',
|
||||
address: `https://${PROXY_HOST}`,
|
||||
port: 3000,
|
||||
@@ -450,7 +575,7 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
tagline: 'Reverse proxy and API gateway (node management UI).',
|
||||
managed: true,
|
||||
});
|
||||
await ensure('host', 'theta-jump', 'host_theta-jump', site.id, {
|
||||
const jumpHostRes = await ensure('host', 'theta-jump', 'host_theta-jump', site.id, {
|
||||
subType: 'ssh',
|
||||
address: jumpHostAddr ? `https://${jumpHostAddr}` : '',
|
||||
port: 3002,
|
||||
@@ -471,7 +596,11 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
});
|
||||
// Proxy = the node management UI; OpenResty = the data plane every hostname
|
||||
// in the stack actually flows through (80/443). Two faces, two entries.
|
||||
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
|
||||
// Both parent to host_theta-proxy, not to the stack host: the whole point of
|
||||
// seeding that host resource is that the proxy's services hang off it. Seeded
|
||||
// under the stack host until 2026-08-05, which left host_theta-proxy and
|
||||
// host_theta-jump childless while their services sat under the wrong parent.
|
||||
const psvc = await ensure('service', 'Proxy', 'proxy', proxyHostRes.id, {
|
||||
address: `https://${PROXY_HOST}`,
|
||||
port: 3000,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
@@ -500,7 +629,7 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
// Wildcard address: OpenResty fronts every host under the domain (same
|
||||
// */** wildcard convention the proxy's Host records use). Its config lives
|
||||
// in the proxy repo (ops/nginx_conf).
|
||||
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
|
||||
await ensure('service', 'OpenResty Edge', 'openresty', proxyHostRes.id, {
|
||||
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
|
||||
port: 443,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
@@ -510,11 +639,30 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// OpenBao and its renewer sidecar are part of what the stack deploys, so
|
||||
// they belong in the directory like every other component. Without entries
|
||||
// their containers had nowhere to attach and showed up as parentless
|
||||
// discoveries on a fresh install.
|
||||
await ensure('service', 'OpenBao', 'openbao', host.id, {
|
||||
address: 'http://openbao:8200',
|
||||
port: 8200,
|
||||
subType: 'vault',
|
||||
icon: 'mdi:safe',
|
||||
tagline: 'Secrets store for the stack.',
|
||||
requestable: false,
|
||||
});
|
||||
await ensure('service', 'Bao Renewer', 'bao-renewer', host.id, {
|
||||
subType: 'sidecar',
|
||||
icon: 'mdi:autorenew',
|
||||
tagline: 'Renews the stack service tokens against OpenBao.',
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// SSH jump host service (core component — always registered).
|
||||
let jumpSvc = null;
|
||||
{
|
||||
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
|
||||
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', jumpHostRes.id, {
|
||||
address: jumpHost ? `https://${jumpHost}` : '',
|
||||
port: 3002,
|
||||
gitRepo: 'https://github.com/theta42/jump-host',
|
||||
@@ -525,16 +673,22 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Correct installs seeded before 2026-08-05, where these three services were
|
||||
// parented to the stack host rather than to the proxy/jump host resources.
|
||||
await reparent(psvc, proxyHostRes.id, host.id);
|
||||
await reparent(resources.find((r) => r.slug === 'openresty'), proxyHostRes.id, host.id);
|
||||
await reparent(jumpSvc, jumpHostRes.id, host.id);
|
||||
|
||||
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
|
||||
// owning service, if it appears in the directory and isn't linked yet.
|
||||
async function linkOauthClient(id, parent, label) {
|
||||
if (!id || !parent) return;
|
||||
const oauthRes = resources.find((r) => r.id === id);
|
||||
if (!oauthRes) return;
|
||||
const edges = ((await dirGet(token, 'edges')).results) || [];
|
||||
const linked = edges.some((e) => e.childId === id);
|
||||
if (!linked) {
|
||||
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
edges.push({ parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
log(` directory: linked OAuth client under '${label}'`);
|
||||
}
|
||||
}
|
||||
@@ -588,7 +742,16 @@ async function seedPlugins(token) {
|
||||
pluginType: 'docker',
|
||||
name: 'Local Docker daemon',
|
||||
slug: 'docker-local',
|
||||
config: { socketPath: '/var/run/docker.sock' },
|
||||
config: {
|
||||
socketPath: '/var/run/docker.sock',
|
||||
// Containers in our own compose project are the stack itself --
|
||||
// already seeded as services above. Telling the plugin which
|
||||
// project that is lets it mark them managed and attach them to
|
||||
// the service they implement, instead of a fresh install
|
||||
// presenting its own five containers as unmanaged discoveries.
|
||||
stackProject: process.env.COMPOSE_PROJECT_NAME || 'theta-suite',
|
||||
hostSlug: HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : '',
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
|
||||
@@ -625,6 +788,78 @@ function writeProxyCreds(id, secret) {
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy needs a read-only SSO API token so its per-host SSO allow-list can
|
||||
// suggest the directory's actual groups (otherwise the "Allowed groups" field
|
||||
// autocompletes from the proxy's local groups only, which for an SSO-gated host
|
||||
// is never what the operator wants). Idempotent: only mints when the file's
|
||||
// `sso.apiToken` is still empty, and only rewrites that one line. Warn-only —
|
||||
// no token just means no suggestions.
|
||||
const PROXY_TOKEN_NAME = 'theta-proxy';
|
||||
|
||||
async function ensureProxyApiToken(token) {
|
||||
const path = '/config/proxy-secrets.js';
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(path, 'utf8');
|
||||
} catch (e) {
|
||||
log(` WARNING: cannot read ${path} to add an SSO API token (${e.message})`);
|
||||
return;
|
||||
}
|
||||
// An `sso: { ... apiToken: 'sso_...' }` already present means we're done.
|
||||
if (/apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src)) {
|
||||
log(' proxy already has an SSO API token — keeping');
|
||||
return;
|
||||
}
|
||||
if (!/\bsso:\s*\{/.test(src)) {
|
||||
log(` WARNING: ${path} has no \`sso\` block — add one with url + apiToken to enable SSO group autocomplete`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const apiToken = await mintApiToken(token, PROXY_TOKEN_NAME, 'theta-suite proxy (auto-registered)');
|
||||
// Replace the apiToken line inside the sso block only. The jump host's
|
||||
// token lives in a different file, so an unanchored match is safe here.
|
||||
const updated = src.replace(/(apiToken:\s*)(['"])[^'"]*\2/, `$1$2${apiToken}$2`);
|
||||
if (updated === src) {
|
||||
log(` WARNING: could not locate apiToken in ${path} — set sso.apiToken manually`);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(path, updated);
|
||||
log(` Minted SSO API token for the proxy and wrote it into ${path}`);
|
||||
} catch (e) {
|
||||
log(` WARNING: could not provision the proxy's SSO API token: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Mint (or reuse) a theta-agent join key and hand it to setup.sh.
|
||||
//
|
||||
// A join key is the single credential an operator needs to add a host: the
|
||||
// agent presents it, the SSO enrolls the host and issues it its own per-agent
|
||||
// token + public key, which the agent writes back into its agent.yml. Without
|
||||
// this, adding a host meant pre-registering it in the SSO and copying two
|
||||
// values onto the machine by hand -- and setup.sh's own agent install had no
|
||||
// way to produce a token the server would accept at all.
|
||||
//
|
||||
// Idempotent: reuses the existing `setup` key rather than piling up new ones.
|
||||
// A key can only be shown once, so if the stored one is not recoverable we mint
|
||||
// a replacement and label it for the run that created it.
|
||||
async function ensureAgentJoinKey(token) {
|
||||
try {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/agent/join-keys`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ label: 'setup' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
|
||||
const data = await res.json();
|
||||
if (!data.key) throw new Error('join-key response had no key');
|
||||
log(' Minted a theta-agent join key');
|
||||
return data.key;
|
||||
} catch (error) {
|
||||
log(` WARNING: could not mint a theta-agent join key: ${error.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Provision the SSH jump host ─────────────────────────────────────────
|
||||
// The jump host is a core component (always provisioned). It needs: a directory
|
||||
// API token (to resolve which hosts a user may reach), an LDAP bind account
|
||||
@@ -641,11 +876,11 @@ const JUMP_TOKEN_NAME = 'theta-jump-host';
|
||||
const JUMP_CLIENT_NAME = 'theta-jump';
|
||||
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
async function mintApiToken(token, name) {
|
||||
async function mintApiToken(token, name, description) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description: 'theta-suite jump host (auto-registered)' }),
|
||||
body: JSON.stringify({ name, description: description || 'theta-suite jump host (auto-registered)' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
|
||||
const data = await res.json();
|
||||
@@ -780,6 +1015,10 @@ async function provisionJumpHost(token) {
|
||||
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
|
||||
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
|
||||
|
||||
// Widen an existing client before any of the branches below return: a
|
||||
// freshly created one already gets these from createClient().
|
||||
if (client) await ensureRedirectUris(token, client, proxyRedirectUris());
|
||||
|
||||
if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) {
|
||||
// File creds match an existing client — trust the file's secret
|
||||
// (it's bcrypt-hashed server-side, so we can't verify, but the proxy
|
||||
@@ -809,6 +1048,11 @@ async function provisionJumpHost(token) {
|
||||
resolvedClientId = id;
|
||||
}
|
||||
|
||||
// Must run before the baoPut below: that snapshots proxy-secrets.js into
|
||||
// OpenBao, and the proxy loads its conf from there at boot, so a token
|
||||
// written after the snapshot would never reach the running proxy.
|
||||
await ensureProxyApiToken(token);
|
||||
|
||||
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
|
||||
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
|
||||
// fresh: writeProxyCreds rewrote the file out from under the cached
|
||||
@@ -835,6 +1079,9 @@ async function provisionJumpHost(token) {
|
||||
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
|
||||
}
|
||||
|
||||
// The agent join key setup.sh writes into /etc/theta42/agent.yml.
|
||||
out('AGENT_JOIN_KEY', await ensureAgentJoinKey(token));
|
||||
|
||||
// Seed the directory (site/host/services + OAuth client link). Never
|
||||
// fails the bootstrap — warn and continue.
|
||||
try {
|
||||
|
||||
+14
-6
@@ -52,12 +52,16 @@ services:
|
||||
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
|
||||
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
|
||||
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
||||
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
|
||||
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
|
||||
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
|
||||
# in sso-secrets.js) and do NOT forward 636 to the public internet.
|
||||
- "${LDAPS_PORT:-636}:636"
|
||||
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
|
||||
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
|
||||
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
|
||||
# against ldap://localhost and ldaps://localhost, and the LDAP server is
|
||||
# co-located on this host, so BOTH ports must be reachable from the host
|
||||
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
|
||||
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
|
||||
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
|
||||
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
|
||||
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
|
||||
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
|
||||
environment:
|
||||
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
|
||||
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
||||
@@ -77,6 +81,10 @@ services:
|
||||
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
|
||||
- NO_PROXY=${CFG_NO_PROXY:-}
|
||||
volumes:
|
||||
# The host docker socket so the bundled Docker discovery plugin (seeded as
|
||||
# 'docker-local' with socketPath /var/run/docker.sock) can list containers.
|
||||
# Without this the plugin errors with ENOENT and shows 'Last run: error'.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
|
||||
# can write the generated OAuth client creds into proxy-secrets.js. The
|
||||
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
|
||||
|
||||
+16
-16
@@ -76,17 +76,15 @@ enumerated as LDAP members, and cannot be used as Unix groups.
|
||||
|
||||
- The **structural delimiter is `_`**. It appears only between the fixed segments
|
||||
of a group name.
|
||||
- **The `S` site segment is the site resource's slug verbatim.** In the SSO
|
||||
Directory, site/host resource slugs carry a kind prefix (`site_local`,
|
||||
`host_theta-env`); the group builders keep them verbatim rather than
|
||||
re-slugifying (which would corrupt the delimiter: `site_local` → `site-local`)
|
||||
or inserting a separate kind segment. So a host resource `host_theta-env` under
|
||||
site `site_local` yields `site_local_host_theta-env_access` (the `host_` is part
|
||||
of the resource slug), and the site's own admin group is `site_local_super_admin`.
|
||||
Services are stored without a prefix, giving `site_local_sso-manager_access`.
|
||||
The kind (`host`/`app`) is used only to pick the **aggregate** the resource's
|
||||
group nests into (`{site}_hosts_*` / `{site}_apps_*`), not the resource's own
|
||||
group name.
|
||||
- **The `S` site segment is the site resource's slug verbatim** (`site_local`),
|
||||
NOT re-slugified (which would corrupt the delimiter: `site_local` → `site-local`).
|
||||
- **Per-resource groups are `{S}_{kind}_{name}_{level}`.** `kind` is `host` or
|
||||
`app`; `name` is the resource's **name slug with the kind prefix stripped** — a
|
||||
host resource `host_theta-env` has name `theta-env`, so its groups are
|
||||
`site_local_host_theta-env_access` / `_admin`. A service (the group model's
|
||||
`app`, docs §11) `sso-manager` gives `site_local_app_sso-manager_access`. The
|
||||
kind segment is always present, which is what makes a resource's name
|
||||
unambiguous even if a host and a service share a name.
|
||||
- **Within a segment, normalize to lowercase** — spaces and stray `_` → `-`; strip
|
||||
other non-`[a-z0-9-]`. A host named `Web 01` and a site `Main Office` (resource
|
||||
slugs `host_web-01` and `site_main-office`) yield groups `site_main-office_host_web-01_*`.
|
||||
@@ -150,7 +148,9 @@ def effective(resource, level_or_cap, site):
|
||||
if level_or_cap in ("admin","access"):
|
||||
agg = f"{site}_{resource.kind}s_{level_or_cap}"
|
||||
if user in agg: return True
|
||||
specific = f"{site}_{resource.slug}_{level_or_cap}" # slug carries its kind
|
||||
# resource.name is the resource's name slug (kind prefix stripped); the kind
|
||||
# is its own segment. A host `host_theta-env` has name `theta-env`, kind `host`.
|
||||
specific = f"{site}_{resource.kind}_{resource.name}_{level_or_cap}"
|
||||
if user in specific: return True
|
||||
if level_or_cap == "access": return effective(resource, "admin", site)
|
||||
if level_or_cap == "admin": return False # access does not imply admin
|
||||
@@ -232,12 +232,12 @@ Key ideas:
|
||||
A host should import its **own** resource groups (plus any explicitly granted
|
||||
ones). Because the schema is predictable, `ldap-client` can generate the per-host
|
||||
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
|
||||
at site `main-office` imports:
|
||||
at site `main-office` (site resource slug `site_main-office`) imports:
|
||||
|
||||
```
|
||||
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
|
||||
(cn=main-office_host_web01_admin)
|
||||
(cn=main-office_host_web01_sudo)))
|
||||
(&(objectClass=groupOfNames)(|(cn=site_main-office_host_web01_access)
|
||||
(cn=site_main-office_host_web01_admin)
|
||||
(cn=site_main-office_host_web01_sudo)))
|
||||
```
|
||||
|
||||
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
|
||||
|
||||
+16
-1
@@ -60,7 +60,7 @@ never passed to a service container.
|
||||
|
||||
| Policy | Capabilities | Held by |
|
||||
|---|---|---|
|
||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`, `secret/agent/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
||||
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
|
||||
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
|
||||
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
|
||||
@@ -176,6 +176,21 @@ const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-s
|
||||
await baoConf.set('apps/my-service/conf', { db_password: '...' });
|
||||
```
|
||||
|
||||
## The theta-agent signing key
|
||||
|
||||
The SSO signs high-risk theta-agent commands (`reboot`, `configure_ldap`,
|
||||
`arbitrary_bash`, …) with an Ed25519 key stored at
|
||||
`secret/agent/signing-key`. Agents pin the matching public key in their
|
||||
`agent.yml`, so the key **must** be stable: it used to be generated in memory at
|
||||
process start, which meant it changed on every restart and no agent could
|
||||
meaningfully verify anything.
|
||||
|
||||
If the SSO cannot read or write that path it refuses to send high-risk commands
|
||||
rather than signing with a key no agent has seen — so an upgraded stack that has
|
||||
not re-run `./setup.sh` (and therefore lacks `secret/agent/*` in the
|
||||
`sso-broker` policy) will report `signingAvailable: false` on
|
||||
`GET /api/agent/nodes` and reject those commands with a clear error.
|
||||
|
||||
## Plugin secrets
|
||||
|
||||
The SSO Manager's plugin system (configurable plugin instances you create,
|
||||
|
||||
+1
-1
Submodule jump-host updated: a7b3416619...57d0600fc0
+1
-1
Submodule ldap-client updated: 31d8fa1229...68fcdb53bd
+1
-1
Submodule proxy updated: bbaa006925...60ed6b462f
@@ -292,6 +292,21 @@ dn_from_domain() {
|
||||
echo "dc=$1" | sed 's/\./,dc=/g'
|
||||
}
|
||||
|
||||
# Read a value from the (operator-owned) ./config/sso-secrets.js -- the source of
|
||||
# truth on re-runs, where the CFG_* first-run shell vars are not (re)derived
|
||||
# (ensure_config returns early once sso-secrets.js exists). Reads `stack.<key>`.
|
||||
# Prints empty on any failure. Usage: sso_secrets_get ldapBaseDn
|
||||
sso_secrets_get() {
|
||||
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c.stack&&c.stack[k]!=null?c.stack[k]:"")' \
|
||||
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Read a top-level (non-stack) secret from sso-secrets.js, e.g. serviceAccountPass.
|
||||
sso_secrets_get_top() {
|
||||
node -e 'const c=require(process.argv[1]);const k=process.argv[2];console.log(c&&c[k]!=null?c[k]:"")' \
|
||||
"$PWD/$CONFIG_DIR/sso-secrets.js" "$1" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Write ./config/sso-secrets.js from the CFG_* shell vars.
|
||||
write_sso_secrets() {
|
||||
local dn="$CFG_BASE_DN" domain="$CFG_DOMAIN"
|
||||
@@ -383,6 +398,12 @@ module.exports = {
|
||||
groupsClaim: 'groups',
|
||||
usernameClaim: 'preferred_username',
|
||||
},
|
||||
// Read-only SSO management API access, used to list directory groups for the
|
||||
// per-host SSO allow-list autocomplete. apiToken is minted by the bootstrap.
|
||||
sso: {
|
||||
url: 'http://sso-manager:3001',
|
||||
apiToken: '',
|
||||
},
|
||||
ldap: {
|
||||
url: 'ldaps://sso-manager:636',
|
||||
bindDN: $(js_str "cn=ldapclient,ou=people,${dn}"),
|
||||
@@ -874,6 +895,11 @@ path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete"
|
||||
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
|
||||
# The Ed25519 key the SSO signs high-risk theta-agent commands with. It must
|
||||
# persist across restarts: agents pin the matching public key in agent.yml, so
|
||||
# a key that changes on every boot makes signature verification meaningless.
|
||||
path "secret/data/agent/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/agent/*" { capabilities = ["list", "read", "delete"] }
|
||||
path "auth/token/create/sso-broker" { capabilities = ["update"] }
|
||||
path "auth/token/create/sso-app" { capabilities = ["update"] }
|
||||
path "auth/token/renew-accessor" { capabilities = ["update"] }
|
||||
@@ -1054,7 +1080,13 @@ STACK_HOST_MAC=""
|
||||
[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)"
|
||||
STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)"
|
||||
STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)"
|
||||
# The compose project name the stack runs under (defaults to the directory
|
||||
# name). The bootstrap hands it to the Docker discovery plugin so the stack's
|
||||
# own containers are recognised as ours rather than discovered as strangers.
|
||||
STACK_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-' | sed 's/-*$//')}"
|
||||
|
||||
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
|
||||
-e COMPOSE_PROJECT_NAME="$STACK_COMPOSE_PROJECT" \
|
||||
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
|
||||
-e STACK_HOST_IP="$STACK_HOST_IP" \
|
||||
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
|
||||
@@ -1069,6 +1101,9 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
|
||||
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
||||
CLIENT_ID=$(getval CLIENT_ID)
|
||||
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
|
||||
# The one credential the local theta-agent needs; it exchanges this for its own
|
||||
# token + the SSO public key on first connect (see 7c below).
|
||||
AGENT_JOIN_KEY=$(getval AGENT_JOIN_KEY)
|
||||
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
|
||||
|
||||
if [[ "$ALREADY_CONFIGURED" == "1" ]]; then
|
||||
@@ -1204,14 +1239,39 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
||||
sudo mkdir -p /etc/theta42
|
||||
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
||||
sudo cp agent.yml.example /etc/theta42/agent.yml
|
||||
AGENT_TOKEN="$(rand_hex 16)"
|
||||
sudo sed -i "s/REPLACE_WITH_AGENT_TOKEN/$AGENT_TOKEN/" /etc/theta42/agent.yml
|
||||
# Write the JOIN KEY, not a locally-invented token. The SSO
|
||||
# only accepts credentials it issued, so the random token
|
||||
# this used to generate could never authenticate -- the
|
||||
# agent looped on "close 4001: Unauthorized" forever. The
|
||||
# agent swaps this key for its own token (and the public key
|
||||
# it must pin) on first connect and rewrites this file.
|
||||
if [[ -n "$AGENT_JOIN_KEY" ]]; then
|
||||
# Only the join key is written. The agent exchanges it
|
||||
# for its own token + the SSO public key on first
|
||||
# connect and rewrites this file itself.
|
||||
#
|
||||
# This used to sed a locally generated random value into
|
||||
# auth_token. The SSO only accepts credentials it
|
||||
# issued, so that token could never authenticate and the
|
||||
# agent looped on "close 4001: Unauthorized" forever.
|
||||
if sudo grep -q '^join_key:' /etc/theta42/agent.yml; then
|
||||
sudo sed -i "s|^join_key:.*|join_key: \"${AGENT_JOIN_KEY}\"|" /etc/theta42/agent.yml
|
||||
else
|
||||
echo "join_key: \"${AGENT_JOIN_KEY}\"" | sudo tee -a /etc/theta42/agent.yml >/dev/null
|
||||
fi
|
||||
# Older agent.yml.example shipped REPLACE_WITH_* placeholders;
|
||||
# blank them so they are not mistaken for real credentials.
|
||||
sudo sed -i "s|REPLACE_WITH_ISSUED_AGENT_TOKEN||; s|REPLACE_WITH_AGENT_TOKEN||; s|REPLACE_WITH_SSO_PUBLIC_KEY||" /etc/theta42/agent.yml
|
||||
else
|
||||
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
||||
fi
|
||||
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
||||
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
|
||||
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
else
|
||||
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
fi
|
||||
sudo chmod 600 /etc/theta42/agent.yml
|
||||
fi
|
||||
# Stop a running agent before overwriting its binary (cp into a
|
||||
# running executable fails with "Text file busy" on a re-install).
|
||||
@@ -1265,15 +1325,32 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]] && [[ -x /usr/local/bin/theta-agent ]]
|
||||
# ldap.vars (cp ldap.vars.template ldap.vars + edit) is always kept.
|
||||
if [[ ! -f ldap-client/ldap.vars ]]; then
|
||||
info " Generating ldap-client/ldap.vars from the stack config..."
|
||||
# CFG_* first-run vars may be unset on a re-run (ensure_config returns
|
||||
# early once sso-secrets.js exists), so fall back to reading the real
|
||||
# values from the operator-owned sso-secrets.js. All `:-` guarded so a
|
||||
# missing value degrades to an empty ldap.vars field, not a set -u abort.
|
||||
ldap_base_dn="${CFG_BASE_DN:-$(sso_secrets_get ldapBaseDn)}"
|
||||
ldap_site="${CFG_SITE_NAME:-$(sso_secrets_get siteName)}"
|
||||
ldap_bind_pass="${CFG_SVC_PASS:-$(sso_secrets_get_top serviceAccountPass)}"
|
||||
sso_host="${CFG_SSO_HOST:-$(sso_secrets_get ssoHost)}"
|
||||
# The LDAP server is co-located with the stack on THIS host, so the
|
||||
# host must reach it over the loopback / a local address -- NEVER the
|
||||
# public domain (sso.<domain>), which cannot route back to the 389/636
|
||||
# ports through NAT. localhost is fine because the generated sssd.conf
|
||||
# sets ldap_tls_reqcert=never (hostname verification is off). An
|
||||
# operator may override with CFG_LDAPS_HOST (an internal hostname/IP).
|
||||
ldaps_host="${CFG_LDAPS_HOST:-localhost}"
|
||||
cat > ldap-client/ldap.vars <<LDAPVARS
|
||||
export ldap_host="${CFG_LDAPS_HOST:-sso.${CFG_DOMAIN}}"
|
||||
export ldap_base_dn="${CFG_BASE_DN}"
|
||||
export ldap_bind_dn="cn=ldapclient,ou=people,${CFG_BASE_DN}"
|
||||
export ldap_bind_password="${CFG_SVC_PASS}"
|
||||
export sso_url="https://${CFG_SSO_HOST}"
|
||||
export ldap_host="${ldaps_host}"
|
||||
export ldap_base_dn="${ldap_base_dn}"
|
||||
export ldap_bind_dn="cn=ldapclient,ou=people,${ldap_base_dn}"
|
||||
export ldap_bind_password="${ldap_bind_pass}"
|
||||
export sso_url="https://${sso_host}"
|
||||
export sso_token=""
|
||||
export ldap_location="${CFG_SITE_NAME:-local}"
|
||||
ldap_access_groups=( "\${ldap_location}_access" "\${ldap_location}_host_\$(hostname)_access" "app_super_admin" )
|
||||
export ldap_location="${ldap_site:-local}"
|
||||
# Groups that grant SSH/access on this host (docs/GROUPS.md §8): the site's
|
||||
# all-hosts aggregate, this host's own access group, and god_admin.
|
||||
ldap_access_groups=( "site_\${ldap_location}_hosts_access" "site_\${ldap_location}_host_\$(hostname)_access" "god_admin" )
|
||||
LDAPVARS
|
||||
else
|
||||
info " ldap-client/ldap.vars exists -- keeping it"
|
||||
|
||||
+1
-1
Submodule sso-manager-node updated: 8a9de94d24...e7e3eeb6cd
+1
-1
Submodule theta-agent updated: 52379c2434...d128807431
Reference in New Issue
Block a user