Compare commits
103 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3450ef1a8a | |||
| 1c9d9fe405 | |||
| 56e76e1a40 | |||
| 5abd5296f3 | |||
| 5166859f29 | |||
| f05fb27e5e | |||
| 33dfb682f4 | |||
| da60310834 | |||
| 20e9c1dfbe | |||
| 98b2f9f389 | |||
| 90a1d19254 | |||
| 55d6f0b936 | |||
| ba2e905155 | |||
| 455450db1f | |||
| 5ed83a2f59 | |||
| ca812bed8e | |||
| 0e59b8dcb4 | |||
| 2d862f93c9 | |||
| e0bdc0df2e | |||
| 577c264a6a | |||
| 4f09354e32 | |||
| 744c85f4bf | |||
| d8d811b0ab | |||
| 42ec5aa208 | |||
| 734c62ac83 | |||
| 1d85aa81f3 | |||
| 47f7f976ab | |||
| ace7b441c2 | |||
| 35c1a476c9 | |||
| aeed5b8723 | |||
| 6640f8059a | |||
| d6611c7d1b | |||
| 9aaa35fa4e | |||
| 182787f268 | |||
| d7698a60e7 | |||
| 484bf0e91d | |||
| f42b69c084 | |||
| 0367c33542 | |||
| ff9c87ff20 | |||
| 423e064147 | |||
| 52c9c30c52 | |||
| ea75e94b3e | |||
| 301770321e | |||
| c6c6f09d48 | |||
| cccde0792e | |||
| a2afc127c4 | |||
| ad2f7b7e11 | |||
| ec73eae07d | |||
| 13aeac059f | |||
| 349d3d4cd0 | |||
| d8725990b3 | |||
| de2d65fe8f | |||
| e5446b2cfe | |||
| 0c87c79f06 | |||
| c7bf1d0edd | |||
| a917915037 | |||
| 141e5e14f0 | |||
| f09d38d00b | |||
| 8632798735 | |||
| 17f488e2e2 | |||
| 3ec641c426 | |||
| 69a7843dca | |||
| 4db87de240 | |||
| 9c521b0d08 | |||
| e091ca406c | |||
| 3c40c66636 | |||
| 2a0d194cae | |||
| 656c2ed8c8 | |||
| 282071abca | |||
| 1d742c51eb | |||
| 85a822eb36 | |||
| 489fe7b127 | |||
| ad6f17515b | |||
| eca92f3f37 | |||
| acea5217ac | |||
| 47ceb63049 | |||
| 86d1601069 | |||
| 8bfde63684 | |||
| 603156cbfe | |||
| 277b68af61 | |||
| 7d55048905 | |||
| b799d59672 | |||
| f46ef31ca9 | |||
| 8451d3f12d | |||
| 67e7e4eb34 | |||
| 403e1a3cd4 | |||
| 28fad8a49e | |||
| 70fc19e37f | |||
| 5bb2c19fec | |||
| ff9b62d331 | |||
| fabb250887 | |||
| 221e890897 | |||
| 5925940936 | |||
| ea5d2350a4 | |||
| 050ff87a0b | |||
| bc87d4e381 | |||
| 7efc271938 | |||
| 4bf875e841 | |||
| 6479d35fb8 | |||
| 3ca4802075 | |||
| 86026e90e7 | |||
| 3354407f04 | |||
| 84d7c96c17 |
@@ -28,6 +28,8 @@ jobs:
|
||||
run: shellcheck -S warning setup.sh
|
||||
|
||||
bootstrap-syntax:
|
||||
# Keep this job name stable: branch protection on master requires a status
|
||||
# check named exactly "Syntax check bootstrap.js".
|
||||
name: Syntax check bootstrap.js
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -40,7 +42,9 @@ jobs:
|
||||
node-version: 22.x
|
||||
|
||||
- name: Syntax check
|
||||
run: node --check bootstrap/bootstrap.js
|
||||
run: |
|
||||
node --check bootstrap/bootstrap.js
|
||||
node --check bootstrap/site-join.js
|
||||
|
||||
- name: Jump-host LDAP config consistency
|
||||
run: node test/check_jump_ldap_tls.js
|
||||
|
||||
@@ -19,6 +19,11 @@ proxy.env
|
||||
# per-deployment and is not committed.
|
||||
setup.env
|
||||
|
||||
# spoke.env — same rule as setup.env, but for the join-a-cluster vars split
|
||||
# out for clarity (spoke.env.example IS committed). Holds a real site join
|
||||
# key once filled in.
|
||||
spoke.env
|
||||
|
||||
# Backup artifacts (hold secrets — the whole user directory + Redis dumps)
|
||||
*.rdb
|
||||
*.ldif
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[submodule "sso-manager-node"]
|
||||
path = sso-manager-node
|
||||
url = https://github.com/theta42/sso-manager-node.git
|
||||
url = https://github.com/theta42/theta-directory.git
|
||||
[submodule "proxy"]
|
||||
path = proxy
|
||||
url = https://github.com/theta42/proxy.git
|
||||
|
||||
@@ -5,9 +5,567 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||
correspond to git tags (`vX.Y.Z`). Entries here cover theta-suite's own
|
||||
orchestration code; see each submodule's own `CHANGELOG.md`
|
||||
([proxy](https://github.com/theta42/proxy/blob/master/CHANGELOG.md),
|
||||
[sso-manager-node](https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md))
|
||||
[theta-directory](https://github.com/theta42/theta-directory/blob/master/CHANGELOG.md),
|
||||
[jump-host](https://github.com/theta42/jump-host/blob/master/CHANGELOG.md))
|
||||
for what changed inside the apps it composes.
|
||||
|
||||
## [v2.8.0] - 2026-08-11
|
||||
|
||||
Rolls up **sso-manager-node v2.8.0**. Fixes found while auditing the v2.7.0
|
||||
multi-site work for gaps, plus the published docs staleness that same audit
|
||||
turned up.
|
||||
|
||||
### sso-manager-node v2.8.0
|
||||
- Promotion no longer orphans the demoted old master's LDAP replication --
|
||||
`/demote` now registers itself with the new master immediately instead of
|
||||
being left with no way to ever get a real `ldapServerId` again.
|
||||
- The Directory's site slug and the multi-site replication identity are
|
||||
unified -- previously unrelated values that happened to share a name.
|
||||
- LDAP replication status (real vs. advertised ServerID, a `stale` flag) and
|
||||
per-spoke detail (not just an aggregate count) on the Multi-Site modal.
|
||||
|
||||
### theta-suite docs
|
||||
- Fixed multiple stale/contradictory claims on the published site: the
|
||||
no-inbound relay described as "designed but not automated" (shipped
|
||||
earlier this session), `docs/sso/replication.md` never mentioning the new
|
||||
LDAP MMR auto-config at all, and the homepage feature list only
|
||||
describing the old manual N-way replication.
|
||||
|
||||
## [v2.7.0] - 2026-08-11
|
||||
|
||||
Rolls up **sso-manager-node v2.7.0**. Closes the last "operator hand-sets
|
||||
this" item on the multi-site TODO: OpenLDAP N-way multi-master replication
|
||||
now configures itself.
|
||||
|
||||
### theta-suite orchestration
|
||||
- **`bootstrap/site-ldap-register.js`**: runs on every `setup.sh` invocation
|
||||
(master and spoke), fetches this node's auto-assigned `LDAP_SERVER_ID` +
|
||||
current peer list from sso-manager-node's new endpoints, and restarts
|
||||
`sso-manager` only when the computed config actually changed.
|
||||
- `CFG_LDAP_MMR_MANUAL=true` skips the automatic step for a topology outside
|
||||
this cluster the script can't derive on its own -- otherwise it always
|
||||
runs (every fresh install starts as a master) and would overwrite
|
||||
hand-set values.
|
||||
- `setup.env.example`'s old `#LDAP_SERVER_ID=1`/`#LDAP_REPLICATION_HOSTS=`
|
||||
manual-config prompt is gone for the common case.
|
||||
|
||||
### sso-manager-node v2.7.0
|
||||
- `SiteSpoke.ldapServerId` auto-assigned at registration (same pattern as
|
||||
jump-host's mesh index); each site's LDAP URL derived from its
|
||||
already-known HTTP(S) endpoint. New `GET /api/site/ldap-peers`
|
||||
(spoke-facing) and `GET /directory-admin/ldap-replication-config`
|
||||
(master-local). Verified against real running containers.
|
||||
|
||||
## [v2.6.0] - 2026-08-11
|
||||
|
||||
Rolls up **sso-manager-node v2.6.0** and **jump-host v2.1.1** (already
|
||||
current). Fixes several real bugs found on a live deployment: theta-agent
|
||||
never got its `server_url` written, `theta-agent update` 404'd because
|
||||
setup.sh installed a stale committed binary, the Directory's site slug and
|
||||
gateway count were both wrong, and duplicate group rows accumulated on
|
||||
repeated resource promotion.
|
||||
|
||||
### theta-suite orchestration
|
||||
- **`server_url` now gets written into `/etc/theta42/agent.yml`.** setup.sh's
|
||||
theta-agent install step `sed`'d in `join_key` but never touched
|
||||
`server_url`, so it kept `agent.yml.example`'s literal placeholder forever.
|
||||
Self-heals an already-installed `agent.yml` too (never touches
|
||||
`join_key`/`auth_token`).
|
||||
- **`theta-agent update` no longer 404s.** setup.sh now downloads the current
|
||||
release binary from GitHub instead of trusting one committed in the
|
||||
theta-agent submodule checkout, which predated an upstream fix and could
|
||||
never self-update out of the bug. Matches theta-agent's own `install.sh`.
|
||||
The stale committed binaries were removed from the theta-agent repo.
|
||||
- **`SITE_SLUG` auto-derived from `CFG_SITE_NAME`.** Nothing ever set it
|
||||
before, so a fresh master always showed the app's own literal
|
||||
"site-default" fallback.
|
||||
- **`PROXY_INTERNAL_URL`/`JUMP_INTERNAL_URL` wired into `docker-compose.yml`.**
|
||||
Both the no-inbound relay automation and the new real gateway-mesh count
|
||||
existed in sso-manager-node's code but were completely unreachable in
|
||||
every real deployment -- neither env var was ever actually set.
|
||||
- **`spoke.env.example`** -- a dedicated file for the join-a-cluster vars,
|
||||
split out of `setup.env` for clarity (which still has every option).
|
||||
Layered on top of `setup.env` when present. Also adds `CFG_PUBLIC_DOMAIN`
|
||||
(documented in the spec but never wired into setup.sh before).
|
||||
|
||||
### sso-manager-node v2.6.0
|
||||
- Fixed duplicate access/admin groups accumulating on repeated resource
|
||||
promotion (three independent copies of the same missing-existence-check
|
||||
bug).
|
||||
- `GET /api/directory-admin/resources` no longer runs a full LDAP group
|
||||
self-heal fan-out on every list -- moved to write-time, with an explicit
|
||||
`POST /resources/heal-groups` for backfill.
|
||||
- Recovers an nmap scan that completed successfully despite a benign stderr
|
||||
warning nmap itself prints (`node-nmap` treated it as a fatal failure).
|
||||
- The Multi-Site modal's gateway count now queries jump-host's real mesh
|
||||
registry instead of an unrelated WireGuard subsystem.
|
||||
|
||||
## [v2.5.0] - 2026-08-10
|
||||
|
||||
Rolls up **sso-manager-node v2.5.0** and **jump-host v2.1.1** — closes the
|
||||
last gap in no-inbound relay automation (the mechanism existed at the API
|
||||
level but nothing in the real operator bring-up flow could reach it) and
|
||||
fixes two real bugs found live-testing it.
|
||||
|
||||
### theta-suite orchestration
|
||||
- **`bootstrap/site-relay-register.js`** + `CFG_SPOKE_NO_INBOUND`/
|
||||
`CFG_SPOKE_PUBLIC_HOST` (`setup.env.example`): a no-inbound spoke's
|
||||
`setup.sh` run now discovers its own jump-host's WireGuard mesh IP and
|
||||
registers it with the master on every invocation (idempotent no-op until
|
||||
the two jump-hosts are actually meshed — that peering stays a deliberate
|
||||
manual step, same as minting/pasting a site join key).
|
||||
- `docs/MULTI_SITE_SPEC.md` and the published `docs/jump-host/mesh.md` page
|
||||
updated — both still described this as "designed but not automated" after
|
||||
the API-level work had already shipped in sso-manager-node/jump-host.
|
||||
|
||||
### sso-manager-node v2.5.0
|
||||
- **No-inbound relay automation reachable from the real join flow.**
|
||||
`POST /api/site/join` now forwards `noInbound`/`meshIp`/`publicHost`
|
||||
through to `POST /api/site/spokes`, which drives `utils/proxy_client.js`
|
||||
to auto-create/update the relay route on the master's `theta-proxy` (a new
|
||||
self-service `prx_...` API token client — reuses `theta-proxy`'s existing
|
||||
token system, not a new credential type). Verified against a real running
|
||||
`theta-proxy` container.
|
||||
- **Replication traffic prefers the mesh.** `utils/site_replicate.js`'s
|
||||
fire-and-forget resync push tries a registered spoke's `meshIp` first,
|
||||
falling back to its public endpoint on failure.
|
||||
|
||||
### jump-host v2.1.1
|
||||
- **`GET /api/mesh/self`** — this gateway's own mesh IP, for local scripts
|
||||
(gated by any self-service API token, not a full admin session).
|
||||
- **Fixed: `/api/mesh/register` was unreachable via HTTP.** A route-mounting
|
||||
order bug meant every `/api/mesh/*` request hit an admin-session gate
|
||||
before `routes/mesh.js` ever ran, so a real gateway-to-gateway mesh join
|
||||
always 401'd. Found live-testing `/self` with two real containers.
|
||||
- **Fixed: the initiating side of a mesh join never recorded its own
|
||||
identity** — `GET /api/mesh/self` and the mesh UI's own-entry handling
|
||||
silently saw nothing on whichever gateway called `/join` (only the
|
||||
receiving side of `/register` persisted a self-entry). Verified with two
|
||||
real meshed containers: both sides now report their own correct mesh IP.
|
||||
- **Mesh peer removal now cleans up its kernel routes** (`wg_iface.removePeer()`)
|
||||
— verified live: routes present after `setPeer`, gone after `removePeer`.
|
||||
|
||||
## [v2.4.0] - 2026-08-10
|
||||
|
||||
Rolls up **theta-agent v2.2.0** — mDNS local-discovery is now Windows-capable,
|
||||
closing the last gap in the original multi-site design on the agent side.
|
||||
|
||||
### theta-agent v2.2.0
|
||||
- **Windows local-discovery**: the hosts override now runs on Windows
|
||||
(`%SystemRoot%\System32\drivers\etc\hosts`, CRLF-aware, `ipconfig /flushdns`
|
||||
after every change). Reachable because the agent runs as a SYSTEM service, so
|
||||
the elevation question in the spec resolved in our favor. The Windows CI leg
|
||||
now runs the real Windows write path instead of skipping.
|
||||
- **Local route pinning** (`local_route*.go`): the hosts override only fixes
|
||||
*name resolution*; the packet path is the routing table's job. If the WireGuard
|
||||
mesh tunnel is up with `AllowedIPs` covering the LAN subnet (or a full-tunnel
|
||||
`0.0.0.0/0`), the tunnel route would swallow the direct connection to the
|
||||
discovered LAN IP. Discovery now pins a `/32` host route via the owning local
|
||||
interface (`route.exe add ... metric 1` on Windows, `ip route replace` on
|
||||
Linux) and drops it on revert — closing a real gap in the shipped Linux path.
|
||||
- **Prompt reconnect**: an apply/revert signals the WebSocket loop, which
|
||||
reconnects immediately instead of waiting out its 5s backoff.
|
||||
- **Installer version fix**: the setup.exe previously hardcoded `2.1.0` in its
|
||||
file name and version resources no matter the tag; it now derives the version
|
||||
from the git tag.
|
||||
|
||||
### docs
|
||||
- `docs/MULTI_SITE_SPEC.md` status table updated: Windows local-discovery marked
|
||||
shipped; macOS remains the one unbuilt piece (hosts override compiles on
|
||||
darwin but needs `dscacheutil -flushcache` + real hardware testing, being done
|
||||
on a macOS VM).
|
||||
|
||||
## [v2.3.0] - 2026-08-10
|
||||
|
||||
Rolls up **theta-directory v2.4.0**, **jump-host v2.1.0**, **theta-agent v2.1.2**. Live catalog replication and a real gateway-to-gateway WireGuard mesh land in the same pass — multi-site directory sync stops being a one-time snapshot, and site-to-site networking becomes real infrastructure instead of a documented-but-unbuilt design. See `docs/MULTI_SITE_SPEC.md` for the full architecture and an explicit TODO list of what's still open (Windows/macOS mDNS, routing directory traffic over the mesh, `theta-proxy` no-inbound relay automation).
|
||||
|
||||
### theta-directory v2.4.0
|
||||
|
||||
#### Added
|
||||
- **Live catalog replication.** A spoke now stays in sync after joining instead of only getting a one-time snapshot: it registers its own endpoint with the master at join time (`POST /api/site/spokes`, Bearer the site join key), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, concurrently — one unreachable spoke never blocks or delays delivery to another. The spoke's `POST /api/site/resync` handler re-runs the same tested export-pull-and-import path used at join time rather than applying a partial diff.
|
||||
- **Identical-directory agent-signing key.** `POST /api/site/export` now best-effort includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on both join and every resync, so any site's `sso-manager-node` can validly sign a command for any agent enrolled at any other site (a deliberate blast-radius tradeoff for this deployment's small, trusted scale — see `docs/MULTI_SITE_SPEC.md` §2).
|
||||
- **Coordinated master promotion.** `POST /api/directory-admin/site-promote` now demotes the previous master as part of the same action (mints it a fresh join key, calls its new `POST /api/site/demote`) instead of leaving a manual two-step gap where two nodes could both believe they're master. Best-effort: an unreachable old master never blocks the local promotion — the response's `handoff` field reports what happened.
|
||||
- **Master Site modal UI**: new "Live Replication" (spoke) / "Registered Spokes" (master) status rows; the join form gained a "this site's own reachable URL" field wired to `selfUrl`, which the join API already supported but the UI never sent; the promote button's success toast now reports the actual handoff result.
|
||||
|
||||
#### Fixed
|
||||
- **`site-promote`'s god_admin check was dead on arrival** — it read `req.user.groups`, a field nothing in the codebase ever populates, so the check silently evaluated to an empty array on every request. Promotion returned 403 for every user, including a real god_admin, since it shipped in v2.0.0. Only surfaced by live two-container testing, not by inspection.
|
||||
- **The read-only write-gate blocked `site-promote` on a spoke** before its handler could run — the one mutating request a spoke must be able to make to itself.
|
||||
- **`GET /api/site/config` was returning live credentials** (`masterJoinKey`, `replicationPushToken`) directly to the browser on every admin session. Replaced with boolean derivatives.
|
||||
|
||||
### jump-host v2.1.0
|
||||
|
||||
#### Added
|
||||
- **Gateway-to-gateway WireGuard mesh** (`routes/mesh.js`) — real site-to-site tunnels between theta-gateway instances, distinct from the existing roaming-client/exit-node WireGuard feature. Join-token bootstrap, mesh-index addressing (172.24.\<idx\>.0/16 + 10.\<idx\>.0.0/16).
|
||||
- **In-kernel WireGuard with a userspace fallback** (`utils/wg_iface.js`) — prefers `ip link add type wireguard`, falls back to `wireguard-go` when the kernel module isn't available.
|
||||
- **mDNS local-discovery announcer** (`services/mdns_announce.js`) — advertises which public hostnames this site fronts so a `theta-agent` on the same LAN segment can skip the relay/WAN path.
|
||||
- **Mesh UI** (`/mesh`) — gateway identity, join-token minting, remote-join form, meshed-gateways table.
|
||||
|
||||
Verified with real two-container tests: an actual encrypted WireGuard tunnel passing ICMP traffic end to end (0% loss), and the mDNS announce/discover/apply/revert cycle over real multicast. Two real bugs found and fixed: `wg set ... allowed-ips` doesn't add a kernel route (a real handshake completed with zero routing until `setPeer()` was fixed to add it); mDNS's default IPv6 query aborting the entire lookup after a valid IPv4 response had already arrived.
|
||||
|
||||
### theta-agent v2.1.2
|
||||
|
||||
#### Added
|
||||
- **Linux mDNS local-discovery** (`local_discovery.go`, `hosts_override.go`) — opt-in via `prefer_local_directory`; skips the relay/WAN path when a local `theta-gateway`/`theta-proxy` announces it fronts this agent's `server_url` host. Never touches TLS/certificate validation — only changes where the agent connects, never whether it trusts what answers.
|
||||
|
||||
#### Fixed (found via live two-container testing over real multicast)
|
||||
- `mdns.Lookup()`'s default IPv6 query aborted the whole lookup — discarding an already-valid IPv4 response — when IPv6 wasn't available. Fixed by disabling IPv6 querying explicitly.
|
||||
- Hosts-file writes used write-tmp-then-rename; `/etc/hosts` is frequently a bind mount (every container runtime does this) and `rename()` onto one fails with `EBUSY`. Switched to truncate-and-rewrite in place.
|
||||
|
||||
Windows/macOS local-discovery remain unbuilt — see `docs/AGENT_LOCAL_DISCOVERY_SPEC.md`.
|
||||
|
||||
Also backfills v2.1.0/v2.1.1 changelog entries (Windows agent, WireGuard client, installer, CI) in theta-agent's own CHANGELOG.md, which were tagged and released earlier but never documented there.
|
||||
|
||||
## [v2.2.0] - 2026-08-10
|
||||
|
||||
Multi-site join is now end-to-end: theta-directory can adopt an existing
|
||||
master's directory as a read-only spoke (v2.3.0), and setup.sh wires it into a
|
||||
first-run bring-up.
|
||||
|
||||
### theta-directory v2.3.0
|
||||
- **Master Site modal**: "Join an Existing Site" form (fresh installs only),
|
||||
Site Join Keys manager (mint/revoke/list), live WAN Sync Health via
|
||||
`POST /api/site/ping`.
|
||||
- **Spoke read-only**: directory writes (resources/edges/groups/secrets/grants/
|
||||
driver actions/discovery merges) return 403 pointing at the master.
|
||||
- **Fresh-install guard**: `/api/site/join` refuses unless no users beyond the
|
||||
bootstrap admin and no enrolled agents; `site-status` exposes `canJoin`.
|
||||
- (Server endpoints, join keys, persisted role shipped in theta-directory v2.2.0.)
|
||||
|
||||
### theta-suite (this repo)
|
||||
- **First-run site join**: `setup.sh` step 5b runs `bootstrap/site-join.js`
|
||||
(logs in as the admin, calls `/api/site/join`) when `setup.env` sets
|
||||
`CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`. Honored only on
|
||||
first run (once `./config/` exists it is ignored), so a populated directory
|
||||
can never be merged. Idempotent — an already-joined node reports "already a
|
||||
spoke".
|
||||
- `setup.env.example` documents the two vars; the lint job (branch-protected
|
||||
name "Syntax check bootstrap.js") now also `node --check`s `site-join.js`.
|
||||
|
||||
## [v2.1.1] - 2026-08-10
|
||||
|
||||
Fresh-install fixes for the v2.1.0 Windows rollout.
|
||||
|
||||
### theta-agent v2.1.1
|
||||
- **Silent installs wrote an empty `server_url`.** `CurPageChanged` fires even when the wizard is walked programmatically in silent mode, so it read the (empty) edit boxes and clobbered the `/SERVER_URL` `/JOIN_KEY` command-line params. Now guarded with `WizardSilent()` — silent installs keep the params (this was also why the service exited at first connect and the Directory showed the agent as a placeholder version).
|
||||
- **Tray post-install launch had `skipifsilent`** — a silent install (the common path from the Directory's Windows command) never started the tray. Removed.
|
||||
- **`theta-agent update` 404'd** — it downloaded from the SSO `/resources` path, which no longer serves binaries (they are GitHub release artifacts). Pointed at `releases/latest/download/`.
|
||||
|
||||
### theta-directory v2.1.1
|
||||
- **"Master Site" button error** (`app.modal.show is not a function`) — the multi-site status modal used the legacy `app.modal.show()` signature; now `app.modal.open({ title, bodyHtml, size })`.
|
||||
- **Agents with no discovery showed a fake `v2.0.0`** — hardcoded fallbacks now report `unknown`.
|
||||
|
||||
## [v2.1.0] - 2026-08-10
|
||||
|
||||
Rolls up **theta-agent v2.1.0** and **theta-directory v2.1.0**: the theta-agent
|
||||
Windows client is now a first-class citizen (Windows service, WireGuard mesh,
|
||||
IAM, tray, fully-offline installer) and the Directory can hand a Windows host
|
||||
the same one-command install it has always handed Linux.
|
||||
|
||||
### theta-agent v2.1.0
|
||||
- **Windows agent** (`feat/windows-agent`): platform ops (shutdown.exe / sc.exe /
|
||||
PowerShell / Get-WinEvent), Windows service wrapper (`install-service` starts it
|
||||
immediately), session helper (lock/display/logout/staged self-update), signed
|
||||
`wireguard_apply`/`wireguard_remove` + auto-VPN, IAM via local groups + OpenSSH
|
||||
keys, tray icon fix (PNG→ICO), and the fully-offline Inno installer with a
|
||||
wizard page (Theta Directory URL + join key + "open install-agent page").
|
||||
- **Release pipeline**: `.github/workflows/release.yml` builds every binary on
|
||||
GitHub Actions and attaches them to the GitHub release as artifacts (with
|
||||
optional Azure Trusted Signing); nothing binary is committed to the repo.
|
||||
`install.sh` and the Directory modal download from `releases/latest/download/`.
|
||||
|
||||
### theta-directory v2.1.0
|
||||
- **Windows install commands in the Install Agent modal.** PowerShell one-liners
|
||||
for the join-key / pre-register / custom-config flows (download the offline
|
||||
`setup.exe`, pass `/SERVER_URL`, `/JOIN_KEY`, `/AUTH_TOKEN`, `/PUBLIC_KEY` or
|
||||
`/B64_CONFIG`).
|
||||
- **Dropped the committed binaries** from `nodejs/public/resources/theta-agent/`
|
||||
(they are GitHub release artifacts now); the small `install.sh` bootstrap
|
||||
script remains.
|
||||
|
||||
## [v2.0.2] - 2026-08-09
|
||||
|
||||
Rolls up **theta-directory v2.0.2**, **proxy v2.0.1**, **jump-host v2.0.1**. Unifies the GitHub Pages docs site: the SSO/Proxy/Jump Host pages, their nav labels, and each component's own README now consistently say Theta Directory / Theta Proxy / Theta Gateway, drop marketing sections ("Why this over the alternatives", "Get it", "Related projects") that don't apply to a suite component, remove every standalone/bare-metal install path, and link to `theta42.github.io/theta-suite/...` instead of the old per-repo Pages sites.
|
||||
|
||||
### theta-directory v2.0.2
|
||||
- **README rebranding & standalone-install cleanup.** Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (`theta42.github.io/sso-manager-node/`); documentation and secrets links now point at the unified `theta42.github.io/theta-suite/` site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
|
||||
- **Docs site (`docs/sso/`)**: full rewrite of the landing page — dropped "Why this over the alternatives" / "Get it" / "Related projects", refreshed all screenshots, added the two agent screenshots, and swept "SSO Manager" → "Theta Directory" across every sub-page (configuration, OAuth, LDAP, directory, agents, replication, vault, concepts-*).
|
||||
|
||||
### proxy v2.0.1
|
||||
- Finishes the pending v2.0.0 Docker-only rewrite (README's Quick start already trimmed to one Docker Compose path via Theta Suite).
|
||||
- Removed "Why this over the alternatives"; trimmed Requirements to actual Docker-host requirements (was still listing bare-metal items: root access, directly-installed OpenResty/Redis).
|
||||
- Fixed stale links to the old per-repo GitHub Pages site; synced `package-lock.json`'s version (missed by the earlier 2.0.0 bump commit).
|
||||
- Docs site (`docs/proxy/`): renamed to Theta Proxy, dropped the same three sections, refreshed screenshots, relative links within the unified site. Deleted a stale `docs/proxy/README.md` meta-doc left over from when this repo had its own separate Pages site (wrong live URL, referenced a deleted `installation.md`).
|
||||
|
||||
### jump-host v2.0.1
|
||||
- Rebranded docs to Theta Gateway (matches the repo's own README/package.json naming).
|
||||
- Removed the "Standalone Docker" and "Bare metal" install paths, which contradicted the Deployment section's own "exclusively via Docker Compose within Theta Suite" claim.
|
||||
- Fixed stale links to the old per-repo GitHub Pages sites.
|
||||
- Docs site (`docs/jump-host/`): same section removal, added a WireGuard mesh-routing feature bullet, refreshed screenshots, fixed three more stale absolute links in `architecture.md`. Deleted the same kind of stale `docs/jump-host/README.md` meta-doc.
|
||||
|
||||
## [v2.0.4] - 2026-08-10
|
||||
|
||||
Rolls up **theta-directory v2.0.4**.
|
||||
|
||||
### Changed
|
||||
- **`Dockerfile.openldap` no longer compiles OpenLDAP from source.** Extracted the from-source compile (nestgroup overlay, ~5 min, dependent on `git.openldap.org` being reachable — it 502'd twice tonight, blocking two PRs) into its own image, `ghcr.io/theta42/openldap-nestgroup:<pinned commit>`, published by a new workflow whenever the pin changes. `Dockerfile.openldap`'s `ldapbuild` stage now just pulls it. Cut CI's "Build LDAP test image" step from ~5-6 minutes to ~1.5 minutes total per matrix run; every local build of this Dockerfile gets the same speedup.
|
||||
|
||||
## [v2.0.3] - 2026-08-09
|
||||
|
||||
Rolls up **theta-directory v2.0.3**.
|
||||
|
||||
### Fixed
|
||||
- **Directory tab showed unpromoted discoveries.** `GET /api/directory-admin/resources` unconditionally admitted every `kind: 'host'` resource, and every discovery plugin (UniFi, Proxmox, nmap) creates its finds as `kind: 'host'` — so unchecking "Auto-promote to Directory" on a plugin never actually kept undiscovered/unpromoted devices out of the Directory tab, only out of the LDAP-group auto-provisioning. Now only `site` resources are unconditionally shown; anything else that discovery ever touched requires `metadata.managed === true` (set by promotion, an agent, or merging into an already-managed resource).
|
||||
- **`GET /api/directory-admin/site-status` 500'd.** Queried `Resource.list({ where: { subType: 'wireguard' } })`, but `subType` only ever lives in `metadata.subType` — never a top-level DB column, so SQLite raised `no such column: Resource.subType`. Filters in JS over `metadata.subType` now.
|
||||
- **Discovered Inventory had no way to review ignored devices.** Added a "Show ignored" toggle (off by default).
|
||||
|
||||
### Chore
|
||||
- **Untracked `nodejs/config/inventory.sqlite`** in theta-directory — the app's default runtime DB, not a fixture; had been committed by mistake across 13 prior releases.
|
||||
|
||||
## [v2.0.2] - 2026-08-09
|
||||
|
||||
Rolls up **theta-directory v2.0.2**, **proxy v2.0.1**, **jump-host v2.0.1**. Unifies the GitHub Pages docs site: the SSO/Proxy/Jump Host pages, their nav labels, and each component's own README now consistently say Theta Directory / Theta Proxy / Theta Gateway, drop marketing sections ("Why this over the alternatives", "Get it", "Related projects") that don't apply to a suite component, remove every standalone/bare-metal install path, and link to `theta42.github.io/theta-suite/...` instead of the old per-repo Pages sites.
|
||||
|
||||
### theta-directory v2.0.2
|
||||
- **README rebranding & standalone-install cleanup.** Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (`theta42.github.io/sso-manager-node/`); documentation and secrets links now point at the unified `theta42.github.io/theta-suite/` site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
|
||||
- **Docs site (`docs/sso/`)**: full rewrite of the landing page — dropped "Why this over the alternatives" / "Get it" / "Related projects", refreshed all screenshots, added the two agent screenshots, and swept "SSO Manager" → "Theta Directory" across every sub-page (configuration, OAuth, LDAP, directory, agents, replication, vault, concepts-*).
|
||||
|
||||
### proxy v2.0.1
|
||||
- Finishes the pending v2.0.0 Docker-only rewrite (README's Quick start already trimmed to one Docker Compose path via Theta Suite).
|
||||
- Removed "Why this over the alternatives"; trimmed Requirements to actual Docker-host requirements (was still listing bare-metal items: root access, directly-installed OpenResty/Redis).
|
||||
- Fixed stale links to the old per-repo GitHub Pages site; synced `package-lock.json`'s version (missed by the earlier 2.0.0 bump commit).
|
||||
- Docs site (`docs/proxy/`): renamed to Theta Proxy, dropped the same three sections, refreshed screenshots, relative links within the unified site. Deleted a stale `docs/proxy/README.md` meta-doc left over from when this repo had its own separate Pages site (wrong live URL, referenced a deleted `installation.md`).
|
||||
|
||||
### jump-host v2.0.1
|
||||
- Rebranded docs to Theta Gateway (matches the repo's own README/package.json naming).
|
||||
- Removed the "Standalone Docker" and "Bare metal" install paths, which contradicted the Deployment section's own "exclusively via Docker Compose within Theta Suite" claim.
|
||||
- Fixed stale links to the old per-repo GitHub Pages sites.
|
||||
- Docs site (`docs/jump-host/`): same section removal, added a WireGuard mesh-routing feature bullet, refreshed screenshots, fixed three more stale absolute links in `architecture.md`. Deleted the same kind of stale `docs/jump-host/README.md` meta-doc.
|
||||
|
||||
## [v2.0.1] - 2026-08-09
|
||||
|
||||
Rolls up **sso-manager-node v2.0.1** and **theta-agent v2.0.1**.
|
||||
|
||||
### Added
|
||||
- **Non-Root Secrets Group Access**: Set up `theta-secrets` and `theta` system groups in `setup.sh` and set permission flags to `0750` on `/etc/theta42` and `0640` on `agent.yml` to allow non-root users in the group to request secrets.
|
||||
- **Autostart Tray Icon Companion**: Configured `/etc/xdg/autostart/theta-agent-tray.desktop` in `setup.sh` to automatically launch the desktop tray icon companion.
|
||||
- **OpenBao / OpenBoa Resource Exclusion**: Skipped internal secret/renewer services from populating the directory resources catalogue.
|
||||
- **Full Docker Integration Tests**: Rewrote the integration test runner (`test-integration.sh`) to support full env-mode LDAP seeding (`seed-test-user.sh`) and pass test environment configs/parameters reliably inside Docker containers.
|
||||
|
||||
## [v2.0.0] - 2026-08-09
|
||||
|
||||
Rolls up **sso-manager-node v2.0.0**, **theta-agent v2.0.0**, **jump-host v2.0.0**.
|
||||
|
||||
### Added
|
||||
- **Theta Gateway (\`jump-host\` v2.0.0)**: WireGuard mesh exit node management, QR code profiles, \`.conf\` file downloads, and automatic X25519 keypair bootstrap.
|
||||
- **Theta Agent (v2.0.0)**: System tray status companion badge, systemd logind desktop session detection, and real-time CPU / disk / process telemetry stream.
|
||||
- **SSO Directory (\`sso-manager-node\` v2.0.0)**: Live telemetry dashboard cards, desktop session power controls (lock, display off, logout, sleep), and site reconciler.
|
||||
|
||||
## [v1.48.0] - 2026-08-08
|
||||
|
||||
Rolls up **sso-manager-node v1.33.0**, **theta-agent v1.8.0**, **proxy v1.35.1**, **jump-host v1.19.1**.
|
||||
|
||||
### Added
|
||||
- **Directory Key Badges & Secret Search/Filter.** Added key badges `🔑 Secret` to resources with stored OpenBao secrets, secret key search filtering, and a `With Secrets` tree toggle.
|
||||
- **Discovered Inventory Merge & Ignore Actions.** Supported merging discovered network devices into existing Directory resources and ignoring unmanaged entries.
|
||||
- **Kind-Specific Resource Creation Modals.** Added dedicated `+ Add Site`, Host, and Service creation workflows.
|
||||
- **Optional Child Secret Key Name on Inheritance.** Made key name optional when inheriting parent secrets — defaulting to the original parent secret key name if left blank.
|
||||
- **Agent Tab Versioning, Logged Users & Desktop Operations.** Added Agent Version badge (`v1.8.0`), physical partitions table, active logged-in sessions list (`who` / `host.Users()`), and desktop session/power controls (Lock, Display Off, Log Out, Sleep Host).
|
||||
|
||||
## [v1.47.0] - 2026-08-08
|
||||
|
||||
Rolls up **sso-manager-node v1.32.0**, **theta-agent v1.7.0**, **proxy v1.35.1**, **jump-host v1.19.1**.
|
||||
|
||||
### Added
|
||||
- **Subtype Management & Metrics Drivers Architecture.** Implemented a 4-tier resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||
- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance with explicit pointer resolution (`INHERIT:<parentSlug>:<parentKey>`).
|
||||
- **Cross-Platform Theta Agent Binaries.** Compiled native zero-dependency Go binaries for **Linux (amd64, arm64, armv7)**, **Windows (amd64, arm64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**.
|
||||
- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item.
|
||||
- **Multi-Secret Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources/<slug>/conf` with per-key merging and deletion.
|
||||
- **Automated Integration Testing.** Fixed `test-integration.sh` to use modern `docker compose` syntax and added driver test coverage.
|
||||
|
||||
### Fixed
|
||||
- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths.
|
||||
- **Dockerfile Driver Staging.** Added `COPY nodejs/drivers ./drivers` to `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution.
|
||||
|
||||
## [v1.46.0] - 2026-08-07
|
||||
|
||||
Rolls up **theta-agent v1.6.0**, **sso-manager-node v1.31.0**, **jump-host v1.19.1**.
|
||||
|
||||
### Added
|
||||
- **On-demand CLI Secret Fetching.** `theta-agent get-secret <key>` and `theta-agent get-secrets [--env|--json]` for dynamic secret resolution without plaintext files on disk.
|
||||
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources with strict regex validation (`^[A-Za-z0-9_]+$`), password generator, and multi-level hierarchy secret inheritance.
|
||||
- **OpenBao `sso-broker` Policy.** Granted `secret/data/resources/*` and `secret/metadata/resources/*` permissions to `sso-broker`.
|
||||
- **Zero-Trust LDAP WebSocket Tunnel.** Auto-starts local `/run/theta/ldap.sock` and `127.0.0.1:3890` loopback listeners on managed nodes.
|
||||
- **Agent Self-Update & Service Control.** Added `theta-agent update` and `theta-agent reinitialize` CLI commands with automated service restarts (`sssd`, `sshd`).
|
||||
|
||||
## [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**.
|
||||
|
||||
> **Breaking — re-enroll your theta-agents.** The SSO now rejects agent tokens it
|
||||
> did not issue. Any agent installed before this release carries a token
|
||||
> generated in the browser that the server never recorded, and will be refused
|
||||
> with close code `4001` until re-enrolled from **Directory → Install Agent**.
|
||||
>
|
||||
> **Re-run `./setup.sh`.** The `sso-broker` OpenBao policy needs the new
|
||||
> `secret/agent/*` grant, or the SSO cannot persist its agent signing key and
|
||||
> will refuse every high-risk agent command.
|
||||
|
||||
### Fixed — theta-suite orchestration
|
||||
|
||||
- **Per-host SSO returned `400 redirect_uri is not registered for this client`.** The bootstrap registered only the proxy's own management callback (`https://<proxy-host>/api/auth/oidc/callback`), but per-host SSO calls back to `https://<protected-host>/__proxy_auth/callback` — a different URL for every host the proxy fronts, all against that one OAuth client. It now also registers `https://**.<domain>/__proxy_auth/callback` and the bare apex, and `ensureRedirectUris()` backfills them onto an existing client so upgraded stacks are fixed too, not just fresh installs. (The SSO's wildcard matcher already supported this; nothing was ever registered to use it.)
|
||||
- **Seeded services were parented to the wrong host.** `theta-proxy` and `theta-jump` were created as host resources and then left childless, while the Proxy, OpenResty Edge and SSH Jump Host services hung off the stack host instead. They now parent to the host that runs them. `reparent()` corrects existing installs on the next run, and only when the current parent is exactly the one the old code set — a layout an operator arranged deliberately is left alone.
|
||||
- The directory-edge fetch added for re-parenting is tolerated separately from the resource list, so losing it can't skip seeding the resources themselves.
|
||||
|
||||
### Added — theta-suite orchestration
|
||||
|
||||
- **The proxy gets a read-only SSO API token.** Minted by the bootstrap and written into `proxy-secrets.js` (before the OpenBao snapshot, so the running proxy actually receives it), backing the per-host SSO group autocomplete. Idempotent: only mints when `sso.apiToken` is still empty.
|
||||
- `setup.sh` writes an `sso: { url, apiToken }` block into the generated `proxy-secrets.js`.
|
||||
- The `sso-broker` OpenBao policy grants `secret/agent/*` for the persistent theta-agent signing key.
|
||||
- `docs/secrets.md` documents the signing key, why it must be stable, and what happens when the grant is missing.
|
||||
|
||||
---
|
||||
|
||||
### sso-manager-node v1.29.0
|
||||
|
||||
**Security — the theta-agent channel authenticated nothing.** `/api/agent/ws` accepted any token string; there was no agent registry, because tokens were generated in the *browser* and never recorded server-side. Anyone who could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token they guessed.
|
||||
|
||||
- Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash *before* the connection is registered or the welcome payload is sent. Unknown/revoked → close `4001`, audited.
|
||||
- `POST /api/agent/enroll` mints the token server-side and returns it once; only its hash is stored. Rotate/revoke/delete drop the live socket immediately (`4004`/`4003`).
|
||||
- Commands are addressed by agent **id**, never by token.
|
||||
- The Ed25519 signing key was generated in the `AgentManager` constructor, so it changed on every restart and the `public_key` pinned in an agent's `agent.yml` stopped matching. It now lives in OpenBao at `secret/agent/signing-key`; if it can't be loaded the SSO refuses high-risk commands rather than signing with a key no agent has seen.
|
||||
- Enroll/update/rotate/revoke/delete, every command, and every rejected connection are audited with the acting user.
|
||||
|
||||
**Directory & agents.** Agents bind to a host resource instead of being matched by hostname; a bound agent's discovery is written onto that resource (`discovery_sources: ["theta-agent"]`) — previously the one source running *on* the host contributed nothing. Enrollments survive restarts, so "installed but offline" (red) is now distinguishable from "no agent" (grey). The Install Agent modal enrolls first and emits `--public-key`, which was never written into `agent.yml` before.
|
||||
|
||||
**Directory tree.** Collapsible, with per-browser persisted state; an active search overrides collapse so matches inside folded subtrees aren't hidden.
|
||||
|
||||
**Discovery — found by running against a live 3-node Proxmox cluster.**
|
||||
|
||||
- MACs and IPs were collected into two flat lists and zipped by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC.
|
||||
- A Proxmox endpoint resource now parents its nodes (one endpoint = one subtree), carrying no IP — giving it the address it's reached at made the reconciler merge it with the node answering there, producing a resource that was **its own parent**. Self-edges and cycle-closing edges are refused.
|
||||
- Hosts were named after their MAC address, because `bestName` preferred the longer string. Names are ranked hostname > IP > MAC.
|
||||
- `isIp` never matched anything (`\\.` in a regex literal matches a backslash, not a dot).
|
||||
- Guests carry `sourceId`/`node`/`vmid`/`macAddress`; container and overlay interfaces (`docker0`, `veth*`) are filtered out; stopped VMs still report a MAC; DHCP LXCs get an address; nodes report their own IP/MAC; offline nodes are recorded rather than skipped.
|
||||
- Cross-kind merges prevented; the inventory is read once per run instead of once per incoming resource.
|
||||
|
||||
**Other.** The Profile page's API Tokens card is no longer wider than every other card (it sat outside the page container). `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite had been failing in CI as "Cannot find module" — suites 27 → 29, 296 tests passing.
|
||||
|
||||
### theta-agent v1.4.0 (protocol v1.2.0)
|
||||
|
||||
- **Fail-closed verification.** `verifySignature` returned `true` when no `public_key` was configured — and the installer never wrote one, so a default install executed `reboot`, `configure_ldap`, `arbitrary_bash` and `update_binary` **unverified**.
|
||||
- **Canonicalization disagreed with the server.** Go's `encoding/json` escapes `<`, `>` and `&`; `JSON.stringify` does not. Any payload containing them failed verification — for `arbitrary_bash` that is most real scripts (`>` redirection, `&&`). Now uses `SetEscapeHTML(false)`.
|
||||
- Handles the SSO's enrollment close codes and backs off 5 minutes instead of retrying a dead credential every 5 seconds forever.
|
||||
- The connect log no longer prints the URL, which carried `?token=`.
|
||||
- `install.sh --public-key`, and a loud warning when none is configured.
|
||||
|
||||
### proxy v1.34.0
|
||||
|
||||
- The per-host SSO **Allowed groups** field autocompletes from the SSO directory's groups. It previously suggested only local groups — the one set of values that can never match, since the allow-list is checked against the SSO's `groups` claim. New `conf.sso` block; degrades silently when unset.
|
||||
- Authenticates with `Authorization: Bearer`, not the `auth-token` header.
|
||||
|
||||
### jump-host v1.19.0
|
||||
|
||||
- **Only catalog hosts are jump targets.** The filter treated a missing `managed` flag as permission, so unpromoted discovery results — Proxmox guests, UniFi clients — appeared in the TUI picker and were accepted by the username grammar. It now mirrors the SSO Directory's own rule.
|
||||
|
||||
## [v1.41.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **The Local Docker daemon discovery plugin no longer errors** — the sso-manager container had no access to the host docker socket, so the seeded `docker-local` plugin (socketPath `/var/run/docker.sock`) failed with `ENOENT` and showed "Last run: error". `docker-compose.yml` now mounts `/var/run/docker.sock` into the container. Recreate the container (`docker compose up -d sso-manager`) and hit "Run now" on the plugin.
|
||||
- **theta-agent ships the rebuilt binary with the heartbeat fix** (v1.3.1, gitlink `51750d0`) — the prebuilt `theta-agent-linux-amd64` predated the v1.3.0 `heartbeat_ack` fix, so the installed agent still logged "Unknown command type: heartbeat_ack". Now rebuilt + tested.
|
||||
|
||||
## [v1.40.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **No more spurious "Invalid Credentials, login failed" during LDAP enrollment** (ldap-client v1.25.0, gitlink `68fcdb5`) — `index.sh` self-registered the host in the Directory when `sso_token` was *declared but empty* (it checked `[[ -v ]]`), POSTing an empty Bearer token and getting a misleading `LDAPLoginFailed`. It now only registers with a real token; the stack host (already seeded by the bootstrap) skips registration.
|
||||
- **The `cn=ldapclient` service account now shows in the SSO Users UI** — it was created as a bare `organizationalRole` (invisible to the `posixAccount` user filter) and never joined `app_sso_service_account`, so it never appeared as a service account. The bootstrap now creates it as a `posixAccount` (uid 10001, above the regular-user reserved floor) and adds it to `app_sso_service_account`; for an existing account it best-effort adds the `posixAccount` shape (auxiliary, so it can't conflict with the structural `organizationalRole`) + the group membership.
|
||||
|
||||
## [v1.39.0] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
- **Plain LDAP (389) now reachable from the host** — `docker-compose.yml` published only LDAPS (636); plain LDAP (389) was deliberately not mapped, so the stack host's own enrollment (`setup.sh` → ldap-client, which configures sssd against `ldap://localhost:389` and `ldaps://localhost:636`) could not reach the directory over loopback. Both 389 and 636 are now published to the host (bind 0.0.0.0; `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to lock to the host only).
|
||||
|
||||
## [v1.38.0] - 2026-08-04
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -1,74 +1,68 @@
|
||||
# theta-suite
|
||||
# Theta Suite 2.0
|
||||
|
||||
The whole theta42 identity + access stack in one repo, brought up with a single
|
||||
command — for home labs and small businesses.
|
||||
Theta Suite 2.0 is your production-grade, single-command solution for replacing fragmented identity, gateway, and host management setups with a unified zero-trust infrastructure stack. It seamlessly integrates OIDC identity, LDAP directories, automated host enrollment, WireGuard mesh routing, and centralized secret management in one command.
|
||||
|
||||
It composes four applications around a shared [OpenBao](https://openbao.org/)
|
||||
secrets store, brought up with one command:
|
||||
Theta Suite composes core applications around a shared [OpenBao](https://openbao.org/) secrets store, brought up with a single `./setup.sh`:
|
||||
|
||||
- **[SSO Manager](https://github.com/theta42/sso-manager-node)** — an OIDC
|
||||
provider with a built-in LDAP directory (OpenLDAP) and a web UI for managing
|
||||
users, groups, and OAuth clients.
|
||||
- **[theta42/proxy](https://github.com/theta42/proxy)** — an OIDC-protected
|
||||
reverse proxy (OpenResty) that puts any of your apps behind SSO login and can
|
||||
look users up directly in LDAP.
|
||||
- **[Jump Host](https://github.com/theta42/jump-host)** — directory-driven SSH
|
||||
access to your machines through one public entry point.
|
||||
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls Linux
|
||||
hosts into the directory for PAM/SSSD login, sudo, and SSH keys.
|
||||
- **[Theta Directory](https://github.com/theta42/sso-manager-node)** — an OIDC provider with a built-in OpenLDAP directory, resource catalog, IAM group access controls, and administrative web console.
|
||||
- **[Theta Gateway](https://github.com/theta42/jump-host)** — directory-driven SSH access gateway and integrated WireGuard mesh network router with site-aware target filtering and NETMAP shadow subnets.
|
||||
- **[Theta Agent](https://github.com/theta42/theta-agent)** — lightweight multi-platform host telemetry and desktop control agent for Linux (amd64, arm64, armv7), macOS (Intel, Apple Silicon), and Windows.
|
||||
- **[Theta Proxy](https://github.com/theta42/proxy)** — an OIDC-protected reverse proxy (OpenResty) that puts web applications behind directory authentication with direct LDAP user lookups.
|
||||
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls Linux hosts into the directory for PAM/SSSD login, sudo, and SSH keys.
|
||||
|
||||
All four load their secrets from OpenBao at boot; `setup.sh` automates the
|
||||
first-run glue so they find each other and the secrets store.
|
||||
All applications load their secrets from OpenBao at boot; `./setup.sh` automates the first-run glue so components discover each other and the secrets engine automatically.
|
||||
|
||||
**Documentation:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
|
||||
**Site:** [https://theta42.github.io/theta-suite/](https://theta42.github.io/theta-suite/)
|
||||
|
||||
## Screenshots
|
||||
|
||||
The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
||||
The Theta Directory and Theta Proxy, stood up by one `./setup.sh` run:
|
||||
|
||||
| SSO Manager Dashboard | Proxy Hosts |
|
||||
| Theta Directory Dashboard | Proxy Hosts |
|
||||
| --- | --- |
|
||||
| [](docs/images/sso-dashboard.png) | [](docs/images/proxy-hosts.png) |
|
||||
| [](docs/images/sso-dashboard.png) | [](docs/images/proxy-hosts.png) |
|
||||
|
||||
## Configuration
|
||||
|
||||
`setup.sh` automates the first-run glue between subprojects:
|
||||
- Asks for your domain once (in `setup.env`) and fills it in across all config files.
|
||||
- Registers the proxy as an OIDC client of the SSO.
|
||||
- Persists submodule commit hashes in `.env` for reproducibility (e.g., `SSO_GIT_COMMIT`, `PROXY_GIT_COMMIT`). This ensures future `docker compose` runs use the same submodule versions.
|
||||
|
||||
**Why use this instead of running the two separately?** The two only become useful once the proxy is registered as an OIDC client of the SSO and pointed at the SSO's LDAP directory — and the SSO's domain has to match across half a dozen config fields or logins silently fail with `Invalid Credentials`. Doing that by hand is fiddly and easy to get wrong. `setup.sh` handles this automatically and snapshots state before every rebuild — so you get a working SSO + proxy stack in one command and a safe way to upgrade it.
|
||||
|
||||
## Unified Release Status
|
||||
- ✅ **Phase 1 (oidc-client)**: Complete.
|
||||
- ⏳ **Phases 2-5**: Pending (see [roadmap](#)).
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ 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)
|
||||
│ │ │
|
||||
┌────────▼─────────┐ ┌────────▼──────────┐ │
|
||||
│ theta-proxy │ │ theta-gateway │ │
|
||||
│ OpenResty │ │ SSH Gateway │ │
|
||||
│ :80/:443/:4443 │ │ WireGuard Mesh │ │
|
||||
│ mgmt app :3000 │ └────────┬──────────┘ │
|
||||
└────────┬─────────┘ │ OIDC + LDAP │
|
||||
│ http:3001 (internal)│ via theta-directory
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ theta-directory (Express + OpenLDAP + Redis) │
|
||||
│ OIDC provider + LDAP directory + Resource Catalog │
|
||||
│ 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.
|
||||
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine (`theta-agent`, specialized subtype driver, Proxmox hypervisor fallback, unmanaged) for systemd, docker, proxmox, wireguard, database, and k8s resources.
|
||||
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), guaranteeing strict secret scoping across services and containers.
|
||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master LDAP replication across physical locations.
|
||||
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
|
||||
|
||||
@@ -129,18 +123,24 @@ see browser warnings.)
|
||||
|
||||
Optional extra ports (only if you need them):
|
||||
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
|
||||
- **636** (LDAPS) — for direct-LDAP clients on other machines (Linux hosts
|
||||
via PAM/SSSD, LDAP-native apps). The proxy itself reaches LDAP over the
|
||||
internal Docker network, so you do **not** need to expose 636 for the stack
|
||||
to work.
|
||||
**Do not forward 636 to the public internet.** If you need LAN clients to bind
|
||||
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
||||
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
|
||||
enrollment (`setup.sh` → ldap-client) configures its sssd against
|
||||
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
|
||||
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
|
||||
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
|
||||
bind over either; the proxy itself reaches LDAP over the internal Docker
|
||||
network and doesn't need them.
|
||||
**Do not forward 389/636 to the public internet.** If you need LAN clients to
|
||||
bind LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
|
||||
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
|
||||
SAN. The default shows the public SSO hostname, which implies a public route.
|
||||
|
||||
### 4. Docker + Docker Compose
|
||||
|
||||
You must use the modern Docker Compose v2 plugin (`docker compose`). The older v1 standalone (`docker-compose`) is not compatible with the BuildKit images generated by this suite and will fail with a `ContainerConfig` KeyError during deployment.
|
||||
You must use the modern Docker Compose v2 plugin (`docker compose`). The older
|
||||
v1 standalone (`docker-compose`) is not compatible with the BuildKit images
|
||||
generated by this suite and will fail with a `ContainerConfig` KeyError during
|
||||
deployment.
|
||||
|
||||
---
|
||||
|
||||
@@ -270,17 +270,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
|
||||
@@ -470,12 +472,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
|
||||
|
||||
@@ -84,6 +84,26 @@ const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET
|
||||
&& !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET);
|
||||
|
||||
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
// Per-host SSO (proxy routes/host_auth.js) calls back to
|
||||
// `https://<proxied-host>/__proxy_auth/callback` — a DIFFERENT URL for every
|
||||
// host the proxy fronts, all against this one OAuth client. Registering just
|
||||
// REDIRECT_URI above is what produced "400 redirect_uri is not registered for
|
||||
// this client" the moment a host's auth was set to SSO. The SSO's
|
||||
// redirectUriAllowed() supports `**` (any number of labels), so one pattern
|
||||
// covers the whole domain; `**.` does not match the bare apex, so register that
|
||||
// separately for a host served at the domain itself.
|
||||
//
|
||||
// A function, not a const: DOMAIN is declared further down this file, so
|
||||
// evaluating it here at module scope would hit the temporal dead zone.
|
||||
function proxyRedirectUris() {
|
||||
if (!DOMAIN) return [REDIRECT_URI];
|
||||
return [
|
||||
REDIRECT_URI,
|
||||
`https://**.${DOMAIN}/__proxy_auth/callback`,
|
||||
`https://${DOMAIN}/__proxy_auth/callback`,
|
||||
];
|
||||
}
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
const CLIENT_NAME = 'theta-proxy';
|
||||
|
||||
@@ -178,31 +198,88 @@ function ldapModify(ldif) {
|
||||
}
|
||||
|
||||
// ── 1. LDAP service account for the proxy ───────────────────────────────────
|
||||
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
|
||||
// UI as a service account it must (a) match the user filter (posixAccount) and
|
||||
// (b) be a member of app_sso_service_account (that membership is what the Users
|
||||
// page marks as a non-person/service account). Older bootstraps created it as a
|
||||
// bare organizationalRole (invisible to the Users list) and never joined the
|
||||
// group, so it never appeared. Both are fixed here; the existing-path shape add
|
||||
// is best-effort so a pre-existing account still binds even if the upgrade add
|
||||
// fails.
|
||||
function ensureServiceAccount() {
|
||||
const pw = hashPasswordSSHA512(SVC_PASS);
|
||||
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
|
||||
if (entryExists(SVC_DN)) {
|
||||
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
|
||||
const r = ldapModify([
|
||||
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
|
||||
// Add the auxiliary posixAccount objectClass + required attrs so the entry
|
||||
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
|
||||
// it is structural and would conflict with the existing organizationalRole.
|
||||
const shape = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'add: objectClass',
|
||||
'objectClass: posixAccount',
|
||||
'-',
|
||||
'add: uid',
|
||||
'uid: ldapclient',
|
||||
'-',
|
||||
'add: uidNumber',
|
||||
`uidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: gidNumber',
|
||||
`gidNumber: ${uidNum}`,
|
||||
'-',
|
||||
'add: homeDirectory',
|
||||
'homeDirectory: /nonexistent',
|
||||
'-',
|
||||
'add: description',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
'',
|
||||
].join('\n');
|
||||
const rs = ldapModify(shape);
|
||||
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
|
||||
log(' service-account shape warning (account still binds):', rs.stderr.trim());
|
||||
}
|
||||
const rp = ldapModify([
|
||||
`dn: ${SVC_DN}`,
|
||||
'changetype: modify',
|
||||
'replace: userPassword',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
|
||||
return;
|
||||
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
|
||||
} else {
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const entry = [
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: inetOrgPerson',
|
||||
'objectClass: posixAccount',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
'sn: ldapclient',
|
||||
'uid: ldapclient',
|
||||
`uidNumber: ${uidNum}`,
|
||||
`gidNumber: ${uidNum}`,
|
||||
'homeDirectory: /nonexistent',
|
||||
'description: LDAP bind service account (proxy / ldap-client)',
|
||||
`userPassword: ${pw}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const r = ldapAdd(entry);
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
}
|
||||
log(`Creating service account ${SVC_DN}`);
|
||||
const r = ldapAdd([
|
||||
`dn: ${SVC_DN}`,
|
||||
'objectClass: organizationalRole',
|
||||
'objectClass: simpleSecurityObject',
|
||||
'objectClass: top',
|
||||
'cn: ldapclient',
|
||||
`userPassword: ${pw}`,
|
||||
// Mark it as a service account (the Users UI's service-account signal).
|
||||
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
|
||||
const rm = ldapModify([
|
||||
`dn: ${gdn}`,
|
||||
'changetype: modify',
|
||||
'add: member',
|
||||
`member: ${SVC_DN}`,
|
||||
'',
|
||||
].join('\n'));
|
||||
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
|
||||
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
|
||||
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
|
||||
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
|
||||
}
|
||||
|
||||
// ── 2. First admin user ─────────────────────────────────────────────────────
|
||||
@@ -281,7 +358,7 @@ async function listClients(token) {
|
||||
}
|
||||
|
||||
async function createClient(token, opts) {
|
||||
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
|
||||
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: proxyRedirectUris() };
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
@@ -305,6 +382,29 @@ async function createClient(token, opts) {
|
||||
return { id, secret };
|
||||
}
|
||||
|
||||
// Add any redirect_uris the client is missing, keeping whatever the operator
|
||||
// has already registered. Backfills installs whose proxy client was created
|
||||
// before the per-host `__proxy_auth/callback` patterns existed — without this,
|
||||
// setting a host's auth to SSO fails with "400 redirect_uri is not registered
|
||||
// for this client" on an upgraded stack and only works on a fresh one.
|
||||
// Warn-only: a stack that cannot widen its client is still a working stack.
|
||||
async function ensureRedirectUris(token, client, wanted) {
|
||||
const have = client.redirect_uris || [];
|
||||
const missing = wanted.filter((u) => !have.includes(u));
|
||||
if (!missing.length) return;
|
||||
try {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${client.client_id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ redirect_uris: [...have, ...missing] }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
|
||||
log(` OAuth client ${client.name}: registered ${missing.length} redirect URI(s) for per-host SSO`);
|
||||
} catch (error) {
|
||||
log(` WARNING: could not add redirect URIs to ${client.name}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateClient(token, id) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
|
||||
method: 'POST',
|
||||
@@ -368,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.
|
||||
@@ -387,6 +499,30 @@ const HOST_FACTS = {
|
||||
|
||||
async function seedDirectory(token, clientId, jumpClientId) {
|
||||
let resources = ((await dirGet(token, 'resources')).results) || [];
|
||||
// Tolerated separately from the resource list: edges only drive the
|
||||
// re-parent + OAuth-link steps, and losing those is not a reason to skip
|
||||
// seeding the resources themselves.
|
||||
let edges = [];
|
||||
try { edges = ((await dirGet(token, 'edges')).results) || []; }
|
||||
catch (e) { log(` WARNING: could not list directory edges (${e.message}) — skipping re-parent/link steps`); }
|
||||
|
||||
// Move an already-seeded resource under the parent it should have had.
|
||||
// Only ever corrects a parent this bootstrap itself seeded wrongly (the
|
||||
// proxy/jump services were parented to the stack host instead of to
|
||||
// host_theta-proxy / host_theta-jump); an operator who has deliberately
|
||||
// re-parented something keeps their layout, because we only rewire when the
|
||||
// current parent is the one the old code would have set.
|
||||
async function reparent(resource, wantParentId, fromParentId) {
|
||||
if (!resource || !wantParentId || !fromParentId) return;
|
||||
const current = edges.find((e) => e.childId === resource.id && (e.relation === 'hosts' || e.relation === 'oauth'));
|
||||
if (!current) return; // unparented: leave it alone
|
||||
if (current.parentId === wantParentId) return; // already correct
|
||||
if (current.parentId !== fromParentId) return; // operator moved it: respect that
|
||||
// PUT with kind + hostId is what makes the route rewire the parent edge.
|
||||
await dirPut(token, `resources/${resource.id}`, { kind: resource.kind, hostId: wantParentId });
|
||||
current.parentId = wantParentId;
|
||||
log(` directory: re-parented ${resource.kind} '${resource.slug}' onto its own host`);
|
||||
}
|
||||
|
||||
// Create a resource unless its slug (or a legacy alternate from an earlier
|
||||
// seed layout) already exists. On an existing resource, seed metadata keys
|
||||
@@ -437,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}` : '');
|
||||
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,
|
||||
});
|
||||
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}`,
|
||||
@@ -471,7 +601,8 @@ 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.
|
||||
// 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,
|
||||
@@ -511,6 +642,14 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// Remove or set ignored on OpenBao/bao-renewer seed resources if present — OpenBao is an
|
||||
// internal stack service, not a user-facing published directory service.
|
||||
for (const r of resources) {
|
||||
if (r.slug === 'openbao' || r.slug === 'openboa' || r.slug === 'bao-renewer' || (r.name && (/openbao|openboa|bao-renewer/i.test(r.name)))) {
|
||||
await dirDelete(token, `resources/${r.id}`).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// SSH jump host service (core component — always registered).
|
||||
let jumpSvc = null;
|
||||
{
|
||||
@@ -526,16 +665,50 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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.
|
||||
async function linkOauthClient(id, parent, label) {
|
||||
if (!id || !parent) return;
|
||||
const oauthRes = resources.find((r) => r.id === id);
|
||||
if (!oauthRes) return;
|
||||
const edges = ((await dirGet(token, 'edges')).results) || [];
|
||||
const linked = edges.some((e) => e.childId === id);
|
||||
if (!linked) {
|
||||
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
edges.push({ parentId: parent.id, childId: id, relation: 'oauth' });
|
||||
log(` directory: linked OAuth client under '${label}'`);
|
||||
}
|
||||
}
|
||||
@@ -589,7 +762,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`);
|
||||
@@ -626,6 +808,78 @@ function writeProxyCreds(id, secret) {
|
||||
}
|
||||
}
|
||||
|
||||
// The proxy needs a read-only SSO API token so its per-host SSO allow-list can
|
||||
// suggest the directory's actual groups (otherwise the "Allowed groups" field
|
||||
// autocompletes from the proxy's local groups only, which for an SSO-gated host
|
||||
// is never what the operator wants). Idempotent: only mints when the file's
|
||||
// `sso.apiToken` is still empty, and only rewrites that one line. Warn-only —
|
||||
// no token just means no suggestions.
|
||||
const PROXY_TOKEN_NAME = 'theta-proxy';
|
||||
|
||||
async function ensureProxyApiToken(token) {
|
||||
const path = '/config/proxy-secrets.js';
|
||||
let src;
|
||||
try {
|
||||
src = fs.readFileSync(path, 'utf8');
|
||||
} catch (e) {
|
||||
log(` WARNING: cannot read ${path} to add an SSO API token (${e.message})`);
|
||||
return;
|
||||
}
|
||||
// An `sso: { ... apiToken: 'sso_...' }` already present means we're done.
|
||||
if (/apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src)) {
|
||||
log(' proxy already has an SSO API token — keeping');
|
||||
return;
|
||||
}
|
||||
if (!/\bsso:\s*\{/.test(src)) {
|
||||
log(` WARNING: ${path} has no \`sso\` block — add one with url + apiToken to enable SSO group autocomplete`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const apiToken = await mintApiToken(token, PROXY_TOKEN_NAME, 'theta-suite proxy (auto-registered)');
|
||||
// Replace the apiToken line inside the sso block only. The jump host's
|
||||
// token lives in a different file, so an unanchored match is safe here.
|
||||
const updated = src.replace(/(apiToken:\s*)(['"])[^'"]*\2/, `$1$2${apiToken}$2`);
|
||||
if (updated === src) {
|
||||
log(` WARNING: could not locate apiToken in ${path} — set sso.apiToken manually`);
|
||||
return;
|
||||
}
|
||||
fs.writeFileSync(path, updated);
|
||||
log(` Minted SSO API token for the proxy and wrote it into ${path}`);
|
||||
} catch (e) {
|
||||
log(` WARNING: could not provision the proxy's SSO API token: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -642,11 +896,11 @@ const JUMP_TOKEN_NAME = 'theta-jump-host';
|
||||
const JUMP_CLIENT_NAME = 'theta-jump';
|
||||
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
|
||||
|
||||
async function mintApiToken(token, name) {
|
||||
async function mintApiToken(token, name, description) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, description: 'theta-suite jump host (auto-registered)' }),
|
||||
body: JSON.stringify({ name, description: description || 'theta-suite jump host (auto-registered)' }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
|
||||
const data = await res.json();
|
||||
@@ -781,6 +1035,10 @@ async function provisionJumpHost(token) {
|
||||
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
|
||||
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
|
||||
|
||||
// Widen an existing client before any of the branches below return: a
|
||||
// freshly created one already gets these from createClient().
|
||||
if (client) await ensureRedirectUris(token, client, proxyRedirectUris());
|
||||
|
||||
if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) {
|
||||
// File creds match an existing client — trust the file's secret
|
||||
// (it's bcrypt-hashed server-side, so we can't verify, but the proxy
|
||||
@@ -810,6 +1068,11 @@ async function provisionJumpHost(token) {
|
||||
resolvedClientId = id;
|
||||
}
|
||||
|
||||
// Must run before the baoPut below: that snapshots proxy-secrets.js into
|
||||
// OpenBao, and the proxy loads its conf from there at boot, so a token
|
||||
// written after the snapshot would never reach the running proxy.
|
||||
await ensureProxyApiToken(token);
|
||||
|
||||
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
|
||||
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
|
||||
// fresh: writeProxyCreds rewrote the file out from under the cached
|
||||
@@ -836,6 +1099,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 {
|
||||
|
||||
@@ -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,91 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-join — runs inside the sso-manager container to adopt a
|
||||
* master site's directory as a read-only spoke. Invoked by setup.sh when
|
||||
* setup.env sets CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY:
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-join.js \
|
||||
* https://sso.master.example.com stj_9f2e... https://sso.this-site.example.com
|
||||
*
|
||||
* The third argument (selfUrl, optional) is this site's own public SSO host
|
||||
* (setup.sh passes https://$CFG_SSO_HOST) -- without it the join still
|
||||
* succeeds, it just registers for one-time adoption only: the master has no
|
||||
* way to reach this spoke to push live replication resync pings at it (see
|
||||
* theta-directory's docs/site-join.md and utils/site_replicate.js).
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as bootstrap.js —
|
||||
* it does NOT require the SSO's internal models. It logs in as the bootstrap
|
||||
* admin (reading /config/sso-secrets.js) and calls the SSO's own
|
||||
* /api/site/join, which imports the master's resource catalog + LDAP tree and
|
||||
* persists the spoke role in /config/site.json.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): JOINED, SITE_SLUG, RESOURCES, LDAP.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const sso = require('/config/sso-secrets.js');
|
||||
|
||||
const ADMIN_UID = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||
const ADMIN_USER_PASS = (sso.bootstrap && sso.bootstrap.adminPass) || '';
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
|
||||
const masterUrl = process.argv[2];
|
||||
const joinKey = process.argv[3];
|
||||
const selfUrl = process.argv[4] || '';
|
||||
|
||||
function log(msg) { console.error('[site-join] ' + msg); }
|
||||
|
||||
async function main() {
|
||||
if (!masterUrl || !joinKey) {
|
||||
throw new Error('usage: node /bootstrap/site-join.js <masterUrl> <joinKey>');
|
||||
}
|
||||
|
||||
// 1. Login as the bootstrap admin (validates the password end-to-end).
|
||||
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_USER_PASS }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
}
|
||||
const loginData = await loginRes.json();
|
||||
const token = loginData.token;
|
||||
if (!token) throw new Error('admin login returned no token');
|
||||
log(`Logged in as ${ADMIN_UID}`);
|
||||
|
||||
// 2. Join the master.
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/site/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ masterUrl, joinKey, ...(selfUrl ? { selfUrl } : {}) }),
|
||||
});
|
||||
const text = await res.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!res.ok) {
|
||||
// A node that already joined is a no-op, not a failure (idempotent setup).
|
||||
if (res.status === 400 && data && /already a spoke/i.test(data.message || '')) {
|
||||
log('Already a spoke — nothing to do.');
|
||||
console.log('JOINED=already');
|
||||
return;
|
||||
}
|
||||
throw new Error(`join failed (${res.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
|
||||
log(`Joined master site ${masterUrl} as ${data.siteSlug || '?'}`);
|
||||
log(`Live replication: ${(data.replication && data.replication.note) || 'unknown'}`);
|
||||
console.log([
|
||||
`JOINED=yes`,
|
||||
`SITE_SLUG=${data.siteSlug || ''}`,
|
||||
`RESOURCES=${(data.resources && data.resources.created) || 0}`,
|
||||
`LDAP=${(data.ldap && data.ldap.note) || ''}`,
|
||||
`LIVE_REPLICATION=${(data.replication && data.replication.live) ? 'yes' : 'no'}`
|
||||
].join(' '));
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-join] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-ldap-register — runs inside the sso-manager container on
|
||||
* every setup.sh run (both master and spoke) to keep OpenLDAP N-way
|
||||
* multi-master replication config (docs/replication.md) in sync without an
|
||||
* operator hand-maintaining LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS.
|
||||
*
|
||||
* The master assigns each spoke a unique LDAP_SERVER_ID at join time (same
|
||||
* mechanism as jump-host's WireGuard mesh index) and derives every site's
|
||||
* LDAP URL from its already-known HTTPS endpoint -- see sso-manager-node's
|
||||
* GET /api/site/ldap-peers (spoke-facing) and
|
||||
* GET /directory-admin/ldap-replication-config (master-local).
|
||||
*
|
||||
* This script fetches whichever of those two applies to this node's role,
|
||||
* and writes the result to /config/ldap-replication.env (KEY=VALUE, the
|
||||
* same shape setup.env/spoke.env use) if it changed since last run. setup.sh
|
||||
* sources that file before starting sso-manager on every invocation, and
|
||||
* restarts the container when this script reports a change -- OpenLDAP's
|
||||
* static slapd.conf is only read at process start, so a config change needs
|
||||
* a restart to take effect; there's no live push, which is why this has to
|
||||
* be re-run periodically (every setup.sh invocation) rather than working
|
||||
* once at join time and never again, especially on the MASTER, whose peer
|
||||
* list changes every time a new spoke joins.
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-ldap-register.js <selfUrl>
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as
|
||||
* bootstrap.js/site-join.js -- does NOT require the SSO's internal models.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): LDAP_CONFIG_CHANGED=<yes|no>,
|
||||
* LDAP_SERVER_ID=<n>, LDAP_REPLICATION_HOSTS=<space-separated, may be empty>.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SITE_CONFIG = '/config/site.json';
|
||||
const LDAP_CONFIG_FILE = '/config/ldap-replication.env';
|
||||
const SSO_INTERNAL = 'http://localhost:3001';
|
||||
|
||||
const selfUrl = process.argv[2];
|
||||
|
||||
function log(msg) { console.error('[site-ldap-register] ' + msg); }
|
||||
|
||||
function readPersisted() {
|
||||
if (!fs.existsSync(LDAP_CONFIG_FILE)) return { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||
const out = { LDAP_SERVER_ID: '', LDAP_REPLICATION_HOSTS: '' };
|
||||
for (const line of fs.readFileSync(LDAP_CONFIG_FILE, 'utf8').split('\n')) {
|
||||
const m = line.match(/^([A-Z_]+)=(.*)$/);
|
||||
if (m && m[1] in out) out[m[1]] = m[2];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function fetchMasterConfig() {
|
||||
const sso = require('/config/sso-secrets.js');
|
||||
const adminUid = (sso.bootstrap && sso.bootstrap.adminUid) || 'admin';
|
||||
const adminPass = (sso.bootstrap && sso.bootstrap.adminPass) || '';
|
||||
|
||||
const loginRes = await fetch(`${SSO_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uid: adminUid, password: adminPass }),
|
||||
});
|
||||
if (!loginRes.ok) throw new Error(`local admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
const { token } = await loginRes.json();
|
||||
if (!token) throw new Error('local admin login returned no token');
|
||||
|
||||
const cfgRes = await fetch(`${SSO_INTERNAL}/api/directory-admin/ldap-replication-config`, {
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!cfgRes.ok) throw new Error(`ldap-replication-config failed (${cfgRes.status}): ${await cfgRes.text().catch(() => '')}`);
|
||||
return cfgRes.json();
|
||||
}
|
||||
|
||||
async function fetchSpokeConfig(site, selfUrl) {
|
||||
const url = `${site.masterUrl.replace(/\/+$/, '')}/api/site/ldap-peers?endpoint=${encodeURIComponent(selfUrl)}`;
|
||||
const res = await fetch(url, { headers: { Authorization: 'Bearer ' + site.masterJoinKey } });
|
||||
const text = await res.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
log('This site is not registered as a spoke on the master yet (join with selfUrl, or re-run site-relay-register.js). Skipping.');
|
||||
return null;
|
||||
}
|
||||
throw new Error(`ldap-peers failed (${res.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!fs.existsSync(SITE_CONFIG)) {
|
||||
log('No /config/site.json yet. Skipping.');
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
const site = JSON.parse(fs.readFileSync(SITE_CONFIG, 'utf8'));
|
||||
|
||||
let result;
|
||||
if (site.isMaster) {
|
||||
result = await fetchMasterConfig();
|
||||
} else {
|
||||
if (!site.masterUrl || !site.masterJoinKey) {
|
||||
log('Spoke role but missing masterUrl/masterJoinKey. Skipping.');
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
if (!selfUrl) throw new Error('usage: node /bootstrap/site-ldap-register.js <selfUrl> (required for a spoke)');
|
||||
result = await fetchSpokeConfig(site, selfUrl);
|
||||
if (!result) {
|
||||
console.log('LDAP_CONFIG_CHANGED=no');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const serverId = String(result.ldapServerId || '');
|
||||
const hosts = (result.peers || []).map((p) => p.ldapHost).filter(Boolean).join(' ');
|
||||
|
||||
const before = readPersisted();
|
||||
const changed = before.LDAP_SERVER_ID !== serverId || before.LDAP_REPLICATION_HOSTS !== hosts;
|
||||
|
||||
if (changed) {
|
||||
fs.writeFileSync(LDAP_CONFIG_FILE, `LDAP_SERVER_ID=${serverId}\nLDAP_REPLICATION_HOSTS=${hosts}\n`);
|
||||
log(`Replication config changed -- ServerID ${serverId}, ${(result.peers || []).length} peer(s). Wrote ${LDAP_CONFIG_FILE}.`);
|
||||
} else {
|
||||
log(`Replication config unchanged -- ServerID ${serverId}, ${(result.peers || []).length} peer(s).`);
|
||||
}
|
||||
|
||||
console.log(`LDAP_CONFIG_CHANGED=${changed ? 'yes' : 'no'}`);
|
||||
console.log(`LDAP_SERVER_ID=${serverId}`);
|
||||
console.log(`LDAP_REPLICATION_HOSTS=${hosts}`);
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-ldap-register] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* theta-suite site-relay-register — runs inside the sso-manager container
|
||||
* (same pattern as site-join.js) to finish no-inbound relay automation for a
|
||||
* spoke with no public IP (MULTI_SITE_SPEC.md §5.2).
|
||||
*
|
||||
* site-join.js's initial join can't supply a mesh IP: this site's jump-host
|
||||
* isn't meshed to the master's yet at that point (mesh peering is a manual,
|
||||
* out-of-band action on both jump-hosts -- mint a join token on the master's
|
||||
* jump-host, paste it into this site's jump-host "Join a mesh" UI action --
|
||||
* the same reason the site join key itself is minted/pasted by hand rather
|
||||
* than automated). This script is the follow-up: run it (setup.sh does, on
|
||||
* every run, when CFG_SPOKE_NO_INBOUND is set) once meshing is done, and it
|
||||
* discovers this jump-host's mesh IP and registers it with the master so
|
||||
* theta-proxy there can auto-create the relay route (see sso-manager-node's
|
||||
* utils/proxy_client.js). Safe to run before meshing completes -- reports
|
||||
* "not meshed yet" and exits 0 so a re-run later just picks it up.
|
||||
*
|
||||
* docker compose exec sso-manager node /bootstrap/site-relay-register.js \
|
||||
* https://sso.this-site.example.com sso-branch2.master-domain.example.com
|
||||
*
|
||||
* Self-contained (Node built-ins + global fetch), same rule as bootstrap.js
|
||||
* and site-join.js -- it does NOT require the SSO's internal models. It
|
||||
* reads this node's own spoke role from /config/site.json (written by
|
||||
* site-join.js) and logs into the LOCAL jump-host as its bootstrap-minted
|
||||
* local admin (/config/jump-secrets.js) to call jump-host's own
|
||||
* GET /api/mesh/self.
|
||||
*
|
||||
* Output (stdout, KEY=VALUE for setup.sh): RELAY=<registered|not-meshed|not-a-spoke|skipped>.
|
||||
* Progress logs go to stderr.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
const SITE_CONFIG = '/config/site.json';
|
||||
const JUMP_SECRETS = '/config/jump-secrets.js';
|
||||
const JUMP_INTERNAL = 'http://jump-host:3002';
|
||||
|
||||
const selfUrl = process.argv[2];
|
||||
const publicHost = process.argv[3];
|
||||
|
||||
function log(msg) { console.error('[site-relay-register] ' + msg); }
|
||||
|
||||
async function main() {
|
||||
if (!selfUrl || !publicHost) {
|
||||
throw new Error('usage: node /bootstrap/site-relay-register.js <selfUrl> <publicHost>');
|
||||
}
|
||||
|
||||
if (!fs.existsSync(SITE_CONFIG)) {
|
||||
log('No /config/site.json yet — this node has not joined a master. Nothing to do.');
|
||||
console.log('RELAY=not-a-spoke');
|
||||
return;
|
||||
}
|
||||
const site = JSON.parse(fs.readFileSync(SITE_CONFIG, 'utf8'));
|
||||
if (site.isMaster || !site.masterUrl || !site.masterJoinKey) {
|
||||
log('Not a joined spoke (missing masterUrl/masterJoinKey, or this is a master). Nothing to do.');
|
||||
console.log('RELAY=not-a-spoke');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(JUMP_SECRETS)) {
|
||||
log('No /config/jump-secrets.js — jump-host has not been provisioned yet. Skipping.');
|
||||
console.log('RELAY=skipped');
|
||||
return;
|
||||
}
|
||||
const jumpSecrets = require(JUMP_SECRETS);
|
||||
const jumpAdminUser = (jumpSecrets.auth && jumpSecrets.auth.adminUsers && jumpSecrets.auth.adminUsers[0]) || 'jumpadmin';
|
||||
const jumpAdminPass = (jumpSecrets.auth && jumpSecrets.auth.localAdminPass) || '';
|
||||
if (!jumpAdminPass) {
|
||||
log('jump-secrets.js has no local admin password. Skipping.');
|
||||
console.log('RELAY=skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
const loginRes = await fetch(`${JUMP_INTERNAL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// jump-host's login route (@simpleworkjs/oidc-client's shared router)
|
||||
// expects `username`, not `uid` -- unlike sso-manager-node's own
|
||||
// /api/auth/login (see site-join.js). Confirmed against a real running
|
||||
// jump-host container; `uid` here just silently 401s.
|
||||
body: JSON.stringify({ username: jumpAdminUser, password: jumpAdminPass }),
|
||||
});
|
||||
if (!loginRes.ok) {
|
||||
throw new Error(`jump-host admin login failed (${loginRes.status}): ${await loginRes.text().catch(() => '')}`);
|
||||
}
|
||||
const { token: jumpToken } = await loginRes.json();
|
||||
if (!jumpToken) throw new Error('jump-host login returned no token');
|
||||
|
||||
const selfRes = await fetch(`${JUMP_INTERNAL}/api/mesh/self`, { headers: { 'auth-token': jumpToken } });
|
||||
if (!selfRes.ok) {
|
||||
throw new Error(`jump-host mesh self-lookup failed (${selfRes.status}): ${await selfRes.text().catch(() => '')}`);
|
||||
}
|
||||
const selfData = await selfRes.json();
|
||||
if (!selfData.meshIp) {
|
||||
log('jump-host is not meshed yet (no mesh IP assigned). Mesh-join it first (jump-host UI), then re-run setup.sh.');
|
||||
console.log('RELAY=not-meshed');
|
||||
return;
|
||||
}
|
||||
log(`Discovered mesh IP ${selfData.meshIp}. Registering with ${site.masterUrl}...`);
|
||||
|
||||
const regRes = await fetch(`${site.masterUrl.replace(/\/+$/, '')}/api/site/spokes`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + site.masterJoinKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: selfUrl,
|
||||
siteSlug: site.siteSlug || '',
|
||||
noInbound: true,
|
||||
meshIp: selfData.meshIp,
|
||||
publicHost,
|
||||
}),
|
||||
});
|
||||
const text = await regRes.text().catch(() => '');
|
||||
let data = null;
|
||||
try { data = JSON.parse(text); } catch (e) { /* not JSON */ }
|
||||
if (!regRes.ok) {
|
||||
throw new Error(`relay registration failed (${regRes.status}): ${(data && data.message) || text}`);
|
||||
}
|
||||
|
||||
log(`Relay: ${(data.relay && data.relay.note) || 'registered'}`);
|
||||
console.log('RELAY=registered');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('[site-relay-register] FAILED: ' + e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -52,12 +52,16 @@ services:
|
||||
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
|
||||
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
|
||||
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
|
||||
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
|
||||
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
|
||||
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
|
||||
# in sso-secrets.js) and do NOT forward 636 to the public internet.
|
||||
- "${LDAPS_PORT:-636}:636"
|
||||
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
|
||||
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
|
||||
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
|
||||
# against ldap://localhost and ldaps://localhost, and the LDAP server is
|
||||
# co-located on this host, so BOTH ports must be reachable from the host
|
||||
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
|
||||
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
|
||||
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
|
||||
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
|
||||
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
|
||||
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
|
||||
environment:
|
||||
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
|
||||
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
|
||||
@@ -67,8 +71,19 @@ services:
|
||||
# setup.sh (policy sso-broker) — NOT the root token.
|
||||
- NODE_ENV=production
|
||||
- NODE_PORT=3001
|
||||
# Only a first-run default (site_config.js's envDefaults()) -- a real
|
||||
# join/promote persists its own value to /config/site.json afterward,
|
||||
# which always wins. Derived by setup.sh from CFG_SITE_NAME.
|
||||
- SITE_SLUG=${SITE_SLUG:-}
|
||||
- LDAP_SERVER_ID=${LDAP_SERVER_ID:-}
|
||||
- LDAP_REPLICATION_HOSTS=${LDAP_REPLICATION_HOSTS:-}
|
||||
# utils/proxy_client.js (no-inbound relay automation) and
|
||||
# utils/jump_client.js (real mesh-gateway count on the Multi-Site
|
||||
# modal) both no-op/skip without these -- neither was ever actually
|
||||
# wired into the compose environment before, so both features were
|
||||
# unreachable in every real deployment despite existing in code.
|
||||
- PROXY_INTERNAL_URL=http://proxy:3000
|
||||
- JUMP_INTERNAL_URL=http://jump-host:3002
|
||||
- VAULT_ADDR=http://openbao:8200
|
||||
- VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
|
||||
# Optional upstream HTTP(S) proxy for outbound calls (SMTP, etc.) at
|
||||
@@ -77,6 +92,10 @@ services:
|
||||
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
|
||||
- NO_PROXY=${CFG_NO_PROXY:-}
|
||||
volumes:
|
||||
# The host docker socket so the bundled Docker discovery plugin (seeded as
|
||||
# 'docker-local' with socketPath /var/run/docker.sock) can list containers.
|
||||
# Without this the plugin errors with ENOENT and shows 'Last run: error'.
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
|
||||
# can write the generated OAuth client creds into proxy-secrets.js. The
|
||||
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# theta-agent: Local-Discovery Spec (mDNS "prefer local directory")
|
||||
|
||||
**Audience**: implementer on Windows/Mac (this was authored on Linux; Windows/Mac-specific network and hosts-file behavior needs to be built and tested there, not assumed here).
|
||||
**Repo**: `theta-agent` (Go). Signing/config mechanism referenced below: `websocket.go`, `config.go`.
|
||||
**Parent doc**: [`MULTI_SITE_SPEC.md`](./MULTI_SITE_SPEC.md) §5.3 — read that section for the "why," this doc is the "what," precisely enough to implement without re-deriving the reasoning.
|
||||
|
||||
## Problem
|
||||
|
||||
A no-inbound spoke site's public hostname (e.g. `sso-staten-island.theta42.com`) resolves, from the internet, to the master's IP, which relays over WireGuard to the spoke. A device physically on that spoke's LAN resolving the same hostname takes the same path — out to the master, back over the tunnel — even though the real service is a few feet away. This is wasted latency, not a correctness bug, but it's the kind of thing users notice.
|
||||
|
||||
## What to Build
|
||||
|
||||
### 1. Announcer (gateway/proxy side — may already be scoped elsewhere, confirm before duplicating)
|
||||
The spoke's `theta-gateway` or `theta-proxy` periodically advertises itself via mDNS on the local segment:
|
||||
- Service type: `_theta-suite._tcp.local`
|
||||
- TXT records: `site=<slug>`, `hosts=<comma-separated list of public hostnames this site fronts>`
|
||||
- Advertised address: the service's own local LAN IP
|
||||
|
||||
### 2. Listener + Override (this doc's actual scope — theta-agent)
|
||||
- New config field in `agent.yml`, e.g. `prefer_local_discovered_directory: bool` (default `false` — opt-in, not automatic, since it changes name resolution behavior on the host).
|
||||
- When `true`, the agent runs an mDNS browser for `_theta-suite._tcp.local` in the background.
|
||||
- On receiving an announcement whose `hosts` TXT list includes a hostname the agent cares about (at minimum: the hostname the agent itself is currently configured to connect to for its WS connection), the agent installs a **local override** redirecting that hostname to the discovered local IP.
|
||||
- No announcement seen (agent off-site, or flag disabled) → no override installed, normal DNS resolution applies. Nothing else about the agent's behavior changes in this case.
|
||||
- If a previously-discovered site's announcement stops being seen (TTL expiry / agent moved networks), the override must be **removed**, not left stale. Don't let a laptop that left the office keep resolving the old office hostname to a now-unreachable LAN IP.
|
||||
|
||||
### 3. Override Mechanism — Platform-Specific, Needs Real Investigation
|
||||
|
||||
This is the part that most needs Windows/Mac-native work; do not assume the Linux/Unix approach ports directly:
|
||||
|
||||
- **Windows**: hosts file lives at `%SystemRoot%\System32\drivers\etc\hosts`; writing to it requires elevation, and Windows caches DNS results independently (`ipconfig /flushdns` needed after an edit, or the change won't take effect immediately — verify whether theta-agent already runs elevated on Windows, since if it doesn't, this whole approach may need a different mechanism, e.g. a local proxy/resolver instead of hosts-file edits).
|
||||
- **macOS**: hosts file at `/etc/hosts`, also requires root; macOS's mDNSResponder/DNS caching behavior differs from Windows and Linux (`dscacheutil -flushcache; killall -HUP mDNSResponder` territory) — confirm whether an installed hosts entry is actually honored promptly, or whether the built-in mDNSResponder needs to be told directly instead of fighting it with a hosts-file edit (macOS already *has* native mDNS support baked into resolution — it may be simpler/more idiomatic there to register via the OS's own Bonjour APIs rather than hand-roll hosts-file mutation).
|
||||
- **Linux**: `/etc/hosts`, requires root, comparatively straightforward, `systemd-resolved` caching considerations may apply depending on distro.
|
||||
|
||||
Given the platform divergence, seriously consider whether a **local stub resolver** (theta-agent listens on `127.0.0.1:<port>`, answers matching hostnames from its own discovery cache, forwards everything else upstream — with the OS's DNS pointed at it only for the duration this feature is active) is actually simpler and more uniform across all three platforms than hosts-file mutation, despite the extra moving part. Recommend evaluating both before committing to an implementation; this doc intentionally doesn't prescribe one, since that call needs platform testing this environment can't do.
|
||||
|
||||
## Hard Security Rule (non-negotiable, applies regardless of mechanism chosen)
|
||||
|
||||
mDNS is **unauthenticated** on a local network — anyone on the same LAN segment can broadcast a spoofed announcement. This feature may only ever change **where** the agent connects (which IP a hostname resolves to). It must **never** change **whether** the agent trusts what answers there. Concretely:
|
||||
- TLS certificate validation and hostname verification against the redirected IP must remain fully enforced — no exceptions, no "local network so it's fine" carve-out.
|
||||
- A spoofed rogue announcement pointing a hostname at an attacker's local IP should produce a TLS handshake failure (cert won't match), not a silent connection. If your chosen mechanism has *any* code path where local-discovery bypasses or weakens cert checking, that's a bug, not an optimization — fix it before shipping.
|
||||
|
||||
## Out of Scope for This Piece
|
||||
|
||||
- The announcer side's exact library/implementation on the gateway/proxy (Linux — can be built where this spec was authored, not blocked on Windows/Mac).
|
||||
- Anything about the master-relay mechanism itself (§5.2 of the parent spec) — this doc is purely the "skip the relay when local" optimization layered on top of it.
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- Flag exists, defaults off.
|
||||
- Enabling it on a machine physically on a spoke's LAN measurably routes traffic to the local spoke instead of through the master relay (verify via a network capture or the proxy's own access logs on each side, not just "it feels faster").
|
||||
- Leaving that LAN (or disabling the flag) reliably reverts to normal resolution — no stale overrides.
|
||||
- A test with a spoofed/rogue mDNS announcement (a second, non-legitimate advertiser) results in a TLS failure, not a successful connection to the impostor.
|
||||
- Behavior verified on both Windows and macOS, not just Linux.
|
||||
@@ -0,0 +1,278 @@
|
||||
# Theta Suite Multi-Site Architecture & VPN Specification
|
||||
|
||||
**Specification Version**: `2.2.0`
|
||||
**Status**: Mostly shipped. Read the status table at the bottom before trusting any section's detail as current behavior — this document accumulated across several build passes and earlier sections describe things that were aspirational when written and real by the time later sections were added.
|
||||
**Target Suite Version**: `v1.50.0+`
|
||||
**Repository**: [`theta-suite`](https://github.com/theta42/theta-suite)
|
||||
|
||||
> ## Shipped today
|
||||
> - **Join, live replication, promotion** (`sso-manager-node`): a spoke joins via a one-time export over a site join key (`POST /api/site/join-keys` / `/export` / `/join`), then registers its own endpoint so the master can push live resync pings on every catalog write — no longer a one-time snapshot. Promotion (`POST /api/directory-admin/site-promote`) coordinates a real handoff, demoting the old master as one action. Identical agent-signing keys ride the same export/resync path. Read [`sso-manager-node/docs/site-join.md`](https://github.com/theta42/theta-directory/blob/master/docs/site-join.md) and `directory_spec.md` §11 for the endpoint-level detail.
|
||||
> - **Gateway-to-gateway WireGuard mesh** (`theta-gateway`): real site-to-site tunnels via `POST /api/mesh/register`/`/join`, kernel WireGuard with a userspace `wireguard-go` fallback. Verified with an actual two-container encrypted tunnel passing traffic, not a mock.
|
||||
> - **Cross-component routing + no-inbound relay automation**: `sso-manager-node`'s replication traffic now prefers a spoke's mesh IP over the open internet when one is on file (`utils/site_replicate.js`), and a no-inbound spoke's join (`POST /api/site/join` → `/api/site/spokes`) can carry `noInbound`/`meshIp`/`publicHost`, which drives `utils/proxy_client.js` to auto-create the relay route on the master's `theta-proxy` via its existing self-service API token system (reused, not a new credential type). The one piece that stays a manual, out-of-band step is the mesh peering itself (mint a join token on one jump-host, paste it into the other's "Join a mesh" UI) — `theta-suite`'s `bootstrap/site-relay-register.js` (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`) picks up from there on the next `setup.sh` run.
|
||||
> - **mDNS local-discovery (Linux + Windows)**: shipped and verified — `theta-gateway` announces (`services/mdns_announce.js`), `theta-agent` discovers and applies a hosts-file override, cleanly reverts when the announcement disappears. Linux was verified end-to-end over real multicast; Windows shipped in `theta-agent` v2.2.0 (CRLF-aware hosts override, `ipconfig /flushdns`, and a /32 host-route pin so the WireGuard tunnel can't swallow the direct LAN path). macOS still needs real testing — see the TODO note.
|
||||
|
||||
Design scale: a handful of sites (dozen max, 254 hard ceiling — see §4), a few hundred users/hosts total. This is a deliberate, small, trusted-operator deployment, not a hyperscale/adversarial-tenant one — several decisions below (fire-and-forget replication, identical directories) trade blast-radius for simplicity *because* the scale allows it. Don't generalize these choices past that scale without re-deriving them.
|
||||
|
||||
---
|
||||
|
||||
## 1. High-Level System Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
subgraph ControlPlane["Master Site (write authority)"]
|
||||
ssoM["sso-manager-node (isMaster=true)"]
|
||||
ldapM["OpenLDAP (MMR write node)"]
|
||||
baoM["OpenBao (local, replication source)"]
|
||||
proxyM["theta-proxy"]
|
||||
gateM["theta-gateway"]
|
||||
agentM["theta-agent"]
|
||||
end
|
||||
|
||||
subgraph SiteB["Spoke — inbound (has a public IP)"]
|
||||
ssoB["sso-manager-node (isMaster=false)"]
|
||||
ldapB["OpenLDAP (MMR read replica)"]
|
||||
baoB["OpenBao (local replica)"]
|
||||
proxyB["theta-proxy — serves this site's public traffic directly"]
|
||||
gateB["theta-gateway"]
|
||||
agentB["theta-agent"]
|
||||
end
|
||||
|
||||
subgraph SiteC["Spoke — no inbound (CGNAT)"]
|
||||
ssoC["sso-manager-node (isMaster=false)"]
|
||||
ldapC["OpenLDAP (MMR read replica)"]
|
||||
baoC["OpenBao (local replica)"]
|
||||
proxyC["theta-proxy — LAN-local traffic only"]
|
||||
gateC["theta-gateway"]
|
||||
agentC["theta-agent"]
|
||||
end
|
||||
|
||||
gateM <==>|"WireGuard mesh tunnel"| gateB
|
||||
gateM <==>|"WireGuard mesh tunnel"| gateC
|
||||
|
||||
ssoM -.->|"fire-and-forget push: catalog + secrets + signing key"| ssoB
|
||||
ssoM -.->|"fire-and-forget push"| ssoC
|
||||
ldapM <==>|"OpenLDAP MMR syncrepl"| ldapB
|
||||
ldapM <==>|"OpenLDAP MMR syncrepl"| ldapC
|
||||
|
||||
proxyM -->|"TLS-terminate + relay (no direct path exists)"| gateM
|
||||
gateM ==>|"WG tunnel"| gateC
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Every Directory Is Identical
|
||||
|
||||
Master and every spoke run the **same LDAP data, the same OpenBao secrets, and the same agent-signing key**. Hitting any site's `sso-manager-node` for read/auth purposes is equivalent to hitting any other. The only asymmetry is **write authority** (§3).
|
||||
|
||||
This is a deliberate tradeoff, not a default: it means compromising *any single spoke* — including the smallest, least-secured one — grants an attacker the same agent-command authority (`update_binary`, `arbitrary_bash`, service control) as compromising the master, because every site holds the same Ed25519 signing key (`sso-manager-node/nodejs/utils/agent_keys.js`). Accepted here because the deployment scale is small and trusted. Do not extend this pattern to a larger/adversarial-tenant deployment without revisiting it.
|
||||
|
||||
Consequence: `theta-agent` needs **no change** to support multi-site — it already does TOFU pairing against a single trusted key (`websocket.go:341-351`), and since that key is identical everywhere, any site's `sso-manager-node` can validly sign a command for any agent, anywhere, without agents needing a keyring.
|
||||
|
||||
### 2.1 What Replicates, and How
|
||||
|
||||
| Data | Mechanism | Direction |
|
||||
|---|---|---|
|
||||
| LDAP (users, groups) | OpenLDAP MMR syncrepl | master (write) → spokes (read-only) |
|
||||
| OpenBao secrets (incl. agent-signing key at `secret/agent/signing-key`) | **New**: custom replicator (OpenBao has no built-in multi-site replication — Performance Replication is Vault-Enterprise-only, confirmed absent from OpenBao as of this writing) | master (write) → spokes (read-only) |
|
||||
| Directory catalog (Resources: hosts, apps, sites) | Existing catalog change events | master (write) → spokes (read-only) |
|
||||
| Audit log | Async batch worker, already speced (§6) | spokes → master |
|
||||
|
||||
### 2.2 Replication Delivery: Fire-and-Forget
|
||||
|
||||
Master is the sole writer (§3), so there is exactly one producer per data type — no conflict resolution, no consensus, no vector clocks needed. On every write, master pushes the change to all connected spokes **concurrently** (not sequentially — spokes are independent WG peers, none blocks on another) and does **not** wait for acks. A spoke that's offline queues nothing on the master's side; on reconnect, the spoke pulls (or master replays) missed versions.
|
||||
|
||||
This is a deliberate choice over "wait for all spokes to ack": with a dozen spokes, concurrent push completes in low hundreds of milliseconds on the happy path, but *waiting* for acks makes every write's latency bounded by the slowest/offline spoke — reintroducing the split-brain-adjacent stall that §3's explicit-promotion design exists to avoid. Never make a master write block on spoke reachability.
|
||||
|
||||
---
|
||||
|
||||
## 3. Explicit Master Control & Human `god_admin` Authority
|
||||
|
||||
Automatic failover across WAN is explicitly disabled — 0% split-brain risk by design:
|
||||
|
||||
```
|
||||
WAN OUTAGE DETECTED
|
||||
│
|
||||
▼
|
||||
Spoke Node Unconditionally Retains SPOKE Mode
|
||||
│
|
||||
▼
|
||||
Requires Human god_admin Promotion Action
|
||||
```
|
||||
|
||||
1. **Unreachable master**: a spoke that loses the master unconditionally stays a spoke. No auto-election.
|
||||
2. **Promotion is a single coordinated action, not two steps**: `POST /api/directory-admin/site-promote` (god_admin-gated) calls out to the *current* master over the WG tunnel and demotes it as part of the same operation — there's never a window with two masters. (Requires the old master to be reachable; if it isn't, that's an operator-visible failure to resolve manually, not a silent partial-promotion.)
|
||||
3. Because every directory is identical (§2), promotion carries **no agent re-keying cost** — this was the main risk in earlier drafts of this design and is now moot.
|
||||
4. Site state (name, slug, `isMaster`, `masterUrl`, `wanConnected`) lives on the site's own `kind:'site'` Resource (`metadata.multiSite`), not in server memory — it must survive restarts and be visible via the same directory API as everything else.
|
||||
|
||||
---
|
||||
|
||||
## 4. `spoke.env` vs `setup.env`
|
||||
|
||||
A spoke shares almost none of `setup.env`'s concerns (it doesn't mint LDAP admin/JWT/service-account secrets — those arrive via replication, §2) so it gets its own, much shorter file:
|
||||
|
||||
```
|
||||
CFG_DOMAIN=theta42.com # REQUIRED, must match the master's exactly — this is the shared LDAP base DN (dc=theta42,dc=com). Never per-site.
|
||||
CFG_SITE_NAME=staten-island # this site's name/slug
|
||||
CFG_SPOKE_INBOUND=false # true: this site has a public IP and serves its own traffic directly (standalone-style). false: no inbound path exists; master relays (§5).
|
||||
CFG_PUBLIC_DOMAIN= # only used when CFG_SPOKE_INBOUND=true — this site's own domain, own DNS, own ACME cert, independent of the master's domain.
|
||||
CFG_JOIN_TOKEN= # one-time token from the master, used for WG mesh auto-registration (§4.1) and initial catalog/secret pull.
|
||||
CFG_MASTER_ENDPOINT= # master's WG endpoint (host:port) to join through.
|
||||
```
|
||||
|
||||
`CFG_DOMAIN` is the identity namespace (LDAP DN) and must be identical across every site — MMR replicas cannot diverge on base DN. `CFG_PUBLIC_DOMAIN` is a *web-hostname* concern, unrelated to LDAP, and only exists at all for inbound spokes.
|
||||
|
||||
### 4.1 WireGuard Mesh Auto-Registration
|
||||
|
||||
1. A new `theta-gateway` boots with `CFG_JOIN_TOKEN` + `CFG_MASTER_ENDPOINT`, generates its Curve25519 keypair, and calls `POST /api/mesh/gateway/register` on the master over an initial bootstrap tunnel.
|
||||
2. Master assigns the next free **site index** (one octet, used identically in both `172.24.<site>.0/16` and `10.<site>.0.0/16` per the reference topology in Appendix A) and returns full mesh peer config.
|
||||
3. **Site index ceiling is 254** (0 and 255 excluded) — a hard technical limit of this addressing scheme, not an arbitrary cap. Real deployments target a dozen or fewer; no need to cap lower than the real ceiling.
|
||||
4. Each `theta-gateway` applies the new peer set to its running `wg0` via `wgctrl` without dropping existing connections.
|
||||
|
||||
---
|
||||
|
||||
## 5. Inbound vs. No-Inbound Spokes
|
||||
|
||||
Whether a spoke has a public IP determines everything about how its traffic reaches the outside world — these are two distinct, documented operating modes, not a single universal mechanism.
|
||||
|
||||
### 5.1 Inbound Spoke (`CFG_SPOKE_INBOUND=true`)
|
||||
Behaves like a standalone install. Own `CFG_PUBLIC_DOMAIN`, own DNS pointed at its own public IP, own ACME cert. `theta-proxy` and `theta-gateway` serve public web + SSH traffic directly — no relay involved. The only WAN-facing traffic to the master is replication (§2) and audit shipping (§6).
|
||||
|
||||
### 5.2 No-Inbound Spoke (`CFG_SPOKE_INBOUND=false`)
|
||||
No public IP exists, so *any* external access must go through the master:
|
||||
|
||||
1. Master mints a public hostname for the spoke's services (e.g. `sso-{slug}.{master's public domain}`) and creates the corresponding `theta-proxy` route (already dynamic/DB-backed — `proxy/nodejs/models/host.js` — no new plumbing needed there).
|
||||
2. Master **terminates TLS** for that hostname and relays to the spoke over the WG tunnel — both `theta-proxy` (any site-hosted web app) and `theta-gateway` (SSH jump) traffic relay this way, not just SSO.
|
||||
3. Terminating at the master (rather than SNI passthrough) is fine here specifically because master↔spoke already rides an encrypted WG tunnel — there's no unencrypted hop being introduced.
|
||||
|
||||
### 5.3 Local-Direct Resolution (Skip the Relay On-LAN)
|
||||
|
||||
A client physically on a no-inbound spoke's LAN would otherwise hairpin out to the master and back to reach its own local site. Solved via **mDNS local-service-discovery**, not directory-side network topology:
|
||||
|
||||
1. The spoke's `theta-gateway`/`theta-proxy` announces itself on the local segment via mDNS (`_theta-suite._tcp.local`, TXT records: site slug, public hostnames it fronts, local IP).
|
||||
2. `theta-agent`, when a config flag (`preferLocalDiscoveredDirectory` or similar — see the agent-side spec, Appendix B) is enabled, listens for this announcement and overrides local resolution for matching hostnames to the discovered local IP.
|
||||
3. No match (off-site, or flag disabled) → normal public DNS → master relay. Multicast is link-local by nature, so "on-site or not" needs no explicit detection logic — presence/absence of the announcement *is* the signal. This also solves roaming-admin access (§ formerly "5", folded in here) for free: same laptop, same flag, local-fast-path at the office and relay-path everywhere else.
|
||||
4. **Hard rule**: mDNS is unauthenticated on a LAN. It may only ever change *where* the agent connects, never *whether* it trusts what answers — TLS/hostname validation against the redirected IP must stay intact, so a spoofed rogue announcement produces a TLS failure, not a silent MITM.
|
||||
|
||||
This piece needs Windows/Mac-specific implementation and testing that can't be done from this (Linux) environment — see Appendix B for the standalone spec handed off for that work.
|
||||
|
||||
---
|
||||
|
||||
## 6. Non-Canonical Audit Logging
|
||||
|
||||
Unchanged from prior draft: OAuth logins, SSH session events, proxy access, and agent execution events write to local site audit tables without blocking on WAN. An async worker flushes batches to master via `POST /api/directory-admin/audit/ingest` when reachable.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A: Production Reference WireGuard Topology Config
|
||||
|
||||
### Site 10.2 (Staten Island LAN Node) Gateway Reference (`wg0.conf`)
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 172.24.0.2/32
|
||||
PrivateKey = <SITE_10_2_PRIVATE_KEY>
|
||||
ListenPort = 51820
|
||||
Table = off
|
||||
|
||||
# Mesh Subnet Routes
|
||||
PostUp = ip route add 10.0.0.0/8 dev %i
|
||||
PostUp = ip route add 172.24.0.0/13 dev %i
|
||||
|
||||
# Policy Routing Exits
|
||||
PostUp = ip route add default via 10.5.0.1 dev %i table offshore
|
||||
PostUp = ip route add default via 172.24.0.1 dev %i table us_vps
|
||||
PostUp = ip rule add from 10.2.254.0/24 lookup offshore
|
||||
PostUp = ip rule add from 10.2.253.0/24 lookup main preference 1000
|
||||
|
||||
# NETMAP Shadow Network (10.2.168.x -> 192.168.1.x)
|
||||
PostUp = iptables -t nat -A PREROUTING -i %i -d 10.2.168.0/24 -j NETMAP --to 192.168.1.0/24
|
||||
PostUp = iptables -t nat -A POSTROUTING -o %i -s 192.168.1.0/24 -j NETMAP --to 10.2.168.0/24
|
||||
PostUp = ip route add local 10.2.168.0/24 dev lo
|
||||
|
||||
# Forwarding & NAT
|
||||
PostUp = iptables -t nat -A POSTROUTING -s 192.168.1.0/24 -o %i -j MASQUERADE
|
||||
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
PostUp = iptables -A FORWARD -i %i -o eth0 -j ACCEPT
|
||||
PostUp = iptables -A FORWARD -i eth0 -o %i -m state --state RELATED,ESTABLISHED -j ACCEPT
|
||||
|
||||
# System Kernel Options
|
||||
PostUp = sysctl -w net.ipv4.ip_forward=1
|
||||
PostUp = sysctl -w net.ipv4.conf.all.rp_filter=0
|
||||
PostUp = sysctl -w net.ipv4.conf.eth0.rp_filter=0
|
||||
PostUp = sysctl -w net.ipv4.conf.%i.rp_filter=0
|
||||
|
||||
# --- PEERS ---
|
||||
[Peer]
|
||||
# Site 10.1: US Hub / VPS Exit
|
||||
PublicKey = QZCvR3N1CdUabC2xWfc1lmYKHfSiXYs1UoVINIMftws=
|
||||
Endpoint = gg-si1.wgnode.com:51820
|
||||
AllowedIPs = 172.24.0.0/16, 10.0.0.0/8, 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
|
||||
[Peer]
|
||||
# Site 10.5: Netherlands Offshore Exit Node
|
||||
PublicKey = MlF6h3YI1MIvOlgyNozCMoa/rICoLNtc7r/pseKiHQQ=
|
||||
Endpoint = nl-alexhost.wgnode.com:51871
|
||||
AllowedIPs = 172.24.0.5/32, 10.5.0.0/16, 0.0.0.0/0
|
||||
PersistentKeepalive = 25
|
||||
```
|
||||
|
||||
### Site 10.5 (Netherlands Exit Node) Gateway Reference (`wg0.conf`)
|
||||
```ini
|
||||
[Interface]
|
||||
Address = 172.24.0.5/32
|
||||
PrivateKey = <SITE_10_5_PRIVATE_KEY>
|
||||
ListenPort = 51871
|
||||
|
||||
PostUp = ip addr add 10.5.0.1/16 dev %i
|
||||
PostUp = iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
|
||||
# Dynamic Return Path Masquerading (SOURCENAT)
|
||||
PostUp = iptables -t nat -A POSTROUTING -o %i ! -s 172.24.0.0/13 -j MASQUERADE
|
||||
PostUp = sysctl -w net.ipv4.ip_forward=1
|
||||
|
||||
[Peer]
|
||||
# Site 10.2: Staten Island LAN
|
||||
PublicKey = AsS7aikCUrXpdfSvwFnMs0yUaoQ7ZCkoUVOmNdl7NS8=
|
||||
AllowedIPs = 172.24.0.2/32, 10.2.0.0/16
|
||||
|
||||
[Peer]
|
||||
# Site 10.1: US Hub VPS
|
||||
PublicKey = QZCvR3N1CdUabC2xWfc1lmYKHfSiXYs1UoVINIMftws=
|
||||
AllowedIPs = 172.24.0.1/32, 10.1.0.0/8
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix B: Agent-Side Work
|
||||
|
||||
See [`AGENT_LOCAL_DISCOVERY_SPEC.md`](./AGENT_LOCAL_DISCOVERY_SPEC.md) — split out because it needs Windows/Mac implementation and testing that a Linux-only dev environment cannot meaningfully do. That doc is the handoff: it specifies behavior precisely enough to implement and test independently, without needing to re-derive the reasoning in this file.
|
||||
|
||||
---
|
||||
|
||||
## Status of This Spec vs. Code (as of this revision)
|
||||
|
||||
| Piece | Status |
|
||||
|---|---|
|
||||
| Site role persisted (not in-memory) | **Shipped** — `/config/site.json` on `sso-manager-node`, survives restarts (v2.2.0) |
|
||||
| Join key issuance + one-time directory adoption | **Shipped** — `/api/site/join-keys`, `/api/site/export`, `/api/site/join`, fresh-install-gated (v2.2.0–v2.3.0) |
|
||||
| Spoke read-only enforcement | **Shipped** — directory-write routes 403 toward the master once joined (v2.3.0) |
|
||||
| WAN health check | **Shipped** — `/api/site/ping`, live in the Master Site modal (v2.2.0–v2.3.0) |
|
||||
| `setup.env` / `setup.sh` join wiring | **Shipped** — `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY`, `bootstrap/site-join.js` (theta-suite v2.2.0). Also readable from a dedicated `spoke.env` (`spoke.env.example`, layered on top of `setup.env`) for operators who want join-a-cluster config kept separate from the rest of first-run setup. |
|
||||
| Continuous/live replication (vs. one-time export-on-join) | **Shipped** (`sso-manager-node`) — a spoke registers its own endpoint at join time (`POST /api/site/spokes`), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, which re-pulls a fresh export. Verified end-to-end in `docker-compose.multisite-e2e.yml`. |
|
||||
| Identical-directory signing key | **Shipped** — `POST /api/site/export` includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on join and every resync. OpenBao secret replication *beyond* this one key is still not built. |
|
||||
| OpenLDAP N-way multi-master replication auto-config | **Shipped** — the master auto-assigns each spoke a unique `LDAP_SERVER_ID` at registration (`SiteSpoke.ldapServerId`, same pattern as jump-host's mesh index) and derives every site's LDAP URL from its already-known HTTPS endpoint; `theta-suite`'s `bootstrap/site-ldap-register.js` applies it, re-checked on every `setup.sh` run since the peer list grows as spokes join. Verified against real running containers. Known gap: the master's own config only updates when ITS `setup.sh` is re-run, not live the moment a new spoke joins (see `docs/replication.md`). |
|
||||
| Coordinated master promotion (demote the old master as one action) | **Shipped** — `POST /api/site/demote` + `site-promote`'s handoff logic. Fixed two real pre-existing bugs while wiring this in: `site-promote`'s god_admin check read a `req.user.groups` field nothing ever populated (permanently 403'd for everyone), and the read-only write-gate 403'd `site-promote` itself before the handler could run. |
|
||||
| WireGuard gateway-to-gateway mesh (`theta-gateway`) | **Shipped** — `POST /api/mesh/register`/`/join` (join-token bootstrap), `utils/wg_iface.js` (kernel WireGuard, falls back to userspace `wireguard-go`). Verified with a real two-container test: actual encrypted tunnel, real ICMP traffic across it, 0% loss. `wg_iface.removePeer()` also cleans up the kernel routes `setPeer()` added (verified live: routes present after `setPeer`, gone after `removePeer`, own local route untouched), and `DELETE /api/mesh/gateways/:id` exposes it from the mesh UI. |
|
||||
| Cross-component routing (replication over the mesh) | **Shipped** — `utils/site_replicate.js` tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) when pushing resync pings; a spoke with no `meshIp` on file behaves exactly as before. |
|
||||
| No-inbound-spoke relay (master proxies a spoke with no public IP) | **Shipped at the API/automation layer, wired into the real bootstrap flow.** `POST /api/site/join`/`/api/site/spokes` accept `noInbound`/`meshIp`/`publicHost` and call `utils/proxy_client.js`, which mints/reuses a `theta-proxy` self-service API token (`prx_...`, OpenBao `secret/integrations/theta-proxy`) and calls the proxy's real Host API to create or update the relay route — verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed). `theta-suite`'s `bootstrap/site-relay-register.js` + `CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST` (`setup.env.example`) drive it from the operator-facing bring-up flow, re-run automatically on every `setup.sh` invocation until the jump-host mesh IP is discoverable. What's still a manual step, deliberately: the gateway-to-gateway mesh *peering* itself (mint a join token on one jump-host, paste it into the other's UI) — same pattern as minting/pasting a site join key, not something an unattended script should do blind. A spoke with zero inbound *and* zero outbound path still can't join at all (join/export still need the spoke to reach the master's API directly). |
|
||||
| mDNS local-discovery (Linux) | **Shipped** — `theta-gateway` announces (`services/mdns_announce.js`, opt-in via `THETA_LOCAL_DISCOVERY_HOSTS`), `theta-agent` discovers and applies a hosts-file override (`local_discovery.go`, opt-in via `prefer_local_directory`). Verified end-to-end with real containers over real multicast: announce → discover → apply → clean revert on disappearance, all confirmed. Caught two real bugs along the way (`mdns.Lookup()`'s IPv6 query aborting the whole lookup even after a valid IPv4 response arrived; `rename()` failing with EBUSY over a bind-mounted `/etc/hosts`, common in every container runtime) — see the commit messages in `theta-agent`. |
|
||||
| mDNS local-discovery (Windows) | **Shipped** — `theta-agent` v2.2.0: Windows hosts override (`%SystemRoot%\System32\drivers\etc\hosts`, CRLF-aware, `ipconfig /flushdns` after each change — reachable because the agent runs as a SYSTEM service, so the elevation question resolved in our favor), plus a /32 host-route pin via the owning local interface (`route.exe add ... metric 1`) so the WireGuard mesh tunnel can't swallow the direct LAN path, and a prompt WS reconnect on apply/revert. Tests run the real Windows write path on the Windows CI leg. |
|
||||
| mDNS local-discovery (macOS) | Not built — the hosts override compiles on darwin via the shared unix path, but macOS still needs `dscacheutil -flushcache` and real hardware testing (mDNSResponder behavior, hosts-file vs. native Bonjour — see Appendix B §3). Being built on a real macOS VM. |
|
||||
|
||||
### TODO — what's actually left
|
||||
|
||||
1. **Full secret replication** — only the agent-signing key is replicated today. LDAP admin credentials, JWT secrets, and other per-deployment secrets still differ per site, which complicates full disaster recovery. **Paused pending a real-deployment question independent of the code**: this repo's own `conf/secrets.js` was found to contain committed real credentials during this work (LDAP bind, SMTP, VoIP.ms) — see the git-remediation note elsewhere in this repo's history. Building a feature that copies live secrets to additional sites shouldn't proceed until provider-side rotation of those specific credentials is confirmed done; the mechanism itself (generic secret sync, never touching those particular values) can still be designed without that answer.
|
||||
2. Service-to-service auth, cross-component routing, no-inbound relay automation, and mesh peer cleanup (the four items formerly listed here) are **done** — see the status table above. What remains genuinely open in that area is documented there inline (mesh peering stays a manual step by design; zero-inbound-and-zero-outbound spokes still can't join).
|
||||
|
||||
**mDNS local-discovery, macOS** is deliberately not listed above: the Linux and Windows sides are shipped and verified (`theta-agent` v2.2.0), and macOS is being built on a real macOS VM where the darwin-specific behavior (mDNSResponder/DNS-cache) can actually be tested. Check `theta-agent`'s recent history before assuming it's still open.
|
||||
|
||||
*Committed under [`docs/MULTI_SITE_SPEC.md`](file:///home/william/dev/theta42/theta-env/docs/MULTI_SITE_SPEC.md).*
|
||||
@@ -1,4 +1,4 @@
|
||||
title: theta-suite
|
||||
title: Theta Suite
|
||||
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses.
|
||||
url: "https://theta42.github.io"
|
||||
baseurl: "/theta-suite"
|
||||
@@ -28,13 +28,13 @@ nav:
|
||||
- title: Secrets
|
||||
page: /secrets.html
|
||||
icon: fa-key
|
||||
- title: SSO Manager
|
||||
- title: Theta Directory
|
||||
page: /sso/
|
||||
icon: fa-users
|
||||
- title: Proxy
|
||||
- title: Theta Proxy
|
||||
page: /proxy/
|
||||
icon: fa-shield-halved
|
||||
- title: Jump Host
|
||||
- title: Theta Gateway
|
||||
page: /jump-host/
|
||||
icon: fa-terminal
|
||||
- title: Changelog
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css">
|
||||
<link rel="stylesheet" href="{{ '/assets/css/style.css' | relative_url }}">
|
||||
|
||||
<script defer src="https://tracking.718it.biz/script.js" data-website-id="a5df0dec-6c54-4c1a-a167-02867a56e2cc"></script>
|
||||
</head>
|
||||
<body class="d-flex flex-column min-vh-100">
|
||||
|
||||
|
||||
@@ -1,38 +1,32 @@
|
||||
---
|
||||
layout: default
|
||||
title: Architecture
|
||||
description: How theta-suite composes the SSO Manager, proxy, jump host, and ldap-client around a shared OpenBao secrets store — the OIDC/LDAP/secrets wiring setup.sh generates from one domain.
|
||||
description: How Theta Suite 2.0 composes Theta Directory, Theta Gateway, Theta Proxy, and Theta Agent around a shared OpenBao secrets store — the zero-trust identity, mesh gateway, and telemetry architecture.
|
||||
---
|
||||
|
||||
# Architecture
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
theta-suite is a **composition** repo: it builds four applications from their
|
||||
git submodules and adds the glue that wires them together — plus a shared
|
||||
[OpenBao](https://openbao.org/) secrets store — on one Docker network. It
|
||||
does not fork or patch the components; it composes and configures them.
|
||||
Theta Suite 2.0 is a production-grade **composition repository**: it composes applications from git submodules and provides the automated first-run orchestration, secrets initialization, and container networking for a complete zero-trust infrastructure stack.
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
## Core Infrastructure Components
|
||||
|
||||
| Repo / image | Role |
|
||||
| Subproject / Image | Component Role |
|
||||
|------|------|
|
||||
| [`theta42/sso-manager-node`](https://github.com/theta42/sso-manager-node) | OIDC provider + OpenLDAP directory + web UI. All-in-one image (`Dockerfile.openldap`). |
|
||||
| [`theta42/proxy`](https://github.com/theta42/proxy) | OIDC-protected reverse proxy (OpenResty + Node mgmt app + Redis). All-in-one image (`Dockerfile`). |
|
||||
| [`theta42/jump-host`](https://github.com/theta42/jump-host) | Directory-driven SSH jump host (sshd + Node web UI). Image (`Dockerfile`). |
|
||||
| [`theta42/ldap-client`](https://github.com/theta42/ldap-client) | Enrolls real Linux hosts into the directory (SSSD + AuthorizedKeysCommand). Also the opt-in `ldap-test-host` fixture. |
|
||||
| `quay.io/openbao/openbao` | Central secrets store (Vault fork), KV-v2 at `secret/`. |
|
||||
| `theta42/theta-suite` (this repo) | Composes all of the above on one network + automates first-run wiring. |
|
||||
|
||||
The four applications are pinned as **git submodules**; OpenBao uses the
|
||||
upstream image. `git clone --recursive` fetches the submodules in one step;
|
||||
`git submodule update --remote` bumps them.
|
||||
| [`theta42/theta-directory`](https://github.com/theta42/theta-directory) | **Theta Directory** — OIDC provider + OpenLDAP directory + Resource Catalog + Web Admin Console. All-in-one container. |
|
||||
| [`theta42/jump-host`](https://github.com/theta42/jump-host) | **Theta Gateway** — Directory-driven SSH access gateway and WireGuard mesh router with NETMAP shadow subnets. |
|
||||
| [`theta42/theta-agent`](https://github.com/theta42/theta-agent) | **Theta Agent** — Multi-platform host telemetry, hardware details, desktop session controls, and secret delivery agent. |
|
||||
| [`theta42/proxy`](https://github.com/theta42/proxy) | **Theta Proxy** — OIDC-protected reverse proxy (OpenResty + Node management app + Redis). |
|
||||
| [`theta42/ldap-client`](https://github.com/theta42/ldap-client) | **ldap-client** — Enrolls real Linux hosts into the directory for PAM/SSSD login, sudo rules, and SSH keys. |
|
||||
| `quay.io/openbao/openbao` | **OpenBao** — Central secrets engine (Vault fork), KV-v2 versioned store at `secret/`. |
|
||||
| `theta42/theta-suite` (this repo) | Composes all components on a single Docker network + automates `./setup.sh` first-run wiring. |
|
||||
|
||||
---
|
||||
|
||||
## The stack
|
||||
## Architecture Stack
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
@@ -42,17 +36,17 @@ upstream image. `git clone --recursive` fetches the submodules in one step;
|
||||
https (:443) ssh (:2222) ldaps (:636)
|
||||
│ │ │
|
||||
┌────────▼────────┐ ┌──────────▼────────┐ │
|
||||
│ proxy │ │ jump-host │ │
|
||||
│ OpenResty │ │ sshd :2222 │ │
|
||||
│ :80/:443/:4443 │ │ web UI :3002 │ │
|
||||
│ theta-proxy │ │ theta-gateway │ │
|
||||
│ OpenResty │ │ SSH Gateway │ │
|
||||
│ :80/:443/:4443 │ │ WireGuard Mesh │ │
|
||||
│ mgmt app :3000 │ └────────┬──────────┘ │
|
||||
└────────┬─────────┘ │ OIDC + LDAP │
|
||||
│ http:3001 (internal)│ via sso-manager │
|
||||
▼ ▼ ▼
|
||||
│ http:3001 (internal)│ via theta-directory
|
||||
▼ ▼ ▼
|
||||
┌───────────────────────────────────────────────────────┐
|
||||
│ sso-manager (Express + OpenLDAP + Redis) │
|
||||
│ OIDC provider + LDAP directory │
|
||||
│ web UI :3001 (internal) ldaps :636 (published) │
|
||||
│ theta-directory (Express + OpenLDAP + Redis) │
|
||||
│ OIDC provider + LDAP directory + Resource Catalog │
|
||||
│ web UI :3001 (internal) ldaps :636 (published) │
|
||||
└───────────────────────────────────────────────────────┘
|
||||
▲ loads secrets at boot (scoped token each)
|
||||
┌───────────┴───────────────────┐
|
||||
|
||||
@@ -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.
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
@@ -4,24 +4,29 @@ title: Home
|
||||
description: A unified, one-command SSO Manager + OIDC proxy stack for home labs and small businesses. Wires together a self-hosted identity provider and a reverse proxy with one setup.sh.
|
||||
---
|
||||
|
||||
# theta-suite
|
||||
# Theta suite
|
||||
|
||||
The whole theta42 identity, access, and secrets stack in one repo, brought up
|
||||
with a single command — for home labs and small businesses.
|
||||
Theta Suite is your one-line solution to replacing fragmented, hard-to-wire
|
||||
authentication setups with a unified security stack. It wires together OIDC
|
||||
authentication, LDAP user directories, automated host enrollment, and
|
||||
centralized secret management in a single command. It eliminates the manual
|
||||
configuration friction so you get secure access, auditability, and
|
||||
[multi-site](sso/multi-site.html) replication when you need more than one
|
||||
location.
|
||||
|
||||
It composes four applications around a shared secrets store:
|
||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
|
||||
LDAP directory), [Proxy](https://theta42.github.io/proxy/) (an OIDC-protected
|
||||
reverse proxy that can also look users up directly in LDAP),
|
||||
[Jump Host](https://theta42.github.io/jump-host/) (directory-driven SSH access
|
||||
through one public entry point), and
|
||||
[ldap-client](https://theta42.github.io/ldap-client/) (enrolls your Linux
|
||||
hosts into the directory for PAM/SSSD, sudo, and SSH keys). All of them read
|
||||
their secrets at boot from [OpenBao](https://openbao.org/), the central secrets
|
||||
store. `setup.sh` automates the fiddly part: registering the proxy as an OIDC
|
||||
client of the SSO, pointing every component at the right LDAP directory and
|
||||
the OpenBao token it needs, and generating hostnames and secrets from one
|
||||
`setup.env`.
|
||||
## Who This Is For
|
||||
* **Self-Hosters & Homelab Engineers:** Anyone running local bare metal,
|
||||
Proxmox, or private VPS nodes who wants enterprise-grade OIDC, multi-master
|
||||
LDAP, PAM/SSSD host enrollment, and OpenBao secret management without spending
|
||||
days manually wiring glue code.
|
||||
* **Small-to-Medium Businesses (SMBs):** Infrastructure teams that need a
|
||||
unified, directory-driven access plane across both web apps and Linux boxes,
|
||||
but want to bypass per-user SaaS taxes (Okta, Azure AD) and cloud vendor
|
||||
lock-in.
|
||||
* **DevOps & Systems Operators:** Engineers who value idempotent, single-command
|
||||
deployments (`./setup.sh`) and need a production-grade baseline supporting
|
||||
zero-trust proxying, SSH jump-host access control, and
|
||||
[multi-site](sso/multi-site.html) replication out of the box.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -33,41 +38,51 @@ The SSO Manager and the proxy it fronts, both stood up by one `./setup.sh` run:
|
||||
|
||||
*(click either screenshot to view full size)*
|
||||
|
||||
## Why this over running them separately
|
||||
|
||||
The components are designed to integrate — they're only useful together once
|
||||
the proxy is registered as an OIDC client of the SSO *and* pointed at the
|
||||
SSO's LDAP directory — and the domain has to match across half a dozen config
|
||||
fields, or logins silently fail. Doing that by hand is fiddly. `setup.sh`
|
||||
asks for your domain once, generates both apps' config with it filled in
|
||||
everywhere, registers the proxy as an OIDC client automatically, and
|
||||
snapshots state before every rebuild.
|
||||
|
||||
## What you get
|
||||
|
||||
- **SSO Manager**, fronted by the proxy under TLS — manage users, groups,
|
||||
and OAuth clients.
|
||||
- **Proxy** — add the hosts you want to protect with OIDC login.
|
||||
- **LDAPS** for direct binds — Linux hosts (PAM/SSSD, sudo, SSH keys) and
|
||||
LDAP-native apps authenticate against the same directory.
|
||||
- **Hierarchical groups & permissions** — every adopted host and app gets its own
|
||||
`admin`/`access`/`capability` groups, generated from the Directory; they double
|
||||
as real POSIX groups for sudo/SSH. See
|
||||
[Group & Permission Model](GROUPS.html).
|
||||
- **ldap-client** — enroll Linux hosts into the directory (PAM/SSSD login,
|
||||
sudo, SSH keys); the host inventory shows up in the SSO UI and drives
|
||||
jump-host routing.
|
||||
- **SSH Jump Host** — `ssh uid_-_host@jump.<domain>` (WinSCP-friendly)
|
||||
or an interactive picker; access is driven by directory group membership, with
|
||||
a web UI for audit + metrics.
|
||||
- **Central secrets (OpenBao)** — every component loads its secrets from one
|
||||
[OpenBao](https://openbao.org/) instance at boot; each user gets personal
|
||||
secret storage, and admins mint scoped tokens for external apps. See
|
||||
- **Unified SSO Manager**: An OpenID Connect (OIDC) provider and OAuth 2.0
|
||||
authorization server fronted by TLS. Includes a web dashboard for managing
|
||||
users, groups, and OAuth apps, plus automated invitation and password reset
|
||||
flows.
|
||||
- **Identity-Aware Reverse Proxy**: Intercepts HTTP/HTTPS traffic to protect
|
||||
upstream applications with OIDC login and direct LDAP group authorization,
|
||||
featuring automatic TLS certificate issuance and automated host routing.
|
||||
- **Embedded LDAPS Directory**: A bundled OpenLDAP core acting as your single
|
||||
source of truth for POSIX accounts, SSH public keys, and sudo roles. Native
|
||||
apps, legacy infrastructure, and Linux machines authenticate directly over
|
||||
encrypted LDAPS (port 636) or StartTLS.
|
||||
- **Hierarchical Directory Group & Permission Model**: Every adopted application
|
||||
and machine automatically inherits dedicated `admin`, `access`, and
|
||||
`capability` groups generated directly from the LDAP directory. These map
|
||||
cleanly to real POSIX groups for fine-grained sudo and SSH privilege controls.
|
||||
See [Group & Permission Model](GROUPS.html).
|
||||
- **Automated Linux Host Enrollment (ldap-client)**: A lightweight host agent
|
||||
that enrolls Linux machines into the central directory. It configures system
|
||||
PAM/SSSD for login, applies sudo policies, distributes SSH public keys, and
|
||||
registers host telemetry in the primary inventory dashboard.
|
||||
- **Directory-Driven SSH Jump Host**: A centralized bastion host that routes
|
||||
inbound terminal traffic (`ssh uid_-_host@jump.<domain>`) using active
|
||||
directory group memberships. Supports WinSCP, file transfers, interactive
|
||||
host pickers, and a dedicated audit interface for tracking user sessions and
|
||||
connection metrics.
|
||||
- **Central Secrets Engine (OpenBao integration)**: Bootstraps every component
|
||||
against an embedded [OpenBao](https://openbao.org/) instance to load tokens
|
||||
and cryptographic keys at runtime. Provides per-user secret vaults and enables
|
||||
administrators to mint scoped API tokens for external services. See
|
||||
[Secrets](secrets.html).
|
||||
- **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.
|
||||
- **Multi-target load balancing** — built-in proxy support for round-robin load balancing across multiple application servers.
|
||||
- **Self-Service & CI/CD API Tokens**: Granular, personal access token
|
||||
management built directly into the web interface, allowing operators to drive
|
||||
system administration and automation pipelines programmatically without an
|
||||
active browser session.
|
||||
- **Multi-Site Geo-Replication**: A master site and any number of spokes join
|
||||
with one key and one URL, staying in sync automatically (no manual LDAP
|
||||
config) — see [Multi-Site (Master/Spoke Join)](sso/multi-site.html). Raw
|
||||
N-Way Multi-Master LDAP replication is also available directly for
|
||||
deployments that want every site independently writable with no
|
||||
master/spoke concept — see [Geo-Location Scaling](sso/replication.html).
|
||||
- **Multi-Target Load Balancing**: Native reverse-proxy load balancing that
|
||||
distributes traffic across multiple application backends using customizable
|
||||
health checks and round-robin strategies.
|
||||
|
||||
## Get it
|
||||
|
||||
@@ -78,10 +93,11 @@ cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
You need **Docker** + **Docker Compose**. `./setup.sh` is idempotent — re-run
|
||||
any time to converge the stack to `./config/`. For the full config reference,
|
||||
architecture, see the
|
||||
**[GitHub repository](https://github.com/theta42/theta-suite)**.
|
||||
You need a **Domain**, some **Ports Forwarded**, **Docker** +
|
||||
**Docker Compose**. `./setup.sh` is idempotent — re-run any time to converge the
|
||||
stack to `./config/`. For the full config reference, architecture, see the
|
||||
**[GitHub repository](https://github.com/theta42/theta-suite#before-you-begin)**
|
||||
for a details.
|
||||
|
||||
## Related projects
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
# Documentation
|
||||
|
||||
This directory is the GitHub Pages documentation site for the Jump Host project.
|
||||
|
||||
**Live site:** https://theta42.github.io/jump-host/
|
||||
|
||||
## Pages
|
||||
|
||||
- `index.md` — overview and quick start
|
||||
- `connecting.md` — usage: the username grammar, the TUI picker, SFTP/WinSCP
|
||||
- `architecture.md` — how auth, access resolution, key injection, and bridging work
|
||||
- `installation.md` — Docker, bare-metal, and theta-env install; the LDAP write-ACL
|
||||
|
||||
## Local preview
|
||||
|
||||
```bash
|
||||
gem install jekyll bundler
|
||||
cd docs && jekyll serve
|
||||
# http://localhost:4000/jump-host/
|
||||
```
|
||||
|
||||
## Updating
|
||||
|
||||
Edit the markdown, push to `master`, and GitHub Pages rebuilds automatically.
|
||||
@@ -56,8 +56,8 @@ name → IP → address hostname. A raw IP that isn't an accessible directory ho
|
||||
is refused unless explicitly allowed.
|
||||
|
||||
> The directory auto-creates `<slug>_access` / `<slug>_admin` groups for every
|
||||
> host and service (see the SSO's
|
||||
> [Directory & Inventory](https://theta42.github.io/sso-manager-node/directory.html)
|
||||
> host and service (see Theta Directory's
|
||||
> [Directory & Inventory](../sso/directory.html)
|
||||
> docs), which is exactly what this authorization reads.
|
||||
|
||||
## 3. Upstream Authentication (PKI or LDAP Keys) {#upstream-authentication}
|
||||
@@ -96,7 +96,7 @@ Byte counts per direction are tallied cheaply for the audit record.
|
||||
## Web UI, API & audit
|
||||
|
||||
An Express + EJS + Bootstrap app on `:3002` — the same front-end stack and
|
||||
look/feel as the SSO Manager and Proxy. Login is OIDC against the SSO plus a
|
||||
look/feel as Theta Directory and Theta Proxy. Login is OIDC against the SSO plus a
|
||||
local anti-lockout admin (`auth.adminUsers`), with admin access gated by
|
||||
`auth.adminGroups`. It exposes:
|
||||
|
||||
@@ -111,12 +111,12 @@ success + failure reason, downstream host-key fingerprint, timing, and bytes in/
|
||||
|
||||
## Where it sits in the stack
|
||||
|
||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — provides the
|
||||
- **[Theta Directory](../sso/)** — provides the
|
||||
OpenLDAP directory (users, groups, `sshPublicKey`) and the inventory API this
|
||||
jump host reads.
|
||||
- **[ldap-client](https://github.com/theta42/ldap-client)** — enrolls the
|
||||
downstream Linux hosts (SSSD/PAM + `AuthorizedKeysCommand`) that the jump host
|
||||
connects into.
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — fronts the jump host's web UI
|
||||
- **[Theta Proxy](../proxy/)** — fronts the jump host's web UI
|
||||
under TLS.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — wires it all together.
|
||||
- **theta-suite** — wires it all together.
|
||||
|
||||
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 123 KiB |
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 177 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 72 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,25 +1,24 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: An SSH jump host for the theta42 stack — one public host and directory-driven access to every downstream machine you're entitled to.
|
||||
description: Theta Gateway — an SSH jump host for theta-suite, giving directory-driven access to every downstream machine you're entitled to from one public host.
|
||||
---
|
||||
|
||||
# Jump Host
|
||||
# Theta Gateway
|
||||
|
||||
An SSH jump host for the [theta42](https://github.com/theta42) self-hosted
|
||||
stack. Users SSH into **one** public host and land on any downstream host
|
||||
they're entitled to — authenticated against the shared LDAP directory,
|
||||
authorized from the [SSO Manager](https://theta42.github.io/sso-manager-node/)'s
|
||||
inventory graph, and audited end to end.
|
||||
The SSH jump host component of [theta-suite](../). Users SSH into **one**
|
||||
public host and land on any downstream host they're entitled to —
|
||||
authenticated against the shared LDAP directory, authorized from
|
||||
[Theta Directory](../sso/)'s inventory graph, and audited end to end.
|
||||
|
||||
No per-host accounts, no distributing keys, no VPN. The same people who log in
|
||||
to your SSO are the people who can reach your machines — and only the machines
|
||||
their directory groups grant.
|
||||
to Theta Directory are the people who can reach your machines — and only the
|
||||
machines their directory groups grant.
|
||||
|
||||
Part of the theta42 self-hosted identity stack, alongside
|
||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
||||
[Proxy](https://theta42.github.io/proxy/), composable with one command via
|
||||
[theta-env](https://theta42.github.io/theta-env/).
|
||||
Theta Gateway is deployed as part of theta-suite, alongside
|
||||
[Theta Directory](../sso/) and [Theta Proxy](../proxy/) — it isn't installed
|
||||
or run on its own. See the [Quickstart](../quickstart.html) to stand up the
|
||||
whole stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,8 +29,6 @@ Part of the theta42 self-hosted identity stack, alongside
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
|
||||
|
||||
## Two ways to connect
|
||||
|
||||
**Direct (WinSCP/SFTP-friendly):**
|
||||
@@ -63,17 +60,18 @@ through a single audited entry point. What's usually painful is *authorization*
|
||||
and *credentials*: who may reach which host, and how the bastion authenticates
|
||||
onward without you copying keys everywhere.
|
||||
|
||||
This jump host answers both from your directory:
|
||||
Theta Gateway answers both from your directory:
|
||||
|
||||
- **Authorization is your directory graph.** The hosts you can reach are the
|
||||
union of your LDAP groups × the SSO's inventory (the `host_<name>_access`
|
||||
groups the directory already auto-creates). Add someone to a group; they can
|
||||
reach the host. No bastion-side allow-list to maintain.
|
||||
- **Onward auth is automatic.** The jump host holds one key and injects its
|
||||
union of your LDAP groups × Theta Directory's inventory (the
|
||||
`host_<name>_access` groups the directory already auto-creates). Add
|
||||
someone to a group; they can reach the host. No bastion-side allow-list to
|
||||
maintain.
|
||||
- **Onward auth is automatic.** Theta Gateway holds one key and injects its
|
||||
public half into your `sshPublicKey` on first use, then connects downstream
|
||||
**as you**. Downstream hosts already serve keys from LDAP (via
|
||||
[ldap-client](https://github.com/theta42/ldap-client)'s
|
||||
`AuthorizedKeysCommand`), so nothing downstream needs configuring.
|
||||
ldap-client's `AuthorizedKeysCommand`), so nothing downstream needs
|
||||
configuring.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -82,35 +80,12 @@ This jump host answers both from your directory:
|
||||
- **Interactive TUI host picker** on plain login, scoped to your access
|
||||
- **LDAP inbound auth** — public key or password (keys-only policy recommended
|
||||
for a public host)
|
||||
- **Directory-driven access** — reachable hosts come from the SSO inventory, not
|
||||
a static list
|
||||
- **Directory-driven access** — reachable hosts come from the Theta Directory
|
||||
inventory, not a static list
|
||||
- **Per-user key injection** — no downstream changes, no key distribution
|
||||
- **Shell, exec, and SFTP** bridging
|
||||
- **[WireGuard mesh routing](mesh.html)** — cross-site network access alongside SSH
|
||||
- **Web UI + HTTP API** for auditing and metrics — active sessions, a searchable
|
||||
audit log, per-user/per-host counters
|
||||
- **Full audit trail** — who, target, method, result, bytes, duration, and the
|
||||
downstream host-key fingerprint
|
||||
|
||||
- Packaged like the rest of the stack: one-command Docker, idempotent bare-metal
|
||||
installer, or bundled in theta-env
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/theta42/jump-host.git
|
||||
cd jump-host
|
||||
cp secrets.js.example config/jump-secrets.js # then edit it
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
For bare-metal and the bundled theta-env
|
||||
option, see **[Installation](installation.html)**.
|
||||
|
||||
## Related projects
|
||||
|
||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OpenLDAP
|
||||
directory + OIDC provider + the inventory graph this jump host reads.
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — puts your web apps behind the
|
||||
same identity; fronts this jump host's web UI.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs the whole stack,
|
||||
jump host included, with one command.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
layout: default
|
||||
title: Gateway Mesh
|
||||
---
|
||||
|
||||
# Gateway Mesh
|
||||
|
||||
Theta Gateway can mesh with other Theta Gateway instances over real
|
||||
site-to-site WireGuard tunnels — separate from its [SSH jump
|
||||
host](connecting.html) role, and separate from the roaming-client/exit-node
|
||||
WireGuard feature (individual peer configs for laptops/phones). This is
|
||||
gateway-to-gateway: two sites' networks reaching each other directly.
|
||||
|
||||
## Why and when to use this
|
||||
|
||||
- **Direct site-to-site networking**, not just SSH. Once two gateways are
|
||||
meshed, hosts behind each can reach each other over the tunnel using the
|
||||
mesh addressing scheme below — not limited to jumping through SSH.
|
||||
- **No manual WireGuard config.** Meshing is a join-token exchange; both
|
||||
sides come out with a live, working peer entry for each other
|
||||
automatically.
|
||||
- **Works without a kernel WireGuard module.** Prefers in-kernel WireGuard,
|
||||
falls back to the userspace `wireguard-go` implementation automatically —
|
||||
useful for older kernels, some container/cloud images, or hosts where the
|
||||
kernel module isn't available.
|
||||
|
||||
## How it works
|
||||
|
||||
1. On the gateway you want others to join, mint a join token: **Mesh** page
|
||||
→ **Mint a Join Token**. It's single-use and expires in 15 minutes.
|
||||
2. On the new gateway, use **Join a Remote Gateway's Mesh**: paste the other
|
||||
gateway's URL and the token.
|
||||
3. Both sides now have a live WireGuard peer for each other. The **Meshed
|
||||
Gateways** table shows every peer, its assigned mesh subnet, and when it
|
||||
was last seen.
|
||||
|
||||
Each gateway is assigned a **mesh index** (an integer 1–254) the first time
|
||||
it either mints a token or is registered by another gateway. That index
|
||||
determines its subnet: `172.24.<index>.0/24` for the mesh tunnel itself, plus
|
||||
`10.<index>.0.0/16` reserved for that site's own local network — 254 sites is
|
||||
the hard ceiling this addressing scheme supports.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Both gateways need a reachable endpoint (host:port) for the WireGuard
|
||||
handshake — typically the same public host the SSH/web ports are already
|
||||
on, with UDP 51820 reachable.
|
||||
- `NET_ADMIN` capability (or equivalent) on the container/host running the
|
||||
gateway, to create the WireGuard interface.
|
||||
|
||||
## Connected to directory sync
|
||||
|
||||
[Theta Directory's multi-site join](../sso/multi-site.html) (catalog + LDAP
|
||||
replication between a master and its spokes) prefers this mesh once it's up:
|
||||
a spoke that's registered a mesh IP gets its live resync pushes routed over
|
||||
the tunnel instead of the open internet, falling back to its public endpoint
|
||||
if the mesh path fails. A spoke with no public IP at all can also register as
|
||||
no-inbound (`CFG_SPOKE_NO_INBOUND` in `theta-suite`'s `setup.env`) so the
|
||||
master auto-creates a relay route through its own `theta-proxy` — the master
|
||||
terminates TLS for that spoke's hostname and relays over this mesh. The mesh
|
||||
peering itself (this page) stays a manual step on both sides; directory join
|
||||
and relay registration pick up from there. See the [architecture
|
||||
spec](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
||||
for the full detail.
|
||||
@@ -1,39 +0,0 @@
|
||||
# Documentation
|
||||
|
||||
This directory contains the GitHub Pages documentation site for the Proxy project.
|
||||
|
||||
**Live site:** https://theta42.github.io/proxy/
|
||||
|
||||
## Pages
|
||||
|
||||
- `index.md` - Home page with project overview
|
||||
- `installation.md` - Installation and setup guide
|
||||
- `api.md` - Complete API reference
|
||||
- `architecture.md` - System architecture and design
|
||||
- `contributing.md` - Development and contribution guide
|
||||
|
||||
## Local Preview
|
||||
|
||||
To preview the site locally:
|
||||
|
||||
```bash
|
||||
# Install Jekyll (one-time setup)
|
||||
gem install jekyll bundler
|
||||
|
||||
# Run local server
|
||||
cd docs
|
||||
jekyll serve
|
||||
|
||||
# View at http://localhost:4000/proxy/
|
||||
```
|
||||
|
||||
## Theme
|
||||
|
||||
The site uses the Cayman theme (`jekyll-theme-cayman`). Configuration is in `_config.yml`.
|
||||
|
||||
## Updating Documentation
|
||||
|
||||
1. Edit markdown files in this directory
|
||||
2. Commit and push to master branch
|
||||
3. GitHub Pages automatically rebuilds (may take 1-2 minutes)
|
||||
4. Changes visible at https://theta42.github.io/proxy/
|
||||
@@ -22,7 +22,7 @@ There are two ways someone gets into the proxy's own management UI:
|
||||
|
||||
- **A local account**, created on the **Users** page — a username and
|
||||
password specific to this app.
|
||||
- **Single sign-on**, if you've connected this proxy to an SSO Manager (or
|
||||
- **Single sign-on**, if you've connected this proxy to Theta Directory (or
|
||||
another OIDC provider) — the same login your other connected apps use.
|
||||
|
||||
Either way, once logged in, what they're actually *allowed to do* here is
|
||||
|
||||
|
Before Width: | Height: | Size: 328 KiB After Width: | Height: | Size: 345 KiB |
|
Before Width: | Height: | Size: 326 KiB After Width: | Height: | Size: 376 KiB |
|
Before Width: | Height: | Size: 310 KiB After Width: | Height: | Size: 506 KiB |
|
Before Width: | Height: | Size: 428 KiB After Width: | Height: | Size: 440 KiB |
@@ -1,24 +1,24 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: A reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
|
||||
description: Theta Proxy — a reverse proxy and HTTPS termination service built on OpenResty/nginx, with automatic Let's Encrypt certs, OIDC login, and direct LDAP access control per host.
|
||||
---
|
||||
|
||||
# Proxy
|
||||
# Theta Proxy
|
||||
|
||||
A reverse proxy and HTTPS termination service built on OpenResty/nginx, with a
|
||||
management API and web GUI. It puts any of your apps behind single sign-on
|
||||
(OIDC) and can also look users up directly in LDAP — so the same people who
|
||||
log in to your SSO are the people allowed to reach your proxied apps.
|
||||
The reverse proxy and HTTPS termination component of [theta-suite](../), built
|
||||
on OpenResty/nginx. It puts any of your apps behind single sign-on (OIDC) and
|
||||
can also look users up directly in LDAP — so the same people who log in to
|
||||
[Theta Directory](../sso/) are the people allowed to reach your proxied apps.
|
||||
|
||||
Automatic HTTPS from Let's Encrypt (including wildcards), routing by hostname,
|
||||
and per-host access control tied to your identity provider — managed from a
|
||||
web UI or a REST API, with no downtime on config changes.
|
||||
|
||||
Part of the theta42 self-hosted identity stack, alongside
|
||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) and
|
||||
[theta-env](https://theta42.github.io/theta-env/) (the two composed with one
|
||||
command).
|
||||
Theta Proxy is deployed as part of theta-suite, alongside
|
||||
[Theta Directory](../sso/) and [Theta Gateway](../jump-host/) — it isn't
|
||||
installed or run on its own. See the [Quickstart](../quickstart.html) to stand
|
||||
up the whole stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -30,49 +30,22 @@ management once basic auth is enabled:
|
||||
|
||||
<a href="images/host-auth-basic.png" target="_blank"><img src="images/host-auth-basic.png" alt="Per-host basic auth" width="60%"></a>
|
||||
|
||||
Multiple backend targets per host, load balanced round-robin:
|
||||
|
||||
<a href="images/load-balancing.png" target="_blank"><img src="images/load-balancing.png" alt="Load balancing" width="60%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
## Why this over the alternatives
|
||||
|
||||
Nginx Proxy Manager, Traefik, and Caddy are all good reverse proxies with
|
||||
auto-HTTPS. This one is built around identity: it is both an **OIDC client**
|
||||
of an SSO provider (for browser login) **and** a direct **LDAP client** (for
|
||||
user lookups and per-host access control), so access decisions come from your
|
||||
real user directory, not a static allow-list or a separate auth proxy bolted
|
||||
on top. The trade-off is that it expects an OIDC/LDAP identity source to point
|
||||
at — it is not an auth server on its own. Pair it with
|
||||
[SSO Manager](https://theta42.github.io/sso-manager-node/) (bundled OpenLDAP +
|
||||
OIDC) for a self-hosted SSO + proxy stack, or point it at any OIDC provider +
|
||||
LDAP directory you already run.
|
||||
|
||||
## Features
|
||||
|
||||
- Automated HTTPS via Let's Encrypt — HTTP-01 and DNS-01 (wildcard) challenges
|
||||
- Multiple DNS providers (Cloudflare, DigitalOcean, PorkBun, DuckDNS — free)
|
||||
- Dynamic host routing with wildcard domain matching (`*`, `**`)
|
||||
- **Multi-target load balancing** — configure multiple backend targets per host with built-in round-robin load balancing
|
||||
- **OIDC login** and **direct LDAP lookups**, independently of each other
|
||||
- **OIDC login** and **direct LDAP lookups**, independently of each other, against [Theta Directory](../sso/)
|
||||
- Per-host **basic auth** as an alternative to SSO (mutually exclusive, so
|
||||
it's never ambiguous which one gated a request)
|
||||
- **Role-based access control** — global admins, local groups, and
|
||||
per-domain permissions (viewer/manager)
|
||||
- Self-service API tokens for scripting/CI without a browser session
|
||||
- Web UI and a full REST API
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/theta42/proxy.git
|
||||
cd proxy && docker compose up -d --build
|
||||
```
|
||||
|
||||
For the full set of install options (Docker, bare-metal, or as part of the combined
|
||||
SSO + proxy stack), configuration reference, and API docs, see the
|
||||
**[GitHub repository](https://github.com/theta42/proxy)**.
|
||||
|
||||
## Related projects
|
||||
|
||||
- **[SSO Manager](https://theta42.github.io/sso-manager-node/)** — the OIDC
|
||||
provider + LDAP directory this proxy is designed to sit in front of.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this proxy and
|
||||
SSO Manager together with one command.
|
||||
|
||||
@@ -74,6 +74,11 @@ file shape.
|
||||
> exist, `./setup.sh` migrates them into `./config/` preserving your existing
|
||||
> secrets — no need to write a `setup.env`.
|
||||
|
||||
> **Joining an existing Theta Directory cluster instead of seeding a fresh
|
||||
> one?** See [Multi-Site (Master/Spoke Join)](sso/multi-site.html) —
|
||||
> `spoke.env.example` has the join-a-cluster vars split out into their own
|
||||
> file, or set them directly in `setup.env` (which has every option).
|
||||
|
||||
---
|
||||
|
||||
## 3. Run
|
||||
|
||||
@@ -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.
|
||||
@@ -8,48 +8,9 @@ description: theta-suite's central secrets architecture — OpenBao as the singl
|
||||
|
||||
theta-suite keeps **every secret in one place: [OpenBao](https://openbao.org/)**
|
||||
(a Vault-community fork), running on the `theta-net` docker network at
|
||||
`http://openbao:8200`. The three apps (SSO Manager, proxy, jump host) load
|
||||
their boot secrets from it; end users get personal per-user secret storage
|
||||
through the SSO UI; and external apps get scoped, self-contained access to
|
||||
their own namespace.
|
||||
|
||||
This page is the operator reference. For the package API, see
|
||||
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/).
|
||||
|
||||
## Why a central store
|
||||
|
||||
Before this, secret handling was partial and inconsistent: only the SSO read
|
||||
one path from OpenBao; the proxy and jump host read bind-mounted
|
||||
`./config/*-secrets.js` files; the bootstrap wrote generated OAuth creds to
|
||||
those files on disk; and the SSO `/api/vault` UI was an ungated, broken
|
||||
pass-through. Centralising on OpenBao gives every app the same fail-soft load
|
||||
path, makes per-user secret storage possible, and lets external apps get
|
||||
least-privilege access without anyone handing them the root token.
|
||||
|
||||
## The load path (every app)
|
||||
|
||||
1. `@simpleworkjs/conf` **synchronously** loads the bind-mounted
|
||||
`./config/<app>-secrets.js` at require time — the file is the operator-edit
|
||||
layer and the fail-soft fallback.
|
||||
2. `@simpleworkjs/bao-conf`'s `init({ path: '<app>', conf })` **deep-merges**
|
||||
`secret/data/<app>/conf` from OpenBao over the live `conf` object. It is
|
||||
**fail-soft**: if OpenBao is unreachable or the path is absent, boot
|
||||
continues with the file-loaded config.
|
||||
3. A few secrets are **captured at require time** (notably the OIDC
|
||||
`clientSecret`, consumed inside `createOidcClient` during
|
||||
`require('../models')`). So `init()` must resolve *before* that
|
||||
`require()`. Each app's `bin/www` handles this:
|
||||
- **proxy** — defers `require('../app')` (which transitively loads models)
|
||||
behind `bao-conf.init()`.
|
||||
- **jump host** — gates the explicit `require('../models')` behind
|
||||
`bao-conf.init()`.
|
||||
- **SSO** — swaps the old `conf_manager.init()` call (same position in its
|
||||
existing `.then()` boot chain) for `bao-conf.init()`; nothing in the SSO
|
||||
captures a secret at require time, so no reordering was needed.
|
||||
|
||||
`VAULT_TOKEN` (a scoped per-app token, **not** the root token) and
|
||||
`VAULT_ADDR=http://openbao:8200` are passed to each container via
|
||||
`docker-compose.yml`. The `./config/*-secrets.js` mounts stay as the fallback.
|
||||
`http://openbao:8200`. The SSO Manager acts as management and abstraction point
|
||||
for secrets management. Via the directory, secrets can be set, cycled, revoked
|
||||
or inherited. **You are not meant to interact with opanBoa directly.**
|
||||
|
||||
## Policies, token role, and tokens
|
||||
|
||||
@@ -60,13 +21,64 @@ never passed to a service container.
|
||||
|
||||
| Policy | Capabilities | Held by |
|
||||
|---|---|---|
|
||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
||||
| `sso-broker` | read/write `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`, `secret/agent/*`, `secret/data/resources/*`, `secret/metadata/resources/*`; `update` on `auth/token/create/sso-broker` + `create/sso-app` and `auth/token/renew-accessor`/`revoke-accessor`/`lookup-accessor`; `update` on `sys/policies/acl/user-*`, `app-*`, `sso-admin` | SSO (`SSO_VAULT_TOKEN`) |
|
||||
| `sso-admin` | read/write/list all of `secret/*` | admin UI sessions (minted by the broker) |
|
||||
| `proxy` | read `secret/proxy/conf` | proxy (`PROXY_VAULT_TOKEN`) |
|
||||
| `jump-host` | read `secret/jump-host/conf` | jump host (`JUMP_VAULT_TOKEN`) |
|
||||
| `user-<uid>` | read/write `secret/users/<uid>/*` | per-user tokens (minted lazily by the broker) |
|
||||
| `app-<name>` | read/write `secret/apps/<name>/*` | per-external-app tokens (minted by an admin) |
|
||||
|
||||
## Resource Secrets & Zero-View Security Model
|
||||
|
||||
Directory resources (Services, Hosts, Containers, Sites) manage their application secrets at `secret/data/resources/<resource_slug>/conf` in OpenBao KV-v2.
|
||||
|
||||
### 1. Zero-View Security Model
|
||||
* **API Metadata Only**: `GET /api/directory-admin/resources/:id/secrets` returns
|
||||
key names and metadata (`hasValue: true`, `isInherited: true`, `parentSlug`),
|
||||
but **NEVER returns raw secret values**.
|
||||
* **Browser Isolation**: Secret values are never exposed in HTML DOM templates,
|
||||
JSON admin APIs, or browser dev tools.
|
||||
* **Agent-Exclusive Delivery**: Raw secret values are fetched exclusively over
|
||||
TLS by authenticated `theta-agent` instances using machine authorization tokens
|
||||
(`POST /api/v1/agent/secrets`).
|
||||
|
||||
### 2. Multi-Level Hierarchy Secret Inheritance
|
||||
Resources inherit secrets across any level of the directory hierarchy
|
||||
(`Services / Apps → Hosts / Nodes → Global Sites`):
|
||||
* An inherited secret reference is stored as `INHERIT:<parent_slug>:<parent_key>`
|
||||
(or `INHERIT:<key>`).
|
||||
* When requested by `theta-agent`, SSO Manager resolves the inheritance chain
|
||||
dynamically, fetching the final secret value from the parent Site or Host's
|
||||
OpenBao store.
|
||||
|
||||
### 3. Key Validation & Generator
|
||||
* **Key Format**: Secret keys are strictly validated against `^[A-Za-z0-9_]+$`
|
||||
(Standard Environment Variable format, e.g. `DB_PASSWORD`).
|
||||
* **Cryptographic Generator**: The UI includes a client-side cryptographic
|
||||
secret generator (`window.crypto.getRandomValues`) with length choices from 8 to
|
||||
128 characters. Populating the input field displays an inline security warning
|
||||
notifying operators to save immediately before values are hidden.
|
||||
|
||||
## On-Demand CLI Secret Delivery (`theta-agent get-secret`)
|
||||
|
||||
`theta-agent` delivers secrets on demand directly to local processes, shell
|
||||
scripts, Systemd services, and Docker containers without writing plaintext
|
||||
secret files to disk.
|
||||
|
||||
```bash
|
||||
# Fetch single raw secret value (stdout, no trailing newline):
|
||||
theta-agent get-secret DB_PASSWORD
|
||||
|
||||
# Assign directly to shell environment variables:
|
||||
export DB_PASSWORD=$(theta-agent get-secret DB_PASSWORD)
|
||||
|
||||
# Export all host/resource secrets for Systemd EnvironmentFile:
|
||||
theta-agent get-secrets --env
|
||||
|
||||
# Format all secrets as JSON for automation scripts:
|
||||
theta-agent get-secrets --json
|
||||
```
|
||||
|
||||
**Token roles** — three, all orphan + renewable:
|
||||
|
||||
- `sso-broker` — `allowed_policies=sso-admin`, `allowed_policies_glob=user-*,app-*`,
|
||||
@@ -176,6 +188,21 @@ const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-s
|
||||
await baoConf.set('apps/my-service/conf', { db_password: '...' });
|
||||
```
|
||||
|
||||
## The theta-agent signing key
|
||||
|
||||
The SSO signs high-risk theta-agent commands (`reboot`, `configure_ldap`,
|
||||
`arbitrary_bash`, …) with an Ed25519 key stored at
|
||||
`secret/agent/signing-key`. Agents pin the matching public key in their
|
||||
`agent.yml`, so the key **must** be stable: it used to be generated in memory at
|
||||
process start, which meant it changed on every restart and no agent could
|
||||
meaningfully verify anything.
|
||||
|
||||
If the SSO cannot read or write that path it refuses to send high-risk commands
|
||||
rather than signing with a key no agent has seen — so an upgraded stack that has
|
||||
not re-run `./setup.sh` (and therefore lacks `secret/agent/*` in the
|
||||
`sso-broker` policy) will report `signingAvailable: false` on
|
||||
`GET /api/agent/nodes` and reject those commands with a clear error.
|
||||
|
||||
## Plugin secrets
|
||||
|
||||
The SSO Manager's plugin system (configurable plugin instances you create,
|
||||
|
||||
@@ -6,7 +6,7 @@ nav_order: 5
|
||||
|
||||
# Discovery Agents
|
||||
|
||||
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||
Theta Directory supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||
|
||||
## Writing a Custom Agent
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Accounts, Groups & Managers
|
||||
description: A plain-language guide to users, service accounts, personal groups, and managers in SSO Manager.
|
||||
description: A plain-language guide to users, service accounts, personal groups, and managers in Theta Directory.
|
||||
---
|
||||
|
||||
# Accounts, Groups & Managers
|
||||
@@ -12,7 +12,7 @@ see the [LDAP reference](ldap.html).
|
||||
|
||||
## What's an account?
|
||||
|
||||
Every person (or app) that can sign in through this SSO Manager has an
|
||||
Every person (or app) that can sign in through Theta Directory has an
|
||||
**account** — a username, a display name, maybe an email address, and a
|
||||
password (or, for service accounts, no password at all — see below).
|
||||
Accounts live in the directory this app manages, and any other app you've
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: API Tokens
|
||||
description: A plain-language guide to personal access tokens in SSO Manager.
|
||||
description: A plain-language guide to personal access tokens in Theta Directory.
|
||||
---
|
||||
|
||||
# API Tokens
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
---
|
||||
layout: default
|
||||
title: Connecting Apps (Single Sign-On)
|
||||
description: A plain-language guide to OAuth/OIDC clients and single sign-on in SSO Manager.
|
||||
description: A plain-language guide to OAuth/OIDC clients and single sign-on in Theta Directory.
|
||||
---
|
||||
|
||||
# Connecting Apps (Single Sign-On)
|
||||
|
||||
This page explains, in plain language, what happens when you "connect" an
|
||||
app to your SSO Manager so people can log into it with their existing
|
||||
app to Theta Directory so people can log into it with their existing
|
||||
account. For the technical endpoint/token detail, see the
|
||||
[OAuth reference](oauth.html).
|
||||
|
||||
## What does "single sign-on" actually mean?
|
||||
|
||||
Instead of every app you run having its own separate list of usernames and
|
||||
passwords, they all check with this SSO Manager instead. You log in once,
|
||||
passwords, they all check with Theta Directory instead. You log in once,
|
||||
here, and any connected app trusts that login — no separate password to
|
||||
remember or manage for each one. If you ever need to lock someone out
|
||||
everywhere at once, you do it in one place (deactivate their account here)
|
||||
@@ -33,11 +33,11 @@ gives you a **Client ID** and **Client Secret**: think of these like a
|
||||
username and password, but for the *app itself* rather than for a person.
|
||||
You paste them into the other app's own "Single Sign-On" or "OIDC" setup
|
||||
screen, along with the discovery URL shown at the top of this page, and
|
||||
that app is now able to ask this SSO Manager to authenticate people on its
|
||||
that app is now able to ask Theta Directory to authenticate people on its
|
||||
behalf.
|
||||
|
||||
**Treat the Client Secret like a password** — anyone who has it can
|
||||
impersonate that app when talking to your SSO Manager. If you ever suspect
|
||||
impersonate that app when talking to Theta Directory. If you ever suspect
|
||||
it's leaked, rotate it from the client's card.
|
||||
|
||||
## What are "scopes"?
|
||||
@@ -50,13 +50,13 @@ setup instructions; when in doubt, the default set (`openid`, `profile`,
|
||||
|
||||
## "Restrict to Groups"
|
||||
|
||||
By default, *any* account with an SSO Manager login can sign into a
|
||||
By default, *any* account with a Theta Directory login can sign into a
|
||||
connected app. If that's not what you want — say, a home automation
|
||||
dashboard that only certain family members should reach — set **Restrict
|
||||
to Groups** on that client to one of your [groups](concepts-accounts.html).
|
||||
Only members of that group will be allowed to log into that particular
|
||||
app; everyone else gets turned away at the login step, even though their
|
||||
SSO Manager account still works everywhere else.
|
||||
Theta Directory account still works everywhere else.
|
||||
|
||||
## Redirect URIs
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: Configuration
|
||||
description: SSO Manager's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||
description: Theta Directory's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
|
||||
---
|
||||
|
||||
# Configuration
|
||||
|
||||
@@ -6,7 +6,7 @@ description: Managing your Home-Lab infrastructure, services, and LDAP access re
|
||||
|
||||
# Directory Management
|
||||
|
||||
The SSO Manager ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
|
||||
Theta Directory ships with a built-in **Directory & Inventory Management** feature. Instead of just managing bare LDAP groups for your homelab, the Directory allows you to map out your infrastructure graph and assign rich metadata to your services.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -18,11 +18,11 @@ There are three primary **Kinds** of resources you can define:
|
||||
- **Service (App)**: An application, web service. A Service **must** have a parent Host or another Service.
|
||||
- **OAuth Integration**: An OAuth 2.0 / OpenID Connect client application. An OAuth integration **must** have a parent Service.
|
||||
|
||||
By defining this hierarchy, the SSO Manager builds a queryable graph of your infrastructure.
|
||||
By defining this hierarchy, Theta Directory builds a queryable graph of your infrastructure.
|
||||
|
||||
## Automatic LDAP Group Creation
|
||||
|
||||
When you create a new **Host** or **Service** in the Directory via the web UI (or API), the SSO Manager will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||
When you create a new **Host** or **Service** in the Directory via the web UI (or API), Theta Directory will automatically provision two LDAP groups in your directory to govern access to that resource:
|
||||
|
||||
1. `<slug>_access` (Member level access)
|
||||
2. `<slug>_admin` (Owner level access)
|
||||
@@ -84,7 +84,7 @@ The Directory Management interface provides a **Tree View** toggle that visually
|
||||
|
||||
## Slug conventions
|
||||
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around the SSO Manager follows a shared convention:
|
||||
Slugs are the stable identifiers automation keys off, so the tooling around Theta Directory follows a shared convention:
|
||||
|
||||
- **Sites**: `site_<name>` — e.g. `site_local`, `site_us-east`
|
||||
- **Hosts**: `host_<hostname>` — e.g. `host_pve1`, `host_web01`
|
||||
@@ -102,7 +102,7 @@ You don't have to build the graph by hand — the theta42 tooling registers itse
|
||||
|
||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||
- the **services** it composes — Theta Directory, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||
|
||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||
|
||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 320 KiB |
@@ -1,24 +1,25 @@
|
||||
---
|
||||
layout: default
|
||||
title: Home
|
||||
description: A self-hosted OpenID Connect provider with a bundled OpenLDAP directory and a web management UI. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||
description: Theta Directory — the OpenID Connect provider, bundled OpenLDAP directory, and resource inventory at the core of theta-suite. One login for your modern apps, one LDAP directory for the rest, no phone-home.
|
||||
---
|
||||
|
||||
# SSO Manager
|
||||
# Theta Directory
|
||||
|
||||
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
||||
and a web management UI — for home labs and small businesses that want their
|
||||
own identity provider instead of a hosted one.
|
||||
The identity and directory component of [theta-suite](../): an **OpenID
|
||||
Connect provider**, a bundled **OpenLDAP directory**, and a **resource
|
||||
inventory & IAM engine**, all behind one web console.
|
||||
|
||||
One place to manage your users and groups, one login (OIDC) your modern apps
|
||||
can use, and one LDAP directory your older or odder apps can bind to directly.
|
||||
Everything runs on your own hardware; no phone-home, no hosted control plane,
|
||||
no per-user pricing.
|
||||
can use, and one LDAP directory your older or odder apps can bind to directly
|
||||
— plus a graph of every site, host, and service you run, with auto-provisioned
|
||||
access groups. Everything runs on your own hardware; no phone-home, no hosted
|
||||
control plane, no per-user pricing.
|
||||
|
||||
Part of the theta42 self-hosted identity stack, alongside
|
||||
[Proxy](https://theta42.github.io/proxy/) (an OIDC + LDAP-aware reverse proxy)
|
||||
and [theta-env](https://theta42.github.io/theta-env/) (the two composed with
|
||||
one command).
|
||||
Theta Directory is deployed as part of theta-suite, alongside
|
||||
[Proxy](../proxy/) and [Jump Host](../jump-host/) — it isn't installed or run
|
||||
on its own. See the [Quickstart](../quickstart.html) to stand up the whole
|
||||
stack with one command.
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -27,23 +28,11 @@ one command).
|
||||
<a href="images/groups.png" target="_blank"><img src="images/groups.png" alt="Groups" width="49%"></a>
|
||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory" width="49%"></a>
|
||||
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="OAuth client (edit view)" width="49%"></a>
|
||||
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Agent capabilities & metrics" width="49%"></a>
|
||||
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Agent install with join key" width="49%"></a>
|
||||
|
||||
*(click any screenshot to view full size)*
|
||||
|
||||
## Why this over the alternatives
|
||||
|
||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
||||
LDAP is either a paid feature, a federation target you have to run
|
||||
separately, or absent. If your stack already has apps that speak LDAP
|
||||
directly — or you just want one real directory as the source of truth — you
|
||||
end up running *two* identity systems and keeping them in sync.
|
||||
|
||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
||||
scope: it's intentionally small and self-hosted, not an enterprise IAM suite.
|
||||
If you want a lightweight, self-contained identity provider with a real LDAP
|
||||
backend, that's the niche.
|
||||
|
||||
## Features
|
||||
|
||||
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
|
||||
@@ -56,30 +45,8 @@ backend, that's the niche.
|
||||
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
|
||||
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
|
||||
directory.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
|
||||
## Get it
|
||||
|
||||
```bash
|
||||
git clone https://github.com/theta42/sso-manager-node.git
|
||||
cd sso-manager-node
|
||||
cp secrets.js.example nodejs/conf/secrets.js # edit it, or use app_* env
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
For the full set of install options
|
||||
(Docker, bare-metal, or as part of the combined SSO + proxy stack), the
|
||||
`app_*` env reference, and the OAuth/LDAP internals, see the
|
||||
**[GitHub repository](https://github.com/theta42/sso-manager-node)**.
|
||||
|
||||
## Related projects
|
||||
|
||||
- **[Proxy](https://theta42.github.io/proxy/)** — an OIDC + LDAP-aware
|
||||
reverse proxy, designed to sit in front of this SSO.
|
||||
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that
|
||||
uses this SSO's directory to decide who may reach which machine.
|
||||
- **[theta-env](https://theta42.github.io/theta-env/)** — runs this SSO
|
||||
Manager and the proxy together with one command.
|
||||
- **[Multi-Site](multi-site.html)** — one master site, any number of read-only spokes that join with a single key and stay live-synced, with god_admin-gated promotion if the master goes down for good.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites (a different, lower-level mechanism — see [Multi-Site](multi-site.html) for how the two compare).
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-suite's agents and discovery plugins. Drives directory-aware tools like the [SSH jump host](../jump-host/).
|
||||
- **Subtype metrics & lifecycle drivers** — telemetry, log streaming, and remote control for resources tagged with a `subType` (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`, …).
|
||||
- **OpenBao-backed secrets** — per-resource and per-user secrets with explicit upward inheritance (`Resource → Host → Cluster → Site`).
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: LDAP
|
||||
description: SSO Manager's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||
description: Theta Directory's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
|
||||
---
|
||||
|
||||
# LDAP Directory
|
||||
@@ -12,7 +12,7 @@ description: SSO Manager's bundled OpenLDAP directory — schema, service accoun
|
||||
> instead of schema/attribute detail? See
|
||||
> [Accounts, Groups & Managers](concepts-accounts.html).
|
||||
|
||||
SSO Manager runs an OpenLDAP directory holding your users and groups. The app
|
||||
Theta Directory runs an OpenLDAP directory holding your users and groups. The app
|
||||
authenticates against it over `localhost:389` (inside the all-in-one container)
|
||||
and exposes **LDAPS** (`ldaps://…:636`, TLS) for anything that binds LDAP
|
||||
directly — Linux hosts (PAM/SSSD, sudo rules, SSH keys), Gitea, Emby, the
|
||||
@@ -167,8 +167,8 @@ internal-only patterns and set `conf.ldap.ldapsHost` (or
|
||||
|
||||
### 1. Same Docker / local network host (best for apps on this machine)
|
||||
|
||||
If the LDAP client runs on the same Docker network as the SSO Manager (for
|
||||
example, the bundled `theta-env` stack), use the internal service name:
|
||||
If the LDAP client runs on the same Docker network as Theta Directory (for
|
||||
example, the bundled `theta-suite` stack), use the internal service name:
|
||||
|
||||
```
|
||||
ldaps://sso-manager:636
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
---
|
||||
layout: default
|
||||
title: Multi-Site (Master/Spoke Join)
|
||||
---
|
||||
|
||||
# Multi-Site (Master/Spoke Join)
|
||||
|
||||
If you run more than one physical site, Theta Directory can run one site as
|
||||
the **master** (single write authority for the shared catalog) and any
|
||||
number of **spokes** — read-only replicas that stay in sync automatically and
|
||||
run local authentication with zero WAN dependency.
|
||||
|
||||
This is a higher-level mechanism than [raw LDAP N-way
|
||||
replication](replication.html) — and it now drives that lower-level
|
||||
replication for you automatically. See [How this relates to LDAP
|
||||
replication](#how-this-relates-to-ldap-replication) below.
|
||||
|
||||
## Why and when to use this
|
||||
|
||||
- **Zero-touch spoke setup.** One join key, one URL, and a spoke adopts the
|
||||
whole directory (users, groups, resource catalog) in one step — no manual
|
||||
`syncrepl` configuration.
|
||||
- **Single write authority, no split-brain.** Only the master accepts
|
||||
directory writes. A spoke that loses WAN connectivity keeps working for
|
||||
local reads/auth and unconditionally stays read-only — it never silently
|
||||
promotes itself. Changing which site is master always requires an explicit,
|
||||
authenticated action by a `god_admin`.
|
||||
- **Stays in sync, not just a one-time copy.** Once joined, a spoke keeps
|
||||
receiving live updates whenever the master's catalog changes — you don't
|
||||
re-run the join to pick up new hosts/apps/users.
|
||||
|
||||
## How it works
|
||||
|
||||
1. **On the master**, an admin mints a **site join key** (Directory → the
|
||||
Master Site modal → **Site Join Keys** → Mint key). It's shown once,
|
||||
stored hashed, and revocable.
|
||||
2. **On the spoke** (must be a fresh install — no users beyond the bootstrap
|
||||
admin, no enrolled agents), either:
|
||||
- Paste the master's URL and the join key into the Master Site modal's
|
||||
**Join an Existing Site** form, or
|
||||
- Set `CFG_MASTER_DIRECTORY_URL` / `CFG_MASTER_DIRECTORY_JOIN_KEY` before
|
||||
the first `./setup.sh` run -- either in `setup.env` (which has every
|
||||
option), or in a dedicated `spoke.env` (`cp spoke.env.example spoke.env`)
|
||||
if you'd rather keep join-a-cluster config separate from the rest of the
|
||||
stack's setup. Both are read; `spoke.env`'s values win on a conflict.
|
||||
No public IP on this site at all? `spoke.env.example` also covers the
|
||||
no-inbound relay vars (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`).
|
||||
|
||||
Want this spoke reachable at its own public domain rather than sharing
|
||||
the master's? `CFG_DOMAIN` (the LDAP identity namespace) must stay
|
||||
identical across every site in a cluster — MMR replicas can't diverge
|
||||
on base DN — but `CFG_PUBLIC_DOMAIN` overrides just this site's own web
|
||||
hostnames (`sso.*`/`proxy.*`) independently of it. Only meaningful for
|
||||
an inbound spoke serving its own traffic directly.
|
||||
3. The spoke pulls the master's full export (LDAP tree, resource catalog,
|
||||
agent-signing key) and adopts it, then registers its own reachable URL
|
||||
with the master so it can receive live updates going forward.
|
||||
4. From then on, every change to the master's catalog pushes to every
|
||||
registered spoke automatically. A spoke's own directory-write requests are
|
||||
rejected with a `403` pointing at the master — writes always go there.
|
||||
|
||||
### Promoting a spoke to master
|
||||
|
||||
If the master site goes down for good (or you're relocating write
|
||||
authority), a `god_admin` can promote any spoke from its own Master Site
|
||||
modal. Promotion is one coordinated action: it demotes the previous master as
|
||||
part of the same request (best-effort — an unreachable old master never
|
||||
blocks the promotion, since that's exactly the scenario this exists for), and
|
||||
every other spoke gets pointed at the new master automatically.
|
||||
|
||||
## What replicates
|
||||
|
||||
| Data | How |
|
||||
|---|---|
|
||||
| LDAP (users, groups) | Full export on join; live push on every master change |
|
||||
| Resource catalog (hosts, apps, sites) | Same |
|
||||
| Agent-signing key | Same — every site can validly sign a command for any agent enrolled at *any* site |
|
||||
|
||||
The agent-signing key being identical everywhere is a deliberate tradeoff for
|
||||
small, trusted deployments (a handful of sites, not hundreds) — it means
|
||||
compromising the least-secured spoke has the same agent-command blast radius
|
||||
as compromising the master. If that tradeoff doesn't fit your deployment,
|
||||
don't rely on this mechanism as-is.
|
||||
|
||||
Secrets *beyond* the agent-signing key (LDAP admin password, JWT secret, and
|
||||
so on) are **not** currently synced — each site still generates its own.
|
||||
|
||||
## Requirements and current limits
|
||||
|
||||
- Both sites need a network path to each other's HTTP(S) API — the master to
|
||||
pull an export from, the spoke to push replication updates back to. A site
|
||||
with **no inbound path at all** (e.g. behind CGNAT) can still join: set
|
||||
`CFG_SPOKE_NO_INBOUND=true` + `CFG_SPOKE_PUBLIC_HOST` (`spoke.env.example`)
|
||||
once its jump-host is meshed to the master's over WireGuard (mesh peering
|
||||
itself is a manual, one-time step on both jump-hosts — see [Theta Gateway
|
||||
→ Mesh](../jump-host/mesh.html)) — the master then relays traffic to it
|
||||
and auto-creates the matching route on its own `theta-proxy`. A spoke with
|
||||
**zero inbound and zero outbound** path still can't join at all (the join
|
||||
itself needs to reach the master's API directly).
|
||||
- Joining only ever happens on a **fresh install**. There's no way to merge
|
||||
an already-populated directory into a master's — re-provision the host
|
||||
first.
|
||||
- Promoting a spoke to master doesn't instantly finish reconciling OpenLDAP
|
||||
replication (see below) — re-run `setup.sh` on the newly-promoted node
|
||||
promptly afterward.
|
||||
|
||||
## How this relates to LDAP replication
|
||||
|
||||
[N-way LDAP replication](replication.html) is the *lower-level* mechanism
|
||||
underneath this: `slapd`'s own `syncrepl`, wired via `LDAP_SERVER_ID` +
|
||||
`LDAP_REPLICATION_HOSTS`. Originally this was hand-configured by the
|
||||
operator, separately from the join flow above, for deployments that wanted
|
||||
every site independently writable with no concept of a master.
|
||||
|
||||
**When you join via this page's flow, that lower-level config is now handled
|
||||
for you.** The master auto-assigns each spoke a unique `LDAP_SERVER_ID` at
|
||||
join time and derives every site's LDAP URL from its already-known HTTPS
|
||||
endpoint — `theta-suite`'s `bootstrap/site-ldap-register.js` applies it,
|
||||
re-checked on every `setup.sh` run since the peer list grows as spokes join.
|
||||
You don't hand-set `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a cluster
|
||||
built this way. See [Geo-Location Scaling](replication.html#automatic-config-via-multi-site-join)
|
||||
for the mechanics, and its documented limitation: the *master's* own
|
||||
replication list only updates on ITS next `setup.sh` run, not live the
|
||||
instant a new spoke joins.
|
||||
|
||||
Still want fully independent, always-writable sites with no master/spoke
|
||||
concept at all? `CFG_LDAP_MMR_MANUAL=true` opts out of the automatic path so
|
||||
you can hand-set `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` directly, same as
|
||||
before this integration existed.
|
||||
|
||||
## See also
|
||||
|
||||
- Full architecture and current implementation status:
|
||||
[`MULTI_SITE_SPEC.md`](https://github.com/theta42/theta-suite/blob/master/docs/MULTI_SITE_SPEC.md)
|
||||
in the `theta-suite` repo.
|
||||
- Endpoint-level detail: [`docs/site-join.md`](https://github.com/theta42/theta-directory/blob/master/docs/site-join.md)
|
||||
in the `theta-directory` repo.
|
||||
- Site-to-site networking (WireGuard mesh between gateways, independent of
|
||||
directory sync): [Theta Gateway → Mesh](../jump-host/mesh.html).
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
layout: default
|
||||
title: OAuth / OIDC
|
||||
description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
|
||||
description: Theta Directory's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
|
||||
---
|
||||
|
||||
# OAuth 2.0 / OpenID Connect
|
||||
@@ -12,7 +12,7 @@ description: SSO Manager's OpenID Connect / OAuth 2.0 provider — discovery doc
|
||||
> of endpoint-level detail? See
|
||||
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
|
||||
|
||||
SSO Manager is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
||||
Theta Directory is an **OpenID Connect / OAuth 2.0 provider**: it issues its own
|
||||
access, refresh, and ID tokens that your apps can consume to authenticate
|
||||
users and authorize API calls. It also runs a full OpenLDAP directory, so it
|
||||
can be both your SSO and your user directory at once.
|
||||
|
||||
@@ -5,7 +5,7 @@ title: Geo-Location Scaling (Replication)
|
||||
|
||||
# Geo-Location Scaling (Replication)
|
||||
|
||||
SSO Manager is built to be a self-contained identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||
Theta Directory bundles its own identity provider, but if you have multiple physical sites, you may want a local copy of the directory at each site to ensure low latency and high availability.
|
||||
|
||||
## Why and when to use this?
|
||||
- **High Availability (HA)**: If your primary site goes completely offline, your other sites can still authenticate users locally without depending on a WAN link.
|
||||
@@ -23,33 +23,58 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||
The container's entrypoint reads two environment variables to configure this
|
||||
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||
in `/etc/openldap/slapd.conf`.
|
||||
|
||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||
### Automatic config via Multi-Site join
|
||||
|
||||
### Example using `theta-env` / Docker Compose
|
||||
**If you're using [Multi-Site join](multi-site.html) (`CFG_MASTER_DIRECTORY_URL`/`spoke.env`),
|
||||
you don't set these by hand.** The master assigns each spoke a unique
|
||||
`LDAP_SERVER_ID` at join time (the same way it assigns a WireGuard mesh
|
||||
index) and derives `LDAP_REPLICATION_HOSTS` from every site's already-known
|
||||
HTTPS endpoint (`ldaps://<same-host>:636`) -- `theta-suite`'s `bootstrap/
|
||||
site-ldap-register.js` applies it and re-checks on every `setup.sh` run,
|
||||
since the peer list grows as new spokes join, restarting `sso-manager` only
|
||||
when the computed config actually changed.
|
||||
|
||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||
**Known limitation**: the *master's* own `LDAP_REPLICATION_HOSTS` only gets
|
||||
recomputed when its `setup.sh` is re-run — there's no live push telling an
|
||||
already-running master about a spoke that joined five minutes ago. Re-run
|
||||
`setup.sh` on the master after bringing up a new spoke (or after promoting
|
||||
one to master) to pick up the current peer list. A spoke's own config, by
|
||||
contrast, is re-checked and applied on every `setup.sh` run there, which is
|
||||
the common/recurring event.
|
||||
|
||||
### Manual configuration
|
||||
|
||||
Have a topology outside a `theta-suite`-managed cluster (fully independent,
|
||||
always-writable sites, no master/spoke concept)? Set
|
||||
`CFG_LDAP_MMR_MANUAL=true` to skip the automatic path entirely and set the
|
||||
two variables directly -- without this, the automatic step runs on every
|
||||
deployment (every fresh install starts as a master) and will overwrite them.
|
||||
|
||||
**Site 1**
|
||||
```env
|
||||
LDAP_SERVER_ID=1
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||
**Site 2**
|
||||
```env
|
||||
LDAP_SERVER_ID=2
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||
**Site 3**
|
||||
```env
|
||||
LDAP_SERVER_ID=3
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||
```
|
||||
|
||||
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||
|
||||
## User Locations
|
||||
|
||||
When creating or editing a user, you can specify their **Location (Site)**. This maps directly to the standard LDAP `l` (localityName) attribute, allowing you to track which physical site a user belongs to natively within the directory.
|
||||
|
||||
@@ -6,9 +6,9 @@ nav_order: 6
|
||||
|
||||
# Secrets Vault
|
||||
|
||||
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
||||
Theta Directory integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
||||
|
||||
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
||||
The Vault proxy endpoint is exposed directly through Theta Directory at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -18,10 +18,10 @@ When the environment is initialized via `setup.sh`, OpenBao is automatically uns
|
||||
|
||||
## Accessing the Vault
|
||||
|
||||
The SSO Manager Vault can be accessed in two ways:
|
||||
The Theta Directory Vault can be accessed in two ways:
|
||||
|
||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||
1. **Via the Theta Directory UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your Theta Directory session or API Token.
|
||||
|
||||
### API Example
|
||||
|
||||
@@ -32,14 +32,14 @@ Only administrators with `app_sso_admin` or `admin` permissions can query the va
|
||||
|
||||
## Namespaces and Paths
|
||||
|
||||
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
||||
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, Theta Directory performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
||||
|
||||
## Plugin Integration
|
||||
|
||||
Plugin instances store their per-instance secrets in OpenBao at
|
||||
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
||||
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
||||
in-process, so the SSO Manager reads/writes those secrets server-side through
|
||||
in-process, so Theta Directory reads/writes those secrets server-side through
|
||||
the `sso-broker` token; the admin UI only ever sees masked values, and external
|
||||
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
|
||||
consistently enforced instead of hardcoding them.
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
https://github.com/theta42/theta-suite/pull/75
|
||||
@@ -48,6 +48,60 @@ CFG_DOMAIN=example.com
|
||||
# under an OU-style prefix). Leave unset to use the DN built from CFG_DOMAIN:
|
||||
#CFG_BASE_DN=dc=example,dc=com
|
||||
|
||||
# ── Multi-Site: join an existing (master) directory ──────────────────────────
|
||||
# To run THIS deployment as a read-only SPOKE of an existing Theta Directory
|
||||
# instead of seeding a fresh one, set the master's URL and a site join key
|
||||
# (mint one on the master: Directory -> the Master Site modal -> Site Join Keys
|
||||
# -> Mint key). Honored ONLY on a first-run bring-up (before ./config/ exists),
|
||||
# so it can never merge an already-populated directory; re-runs ignore it.
|
||||
# The spoke adopts the master's users/groups/resources and persists its spoke
|
||||
# role in ./config/site.json (isMaster=false, masterUrl, siteSlug). It also
|
||||
# registers itself with the master (using this site's own CFG_SSO_HOST) so
|
||||
# future catalog changes on the master get pushed here live instead of this
|
||||
# being a one-time snapshot -- the master must be able to reach THIS site's
|
||||
# CFG_SSO_HOST for that part to work; if it can't (this site has no inbound
|
||||
# path), the join still succeeds, it just never receives live updates.
|
||||
#
|
||||
# All of these (and the no-inbound relay pair below) also live in their own
|
||||
# spoke.env.example, if you'd rather keep join-a-cluster config in a
|
||||
# dedicated file instead of here -- both are read, spoke.env's values win.
|
||||
#CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
#CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
|
||||
# This site's own public web domain, independent of CFG_DOMAIN above (the
|
||||
# shared LDAP identity namespace, which must be identical across every site).
|
||||
# Optional -- only meaningful for an inbound spoke/standalone site that wants
|
||||
# its own domain rather than sharing the master's.
|
||||
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||
|
||||
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||
# manual, out-of-band step on BOTH jump-hosts (mint a mesh join token on the
|
||||
# master's jump-host, paste it into this site's jump-host "Join a mesh" UI
|
||||
# action) that can't run unattended inside this script. Once that's done, set
|
||||
# these two and re-run setup.sh: it discovers this jump-host's assigned mesh
|
||||
# IP (GET /api/mesh/self) and registers it with the master, which then
|
||||
# auto-creates the relay route on its own theta-proxy. Safe to leave set
|
||||
# before meshing -- setup.sh just reports "not meshed yet" and skips until a
|
||||
# later re-run finds the mesh IP.
|
||||
#CFG_SPOKE_NO_INBOUND=true
|
||||
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||
|
||||
# Two service-to-service integrations the Directory uses (both reuse each
|
||||
# app's existing self-service API token system -- see MULTI_SITE_SPEC.md's
|
||||
# "service-to-service auth" note -- not a new credential type each):
|
||||
# - No-inbound relay automation (above) needs a theta-proxy API token so
|
||||
# sso-manager can create/update the relay Host route on its own.
|
||||
# - The Multi-Site modal's real gateway-mesh count needs a jump-host API
|
||||
# token (minted by a jump-admin user) to read GET /api/mesh/gateways.
|
||||
# Neither is required for the rest of the stack to work -- both features
|
||||
# just report "not configured" until you mint a token in each app's own web
|
||||
# UI (Settings -> API Tokens) and store it in OpenBao, from inside the
|
||||
# sso-manager container (VAULT_ADDR/VAULT_TOKEN are already set there):
|
||||
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-proxy', {token: 'prx_...'})"
|
||||
# docker compose exec sso-manager node -e "require('@simpleworkjs/bao-conf').set('integrations/theta-jump', {token: 'jmp_...'})"
|
||||
|
||||
# ── Optional outbound HTTP(S) proxy ──────────────────────────────────────────
|
||||
# For an isolated/offline/corporate-network test host that only reaches the
|
||||
# internet through an upstream HTTP proxy — NOT the theta42 "proxy" app.
|
||||
@@ -98,14 +152,20 @@ CFG_DOMAIN=example.com
|
||||
#CFG_THETA_AGENT_FULL_CONTROL=1
|
||||
|
||||
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
|
||||
# If deploying this stack across multiple physical sites to provide local HA
|
||||
# for directory services, you can enable N-Way Multi-Master OpenLDAP replication.
|
||||
# This requires assigning a unique ID to each site and listing the LDAPS URLs
|
||||
# of all OTHER sites in the cluster.
|
||||
# If you're joining a directory cluster (CFG_MASTER_DIRECTORY_URL/spoke.env
|
||||
# above), N-Way Multi-Master OpenLDAP replication is configured for you
|
||||
# automatically -- setup.sh's bootstrap/site-ldap-register.js asks the master
|
||||
# for a unique LDAP_SERVER_ID and the current list of every other site's LDAP
|
||||
# URL on every run (see docs/replication.md), restarting sso-manager only
|
||||
# when that config actually changed. Nothing to set here for the common case.
|
||||
#
|
||||
# Each site MUST have a unique LDAP_SERVER_ID (e.g. 1, 2, 3).
|
||||
# LDAP_REPLICATION_HOSTS is a space-separated list of the other sites' LDAP URLs.
|
||||
# Example for Site 1:
|
||||
# Have a manually-coordinated LDAP MMR topology this script can't derive on
|
||||
# its own (e.g. peers outside this theta-suite cluster)? Set
|
||||
# CFG_LDAP_MMR_MANUAL=true to skip the automatic step entirely and set
|
||||
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS directly -- without this, the
|
||||
# automatic step runs on every deployment (every fresh install starts as a
|
||||
# master) and will overwrite them.
|
||||
#CFG_LDAP_MMR_MANUAL=true
|
||||
#LDAP_SERVER_ID=1
|
||||
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
|
||||
|
||||
@@ -78,10 +78,13 @@ die() { error "$*"; exit 1; }
|
||||
# (stale policies/tokens causing vault 403s). The Redis vault-token cache is
|
||||
# flushed once sso-manager is back up (see the OpenBao bootstrap section).
|
||||
RESET_OPENBAO=0
|
||||
SEED_NODE_SECRET=0
|
||||
SEED_NODE_ARGS=()
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--reset-openbao) RESET_OPENBAO=1 ;;
|
||||
*) warn "unknown argument: $arg (ignored)" ;;
|
||||
--seed-node-secret) SEED_NODE_SECRET=1 ;;
|
||||
*) if [[ "$SEED_NODE_SECRET" == 1 ]]; then SEED_NODE_ARGS+=("$arg"); else warn "unknown argument: $arg (ignored)"; fi ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -197,6 +200,10 @@ fi
|
||||
# later steps can use it. The authoritative CFG_* for secrets are still
|
||||
# resolved in ensure_config; this is only the hostname override.
|
||||
[[ -f ./setup.env ]] && parse_kv_file ./setup.env
|
||||
# spoke.env (optional, see spoke.env.example): the join-a-cluster vars split
|
||||
# out of setup.env for clarity, layered on top so its values win over any
|
||||
# same-named ones in setup.env. Same first-run-only rule as setup.env below.
|
||||
[[ -f ./spoke.env ]] && parse_kv_file ./spoke.env
|
||||
export CFG_JUMP_HOST
|
||||
CFG_CREATE_ALL_HTTP="${CFG_CREATE_ALL_HTTP:-0}"
|
||||
export CFG_CREATE_ALL_HTTP
|
||||
@@ -398,6 +405,12 @@ module.exports = {
|
||||
groupsClaim: 'groups',
|
||||
usernameClaim: 'preferred_username',
|
||||
},
|
||||
// Read-only SSO management API access, used to list directory groups for the
|
||||
// per-host SSO allow-list autocomplete. apiToken is minted by the bootstrap.
|
||||
sso: {
|
||||
url: 'http://sso-manager:3001',
|
||||
apiToken: '',
|
||||
},
|
||||
ldap: {
|
||||
url: 'ldaps://sso-manager:636',
|
||||
bindDN: $(js_str "cn=ldapclient,ou=people,${dn}"),
|
||||
@@ -458,6 +471,10 @@ BAOEOF
|
||||
info "Reading domain/hosts from ./setup.env ..."
|
||||
parse_kv_file ./setup.env
|
||||
fi
|
||||
if [[ -f ./spoke.env ]]; then
|
||||
info "Reading multi-site join config from ./spoke.env ..."
|
||||
parse_kv_file ./spoke.env
|
||||
fi
|
||||
|
||||
# Bind the CFG_* vars to empty where setup.env / the environment didn't set
|
||||
# them, so the .env migration's `${LDAP_X:-$CFG_X}` defaults below don't trip
|
||||
@@ -531,10 +548,26 @@ BAOEOF
|
||||
[[ -n "$CFG_DOMAIN" ]] \
|
||||
|| die "First run: 'cp setup.env.example setup.env', set CFG_DOMAIN to your domain (e.g. example.com), then re-run ./setup.sh"
|
||||
CFG_BASE_DN="${CFG_BASE_DN:-$(dn_from_domain "$CFG_DOMAIN")}"
|
||||
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.$CFG_DOMAIN}"
|
||||
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.$CFG_DOMAIN}"
|
||||
# CFG_PUBLIC_DOMAIN (MULTI_SITE_SPEC.md §4): an inbound spoke's own public
|
||||
# web domain, independent of CFG_DOMAIN. CFG_DOMAIN is the LDAP identity
|
||||
# namespace and MUST be identical across every site (MMR replicas can't
|
||||
# diverge on base DN) -- CFG_PUBLIC_DOMAIN only changes where the web
|
||||
# hostnames point, never the DN. Unset (the default): behaves exactly as
|
||||
# before, hostnames derive from CFG_DOMAIN like any standalone install.
|
||||
CFG_SSO_HOST="${CFG_SSO_HOST:-sso.${CFG_PUBLIC_DOMAIN:-$CFG_DOMAIN}}"
|
||||
CFG_PROXY_HOST="${CFG_PROXY_HOST:-proxy.${CFG_PUBLIC_DOMAIN:-$CFG_DOMAIN}}"
|
||||
CFG_SITE_NAME="${CFG_SITE_NAME:-local}"
|
||||
CFG_ORG="${CFG_ORG:-SSO Manager}"
|
||||
# Multi-site identity (site_config.js's `siteSlug`, shown on the Directory's
|
||||
# Multi-Site modal) -- without this it's never set anywhere and every fresh
|
||||
# master shows the module's own literal fallback, "site-default", forever.
|
||||
# Derived from CFG_SITE_NAME with the same slugify rule bootstrap.js uses
|
||||
# for the site Resource's own slug (site_$(slugify), underscore prefix --
|
||||
# this is hyphenated to match site_config.js's own "site-default" format).
|
||||
# site.json overrides this after first bring-up (join/promote write real
|
||||
# values there), so this only ever matters for a fresh install.
|
||||
SITE_SLUG="site-$(echo "$CFG_SITE_NAME" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+|-+$//g')"
|
||||
export SITE_SLUG
|
||||
CFG_ORG="${CFG_ORG:-Theta Directory}"
|
||||
CFG_ADMIN_UID="${CFG_ADMIN_UID:-admin}"
|
||||
CFG_ADMIN_EMAIL="${CFG_ADMIN_EMAIL:-admin@$CFG_PROXY_HOST}"
|
||||
CFG_LDAP_CERT_CN="${CFG_LDAP_CERT_CN:-}"
|
||||
@@ -872,6 +905,29 @@ seed_app_conf() {
|
||||
|| warn " could not seed secret/${vault_path} (continuing — app will use its file fallback)"
|
||||
}
|
||||
|
||||
# Seed a node-scoped secret for a theta-agent (DESIGN.md §5). Node secrets live
|
||||
# at secret/data/nodes/<agent-id>/* and are read by the agent (via the SSO's
|
||||
# /api/v1/agent/secrets) on behalf of 3rd-party apps on the host. Agent ids are
|
||||
# minted at enrollment, so this is a helper the operator calls per node, not a
|
||||
# boot-time seed:
|
||||
# ./setup.sh --seed-node-secret <agent-id> <name> <key>=<value> [<key>=<value>...]
|
||||
seed_node_conf() {
|
||||
local agent_id="$1" name="$2"; shift 2
|
||||
[[ -n "$agent_id" && -n "$name" ]] || die "seed_node_conf: need <agent-id> <name>"
|
||||
# CLI paths are mount-relative (no "data/" segment -- the CLI inserts that
|
||||
# itself for KV v2, same as seed_app_conf's "secret/${vault_path}" above).
|
||||
# The HTTP API path api_agent_ops.js checks against (secret/data/nodes/...)
|
||||
# is what this resolves to underneath.
|
||||
local path="secret/nodes/${agent_id}/${name}"
|
||||
if bao_run kv get "$path" >/dev/null 2>&1; then
|
||||
info " ${path} already seeded — keeping."
|
||||
return 0
|
||||
fi
|
||||
info "Seeding ${path}..."
|
||||
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put "$path" "$@" >/dev/null \
|
||||
|| die "failed to seed ${path}"
|
||||
}
|
||||
|
||||
info "Configuring OpenBao policies..."
|
||||
# sso-broker — sso's authority to read/write its own conf, mint per-user and
|
||||
# per-app tokens (auth/token/create/sso-broker), and create the matching
|
||||
@@ -889,14 +945,27 @@ path "secret/data/apps/*" { capabilities = ["create", "read", "update", "delete"
|
||||
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/data/plugins/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
|
||||
# The Ed25519 key the SSO signs high-risk theta-agent commands with. It must
|
||||
# persist across restarts: agents pin the matching public key in agent.yml, so
|
||||
# a key that changes on every boot makes signature verification meaningless.
|
||||
path "secret/data/agent/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/agent/*" { capabilities = ["list", "read", "delete"] }
|
||||
# Node-scoped secrets for theta-agent (DESIGN.md §5): each node reads only its
|
||||
# own secret/data/nodes/<agent-id>/* subtree via the SSO's /api/v1/agent/secrets
|
||||
# endpoint. The SSO (sso-broker) must be able to read them on the agent's behalf.
|
||||
path "secret/data/resources/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/resources/*" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/data/nodes/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/nodes/*" { capabilities = ["list", "read", "delete"] }
|
||||
path "auth/token/create/sso-broker" { capabilities = ["update"] }
|
||||
path "auth/token/create/sso-app" { capabilities = ["update"] }
|
||||
path "auth/token/renew-accessor" { capabilities = ["update"] }
|
||||
path "auth/token/revoke-accessor" { capabilities = ["update"] }
|
||||
path "auth/token/lookup-accessor" { capabilities = ["update"] }
|
||||
path "sys/policies/acl/user-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policies/acl/app-*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policies/acl/sso-admin" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policies/acl/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policies/acl" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policy/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "sys/policy" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
HCL
|
||||
# sso-admin — admin users in the vault UI: read/write/list everything under secret/.
|
||||
# The bare `secret/metadata` grant lets an admin LIST the KV mount root (the
|
||||
@@ -969,6 +1038,13 @@ info " policies: sso-broker, sso-admin, proxy, jump-host (+ per-user/app cre
|
||||
info " token roles: sso-broker (user-*/app-*/sso-admin, 24h period), sso-app (app-*, 768h period), theta-svc (service tokens, 768h period)"
|
||||
info " app tokens: SSO_VAULT_TOKEN, PROXY_VAULT_TOKEN, JUMP_VAULT_TOKEN in .env (periodic; renewed by bao-renewer)"
|
||||
|
||||
# --seed-node-secret <agent-id> <name> <key>=<value>... : seed a node-scoped
|
||||
# secret for a theta-agent (DESIGN.md §5). Runs after OpenBao is configured so
|
||||
# the sso-broker policy (which grants secret/data/nodes/*) is in place.
|
||||
if [[ "$SEED_NODE_SECRET" == 1 ]]; then
|
||||
seed_node_conf "${SEED_NODE_ARGS[@]}"
|
||||
fi
|
||||
|
||||
# bao-renewer: renews the three periodic service tokens every 12h so they never
|
||||
# hit their period boundary while the stack is running. Recreated (not just
|
||||
# started) so it always picks up freshly re-minted tokens from .env.
|
||||
@@ -984,6 +1060,15 @@ info "Starting bao-renewer (service-token renewal sidecar)..."
|
||||
SSO_GIT_COMMIT="$(git -C sso-manager-node rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
export SSO_GIT_COMMIT
|
||||
env_upsert SSO_GIT_COMMIT "$SSO_GIT_COMMIT"
|
||||
# OpenLDAP multi-master replication (docs/replication.md, auto-configured --
|
||||
# see step 7e below and bootstrap/site-ldap-register.js): pick up whatever
|
||||
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS a PRIOR run already computed, so a
|
||||
# restart doesn't silently drop back to standalone (no LDAP_SERVER_ID env at
|
||||
# all). A truly fresh install has no file yet -- that's fine, it just starts
|
||||
# standalone until step 7e computes and applies real values. Skipped under
|
||||
# CFG_LDAP_MMR_MANUAL=true so a manually hand-set LDAP_SERVER_ID/
|
||||
# LDAP_REPLICATION_HOSTS in setup.env isn't clobbered by a stale auto file.
|
||||
[[ "${CFG_LDAP_MMR_MANUAL:-false}" != "true" && -f "$CONFIG_DIR/ldap-replication.env" ]] && parse_kv_file "$CONFIG_DIR/ldap-replication.env"
|
||||
info "Building + starting sso-manager (first run builds the image; this takes a while)..."
|
||||
"${COMPOSE[@]}" up -d --build sso-manager
|
||||
|
||||
@@ -1069,7 +1154,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" \
|
||||
@@ -1084,6 +1175,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
|
||||
@@ -1092,6 +1186,26 @@ else
|
||||
info "OAuth client registered + creds written into $CONFIG_DIR/proxy-secrets.js."
|
||||
fi
|
||||
|
||||
# ── 5b. Multi-site: join an existing master directory (first-run only) ────────
|
||||
# setup.env: CFG_MASTER_DIRECTORY_URL + CFG_MASTER_DIRECTORY_JOIN_KEY (mint a
|
||||
# site join key on the master). Only honored on a first-run bring-up: ensure_config
|
||||
# reads setup.env once and ignores it once ./config/ exists, so an already-running
|
||||
# directory can never be merged into a master's. Idempotent — a node that already
|
||||
# joined reports "already a spoke" and setup continues.
|
||||
if [[ -n "${CFG_MASTER_DIRECTORY_URL:-}" && -n "${CFG_MASTER_DIRECTORY_JOIN_KEY:-}" ]]; then
|
||||
info "Joining master site ${CFG_MASTER_DIRECTORY_URL} (CFG_MASTER_DIRECTORY_*)..."
|
||||
# selfUrl (https://$CFG_SSO_HOST, already derived above) registers this
|
||||
# spoke for LIVE replication -- without it the join still succeeds, but
|
||||
# the master has no way to reach this spoke to push resync pings, so it
|
||||
# only ever gets the one-time snapshot from the moment it joined.
|
||||
if ! "${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-join.js \
|
||||
"$CFG_MASTER_DIRECTORY_URL" "$CFG_MASTER_DIRECTORY_JOIN_KEY" "https://$CFG_SSO_HOST"; then
|
||||
die "site join failed — check the master URL + site join key (mint one on the master's Site Join Keys card)."
|
||||
fi
|
||||
else
|
||||
info "No CFG_MASTER_DIRECTORY_URL/CFG_MASTER_DIRECTORY_JOIN_KEY — running as a fresh master site."
|
||||
fi
|
||||
|
||||
# ── 6. Start the proxy, wait for health ───────────────────────────────────────
|
||||
# PROXY_GIT_COMMIT: same reasoning as SSO_GIT_COMMIT above.
|
||||
PROXY_GIT_COMMIT="$(git -C proxy rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||
@@ -1198,6 +1312,59 @@ NODEEOF
|
||||
)
|
||||
echo "$JUMP_HOSTS_OUT" | sed 's/^/[setup] /'
|
||||
|
||||
# ── 7b2. No-inbound relay registration (first-run *and* every re-run) ─────────
|
||||
# CFG_SPOKE_NO_INBOUND: this site has no public IP, so the master relays to it
|
||||
# over the gateway-to-gateway WireGuard mesh (MULTI_SITE_SPEC.md §5.2). The
|
||||
# mesh peering itself is a manual, out-of-band step on both jump-hosts (mint a
|
||||
# join token on the master's jump-host, paste it into this site's jump-host
|
||||
# "Join a mesh" UI action) -- it can't run unattended here, and it commonly
|
||||
# happens AFTER this first setup.sh run finishes. So this step runs on every
|
||||
# invocation, not just first-run: it discovers this jump-host's mesh IP and
|
||||
# (re-)registers it with the master, and is a no-op until meshing is done.
|
||||
if [[ "${CFG_SPOKE_NO_INBOUND:-false}" == "true" ]]; then
|
||||
if [[ -z "${CFG_SPOKE_PUBLIC_HOST:-}" ]]; then
|
||||
warn "CFG_SPOKE_NO_INBOUND=true but CFG_SPOKE_PUBLIC_HOST is unset — skipping relay registration."
|
||||
else
|
||||
info "Checking no-inbound relay registration (CFG_SPOKE_PUBLIC_HOST=${CFG_SPOKE_PUBLIC_HOST})..."
|
||||
"${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-relay-register.js \
|
||||
"https://$CFG_SSO_HOST" "$CFG_SPOKE_PUBLIC_HOST" || warn "relay registration did not complete — check: ${COMPOSE[*]} exec sso-manager node /bootstrap/site-relay-register.js https://$CFG_SSO_HOST $CFG_SPOKE_PUBLIC_HOST"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── 7e. OpenLDAP multi-master replication auto-config (every run) ────────────
|
||||
# docs/replication.md: the master assigns each spoke a unique LDAP_SERVER_ID
|
||||
# and derives every site's LDAP URL automatically (bootstrap/
|
||||
# site-ldap-register.js) instead of an operator hand-maintaining
|
||||
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS. Runs on every invocation -- both
|
||||
# master (its peer list grows as spokes join) and spoke -- and restarts
|
||||
# sso-manager only when the computed config actually changed, since
|
||||
# OpenLDAP's static slapd.conf is only read at process start.
|
||||
#
|
||||
# CFG_LDAP_MMR_MANUAL=true skips this entirely -- every fresh install starts
|
||||
# as a master, so without this escape hatch an operator's own hand-set
|
||||
# LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS (a topology outside this theta-suite
|
||||
# cluster this script can't derive) would get silently overwritten.
|
||||
if [[ "${CFG_LDAP_MMR_MANUAL:-false}" == "true" ]]; then
|
||||
info "CFG_LDAP_MMR_MANUAL=true — skipping automatic LDAP replication config."
|
||||
LDAP_REG_OUT=""
|
||||
else
|
||||
LDAP_REG_OUT=$("${COMPOSE[@]}" exec -T sso-manager node /bootstrap/site-ldap-register.js "https://$CFG_SSO_HOST" 2>&1) || warn "LDAP replication config check failed — check: ${COMPOSE[*]} exec sso-manager node /bootstrap/site-ldap-register.js https://$CFG_SSO_HOST"
|
||||
echo "$LDAP_REG_OUT" | sed 's/^/[setup] /'
|
||||
fi
|
||||
if echo "$LDAP_REG_OUT" | grep -q '^LDAP_CONFIG_CHANGED=yes'; then
|
||||
info "LDAP replication config changed — restarting sso-manager to apply it..."
|
||||
[[ -f "$CONFIG_DIR/ldap-replication.env" ]] && parse_kv_file "$CONFIG_DIR/ldap-replication.env"
|
||||
"${COMPOSE[@]}" up -d --force-recreate sso-manager
|
||||
info "Waiting for sso-manager to be healthy again..."
|
||||
for i in $(seq 1 60); do
|
||||
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
|
||||
info "sso-manager is healthy."; break
|
||||
fi
|
||||
if (( i == 60 )); then warn "sso-manager did not become healthy in 120s after the LDAP config restart. Check: ${COMPOSE[*]} logs sso-manager"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
fi
|
||||
|
||||
# ── 7c. Install theta-agent on the host ──────────────────────────────────────
|
||||
# Controlled by CFG_THETA_AGENT_ENABLE (default: 1 = enabled)
|
||||
CFG_THETA_AGENT_ENABLE="${CFG_THETA_AGENT_ENABLE:-1}"
|
||||
@@ -1205,34 +1372,111 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
||||
info "Setting up theta-agent on the host..."
|
||||
(
|
||||
cd theta-agent || exit 0
|
||||
# Install the prebuilt binary that ships in the theta-agent submodule (the
|
||||
# repo's own install.sh uses the same release binary). We do NOT build from
|
||||
# source here: a previous `go build -o theta-agent main.go websocket.go
|
||||
# config.go` omitted executor.go/telemetry.go, failed to compile, and was
|
||||
# silently skipped, so the agent was never installed.
|
||||
if [[ ! -f "theta-agent-linux-amd64" ]]; then
|
||||
warn "Prebuilt theta-agent-linux-amd64 missing from the theta-agent submodule. Skipping theta-agent installation."
|
||||
# Download the current release binary from GitHub rather than trusting a
|
||||
# binary committed in the submodule checkout. A committed binary drifts:
|
||||
# theta-agent's own `theta-agent update` moved to pulling from GitHub
|
||||
# Releases (DESIGN-WINDOWS.md §9, "nothing binary lives in the repos")
|
||||
# once, but this script kept installing the stale binary that shipped
|
||||
# with an old submodule pin, which still pointed `update` at a dead
|
||||
# SSO /resources/ URL that never existed server-side -- so an agent
|
||||
# installed this way could never even self-update out of the bug. We
|
||||
# do NOT build from source here either: a previous `go build -o
|
||||
# theta-agent main.go websocket.go config.go` omitted
|
||||
# executor.go/telemetry.go, failed to compile, and was silently
|
||||
# skipped, so the agent was never installed.
|
||||
AGENT_BIN_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||
AGENT_BIN_TMP="$(mktemp)"
|
||||
info " Downloading latest theta-agent-linux-amd64 release binary..."
|
||||
if ! curl -fsSL -o "$AGENT_BIN_TMP" "$AGENT_BIN_URL" || [[ ! -s "$AGENT_BIN_TMP" ]]; then
|
||||
rm -f "$AGENT_BIN_TMP"
|
||||
warn "Could not download theta-agent-linux-amd64 from $AGENT_BIN_URL. Skipping theta-agent installation."
|
||||
else
|
||||
info " Installing prebuilt theta-agent binary..."
|
||||
if [[ -x "theta-agent-linux-amd64" ]]; then
|
||||
chmod +x "$AGENT_BIN_TMP"
|
||||
info " Installing theta-agent binary..."
|
||||
if true; then
|
||||
# The agent binary reads /etc/theta42/agent.yml (theta-agent/main.go).
|
||||
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
|
||||
# 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
|
||||
# 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
|
||||
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
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.
|
||||
# Without this, agent.yml keeps agent.yml.example's literal
|
||||
# "https://sso.example.com" placeholder forever -- nothing
|
||||
# else in this block ever touched server_url, only join_key.
|
||||
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
||||
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
||||
sudo getent group theta-secrets >/dev/null 2>&1 || sudo groupadd -r theta-secrets 2>/dev/null || true
|
||||
sudo getent group theta >/dev/null 2>&1 || sudo groupadd -r theta 2>/dev/null || true
|
||||
SECRETS_GRP="root"
|
||||
if getent group theta-secrets >/dev/null 2>&1; then SECRETS_GRP="theta-secrets"; elif getent group theta >/dev/null 2>&1; then SECRETS_GRP="theta"; fi
|
||||
sudo chown -R "root:$SECRETS_GRP" /etc/theta42 2>/dev/null || true
|
||||
sudo chmod 750 /etc/theta42
|
||||
sudo chmod 640 /etc/theta42/agent.yml
|
||||
else
|
||||
# Self-heal an already-installed agent.yml that predates the
|
||||
# server_url fix above -- it would otherwise keep whatever
|
||||
# placeholder/stale host it was first installed with
|
||||
# forever, since nothing else in this script ever revisits
|
||||
# an existing agent.yml. Never touches join_key/auth_token:
|
||||
# those may since have been rewritten by the agent itself
|
||||
# with real issued credentials.
|
||||
AGENT_SCHEME="https"; [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]] && AGENT_SCHEME="http"
|
||||
if sudo grep -q '^server_url:' /etc/theta42/agent.yml; then
|
||||
sudo sed -i "s|^server_url:.*|server_url: \"${AGENT_SCHEME}://${CFG_SSO_HOST}\"|" /etc/theta42/agent.yml
|
||||
fi
|
||||
fi
|
||||
# Stop a running agent before overwriting its binary (cp into a
|
||||
# running executable fails with "Text file busy" on a re-install).
|
||||
sudo systemctl stop theta-agent.service 2>/dev/null || true
|
||||
sudo cp theta-agent-linux-amd64 /usr/local/bin/theta-agent
|
||||
sudo cp "$AGENT_BIN_TMP" /usr/local/bin/theta-agent
|
||||
sudo chmod +x /usr/local/bin/theta-agent
|
||||
rm -f "$AGENT_BIN_TMP"
|
||||
|
||||
# Install desktop tray companion if available
|
||||
TRAY_SRC="dist/theta-agent-tray-linux-amd64"
|
||||
if [[ ! -f "$TRAY_SRC" ]] && [[ -f "theta-agent-tray-linux-amd64" ]]; then TRAY_SRC="theta-agent-tray-linux-amd64"; fi
|
||||
if [[ -f "$TRAY_SRC" ]]; then
|
||||
sudo cp "$TRAY_SRC" /usr/local/bin/theta-agent-tray
|
||||
sudo chmod +x /usr/local/bin/theta-agent-tray
|
||||
sudo mkdir -p /etc/xdg/autostart
|
||||
sudo bash -c "cat <<'EOF' > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Theta Agent Tray
|
||||
Comment=Theta Agent Desktop Tray Companion
|
||||
Exec=/usr/local/bin/theta-agent-tray
|
||||
Icon=network-workgroup
|
||||
Terminal=false
|
||||
Categories=Utility;System;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF"
|
||||
info " theta-agent-tray companion installed."
|
||||
fi
|
||||
|
||||
sudo bash -c "cat <<'EOF' > /etc/systemd/system/theta-agent.service
|
||||
[Unit]
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# spoke.env — join this stack to an existing Theta Directory as a read-only
|
||||
# spoke, instead of seeding a fresh master (MULTI_SITE_SPEC.md).
|
||||
#
|
||||
# This is the ONE place the join-a-cluster vars live -- split out of
|
||||
# setup.env.example (which still has every option, including these, for a
|
||||
# single-file bring-up) purely for clarity: standing up a spoke is a distinct
|
||||
# operation from configuring a fresh install, so it gets its own small file
|
||||
# instead of being buried among unrelated options. Set what you need here;
|
||||
# everything else (domain, admin creds, SMTP, ...) still comes from setup.env
|
||||
# as normal -- copy setup.env.example too and fill in CFG_DOMAIN there first.
|
||||
#
|
||||
# Same first-run-only rule as setup.env: read once (layered on top of
|
||||
# setup.env, so a var set in both places takes this file's value), then
|
||||
# ignored once ./config/ exists -- an already-running directory can never be
|
||||
# merged into a master's this way. The one exception is the no-inbound relay
|
||||
# vars at the bottom, which setup.sh re-checks on every run (see their
|
||||
# comment) since mesh peering usually finishes after the first bring-up.
|
||||
#
|
||||
# cp setup.env.example setup.env # if you haven't already -- set CFG_DOMAIN
|
||||
# cp spoke.env.example spoke.env
|
||||
# $EDITOR spoke.env # set CFG_MASTER_DIRECTORY_URL + _JOIN_KEY below
|
||||
# ./setup.sh
|
||||
#
|
||||
# Copying this file to spoke.env (gitignored) keeps your join key out of git.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
# The master's URL and a site join key. Mint a key on the master:
|
||||
# Directory -> the Master Site modal -> Site Join Keys -> Mint key.
|
||||
# Both required to join; if either is unset this stack seeds a fresh master
|
||||
# instead (setup.env.example's normal behavior).
|
||||
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
|
||||
# This spoke's own public web domain, if it needs one independent of the
|
||||
# master's (an inbound spoke serving its own traffic directly -- see
|
||||
# CFG_SPOKE_NO_INBOUND below for the opposite case). Optional: CFG_DOMAIN
|
||||
# (in setup.env) is the shared LDAP identity namespace and must be identical
|
||||
# across every site in the cluster -- this only changes where THIS site's own
|
||||
# web hostnames (sso.*, proxy.*) point, never the LDAP base DN.
|
||||
#CFG_PUBLIC_DOMAIN=branch2.example.com
|
||||
|
||||
# No public IP at all (CGNAT, etc.)? The master can still reach this spoke by
|
||||
# relaying over the gateway-to-gateway WireGuard mesh instead of the open
|
||||
# internet (MULTI_SITE_SPEC.md §5.2) -- but the mesh peering itself is a
|
||||
# manual, out-of-band step on BOTH jump-hosts (mint a mesh join token on the
|
||||
# master's jump-host, paste it into this site's jump-host "Join a mesh" UI
|
||||
# action) that can't run unattended inside this script. Once that's done, set
|
||||
# these two and re-run setup.sh: it discovers this jump-host's assigned mesh
|
||||
# IP and registers it with the master, which then auto-creates the relay
|
||||
# route on its own theta-proxy. Safe to leave set before meshing -- setup.sh
|
||||
# just reports "not meshed yet" and skips until a later re-run finds the IP.
|
||||
#CFG_SPOKE_NO_INBOUND=true
|
||||
#CFG_SPOKE_PUBLIC_HOST=sso-branch2.master-domain.example.com
|
||||
@@ -1,44 +1,227 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
#!/usr/bin/env bash
|
||||
# test-integration.sh — Full Docker integration test for theta-suite.
|
||||
#
|
||||
# Starts the sso-manager container in test mode (no secrets.js required),
|
||||
# seeds an LDAP test user, runs the full jest suite inside the container,
|
||||
# then tears everything down.
|
||||
#
|
||||
# Usage:
|
||||
# ./test-integration.sh # run all tests
|
||||
# ./test-integration.sh --no-build # skip docker build (reuse existing image)
|
||||
# ./test-integration.sh --keep # leave containers up after tests (for debugging)
|
||||
|
||||
echo "=== Starting theta-suite Integration Tests ==="
|
||||
set -euo pipefail
|
||||
|
||||
echo "=> Cleaning up any existing containers and volumes..."
|
||||
docker-compose down -v
|
||||
# ── Colours ──────────────────────────────────────────────────────────────────
|
||||
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m'
|
||||
info() { echo -e "${CYAN}[test]${NC} $*"; }
|
||||
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
|
||||
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
|
||||
fail() { echo -e "${RED}[✗]${NC} $*"; exit 1; }
|
||||
|
||||
echo "=> Running setup.sh to initialize environment..."
|
||||
# Run setup non-interactively if possible (we might need to export some env vars)
|
||||
# setup.sh uses dialog, which requires a terminal, but it falls back to defaults if not interactive?
|
||||
# Actually setup.sh has a dialog UI. Let's just run it or provide a seeded config.
|
||||
# If setup.sh is strictly interactive, we might need to bypass it or provide answers.
|
||||
# Let's try running docker-compose up directly if setup.sh is too interactive, but the user explicitly said "Make sure setup.sh like your change, then do a full release. Make sure each repo has a current change log, is pushed and and merged." and "Automated testing in theta-suite to test integration between all the include projects".
|
||||
# ── Options ───────────────────────────────────────────────────────────────────
|
||||
NO_BUILD=0; KEEP=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-build) NO_BUILD=1 ;;
|
||||
--keep) KEEP=1 ;;
|
||||
--help|-h) echo "Usage: $0 [--no-build] [--keep]"; exit 0 ;;
|
||||
*) warn "Unknown option: $arg" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Wait, setup.sh has no silent mode out of the box unless we provide answers.
|
||||
echo "=> Initializing OpenBao manually for tests (simulating setup.sh)"
|
||||
# Actually, setup.sh initializes Vault. If we don't run it, Vault is sealed!
|
||||
# Let's just write a curl test that checks if the containers start.
|
||||
# ── Test environment config (self-contained, no secrets.js needed) ────────────
|
||||
export COMPOSE_PROJECT_NAME="theta-test"
|
||||
TEST_CONTAINER="theta-test-sso-manager-1"
|
||||
|
||||
docker-compose up -d
|
||||
LDAP_BASE_DN="dc=test,dc=local"
|
||||
LDAP_ADMIN_PASS="testadminpass"
|
||||
TEST_UID="test"
|
||||
TEST_PASSWORD="MyTestPassword!2" # must match tests/setup.js TEST_CREDS
|
||||
|
||||
echo "=> Waiting for services to become healthy..."
|
||||
sleep 15 # Give time for containers to spin up
|
||||
# ── Cleanup on exit ───────────────────────────────────────────────────────────
|
||||
cleanup() {
|
||||
local exit_code=$?
|
||||
if [[ "$KEEP" == "1" ]]; then
|
||||
warn "Leaving containers up (--keep). Tear down with: docker compose -p theta-test down -v"
|
||||
else
|
||||
info "Tearing down test stack..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml down -v --remove-orphans 2>/dev/null || true
|
||||
fi
|
||||
exit $exit_code
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# Test proxy
|
||||
echo "=> Testing Proxy..."
|
||||
if ! curl -sS -o /dev/null -w "%{http_code}" http://localhost | grep -q "406"; then
|
||||
echo "❌ Proxy failed to respond with 406 Not Acceptable on port 80 (default behavior)"
|
||||
exit 1
|
||||
# ── Write a minimal test compose override ────────────────────────────────────
|
||||
info "Writing docker-compose.test.yml..."
|
||||
cat > docker-compose.test.yml <<'COMPOSEEOF'
|
||||
# Minimal test stack: sso-manager only (no proxy, no openbao, no jump-host).
|
||||
# Uses env-mode config — no secrets.js or openbao token required.
|
||||
services:
|
||||
sso-manager:
|
||||
build:
|
||||
context: ./sso-manager-node
|
||||
dockerfile: Dockerfile.openldap
|
||||
target: ""
|
||||
container_name: theta-test-sso-manager
|
||||
restart: "no"
|
||||
networks: [theta-test-net]
|
||||
environment:
|
||||
- NODE_ENV=test
|
||||
- NODE_PORT=3001
|
||||
- LDAP_BASE_DN=dc=test,dc=local
|
||||
- LDAP_ADMIN_PASS=testadminpass
|
||||
- ORG_NAME=Test Org
|
||||
- LDAP_DOMAIN=test.local
|
||||
# Inline JWT secret for tests (no secrets.js or bao needed)
|
||||
- app_oauth__jwtSecret=test-integration-jwt-secret-theta42
|
||||
ports:
|
||||
- "13001:3001"
|
||||
- "10389:389"
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3001/health"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 24
|
||||
start_period: 30s
|
||||
networks:
|
||||
theta-test-net:
|
||||
driver: bridge
|
||||
COMPOSEEOF
|
||||
|
||||
# ── Build ─────────────────────────────────────────────────────────────────────
|
||||
if [[ "$NO_BUILD" == "0" ]]; then
|
||||
info "Building sso-manager test image..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml build sso-manager
|
||||
ok "Image built"
|
||||
else
|
||||
warn "Skipping build (--no-build)"
|
||||
fi
|
||||
echo "✅ Proxy responds on port 80"
|
||||
|
||||
# Test SSO Manager Node
|
||||
echo "=> Testing SSO Manager..."
|
||||
if ! curl -sS -f -o /dev/null http://localhost:3001; then
|
||||
echo "❌ SSO Manager failed to respond on port 3001"
|
||||
exit 1
|
||||
# ── Start ─────────────────────────────────────────────────────────────────────
|
||||
info "Starting sso-manager container..."
|
||||
docker compose -p theta-test -f docker-compose.test.yml up -d sso-manager
|
||||
|
||||
# ── Wait for healthy ──────────────────────────────────────────────────────────
|
||||
info "Waiting for sso-manager to become healthy (up to 120s)..."
|
||||
for i in $(seq 1 120); do
|
||||
STATUS=$(docker inspect --format='{{.State.Health.Status}}' theta-test-sso-manager 2>/dev/null || echo "missing")
|
||||
if [[ "$STATUS" == "healthy" ]]; then
|
||||
ok "sso-manager is healthy"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 120 ]]; then
|
||||
warn "Container never became healthy. Logs:"
|
||||
docker logs theta-test-sso-manager --tail 60
|
||||
fail "sso-manager failed to become healthy after 120s"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Wait for LDAP ─────────────────────────────────────────────────────────────
|
||||
info "Waiting for LDAP on port 10389..."
|
||||
for i in $(seq 1 30); do
|
||||
if ldapsearch -x -H ldap://localhost:10389 -b "" -s base "(objectClass=*)" >/dev/null 2>&1; then
|
||||
ok "LDAP is ready"
|
||||
break
|
||||
fi
|
||||
if [[ $i -eq 30 ]]; then
|
||||
fail "LDAP did not become reachable on localhost:10389 after 30s"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# ── Seed test user ────────────────────────────────────────────────────────────
|
||||
info "Seeding test LDAP user via seed-test-user.sh..."
|
||||
|
||||
docker cp sso-manager-node/test/seed-test-user.sh theta-test-sso-manager:/tmp/seed-test-user.sh
|
||||
docker exec \
|
||||
-e LDAP_HOST=localhost \
|
||||
-e LDAP_PORT=389 \
|
||||
-e BIND_DN="cn=admin,${LDAP_BASE_DN}" \
|
||||
-e BIND_PW="${LDAP_ADMIN_PASS}" \
|
||||
-e BASE_DN="${LDAP_BASE_DN}" \
|
||||
theta-test-sso-manager \
|
||||
sh /tmp/seed-test-user.sh
|
||||
|
||||
ok "Test user seeded"
|
||||
|
||||
# ── Install dev deps (jest) inside the running container ──────────────────────
|
||||
info "Installing test dependencies (jest) inside container..."
|
||||
docker exec theta-test-sso-manager sh -c "
|
||||
cd /app &&
|
||||
if ! command -v jest >/dev/null 2>&1 && [ ! -f node_modules/.bin/jest ]; then
|
||||
npm install --save-dev jest@latest supertest@latest --silent 2>&1 | tail -3
|
||||
else
|
||||
echo 'jest already installed'
|
||||
fi
|
||||
"
|
||||
ok "Test deps ready"
|
||||
|
||||
# ── Copy test files into container ────────────────────────────────────────────
|
||||
info "Copying tests into container..."
|
||||
docker cp sso-manager-node/nodejs/tests/. theta-test-sso-manager:/app/tests/
|
||||
|
||||
ok "Tests copied"
|
||||
|
||||
# ── Run jest ──────────────────────────────────────────────────────────────────
|
||||
info "Running full jest test suite inside container..."
|
||||
echo ""
|
||||
|
||||
# These app_* vars are set by the entrypoint for the main process but NOT
|
||||
# inherited by docker exec subprocesses. Pass them explicitly so the jest
|
||||
# process loads app.js with the correct LDAP connection details.
|
||||
docker exec \
|
||||
-e NODE_ENV=test \
|
||||
-e REDIS_URL="redis://127.0.0.1:6379" \
|
||||
-e app_oauth__jwtSecret="test-integration-jwt-secret-theta42" \
|
||||
-e app_ldap__url="ldap://localhost:389" \
|
||||
-e app_ldap__bindDN="cn=admin,${LDAP_BASE_DN}" \
|
||||
-e app_ldap__bindPassword="${LDAP_ADMIN_PASS}" \
|
||||
-e app_ldap__userBase="ou=people,${LDAP_BASE_DN}" \
|
||||
-e app_ldap__groupBase="ou=groups,${LDAP_BASE_DN}" \
|
||||
theta-test-sso-manager \
|
||||
sh -c "
|
||||
cd /app
|
||||
# Write test conf with full LDAP connection details so jest workers get
|
||||
# the correct config without needing to inherit docker exec env vars.
|
||||
# app_* env vars are only applied at conf-module require time, but jest
|
||||
# workers may not reliably inherit them across all parallelism models.
|
||||
cat > /app/conf/test.js << CONFEOF
|
||||
'use strict';
|
||||
module.exports = {
|
||||
redis: { prefix: 'sso_manager_test_' },
|
||||
oauth: { jwtSecret: 'test-integration-jwt-secret-theta42' },
|
||||
ldap: {
|
||||
url: 'ldap://localhost:389',
|
||||
bindDN: 'cn=admin,${LDAP_BASE_DN}',
|
||||
bindPassword: '${LDAP_ADMIN_PASS}',
|
||||
userBase: 'ou=people,${LDAP_BASE_DN}',
|
||||
groupBase: 'ou=groups,${LDAP_BASE_DN}'
|
||||
}
|
||||
};
|
||||
CONFEOF
|
||||
echo 'conf/test.js written'
|
||||
REDIS_URL='redis://127.0.0.1:6379' node_modules/.bin/jest --forceExit --passWithNoTests 2>&1
|
||||
"
|
||||
JEST_EXIT=$?
|
||||
|
||||
echo ""
|
||||
if [[ $JEST_EXIT -eq 0 ]]; then
|
||||
ok "All jest tests passed!"
|
||||
else
|
||||
fail "Some jest tests failed (exit code $JEST_EXIT)"
|
||||
fi
|
||||
echo "✅ SSO Manager responds on port 3001"
|
||||
|
||||
echo "=== All integration tests passed! ==="
|
||||
docker-compose down -v
|
||||
exit 0
|
||||
# ── Theta-agent Go tests (host-side, no docker needed) ───────────────────────
|
||||
if command -v go >/dev/null 2>&1 && [[ -d theta-agent ]]; then
|
||||
info "Running theta-agent Go tests..."
|
||||
(cd theta-agent && go test ./... -count=1 2>&1)
|
||||
ok "Theta-agent Go tests passed"
|
||||
else
|
||||
warn "Skipping theta-agent Go tests (go not found or theta-agent dir missing)"
|
||||
fi
|
||||
|
||||
ok "All integration tests complete!"
|
||||
|
||||