Persist Redis + config in bind-mounted ./config/ (no .env); add backup/restore (#8)

Part A — lossless upgrades:
- Persist both bundled Redis stores via AOF+RDB on named volumes (sso-data,
  proxy-data) so OAuth clients, Host records, perms, DNS creds, and auto-ssl
  Let's Encrypt certs survive rebuilds.
- setup.sh: backup_before_rebuild() snapshots ./config/ + LDAP (slapcat) +
  both Redis (BGSAVE + compose cp) to ./backups/<ts>/ before each rebuild,
  keeps last BACKUP_KEEP (default 5). First run is a no-op.
- Restore runbook (README + docs): full / Redis-only / LDAP-only, with the
  AOF-vs-RDB note (delete the AOF before restoring an RDB).

Part B — eliminate .env / proxy.env:
- All config + secrets live in bind-mounted ./config/ (gitignored), read by each
  app's @simpleworkjs/conf from a symlinked secrets.js. Compose passes only
  NODE_ENV + NODE_PORT (no app_* env, which would override secrets.js).
- ./config/sso-secrets.js: app secrets + orchestrator-only stack/bootstrap/
  serviceAccountPass keys (app ignores the ones it doesn't use).
- ./config/proxy-secrets.js: oidc (clientId/clientSecret filled in by the
  bootstrap), ldap (bind creds), auth (admin groups/users).
- setup.sh ensure_config(): generates ./config/ with random secrets on first
  run (then exits for editing); one-time migration from .env/proxy.env
  preserving existing secrets (LDAP admin pass, JWT, OAuth client, service
  pass) so a running deployment keeps its directory + tokens + OAuth client.
- bootstrap/bootstrap.js: reads /config/*.js (not process.env), registers the
  proxy as an OIDC client, and writes the SSO-generated client id+secret back
  into ./config/proxy-secrets.js (sso mounts ./config RW, proxy RO).
- config.example/ holds committed annotated templates for manual reference.
- .gitignore: add config/, backups/, *.rdb, *.ldif.

Bump both gitlinks to the merged submodule tips:
- sso-manager-node -> 6920a9f (PR #34)
- proxy -> 8e78604 (PR #118)

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-12 13:16:17 -04:00
committed by GitHub
parent 2d2941e394
commit b5f24d40fc
13 changed files with 929 additions and 445 deletions
+107 -46
View File
@@ -1,54 +1,73 @@
#!/usr/bin/env node
/*
* theta-env bootstrap — runs inside the sso-manager container to wire the
* proxy into a fresh SSO Manager. Invoked by setup.sh:
* 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) + 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 from the environment;
* 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).
* 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).
*
* Idempotent: re-running converges to the .env values. The LDAP service
* account + admin passwords are reset to .env on each run; the OAuth client
* is created if missing, or rotated only if proxy.env is absent (a lost
* proxy.env needs a fresh secret the proxy can actually read).
* 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).
*
* Inputs (env, set by setup.sh from .env):
* LDAP_BASE_DN, LDAP_ADMIN_PASS — directory root creds
* BOOTSTRAP_ADMIN_UID/PASS/EMAIL — first admin to create
* LDAP_SERVICE_PASS — proxy bind account password
* SSO_HOST, PROXY_HOST — public hostnames
* PROXY_ENV_EXISTS (1|0) — set by setup.sh
* 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,
* and ALREADY_CONFIGURED. Progress logs go to stderr.
* ALREADY_CONFIGURED. Progress logs go to stderr.
*/
'use strict';
const { execFileSync } = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const BASE_DN = process.env.LDAP_BASE_DN || 'dc=example,dc=com';
const ADMIN_PASS = process.env.LDAP_ADMIN_PASS || 'admin';
const BIND_DN = `cn=admin,${BASE_DN}`;
const LDAP_URL = 'ldap://localhost:389';
// ── Read config from the mounted ./config/ (NOT env) ─────────────────────────
const sso = require('/config/sso-secrets.js');
const proxy = require('/config/proxy-secrets.js');
const ADMIN_UID = process.env.BOOTSTRAP_ADMIN_UID || 'admin';
const BASE_DN = (sso.stack && sso.stack.ldapBaseDn) || 'dc=example,dc=com';
const ADMIN_PASS = (sso.ldap && sso.ldap.bindPassword) || 'admin';
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=<uid>,ou=people,<base>). Distinct from
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password
// from LDAP_ADMIN_PASS — two different accounts, two different secrets.
const ADMIN_USER_PASS = process.env.BOOTSTRAP_ADMIN_PASS || 'admin';
const ADMIN_EMAIL = process.env.BOOTSTRAP_ADMIN_EMAIL || '';
const SVC_PASS = process.env.LDAP_SERVICE_PASS || 'service';
// ADMIN_PASS above, which is the LDAP *root* (cn=admin,<base>) bind password
// two different accounts, two different secrets.
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || 'admin';
const ADMIN_EMAIL = (sso.bootstrap && sso.bootstrap.adminEmail) || '';
const SVC_PASS = sso.serviceAccountPass || 'service';
const SSO_HOST = process.env.SSO_HOST || 'sso.example.com';
const PROXY_HOST = process.env.PROXY_HOST || 'proxy.example.com';
const PROXY_ENV_EXISTS = process.env.PROXY_ENV_EXISTS === '1';
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';
@@ -104,7 +123,7 @@ function ldapModify(ldif) {
function ensureServiceAccount() {
const pw = hashPasswordSSHA512(SVC_PASS);
if (entryExists(SVC_DN)) {
log(`Service account ${SVC_DN} exists — resetting password to .env`);
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
const r = ldapModify([
`dn: ${SVC_DN}`,
'changetype: modify',
@@ -132,7 +151,7 @@ function ensureServiceAccount() {
function ensureAdmin() {
const pw = hashPasswordSSHA512(ADMIN_USER_PASS);
if (entryExists(ADMIN_DN)) {
log(`Admin ${ADMIN_DN} exists — resetting password to .env and ensuring groups`);
log(`Admin ${ADMIN_DN} exists — resetting password to ./config and ensuring groups`);
ldapModify([
`dn: ${ADMIN_DN}`,
'changetype: modify',
@@ -194,14 +213,13 @@ async function login() {
}
// ── 4. OAuth client for the proxy ───────────────────────────────────────────
async function findClient(token) {
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();
const list = (data && data.results) || [];
return list.find((c) => c.name === CLIENT_NAME) || null;
return (data && data.results) || [];
}
async function createClient(token) {
@@ -243,6 +261,36 @@ async function rotateClient(token, id) {
return { id, secret: data.client_secret };
}
// 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;
}
}
(async function main() {
try {
log(`Base DN: ${BASE_DN}`);
@@ -250,20 +298,33 @@ async function rotateClient(token, id) {
ensureAdmin();
const token = await login();
const existing = await findClient(token);
if (!existing) {
const { id, secret } = await createClient(token);
const list = await listClients(token);
// Find the proxy's client: by id if we have usable creds, else by name.
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');
} 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');
} else if (PROXY_ENV_EXISTS) {
log(`OAuth client ${CLIENT_NAME} exists and proxy.env present — nothing to do`);
out('CLIENT_ID', existing.client_id);
out('CLIENT_SECRET', '__UNCHANGED__');
out('ALREADY_CONFIGURED', '1');
} else {
log(`OAuth client ${CLIENT_NAME} exists but proxy.env is missing — rotating secret`);
const { id, secret } = await rotateClient(token, existing.client_id);
// 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');