Compare commits
93 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 |
@@ -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,418 @@ 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**.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -143,7 +137,10 @@ Optional extra ports (only if you need them):
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -273,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
|
||||
@@ -473,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
|
||||
|
||||
@@ -468,6 +468,18 @@ async function dirPut(token, path, body) {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function dirDelete(token, path) {
|
||||
const res = await fetch(`${SSO_INTERNAL}/api/directory-admin/${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'auth-token': token },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`DELETE /api/directory-admin/${path} failed (${res.status}): ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// The site the stack registers itself under. Also the default "Location
|
||||
// (Site)" that ldap-client-joined Linux hosts attach to (parent slug
|
||||
// site_<name> — see ldap-client/index.sh), so the slugs must line up.
|
||||
@@ -561,29 +573,23 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
managed: true,
|
||||
}, ['stack-host']);
|
||||
|
||||
// theta-proxy and theta-jump are first-class managed host resources (their
|
||||
// names match the OAuth client identities the proxy/jump apps use). They
|
||||
// appear as hosts in the Directory; the per-app services below still carry
|
||||
// the OAuth-client + reachability detail.
|
||||
const jumpHostAddr = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
const proxyHostRes = await ensure('host', 'theta-proxy', 'host_theta-proxy', site.id, {
|
||||
subType: 'linux',
|
||||
address: `https://${PROXY_HOST}`,
|
||||
port: 3000,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
icon: 'mdi:server-network',
|
||||
tagline: 'Reverse proxy and API gateway (node management UI).',
|
||||
managed: true,
|
||||
});
|
||||
const jumpHostRes = await ensure('host', 'theta-jump', 'host_theta-jump', site.id, {
|
||||
subType: 'ssh',
|
||||
address: jumpHostAddr ? `https://${jumpHostAddr}` : '',
|
||||
port: 3002,
|
||||
gitRepo: 'https://github.com/theta42/jump-host',
|
||||
icon: 'mdi:ssh',
|
||||
tagline: 'Secure SSH jump host.',
|
||||
managed: true,
|
||||
});
|
||||
// "Host" means a real, independently-existing machine — something with its
|
||||
// own OS and sshd, that theta-agent or a directory-aware tool like the jump
|
||||
// host could actually reach on its own. A Docker container backing one of
|
||||
// this stack's own services is never that, no matter how convenient it'd be
|
||||
// to group its services under a host-shaped node in the UI: it has no sshd,
|
||||
// no independent network identity, nothing jump-host could honestly offer
|
||||
// as an SSH target. Proxy and jump-host are two of this stack's five
|
||||
// containers, running on the one real host above (`host`) — not machines of
|
||||
// their own. Briefly (2026-08-05 through the next release) this file seeded
|
||||
// `host_theta-proxy` / `host_theta-jump` as first-class `kind: 'host'`
|
||||
// resources to fix their services being parented to the stack host; that
|
||||
// solved the parenting problem with the wrong tool. The right tool already
|
||||
// existed: `kind: 'container'` (see seedPlugins' Docker discovery, which
|
||||
// already attaches `docker-theta-suite-proxy` etc. under these services
|
||||
// correctly) sits one layer below `service`, same as `sso-manager` and
|
||||
// `openbao` already do. So: no synthetic hosts — Proxy's and jump-host's
|
||||
// services parent directly onto the stack host, same as everything else.
|
||||
|
||||
await ensure('service', 'SSO Manager', 'sso-manager', host.id, {
|
||||
address: `https://${SSO_HOST}`,
|
||||
@@ -595,12 +601,9 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
requestable: false,
|
||||
});
|
||||
// Proxy = the node management UI; OpenResty = the data plane every hostname
|
||||
// in the stack actually flows through (80/443). Two faces, two entries.
|
||||
// Both parent to host_theta-proxy, not to the stack host: the whole point of
|
||||
// seeding that host resource is that the proxy's services hang off it. Seeded
|
||||
// under the stack host until 2026-08-05, which left host_theta-proxy and
|
||||
// host_theta-jump childless while their services sat under the wrong parent.
|
||||
const psvc = await ensure('service', 'Proxy', 'proxy', proxyHostRes.id, {
|
||||
// in the stack actually flows through (80/443). Two faces, two entries, both
|
||||
// parented directly to the stack host — see the "Host means..." note above.
|
||||
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
|
||||
address: `https://${PROXY_HOST}`,
|
||||
port: 3000,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
@@ -629,7 +632,7 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
// Wildcard address: OpenResty fronts every host under the domain (same
|
||||
// */** wildcard convention the proxy's Host records use). Its config lives
|
||||
// in the proxy repo (ops/nginx_conf).
|
||||
await ensure('service', 'OpenResty Edge', 'openresty', proxyHostRes.id, {
|
||||
await ensure('service', 'OpenResty Edge', 'openresty', host.id, {
|
||||
address: DOMAIN ? `https://*.${DOMAIN}` : `https://${PROXY_HOST}`,
|
||||
port: 443,
|
||||
gitRepo: 'https://github.com/theta42/proxy',
|
||||
@@ -639,30 +642,19 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
requestable: false,
|
||||
});
|
||||
|
||||
// OpenBao and its renewer sidecar are part of what the stack deploys, so
|
||||
// they belong in the directory like every other component. Without entries
|
||||
// their containers had nowhere to attach and showed up as parentless
|
||||
// discoveries on a fresh install.
|
||||
await ensure('service', 'OpenBao', 'openbao', host.id, {
|
||||
address: 'http://openbao:8200',
|
||||
port: 8200,
|
||||
subType: 'vault',
|
||||
icon: 'mdi:safe',
|
||||
tagline: 'Secrets store for the stack.',
|
||||
requestable: false,
|
||||
});
|
||||
await ensure('service', 'Bao Renewer', 'bao-renewer', host.id, {
|
||||
subType: 'sidecar',
|
||||
icon: 'mdi:autorenew',
|
||||
tagline: 'Renews the stack service tokens against OpenBao.',
|
||||
requestable: false,
|
||||
});
|
||||
// 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;
|
||||
{
|
||||
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
|
||||
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', jumpHostRes.id, {
|
||||
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
|
||||
address: jumpHost ? `https://${jumpHost}` : '',
|
||||
port: 3002,
|
||||
gitRepo: 'https://github.com/theta42/jump-host',
|
||||
@@ -673,11 +665,39 @@ async function seedDirectory(token, clientId, jumpClientId) {
|
||||
});
|
||||
}
|
||||
|
||||
// Correct installs seeded before 2026-08-05, where these three services were
|
||||
// parented to the stack host rather than to the proxy/jump host resources.
|
||||
await reparent(psvc, proxyHostRes.id, host.id);
|
||||
await reparent(resources.find((r) => r.slug === 'openresty'), proxyHostRes.id, host.id);
|
||||
await reparent(jumpSvc, jumpHostRes.id, host.id);
|
||||
// Correct installs seeded between 2026-08-05 and this release, where Proxy's
|
||||
// and jump-host's services were parented to now-removed synthetic
|
||||
// `host_theta-proxy` / `host_theta-jump` resources instead of the stack
|
||||
// host. Look them up by slug (never created going forward) rather than
|
||||
// `ensure`-ing them back into existence: on any install that never had
|
||||
// them, or already got corrected, this is a no-op.
|
||||
const proxyHostRes = resources.find((r) => r.slug === 'host_theta-proxy');
|
||||
const jumpHostRes = resources.find((r) => r.slug === 'host_theta-jump');
|
||||
if (proxyHostRes) {
|
||||
await reparent(psvc, host.id, proxyHostRes.id);
|
||||
await reparent(resources.find((r) => r.slug === 'openresty'), host.id, proxyHostRes.id);
|
||||
}
|
||||
if (jumpHostRes) {
|
||||
await reparent(jumpSvc, host.id, jumpHostRes.id);
|
||||
}
|
||||
|
||||
// Once childless, the synthetic host itself is dead weight from this file's
|
||||
// own earlier mistake — never something an operator would hand-create at
|
||||
// these exact reserved slugs — so remove it. DELETE /resources/:id clears
|
||||
// its own edges first, so this is safe now that the reparents above have
|
||||
// already moved the real children off of it.
|
||||
async function removeIfChildless(resource, label) {
|
||||
if (!resource) return;
|
||||
const stillHasChildren = edges.some((e) => e.parentId === resource.id);
|
||||
if (stillHasChildren) {
|
||||
log(` directory: '${label}' still has children after reparenting — leaving it for now`);
|
||||
return;
|
||||
}
|
||||
await dirDelete(token, `resources/${resource.id}`);
|
||||
log(` directory: removed now-empty synthetic host '${label}'`);
|
||||
}
|
||||
await removeIfChildless(proxyHostRes, 'host_theta-proxy');
|
||||
await removeIfChildless(jumpHostRes, 'host_theta-jump');
|
||||
|
||||
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
|
||||
// owning service, if it appears in the directory and isn't linked yet.
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -71,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
|
||||
|
||||
@@ -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/*`, `secret/agent/*`; `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-*`,
|
||||
|
||||
@@ -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
|
||||
@@ -464,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
|
||||
@@ -537,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:-}"
|
||||
@@ -878,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
|
||||
@@ -900,14 +950,22 @@ path "secret/metadata/plugins/*" { capabilities = ["list", "read", "delete"] }
|
||||
# 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
|
||||
@@ -980,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.
|
||||
@@ -995,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
|
||||
|
||||
@@ -1112,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)"
|
||||
@@ -1218,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}"
|
||||
@@ -1225,16 +1372,28 @@ 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
|
||||
@@ -1265,19 +1424,59 @@ if [[ "$CFG_THETA_AGENT_ENABLE" == "1" ]]; then
|
||||
else
|
||||
warn "No agent join key available — /etc/theta42/agent.yml has no credential and the agent will not connect."
|
||||
fi
|
||||
# We want to connect to either https or http depending on CFG_CREATE_ALL_HTTP
|
||||
if [[ "${CFG_CREATE_ALL_HTTP:-0}" == "1" ]]; then
|
||||
sudo sed -i "s|https://sso.example.com|http://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
else
|
||||
sudo sed -i "s|https://sso.example.com|https://${SSO_HOST}|" /etc/theta42/agent.yml
|
||||
# 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
|
||||
sudo chmod 600 /etc/theta42/agent.yml
|
||||
fi
|
||||
# Stop a running agent before overwriting its binary (cp into a
|
||||
# running executable fails with "Text file busy" on a re-install).
|
||||
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!"
|
||||
|
||||