#!/usr/bin/env node /* * theta-suite bootstrap — runs inside the sso-manager container to wire the * proxy into a (fresh or existing) SSO Manager. Invoked by setup.sh: * * docker compose exec sso-manager node /bootstrap/bootstrap.js * * It is intentionally self-contained: only Node built-ins (child_process, * crypto, fs) + global fetch. No requiring of the SSO's internal models (which * would read the wrong conf.ldap in a docker-exec process and risk model side * effects). LDAP ops use the openldap-clients binaries (ldapadd / ldapsearch / * ldapmodify) with explicit admin creds; the OAuth client is created via the * SSO's own HTTP API (logging in as the bootstrapped admin, which also * validates the admin password end-to-end). * * Config is read from the bind-mounted ./config/ directory (at /config in the * container), NOT from environment variables: * /config/sso-secrets.js — directory root creds, first admin, service * account pass, public hostnames, base DN * /config/proxy-secrets.js — the proxy's OIDC client creds (clientId / * clientSecret). The SSO *generates* these on * client create, so this script writes them back * into the file (the sso-manager mounts ./config * read-write for this purpose). * * Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when * the jump host is enabled, secret/jump-host/conf) so the proxy + jump host * load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes * the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is * fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config * file remains the fallback and bootstrap does not fail the bring-up over it. * * Idempotent: re-running converges to the ./config values. The LDAP service * account + admin passwords are reset to the file values on each run; the * OAuth client is created if missing. If proxy-secrets.js already holds a * clientId+clientSecret matching an existing client, they are kept (the proxy * keeps working). If the client is missing but the file has creds, a new client * is created and the file is updated. The secret is rotated only when a client * exists but the file has no usable secret to recover. * * Output (stdout, KEY=VALUE for setup.sh to parse): CLIENT_ID, CLIENT_SECRET, * ALREADY_CONFIGURED. Progress logs go to stderr. */ 'use strict'; const { execFileSync } = require('child_process'); const crypto = require('crypto'); const fs = require('fs'); // ── Read config from the mounted ./config/ (NOT env) ───────────────────────── const sso = require('/config/sso-secrets.js'); const proxy = require('/config/proxy-secrets.js'); function requireConf(value, name) { if (value === undefined || value === null || value === '' || value === 'CHANGE-ME') { throw new Error(`${name} is not configured in /config/sso-secrets.js`); } return value; } const BASE_DN = requireConf((sso.stack && sso.stack.ldapBaseDn), 'stack.ldapBaseDn'); const ADMIN_PASS = requireConf((sso.ldap && sso.ldap.bindPassword), 'ldap.bindPassword'); const BIND_DN = `cn=admin,${BASE_DN}`; const LDAP_URL = 'ldap://localhost:389'; const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin'; // The first admin *user's* password (cn=,ou=people,). Distinct from // ADMIN_PASS above, which is the LDAP *root* (cn=admin,) bind password — // two different accounts, two different secrets. const ADMIN_USER_PASS = requireConf((sso.bootstrap && sso.bootstrap.adminPass), 'bootstrap.adminPass'); const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || ''; const SVC_PASS = requireConf(sso.serviceAccountPass, 'serviceAccountPass'); const SSO_HOST = (sso.stack && sso.stack.ssoHost) || 'sso.example.com'; const PROXY_HOST = (sso.stack && sso.stack.proxyHost) || 'proxy.example.com'; // OAuth client creds the proxy will use. The SSO generates these on create; // proxy-secrets.js starts with placeholders, and this script writes the real // values back (writeProxyCreds below). const EXISTING_ID = (proxy.oidc && proxy.oidc.clientId) || ''; const EXISTING_SECRET = (proxy.oidc && proxy.oidc.clientSecret) || ''; const PLACEHOLDER = /^set-me$|^$/; 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:///__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 // per-console admin groups still used by the SSO UI. god_admin is nested into // 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'); const out = (k, v) => process.stdout.write(`${k}=${v}\n`); // ── OpenBao (Vault) writes ─────────────────────────────────────────────────── // bootstrap generates the proxy's + jump host's OAuth client creds and writes // them back into /config/*-secrets.js (the file fallback). It ALSO writes the // complete conf into OpenBao so the proxy + jump host load it from there at // boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this // exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write // is skipped with a warning — the file remains the fallback and bootstrap does // not fail the bring-up over it. const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; const VAULT_TOKEN = process.env.VAULT_TOKEN || ''; // Re-require a /config module after its file has been rewritten on disk // (require caches the old contents otherwise). function freshRequire(p) { delete require.cache[require.resolve(p)]; return require(p); } // PUT (replace) the data at secret/data/ with `data`. Warn-only. async function baoPut(vaultPath, data) { if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; } try { const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, { method: 'POST', headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ data }), }); if (!res.ok) { const text = await res.text().catch(() => ''); log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`); } else { log(`OpenBao: wrote secret/${vaultPath}`); } } catch (e) { log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`); } } // Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the // directory stores passwords the SSO can verify on bind (pw-sha2 module). function hashPasswordSSHA512(password) { const salt = crypto.randomBytes(8); const hash = crypto.createHash('sha512').update(password).update(salt).digest(); return '{SSHA512}' + Buffer.concat([hash, salt]).toString('base64'); } // Run an openldap client binary; returns {code, stdout, stderr}. Does not throw // on non-zero (ldapsearch exits 32 for "no such object", which we branch on). function ldap(bin, args, ldif) { try { const stdout = execFileSync(bin, args, { input: ldif ? Buffer.from(ldif) : undefined, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, LDAPTLS_REQCERT: 'never' }, }); return { code: 0, stdout, stderr: '' }; } catch (e) { return { code: e.status || 1, stdout: (e.stdout || '').toString(), stderr: (e.stderr || '').toString() }; } } const bindArgs = (extra) => ['-x', '-H', LDAP_URL, '-D', BIND_DN, '-w', ADMIN_PASS, ...(extra || [])]; function entryExists(dn) { const r = ldap('ldapsearch', bindArgs(['-b', dn, '-s', 'base', '(objectClass=*)', 'dn'])); return r.code === 0; } function ldapAdd(ldif) { return ldap('ldapadd', bindArgs(), ldif); } function ldapModify(ldif) { return ldap('ldapmodify', bindArgs(), 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 — 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 (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()}`); } // 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 (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 ───────────────────────────────────────────────────── function ensureAdmin() { const pw = hashPasswordSSHA512(ADMIN_USER_PASS); if (entryExists(ADMIN_DN)) { log(`Admin ${ADMIN_DN} exists — resetting password to ./config and ensuring groups`); ldapModify([ `dn: ${ADMIN_DN}`, 'changetype: modify', 'replace: userPassword', `userPassword: ${pw}`, '', ].join('\n')); } else { log(`Creating admin ${ADMIN_DN}`); const entry = [ `dn: ${ADMIN_DN}`, 'objectClass: inetOrgPerson', 'objectClass: posixAccount', 'objectClass: top', `cn: ${ADMIN_UID}`, `sn: Admin`, `uid: ${ADMIN_UID}`, 'uidNumber: 10000', 'gidNumber: 10000', `homeDirectory: /home/${ADMIN_UID}`, `userPassword: ${pw}`, ]; if (ADMIN_EMAIL) entry.push(`mail: ${ADMIN_EMAIL}`); entry.push(''); const r = ldapAdd(entry.join('\n')); if (r.code !== 0) throw new Error(`ldapadd admin failed: ${r.stderr.trim()}`); } // Ensure group membership (idempotent — ignore "value already exists"). for (const g of ADMIN_GROUPS) { const groupDn = `cn=${g},ou=groups,${BASE_DN}`; const r = ldapModify([ `dn: ${groupDn}`, 'changetype: modify', 'add: member', `member: ${ADMIN_DN}`, '', ].join('\n')); if (r.code === 0) log(` added ${ADMIN_UID} to ${g}`); else if (/already exists|Type or value exists/i.test(r.stderr)) log(` already in ${g}`); else log(` group ${g} warning:`, r.stderr.trim()); } } // ── 3. Login as the admin (validates the password) ────────────────────────── async function login() { const res = await fetch(`${SSO_INTERNAL}/api/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_USER_PASS }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`admin login failed (${res.status}): ${text}`); } const data = await res.json(); if (!data.token) throw new Error(`admin login returned no token: ${JSON.stringify(data)}`); log(`Logged in as ${ADMIN_UID}`); return data.token; } // ── 4. OAuth client for the proxy ─────────────────────────────────────────── async function listClients(token) { const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, { headers: { 'auth-token': token }, }); if (!res.ok) throw new Error(`list OAuth clients failed (${res.status})`); const data = await res.json(); return (data && data.results) || []; } async function createClient(token, opts) { 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' }, body: JSON.stringify({ name: o.name, description: o.description, redirect_uris: o.redirect_uris, scopes: ['openid', 'profile', 'email', 'groups'], allowed_groups: [], }), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`create OAuth client failed (${res.status}): ${text}`); } const data = await res.json(); const id = (data.results && data.results.client_id) || data.client_id; const secret = data.client_secret; if (!id || !secret) throw new Error(`create OAuth client returned no id/secret: ${JSON.stringify(data)}`); log(`Created OAuth client ${o.name} (${id})`); 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', headers: { 'auth-token': token }, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`rotate OAuth client failed (${res.status}): ${text}`); } const data = await res.json(); if (!data.client_secret) throw new Error(`rotate returned no secret: ${JSON.stringify(data)}`); log(`Rotated secret for OAuth client ${id}`); return { id, secret: data.client_secret }; } // ── 5. Seed the SSO directory with the stack's own resources ──────────────── // The Directory page (site → host → service hierarchy) starts empty even // though this stack knows exactly what it deployed. Seed it: one site (the // domain), one host (the box this stack runs on), and the two services // (SSO Manager + proxy), then link the proxy's OAuth client under its // service. Idempotent — existing slugs are left untouched, so operator // edits (renames, metadata, extra resources) survive re-runs. Failures // here only warn: the directory is a nicety, never worth failing a // bring-up over (e.g. an older sso-manager image without /api/directory). const DOMAIN = (sso.stack && sso.stack.ldapDomain) || ''; const ORG = sso.name || 'SSO Manager'; const slugify = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); async function dirGet(token, path) { const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, { headers: { 'auth-token': token }, }); if (!res.ok) throw new Error(`GET /api/directory-admin/${path} failed (${res.status})`); return res.json(); } async function dirPost(token, path, body) { const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, { method: 'POST', headers: { 'auth-token': token, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`POST /api/directory-admin/${path} failed (${res.status}): ${text}`); } return res.json(); } async function dirPut(token, path, body) { const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, { method: 'PUT', headers: { 'auth-token': token, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`PUT /api/directory-admin/${path} failed (${res.status}): ${text}`); } return res.json(); } async function dirDelete(token, path) { const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, { method: 'DELETE', headers: { 'auth-token': token }, }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`DELETE /api/directory-admin/${path} failed (${res.status}): ${text}`); } return res.json(); } // The site the stack registers itself under. Also the default "Location // (Site)" that ldap-client-joined Linux hosts attach to (parent slug // site_ — see ldap-client/index.sh), so the slugs must line up. const SITE_NAME = (sso.stack && sso.stack.siteName) || 'local'; // Host facts, collected by setup.sh ON THE HOST (inside this container // hostname/uname describe the container) and passed via the exec env. Same // fields ldap-client/index.sh registers, so stack hosts and ldap-client- // joined hosts carry identical metadata. const HOST_FACTS = { name: process.env.STACK_HOST_NAME || '', ip: process.env.STACK_HOST_IP || '', mac: process.env.STACK_HOST_MAC || '', os: process.env.STACK_HOST_OS || '', kernel: process.env.STACK_HOST_KERNEL || '', }; 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 // it doesn't have yet are filled in — operator-set values always win and // are never overwritten. async function ensure(kind, name, slug, parentId, metadata, altSlugs) { const slugs = [slug, ...(altSlugs || [])]; const found = resources.find((r) => slugs.includes(r.slug)); if (found) { const have = found.metadata || {}; const missing = Object.entries(metadata || {}) .filter(([k, v]) => (have[k] === undefined || have[k] === '') && v !== ''); if (missing.length) { const merged = { ...have }; for (const [k, v] of missing) merged[k] = v; // metadata-only PUT: no kind/hostId in the body, so the route's // parent validation and edge rewiring are not triggered. await dirPut(token, `resources/${found.id}`, { metadata: merged }); found.metadata = merged; log(` directory: ${kind} '${found.slug}' exists — filled ${missing.map(([k]) => k).join(', ')}`); } else { log(` directory: ${kind} '${found.slug}' exists — keeping`); } return found; } const body = { kind, name, slug, metadata: metadata || {} }; if (parentId) body.hostId = parentId; // POST creates the parent edge const created = (await dirPost(token, 'resources', body)).results; resources.push(created); log(` directory: created ${kind} '${slug}'`); return created; } // site_ / host_ slug convention matches ldap-client/index.sh. // altSlugs grandfather in the layout the first seed release used. const site = await ensure('site', SITE_NAME, `site_${slugify(SITE_NAME)}`, null, { isCurrentSite: true }, [slugify(DOMAIN || ORG)]); const hostSlug = HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : 'stack-host'; const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, { subType: 'linux', ip: HOST_FACTS.ip, address: HOST_FACTS.ip, macAddress: HOST_FACTS.mac, os: HOST_FACTS.os, kernel: HOST_FACTS.kernel, sshPort: 22, managed: true, }, ['stack-host']); // "Host" means a real, independently-existing machine — something with its // own OS and sshd, that theta-agent or a directory-aware tool like the jump // host could actually reach on its own. A Docker container backing one of // this stack's own services is never that, no matter how convenient it'd be // to group its services under a host-shaped node in the UI: it has no sshd, // no independent network identity, nothing jump-host could honestly offer // as an SSH target. Proxy and jump-host are two of this stack's five // containers, running on the one real host above (`host`) — not machines of // their own. Briefly (2026-08-05 through the next release) this file seeded // `host_theta-proxy` / `host_theta-jump` as first-class `kind: 'host'` // resources to fix their services being parented to the stack host; that // solved the parenting problem with the wrong tool. The right tool already // existed: `kind: 'container'` (see seedPlugins' Docker discovery, which // already attaches `docker-theta-suite-proxy` etc. under these services // correctly) sits one layer below `service`, same as `sso-manager` and // `openbao` already do. So: no synthetic hosts — Proxy's and jump-host's // services parent directly onto the stack host, same as everything else. await ensure('service', 'SSO Manager', 'sso-manager', host.id, { address: `https://${SSO_HOST}`, port: 3001, gitRepo: 'https://github.com/theta42/sso-manager-node', subType: 'web', icon: 'mdi:shield-account', tagline: 'Home-lab identity and access management.', requestable: false, }); // Proxy = the node management UI; OpenResty = the data plane every hostname // in the stack actually flows through (80/443). Two faces, two entries, both // parented directly to the stack host — see the "Host means..." note above. const psvc = await ensure('service', 'Proxy', 'proxy', host.id, { address: `https://${PROXY_HOST}`, port: 3000, gitRepo: 'https://github.com/theta42/proxy', subType: 'web', icon: 'mdi:server-network', tagline: 'Reverse proxy and API gateway.', requestable: false, }); // OpenLDAP is independently consumed — Linux hosts authenticate against it // (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly // (see the SSO's /integrations page) — so it gets its own entry. Advertise // the operator-configured LDAPS hostname when set, else the SSO host. // The bundled slapd's image/config live in sso-manager-node. const LDAPS_HOST = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST; await ensure('service', 'OpenLDAP Directory', 'openldap', host.id, { address: `ldaps://${LDAPS_HOST}:636`, port: 389, externalPort: 636, portMappings: [{ proto: 'tcp', external: 636, internal: 389, comment: 'LDAPS' }], gitRepo: 'https://github.com/theta42/sso-manager-node', subType: 'openldap', icon: 'mdi:book-open-outline', tagline: 'LDAP directory for identity.', requestable: false, }); // 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, { address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`, port: 443, gitRepo: 'https://github.com/theta42/proxy', subType: 'openresty', icon: 'mdi:router-network', tagline: 'Data plane.', requestable: false, }); // Remove or set ignored on OpenBao/bao-renewer seed resources if present — OpenBao is an // internal stack service, not a user-facing published directory service. for (const r of resources) { if (r.slug === 'openbao' || r.slug === 'openboa' || r.slug === 'bao-renewer' || (r.name && (/openbao|openboa|bao-renewer/i.test(r.name)))) { await dirDelete(token, `resources/${r.id}`).catch(() => {}); } } // 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, { address: jumpHost ? `https://${jumpHost}` : '', port: 3002, gitRepo: 'https://github.com/theta42/jump-host', subType: 'ssh', icon: 'mdi:ssh', tagline: 'Secure SSH jump host.', requestable: false, }); } // Correct installs seeded between 2026-08-05 and this release, where Proxy's // and jump-host's services were parented to now-removed synthetic // `host_theta-proxy` / `host_theta-jump` resources instead of the stack // host. Look them up by slug (never created going forward) rather than // `ensure`-ing them back into existence: on any install that never had // them, or already got corrected, this is a no-op. const proxyHostRes = resources.find((r) => r.slug === 'host_theta-proxy'); const jumpHostRes = resources.find((r) => r.slug === 'host_theta-jump'); if (proxyHostRes) { await reparent(psvc, host.id, proxyHostRes.id); await reparent(resources.find((r) => r.slug === 'openresty'), host.id, proxyHostRes.id); } if (jumpHostRes) { await reparent(jumpSvc, host.id, jumpHostRes.id); } // Once childless, the synthetic host itself is dead weight from this file's // own earlier mistake — never something an operator would hand-create at // these exact reserved slugs — so remove it. DELETE /resources/:id clears // its own edges first, so this is safe now that the reparents above have // already moved the real children off of it. async function removeIfChildless(resource, label) { if (!resource) return; const stillHasChildren = edges.some((e) => e.parentId === resource.id); if (stillHasChildren) { log(` directory: '${label}' still has children after reparenting — leaving it for now`); return; } await dirDelete(token, `resources/${resource.id}`); log(` directory: removed now-empty synthetic host '${label}'`); } await removeIfChildless(proxyHostRes, 'host_theta-proxy'); await removeIfChildless(jumpHostRes, 'host_theta-jump'); // 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 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}'`); } } await linkOauthClient(clientId, psvc, 'proxy'); await linkOauthClient(jumpClientId, jumpSvc, 'jump-host'); } // ── Plugin instances ──────────────────────────────────────────────────────── // Seed a sensible default set of plugin instances so the stack is usable the // moment it boots, without the operator having to add them by hand. The setup // stack runs on Docker, so the single biggest win is a Docker discovery plugin // pointed at the local daemon socket: containers that make up the stack (and // any others on the host) get discovered into the Directory automatically. // Idempotent per slug: an instance an operator already created is left alone. async function seedPlugins(token) { async function pluginGet(path) { const res = await fetch(`${SSO_INTERNAL}/api/plugins/${path}`, { headers: { 'auth-token': token }, }); if (!res.ok) throw new Error(`GET /api/plugins/${path} failed (${res.status})`); return res.json(); } async function pluginPost(body) { const res = await fetch(`${SSO_INTERNAL}/api/plugins/`, { method: 'POST', headers: { 'auth-token': token, 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); if (!res.ok) { const text = await res.text().catch(() => ''); throw new Error(`POST /api/plugins failed (${res.status}): ${text}`); } return res.json(); } async function ensurePlugin({ pluginType, name, slug, config }) { const existing = ((await pluginGet('')).results) || []; if (existing.some((i) => i.slug === slug)) { log(` plugins: '${slug}' exists — keeping`); return; } await pluginPost({ pluginType, name, slug, config }); log(` plugins: created '${slug}' (${pluginType})`); } try { // The Docker daemon the setup stack itself runs under. The socket must be // mounted into the sso container for discovery to reach it; if it isn't, // discovery simply errors non-fatally until it is. await ensurePlugin({ pluginType: 'docker', name: 'Local Docker daemon', slug: 'docker-local', 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`); } } // Write the OAuth client creds back into /config/proxy-secrets.js so the proxy // (which reads that file) can use them. Only the clientId/clientSecret lines // are touched; the rest of the file (operator edits, comments) is preserved. // Handles single- or double-quoted values. Creds are UUIDs — no quotes in them. function writeProxyCreds(id, secret) { const path = '/config/proxy-secrets.js'; let src; try { src = fs.readFileSync(path, 'utf8'); } catch (e) { log(`WARNING: cannot read ${path} to write creds back (${e.message}) — update proxy-secrets.js manually with clientId=${id}`); return false; } const before = src; src = src.replace(/(clientId:\s*)(['"])[^'"]*\2/, `$1$2${id}$2`); src = src.replace(/(clientSecret:\s*)(['"])[^'"]*\2/, `$1$2${secret}$2`); if (src === before) { log(`WARNING: could not locate clientId/clientSecret in ${path} — update it manually with clientId=${id} clientSecret=${secret}`); return false; } try { fs.writeFileSync(path, src); log(`Wrote OAuth client creds into ${path}`); return true; } catch (e) { log(`WARNING: cannot write ${path} (${e.message}) — is ./config mounted read-write on sso-manager? Update proxy-secrets.js manually with clientId=${id} clientSecret=${secret}`); return false; } } // 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 // that can WRITE the sshPublicKey attribute (it injects its own key on first // use), and a config file it reads. We write /config/jump-secrets.js deriving // LDAP/site from sso-secrets.js + a freshly minted API token. The bundled jump // host binds as cn=admin (already able to write sshPublicKey) — hardened // bare-metal deployments should use a scoped account + attribute ACL instead // (see the jump-host README). Idempotent: skips if the file already has a real // token. const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); const JUMP_SECRETS = '/config/jump-secrets.js'; 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, 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: 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(); const raw = data.token || (data.results && data.results.token) || data.raw_token; if (!raw) throw new Error(`API token response had no token: ${JSON.stringify(data)}`); return raw; } // The generated file is "complete" only if it has BOTH a real directory API // token AND an OIDC client id — an existing file from the pre-OIDC layout (a // token but no oidc block) is regenerated so the web UI's SSO login works. function jumpFileComplete() { try { const src = fs.readFileSync(JUMP_SECRETS, 'utf8'); const hasToken = /apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src); const hasOidc = /clientId:\s*['"][0-9a-f-]{8,}['"]/.test(src); return hasToken && hasOidc; } catch (_) { return false; } } function writeJumpSecrets(apiToken, oidc, localAdminPass) { const siteName = (sso.stack && sso.stack.siteName) || 'local'; const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST; const body = `'use strict'; // Generated by theta-suite bootstrap. The jump host reads this via // @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the // sshPublicKey attribute (key injection); for a hardened deployment use a // scoped account with an sshPublicKey write-ACL instead (see jump-host README). module.exports = { \tldap: { \t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always \t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts \t\t// treats a non-empty tlsOptions as "use implicit TLS" regardless of the \t\t// URL scheme -- pointed at the plain port, that means it opens a raw TLS \t\t// handshake against a server expecting plaintext LDAP, which slapd just \t\t// drops (logged as "connection lost", no BIND ever attempted). This bit \t\t// jump-host silently: every SSH login failed with the generic \t\t// "Permission denied" for any password, because getUser()/checkPassword() \t\t// never even reached slapd. \t\turl: 'ldaps://sso-manager:636', \t\tbindDN: ${JSON.stringify(BIND_DN)}, \t\tbindPassword: ${JSON.stringify(ADMIN_PASS)}, \t\tuserBase: ${JSON.stringify(`ou=people,${BASE_DN}`)}, \t\tgroupBase: ${JSON.stringify(`ou=groups,${BASE_DN}`)}, \t\ttlsOptions: { rejectUnauthorized: false }, \t}, \tsso: { \t\turl: 'http://sso-manager:3001', \t\tapiToken: ${JSON.stringify(apiToken)}, \t}, \tssh: { \t\tlistenPort: 2222, \t\thostKeyPath: '/var/lib/jump-host/keys', \t\tpasswordAuth: 'off', \t\tkeyComment: ${JSON.stringify(`jump-host@${siteName}`)}, \t}, \tweb: { port: 3002 }, \t// Web UI SSO login — the jump host's own OAuth client. tokenEndpoint / \t// userinfoEndpoint use the internal docker-net address (server-to-server); \t// authorizationEndpoint is the public SSO host (browser-facing). \toidc: { \t\tenabled: true, \t\tissuer: ${JSON.stringify(`https://${SSO_HOST}`)}, \t\tauthorizationEndpoint: ${JSON.stringify(`https://${SSO_HOST}/oauth/authorize`)}, \t\ttokenEndpoint: 'http://sso-manager:3001/oauth/token', \t\tuserinfoEndpoint: 'http://sso-manager:3001/oauth/userinfo', \t\tclientId: ${JSON.stringify(oidc.id)}, \t\tclientSecret: ${JSON.stringify(oidc.secret)}, \t\tredirectUri: ${JSON.stringify(JUMP_REDIRECT_URI)}, \t\tscopes: ['openid', 'profile', 'email', 'groups'], \t\tgroupsClaim: 'groups', \t\tusernameClaim: 'preferred_username', \t}, \tauth: { \t\tadminGroups: ['app_sso_admin'], \t\tadminUsers: ['jumpadmin'], \t\tlocalAdminPass: ${JSON.stringify(localAdminPass)}, \t}, \tredis: { prefix: 'jump_host_', redisConf: { url: 'redis://127.0.0.1:6379' } }, \tstack: { ssoHost: ${JSON.stringify(SSO_HOST)}, jumpHost: ${JSON.stringify(JUMP_HOST)}, ldapsHost: ${JSON.stringify(ldapsHost)} }, }; `; fs.writeFileSync(JUMP_SECRETS, body, { mode: 0o600 }); } // Returns the jump host's OAuth client id (so seedDirectory can link it under // the SSH Jump Host service), whether or not this run actually wrote a fresh // jump-secrets.js -- otherwise re-runs on an already-configured deployment // never get a chance to self-heal a missing directory link (see the "no // parent" bug this was written for). async function provisionJumpHost(token) { if (jumpFileComplete()) { log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.'); const clients = await listClients(token); const existing = clients.find((c) => c.name === JUMP_CLIENT_NAME); return existing ? existing.client_id : null; } const apiToken = await mintApiToken(token, JUMP_TOKEN_NAME); // Mint (or reuse) the jump host's own OAuth client for web-UI SSO login. const clients = await listClients(token); let oidc = clients.find((c) => c.name === JUMP_CLIENT_NAME); if (oidc && oidc.client_id) { oidc = await rotateClient(token, oidc.client_id); oidc = { id: oidc.id, secret: oidc.secret }; } else { oidc = await createClient(token, { name: JUMP_CLIENT_NAME, description: 'theta-suite jump host web UI (auto-registered)', redirect_uris: [JUMP_REDIRECT_URI], }); } const localAdminPass = crypto.randomBytes(16).toString('hex'); writeJumpSecrets(apiToken, oidc, localAdminPass); log(`Jump host: wrote /config/jump-secrets.js (API token + OAuth client ${oidc.id}).`); log(`Jump host: local admin 'jumpadmin' password: ${localAdminPass}`); return oidc.id; } (async function main() { try { log(`Base DN: ${BASE_DN}`); ensureServiceAccount(); ensureAdmin(); const token = await login(); const list = await listClients(token); // Find the proxy's client: by id if we have usable creds, else by name. let resolvedClientId = ''; let client = null; 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 // was working with it). Keep the file as-is. log(`OAuth client ${CLIENT_NAME} (${EXISTING_ID}) exists and proxy-secrets.js has its creds — keeping`); out('CLIENT_ID', EXISTING_ID); out('CLIENT_SECRET', EXISTING_SECRET); out('ALREADY_CONFIGURED', '1'); resolvedClientId = EXISTING_ID; } else if (client) { // Client exists but the file has no recoverable secret for it — rotate // so the proxy gets a fresh secret it can actually read, then write back. log(`OAuth client ${CLIENT_NAME} (${client.client_id}) exists but proxy-secrets.js has no usable secret — rotating + writing back`); const { id, secret } = await rotateClient(token, client.client_id); writeProxyCreds(id, secret); out('CLIENT_ID', id); out('CLIENT_SECRET', secret); out('ALREADY_CONFIGURED', '0'); resolvedClientId = id; } else { // No client yet — create one and write the generated creds back. const { id, secret } = await createClient(token); writeProxyCreds(id, secret); out('CLIENT_ID', id); out('CLIENT_SECRET', secret); out('ALREADY_CONFIGURED', '0'); 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 // `proxy` object. setup.sh's seed already put a placeholder version // here; this replaces it with the complete file (operator edits + // generated OAuth creds). Warn-only. await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js')); // Provision the jump host (mint token + write config). Warn-only — never // fail the whole bring-up over it, but it's a core component so always // attempted (no longer gated by CFG_JUMP_HOST_ENABLED). let jumpClientId = null; try { jumpClientId = await provisionJumpHost(token); out('JUMP_HOST_CONFIGURED', '1'); // Mirror jump-secrets.js (just written by provisionJumpHost) into // OpenBao so the jump host loads it from there at boot via // @simpleworkjs/bao-conf. setup.sh's seed may have put a // placeholder/stale version here; this replaces it with the // complete file (LDAP bind, minted API token, OAuth client). // Warn-only. await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS)); } catch (e) { 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 { log('Seeding directory resources...'); await seedDirectory(token, resolvedClientId, jumpClientId); } catch (e) { log(`WARNING: directory seed failed (${e.message || e}) — continuing`); } // Seed default plugin instances (Docker discovery) — same warn-and-go // policy; a stack without plugins is still usable. try { log('Seeding default plugins...'); await seedPlugins(token); } catch (e) { log(`WARNING: plugin seed failed (${e.message || e}) — continuing`); } log('Done.'); process.exit(0); } catch (e) { process.stderr.write(`[bootstrap] ERROR: ${e.message || e}\n`); process.exit(1); } })();