diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b2fcce..d2da02b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ orchestration code; see each submodule's own `CHANGELOG.md` [sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md)) for what changed inside the apps it composes. +## [v1.45.0] - 2026-08-06 + +Rolls up **sso-manager-node v1.30.2**, **proxy v1.35.1**, **jump-host v1.19.1**. + +### bootstrap.js — Directory topology fix + +**`host_theta-proxy` / `host_theta-jump` were synthetic `kind: 'host'` resources that never should have existed.** "Host" means a real, independently-existing machine — something with its own OS and sshd. A Docker container backing one of this stack's own services is never that: it has no sshd, no independent network identity. Proxy and jump-host are two of this stack's five containers, running on the one real stack host — not machines of their own. + +A 2026-08-05 change gave them their own `host` resources to fix their services being parented to the stack host, solving that parenting problem with the wrong tool — the correct one, `kind: 'container'`, already existed one layer below `service` (same as `sso-manager` and `openbao` already used correctly). Beyond being conceptually wrong, this had a real functional consequence: jump-host resolves its "hosts you can reach" list from exactly `kind: host` resources, so it could offer `theta-proxy`/`theta-jump` as SSH targets — machines that don't exist and can't be reached. + +Fixed: `bootstrap.js` no longer creates the synthetic hosts. Proxy's and jump-host's services parent directly onto the stack host, like every other component. On an install seeded between 2026-08-05 and this release, the fix self-heals on the next `./setup.sh` run — existing children are re-parented onto the real host and the now-empty synthetic host resources are removed automatically; a fresh install never creates them. + +### Docs + +- `README.md`'s architecture diagram and "Repo layout" section described a stale 2-service (sso-manager + proxy) architecture from before jump-host and OpenBao existed — updated to match the (already-accurate) `docs/architecture.md`, and listed only 2 of 5 git submodules — added the rest. +- `docs/fixtures.md` (new) — the canonical demo-fixtures reference: exact users/groups/hosts for a consistent homelab/small-business demo dataset, so future screenshot passes only need to re-capture pages whose UI actually changed. +- `docs/screenshots.md` (new) — the screenshot-capture workflow, including two gotchas hit while building it: a stale-browser-cache issue with `app.modal.js`, and never touching a login form that autofills a real saved credential. +- `bootstrap/seed-demo-users.sh` (new) — idempotent script seeding the fixtures.md user/group list via direct LDAP writes matching the app's own schema. + ## [v1.44.0] - 2026-08-06 Rolls up **sso-manager-node v1.30.1**. diff --git a/README.md b/README.md index 3f9fc48..da1d709 100644 --- a/README.md +++ b/README.md @@ -44,29 +44,40 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run: - ⏳ **Phases 2-5**: Pending (see [roadmap](#)). ``` - ┌──────────────────────────────────────────────┐ - │ your browser / apps │ - └───────────────┬──────────────────────────────┘ - │ https - ┌─────────▼─────────┐ - │ proxy │ OpenResty :80/:443/:4443 - │ (OIDC + LDAP) │ mgmt app :3000 (localhost) - └─────────┬─────────┘ bundled redis - ┌─────────────┼──────────────────────┐ - │ ldaps:636 │ http:3001 (internal)│ OIDC token/userinfo - ▼ ▼ │ - ┌──────────────────────────┐ │ - │ sso-manager │◄────────────────┘ - │ OIDC provider + OpenLDAP │ bundled redis - │ web UI :3001 (localhost) │ - │ ldaps :636 (LAN clients) │ - └───────────────────────────┘ + ┌──────────────────────────────────────────────────────────┐ + │ browser / OIDC apps │ SSH clients │ Linux hosts │ + │ │ │ (PAM/SSSD, sudo, keys) │ + └────────┬────────────┴──────┬──────┴───────────┬───────────┘ + https (:443) ssh (:2222) ldaps (:636) + │ │ │ + ┌────────▼────────┐ ┌──────────▼────────┐ │ + │ proxy │ │ jump-host │ │ + │ OpenResty │ │ sshd :2222 │ │ + │ :80/:443/:4443 │ │ web UI :3002 │ │ + │ mgmt app :3000 │ └────────┬──────────┘ │ + └────────┬─────────┘ │ OIDC + LDAP │ + │ http:3001 (internal)│ via sso-manager │ + ▼ ▼ ▼ + ┌───────────────────────────────────────────────────────┐ + │ sso-manager (Express + OpenLDAP + Redis) │ + │ OIDC provider + LDAP directory │ + │ web UI :3001 (internal) ldaps :636 (published) │ + └───────────────────────────────────────────────────────┘ + ▲ loads secrets at boot (scoped token each) + ┌───────────┴───────────────────┐ + │ openbao (KV-v2 at secret/) │ ← central secrets store + │ :8200 (internal) │ per-user + per-app KV + │ :8080 (operator UI/API) │ + └───────────────────────────────┘ ``` -The proxy fronts the SSO Manager UI under TLS and protects it with OIDC login. -It is **both** an OIDC client of the SSO (for login) **and** a direct LDAP -client (for user lookups). Legacy apps can still bind to LDAPS on the SSO -directly. +The proxy fronts the SSO Manager UI (and the jump-host web UI) under TLS and +protects them with OIDC login. It is **both** an OIDC client of the SSO (for +login) **and** a direct LDAP client (for user lookups). Legacy apps can still +bind to LDAPS on the SSO directly. See +[docs/architecture.md](docs/architecture.md) for the full diagram (ports, +secrets flow, jump-host, ldap-client) and [docs/secrets.md](docs/secrets.md) +for the OpenBao model. - **Self-service API tokens** in both apps' UIs, for scripting/CI without a browser session. - **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations. @@ -273,17 +284,19 @@ for details. ## Logs -The stack runs under Docker Compose with two services — `sso-manager` and -`proxy`. Both the Node app and, for the SSO, OpenLDAP write to the container's -stdout/stderr, so `docker compose logs` is the primary view. +The stack runs under Docker Compose with several services — `sso-manager`, +`proxy`, `jump-host`, and `openbao` (plus its `bao-renewer` sidecar). Both the +Node app and, for the SSO, OpenLDAP write to the container's stdout/stderr, so +`docker compose logs` is the primary view. ```bash -# Follow both services live +# Follow all services live docker compose logs -f # One service docker compose logs -f sso-manager docker compose logs -f proxy +docker compose logs -f jump-host # Last 200 lines and keep following docker compose logs --tail=200 -f proxy @@ -473,12 +486,15 @@ exactly in the bootstrap) so the SSO can verify them on bind. theta-suite/ ├── setup.env.example # first-run config template — cp to setup.env, set CFG_DOMAIN ├── config.example/ # committed annotated config templates (copy to ./config/) -├── docker-compose.yml # sso-manager + proxy on one bridge net +├── docker-compose.yml # sso-manager + proxy + jump-host + openbao on one bridge net ├── setup.sh # one-command idempotent bring-up (manages ./config/ + backups) ├── bootstrap/ │ └── bootstrap.js # runs in the sso-manager container ├── sso-manager-node/ # git submodule -└── proxy/ # git submodule +├── proxy/ # git submodule +├── jump-host/ # git submodule +├── ldap-client/ # git submodule (enrolls Linux hosts; also the opt-in ldap-test-host fixture) +└── theta-agent/ # git submodule ``` `./setup.sh` reads the gitignored `setup.env` on first run to generate the diff --git a/bootstrap/bootstrap.js b/bootstrap/bootstrap.js index 326c3ed..c3a3986 100644 --- a/bootstrap/bootstrap.js +++ b/bootstrap/bootstrap.js @@ -468,6 +468,18 @@ async function dirPut(token, path, body) { 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. @@ -561,29 +573,23 @@ async function seedDirectory(token, clientId, jumpClientId) { managed: true, }, ['stack-host']); - // theta-proxy and theta-jump are first-class managed host resources (their - // names match the OAuth client identities the proxy/jump apps use). They - // appear as hosts in the Directory; the per-app services below still carry - // the OAuth-client + reachability detail. - const jumpHostAddr = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); - const proxyHostRes = await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, { - subType: 'linux', - address: `https://${PROXY_HOST}`, - port: 3000, - gitRepo: 'https://github.com/theta42/proxy', - icon: 'mdi:server-network', - tagline: 'Reverse proxy and API gateway (node management UI).', - managed: true, - }); - const jumpHostRes = await ensure('host', 'theta-jump', 'host_theta-jump', site.id, { - subType: 'ssh', - address: jumpHostAddr ? `https://${jumpHostAddr}` : '', - port: 3002, - gitRepo: 'https://github.com/theta42/jump-host', - icon: 'mdi:ssh', - tagline: 'Secure SSH jump host.', - managed: true, - }); + // "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}`, @@ -595,12 +601,9 @@ async function seedDirectory(token, clientId, jumpClientId) { 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 parent to host_theta-proxy, not to the stack host: the whole point of - // seeding that host resource is that the proxy's services hang off it. Seeded - // under the stack host until 2026-08-05, which left host_theta-proxy and - // host_theta-jump childless while their services sat under the wrong parent. - const psvc = await ensure('service', 'Proxy', 'proxy', proxyHostRes.id, { + // 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', @@ -629,7 +632,7 @@ async function seedDirectory(token, clientId, jumpClientId) { // Wildcard address: OpenResty fronts every host under the domain (same // */** wildcard convention the proxy's Host records use). Its config lives // in the proxy repo (ops/nginx_conf). - await ensure('service', 'OpenResty Edge', 'openresty', proxyHostRes.id, { + await ensure('service', 'OpenResty Edge', 'openresty', host.id, { address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`, port: 443, gitRepo: 'https://github.com/theta42/proxy', @@ -662,7 +665,7 @@ async function seedDirectory(token, clientId, jumpClientId) { let jumpSvc = null; { const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : ''); - jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', jumpHostRes.id, { + jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, { address: jumpHost ? `https://${jumpHost}` : '', port: 3002, gitRepo: 'https://github.com/theta42/jump-host', @@ -673,11 +676,39 @@ async function seedDirectory(token, clientId, jumpClientId) { }); } - // Correct installs seeded before 2026-08-05, where these three services were - // parented to the stack host rather than to the proxy/jump host resources. - await reparent(psvc, proxyHostRes.id, host.id); - await reparent(resources.find((r) => r.slug === 'openresty'), proxyHostRes.id, host.id); - await reparent(jumpSvc, jumpHostRes.id, host.id); + // 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. diff --git a/bootstrap/seed-demo-users.sh b/bootstrap/seed-demo-users.sh new file mode 100755 index 0000000..5c87690 --- /dev/null +++ b/bootstrap/seed-demo-users.sh @@ -0,0 +1,172 @@ +#!/usr/bin/env bash +# seed-demo-users.sh — Seed realistic homelab/small-business demo users + +# groups into the SSO Manager's LDAP directory, for screenshots/demos. +# +# Mirrors the schema sso-manager-node's addLdapUser/addGroup actually write +# (see nodejs/models/user_ldap.js, group_ldap.js) so accounts created here are +# indistinguishable from ones created through the UI. Idempotent: safe to +# re-run, existing entries are skipped. +# +# Usage (from theta-env/): +# docker compose exec -T sso-manager bash /bootstrap/seed-demo-users.sh +# +# Reads the real LDAP bind DN/password out of the mounted /config/sso-secrets.js +# at runtime rather than hardcoding them, so it keeps working if secrets rotate. + +set -euo pipefail + +LDAP_URL="ldap://localhost:389" +BIND_DN=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindDN)") +BIND_PW=$(node -e "console.log(require('/config/sso-secrets.js').ldap.bindPassword)") +BASE_DN=$(node -e "console.log(require('/config/sso-secrets.js').stack.ldapBaseDn)") +PEOPLE_OU="ou=people,${BASE_DN}" +GROUPS_OU="ou=groups,${BASE_DN}" + +info() { echo "[INFO] $*"; } +error() { echo "[ERROR] $*" >&2; } + +ldap_exists() { + ldapsearch -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -b "$1" -s base '(objectClass=*)' >/dev/null 2>&1 +} + +hash_password() { + node -e " + const crypto = require('crypto'); + const salt = crypto.randomBytes(8); + const hash = crypto.createHash('sha512').update('$1').update(salt).digest(); + console.log('{SSHA512}' + Buffer.concat([hash, salt]).toString('base64')); + " +} + +# create_person [description] +create_person() { + local uid="$1" sn="$2" given="$3" mail="$4" uidnum="$5" pass="$6" desc="${7:-}" + local person_dn="cn=${uid},${PEOPLE_OU}" + local group_dn="cn=${uid},${GROUPS_OU}" + + if ldap_exists "$person_dn"; then + info "User '${uid}' already exists — skipping" + return 0 + fi + + local hash; hash=$(hash_password "$pass") + local tmp; tmp=$(mktemp) + trap 'rm -f "$tmp"' RETURN + + cat > "$tmp" < +create_group() { + local cn="$1" owner_dn="$2" desc="$3" + local group_dn="cn=${cn},${GROUPS_OU}" + + if ldap_exists "$group_dn"; then + info "Group '${cn}' already exists — skipping" + return 0 + fi + + ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" < +add_member() { + local cn="$1" user_dn="$2" + local group_dn="cn=${cn},${GROUPS_OU}" + ldapmodify -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" 2>/dev/null </dev/null 2>&1 && break + [ "$i" -eq 30 ] && { error "LDAP not reachable"; exit 1; } + sleep 1 +done + +# ── Demo users (homelab / small-business cast) ─────────────────────────────── +# uidNumbers start at 5000 to stay well clear of the app's own auto-assigned +# range (nextPosixId scans existing entries and increments from the highest). +# See docs/fixtures.md for the canonical list this mirrors — update both +# together. +create_person schen Chen Sarah sarah.chen@laptop-dev.vm42.us 5000 'DemoPass123!' 'Engineering — DevOps lead' +create_person dkim Kim David david.kim@laptop-dev.vm42.us 5001 'DemoPass123!' 'Engineering — Backend developer' +create_person ppatel Patel Priya priya.patel@laptop-dev.vm42.us 5002 'DemoPass123!' 'Engineering — Frontend developer' +create_person mjohnson Johnson Marcus marcus.johnson@laptop-dev.vm42.us 5003 'DemoPass123!' 'Finance — Finance manager' +create_person lnguyen Nguyen Linda linda.nguyen@laptop-dev.vm42.us 5004 'DemoPass123!' 'Finance — Bookkeeper' +create_person erodriguez Rodriguez Emily emily.rodriguez@laptop-dev.vm42.us 5005 'DemoPass123!' 'Support — Support lead' +create_person tbaker Baker Tom tom.baker@laptop-dev.vm42.us 5006 'DemoPass123!' 'Support — Support tech' +create_person jwilson Wilson James james.wilson@laptop-dev.vm42.us 5007 'DemoPass123!' 'Management — Owner' +create_person svc-monitoring Bot monitoring monitoring@laptop-dev.vm42.us 5008 'ServiceAcct!2024' 'Service account — Grafana/Prometheus scraping' +create_person svc-backup Bot backup backup@laptop-dev.vm42.us 5009 'ServiceAcct!2024' 'Service account — backup automation' + +# ── Department groups (groupOfNames — what shows up in Directory > Groups) ── +ADMIN_DN="cn=admin,${PEOPLE_OU}" +create_group engineering "$ADMIN_DN" "Engineering team" +create_group finance "$ADMIN_DN" "Finance and accounting" +create_group support "$ADMIN_DN" "Support and operations" +create_group management "$ADMIN_DN" "Company management" + +add_member engineering "cn=schen,${PEOPLE_OU}" +add_member engineering "cn=dkim,${PEOPLE_OU}" +add_member engineering "cn=ppatel,${PEOPLE_OU}" +add_member finance "cn=mjohnson,${PEOPLE_OU}" +add_member finance "cn=lnguyen,${PEOPLE_OU}" +add_member support "cn=erodriguez,${PEOPLE_OU}" +add_member support "cn=tbaker,${PEOPLE_OU}" +add_member management "cn=jwilson,${PEOPLE_OU}" + +# Mark the service accounts as service accounts (app_sso_service_account is +# seeded by the app itself on boot, so it should already exist). +if ldap_exists "cn=app_sso_service_account,${GROUPS_OU}"; then + add_member app_sso_service_account "cn=svc-monitoring,${PEOPLE_OU}" + add_member app_sso_service_account "cn=svc-backup,${PEOPLE_OU}" +else + info "app_sso_service_account group not found — skipping service-account tagging" +fi + +info "Demo data seed complete." diff --git a/docs/fixtures.md b/docs/fixtures.md new file mode 100644 index 0000000..a8a0b0d --- /dev/null +++ b/docs/fixtures.md @@ -0,0 +1,175 @@ +--- +title: Canonical demo fixtures +--- + +# Canonical demo fixtures + +The exact users, groups, and hosts that should exist on a stack used for +screenshots or demos, so every future pass seeds the *same* data and a +screenshot diff only shows what actually changed in the UI — not incidental +differences in who/what happened to exist that day. + +Persona: a single admin/power-user running theta42 across a **big homelab and +a small business** — mix of self-hosted infra (Proxmox, Pi-hole, Plex) and +office-y apps (invoicing, helpdesk, wiki) with real department structure. + +Domain: `laptop-dev.vm42.us` (real public DNS pointing at this machine — see +"Domain" below). Update this doc if the domain ever changes again. + +## Users + +| uid | Name | Department | Password | Notes | +|---|---|---|---|---| +| `schen` | Sarah Chen | Engineering | `DemoPass123!` | DevOps lead | +| `dkim` | David Kim | Engineering | `DemoPass123!` | Backend developer | +| `ppatel` | Priya Patel | Engineering | `DemoPass123!` | Frontend developer | +| `mjohnson` | Marcus Johnson | Finance | `DemoPass123!` | Finance manager | +| `lnguyen` | Linda Nguyen | Finance | `DemoPass123!` | Bookkeeper | +| `erodriguez` | Emily Rodriguez | Support | `DemoPass123!` | Support lead | +| `tbaker` | Tom Baker | Support | `DemoPass123!` | Support tech | +| `jwilson` | James Wilson | Management | `DemoPass123!` | Owner | +| `svc-monitoring` | — | service account | `ServiceAcct!2024` | Grafana/Prometheus scraping | +| `svc-backup` | — | service account | `ServiceAcct!2024` | Backup automation | + +uidNumbers 5000–5009 in that order. Mail is `.@laptop-dev.vm42.us` +(service accounts use their uid, e.g. `monitoring@laptop-dev.vm42.us`). + +## Groups + +`groupOfNames`, owned by `cn=admin,...`, member of the department's users: + +- `engineering` — schen, dkim, ppatel +- `finance` — mjohnson, lnguyen +- `support` — erodriguez, tbaker +- `management` — jwilson +- `app_sso_service_account` (built-in) — svc-monitoring, svc-backup + +## Seeding users + groups + +```sh +cd theta-env +docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh +docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh +``` + +Idempotent — re-running skips anything that already exists. If you add a +fixture below, add it to `bootstrap/seed-demo-users.sh` too and keep the two +in sync. + +## Proxy hosts + +All under `*.laptop-dev.vm42.us`. `setup.sh` itself creates the first two +(sso, proxy) — everything else below is added by hand through Hosts → Add +host (Proxy UI, currently no seed script — see note at the bottom). + +| Host | Target | Auth | Notes | +|---|---|---|---| +| `sso` | `sso-manager:3001` | — | created by `setup.sh` | +| `proxy` | `127.0.0.1:3000` | — | created by `setup.sh` | +| `jump` | `jump-host:3002` | — | created by `setup.sh` | +| `proxmox` | `10.0.10.5:8006` (HTTPS) | Basic — realm "Proxmox VE", users `dkim`, `schen` | | +| `pbs` | `10.0.10.6:8007` (HTTPS) | Basic — realm "Proxmox Backup Server", user `dkim` | | +| `grafana` | `10.0.10.12:3000` + LB target `10.0.10.13:3000` | SSO — group `engineering` | load-balancing example | +| `nextcloud` | `10.0.10.20:80` | SSO — any authenticated user | empty allow-lists | +| `ha` | `10.0.10.30:8123` | Basic — realm "Home Assistant", user `jwilson` | | +| `jenkins` | `10.0.10.40:8080` | SSO — group `engineering` | | +| `gitea` | `10.0.10.41:3000` | Off (public) | has its own login | +| `plex` | `10.0.10.50:32400` | Off (public) | has its own login | +| `nas` | `10.0.10.60:5001` (HTTPS) | Basic — realm "Synology NAS", user `jwilson` | | +| `pihole` | `10.0.10.61:80` | Basic — realm "Pi-hole Admin", user `dkim` | | +| `wiki` | `10.0.10.70:3000` | SSO — any authenticated user | | +| `invoices` | `10.0.10.80:8000` | SSO — group `finance` | small-business flavor | +| `helpdesk` | `10.0.10.81:3000` | SSO — group `support` | small-business flavor | + +Basic-auth passwords used: `dkim:HomeLab!2024`, `schen:Engineering!24`, +`jwilson:HomeOwner!24`. + +## Domain + +`CFG_DOMAIN=laptop-dev.vm42.us` in `setup.env`, real public DNS (CNAME +through `718it.biz`) that resolves back to this machine. `CFG_LDAPS_HOST` +is pinned to the LAN IP of the interface holding the default route +(`ip route get 1.1.1.1`), not just any active interface — this machine had +two (wifi + USB ethernet) and only one was actually externally reachable +through the existing port-forward/prod-proxy setup. + +A production reverse proxy in front of this host handles TLS/ACME for +`*.718it.biz`-family domains (to avoid hitting Let's Encrypt's rate limits +re-provisioning a cert every time this dev stack rebuilds) — if a fresh +rebuild's Host records don't resolve correctly from the public domain right +after `setup.sh`, that's the layer to check, not this stack's own nginx/lua +routing. `curl -sk -D - https://sso.laptop-dev.vm42.us/` from the host +machine is the fastest way to confirm whether the issue is server-side. + +## Known-good login shortcuts + +Skip SSO's self-signed-cert dance entirely for admin/screenshot work — every +app ships a local anti-lockout admin account for exactly this: + +```sh +# SSO Manager admin (bootstrap account, uid "admin") +node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)" + +# Proxy — username proxyadmin2 +node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)" + +# Jump-host — username jumpadmin +node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)" +``` + +Ports (from `setup.env` — check it, these are operator-configurable): +SSO `3001`, Proxy management UI `3010` (`MGMT_PORT`), Jump-host `3002`. + +A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS, +enter a DOB) before the rest of the UI is usable — expect that on a stack +that was just rebuilt from scratch. + +## Jump-host access (SSO Directory resource) + +Jump-host's dashboard ("Hosts you can reach") is **not** driven by Proxy's +Host records — it resolves access via the SSO Manager's own Directory +(`kind: host` resources), filtered by the logged-in user's LDAP group +membership. This is a completely separate system from Proxy's HTTP-routing +hosts above; a Proxy host existing does not make it SSH-reachable through +jump-host. + +For a `dkim`-can-reach-something screenshot, one Directory host resource was +added: + +- **Directory → Add Resource**: name `proxmox-node`, kind `Host`, IP + `10.0.10.5`, parent resource `local (site_local)`. +- **Associated LDAP Groups → `site_local_host_proxmox-node_access` → + Members → Add member → `dkim`** (added the individual user directly, not + the `engineering` group — the resource's own auto-generated `_access` + group's member picker only offers individual users). + +To reproduce: repeat those two steps for `proxmox-node` if it's missing, or +add more Directory host resources the same way for a richer "Hosts you can +reach" list. + +**To screenshot as a real fixture user** (not the `jumpadmin` local +anti-lockout admin, whose "My hosts" list is always non-empty by virtue of +infra ownership, not a real access grant): log out, click "Log in with +Jump" on the login page, and sign in as `dkim` / `DemoPass123!` through the +real SSO flow. This exercises the actual OIDC redirect through +`sso.laptop-dev.vm42.us` — by this point in the session it worked cleanly in +the browser; if it doesn't (stale cookies/redirect loop from an earlier bad +state), see `docs/screenshots.md` §2 for the fallback. + +## What's not yet automated + +Proxy hosts are still added by hand (no `seed-demo-hosts.sh` equivalent) — +the Proxy UI has no simple LDIF-style bulk-import path the way LDAP does, and +scripting it means either driving the browser or reverse-engineering the +session-cookie login flow for curl. If this list changes often enough to be +annoying, that's the next thing worth building — a small node script run via +`docker compose exec proxy node ...` calling the `Host` model directly, +mirroring how `setup.sh`'s own step 7 registers the sso/proxy hosts. + +## Screenshot workflow + +See `docs/screenshots.md` for the full screenshot-capture workflow +(save-to-disk, where each doc image lives, the app.modal.js browser-cache +gotcha). Once fixtures match this doc, only re-screenshot pages whose UI +actually changed since the last pass — the data itself shouldn't be the +reason a screenshot looks different. diff --git a/docs/images/jump-dashboard.png b/docs/images/jump-dashboard.png index 8caa276..40bb6fb 100644 Binary files a/docs/images/jump-dashboard.png and b/docs/images/jump-dashboard.png differ diff --git a/docs/images/proxy-hosts.png b/docs/images/proxy-hosts.png index cb6cf08..9f82030 100644 Binary files a/docs/images/proxy-hosts.png and b/docs/images/proxy-hosts.png differ diff --git a/docs/images/sso-dashboard.png b/docs/images/sso-dashboard.png index 18b4a81..04fa9f5 100644 Binary files a/docs/images/sso-dashboard.png and b/docs/images/sso-dashboard.png differ diff --git a/docs/screenshots.md b/docs/screenshots.md new file mode 100644 index 0000000..d89fb17 --- /dev/null +++ b/docs/screenshots.md @@ -0,0 +1,127 @@ +--- +title: Updating gitpages screenshots +--- + +# Updating gitpages screenshots + +How to refresh the docs/images/*.png screenshots across sso-manager-node, +proxy, jump-host, and theta-env's own docs. This comes up periodically as the +UI changes — this doc + `docs/fixtures.md` + `bootstrap/seed-demo-users.sh` +exist so it doesn't have to be re-figured-out from scratch each time. Once +fixtures match `docs/fixtures.md`, you only need to re-screenshot pages whose +UI actually changed since the last pass. + +## 1. Seed realistic demo data + +Screenshots should show a believable homelab/small-business setup, not empty +tables or `test`/`vaulttest` accounts, and the **same** cast every time — see +`docs/fixtures.md` for the canonical list (exact users, groups, hosts, +passwords) and keep it in sync with what's actually seeded. Seed users + +groups with: + +```sh +docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh +docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh +``` + +Idempotent — safe to re-run, existing entries are skipped. Proxy hosts have +no equivalent script yet — add them by hand through the Proxy UI (Hosts → +Add host), following `docs/fixtures.md`'s host table exactly (same hostnames, +targets, auth config every time). + +## 2. Logging in without fighting SSO/TLS + +The SSO's own domain goes through real DNS + a production reverse proxy in +front of this dev stack (see `docs/fixtures.md` → Domain) — logging in via +"Log in with SSO" from Proxy/Jump-host round-trips through that whole path +and can hit stale-cookie/redirect-loop artifacts in an automation browser +profile that a real browser wouldn't. Don't fight this — every app ships a +local anti-lockout admin for exactly this situation. Read the password +straight out of the mounted secrets: + +```sh +# SSO Manager admin (bootstrap account, uid "admin") +node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)" + +# Proxy — username proxyadmin2 +node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)" + +# Jump-host — username jumpadmin +node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)" +``` + +Log in at `http://localhost:/login` for each app — plain HTTP on the +mapped port, no cert/cookie issues at all. Ports come from `setup.env` +(operator-configurable) — check it rather than assuming defaults; e.g. this +deployment maps the Proxy UI to `3010` (`MGMT_PORT`), not the usual `3000`. + +**Don't touch the login form if it autofills a real saved username/password** +(Chrome profile password manager) — clear the fields and type the local admin +credentials above instead. Never submit a real saved credential on the +user's behalf. + +A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS, +enter a DOB) before the rest of the UI is usable — expect that right after a +from-scratch rebuild. + +## 3. Known gotcha: stale `app.modal.js` in the browser cache + +If "Add host" (or any `app.modal`-based modal) opens with tabs/fields but no +Save/Cancel footer, check the console for +`TypeError: app.modal.on is not a function`. That means the browser has an +HTTP-cached copy of `@simpleworkjs/frontend/lib/app.modal.js` from before a +method (`on`, `showTab`, etc.) was added — `curl`-ing the same URL returns the +current file, so it's a caching artifact, not a real app bug. Fix it in-page +without a full hard-reload cycle: + +```js +// via the browser automation JS tool, in the page context +const res = await fetch('/static-modules/@simpleworkjs/frontend/lib/app.modal.js', {cache: 'reload'}); +await res.text(); // {cache:'reload'} both bypasses AND refreshes the cache entry +``` + +Then reload the page normally — the fresh file sticks for the rest of the +session. + +## 4. Capture screenshots + +Use `save_to_disk: true` on the browser screenshot action so files land on +disk instead of just being viewed inline. One screenshot per doc image: + +| File | Page | +|---|---| +| `sso-manager-node/docs/images/dashboard.png` | SSO Catalog (`/`) | +| `sso-manager-node/docs/images/users.png` | SSO Users → People (`/users`) | +| `sso-manager-node/docs/images/directory.png` | SSO Directory (`/directory`) | +| `sso-manager-node/docs/images/groups.png` | A user's profile → "My Groups" tab | +| `sso-manager-node/docs/images/oauth-clients.png` | Directory → an `oauth` resource → Edit → Details tab | +| `proxy/docs/images/hosts.png` | Proxy Hosts list (`/hosts`) | +| `proxy/docs/images/host-auth-basic.png` | Edit a basic-auth host → Authentication tab | +| `proxy/docs/images/host-auth-sso.png` | Edit an SSO-auth host → Authentication tab | +| `proxy/docs/images/load-balancing.png` | Edit a host with "Additional Targets" filled in → General tab | +| `jump-host/docs/images/login.png` | Jump-host login page | +| `jump-host/docs/images/dashboard.png` | Jump-host dashboard, logged in as a real fixture user (e.g. `dkim` via SSO) with an actual access grant — not the `jumpadmin` local admin, whose host list isn't representative. See `docs/fixtures.md` → Jump-host access. | +| `jump-host/docs/images/sessions.png` | Jump-host active sessions | +| `jump-host/docs/images/audit.png` | Jump-host audit log | +| `theta-env/docs/images/sso-dashboard.png` | same as SSO Catalog above | +| `theta-env/docs/images/proxy-hosts.png` | same as Proxy Hosts above | +| `theta-env/docs/images/jump-dashboard.png` | same as Jump-host dashboard above | + +## 5. Where to save them + +Only update the **top-level active clones** — +`/home/william/dev/theta42/{sso-manager-node,proxy,jump-host,theta-env}` (all +on `master`). The copies nested under `theta-env/sso-manager-node`, +`theta-env/proxy`, `theta-env/jump-host` are git submodules pinned to a +release tag (`HEAD detached at vX.Y.Z`) — those update automatically the next +time theta-env's release/tag-bump workflow rolls the submodule pointer +forward, not by hand-editing the pinned checkout. + +```sh +convert screenshot.jpg /home/william/dev/theta42//docs/images/.png +``` + +(`convert` from ImageMagick — the browser tool saves JPEGs, but the repos +track PNGs.) + +Commit each repo separately, same as any other change to that component. diff --git a/jump-host b/jump-host index 57d0600..5a8dbae 160000 --- a/jump-host +++ b/jump-host @@ -1 +1 @@ -Subproject commit 57d0600fc02f49bad7c509c090bd59f6d0311e7e +Subproject commit 5a8dbaee0d4631a5e1014a7f770738980a4219a7 diff --git a/proxy b/proxy index 60ed6b4..ac5bce6 160000 --- a/proxy +++ b/proxy @@ -1 +1 @@ -Subproject commit 60ed6b462f185fef50e50edb955002fd199351db +Subproject commit ac5bce6a860c5b38ab01b7ea3c7d1d4e2317a5d5 diff --git a/sso-manager-node b/sso-manager-node index e7e3eeb..6e748bf 160000 --- a/sso-manager-node +++ b/sso-manager-node @@ -1 +1 @@ -Subproject commit e7e3eeb6cda1286b79db9b5bb6112e85cde9d52a +Subproject commit 6e748bfa66c2387d78556bdf9c63931d3d25e092