Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 221e890897 | |||
| 5925940936 | |||
| ea5d2350a4 | |||
| 050ff87a0b | |||
| bc87d4e381 | |||
| 7efc271938 |
@@ -8,6 +8,87 @@ 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**.
|
||||
|
||||
### sso-manager-node v1.30.1
|
||||
|
||||
**Test Email and Test SMS could never have worked, and all SMS delivery was broken.**
|
||||
|
||||
- Test Email threw `Email.send is not a function`: `models/email.js` exports `{Mail}`, and the handler required the module and called `.send` on it directly.
|
||||
- Test SMS threw `Unexpected token '<', "<!DOCTYPE "...`: it POSTed to `https://api.voip.ms/v1.0/sms/send`, an endpoint that does not exist. VoIP.ms's REST API is a GET against `voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw.
|
||||
- **Every SMS was broken, not just the test.** `models/sms.js` called `PluginInstance.find({…})`, but the ORM has no `find` — the query method is `list({where})`. It threw on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||
- Both test endpoints now send through the same senders every real message uses. A test that reimplements delivery proves nothing about whether real delivery works — which is how two independently broken paths went unnoticed. Failures report as `400` with the underlying reason instead of an opaque `500`.
|
||||
- New guard suite fails the build on any call to a non-existent ORM static, on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||
|
||||
**Install Agent offers the join-key flow.** v1.43.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow. It now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself.
|
||||
|
||||
### Release note
|
||||
|
||||
Tagged with GitHub Actions in a major outage. CI could not run (every job failed at *Set up job* with `Failed to resolve action download info: Service Unavailable`, before reaching any test). Verified locally instead, on the exact merged commit: the full Docker suite — same LDAP + Redis service containers CI uses — passed **299/299**, plus proxy 176/176, jump-host 43/43 and theta-agent green. The Node 18/20/22 matrix was not exercised.
|
||||
|
||||
## [v1.43.0] - 2026-08-06
|
||||
|
||||
Rolls up **sso-manager-node v1.30.0**, **theta-agent v1.5.1**, **proxy v1.35.0**. Fixes what a fresh `setup.sh` install actually produced under v1.42.0.
|
||||
|
||||
> **No manual step to re-enroll agents.** v1.42.0 required an admin to pre-register every host. `setup.sh` now mints a **join key** and the agent enrolls itself, so installing the agent is once again all it takes to add a host.
|
||||
|
||||
### Fixed — theta-suite orchestration
|
||||
|
||||
- **The stack's own theta-agent could never connect.** `setup.sh` generated a random token locally and wrote it into `agent.yml`. The SSO only accepts credentials it issued, so that token was rejected on every attempt and the agent looped on `close 4001: Unauthorized` forever. It now writes a join key the SSO minted; the agent exchanges it for its own token and the SSO's public key on first connect and rewrites its own config.
|
||||
- **`agent.yml` was left holding literal placeholders.** The `REPLACE_WITH_ISSUED_AGENT_TOKEN` / `REPLACE_WITH_SSO_PUBLIC_KEY` strings were shipped as-is when the seds no longer matched the renamed fields, so the file on a fresh install contained no credential at all. The file is also `chmod 600` now that it holds one.
|
||||
- **A fresh install presented its own five containers as unmanaged discoveries** (`theta-proxy`, `theta-jump`, `sso-manager`, `bao-renewer`, `openbao`). The compose project name is now passed to the Docker discovery plugin, which recognises them as ours and links each to the service it implements.
|
||||
- **`openbao` and `bao-renewer` had no directory entries**, so their containers had nothing to attach to and appeared as parentless roots. Both are seeded as services now — they are part of what the stack deploys and belong in the directory like every other component.
|
||||
|
||||
### Added
|
||||
|
||||
- The bootstrap mints a theta-agent join key and hands it to `setup.sh` (`AGENT_JOIN_KEY`), reusing the `setup`-labelled key across runs.
|
||||
|
||||
---
|
||||
|
||||
### sso-manager-node v1.30.0
|
||||
|
||||
**Join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out; a host presenting it is enrolled automatically and immediately issued its **own** per-agent token plus the public key to pin. The join key is a bootstrap credential, never the host's identity — one key stays convenient without becoming a fleet-wide skeleton key, every host remains individually revocable, and revoking a key stops new hosts joining without touching enrolled ones.
|
||||
|
||||
**Collapsing the Directory tree did nothing.** `applyTreeCollapse` found the caret with `.tree-caret i` and returned early when absent — Font Awesome's SVG-with-JS mode rewrites `<i>` to `<svg>`, so that selector matched nothing and the early return skipped setting `hideBelowDepth`, meaning no row was ever hidden. State now lives on the caret button, rotated by CSS.
|
||||
|
||||
**Discovery Plugins.** The delete button called `deleteDiscoveryPlugin()`, which was never defined. The pane also had no `.actionMessage`, and confirmations render into one — without it the promise never settles, so an awaited confirmation hangs forever and the gated action silently never happens. Instances can now be edited (secrets shown blank rather than prefilled with the mask).
|
||||
|
||||
**Discovery.** Docker container slugs came from the container id, which changes on recreate, so every deploy minted a new resource and orphaned the old one; they now derive from compose project + service.
|
||||
|
||||
**Docs.** `/docs/discovery` 404'd; new `docs/discovery.md`. The `agents` slug pointed at `plugins.md`, leaving `docs/agents.md` unreachable in-app.
|
||||
|
||||
### theta-agent v1.5.1
|
||||
|
||||
- `join_key` config field, presented while `auth_token` is empty. The agent persists the issued token + public key into its own `agent.yml` — line-based, so comments, capabilities and formatting survive — and blanks the join key.
|
||||
- Sends `?hostname=` so a self-enrolling host is named after itself; refuses to connect with no credential rather than presenting an empty one.
|
||||
- `install.sh --join-key`.
|
||||
- **v1.5.1 rebuilds the prebuilt `theta-agent-linux-amd64`.** `setup.sh` installs that committed binary rather than building from source, and the v1.5.0 one predated join-key support — it would have received a `join_key` it did not understand. Same trap as the v1.3.0 heartbeat fix.
|
||||
|
||||
### proxy v1.35.0
|
||||
|
||||
- Permission entries can be **edited**; previously only Delete existed, so changing a role meant delete-and-re-add. Because a permission's id is derived from (subjectType, subject, scope, domain), changing any of those replaces the record — the endpoint creates the new grant and removes the superseded one in that order, so an edit can never leave the old grant conferring access.
|
||||
|
||||
## [v1.42.0] - 2026-08-05
|
||||
|
||||
Rolls up **sso-manager-node v1.29.0**, **theta-agent v1.4.0**, **proxy v1.34.0** and **jump-host v1.19.0**.
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+129
-37
@@ -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_<name> — 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',
|
||||
@@ -639,11 +642,30 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// OpenBao and its renewer sidecar are part of what the stack deploys, so
|
||||
// they belong in the directory like every other component. Without entries
|
||||
// their containers had nowhere to attach and showed up as parentless
|
||||
// discoveries on a fresh install.
|
||||
await ensure('service', 'OpenBao', 'openbao', host.id, {
|
||||
address: 'http://openbao:8200',
|
||||
port: 8200,
|
||||
subType: 'vault',
|
||||
icon: 'mdi:safe',
|
||||
tagline: 'Secrets store for the stack.',
|
||||
requestable: false,
|
||||
});
|
||||
await ensure('service', 'Bao Renewer', 'bao-renewer', host.id, {
|
||||
subType: 'sidecar',
|
||||
icon: 'mdi:autorenew',
|
||||
tagline: 'Renews the stack service tokens against OpenBao.',
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// 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', 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',
|
||||
@@ -654,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.
|
||||
@@ -723,7 +773,16 @@ async function seedPlugins(token) {
|
||||
pluginType: 'docker',
|
||||
name: 'Local Docker daemon',
|
||||
slug: 'docker-local',
|
||||
config: { socketPath: '/var/run/docker.sock' },
|
||||
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`);
|
||||
@@ -802,6 +861,36 @@ async function ensureProxyApiToken(token) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -1021,6 +1110,9 @@ async function provisionJumpHost(token) {
|
||||
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 {
|
||||
|
||||
Executable
+172
@@ -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 <uid> <sn> <given_name> <mail> <uidNumber> <password> [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" <<LDIF
|
||||
dn: ${group_dn}
|
||||
objectClass: posixGroup
|
||||
objectClass: top
|
||||
cn: ${uid}
|
||||
gidNumber: ${uidnum}
|
||||
description: Personal group for ${uid}
|
||||
|
||||
dn: ${person_dn}
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: sudoRole
|
||||
objectClass: ldapPublicKey
|
||||
objectClass: top
|
||||
objectClass: theta42Person
|
||||
cn: ${uid}
|
||||
sn: ${sn}
|
||||
givenName: ${given}
|
||||
uid: ${uid}
|
||||
uidNumber: ${uidnum}
|
||||
gidNumber: ${uidnum}
|
||||
homeDirectory: /home/${uid}
|
||||
loginShell: /bin/bash
|
||||
mail: ${mail}
|
||||
userPassword: ${hash}
|
||||
description: ${desc:- }
|
||||
sudoHost: ALL
|
||||
sudoCommand: ALL
|
||||
sudoUser: ${uid}
|
||||
LDIF
|
||||
|
||||
ldapadd -x -H "$LDAP_URL" -D "$BIND_DN" -w "$BIND_PW" -f "$tmp"
|
||||
info "Created user '${uid}' (${mail})"
|
||||
}
|
||||
|
||||
# create_group <cn> <owner_dn> <description>
|
||||
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" <<LDIF
|
||||
dn: ${group_dn}
|
||||
objectClass: groupOfNames
|
||||
objectClass: top
|
||||
cn: ${cn}
|
||||
description: ${desc}
|
||||
member: ${owner_dn}
|
||||
LDIF
|
||||
info "Created group '${cn}'"
|
||||
}
|
||||
|
||||
# add_member <group_cn> <user_dn>
|
||||
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 <<LDIF || true
|
||||
dn: ${group_dn}
|
||||
changetype: modify
|
||||
add: member
|
||||
member: ${user_dn}
|
||||
LDIF
|
||||
}
|
||||
|
||||
info "Waiting for LDAP at ${LDAP_URL}..."
|
||||
for i in $(seq 1 30); do
|
||||
ldapsearch -x -H "$LDAP_URL" -b '' -s base '(objectClass=*)' >/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."
|
||||
@@ -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 `<first>.<last>@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.
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 177 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 506 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
@@ -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:<port>/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/<repo>/docs/images/<name>.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.
|
||||
+1
-1
Submodule jump-host updated: 57d0600fc0...5a8dbaee0d
+1
-1
Submodule proxy updated: 954cda5844...ac5bce6a86
@@ -1080,7 +1080,13 @@ 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)"
|
||||
# The compose project name the stack runs under (defaults to the directory
|
||||
# name). The bootstrap hands it to the Docker discovery plugin so the stack's
|
||||
# own containers are recognised as ours rather than discovered as strangers.
|
||||
STACK_COMPOSE_PROJECT="${COMPOSE_PROJECT_NAME:-$(basename "$(pwd)" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9_-' '-' | sed 's/-*$//')}"
|
||||
|
||||
BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
|
||||
-e COMPOSE_PROJECT_NAME="$STACK_COMPOSE_PROJECT" \
|
||||
-e STACK_HOST_NAME="$STACK_HOST_NAME" \
|
||||
-e STACK_HOST_IP="$STACK_HOST_IP" \
|
||||
-e STACK_HOST_MAC="$STACK_HOST_MAC" \
|
||||
@@ -1095,6 +1101,9 @@ BOOTSTRAP_OUT=$("${COMPOSE[@]}" exec -T \
|
||||
getval() { echo "$BOOTSTRAP_OUT" | grep -m1 "^$1=" | cut -d= -f2-; }
|
||||
CLIENT_ID=$(getval CLIENT_ID)
|
||||
ALREADY_CONFIGURED=$(getval ALREADY_CONFIGURED)
|
||||
# The one credential the local theta-agent needs; it exchanges this for its own
|
||||
# token + the SSO public key on first connect (see 7c below).
|
||||
AGENT_JOIN_KEY=$(getval AGENT_JOIN_KEY)
|
||||
[[ -n "$CLIENT_ID" ]] || die "bootstrap did not return CLIENT_ID:\n${BOOTSTRAP_OUT}"
|
||||
|
||||
if [[ "$ALREADY_CONFIGURED" == "1" ]]; then
|
||||
@@ -1230,14 +1239,39 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
||||
sudo mkdir -p /etc/theta42
|
||||
if [[ ! -f /etc/theta42/agent.yml ]]; then
|
||||
sudo cp agent.yml.example /etc/theta42/agent.yml
|
||||
AGENT_TOKEN="$(rand_hex 16)"
|
||||
sudo sed -i "s/REPLACE_WITH_AGENT_TOKEN/$AGENT_TOKEN/" /etc/theta42/agent.yml
|
||||
# Write the JOIN KEY, not a locally-invented token. The SSO
|
||||
# only accepts credentials it issued, so the random token
|
||||
# this used to generate could never authenticate -- the
|
||||
# agent looped on "close 4001: Unauthorized" forever. The
|
||||
# agent swaps this key for its own token (and the public key
|
||||
# it must pin) on first connect and rewrites this file.
|
||||
if [[ -n "$AGENT_JOIN_KEY" ]]; then
|
||||
# Only the join key is written. The agent exchanges it
|
||||
# for its own token + the SSO public key on first
|
||||
# connect and rewrites this file itself.
|
||||
#
|
||||
# This used to sed a locally generated random value into
|
||||
# auth_token. The SSO only accepts credentials it
|
||||
# issued, so that token could never authenticate and the
|
||||
# agent looped on "close 4001: Unauthorized" forever.
|
||||
if sudo grep -q '^join_key:' /etc/theta42/agent.yml; then
|
||||
sudo sed -i "s|^join_key:.*|join_key: \"${AGENT_JOIN_KEY}\"|" /etc/theta42/agent.yml
|
||||
else
|
||||
echo "join_key: \"${AGENT_JOIN_KEY}\"" | sudo tee -a /etc/theta42/agent.yml >/dev/null
|
||||
fi
|
||||
# Older agent.yml.example shipped REPLACE_WITH_* placeholders;
|
||||
# blank them so they are not mistaken for real credentials.
|
||||
sudo sed -i "s|REPLACE_WITH_ISSUED_AGENT_TOKEN||; s|REPLACE_WITH_AGENT_TOKEN||; s|REPLACE_WITH_SSO_PUBLIC_KEY||" /etc/theta42/agent.yml
|
||||
else
|
||||
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
||||
fi
|
||||
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
||||
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
|
||||
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
else
|
||||
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
fi
|
||||
sudo chmod 600 /etc/theta42/agent.yml
|
||||
fi
|
||||
# Stop a running agent before overwriting its binary (cp into a
|
||||
# running executable fails with "Text file busy" on a re-install).
|
||||
|
||||
+1
-1
Submodule sso-manager-node updated: b28a18064a...6e748bfa66
+1
-1
Submodule theta-agent updated: dc14274edc...d128807431
Reference in New Issue
Block a user