From 3e85e37b63052c043a6b8b17fcc5f9a5bb8d07cc Mon Sep 17 00:00:00 2001 From: William Mantly Date: Thu, 23 Jul 2026 03:18:20 -0400 Subject: [PATCH] feat: site name config, host facts, and service ports/repos in the directory seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CFG_SITE_NAME in setup.env (below CFG_DOMAIN, default "local") names the directory site; slug site_ matches ldap-client's parentSlug convention so joined Linux hosts land under the same site. Wired through sso-secrets.js stack.siteName. - setup.sh collects host facts ON THE HOST (hostname, IP, default-route MAC, OS pretty-name, kernel — same collection as ldap-client/index.sh) and passes them into the bootstrap exec env; the stack host is now registered as host_ with that metadata (subType linux). - Services carry their internal port and git repo in metadata (sso-manager 3001, proxy 3000, openldap 389/ext 636, openresty 443), using the metadata keys the directory UI natively displays. - ensure() now adopts resources from the earlier seed layout (alt slugs 'stack-host' / domain-slug site) and back-fills missing seed metadata via a metadata-only PUT — operator-set values are never overwritten. Verified against a live app: old-layout resources are adopted and back-filled (no duplicates), fresh seed creates the full graph, and a second pass changes nothing. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + bootstrap/bootstrap.js | 93 ++++++++++++++++++++++++++++++++++++++---- setup.env.example | 6 +++ setup.sh | 23 ++++++++++- 4 files changed, 114 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e359414..1a9d898 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ for what changed inside the apps it composes. ## [Unreleased] ### Added +- `CFG_SITE_NAME` in `setup.env` (right below `CFG_DOMAIN`, default `local`): names the SSO directory site the stack registers itself under — slug `site_`, matching the `parentSlug` convention ldap-client-joined Linux hosts use, so they land under the same site. +- The directory seed now collects real host facts on the machine (hostname, IP, MAC of the default-route interface, OS pretty-name, kernel — same collection as `ldap-client/index.sh`) and registers the stack host as `host_` with that metadata, plus fills in each service's internal port and git repo (`sso-manager` 3001, `proxy` 3000, `openldap` 389/636, `openresty` 443). Existing resources from the earlier seed layout (`stack-host`, domain-slug site) are adopted in place — seed metadata only fills fields the operator hasn't set, never overwrites. - The bootstrap now seeds the SSO directory with the stack's own resources: a site (from the configured domain), a "Stack host", and the SSO Manager + Proxy services (with their public URLs in metadata), linking the proxy's auto-registered OAuth client under its service. Also seeds the two non-obvious services the stack runs: the OpenLDAP directory (advertising the `ldaps://` endpoint Linux hosts and LDAP-native apps bind to, honoring `ldap.ldapsHost`) and the OpenResty edge (the 80/443 data plane every hostname flows through, with a wildcard `https://*.` address). The Directory page is populated out of the box instead of starting empty. Idempotent — resources whose slug already exists are operator-owned and never touched, and a seed failure only warns (never fails a bring-up, e.g. against an older sso-manager image without `/api/directory`). ## [1.3.3] - 2026-07-23 diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index 66ae296..878ef31 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -303,14 +303,61 @@ async function dirPost(token, path, body) { 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 already exists (operator-owned then). - async function ensure(kind, name, slug, parentId, metadata) { - const found = resources.find((r) => r.slug === slug); + // 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) { - log(` directory: ${kind} '${slug}' exists — keeping`); + 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 || {} }; @@ -321,25 +368,53 @@ async function seedDirectory(token, clientId) { return created; } - const site = await ensure('site', ORG, slugify(DOMAIN || ORG), null, {}); - const host = await ensure('host', 'Stack host', 'stack-host', site.id, {}); - await ensure('service', 'SSO Manager', 'sso-manager', host.id, { address: `https://${SSO_HOST}` }); + // 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}` }); + 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). + // */** 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', }); diff --git a/setup.env.example b/setup.env.example index 0df1564..c35190b 100644 --- a/setup.env.example +++ b/setup.env.example @@ -21,6 +21,12 @@ # setup.sh refuses to run without it. CFG_DOMAIN=example.com +# Site name for the SSO directory — the root node this stack registers itself +# under on the Directory page, and the default "Location (Site)" that Linux +# hosts joined via ldap-client attach to (parent slug: site_). +# Optional — defaults to "local". +#CFG_SITE_NAME=local + # Public hostnames. Optional — default to sso. / proxy. derived # from CFG_DOMAIN above. Uncomment and set only if your hostnames differ # (e.g. a different subdomain, or the domain isn't the bare apex): diff --git a/setup.sh b/setup.sh index f0f0aed..21c590a 100755 --- a/setup.sh +++ b/setup.sh @@ -265,6 +265,7 @@ module.exports = { stack: { ldapBaseDn: $(js_str "$dn"), ldapDomain: $(js_str "$domain"), + siteName: $(js_str "${CFG_SITE_NAME:-local}"), ldapCertCn: $(js_str "${CFG_LDAP_CERT_CN:-}"), ssoHost: $(js_str "$CFG_SSO_HOST"), proxyHost: $(js_str "$CFG_PROXY_HOST"), @@ -355,6 +356,7 @@ ensure_config() { # derivation block further down (no example.com placeholders here). CFG_BASE_DN="${CFG_BASE_DN:-}" CFG_DOMAIN="${CFG_DOMAIN:-}" + CFG_SITE_NAME="${CFG_SITE_NAME:-}" CFG_ORG="${CFG_ORG:-}" CFG_SSO_HOST="${CFG_SSO_HOST:-}" CFG_PROXY_HOST="${CFG_PROXY_HOST:-}" @@ -421,6 +423,7 @@ ensure_config() { CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}" CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}" CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}" + CFG_SITE_NAME="${CFG_SITE_NAME:-local}" CFG_ORG="${CFG_ORG:-SSO Manager}" CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}" CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}" @@ -639,7 +642,25 @@ info " Admin uid: ${ADMIN_UID}" # The bootstrap reads its inputs from /config/*.js (not env) and writes the # generated OAuth client creds back into /config/proxy-secrets.js. No -e flags. info "Running bootstrap (creates/updates the LDAP service account, first admin, OAuth client)..." -BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/bootstrap.js) \ +# Host facts for the directory seed — collected HERE (on the host; inside the +# container hostname/uname describe the container, not the machine). Same +# collection as ldap-client/index.sh so stack hosts and ldap-client-joined +# hosts carry identical metadata. All best-effort: a missing tool just leaves +# the field blank. +STACK_HOST_NAME="$(hostname 2>/dev/null || true)" +STACK_HOST_IP="$(hostname -I 2>/dev/null | awk '{print $1}' || true)" +_iface="$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}' || true)" +STACK_HOST_MAC="" +[[ -n "$_iface" ]] && STACK_HOST_MAC="$(cat "/sys/class/net/$_iface/address" 2>/dev/null || true)" +STACK_HOST_OS="$( (. /etc/os-release 2>/dev/null && echo "${PRETTY_NAME:-}") || true)" +STACK_HOST_KERNEL="$(uname -r 2>/dev/null || true)" +BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \ + -e STACK_HOST_NAME="$STACK_HOST_NAME" \ + -e STACK_HOST_IP="$STACK_HOST_IP" \ + -e STACK_HOST_MAC="$STACK_HOST_MAC" \ + -e STACK_HOST_OS="$STACK_HOST_OS" \ + -e STACK_HOST_KERNEL="$STACK_HOST_KERNEL" \ + sso-manager node /bootstrap/bootstrap.js) \ || die "bootstrap failed:\n${BOOTSTRAP_OUT}" getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }