Merge pull request #168 from theta42/fix/seed-hierarchy-and-host-sso-redirect

feat: agent enrollment, per-host SSO redirect URIs, seed hierarchy (v1.42.0)
This commit is contained in:
2026-08-05 19:33:01 -04:00
committed by GitHub
8 changed files with 238 additions and 14 deletions
+70
View File
@@ -8,6 +8,76 @@ 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.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
+137 -9
View File
@@ -84,6 +84,26 @@ 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';
@@ -338,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' },
@@ -362,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',
@@ -444,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
@@ -499,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,
@@ -508,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,
@@ -529,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',
@@ -558,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',
@@ -572,7 +643,7 @@ async function seedDirectory(token, clientId, jumpClientId) {
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',
@@ -583,16 +654,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}'`);
}
}
@@ -683,6 +760,48 @@ 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}`);
}
}
// ── 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
@@ -699,11 +818,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();
@@ -838,6 +957,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
@@ -867,6 +990,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
+16 -1
View File
@@ -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 proxy updated: bbaa006925...954cda5844
+11
View File
@@ -398,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}"),
@@ -889,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"] }