#!/usr/bin/env node /* * theta-env 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). * * 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`; 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}`; const ADMIN_GROUPS = ['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`); // 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 ─────────────────────────────────── function ensureServiceAccount() { const pw = hashPasswordSSHA512(SVC_PASS); if (entryExists(SVC_DN)) { log(`Service account ${SVC_DN} exists — resetting password to ./config`); const r = ldapModify([ `dn: ${SVC_DN}`, 'changetype: modify', 'replace: userPassword', `userPassword: ${pw}`, '', ].join('\n')); if (r.code !== 0) log(' password reset warning:', r.stderr.trim()); return; } log(`Creating service account ${SVC_DN}`); const r = ldapAdd([ `dn: ${SVC_DN}`, 'objectClass: organizationalRole', 'objectClass: simpleSecurityObject', 'objectClass: top', 'cn: ldapclient', `userPassword: ${pw}`, '', ].join('\n')); if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.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-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] }; 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 }; } 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(); } // 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) { let resources = ((await dirGet(token, 'resources')).results) || []; // 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, macAddress: HOST_FACTS.mac, os: HOST_FACTS.os, kernel: HOST_FACTS.kernel, }, ['stack-host']); 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', }); // 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, { address: `https://${PROXY_HOST}`, port: 3000, gitRepo: 'https://github.com/theta42/proxy', subType: 'web', }); // 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, gitRepo: 'https://github.com/theta42/sso-manager-node', subType: 'openldap', }); // 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', }); // Optional SSH jump host service. if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) { const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); 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', }); } // Link the proxy's OAuth client (Resource-backed since sso-manager 1.3.0) // under its service, if it appears in the directory and isn't linked yet. if (clientId) { const oauthRes = resources.find((r) => r.id === clientId); if (oauthRes) { const edges = ((await dirGet(token, 'edges')).results) || []; const linked = edges.some((e) => e.childId === clientId); if (!linked) { await dirPost(token, 'edges', { parentId: psvc.id, childId: clientId, relation: 'oauth' }); log(` directory: linked OAuth client under 'proxy'`); } } } } // 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; } } // ── 6. Optional: provision the SSH jump host ──────────────────────────────── // When CFG_JUMP_HOST_ENABLED=true, the jump host 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_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || ''); 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) { 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-env 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-env 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 = { \tname: ${JSON.stringify(sso.name || 'SSO Manager')}, \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 }); } async function provisionJumpHost(token) { if (jumpFileComplete()) { log('Jump host: /config/jump-secrets.js already has API token + OIDC client — keeping.'); return; } 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-env 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}`); } (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); 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; } // Provision the jump host (mint token + write config) when enabled. // Warn-only — never fail the whole bring-up over the optional service. if (JUMP_ENABLED) { try { await provisionJumpHost(token); out('JUMP_HOST_CONFIGURED', '1'); } catch (e) { log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`); } } // 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); } catch (e) { log(`WARNING: directory 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); } })();