Compare commits

...

208 Commits

Author SHA1 Message Date
wmantly 3450ef1a8a Merge pull request #218 from theta42/release-v2.8.0
CI/CD / docker-push (push) Has been skipped
CI/CD / build-theta-agent (push) Successful in 40s
release(v2.8.0): promotion/LDAP-orphan fix, site-slug unification, LDAP status UI
2026-08-10 21:03:27 -07:00
wmantly 1c9d9fe405 release(v2.8.0): promotion/LDAP-orphan fix, site-slug unification, LDAP status UI 2026-08-11 00:02:23 -04:00
wmantly 56e76e1a40 Merge pull request #217 from theta42/docs-multi-site-staleness
docs: fix stale multi-site claims, document LDAP MMR auto-config
2026-08-10 20:33:25 -07:00
wmantly 5abd5296f3 docs: fix stale multi-site claims, document LDAP MMR auto-config + CFG_PUBLIC_DOMAIN
The published docs site had drifted from what actually shipped:

- docs/sso/multi-site.md said the no-inbound relay was "designed but
  not automated" -- it's been automated since earlier this session.
  Also said "don't combine" multi-site join and LDAP replication --
  they're integrated now (join auto-configures LDAP MMR).
- docs/sso/replication.md (the actually-published/linked replication
  page -- distinct from theta-directory's own docs/replication.md,
  which isn't linked from this site's nav at all) still only described
  the old fully-manual LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS setup,
  with zero mention of the new auto-config or CFG_LDAP_MMR_MANUAL.
- docs/index.md's feature bullet described multi-site purely as "N-Way
  Multi-Master LDAP replication" -- the actual master/spoke join
  feature (the more commonly-used, higher-level mechanism) wasn't
  mentioned on the homepage at all.
- CFG_PUBLIC_DOMAIN (shipped, never documented anywhere an operator
  would read it) now explained in multi-site.md.
- spoke.env now discoverable from quickstart.md, not just multi-site.md.

Also documents the real, load-bearing limitation from this session's
promotion/LDAP-orphan fix: the master's own replication peer list only
updates on its own next setup.sh run, not live the instant a spoke
joins or a promotion happens.
2026-08-10 23:32:19 -04:00
wmantly 5166859f29 Merge pull request #216 from theta42/release-v2.7.0
CI/CD / docker-push (push) Has been skipped
CI/CD / build-theta-agent (push) Successful in 42s
release(v2.7.0): auto-configured OpenLDAP multi-master replication
2026-08-10 20:10:37 -07:00
wmantly f05fb27e5e release(v2.7.0): auto-configured OpenLDAP multi-master replication 2026-08-10 23:08:49 -04:00
wmantly 33dfb682f4 Merge pull request #215 from theta42/feat-ldap-mmr-auto-config
feat(multi-site): auto-configure OpenLDAP replication on join/every run
2026-08-10 20:03:04 -07:00
wmantly da60310834 feat(multi-site): auto-configure OpenLDAP replication on join/every run
Removes LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS as vars an operator has
to hand-set and keep in sync across every site. bootstrap/
site-ldap-register.js (new) asks sso-manager-node's new
GET /api/site/ldap-peers (spoke) or GET /directory-admin/
ldap-replication-config (master) for this node's assigned ServerID +
current peer list, persists it to /config/ldap-replication.env, and
restarts sso-manager only when the computed config actually changed
(OpenLDAP's static slapd.conf is only read at process start). Runs on
every setup.sh invocation -- both master (peer list grows as spokes
join) and spoke.

CFG_LDAP_MMR_MANUAL=true skips the automatic step entirely, for a
topology outside this theta-suite cluster the script can't derive on
its own -- without this escape hatch, an operator's hand-set
LDAP_SERVER_ID/LDAP_REPLICATION_HOSTS would get silently overwritten
on the next run, since every fresh install starts as a master (the
automatic step always runs by default).

Bumps sso-manager-node to pick up the new endpoints + SiteSpoke.ldapServerId.
2026-08-10 23:02:01 -04:00
wmantly 20e9c1dfbe Merge pull request #214 from theta42/release-v2.6.0
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Has been skipped
release(v2.6.0): agent server_url/update fixes, real gateway count, dedupe groups
2026-08-10 19:40:16 -07:00
wmantly 98b2f9f389 release(v2.6.0): agent server_url/update fixes, real gateway count, dedupe groups 2026-08-10 22:39:16 -04:00
wmantly 90a1d19254 Merge pull request #213 from theta42/feat-site-slug-auto
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Has been skipped
feat(multi-site): auto-derive site slug; wire proxy/jump service integrations
2026-08-10 19:32:52 -07:00
wmantly 55d6f0b936 Merge remote-tracking branch 'origin/master' into feat-site-slug-auto 2026-08-10 22:30:17 -04:00
wmantly ba2e905155 Merge pull request #212 from theta42/fix-agent-server-url-and-stale-binary
fix(agent): server_url wiring + install from latest release instead of a stale committed binary
2026-08-10 19:29:40 -07:00
wmantly 455450db1f feat(multi-site): spoke.env.example + CFG_PUBLIC_DOMAIN
A dedicated spoke.env for the join-a-cluster vars (CFG_MASTER_DIRECTORY_URL/
_JOIN_KEY, CFG_SPOKE_NO_INBOUND/_PUBLIC_HOST, CFG_PUBLIC_DOMAIN), split out
of setup.env purely for clarity -- setup.env still has every option and
keeps working as a single file if that's preferred. setup.sh reads both
(setup.env first, spoke.env layered on top so its values win), same
first-run-only rule as setup.env already had.

Also adds CFG_PUBLIC_DOMAIN (documented in MULTI_SITE_SPEC.md §4 but never
actually wired into setup.sh): an inbound spoke/standalone site's own public
web domain, independent of CFG_DOMAIN (the shared LDAP identity namespace,
which must stay identical across every site). Unset behaves exactly as
before -- hostnames derive from CFG_DOMAIN like any standalone install.
2026-08-10 22:22:54 -04:00
wmantly 5ed83a2f59 feat(multi-site): auto-derive site slug; wire proxy/jump service integrations
Two real gaps found while fixing the Directory's Multi-Site modal:

1. SITE_SLUG was never set anywhere -- site_config.js's own fallback
   ("site-default") was all a fresh master could ever show, since
   nothing in setup.sh/docker-compose.yml passed it a value and
   bootstrap.js never generated one. Derived from CFG_SITE_NAME (same
   source jump-host's default exit node name already uses) with the
   same slugify rule bootstrap.js's own site Resource slug uses,
   formatted to match site_config.js's own "site-default" convention.
   Only a first-run default -- a real join/promote's persisted
   site.json value always wins.

2. PROXY_INTERNAL_URL and JUMP_INTERNAL_URL -- the env vars
   utils/proxy_client.js (no-inbound relay automation) and the new
   utils/jump_client.js (real gateway-mesh count on the modal) read to
   find each service -- were never actually set anywhere in
   docker-compose.yml. Both features existed in sso-manager-node's
   code but were completely unreachable in every real deployment,
   always hitting their "not configured" fallback. Wired both to the
   docker network hostnames.

Also documents how to mint + store the two integration API tokens
those features need (self-service tokens each app already has, not a
new credential type -- same reasoning as the relay automation).
2026-08-10 22:19:21 -04:00
wmantly ca812bed8e fix(agent): set server_url in agent.yml; install from latest release, not a stale committed binary
Two real bugs found on a live deployment:

1. setup.sh's theta-agent install step sed'd in join_key but never
   touched server_url, so /etc/theta42/agent.yml kept
   agent.yml.example's literal "https://sso.example.com" placeholder
   forever. Fixed for both first install and an already-installed
   agent.yml (self-heals server_url only, never touches
   join_key/auth_token, which may since have been rewritten by the
   agent itself with real issued credentials).

2. `theta-agent update` 404'd downloading
   https://sso.../resources/theta-agent/theta-agent-linux-amd64 --
   that route never existed server-side (only
   /resources/theta-agent/install.sh is static-served); self-update
   itself was already fixed upstream to pull from GitHub Releases, but
   setup.sh was still installing the binary committed in the
   theta-agent submodule checkout, which predated that fix and could
   therefore never self-update out of the bug. Switched setup.sh to
   download the current release binary from GitHub instead (matching
   theta-agent's own install.sh), and bumped the submodule to
   theta-agent's latest commit, which removes the stale committed
   binaries entirely -- this exact "stale committed binary" bug class
   has bitten this repo at least twice before (see theta-agent's
   CHANGELOG v1.5.0 entry).
2026-08-10 22:00:08 -04:00
wmantly 0e59b8dcb4 Merge pull request #211 from theta42/bump-jump-host-v2.1.1
CI/CD / docker-push (push) Failing after 14s
CI/CD / build-theta-agent (push) Successful in 40s
release(v2.5.0): no-inbound relay bootstrap wiring + real mesh-registration fixes
2026-08-10 18:36:06 -07:00
wmantly 2d862f93c9 release(v2.5.0): no-inbound relay bootstrap wiring + real mesh-registration fixes
Rolls up sso-manager-node v2.5.0 and jump-host v2.1.1.
2026-08-10 21:35:04 -04:00
wmantly e0bdc0df2e Merge pull request #210 from theta42/fix-jump-login-field-name
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Has been skipped
fix(bootstrap): site-relay-register.js used the wrong jump-host login field
2026-08-10 18:21:31 -07:00
wmantly 577c264a6a fix(bootstrap): site-relay-register.js used the wrong login field for jump-host
jump-host's local admin login (via @simpleworkjs/oidc-client's shared
router) expects `username`, not `uid` -- sso-manager-node's own
/api/auth/login (used by site-join.js) is the one that expects `uid`.
Caught live: the script's login call to jump-host silently 401'd with
`uid`. Confirmed against a real jump-host container that `username`
succeeds.
2026-08-10 21:20:33 -04:00
wmantly 4f09354e32 Merge pull request #209 from theta42/feat-no-inbound-relay-bootstrap
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Has been skipped
feat(multi-site): wire no-inbound relay registration into the bootstrap flow
2026-08-10 18:17:14 -07:00
wmantly 744c85f4bf feat(multi-site): wire no-inbound relay registration into the real bootstrap flow
sso-manager-node/jump-host already had the relay-automation mechanism
(noInbound/meshIp/publicHost -> theta-proxy route via proxy_client.js,
GET /api/mesh/self on jump-host) but nothing in the actual operator
bring-up flow could ever reach it -- setup.sh, bootstrap/site-join.js,
and setup.env.example had zero wiring for it.

Add bootstrap/site-relay-register.js: reads this spoke's own role from
/config/site.json, logs into the local jump-host as its bootstrap
admin to discover its mesh IP, and registers it with the master. Mesh
peering itself stays a manual step (mint/paste a join token, same
pattern as the site join key), so this runs on every setup.sh
invocation via CFG_SPOKE_NO_INBOUND/CFG_SPOKE_PUBLIC_HOST and is a
no-op ("not meshed yet") until an operator has actually meshed the two
jump-hosts.

Also updates MULTI_SITE_SPEC.md's status table/TODO and the published
mesh.md docs page, which still described this as "designed but not
automated" after the API-level work had already shipped.
2026-08-10 21:09:10 -04:00
wmantly d8d811b0ab Merge pull request #208 from theta42/release-v2.4.0
release(v2.4.0): theta-agent v2.2.0 - Windows local-discovery + route pinning
2026-08-10 17:51:16 -07:00
wmantly 42ec5aa208 release(v2.4.0): theta-agent v2.2.0 - Windows local-discovery + route pinning
Rolls up theta-agent v2.2.0: Windows hosts override (CRLF-aware, ipconfig
/flushdns), /32 host-route pinning so the WireGuard tunnel can't swallow the
direct LAN path, and a prompt WS reconnect on apply/revert. Marks Windows
local-discovery shipped in MULTI_SITE_SPEC.md; macOS remains the one unbuilt
piece (in progress on a macOS VM).
2026-08-10 17:49:46 -07:00
wmantly 734c62ac83 Merge pull request #207 from theta42/docs-todo-reorder
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Has been skipped
docs(multi-site): reorder TODO by dependency, note mDNS Windows handoff
2026-08-10 17:41:39 -07:00
wmantly 1d85aa81f3 docs(multi-site): reorder TODO by real dependency, note mDNS Windows handoff
Service-to-service auth is a prerequisite for both cross-component
routing and no-inbound relay automation (both need a real credential
between sso-manager-node and theta-proxy/theta-gateway) -- reordered so
that's not buried as item 5. Also notes that Windows/macOS mDNS is being
built by a separate session rather than silently dropping it with no
explanation.
2026-08-10 20:12:25 -04:00
wmantly 47f7f976ab Merge pull request #206 from theta42/docs-multi-site-website
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Has been skipped
docs(site): publish multi-site + gateway mesh to the docs website
2026-08-10 16:57:09 -07:00
wmantly ace7b441c2 docs(site): publish multi-site + gateway mesh to the actual docs website
Everything shipped this pass (live replication, master/spoke join,
gateway-to-gateway WireGuard mesh) had real spec docs in the repo
(docs/MULTI_SITE_SPEC.md, sso-manager-node's docs/site-join.md) but
nothing on the actual published docs site (theta42.github.io/theta-suite/)
-- a reader landing there would find no mention of it at all beyond a
vague, unlinked "multi-site replication" bullet on the homepage.

- New docs/sso/multi-site.md: the operator-facing master/spoke join guide
  (why, how, promoting a spoke, what replicates, current limits), with an
  explicit section distinguishing it from the pre-existing N-way LDAP MMR
  replication page (replication.html) -- two different mechanisms that
  were at real risk of being conflated with nothing to tell them apart.
- New docs/jump-host/mesh.md: the gateway-to-gateway WireGuard mesh guide,
  linked from a "WireGuard mesh routing" bullet that already existed on
  the jump-host homepage but pointed nowhere.
- docs/sso/index.md, docs/jump-host/index.md: link the new pages from
  each component's Features list.
- docs/index.md: replaced the oversold, unlinked "multi-site replication
  running in seconds" homepage copy with an accurate, linked claim.
2026-08-10 19:54:25 -04:00
wmantly 35c1a476c9 Merge pull request #205 from theta42/chore-bump-theta-agent-v2.1.3
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Has been skipped
chore(submodules): bump theta-agent to v2.1.3
2026-08-10 16:19:13 -07:00
wmantly aeed5b8723 chore(submodules): bump theta-agent to v2.1.3 (fixes v2.1.2 CI failure)
v2.1.2's release build failed on the Windows CI leg (test-only issue --
TestApplyHostsOverride_* didn't skip on non-Linux, where
applyHostsOverride() correctly refuses). v2.1.2's actual code was never
functionally broken, but v2.1.3 is the release whose CI run is actually
green, so that's what theta-suite should point at.
2026-08-10 19:16:22 -04:00
wmantly 6640f8059a Merge pull request #204 from theta42/release-v2.3.0
CI/CD / docker-push (push) Failing after 15s
CI/CD / build-theta-agent (push) Successful in 41s
release(v2.3.0): live replication + gateway-to-gateway WireGuard mesh
2026-08-10 16:10:10 -07:00
wmantly d6611c7d1b release(v2.3.0): live replication + gateway-to-gateway WireGuard mesh
Rolls up theta-directory v2.4.0, jump-host v2.1.0, theta-agent v2.1.2.

Multi-site directory sync stops being a one-time snapshot (live
fire-and-forget replication, identical agent-signing keys, coordinated
master promotion), and site-to-site networking becomes real
infrastructure (gateway-to-gateway WireGuard mesh, kernel-first with a
userspace wireguard-go fallback, real two-container-verified tunnels)
instead of a documented-but-unbuilt design. Linux mDNS local-discovery
also lands end to end (announcer + agent listener).

See CHANGELOG.md for the full rollup and docs/MULTI_SITE_SPEC.md for
the architecture + explicit TODO list of what's still open.
2026-08-10 19:06:54 -04:00
wmantly 9aaa35fa4e docs(multi-site): mark Linux mDNS local-discovery shipped and verified
Announce (theta-gateway) + discover/apply/revert (theta-agent) confirmed
working end-to-end over real multicast between real containers, including
two real bugs found and fixed along the way (IPv6 query abort, EBUSY on
rename over a bind-mounted /etc/hosts).

Windows/macOS mDNS is now the ONLY unbuilt piece of the original design
this session set out to implement -- and it's blocked on platform access
this environment doesn't have, not on missing design or effort.
2026-08-10 18:10:13 -04:00
wmantly 182787f268 docs(multi-site): add an explicit TODO list, ordered by dependency 2026-08-10 17:31:04 -04:00
wmantly d7698a60e7 docs(multi-site): record no-inbound relay mechanism verification
Confirmed the core idea (master terminates a connection, relays over a
spoke's WG mesh IP to a spoke with zero published/inbound ports of its
own) with a standalone test: an external client hit the master's public
port and got a response that could only have come from the spoke,
which had no reachable port except over the tunnel.

Deliberately did NOT wire this into theta-proxy's actual Lua/Redis
routing engine -- that needs its own dedicated pass to do safely, plus a
real service-to-service credential between sso-manager-node and
theta-proxy/theta-gateway that doesn't exist yet. Recorded as verified
mechanism / unbuilt automation, not conflated with either "done" or
"unknown whether it would even work."
2026-08-10 17:28:45 -04:00
wmantly 484bf0e91d docs(multi-site): reconcile spec with live replication, promotion, WG mesh
Updates the status table and top-of-doc callout to reflect what actually
shipped this pass: live catalog replication, identical-directory signing
key, coordinated master promotion (with two real bugs found + fixed along
the way), and a real, tested gateway-to-gateway WireGuard mesh.

Explicitly calls out what's still NOT true despite all of the above: the
mesh exists as its own transport layer but sso-manager-node's join/
replicate traffic doesn't route over it yet, so the no-inbound-spoke
relay scenario still isn't solved end-to-end. mDNS remains unbuilt.
2026-08-10 17:24:18 -04:00
wmantly f42b69c084 feat(multi-site): wire selfUrl through setup.sh so joins register live
Extends the shipped CFG_MASTER_DIRECTORY_URL/JOIN_KEY join flow with the
selfUrl a spoke needs to register itself for live catalog replication
(theta-directory v2.4.0's POST /api/site/spokes) -- without this, every
spoke was permanently limited to the one-time join snapshot even after
the master gained the ability to push live updates.

setup.sh already computes CFG_SSO_HOST before this point in the script;
passes https://$CFG_SSO_HOST as bootstrap/site-join.js's third argument,
which forwards it as `selfUrl` in the POST /api/site/join body.
2026-08-10 16:50:07 -04:00
wmantly 0367c33542 docs(multi-site): reconcile spec with shipped v1, add mDNS agent handoff spec
MULTI_SITE_SPEC.md described a WireGuard-mesh + live-replication design as
if unbuilt-but-planned; meanwhile theta-directory v2.2.0-v2.3.0 (rolled up
in theta-suite v2.2.0) already shipped a simpler, real join mechanism
(one-time LDIF/catalog export over a site join key, read-only spoke
enforcement, setup.sh wiring) that this doc didn't mention at all. Added a
callout pointing at docs/site-join.md as the actual current behavior, and
corrected the status table so it no longer implies unbuilt features are
implemented.

Also adds AGENT_LOCAL_DISCOVERY_SPEC.md, a standalone handoff spec for the
mDNS "prefer local discovered directory" optimization -- confirmed not
implemented anywhere in theta-agent. Needs Windows/Mac-native investigation
this environment can't do; written so it can be picked up independently.
2026-08-10 15:38:39 -04:00
wmantly ff9c87ff20 Merge pull request #203 from theta42/release-v2.2.0
release(v2.2.0): multi-site join end-to-end
2026-08-10 09:42:04 -07:00
wmantly 423e064147 release(v2.2.0): multi-site join end-to-end (theta-directory v2.3.0 + setup.sh wiring) 2026-08-10 09:40:25 -07:00
wmantly 52c9c30c52 Merge pull request #202 from theta42/feat/multi-site-join-setup
feat(setup): first-run site join via CFG_MASTER_DIRECTORY_URL / CFG_MASTER_DIRECTORY_JOIN_KEY
2026-08-10 09:34:14 -07:00
wmantly ea75e94b3e ci(lint): keep job name 'Syntax check bootstrap.js' for branch protection
Renaming the job broke the master protection rule, which requires a check
named exactly 'Syntax check bootstrap.js'. The job still checks both bootstrap
scripts, just under the protected name.
2026-08-10 09:33:01 -07:00
wmantly 301770321e feat(setup): first-run site join via CFG_MASTER_DIRECTORY_URL / CFG_MASTER_DIRECTORY_JOIN_KEY
Multi-site join wiring (server + UI landed in theta-directory v2.3.0):

- bootstrap/site-join.js: runs inside the sso-manager container (same
  self-contained rule as bootstrap.js); logs in as the bootstrap admin and calls
  /api/site/join. Idempotent: an already-joined node reports 'already a spoke'.
- setup.sh step 5b: if setup.env sets CFG_MASTER_DIRECTORY_URL +
  CFG_MASTER_DIRECTORY_JOIN_KEY, run the join after the bootstrap. Only honored
  on first run (ensure_config reads setup.env once and ignores it once
  ./config/ exists), so an already-populated directory can never be merged.
- setup.env.example documents both vars.
- lint.yml also node --check's site-join.js.
2026-08-10 09:12:47 -07:00
wmantly c6c6f09d48 Merge pull request #201 from theta42/chore/submodules-site-join
chore(submodules): bump theta-directory to v2.2.0
2026-08-10 06:14:21 -07:00
wmantly cccde0792e chore(submodules): bump theta-directory to v2.2.0 (multi-site join endpoints + emoji fix) 2026-08-10 06:12:16 -07:00
wmantly a2afc127c4 Merge pull request #200 from theta42/release-v2.1.1
release(v2.1.1): fresh-install fixes
2026-08-09 20:37:35 -07:00
wmantly ad2f7b7e11 release(v2.1.1): fresh-install fixes (agent silent install, Directory modal/version) 2026-08-09 23:36:19 -07:00
wmantly ec73eae07d Merge pull request #199 from theta42/fix/fresh-install
fix(setup): default org name is Theta Directory; bump theta-agent
2026-08-09 20:29:22 -07:00
wmantly 13aeac059f fix(setup): default org name is Theta Directory; bump theta-agent to the fresh-install fix
- CFG_ORG default was 'SSO Manager', which became the browser tab title / app
  name on fresh installs. Default is now 'Theta Directory' (existing deployments
  keep their operator-owned ./config/sso-secrets.js name).
- Records theta-agent d937f8d (silent-install server_url + tray autostart +
  self-update 404 fixes).
2026-08-09 23:26:33 -07:00
wmantly 349d3d4cd0 Merge pull request #198 from theta42/release-v2.1.0
release(v2.1.0): theta-agent Windows client + theta-directory install commands
2026-08-09 18:45:44 -07:00
wmantly d8725990b3 release(v2.1.0): theta-agent Windows client + theta-directory install commands 2026-08-09 21:44:26 -07:00
wmantly de2d65fe8f Merge pull request #197 from theta42/chore/submodules-theta-agent-v2.1.0
chore(submodules): bump theta-agent to v2.1.0
2026-08-09 18:35:22 -07:00
wmantly e5446b2cfe chore(submodules): bump theta-agent to v2.1.0 (Windows agent: platform ops, WireGuard, IAM, tray, installer, CI) 2026-08-09 20:49:02 -07:00
wmantly 0c87c79f06 release(v2.0.4): bump theta-directory for the OpenLDAP base-image speedup (#196)
Dockerfile.openldap now pulls ghcr.io/theta42/openldap-nestgroup instead of
compiling from source on every build (~5-6min -> ~1.5min per CI matrix run,
and removes the runtime dependency on git.openldap.org).

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 17:10:35 -07:00
wmantly c7bf1d0edd release(v2.0.3): bump theta-directory to fix Directory tab managed-filter bug (#195)
Directory tab admitted every kind:'host' resource regardless of promotion
status (every discovery plugin creates its finds as kind:'host'), and
site-status 500'd on a nonexistent Resource.subType column.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:30:28 -07:00
wmantly a917915037 release(v2.0.2): unify component docs, bump theta-directory/proxy/jump-host (#194)
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.

Bumps submodules: theta-directory v2.0.2, proxy v2.0.1, jump-host v2.0.1.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:14:23 -07:00
wmantly 141e5e14f0 Merge pull request #193 from theta42/release-v2.0.1-submodules
release(v2.0.1): sync submodules with updated binaries and package.json version
2026-08-09 12:40:30 -07:00
wmantly f09d38d00b release(v2.0.1): sync submodules with updated binaries and package.json version 2026-08-09 15:39:28 -04:00
wmantly 8632798735 Merge pull request #192 from theta42/release-v2.0.1
release(v2.0.1): rollup sso-manager-node v2.0.1 and theta-agent v2.0.1
2026-08-09 11:51:32 -07:00
wmantly 17f488e2e2 release(v2.0.1): rollup sso-manager-node v2.0.1 and theta-agent v2.0.1 2026-08-09 14:50:31 -04:00
wmantly 3ec641c426 fix(submodules): update sso-manager-node with bundled v2.0.0 agent binaries (#191) 2026-08-09 01:12:26 -04:00
wmantly 69a7843dca docs(changelog): update CHANGELOG.md and submodules for v2.0.0 (#190) 2026-08-09 00:19:13 -04:00
wmantly 4db87de240 chore: sync sso-manager-node submodule pointer (#189) 2026-08-09 00:11:29 -04:00
wmantly 9c521b0d08 chore(release): update submodules to v2.0.0 release commits (#188) 2026-08-09 00:10:05 -04:00
wmantly e091ca406c feat: WireGuard UI in Theta Gateway and Theta Agent desktop tray companion with Theta logo (#187)
- Theta Gateway: Add WireGuard peer management UI (/wireguard) with QR code generator, .conf download, and per-client exit node selection
- Theta Agent: Add desktop tray companion app (theta-agent-tray) with Theta 42 logo, color-coded status (red, yellow, green, blue), and home LAN detection
- Suite Rebuild: Local stack initialized with CFG_DOMAIN=suite.vm42.us and CFG_SITE_NAME=718it
2026-08-08 23:19:47 -04:00
wmantly 3c40c66636 chore: update submodules to v2.0.0 (#186) 2026-08-08 21:46:00 -04:00
wmantly 2a0d194cae docs: audit docs and READMEs for Theta Suite 2.0, Theta Directory, Theta Gateway, and Docker-only deployment (#185) 2026-08-08 21:22:40 -04:00
wmantly 656c2ed8c8 docs: update sso-manager-node submodule commit (#184) 2026-08-08 20:59:14 -04:00
wmantly 282071abca docs: update README to reflect Theta Directory, Theta Gateway, Theta Agent, and Theta Suite 2.0 (#183) 2026-08-08 20:53:29 -04:00
wmantly 1d742c51eb docs: finalize v2.0 multi-site spec with theta-gateway, NETMAP, and policy routing (#182) 2026-08-08 20:49:10 -04:00
wmantly 85a822eb36 docs: add multi-site architecture and replication specification (#181) 2026-08-08 20:35:38 -04:00
wmantly 489fe7b127 feat(suite): release v1.8.0 - sso-manager v1.33.0, theta-agent v1.8.0, preset templates & system telemetry (#180) 2026-08-08 19:40:07 -04:00
wmantly ad6f17515b Merge pull request #179 from theta42/release-v1.48.0
Release v1.48.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations
2026-08-08 18:17:02 -04:00
wmantly eca92f3f37 release: v1.48.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations 2026-08-08 18:16:33 -04:00
wmantly acea5217ac Merge pull request #178 from theta42/release-v1.47.0
Release v1.47.0 - Subtype Management & Metrics Drivers Engine, Explicit Secret Inheritance & Cross-Platform Agents
2026-08-08 16:13:22 -04:00
wmantly 47ceb63049 release: v1.47.0 - Subtype Management & Metrics Drivers Engine, Explicit Secret Inheritance & Cross-Platform Agents 2026-08-08 16:12:47 -04:00
wmantly 86d1601069 Merge release branch release-v1.47.0 2026-08-08 15:39:16 -04:00
wmantly 8bfde63684 release: v1.47.0 - Subtype Management & Metrics Drivers Engine, Explicit Secret Inheritance & Cross-Platform Agents 2026-08-08 15:39:16 -04:00
wmantly 603156cbfe Merge pull request #177 from theta42/docs/update-secrets-doc
docs: update secrets documentation
2026-08-07 23:47:00 -04:00
wmantly 277b68af61 docs: update secrets.md with Zero-View model, theta-agent get-secret CLI, and multi-level inheritance 2026-08-07 23:46:34 -04:00
wmantly 7d55048905 Merge pull request #176 from theta42/release/v1.46.0-submodules
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Has been skipped
chore: update submodules to v1.31.0 and v1.6.0
2026-08-07 23:43:34 -04:00
wmantly b799d59672 chore: bump submodules to sso-manager-node v1.31.0 and theta-agent v1.6.0
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 12s
2026-08-07 23:43:08 -04:00
wmantly f46ef31ca9 Merge pull request #175 from theta42/release/v1.46.0
chore: release v1.46.0
2026-08-07 23:29:38 -04:00
wmantly 8451d3f12d feat: release v1.46.0 theta-suite with Zero-View secrets engine, theta-agent get-secret CLI, and LDAP tunnel
CI/CD / docker-push (push) Failing after 16s
CI/CD / build-theta-agent (push) Successful in 45s
2026-08-07 23:27:00 -04:00
wmantly 67e7e4eb34 Fix --seed-node-secret path/stdin bugs; docs title casing
seed_node_conf() (added for sso-manager-node's node-scoped secrets
engine, DESIGN.md §5) had two bugs that made it fail on every call:

- Path had an extra "data/" segment (secret/data/nodes/<id>/<name>).
  bao kv put takes the mount-relative path and inserts "data/" itself
  for KV v2 -- same convention seed_app_conf already uses just above it
  (secret/${vault_path}, not secret/data/${vault_path}). Fixed to
  secret/nodes/<id>/<name>, which resolves under the hood to the
  secret/data/nodes/<id>/* path api_agent_ops.js's node-scope check
  expects.
- It piped "key=value\n" lines to `bao kv put path -`, but `-` there
  means "read a JSON object from stdin", not KV lines -- failed with
  "invalid key/value pair \"-\"" before ever reaching OpenBao. Fixed to
  pass key=value pairs as ordinary CLI args.

Verified against a real OpenBao round-trip (write via the fixed
function, read back both via the CLI and the same HTTP path the SSO's
node-scope check uses).

docs/_config.yml: "theta-suite" -> "Theta Suite" in the Jekyll site title.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:20:01 -04:00
wmantly 403e1a3cd4 Merge pull request #174 from theta42/docs/index-page-update
Update docs home page copy
2026-08-07 00:10:34 -04:00
wmantly 28fad8a49e Update docs home page copy
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 00:09:42 -04:00
wmantly 70fc19e37f Merge pull request #173 from theta42/docs/readme-update
Update README intro and repo layout diagram
2026-08-06 23:58:15 -04:00
wmantly 5bb2c19fec Update README intro and repo layout diagram
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:54:02 -04:00
wmantly ff9b62d331 Merge pull request #172 from theta42/chore/add-site-analytics
Add site analytics tracking script
2026-08-06 23:48:05 -04:00
wmantly fabb250887 Add site analytics tracking script to docs layout
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-06 23:45:37 -04:00
wmantly 221e890897 Merge pull request #171 from theta42/fix/directory-topology-container-not-host
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 15s
Fix Directory topology: containers aren't hosts
2026-08-06 21:40:25 -04:00
wmantly 5925940936 Fix Directory topology: containers aren't hosts; bump submodules; docs
bootstrap.js no longer creates host_theta-proxy / host_theta-jump as
synthetic kind:'host' resources. Proxy and jump-host are containers running
on the one real stack host, not machines of their own -- and jump-host
resolves its SSH-reachable-hosts list from exactly kind:'host', so the
mistake wasn't just conceptual, it could offer unreachable SSH targets.
Their services now parent directly onto the stack host, like every other
component. Installs seeded between 2026-08-05 and this release self-heal on
the next ./setup.sh run: existing children are re-parented off the
synthetic hosts and the now-empty synthetic hosts are removed. Validated
live against a running instance carrying the exact bad state.

Bumps submodules to sso-manager-node v1.30.2, proxy v1.35.1,
jump-host v1.19.1.

Also: README's architecture diagram + repo layout were stale (2-service
view predating jump-host/OpenBao, 2 of 5 submodules listed); new
docs/fixtures.md + docs/screenshots.md + bootstrap/seed-demo-users.sh for
consistent, repeatable demo data and screenshot passes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:38:48 -04:00
wmantly ea5d2350a4 Merge pull request #170 from theta42/fix/rollup-sso-v1.30.1
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 19s
fix: roll up sso-manager-node v1.30.1 (v1.44.0)
2026-08-06 14:45:00 -04:00
wmantly 050ff87a0b fix: roll up sso-manager-node v1.30.1 (v1.44.0)
Test Email and Test SMS could never have worked, and all SMS delivery was
broken underneath them:

- Test Email threw "Email.send is not a function" -- models/email.js
  exports {Mail} and the handler called .send on the module.
- Test SMS threw "Unexpected token '<'" -- it POSTed to
  api.voip.ms/v1.0/sms/send, which does not exist, and got HTML back.
- models/sms.js called PluginInstance.find(), but the ORM has no find, so
  every SMS threw before it could even reach the VoIP.ms fallback.

Both test endpoints now go through the same senders real messages use. A
test that reimplements delivery proves nothing, which is how two broken
paths went unnoticed.

Also: the Install Agent modal now offers the join-key flow that v1.43.0
shipped in the API and documented but never surfaced in the UI.

Tagged during a GitHub Actions major outage; verified locally on the
merged commit (299/299 in the same Docker suite CI runs).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 14:42:45 -04:00
wmantly bc87d4e381 Merge pull request #169 from theta42/feat/agent-join-key-provisioning
CI/CD / docker-push (push) Failing after 13s
CI/CD / build-theta-agent (push) Successful in 40s
fix: agent join-key provisioning; account for the stack's own containers (v1.43.0)
2026-08-06 10:49:52 -04:00
wmantly 7efc271938 fix: agent join-key provisioning; account for the stack's own containers (v1.43.0)
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 13s
Rolls up sso-manager-node v1.30.0, theta-agent v1.5.1, proxy v1.35.0.

The stack's own theta-agent could never connect. setup.sh generated a
random token locally and wrote it into agent.yml, but the SSO only
accepts credentials it issued, so it was rejected on every attempt and
the agent looped on "close 4001: Unauthorized" forever. It now writes a
join key the SSO minted; the agent exchanges it for its own token and the
SSO public key on first connect and rewrites its own config.

agent.yml was also left holding literal REPLACE_WITH_* placeholders once
the seds stopped matching the renamed fields, so a fresh install had no
credential at all. The file is chmod 600 now that it holds one.

A fresh install presented its own five containers as unmanaged
discoveries. The compose project name is passed to the Docker discovery
plugin, which recognises them and links each to the service it
implements. openbao and bao-renewer had no directory entries for their
containers to attach to; both are seeded as services now.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:48:20 -04:00
wmantly 4bf875e841 Merge pull request #168 from theta42/fix/seed-hierarchy-and-host-sso-redirect
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 14s
feat: agent enrollment, per-host SSO redirect URIs, seed hierarchy (v1.42.0)
2026-08-05 19:33:01 -04:00
wmantly 6479d35fb8 feat: agent enrollment, per-host SSO redirect URIs, seed hierarchy (v1.42.0)
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
Rolls up sso-manager-node v1.29.0, theta-agent v1.4.0, proxy v1.34.0 and
jump-host v1.19.0.

Per-host SSO returned "400 redirect_uri is not registered for this
client". The bootstrap registered only the proxy's own management
callback, but per-host SSO calls back to
https://<protected-host>/__proxy_auth/callback -- a different URL per
proxied host, all against that one OAuth client. Now registers the
wildcard + apex patterns, and backfills them onto existing clients so
upgraded stacks are fixed too.

theta-proxy and theta-jump were seeded as hosts and then left childless
while their services hung off the stack host. Services now parent to the
host that runs them; reparent() corrects existing installs, but only when
the current parent is the one the old code set.

The proxy gets a read-only SSO API token (minted before the OpenBao
snapshot so the running proxy receives it) backing the per-host SSO group
autocomplete, and the sso-broker policy grants secret/agent/* for the
SSO's persistent theta-agent signing key.

BREAKING: theta-agents must be re-enrolled, and ./setup.sh must be re-run
for the new OpenBao grant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 19:29:29 -04:00
wmantly 3ca4802075 fix: mount docker socket for the docker discovery plugin; roll up sso v1.28.0 + theta-agent v1.3.1 (v1.41.0) (#167)
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 17s
- docker-compose: mount /var/run/docker.sock into sso-manager so the seeded
  docker-local plugin can list containers (was ENOENT -> 'Last run: error')
- gitlinks: sso-manager-node 49100c9 (v1.28.0), theta-agent 51750d0 (v1.3.1)
2026-08-05 03:04:20 -04:00
wmantly 86026e90e7 fix: skip host self-registration when sso_token empty; roll up ldap-client v1.25.0 (v1.40.0) (#166)
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
ldap-client no longer POSTs an empty Bearer to /api/directory-admin/resources
(the misleading 'Invalid Credentials, login failed' during setup). Gitlink ->
ldap-client 68fcdb5 (v1.25.0).
2026-08-05 01:34:32 -04:00
wmantly 3354407f04 fix: publish plain LDAP (389) to the host so setup.sh can reach the directory (v1.39.0) (#165)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
docker-compose only published LDAPS (636); plain LDAP (389) was not mapped, so
the stack host's own enrollment (ldap://localhost:389) couldn't reach the LDAP
server. Now both 389 + 636 are published (bind 0.0.0.0; LDAP_BIND/LDAPS_BIND to
lock to host). README updated.
2026-08-05 00:15:12 -04:00
wmantly 84d7c96c17 fix: LDAP enrollment uses localhost (not the public domain); align SSH access groups; roll up ldap-client v1.24.0 (v1.38.0) (#164)
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 16s
- setup.sh: ldap_host defaults to localhost (the public sso.<domain> can't reach
  the 389/636 LDAP ports through NAT); overridable via CFG_LDAPS_HOST
- ldap.vars access groups + ldap-client sssd filter now reference the SSO group
  model (site_<loc>_hosts_access, site_<loc>_host_<host>_access, god_admin)
- GROUPS.md §5/§8 updated to the corrected naming
- gitlink: ldap-client ebaac18 (v1.24.0)
2026-08-04 23:30:04 -04:00
wmantly 72046a8b29 docs: group naming matches docs/GROUPS.md; roll up sso v1.27.0 (v1.37.0) (#163)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
- GROUPS.md: per-resource groups are {site}_{kind}_{name}_{level}; site carries god + site-wide only
- gitlink: sso-manager-node e8d0420 (v1.27.0)
2026-08-04 23:02:14 -04:00
wmantly 73e1cc807a fix: setup.sh ldap.vars re-run abort + drop app_super_admin; roll up sso v1.26.1 (v1.36.1) (#162)
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 19s
- setup.sh: ldap.vars generation read CFG_* first-run vars (unset on re-run);
  now reads real values from sso-secrets.js, so LDAP enrollment works on re-runs
- generated ldap_access_groups now references god_admin (app_super_admin gone)
- gitlink: sso-manager-node 8db00f0 (v1.26.1)
2026-08-04 19:33:32 -04:00
wmantly a77aa8d2df feat: seed god_admin + docker plugin, fix ldap-client enrollment, roll up sso v1.26.0 + theta-agent v1.3.0 (v1.36.0) (#161)
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 16s
- bootstrap: seed god_admin into the admin's groups; seed a docker-local discovery plugin
- setup.sh: generate ldap-client/ldap.vars from the stack config so LDAP enrollment works
- docs: GROUPS.md site-slug convention (verbatim, kind in resource slug)
- gitlinks: sso-manager-node 8a9de94 (v1.26.0), theta-agent 52379c2 (v1.3.0)
2026-08-04 19:10:32 -04:00
wmantly 0e78a9e282 Merge pull request #160 from theta42/release/v1.35.18
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 19s
chore: sync proxy/jump gitlinks (v1.35.18)
2026-08-04 16:54:41 -04:00
wmantly 59c5c66007 chore: sync proxy/jump gitlinks to version-tagged commits (v1.35.18)
proxy v1.33.0 + jump v1.18.0 had package.json synced to their tags; update the
gitlinks so a deploy reports matching versions.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:53:38 -04:00
wmantly ec426680c3 Merge pull request #159 from theta42/release/v1.35.17
CI/CD / docker-push (push) Failing after 17s
CI/CD / build-theta-agent (push) Successful in 47s
docs: group & permission model + sso group model (v1.35.17)
2026-08-04 16:49:11 -04:00
wmantly 6fae96d977 chore: bump sso-manager-node gitlink to v1.25.0
sso v1.25.0 shipped the group & permission model + the v1.24.0 batch (Agents →
Directory, plugin modal rework, Vault restyle). Update the gitlink for the release.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:48:16 -04:00
wmantly 292b67c334 docs: group & permission model spec + link (v1.35.17)
Add docs/GROUPS.md — the canonical Group & Permission Model (schema, inheritance
resolver, Directory-only management, multi-site, host-side SSSD mapping, migration)
— link it from the docs index, and note sso v1.25.0 in the changelog.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:04:30 -04:00
wmantly 72606bfc13 feat: seed theta-proxy + theta-jump as managed host resources (v1.35.16)
The bootstrap now creates theta-proxy and theta-jump as managed host-kind
resources in the Directory (matching the OAuth client identities), alongside
the existing stack host and its service entries, so a fresh install shows them
as first-class hosts.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:27:28 -04:00
wmantly c28e53e505 Merge pull request #158 from theta42/fix/theta-agent-text-file-busy-v1.35.15
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
fix: stop theta-agent before overwriting binary (v1.35.15)
2026-08-04 00:34:43 -04:00
wmantly 7ae2472c62 fix: stop theta-agent before overwriting binary (v1.35.15)
cp into a running executable fails with 'Text file busy' on a re-install.
Stop the service before copying the prebuilt binary.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:32:33 -04:00
wmantly 0c4abd82be Merge pull request #157 from theta42/release/v1.35.14-token-lifecycle
CI/CD / build-theta-agent (push) Successful in 43s
CI/CD / docker-push (push) Failing after 17s
feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
2026-08-04 00:25:16 -04:00
wmantly c27e8c7867 feat: OpenBao token lifecycle + bump sso to v1.23.0 (v1.35.14)
- theta-svc token role (periodic 768h): SSO/PROXY/JUMP_VAULT_TOKEN now minted
  through it; ensure_token renews periodic tokens on every setup.sh re-run and
  detects/revokes/re-mints valid-but-non-periodic tokens from older installs.
- bao-renewer sidecar (docker-compose): renews the three service tokens every
  12h while the stack runs.
- sso-app token role (periodic 768h) + sso-broker policy grants for
  auth/token/create/sso-app and renew/revoke/lookup-accessor.
- docs/secrets.md rewritten around the new lifecycle.
- Bump sso-manager-node gitlink to v1.23.0 (real vault-403 fix + app-token
  lifecycle).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:21:51 -04:00
wmantly 9750413178 Merge pull request #156 from theta42/release/v1.35.13-agents-page
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 18s
feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13)
2026-08-03 23:16:39 -04:00
wmantly c1a9d8f059 feat: bump sso to v1.22.0 (Agents page + secure /api/agent) (v1.35.13) 2026-08-03 23:15:19 -04:00
wmantly 0edce57f80 Merge pull request #155 from theta42/fix/theta-agent-service-control-v1.35.12
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 18s
fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
2026-08-03 23:06:39 -04:00
wmantly 49b868fedb fix: stop writing invalid service_control:true for theta-agent (v1.35.12)
setup.sh's 'full control' edit set service_control: true, but that field is a
[]string allowlist, so theta-agent failed YAML decode and crash-looped. Remove
the invalid edit; leave the operator's allowlist (or [] default = deny all).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:05:04 -04:00
wmantly f9af81983b Merge pull request #154 from theta42/fix/unseal-key-unbound-v1.35.11
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
fix: UNSEAL_KEY unbound variable in setup.sh (v1.35.11)
2026-08-03 22:45:27 -04:00
wmantly 5ef2493e17 chore: changelog for v1.35.11 2026-08-03 22:44:03 -04:00
wmantly 8535372123 fix: guard UNSEAL_KEY with ${UNSEAL_KEY:-} in setup.sh (v1.35.11)
On a re-run where OpenBao is already unsealed, the unseal block is skipped and
UNSEAL_KEY is never set; line 778 then referenced it under set -u and aborted
with 'UNSEAL_KEY: unbound variable'. Guard with ${UNSEAL_KEY:-} so the
VAULT_UNSEAL_KEY upsert is simply skipped when there's no key this run.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:43:51 -04:00
wmantly ade9a41aed Merge pull request #153 from theta42/release/v1.35.10-reset-openbao-theta-agent
CI/CD / build-theta-agent (push) Successful in 47s
CI/CD / docker-push (push) Failing after 16s
feat: --reset-openbao + fix theta-agent install; bump sso to v1.21.0 (v1.35.10)
2026-08-03 22:26:35 -04:00
wmantly ce664f5cb9 feat: --reset-openbao + fix theta-agent install; bump sso to v1.21.0 (v1.35.10)
- Add --reset-openbao: full clean OpenBao reset (re-init store, flush the
  Redis vault-token cache) to clear stale policies/tokens causing recurring
  vault 403s.
- Fix theta-agent install: copy the prebuilt theta-agent-linux-amd64 from the
  submodule instead of a broken go build; write config to /etc/theta42/agent.yml
  (the path the agent reads), not /etc/theta/agent.yml.
- Bump sso-manager-node gitlink to v1.21.0 (shared secrets + durable vault 403 fix).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:24:48 -04:00
wmantly 647f5b846c Merge pull request #152 from theta42/fix/submodule-version-sync-v1.35.9
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 17s
fix: bump sso & proxy submodules to corrected version tags (v1.35.9)
2026-08-03 21:37:39 -04:00
wmantly fe08c2f8c7 fix: bump sso & proxy submodules to corrected version tags (v1.35.9)
The v1.20.2 / v1.32.0 release tags were created but their package.json
versions lagged (1.20.1 / 1.14.3), so the deployed apps' update-check
banner falsely reported a newer version. Repoint the sso-manager-node and
proxy gitlinks to the corrected commits and release v1.35.9.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 21:36:24 -04:00
wmantly 43c58e7ed5 Merge pull request #151 from theta42/fix/auto-reset-unseal-key-loss-v1.35.8
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
fix(setup): auto-reset OpenBao volume and re-initialize if unseal key is lost v1.35.8
2026-08-03 15:44:22 -04:00
wmantly 13d8a979c7 fix(setup): auto-reset OpenBao volume and re-initialize if unseal key is lost v1.35.8
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:43:35 -04:00
wmantly 16cbe46793 Merge pull request #150 from theta42/fix/restore-bao-init-from-backup-v1.35.7
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 17s
fix(setup): auto-restore bao-init.json from backups if missing from config/ v1.35.7
2026-08-03 15:42:35 -04:00
wmantly 40a1e7f64e fix(setup): auto-restore bao-init.json from backups if missing from config/ v1.35.7
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 15:42:01 -04:00
wmantly 45715b61ea Merge pull request #149 from theta42/fix/env-get-function-order-v1.35.6
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
fix(setup): move env_get helper function definition to top of setup.sh v1.35.6
2026-08-03 15:39:39 -04:00
wmantly 54ebb83bb1 fix(setup): move env_get helper function definition to top of setup.sh v1.35.6
Lint / Shellcheck setup.sh (push) Failing after 10s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 15:39:05 -04:00
wmantly a67d972217 Merge pull request #148 from theta42/fix/setup-openbao-unseal-fallback-v1.35.5
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
fix(setup): fallback to .env VAULT_UNSEAL_KEY and VAULT_TOKEN if bao-init.json is missing v1.35.5
2026-08-03 15:36:46 -04:00
wmantly 875ea874b4 fix(setup): fallback to .env VAULT_UNSEAL_KEY and VAULT_TOKEN if bao-init.json is missing v1.35.5
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 12s
2026-08-03 15:36:22 -04:00
wmantly 119f21b821 Merge pull request #147 from theta42/bump/sso-v1.20.2-proxy-v1.32.0
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 18s
bump(deps): update sso-manager-node to v1.20.2 and proxy to v1.32.0
2026-08-03 15:31:29 -04:00
wmantly 80275c42e4 bump(deps): update sso-manager-node to v1.20.2 and proxy to v1.32.0
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:30:56 -04:00
wmantly a30deae866 Merge pull request #146 from theta42/bump/theta-agent-v1.2.1
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 17s
bump(theta-agent): update to v1.2.1 for automatic SSSD installation
2026-08-03 15:02:56 -04:00
wmantly dc9a7ff9c4 bump(theta-agent): update to v1.2.1 for automatic SSSD installation
Lint / Shellcheck setup.sh (push) Failing after 8s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-03 15:02:14 -04:00
wmantly 7937d6cf92 Merge pull request #145 from theta42/fix/setup-unbound-var-v1.35.2
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 18s
fix(setup): export CFG_CREATE_ALL_HTTP default and add fail-safe parameter expansion v1.35.2
2026-08-03 14:59:07 -04:00
wmantly 21333de814 fix(setup): export CFG_CREATE_ALL_HTTP default and add fail-safe parameter expansion v1.35.2
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
2026-08-03 14:58:39 -04:00
wmantly b3bdebe1c9 Merge pull request #144 from theta42/release/v1.35.1
CI/CD / build-theta-agent (push) Successful in 44s
CI/CD / docker-push (push) Failing after 16s
release(theta-suite): v1.35.1
2026-08-03 14:02:55 -04:00
wmantly e6b318e28e release(theta-suite): v1.35.1 - Submodule updates, Directory, Conf layout & Jump host target filter
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 14s
2026-08-03 14:02:13 -04:00
wmantly 4d0b7f555e Merge pull request #143 from theta42/feature/v1.35.0-final-roll-up
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 18s
release: v1.35.0 final roll-up
2026-08-03 02:42:24 -04:00
wmantly 67b511f8d7 release: v1.35.0 - final roll up (sso v1.20.0, proxy v1.14.3, jump-host v1.17.1, theta-agent v1.2.0) 2026-08-03 02:41:57 -04:00
wmantly b8a8be9697 Merge pull request #142 from theta42/feature/v1.35.0-theta-suite-stack-update
release: v1.35.0 theta-suite stack roll-up
2026-08-03 02:22:39 -04:00
wmantly cd9c81cd92 release: v1.35.0 - roll up submodules (sso v1.20.0, proxy v1.14.3, jump-host v1.17.1, theta-agent v1.2.0) and setup fixes 2026-08-03 02:22:10 -04:00
wmantly c8c04440db Merge pull request #141 from theta42/release/v1.35.0-update
Release v1.35.0 - Bump sso-manager-node to v1.19.6
2026-08-02 23:03:49 -04:00
wmantly d53bdefc2a chore: Bump sso-manager-node to v1.19.6
Lint / Shellcheck setup.sh (push) Failing after 7s
Lint / Syntax check bootstrap.js (push) Successful in 14s
### Changed
- sso-manager-node: v1.18.0-26-gef2207e → v1.19.6 (4945dec)

### sso-manager-node v1.19.6 highlights
- Fixed navbar auth (Catalog/Vault now require login)
- Added docs/directory.md
- SMTP moved to UI-only configuration
- Added test email/SMS API endpoints
- Added non-interactive theta-agent config variables

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:55:30 -04:00
wmantly 542e5fd33f chore: Release v1.35.0 - Non-interactive theta-agent config
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
### Added
- Non-interactive theta-agent configuration via setup.env variables

### Changed
- setup.sh: Made theta-agent setup fully non-interactive

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:34:45 -04:00
wmantly 848f35fc5e chore: Add theta-agent configuration variables to setup.env
### Changed
- **setup.sh**: Made theta-agent installation and configuration non-interactive,
  controlled by CFG_THETA_AGENT_* environment variables.
- **setup.env.example**: Added documentation for:
  - CFG_THETA_AGENT_ENABLE (default: 1)
  - CFG_THETA_AGENT_LDAP_AUTH (default: 1)
  - CFG_THETA_AGENT_FULL_CONTROL (default: 1)

All options default to enabled for backwards compatibility.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 22:23:29 -04:00
wmantly 1d14fcee19 feat: install theta-agent on host and add CFG_CREATE_ALL_HTTP option (#139)
CI/CD / build-theta-agent (push) Successful in 39s
CI/CD / docker-push (push) Failing after 15s
* feat: install theta-agent on host and add CFG_CREATE_ALL_HTTP option

* docs: update changelog for 1.34.5
2026-08-02 20:04:44 -04:00
wmantly 30609de3e8 chore: release v1.34.4 (update sso-manager-node submodule for vault fix) (#138)
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 16s
2026-08-02 19:48:54 -04:00
wmantly 925ac027a6 Merge pull request #137 from theta42/release-v1.34.3
CI/CD / build-theta-agent (push) Successful in 41s
CI/CD / docker-push (push) Failing after 16s
chore: release v1.34.3
2026-08-02 19:09:51 -04:00
wmantly 3b769cf24a chore: release v1.34.3 (updates submodules, docs, bootstrap)
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-02 19:08:14 -04:00
wmantly 2785b861b3 Merge pull request #136 from theta42/update-submodules-2
CI/CD / build-theta-agent (push) Successful in 42s
CI/CD / docker-push (push) Failing after 15s
chore: update submodules to v1.19.4 and v1.14.2
2026-08-02 14:12:02 -04:00
wmantly c216ddd4e8 chore: update submodules to v1.19.4 and v1.14.2 2026-08-02 14:11:11 -04:00
wmantly 9c3cbb0ec2 Merge pull request #135 from theta42/update-submodules
CI/CD / build-theta-agent (push) Successful in 45s
CI/CD / docker-push (push) Failing after 16s
chore: update submodules
2026-08-02 13:25:20 -04:00
wmantly f44c075ede chore: update sso-manager-node submodule 2026-08-02 13:24:14 -04:00
wmantly ac9f672bae Merge pull request #134 from theta42/fix/setup-echo-and-unbound
fix: setup.sh output bugs
2026-08-02 13:21:31 -04:00
wmantly 43b8307e54 docs: note docker compose v1 incompatibility 2026-08-02 13:13:50 -04:00
wmantly 9541c47470 fix: resolve jump-host naming bug and setup.sh secrets list bug 2026-08-02 12:56:55 -04:00
wmantly a40326778e fix: setup.sh color escape and unbound variable 2026-08-02 12:26:19 -04:00
wmantly 68a0ce4d12 Merge pull request #133 from theta42/chore/release-v1.34.0
CI/CD / build-theta-agent (push) Successful in 46s
CI/CD / docker-push (push) Failing after 19s
chore: release v1.34.0
2026-08-02 12:14:18 -04:00
wmantly 3cb5540b2d docs: release v1.34.0
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 13s
2026-08-02 12:13:24 -04:00
wmantly 4d745a9c1d Merge pull request #132 from theta42/fix/setup-redis-snapshot
fix: prevent pipefail abort when redis-cli fails on restarting container
2026-08-02 12:09:50 -04:00
wmantly 36b39dfcc7 fix: prevent pipefail abort when redis-cli fails on restarting container 2026-08-02 11:59:34 -04:00
wmantly 06a4081a50 Merge pull request #131 from theta42/release-pki
v1.9.0: PKI Certificates & Theta Agent C2
2026-08-02 11:37:49 -04:00
wmantly 2f793206ed chore: remove redundant submodule unit test jobs 2026-08-02 11:35:23 -04:00
wmantly f7e9c20f72 fix: ci submodule checkout and bump sso-manager-node 2026-08-02 11:24:45 -04:00
wmantly 5ee486e808 chore: update submodules for PKI cert and agent C2 releases 2026-08-02 01:54:54 -04:00
wmantly 475ae2c893 docs: remove all standalone deployment documentation 2026-08-02 00:51:25 -04:00
wmantly 2eb5bf5daa Merge pull request #130 from theta42/release-v1.33.0
Release v1.33.0
2026-08-02 00:39:29 -04:00
wmantly 35c8a51182 chore: bump submodules for OpenBao secret integration
Lint / Shellcheck setup.sh (push) Failing after 9s
Lint / Syntax check bootstrap.js (push) Successful in 12s
CI/CD / test-sso-manager (push) Failing after 8s
CI/CD / test-jump-host (push) Failing after 8s
CI/CD / test-proxy (push) Failing after 7s
CI/CD / build-telemetry-agent (push) Failing after 8s
CI/CD / docker-push (push) Has been skipped
2026-08-02 00:38:49 -04:00
wmantly f3b951b780 chore: release v1.31.0
CI/CD / test-sso-manager (push) Failing after 9s
CI/CD / test-jump-host (push) Failing after 7s
CI/CD / test-proxy (push) Failing after 8s
CI/CD / build-telemetry-agent (push) Failing after 8s
CI/CD / docker-push (push) Has been skipped
2026-08-02 00:16:27 -04:00
wmantly 8f5ce71bda feat: Add CI/CD workflow, update docs, update submodules 2026-08-02 00:16:27 -04:00
wmantly 6de31aa5e0 Merge pull request #129 from theta42/release-v1.31.1
release v1.31.1: sso v1.17.2 + /vault policy fix (setup.sh)
2026-08-01 22:53:37 -04:00
wmantly 6c02e6c63e release: v1.31.1 — sso v1.17.2 + /vault policy fix (setup.sh)
- bump sso-manager-node submodule gitlink v1.17.1 -> v1.17.2
  (post-deploy fixes: auto-slug plugins, schedule dropdown, /profile
  rendering, plugin-edit persistence, nmap in image, SMS/TOS on /conf,
  sso-side /vault policy grants)
- setup.sh: add sso-admin list grant on secret/metadata (KV mount root)
  so the /vault secrets list no longer 403s for admins
- setup.sh: ensure_policy now always (re)writes the policy so policy
  edits apply on a re-run instead of stranding the old HCL
- CHANGELOG embeds the full sso v1.17.2 changelog

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:52:50 -04:00
wmantly e2e8143880 Merge pull request #128 from theta42/release-v1.31.0
v1.31.0: roll up submodules to latest (sso v1.17.1 + ldap-client v1.23.0)
2026-08-01 21:26:55 -04:00
wmantly aa01a5cc07 release: bump submodules to latest tags (v1.31.0)
sso-manager-node v1.16.1 -> v1.17.1 (plugin system v1.17.0 + /conf secret
masking v1.17.1). ldap-client v1.1.1 -> v1.23.0 (CHANGELOG-only, no code
change). proxy v1.13.1 + jump-host v1.14.1 already latest, unchanged.
Changelog embeds the full sso v1.17.0 + v1.17.1 release notes.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:26:13 -04:00
wmantly 1185bb90b8 Merge pull request #127 from theta42/feature/plugin-secrets-policy
v1.30.1: grant sso-broker OpenBao access to secret/plugins/* (plugin-system prerequisite)
2026-08-01 20:50:20 -04:00
wmantly 403e66556c feat: grant sso-broker OpenBao access to secret/plugins/* (v1.30.1)
Prerequisite for the SSO Manager plugin system (shipped in sso-manager-node
v1.17.0). Adds secret/data/plugins/* (CRUD+list) + secret/metadata/plugins/*
(list/read/delete) to the sso-broker policy HCL so the SSO can store per-instance
plugin secrets in OpenBao instead of sso-secrets.js. ensure_policy is idempotent,
so re-running ./setup.sh grants the existing SSO_VAULT_TOKEN live.

Docs: secrets.md (Plugin secrets section + policy row), architecture.md.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 20:33:21 -04:00
wmantly 3287777b9b v1.30.0: rename theta-env -> theta-suite + docs rewrite + sso v1.16.1 (#126)
Rename the project to theta-suite (it is now an integrated suite of four
apps around a shared OpenBao secrets store, not a two-project env).
- theta-env -> theta-suite across the superproject: _config.yml (title +
  baseurl /theta-suite + repo URLs), README, setup.sh (incl. the
  THETA_SUITE_REEXECED self-update sentinel), docker-compose.yml,
  bootstrap.js, lint.yml, config.example/*, docs/robots.txt, all docs,
  this changelog.
- architecture.md rewritten: real 4-service + ldap-client topology, OpenBao
  secrets section, OpenBao-aware config flow; removed "two containers" /
  "three repos" / LDAP-"legacy" framing.
- index.md: integrated-suite framing + secrets/OpenBao + ldap-client.
- standalone.md + README: standalone reframed as advanced opt-in.
- sso-manager-node submodule -> v1.16.1 (401 fix on /conf and /vault).

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 18:46:10 -04:00
wmantly 5ef3e3fa8c v1.29.0: jump host is core + fix fresh-install setup.sh abort (#125)
Two fresh-install fixes and promote the SSH jump host from opt-in to core.

setup.sh: fix silent abort after "Minting per-app OpenBao tokens". env_get's
grep|cut pipeline returns non-zero under set -euo pipefail when .env exists
(created by the root VAULT_TOKEN env_upsert) but an app-token key is absent
(the normal first-run state); the unguarded existing assignment from env_get
then tripped set -e and killed the script before minting any token. env_get
now always returns 0 (|| true). Reproduced + verified under the exact condition.

jump host is no longer optional:
- docker-compose.yml: drop profiles jump-host from the jump-host service
  (always started); rename the opt-in test fixture profile jump-host to ldap-test.
- setup.sh: SUBMODULES always includes jump-host; build/start/register/summary
  no longer guarded by JUMP_ENABLED; drop the COMPOSE_PROFILES export.
- bootstrap.js: jump provisioning + directory record run unconditionally.
- setup.env.example/docs: drop optional/CFG_JUMP_HOST_ENABLED wording.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 13:48:28 -04:00
wmantly a1ea2d458e Merge pull request #124 from theta42/feature/openbao-secrets
v1.28.0: OpenBao as the central secrets store for the stack
2026-08-01 12:55:32 -04:00
wmantly f7df04c2f0 v1.28.0: OpenBao as the central secrets store for the stack
theta-env orchestration:
- setup.sh: idempotent OpenBao policies (sso-broker, sso-admin, proxy,
  jump-host), sso-broker token role (allowed_policies_glob user-*/app-*,
  24h), mint scoped SSO/PROXY/JUMP_VAULT_TOKEN (orphan, .env reuse),
  seed_app_conf seeds secret/{sso-manager,proxy,jump-host}/conf. Bootstrap
  exec passes root VAULT_ADDR/VAULT_TOKEN for seeding. Root token never
  reaches a service container. shellcheck -S warning clean.
- docker-compose.yml: VAULT_ADDR + VAULT_TOKEN env for sso/proxy/jump;
  proxy/jump depends_on openbao service_started.
- bootstrap/bootstrap.js: baoPut() writes generated OAuth creds to
  secret/proxy/conf + secret/jump-host/conf (OpenBao authoritative).
- docs/secrets.md (new): full secrets architecture. README + nav updated.

Submodule bumps:
- sso-manager-node -> v1.16.0 (OpenBao broker + vault UI + remediation)
- proxy -> v1.13.1 (via v1.13.0: OpenBao boot)
- jump-host -> v1.14.1 (via v1.14.0: OpenBao boot)
- ldap-client unchanged

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:54:56 -04:00
wmantly 3277972037 Merge pull request #123 from theta42/release-v1.27.2
Release v1.27.2
2026-08-01 11:14:39 -04:00
wmantly 0c7593cc59 Bump proxy to v1.12.1 2026-08-01 11:13:51 -04:00
wmantly ec625f7cb0 Merge pull request #122 from theta42/release-v1.27.1
Release v1.27.1
2026-08-01 10:27:38 -04:00
wmantly 2a6c7775c9 Bump sso-manager-node to v1.15.2, add integration tests 2026-08-01 10:26:59 -04:00
wmantly 010ff037ce Merge pull request #121 from theta42/release-v1.27.0
Release v1.27.0
2026-08-01 02:45:44 -04:00
wmantly f8f1961b30 Bump sso-manager-node to v1.15.0 and update CHANGELOG for v1.27.0 2026-08-01 02:44:50 -04:00
wmantly 49dee5c477 Merge pull request #120 from theta42/release-v1.26.0
Release v1.26.0: OpenBao production ready
2026-08-01 02:18:03 -04:00
wmantly 1a832d068e Fix shellcheck warning SC2155 2026-08-01 02:17:29 -04:00
wmantly 60ae421cb2 Release v1.26.0: OpenBao production ready 2026-08-01 02:16:33 -04:00
wmantly 90e95bfece Merge pull request #119 from theta42/release-v1.25.0
Release v1.25.0
2026-08-01 01:41:00 -04:00
wmantly 144e97d0e1 Release v1.25.0 2026-08-01 01:40:28 -04:00
wmantly 9dc2de7818 Merge pull request #118 from theta42/release/v1.24.0
Release v1.24.0 - UI polish across all components
2026-07-31 14:45:03 -04:00
wmantly a959b331ae Release v1.24.0 - UI polish across all components
Bumps submodules to their latest releases:
- jump-host v1.13.0: Title changed to 'SSO Manager', TUI picker with ANSI colors
- sso-manager-node v1.13.0: Profile/catalog page redesign, SSH key column fix
- proxy v1.11.0: Table-based list views, auto-refresh groups
2026-07-31 14:43:56 -04:00
wmantly b64083f008 Merge pull request #117 from theta42/release/v1.23.0
Release v1.23.0 - UI polish across all components
2026-07-31 14:08:45 -04:00
wmantly a2fa7a7fc7 Release v1.23.0 - UI polish across all components
Bumps submodules to their latest releases:
- jump-host v1.13.0: Title changed to 'SSO Manager'
- sso-manager-node v1.13.0: Directory page cleanup, users list key column fix
- proxy v1.11.0: Table-based list views, auto-refresh groups
2026-07-31 14:08:00 -04:00
wmantly 6d6aea6011 Merge pull request #116 from theta42/release/v1.22.0
Release v1.22.0 - UI enhancements across all components
2026-07-31 14:01:31 -04:00
wmantly 43e3c79880 Release v1.22.0 - UI enhancements across all components
Bumps submodules to their latest releases:
- jump-host v1.12.0: TUI picker colors, dashboard/audit page styling
- sso-manager-node v1.12.0: Profile page tabs, catalog page redesign
- proxy v1.10.0: Table-based list views, form validation improvements
2026-07-31 13:52:13 -04:00
wmantly 8b549c3315 Release v1.22.0 - UI enhancements across all components
Bumps submodules to their latest releases:
- jump-host v1.12.0: TUI picker colors, dashboard/audit page styling
- sso-manager-node v1.12.0: Profile page tabs, catalog page redesign
- proxy v1.10.0: Table-based list views, form validation improvements

Full changelog entries embedded in each submodule.
2026-07-31 12:57:57 -04:00
wmantly f25a684eb2 Merge pull request #115 from theta42/release/1.21.0
Release 1.21.0: sso-manager-node v1.11.0, ldap-client v1.1.1
2026-07-31 01:31:25 -04:00
wmantly 87d06441c7 Release 1.21.0: sso-manager-node v1.11.0, ldap-client v1.1.1
Bumps the SSO to the release that closes the end-user half of the directory
(catalog, self-service access requests, admin access visibility) and adds
nested LDAP groups, and ldap-client to the release that makes SSSD resolve
that nesting on hosts pointed at a server without the nestgroup overlay.

Operational note: the SSO image now compiles OpenLDAP from a pinned master
commit, because nestgroup exists only on master -- no 2.6.x release ships it.
That makes the image slower to build, and master's LMDB 1.0.0 cannot read the
0.9.x on-disk format from 2.6.x (or vice versa), so moving an existing
/var/lib/ldap onto this image is a slapcat/slapadd reload rather than a
restart. There is a TODO to drop the from-source stage once nestgroup ships
in a release; the entrypoint already probes for it and the app keys off
app_ldap__nestedGroupsServerSide, so that swap needs no other changes.

jump-host and proxy pointers are deliberately unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:30:32 -04:00
wmantly cf1591cdaa Merge pull request #114 from theta42/release/1.20.0
Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
2026-07-30 12:08:25 -04:00
wmantly d4154cfec6 Release 1.20.0: bump submodules for cross-app super admin; persist GIT_COMMIT to .env
- Bump jump-host, ldap-client, proxy, sso-manager-node submodules to their
  new tags (cross-app app_super_admin group, jump-host's app_jump_admin,
  ldap-client's SSH host access for super admins).
- setup.sh: new env_upsert helper persists SSO_GIT_COMMIT/PROXY_GIT_COMMIT/
  JUMP_GIT_COMMIT into ./.env (docker compose's auto-loaded env file) so an
  ad-hoc single-service rebuild outside a full setup.sh run still bakes the
  right commit hash instead of "unknown".
2026-07-30 12:07:12 -04:00
95 changed files with 10483 additions and 576 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
# theta-env — unified SSO Manager + Proxy deployment.
# theta-suite — unified SSO Manager + Proxy deployment.
#
# Copy this file to `.env` and fill in the values, then run `./setup.sh`.
# All values are read by setup.sh / docker-compose / the bootstrap.
+62
View File
@@ -0,0 +1,62 @@
name: CI/CD
on:
push:
branches: [ "main", "master" ]
tags:
- 'v*.*.*'
pull_request:
branches: [ "main", "master" ]
jobs:
build-theta-agent:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build Agent
working-directory: ./theta-agent
run: go build -v ./...
docker-push:
needs: [build-theta-agent]
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
with:
submodules: recursive
- name: Log in to GitHub Container Registry
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and Push SSO Manager
uses: docker/build-push-action@v4
with:
context: ./sso-manager-node
file: ./sso-manager-node/Dockerfile.openldap
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/sso-manager:latest
ghcr.io/${{ github.repository_owner }}/sso-manager:${{ github.ref_name }}
- name: Build and Push Proxy
uses: docker/build-push-action@v4
with:
context: ./proxy
file: ./proxy/Dockerfile
push: true
tags: |
ghcr.io/${{ github.repository_owner }}/theta-proxy:latest
ghcr.io/${{ github.repository_owner }}/theta-proxy:${{ github.ref_name }}
+6 -2
View File
@@ -1,6 +1,6 @@
name: Lint
# theta-env has no app code of its own to unit-test (it orchestrates the
# theta-suite has no app code of its own to unit-test (it orchestrates the
# proxy/sso-manager-node submodules) -- this checks the one thing that can
# actually break silently: setup.sh and bootstrap.js, plus a static
# consistency check on the config bootstrap.js generates for jump-host
@@ -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
+11 -2
View File
@@ -5,8 +5,12 @@
config/
backups/
# Legacy .env / proxy.env (no longer used — config is in ./config/). Still
# ignored in case a migrated deployment hasn't deleted them yet.
# .env: NOT app config (that's ./config/, generated by setup.sh) — this is
# docker compose's own auto-loaded env file, which setup.sh uses only to
# persist *_GIT_COMMIT build args so an ad-hoc rebuild of a single service
# still bakes the right commit hash. Generated; never commit.
# proxy.env: legacy, no longer used — still ignored in case an old
# deployment hasn't deleted it yet.
.env
proxy.env
@@ -15,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
+4 -1
View File
@@ -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
@@ -11,3 +11,6 @@
[submodule "ldap-client"]
path = ldap-client
url = https://github.com/theta42/ldap-client.git
[submodule "theta-agent"]
path = theta-agent
url = https://github.com/theta42/theta-agent.git
+1398 -36
View File
File diff suppressed because it is too large Load Diff
+107 -100
View File
@@ -1,66 +1,68 @@
# theta-env
# 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 wires together two projects that already work on their own:
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.
- **[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.
Each project still runs **standalone** (`docker compose up` in its own folder);
this repo just composes them and automates the first-run glue so they find each
other.
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-env/](https://theta42.github.io/theta-env/)
**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 |
| --- | --- |
| [![SSO Manager dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
| [![Theta Directory dashboard](docs/images/sso-dashboard.png)](docs/images/sso-dashboard.png) | [![Proxy host list](docs/images/proxy-hosts.png)](docs/images/proxy-hosts.png) |
**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` asks for your domain once (in
`setup.env`), generates both config files with it filled in everywhere, registers
the proxy as an OIDC client, and snapshots state before every rebuild — so you
get a working SSO + proxy stack in one command and a safe way to upgrade it.
## System Architecture
```
┌──────────────────────────────────────────────┐
│ your browser / apps
└───────────────┬──────────────────────────────┘
│ https
┌─────────▼─────────┐
proxyOpenResty :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.
@@ -121,26 +123,32 @@ see browser warnings.)
Optional extra ports (only if you need them):
- **4443** — alternate HTTPS listener (e.g. if 443 is taken by something else).
- **636** (LDAPS) — only if a legacy app on another machine binds to LDAP
directly over the network. The proxy itself reaches LDAP over the internal
Docker network, so you do **not** need to expose 636 for the stack to work.
**Do not forward 636 to the public internet.** If you need LAN clients to bind
LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
- **389** (LDAP) + **636** (LDAPS) — direct-LDAP access. The stack host's **own**
enrollment (`setup.sh` → ldap-client) configures its sssd against
`ldap://localhost:389` / `ldaps://localhost:636`, so both ports are published
to the host by default (bind 0.0.0.0; set `LDAP_BIND`/`LDAPS_BIND=127.0.0.1` to
lock to the host). LAN clients (Linux hosts via PAM/SSSD, LDAP-native apps) can
bind over either; the proxy itself reaches LDAP over the internal Docker
network and doesn't need them.
**Do not forward 389/636 to the public internet.** If you need LAN clients to
bind LDAP, set `CFG_LDAPS_HOST=ldap.internal.example.com` (or `sso-manager` for
same-host Docker clients) in `setup.env` and use an internal DNS record / cert
SAN. The default shows the public SSO hostname, which implies a public route.
### 4. Docker + Docker Compose
Any recent Docker with Compose — the v2 plugin (`docker compose`) or the v1
standalone (`docker-compose`) both work.
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.
---
## Quickstart
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
./setup.sh # first run: generates ./config/ from setup.env, builds + bootstraps + starts
```
@@ -177,11 +185,21 @@ operator-owned and `setup.env` is ignored.
would 404. Idempotent; skips a host that already exists.
6. Prints your first admin login + the public URLs.
### Configuration — `./config/` (no `.env` files)
### Configuration & secrets — OpenBao + `./config/`
All config and secrets live in a bind-mounted `./config/` directory (gitignored),
read by each app's `@simpleworkjs/conf` via the `CONF_SECRETS` env var, which
the entrypoint points at the mounted file:
Secrets live in **OpenBao** (a Vault fork, container `openbao:8200` on
`theta-net`), the single authoritative store. Each app loads them at boot with
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/<app>/conf` over the file-loaded config — **fail-soft**, so
if OpenBao is unreachable the app boots from the file fallback. End users get
personal per-user secret storage (`secret/users/<uid>/*`) in the SSO **Vault**
UI, and admins mint scoped tokens for external apps (`secret/apps/<name>/*`).
See **[docs/secrets.md](docs/secrets.md)** for the full architecture, policies,
token model, rotation, and the external-app convention.
A bind-mounted `./config/` directory (gitignored) holds the operator-edit
seed files and the fail-soft fallback, read by each app's `@simpleworkjs/conf`
via the `CONF_SECRETS` env var:
- **`./config/sso-secrets.js`** — SSO config: `ldap` (base, admin password,
user/group bases), `oauth` (issuer, `jwtSecret`), `smtp`, `name`, plus
@@ -192,11 +210,15 @@ the entrypoint points at the mounted file:
same `serviceAccountPass`), `auth` (admin groups/users).
`./setup.sh` generates both on first run from `./setup.env` (the one place the
domain is entered — see *Quickstart*) with random secrets. There is **no
`.env` / `proxy.env`** — edit `./config/*.js` directly. Compose only interpolates
port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can override on the
command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See `config.example/` for the
full annotated shape, and each submodule's `secrets.js.example`.
domain is entered — see *Quickstart*) with random secrets, seeds them into
OpenBao, and mints scoped per-app tokens (`SSO_VAULT_TOKEN` /
`PROXY_VAULT_TOKEN` / `JUMP_VAULT_TOKEN`) into `./.env`. There is **no
`.env` / `proxy.env`** for app config — edit `./config/*.js` directly (and
re-seed into OpenBao, or use the SSO Configuration UI for live edits). Compose
only interpolates port defaults (`SSO_PORT`, `HTTP_PORT`, etc.), which you can
override on the command line: `SSO_BIND=127.0.0.1 ./setup.sh`. See
`config.example/` for the full annotated shape, and each submodule's
`secrets.js.example`.
> **Migrating from an older `.env`-based deployment?** If `.env` and/or
> `proxy.env` exist when you first run `./setup.sh`, it migrates them into
@@ -215,9 +237,10 @@ full annotated shape, and each submodule's `secrets.js.example`.
- **Proxy mgmt UI**: `https://<PROXY_HOST>` — add the Host records you want to
protect with OIDC. (First-run fallback: `http://<host>:3000`, reachable on the
LAN by default.)
- **Direct LDAP for legacy apps**: bind to `ldaps://<host>:636` as
`cn=admin,<base>` (admin) or `cn=ldapclient,ou=people,<base>` (read-only
service account the bootstrap created). Use LDAPS, not plain LDAP.
- **Direct LDAP for LDAP-native clients and Linux hosts**: bind to
`ldaps://<host>:636` as `cn=admin,<base>` (admin) or
`cn=ldapclient,ou=people,<base>` (read-only service account the bootstrap
created). Use LDAPS, not plain LDAP.
### API tokens (personal access tokens)
@@ -247,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
@@ -325,8 +350,11 @@ docker compose cp sso-manager:/data/dump.rdb sso-manager.rdb
docker compose exec proxy redis-cli BGSAVE
docker compose cp proxy:/data/dump.rdb proxy.rdb
# Secrets
# Secrets — ./config/ (the seed/fallback; OpenBao is authoritative)
cp -a ./config config-backup && chmod 700 config-backup
# OpenBao (the authoritative secret store — back up its data volume)
docker run --rm -v theta-suite_openbao-data:/data -v "$PWD":/backup alpine \
tar czf /backup/openbao-data.tgz -C /data .
```
### Restore — full disaster recovery
@@ -377,30 +405,6 @@ Redis and are preserved by the volume.
---
## Running each project standalone
The two submodules work on their own — this repo just composes them:
- **SSO Manager alone**:
```bash
cd sso-manager-node
mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build
```
See its [DEPLOYMENT.md](sso-manager-node/DEPLOYMENT.md).
- **Proxy alone** (pointing at any external SSO + LDAP via a mounted
`secrets.js`):
```bash
cd proxy
mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build
```
See its [DEPLOYMENT.md](proxy/DEPLOYMENT.md).
No cross-repo file edits are needed at runtime — the unified stack is pure
composition (one compose file + one bootstrap script).
---
## How the first-run wiring works
@@ -465,15 +469,18 @@ exactly in the bootstrap) so the SSO can verify them on bind.
## Repo layout
```
theta-env/
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
@@ -486,7 +493,7 @@ tagged release of each app, not whatever's most recently merged upstream. To
lock to the pinned commits (offline rebuild, or a deliberate pin), run
`SKIP_SUBMODULE_UPDATE=1 ./setup.sh`.
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-env release
See [CHANGELOG.md](CHANGELOG.md) for what changed in each theta-suite release
(and 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)
+480 -44
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/*
* theta-env bootstrap runs inside the sso-manager container to wire the
* theta-suite bootstrap runs inside the sso-manager container to wire the
* proxy into a (fresh or existing) SSO Manager. Invoked by setup.sh:
*
* docker compose exec sso-manager node /bootstrap/bootstrap.js
@@ -23,6 +23,13 @@
* into the file (the sso-manager mounts ./config
* read-write for this purpose).
*
* Generated creds are ALSO written into OpenBao (secret/proxy/conf and, when
* the jump host is enabled, secret/jump-host/conf) so the proxy + jump host
* load them from OpenBao at boot via @simpleworkjs/bao-conf. setup.sh passes
* the root VAULT_TOKEN on this exec for that purpose. The OpenBao write is
* fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the /config
* file remains the fallback and bootstrap does not fail the bring-up over it.
*
* Idempotent: re-running converges to the ./config values. The LDAP service
* account + admin passwords are reset to the file values on each run; the
* OAuth client is created if missing. If proxy-secrets.js already holds a
@@ -77,16 +84,80 @@ const HAS_USABLE_CREDS = EXISTING_ID && EXISTING_SECRET
&& !PLACEHOLDER.test(EXISTING_ID) && !PLACEHOLDER.test(EXISTING_SECRET);
const REDIRECT_URI = `https://${PROXY_HOST}/api/auth/oidc/callback`;
// Per-host SSO (proxy routes/host_auth.js) calls back to
// `https://<proxied-host>/__proxy_auth/callback` — a DIFFERENT URL for every
// host the proxy fronts, all against this one OAuth client. Registering just
// REDIRECT_URI above is what produced "400 redirect_uri is not registered for
// this client" the moment a host's auth was set to SSO. The SSO's
// redirectUriAllowed() supports `**` (any number of labels), so one pattern
// covers the whole domain; `**.` does not match the bare apex, so register that
// separately for a host served at the domain itself.
//
// A function, not a const: DOMAIN is declared further down this file, so
// evaluating it here at module scope would hit the temporal dead zone.
function proxyRedirectUris() {
if (!DOMAIN) return [REDIRECT_URI];
return [
REDIRECT_URI,
`https://**.${DOMAIN}/__proxy_auth/callback`,
`https://${DOMAIN}/__proxy_auth/callback`,
];
}
const SSO_INTERNAL = 'http://localhost:3001';
const CLIENT_NAME = 'theta-proxy';
const ADMIN_DN = `cn=${ADMIN_UID},ou=people,${BASE_DN}`;
const SVC_DN = `cn=ldapclient,ou=people,${BASE_DN}`;
const ADMIN_GROUPS = ['app_sso_admin', 'app_sso_oauth_admin'];
// god_admin is the global super group (docs/GROUPS.md §2); the bootstrapped
// admin is its first member. app_sso_admin / app_sso_oauth_admin are the
// per-console admin groups still used by the SSO UI. god_admin is nested into
// the app_sso_* groups (and every resource's _admin group) by
// docker-entrypoint.sh + api_directory_admin, so LDAP-level consumers (SSSD,
// sudo) resolve it transitively.
const ADMIN_GROUPS = ['god_admin', 'app_sso_admin', 'app_sso_oauth_admin'];
const log = (...a) => process.stderr.write('[bootstrap] ' + a.join(' ') + '\n');
const out = (k, v) => process.stdout.write(`${k}=${v}\n`);
// ── OpenBao (Vault) writes ───────────────────────────────────────────────────
// bootstrap generates the proxy's + jump host's OAuth client creds and writes
// them back into /config/*-secrets.js (the file fallback). It ALSO writes the
// complete conf into OpenBao so the proxy + jump host load it from there at
// boot via @simpleworkjs/bao-conf. setup.sh passes the root VAULT_TOKEN on this
// exec. Fail-soft: if VAULT_TOKEN is unset or OpenBao is unreachable, the write
// is skipped with a warning — the file remains the fallback and bootstrap does
// not fail the bring-up over it.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const VAULT_TOKEN = process.env.VAULT_TOKEN || '';
// Re-require a /config module after its file has been rewritten on disk
// (require caches the old contents otherwise).
function freshRequire(p) {
delete require.cache[require.resolve(p)];
return require(p);
}
// PUT (replace) the data at secret/data/<vaultPath> with `data`. Warn-only.
async function baoPut(vaultPath, data) {
if (!VAULT_TOKEN) { log('OpenBao: VAULT_TOKEN unset — skipping write of secret/' + vaultPath); return; }
try {
const res = await fetch(`${VAULT_ADDR}/v1/secret/data/${vaultPath}`, {
method: 'POST',
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
log(`WARNING: OpenBao write secret/${vaultPath} failed (${res.status}) ${text} — app will use its file fallback`);
} else {
log(`OpenBao: wrote secret/${vaultPath}`);
}
} catch (e) {
log(`WARNING: OpenBao write secret/${vaultPath} threw (${e.message}) — app will use its file fallback`);
}
}
// Replicate the SSO's hashPasswordSSHA512 (models/user_ldap.js) exactly so the
// directory stores passwords the SSO can verify on bind (pw-sha2 module).
function hashPasswordSSHA512(password) {
@@ -127,31 +198,88 @@ function ldapModify(ldif) {
}
// ── 1. LDAP service account for the proxy ───────────────────────────────────
// The proxy / ldap-client bind as cn=ldapclient. For it to SHOW in the SSO Users
// UI as a service account it must (a) match the user filter (posixAccount) and
// (b) be a member of app_sso_service_account (that membership is what the Users
// page marks as a non-person/service account). Older bootstraps created it as a
// bare organizationalRole (invisible to the Users list) and never joined the
// group, so it never appeared. Both are fixed here; the existing-path shape add
// is best-effort so a pre-existing account still binds even if the upgrade add
// fails.
function ensureServiceAccount() {
const pw = hashPasswordSSHA512(SVC_PASS);
const uidNum = '10001'; // distinct from the bootstrap admin's 10000; above uidGidReservedFloor so regular-user id allocation ignores it
if (entryExists(SVC_DN)) {
log(`Service account ${SVC_DN} exists — resetting password to ./config`);
const r = ldapModify([
log(`Service account ${SVC_DN} exists — ensuring service-account shape + password`);
// Add the auxiliary posixAccount objectClass + required attrs so the entry
// matches the Users list filter. inetOrgPerson is deliberately NOT added:
// it is structural and would conflict with the existing organizationalRole.
const shape = [
`dn: ${SVC_DN}`,
'changetype: modify',
'add: objectClass',
'objectClass: posixAccount',
'-',
'add: uid',
'uid: ldapclient',
'-',
'add: uidNumber',
`uidNumber: ${uidNum}`,
'-',
'add: gidNumber',
`gidNumber: ${uidNum}`,
'-',
'add: homeDirectory',
'homeDirectory: /nonexistent',
'-',
'add: description',
'description: LDAP bind service account (proxy / ldap-client)',
'',
].join('\n');
const rs = ldapModify(shape);
if (rs.code !== 0 && !/already exists|Type or value exists/i.test(rs.stderr)) {
log(' service-account shape warning (account still binds):', rs.stderr.trim());
}
const rp = ldapModify([
`dn: ${SVC_DN}`,
'changetype: modify',
'replace: userPassword',
`userPassword: ${pw}`,
'',
].join('\n'));
if (r.code !== 0) log(' password reset warning:', r.stderr.trim());
return;
if (rp.code !== 0) log(' password reset warning:', rp.stderr.trim());
} else {
log(`Creating service account ${SVC_DN}`);
const entry = [
`dn: ${SVC_DN}`,
'objectClass: inetOrgPerson',
'objectClass: posixAccount',
'objectClass: top',
'cn: ldapclient',
'sn: ldapclient',
'uid: ldapclient',
`uidNumber: ${uidNum}`,
`gidNumber: ${uidNum}`,
'homeDirectory: /nonexistent',
'description: LDAP bind service account (proxy / ldap-client)',
`userPassword: ${pw}`,
'',
].join('\n');
const r = ldapAdd(entry);
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
}
log(`Creating service account ${SVC_DN}`);
const r = ldapAdd([
`dn: ${SVC_DN}`,
'objectClass: organizationalRole',
'objectClass: simpleSecurityObject',
'objectClass: top',
'cn: ldapclient',
`userPassword: ${pw}`,
// Mark it as a service account (the Users UI's service-account signal).
const gdn = `cn=app_sso_service_account,ou=groups,${BASE_DN}`;
const rm = ldapModify([
`dn: ${gdn}`,
'changetype: modify',
'add: member',
`member: ${SVC_DN}`,
'',
].join('\n'));
if (r.code !== 0) throw new Error(`ldapadd service account failed: ${r.stderr.trim()}`);
if (rm.code === 0) log(` marked ${SVC_DN} as a service account`);
else if (/already exists|Type or value exists/i.test(rm.stderr)) log(` ${SVC_DN} already in app_sso_service_account`);
else log(` app_sso_service_account membership warning:`, rm.stderr.trim());
}
// ── 2. First admin user ─────────────────────────────────────────────────────
@@ -230,7 +358,7 @@ async function listClients(token) {
}
async function createClient(token, opts) {
const o = opts || { name: CLIENT_NAME, description: 'theta-env proxy (auto-registered)', redirect_uris: [REDIRECT_URI] };
const o = opts || { name: CLIENT_NAME, description: 'theta-suite proxy (auto-registered)', redirect_uris: proxyRedirectUris() };
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
@@ -254,6 +382,29 @@ async function createClient(token, opts) {
return { id, secret };
}
// Add any redirect_uris the client is missing, keeping whatever the operator
// has already registered. Backfills installs whose proxy client was created
// before the per-host `__proxy_auth/callback` patterns existed — without this,
// setting a host's auth to SSO fails with "400 redirect_uri is not registered
// for this client" on an upgraded stack and only works on a fresh one.
// Warn-only: a stack that cannot widen its client is still a working stack.
async function ensureRedirectUris(token, client, wanted) {
const have = client.redirect_uris || [];
const missing = wanted.filter((u) => !have.includes(u));
if (!missing.length) return;
try {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${client.client_id}`, {
method: 'PUT',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ redirect_uris: [...have, ...missing] }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
log(` OAuth client ${client.name}: registered ${missing.length} redirect URI(s) for per-host SSO`);
} catch (error) {
log(` WARNING: could not add redirect URIs to ${client.name}: ${error.message}`);
}
}
async function rotateClient(token, id) {
const res = await fetch(`${SSO_INTERNAL}/api/oauth/client/${id}/rotate`, {
method: 'POST',
@@ -317,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.
@@ -336,6 +499,30 @@ const HOST_FACTS = {
async function seedDirectory(token, clientId, jumpClientId) {
let resources = ((await dirGet(token, 'resources')).results) || [];
// Tolerated separately from the resource list: edges only drive the
// re-parent + OAuth-link steps, and losing those is not a reason to skip
// seeding the resources themselves.
let edges = [];
try { edges = ((await dirGet(token, 'edges')).results) || []; }
catch (e) { log(` WARNING: could not list directory edges (${e.message}) — skipping re-parent/link steps`); }
// Move an already-seeded resource under the parent it should have had.
// Only ever corrects a parent this bootstrap itself seeded wrongly (the
// proxy/jump services were parented to the stack host instead of to
// host_theta-proxy / host_theta-jump); an operator who has deliberately
// re-parented something keeps their layout, because we only rewire when the
// current parent is the one the old code would have set.
async function reparent(resource, wantParentId, fromParentId) {
if (!resource || !wantParentId || !fromParentId) return;
const current = edges.find((e) => e.childId === resource.id && (e.relation === 'hosts' || e.relation === 'oauth'));
if (!current) return; // unparented: leave it alone
if (current.parentId === wantParentId) return; // already correct
if (current.parentId !== fromParentId) return; // operator moved it: respect that
// PUT with kind + hostId is what makes the route rewire the parent edge.
await dirPut(token, `resources/${resource.id}`, { kind: resource.kind, hostId: wantParentId });
current.parentId = wantParentId;
log(` directory: re-parented ${resource.kind} '${resource.slug}' onto its own host`);
}
// Create a resource unless its slug (or a legacy alternate from an earlier
// seed layout) already exists. On an existing resource, seed metadata keys
@@ -378,23 +565,52 @@ async function seedDirectory(token, clientId, jumpClientId) {
const host = await ensure('host', HOST_FACTS.name || 'Stack host', hostSlug, site.id, {
subType: 'linux',
ip: HOST_FACTS.ip,
address: HOST_FACTS.ip,
macAddress: HOST_FACTS.mac,
os: HOST_FACTS.os,
kernel: HOST_FACTS.kernel,
sshPort: 22,
managed: true,
}, ['stack-host']);
// "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}`,
port: 3001,
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'web',
icon: 'mdi:shield-account',
tagline: 'Home-lab identity and access management.',
requestable: false,
});
// Proxy = the node management UI; OpenResty = the data plane every hostname
// in the stack actually flows through (80/443). Two faces, two entries.
// in the stack actually flows through (80/443). Two faces, two entries, both
// parented directly to the stack host — see the "Host means..." note above.
const psvc = await ensure('service', 'Proxy', 'proxy', host.id, {
address: `https://${PROXY_HOST}`,
port: 3000,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'web',
icon: 'mdi:server-network',
tagline: 'Reverse proxy and API gateway.',
requestable: false,
});
// OpenLDAP is independently consumed — Linux hosts authenticate against it
// (PAM/SSSD, sudoRole, sshPublicKey) and LDAP-native apps bind directly
@@ -406,8 +622,12 @@ async function seedDirectory(token, clientId, jumpClientId) {
address: `ldaps://${LDAPS_HOST}:636`,
port: 389,
externalPort: 636,
portMappings: [{ proto: 'tcp', external: 636, internal: 389, comment: 'LDAPS' }],
gitRepo: 'https://github.com/theta42/sso-manager-node',
subType: 'openldap',
icon: 'mdi:book-open-outline',
tagline: 'LDAP directory for identity.',
requestable: false,
});
// Wildcard address: OpenResty fronts every host under the domain (same
// */** wildcard convention the proxy's Host records use). Its config lives
@@ -417,30 +637,78 @@ async function seedDirectory(token, clientId, jumpClientId) {
port: 443,
gitRepo: 'https://github.com/theta42/proxy',
subType: 'openresty',
icon: 'mdi:router-network',
tagline: 'Data plane.',
requestable: false,
});
// Optional SSH jump host service.
// 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;
if (/^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '')) {
{
const jumpHost = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
jumpSvc = await ensure('service', 'SSH Jump Host', 'jump-host', host.id, {
address: jumpHost ? `https://${jumpHost}` : '',
port: 3002,
gitRepo: 'https://github.com/theta42/jump-host',
subType: 'ssh',
icon: 'mdi:ssh',
tagline: 'Secure SSH jump host.',
requestable: false,
});
}
// Correct installs seeded between 2026-08-05 and this release, where Proxy's
// and jump-host's services were parented to now-removed synthetic
// `host_theta-proxy` / `host_theta-jump` resources instead of the stack
// host. Look them up by slug (never created going forward) rather than
// `ensure`-ing them back into existence: on any install that never had
// them, or already got corrected, this is a no-op.
const proxyHostRes = resources.find((r) => r.slug === 'host_theta-proxy');
const jumpHostRes = resources.find((r) => r.slug === 'host_theta-jump');
if (proxyHostRes) {
await reparent(psvc, host.id, proxyHostRes.id);
await reparent(resources.find((r) => r.slug === 'openresty'), host.id, proxyHostRes.id);
}
if (jumpHostRes) {
await reparent(jumpSvc, host.id, jumpHostRes.id);
}
// Once childless, the synthetic host itself is dead weight from this file's
// own earlier mistake — never something an operator would hand-create at
// these exact reserved slugs — so remove it. DELETE /resources/:id clears
// its own edges first, so this is safe now that the reparents above have
// already moved the real children off of it.
async function removeIfChildless(resource, label) {
if (!resource) return;
const stillHasChildren = edges.some((e) => e.parentId === resource.id);
if (stillHasChildren) {
log(` directory: '${label}' still has children after reparenting — leaving it for now`);
return;
}
await dirDelete(token, `resources/${resource.id}`);
log(` directory: removed now-empty synthetic host '${label}'`);
}
await removeIfChildless(proxyHostRes, 'host_theta-proxy');
await removeIfChildless(jumpHostRes, 'host_theta-jump');
// Link an OAuth client (Resource-backed since sso-manager 1.3.0) under its
// owning service, if it appears in the directory and isn't linked yet.
async function linkOauthClient(id, parent, label) {
if (!id || !parent) return;
const oauthRes = resources.find((r) => r.id === id);
if (!oauthRes) return;
const edges = ((await dirGet(token, 'edges')).results) || [];
const linked = edges.some((e) => e.childId === id);
if (!linked) {
await dirPost(token, 'edges', { parentId: parent.id, childId: id, relation: 'oauth' });
edges.push({ parentId: parent.id, childId: id, relation: 'oauth' });
log(` directory: linked OAuth client under '${label}'`);
}
}
@@ -448,6 +716,68 @@ async function seedDirectory(token, clientId, jumpClientId) {
await linkOauthClient(jumpClientId, jumpSvc, 'jump-host');
}
// ── Plugin instances ────────────────────────────────────────────────────────
// Seed a sensible default set of plugin instances so the stack is usable the
// moment it boots, without the operator having to add them by hand. The setup
// stack runs on Docker, so the single biggest win is a Docker discovery plugin
// pointed at the local daemon socket: containers that make up the stack (and
// any others on the host) get discovered into the Directory automatically.
// Idempotent per slug: an instance an operator already created is left alone.
async function seedPlugins(token) {
async function pluginGet(path) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/${path}`, {
headers: { 'auth-token': token },
});
if (!res.ok) throw new Error(`GET /api/plugins/${path} failed (${res.status})`);
return res.json();
}
async function pluginPost(body) {
const res = await fetch(`${SSO_INTERNAL}/api/plugins/`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`POST /api/plugins failed (${res.status}): ${text}`);
}
return res.json();
}
async function ensurePlugin({ pluginType, name, slug, config }) {
const existing = ((await pluginGet('')).results) || [];
if (existing.some((i) => i.slug === slug)) {
log(` plugins: '${slug}' exists — keeping`);
return;
}
await pluginPost({ pluginType, name, slug, config });
log(` plugins: created '${slug}' (${pluginType})`);
}
try {
// The Docker daemon the setup stack itself runs under. The socket must be
// mounted into the sso container for discovery to reach it; if it isn't,
// discovery simply errors non-fatally until it is.
await ensurePlugin({
pluginType: 'docker',
name: 'Local Docker daemon',
slug: 'docker-local',
config: {
socketPath: '/var/run/docker.sock',
// Containers in our own compose project are the stack itself --
// already seeded as services above. Telling the plugin which
// project that is lets it mark them managed and attach them to
// the service they implement, instead of a fresh install
// presenting its own five containers as unmanaged discoveries.
stackProject: process.env.COMPOSE_PROJECT_NAME || 'theta-suite',
hostSlug: HOST_FACTS.name ? `host_${slugify(HOST_FACTS.name)}` : '',
},
});
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
}
// Write the OAuth client creds back into /config/proxy-secrets.js so the proxy
// (which reads that file) can use them. Only the clientId/clientSecret lines
// are touched; the rest of the file (operator edits, comments) is preserved.
@@ -478,27 +808,99 @@ function writeProxyCreds(id, secret) {
}
}
// ── 6. Optional: provision the SSH jump host ────────────────────────────────
// When CFG_JUMP_HOST_ENABLED=true, the jump host needs: a directory API token
// (to resolve which hosts a user may reach), an LDAP bind account that can
// WRITE the sshPublicKey attribute (it injects its own key on first use), and
// a config file it reads. We write /config/jump-secrets.js deriving LDAP/site
// from sso-secrets.js + a freshly minted API token. The bundled jump host
// binds as cn=admin (already able to write sshPublicKey) — hardened bare-metal
// deployments should use a scoped account + attribute ACL instead (see the
// jump-host README). Idempotent: skips if the file already has a real token.
const JUMP_ENABLED = /^(1|true|yes)$/i.test(process.env.CFG_JUMP_HOST_ENABLED || '');
// The proxy needs a read-only SSO API token so its per-host SSO allow-list can
// suggest the directory's actual groups (otherwise the "Allowed groups" field
// autocompletes from the proxy's local groups only, which for an SSO-gated host
// is never what the operator wants). Idempotent: only mints when the file's
// `sso.apiToken` is still empty, and only rewrites that one line. Warn-only —
// no token just means no suggestions.
const PROXY_TOKEN_NAME = 'theta-proxy';
async function ensureProxyApiToken(token) {
const path = '/config/proxy-secrets.js';
let src;
try {
src = fs.readFileSync(path, 'utf8');
} catch (e) {
log(` WARNING: cannot read ${path} to add an SSO API token (${e.message})`);
return;
}
// An `sso: { ... apiToken: 'sso_...' }` already present means we're done.
if (/apiToken:\s*['"]sso_[0-9a-f]{24}_[0-9a-f]{48}['"]/.test(src)) {
log(' proxy already has an SSO API token — keeping');
return;
}
if (!/\bsso:\s*\{/.test(src)) {
log(` WARNING: ${path} has no \`sso\` block — add one with url + apiToken to enable SSO group autocomplete`);
return;
}
try {
const apiToken = await mintApiToken(token, PROXY_TOKEN_NAME, 'theta-suite proxy (auto-registered)');
// Replace the apiToken line inside the sso block only. The jump host's
// token lives in a different file, so an unanchored match is safe here.
const updated = src.replace(/(apiToken:\s*)(['"])[^'"]*\2/, `$1$2${apiToken}$2`);
if (updated === src) {
log(` WARNING: could not locate apiToken in ${path} — set sso.apiToken manually`);
return;
}
fs.writeFileSync(path, updated);
log(` Minted SSO API token for the proxy and wrote it into ${path}`);
} catch (e) {
log(` WARNING: could not provision the proxy's SSO API token: ${e.message}`);
}
}
// Mint (or reuse) a theta-agent join key and hand it to setup.sh.
//
// A join key is the single credential an operator needs to add a host: the
// agent presents it, the SSO enrolls the host and issues it its own per-agent
// token + public key, which the agent writes back into its agent.yml. Without
// this, adding a host meant pre-registering it in the SSO and copying two
// values onto the machine by hand -- and setup.sh's own agent install had no
// way to produce a token the server would accept at all.
//
// Idempotent: reuses the existing `setup` key rather than piling up new ones.
// A key can only be shown once, so if the stored one is not recoverable we mint
// a replacement and label it for the run that created it.
async function ensureAgentJoinKey(token) {
try {
const res = await fetch(`${SSO_INTERNAL}/api/agent/join-keys`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ label: 'setup' }),
});
if (!res.ok) throw new Error(`${res.status} ${await res.text().catch(() => '')}`);
const data = await res.json();
if (!data.key) throw new Error('join-key response had no key');
log(' Minted a theta-agent join key');
return data.key;
} catch (error) {
log(` WARNING: could not mint a theta-agent join key: ${error.message}`);
return '';
}
}
// ── 6. Provision the SSH jump host ─────────────────────────────────────────
// The jump host is a core component (always provisioned). It needs: a directory
// API token (to resolve which hosts a user may reach), an LDAP bind account
// that can WRITE the sshPublicKey attribute (it injects its own key on first
// use), and a config file it reads. We write /config/jump-secrets.js deriving
// LDAP/site from sso-secrets.js + a freshly minted API token. The bundled jump
// host binds as cn=admin (already able to write sshPublicKey) — hardened
// bare-metal deployments should use a scoped account + attribute ACL instead
// (see the jump-host README). Idempotent: skips if the file already has a real
// token.
const JUMP_HOST = process.env.CFG_JUMP_HOST || (DOMAIN ? `jump.${DOMAIN}` : '');
const JUMP_SECRETS = '/config/jump-secrets.js';
const JUMP_TOKEN_NAME = 'theta-jump-host';
const JUMP_CLIENT_NAME = 'theta-jump';
const JUMP_REDIRECT_URI = `https://${JUMP_HOST}/api/auth/oidc/callback`;
async function mintApiToken(token, name) {
async function mintApiToken(token, name, description) {
const res = await fetch(`${SSO_INTERNAL}/api/api-token`, {
method: 'POST',
headers: { 'auth-token': token, 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description: 'theta-env jump host (auto-registered)' }),
body: JSON.stringify({ name, description: description || 'theta-suite jump host (auto-registered)' }),
});
if (!res.ok) throw new Error(`mint API token failed (${res.status}): ${await res.text().catch(() => '')}`);
const data = await res.json();
@@ -523,12 +925,11 @@ function writeJumpSecrets(apiToken, oidc, localAdminPass) {
const siteName = (sso.stack && sso.stack.siteName) || 'local';
const ldapsHost = (sso.ldap && sso.ldap.ldapsHost) || SSO_HOST;
const body = `'use strict';
// Generated by theta-env bootstrap. The jump host reads this via
// Generated by theta-suite bootstrap. The jump host reads this via
// @simpleworkjs/conf (CONF_SECRETS). Binds as cn=admin so it can write the
// sshPublicKey attribute (key injection); for a hardened deployment use a
// scoped account with an sshPublicKey write-ACL instead (see jump-host README).
module.exports = {
\tname: ${JSON.stringify(sso.name || 'SSO Manager')},
\tldap: {
\t\t// ldaps:// (636), not ldap:// (389): @simpleworkjs/ldap's client always
\t\t// sets tlsOptions (see jump-host's models/user_ldap.js), and ldapts
@@ -608,7 +1009,7 @@ async function provisionJumpHost(token) {
} else {
oidc = await createClient(token, {
name: JUMP_CLIENT_NAME,
description: 'theta-env jump host web UI (auto-registered)',
description: 'theta-suite jump host web UI (auto-registered)',
redirect_uris: [JUMP_REDIRECT_URI],
});
}
@@ -634,6 +1035,10 @@ async function provisionJumpHost(token) {
if (HAS_USABLE_CREDS) client = list.find((c) => c.client_id === EXISTING_ID);
if (!client) client = list.find((c) => c.name === CLIENT_NAME);
// Widen an existing client before any of the branches below return: a
// freshly created one already gets these from createClient().
if (client) await ensureRedirectUris(token, client, proxyRedirectUris());
if (client && HAS_USABLE_CREDS && client.client_id === EXISTING_ID) {
// File creds match an existing client — trust the file's secret
// (it's bcrypt-hashed server-side, so we can't verify, but the proxy
@@ -663,18 +1068,40 @@ async function provisionJumpHost(token) {
resolvedClientId = id;
}
// Provision the jump host (mint token + write config) when enabled.
// Warn-only — never fail the whole bring-up over the optional service.
// Must run before the baoPut below: that snapshots proxy-secrets.js into
// OpenBao, and the proxy loads its conf from there at boot, so a token
// written after the snapshot would never reach the running proxy.
await ensureProxyApiToken(token);
// Mirror the (now-current) proxy-secrets.js into OpenBao so the proxy
// loads it from there at boot via @simpleworkjs/bao-conf. Re-require
// fresh: writeProxyCreds rewrote the file out from under the cached
// `proxy` object. setup.sh's seed already put a placeholder version
// here; this replaces it with the complete file (operator edits +
// generated OAuth creds). Warn-only.
await baoPut('proxy/conf', freshRequire('/config/proxy-secrets.js'));
// Provision the jump host (mint token + write config). Warn-only — never
// fail the whole bring-up over it, but it's a core component so always
// attempted (no longer gated by CFG_JUMP_HOST_ENABLED).
let jumpClientId = null;
if (JUMP_ENABLED) {
try {
jumpClientId = await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
try {
jumpClientId = await provisionJumpHost(token);
out('JUMP_HOST_CONFIGURED', '1');
// Mirror jump-secrets.js (just written by provisionJumpHost) into
// OpenBao so the jump host loads it from there at boot via
// @simpleworkjs/bao-conf. setup.sh's seed may have put a
// placeholder/stale version here; this replaces it with the
// complete file (LDAP bind, minted API token, OAuth client).
// Warn-only.
await baoPut('jump-host/conf', freshRequire(JUMP_SECRETS));
} catch (e) {
log(`WARNING: jump host provisioning failed (${e.message || e}) — continuing`);
}
// The agent join key setup.sh writes into /etc/theta42/agent.yml.
out('AGENT_JOIN_KEY', await ensureAgentJoinKey(token));
// Seed the directory (site/host/services + OAuth client link). Never
// fails the bootstrap — warn and continue.
try {
@@ -684,6 +1111,15 @@ async function provisionJumpHost(token) {
log(`WARNING: directory seed failed (${e.message || e}) — continuing`);
}
// Seed default plugin instances (Docker discovery) — same warn-and-go
// policy; a stack without plugins is still usable.
try {
log('Seeding default plugins...');
await seedPlugins(token);
} catch (e) {
log(`WARNING: plugin seed failed (${e.message || e}) — continuing`);
}
log('Done.');
process.exit(0);
} catch (e) {
+172
View File
@@ -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."
+91
View File
@@ -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);
});
+139
View File
@@ -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);
});
+128
View File
@@ -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);
});
+1 -1
View File
@@ -1,5 +1,5 @@
'use strict';
// Example proxy secrets for the theta-env unified stack. Copy to
// Example proxy secrets for the theta-suite unified stack. Copy to
// ./config/proxy-secrets.js (NOT this file — ./config/ is gitignored) and edit.
// `./setup.sh` generates ./config/proxy-secrets.js for you on first run and the
// bootstrap writes the OAuth client clientId/clientSecret back into it; this
+1 -1
View File
@@ -1,5 +1,5 @@
'use strict';
// Example SSO secrets for the theta-env unified stack. Copy to
// Example SSO secrets for the theta-suite unified stack. Copy to
// ./config/sso-secrets.js (NOT this file — ./config/ is gitignored) and edit.
// `./setup.sh` generates ./config/sso-secrets.js for you on first run; this file
// documents the shape for manual editing / reference.
+119 -23
View File
@@ -1,4 +1,4 @@
# theta-env — unified SSO Manager + Proxy.
# theta-suite — unified SSO Manager + Proxy.
#
# Brings up the two all-in-one images on one bridge network so the proxy can
# reach the SSO internally (http://sso-manager:3001 for token/userinfo,
@@ -44,32 +44,58 @@ services:
NO_PROXY: ${CFG_NO_PROXY:-}
container_name: sso-manager
restart: unless-stopped
depends_on:
- openbao
networks: [theta-net]
ports:
# SSO web UI. Bind address is configurable via SSO_BIND (default 0.0.0.0 so
# the UI is reachable on the LAN during setup). Set SSO_BIND=127.0.0.1 to
# lock it to localhost once the proxy fronts it at https://<SSO_HOST>.
- "${SSO_BIND:-0.0.0.0}:${SSO_PORT:-3001}:3001"
# LDAPS for EXTERNAL direct-LDAP clients (legacy apps). The proxy itself
# reaches LDAPS over theta-net (sso-manager:636) without this host mapping.
# Prefer an internal-only hostname (set CFG_LDAPS_HOST in setup.env / ldapsHost
# in sso-secrets.js) and do NOT forward 636 to the public internet.
- "${LDAPS_PORT:-636}:636"
# Plain LDAP (389) is NOT mapped — direct-LDAP clients should use LDAPS.
# LDAPS (636) + plain LDAP (389) for direct-LDAP clients AND for the stack
# host's OWN enrollment: setup.sh / ldap-client configure the host's sssd
# against ldap://localhost and ldaps://localhost, and the LDAP server is
# co-located on this host, so BOTH ports must be reachable from the host
# over loopback — not only over the docker network. Bind 0.0.0.0 (default)
# so LAN clients can use the host's local IP too; set LDAP_BIND and/or
# LDAPS_BIND=127.0.0.1 to lock either to the host only. Prefer an internal
# hostname (CFG_LDAPS_HOST) and do NOT forward 389/636 to the public internet.
- "${LDAP_BIND:-0.0.0.0}:${LDAP_PORT:-389}:389"
- "${LDAPS_BIND:-0.0.0.0}:${LDAPS_PORT:-636}:636"
environment:
# Config (LDAP, OAuth, SMTP, ...) comes from ./config/sso-secrets.js (see
# volumes below), not from env. NODE_ENV/NODE_PORT are the only env the app
# reads that are not part of its conf tree.
# Config (LDAP, OAuth, SMTP, ...) is loaded by @simpleworkjs/conf from
# ./config/sso-secrets.js (see volumes), then @simpleworkjs/bao-conf
# deep-merges secret/sso-manager/conf from OpenBao over it at boot
# (VAULT_ADDR/VAULT_TOKEN below). NODE_ENV/NODE_PORT are the only other
# env the app reads. VAULT_TOKEN is the scoped SSO_VAULT_TOKEN minted by
# 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
# runtime. See the build args above for the same setting during build.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
- HTTPS_PROXY=${CFG_HTTPS_PROXY:-}
- NO_PROXY=${CFG_NO_PROXY:-}
volumes:
# The host docker socket so the bundled Docker discovery plugin (seeded as
# 'docker-local' with socketPath /var/run/docker.sock) can list containers.
# Without this the plugin errors with ENOENT and shows 'Last run: error'.
- /var/run/docker.sock:/var/run/docker.sock
# Operator-edited SSO secrets (sso-secrets.js). Read-WRITE so the bootstrap
# can write the generated OAuth client creds into proxy-secrets.js. The
# entrypoint points CONF_SECRETS at /config/sso-secrets.js.
@@ -113,6 +139,8 @@ services:
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
ports:
- "${HTTP_PORT:-80}:80"
- "${HTTPS_PORT:-443}:443"
@@ -122,10 +150,17 @@ services:
# to lock it to localhost once the proxy fronts it under TLS.
- "${MGMT_BIND:-0.0.0.0}:${MGMT_PORT:-3000}:3000"
environment:
# oidc/ldap/auth config comes from ./config/proxy-secrets.js (see volumes),
# not from env. NODE_ENV/NODE_PORT are process env the app reads directly.
# oidc/ldap/auth config is loaded by @simpleworkjs/conf from
# ./config/proxy-secrets.js (see volumes), then @simpleworkjs/bao-conf
# deep-merges secret/proxy/conf from OpenBao over it at boot. The OAuth
# clientSecret is consumed at require time, so bao-conf.init() runs
# BEFORE require('../app') in bin/www. NODE_ENV/NODE_PORT are process env
# the app reads directly. VAULT_TOKEN is the scoped PROXY_VAULT_TOKEN
# (policy proxy — read only secret/proxy/conf).
- NODE_ENV=production
- NODE_PORT=3000
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (ACME/Let's
# Encrypt, DNS providers) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
@@ -134,7 +169,9 @@ services:
volumes:
# Operator-edited proxy secrets (proxy-secrets.js). READ-ONLY — the proxy
# only reads it; the sso-manager bootstrap writes the OAuth creds. The
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js.
# entrypoint points CONF_SECRETS at /config/proxy-secrets.js. Kept as a
# fail-soft fallback: bao-conf.init() is fail-soft, so if OpenBao is
# unreachable the app boots from this file instead.
- ./config:/config:ro
# Persist Redis (AOF + RDB) so Host records, permissions, DNS creds, local
# users, AND the auto-ssl Let's Encrypt certs survive container recreation.
@@ -152,12 +189,10 @@ services:
retries: 3
start_period: 30s
# Optional SSH jump host. Only started when the `jump-host` compose profile
# is active — setup.sh exports COMPOSE_PROFILES=jump-host when
# CFG_JUMP_HOST_ENABLED=true. Authenticates users against the SSO's OpenLDAP,
# resolves reachable hosts from the directory API, and bridges SSH through.
# SSH jump host — a core component, always built + started alongside the
# SSO and proxy. Authenticates users against the SSO's OpenLDAP, resolves
# reachable hosts from the directory API, and bridges SSH through.
jump-host:
profiles: ["jump-host"]
build:
context: ./jump-host
dockerfile: Dockerfile
@@ -174,11 +209,19 @@ services:
depends_on:
sso-manager:
condition: service_healthy
openbao:
condition: service_started
ports:
- "${JUMP_SSH_PORT:-2222}:2222" # SSH front door
- "${JUMP_WEB_BIND:-0.0.0.0}:${JUMP_WEB_PORT:-3002}:3002" # web UI/API
environment:
- NODE_ENV=production
# Secrets are loaded by @simpleworkjs/conf from ./config/jump-secrets.js,
# then @simpleworkjs/bao-conf deep-merges secret/jump-host/conf from
# OpenBao over it at boot. VAULT_TOKEN is the scoped JUMP_VAULT_TOKEN
# (policy jump-host — read only secret/jump-host/conf).
- VAULT_ADDR=http://openbao:8200
- VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
# Optional upstream HTTP(S) proxy for outbound calls (the directory API
# client) at runtime.
- HTTP_PROXY=${CFG_HTTP_PROXY:-}
@@ -194,11 +237,11 @@ services:
# a container with a manually-dropped public key in authorized_keys never
# exercises the LDAP-key-serving path a real production host does. Built
# from the theta42/ldap-client submodule -- see ./config/ldap-test-host.vars
# for setup notes. Same jump-host profile, so
# `docker compose --profile jump-host up` brings up jump-host and a host it
# can actually reach together.
# for setup notes. Opt-in test fixture: bring it up explicitly with
# `docker compose --profile ldap-test up` (jump-host itself now starts
# unconditionally, so this only adds a downstream host for it to reach).
ldap-test-host:
profiles: ["jump-host"]
profiles: ["ldap-test"]
build:
context: ./ldap-client
dockerfile: Dockerfile
@@ -214,6 +257,58 @@ services:
- ./config/ldap-test-host.vars:/config/ldap.vars:ro
- ./config/ldap-ca.crt:/config/ldap-ca.crt:ro
# Renews the three periodic service tokens (theta-svc role, 768h period)
# every 12h. Periodic tokens live forever ONLY while something renews them —
# this sidecar is that something, so the stack survives arbitrarily long
# uptimes and the tokens in .env never silently expire. If a token is missing
# or already dead it just logs and moves on (setup.sh re-mints on next run).
bao-renewer:
image: quay.io/openbao/openbao:latest
container_name: bao-renewer
restart: unless-stopped
depends_on:
- openbao
environment:
- BAO_ADDR=http://openbao:8200
- SSO_VAULT_TOKEN=${SSO_VAULT_TOKEN:-}
- PROXY_VAULT_TOKEN=${PROXY_VAULT_TOKEN:-}
- JUMP_VAULT_TOKEN=${JUMP_VAULT_TOKEN:-}
entrypoint: ["/bin/sh", "-c"]
command:
- |
renew() {
if [ -z "$$2" ]; then return 0; fi
if BAO_TOKEN="$$2" bao token renew > /dev/null 2>&1; then
echo "[bao-renewer] renewed $$1"
else
echo "[bao-renewer] FAILED to renew $$1 (expired/revoked? re-run setup.sh to re-mint)"
fi
}
while true; do
renew SSO_VAULT_TOKEN "$$SSO_VAULT_TOKEN"
renew PROXY_VAULT_TOKEN "$$PROXY_VAULT_TOKEN"
renew JUMP_VAULT_TOKEN "$$JUMP_VAULT_TOKEN"
sleep 43200
done
networks:
- theta-net
openbao:
image: quay.io/openbao/openbao:latest
container_name: openbao
restart: unless-stopped
cap_add:
- IPC_LOCK
command: server -config=/vault/config/openbao.hcl
environment:
- BAO_ADDR=http://127.0.0.1:8200
ports:
- "8080:8200"
volumes:
- ./config/openbao.hcl:/vault/config/openbao.hcl:ro
- openbao-data:/vault/data
networks:
- theta-net
networks:
theta-net:
driver: bridge
@@ -226,4 +321,5 @@ volumes:
proxy-cache:
proxy-logs:
jump-data:
jump-redis-data:
jump-redis-data:
openbao-data:
+53
View File
@@ -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.
+323
View File
@@ -0,0 +1,323 @@
---
layout: default
title: Group & Permission Model
nav_order: 3
---
# Theta42 Group & Permission Model
This is the canonical reference for how **groups and permissions work** across the
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
hosts** should read and use them. It is written to be implementable by both humans
and LLM agents.
Everything below assumes LDAP is the single source of truth for identity and group
membership. Group membership is managed in the **SSO Manager Directory**, generated
from adopted resources — there is **no standalone "Groups" page**.
---
## 1. Principles
1. **Groups are a projection of the resource graph.** Every adopted host and app
in the Directory gets its own groups, auto-created from its identity. Group
membership is managed on the resource's modal.
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
hosts; an app administers apps. They do not inherit from each other.
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
`admin` implies `access`. Capabilities are explicit and never implied by
`admin`.
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
the site slug.
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
bloat. Only the meta groups are never imported by hosts.
6. **The directory is the only place groups are created.** `god_admin` is the sole
group that does not belong to a resource or site.
---
## 2. Group schema
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
`<capability>` = an opaque, app-defined capability token (see §4).
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
| `S_host_<host>_access` | host | Access to host `<host>`. |
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
| `S_apps_access` | site | Access to **all apps** at `S`. |
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
| `S_app_<app>_access` | app | Access to app `<app>`. |
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
### Meta groups (implicit membership — not POSIX, no gidNumber)
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `everyone` | global | **All authenticated users**, any site. |
| `S_everyone` | site | **All authenticated users** at site `S`. |
These are resolved by the directory (any authenticated user passes), never
enumerated as LDAP members, and cannot be used as Unix groups.
---
## 3. Naming, normalization & reserved rules
- The **structural delimiter is `_`**. It appears only between the fixed segments
of a group name.
- **The `S` site segment is the site resource's slug verbatim** (`site_local`),
NOT re-slugified (which would corrupt the delimiter: `site_local``site-local`).
- **Per-resource groups are `{S}_{kind}_{name}_{level}`.** `kind` is `host` or
`app`; `name` is the resource's **name slug with the kind prefix stripped** — a
host resource `host_theta-env` has name `theta-env`, so its groups are
`site_local_host_theta-env_access` / `_admin`. A service (the group model's
`app`, docs §11) `sso-manager` gives `site_local_app_sso-manager_access`. The
kind segment is always present, which is what makes a resource's name
unambiguous even if a host and a service share a name.
- **Within a segment, normalize to lowercase** — spaces and stray `_``-`; strip
other non-`[a-z0-9-]`. A host named `Web 01` and a site `Main Office` (resource
slugs `host_web-01` and `site_main-office`) yield groups `site_main-office_host_web-01_*`.
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
if a host were named `admin` (that host would be `S_host_admin_admin`).
- **The last segment is the level.** If it is `admin` or `access` it is a known
level; any other value is an **opaque capability** owned by a downstream app.
- **Total length budget:** keep a group cn under ~120 chars; reject group
creation that would exceed it.
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
generated on the host by SSSD for only the groups that host imports (see §8).
---
## 4. Levels and opaque capabilities
- **`admin`** — manage (create/update/delete/config) the resource.
- **`access`** — use/read the resource.
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
manages membership and exposes the group to the app; **the downstream app
defines and enforces what the capability means** (e.g. `emby_admin`,
`gitea_maintain`, `reboot`, `backup`).
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
Everything else on a resource group is treated as an opaque capability group and
passed through to consumers.
---
## 5. Permission resolution (inheritance)
Define a user's **effective permission** on a resource by checking, from most
specific to most general, whether they are a member of any applicable group. The
rule: a higher group implies everything below it.
### On host `H` at site `S`
| Wanted | Granted if the user is a member of **any** of |
| :--- | :--- |
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
### On app `A` at site `S`
Identical, with `app`/`apps` substituted for `host`/`hosts`.
### Management console (SSO / Proxy / Jump-Host)
Each console is registered as an **app** on its site, so console admin is:
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
### Pseudocode
```
def effective(resource, level_or_cap, site):
if user in "god_admin": return True
if user in f"{site}_super_admin": return True
if level_or_cap in ("admin","access"):
agg = f"{site}_{resource.kind}s_{level_or_cap}"
if user in agg: return True
# resource.name is the resource's name slug (kind prefix stripped); the kind
# is its own segment. A host `host_theta-env` has name `theta-env`, kind `host`.
specific = f"{site}_{resource.kind}_{resource.name}_{level_or_cap}"
if user in specific: return True
if level_or_cap == "access": return effective(resource, "admin", site)
if level_or_cap == "admin": return False # access does not imply admin
return False
```
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
---
## 6. Where groups live — the Directory, generated from adopted resources
- There is **no standalone Groups page.** Group creation/management happens on an
**adopted resource** in the Directory.
- When a host or app is **adopted** (promoted from Discovered Inventory to
managed), the directory auto-creates its `_admin` and `_access` groups (and
site aggregates if configured). Capability groups are created on demand.
- Membership (add/remove users) and capability grants are managed on that
resource's modal.
- Deleting a resource removes its per-resource groups.
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
global `god_admin`/`everyone` are managed at the site level (not on a single
host/app resource).
---
## 7. Multi-site isolation
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
`everyone` cross site boundaries.
---
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
There are hundreds of them and only a handful matter on any given host, so we do
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
This keeps the directory clean and the per-host surface tiny.
### SSSD — generate GIDs on the fly, import only what you need
```ini
[domain/example]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example
ldap_search_base = dc=example,dc=com
# groupOfNames (RFC 2307bis) schema
ldap_schema = rfc2307bis
ldap_group_object_class = groupOfNames
ldap_group_member = member
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
ldap_id_mapping = true
ldap_group_uuid = entryUUID
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
```
Key ideas:
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
stable GID for any group it imports, so **no `gidNumber` attribute is required**
in LDAP.
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
match, discarding the other hundreds. After changing the filter, clear the
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
`getent group <cn>`.
### What filter to use — the naming convention is the answer
A host should import its **own** resource groups (plus any explicitly granted
ones). Because the schema is predictable, `ldap-client` can generate the per-host
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
at site `main-office` (site resource slug `site_main-office`) imports:
```
(&(objectClass=groupOfNames)(|(cn=site_main-office_host_web01_access)
(cn=site_main-office_host_web01_admin)
(cn=site_main-office_host_web01_sudo)))
```
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
### Aliasing an LDAP group into a local group (e.g. `input`)
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
Two host-side mechanisms cover it:
- **pam_exec** — a script in the login stack adds the user to the local group for
the session:
```sh
#!/bin/bash
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
```
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
`/etc/pam.d/common-session`.
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
members regardless of the local GID.
### Meta groups
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
implicit membership and are resolved by the directory only.
---
## 9. Downstream-app consumption guide
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
membership from LDAP and interprets it as follows:
1. **Discover the user's groups** — bind with the user's credentials (or use a
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
the `memberOf` reverse attribute on the user's entry.
2. **Match each group to a scope:**
- `god_admin` → the user is a global administrator.
- `{site}_super_admin` → site administrator for that site.
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
- `everyone` / `{site}_everyone` → the user is implicitly a member.
3. **Interpret the last segment:**
- `admin` → full control of that resource.
- `access` → read/use.
- anything else → a capability **you** define; act on it or ignore it.
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
`{site}_app_emby_emby_admin` administers Emby.
The app must **never** treat an unknown last segment as `admin` or `access`.
---
## 10. Migration from the legacy `app_*` groups
The current global groups (`app_sso_admin`, `app_super_admin`,
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
| Legacy | New |
| :--- | :--- |
| `app_super_admin` | `god_admin` |
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
| `app_sso_directory_admin` | `S_app_sso_admin` |
| `app_jump_admin` | `S_app_jump_admin` |
During the transition the legacy groups may be kept as short-lived aliases that
resolve to the same effective permission; once everything is moved, remove them.
---
## 11. The management consoles are apps
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
self-consistent: the SSO is "just another app."
+278
View File
@@ -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.0v2.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.0v2.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).*
+19 -10
View File
@@ -1,7 +1,7 @@
title: theta-env
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-env"
baseurl: "/theta-suite"
logo: /assets/img/theta42.svg
lang: en_US
@@ -10,10 +10,10 @@ plugins:
- jekyll-sitemap
github:
repository_url: https://github.com/theta42/theta-env
zip_url: https://github.com/theta42/theta-env/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/theta-env/archive/refs/heads/master.tar.gz
repository_name: theta42/theta-env
repository_url: https://github.com/theta42/theta-suite
zip_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.zip
tar_url: https://github.com/theta42/theta-suite/archive/refs/heads/master.tar.gz
repository_name: theta42/theta-suite
nav:
- title: Home
@@ -25,11 +25,20 @@ nav:
- title: Architecture
page: /architecture.html
icon: fa-sitemap
- title: Standalone
page: /standalone.html
icon: fa-puzzle-piece
- title: Secrets
page: /secrets.html
icon: fa-key
- title: Theta Directory
page: /sso/
icon: fa-users
- title: Theta Proxy
page: /proxy/
icon: fa-shield-halved
- title: Theta Gateway
page: /jump-host/
icon: fa-terminal
- title: Changelog
url: https://github.com/theta42/theta-env/blob/master/CHANGELOG.md
url: https://github.com/theta42/theta-suite/blob/master/CHANGELOG.md
icon: fa-list
defaults:
+2
View File
@@ -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">
+161 -74
View File
@@ -1,77 +1,125 @@
---
layout: default
title: Architecture
description: How theta-env composes the SSO Manager and proxy submodules — the OIDC/LDAP 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-env is a **composition** repo: it builds the two existing projects from
their git submodules and adds the glue that wires them together. It does not
fork or patch them — both projects work unchanged on their own.
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.
---
## The three repos
## Core Infrastructure Components
| Repo | 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/theta-env` (this repo) | Composes the two on one Docker network + automates first-run wiring. |
The two projects are pinned as **git submodules**. `git clone --recursive`
fetches all three 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 two containers
## Architecture Stack
```
┌──────────────────────────────────────────────┐
│ your browser / apps / direct LDAP clients
└───────────────┬──────────────────────────────┘
│ https (:443) ldaps (:636)
┌─────────▼─────────┐
proxy container │ OpenResty :80/:443/:4443
│ (OIDC + LDAP) │ Node mgmt app :3000 (localhost only)
bundled Redis (127.0.0.1:6379)
└─────────┬─────────┘
┌─────────────┼────────────────────────────┐
│ ldaps:636 │ http:3001 (internal) │ OIDC token + userinfo
│ (docker net)│ (docker net, not published)│ (server-to-server)
▼ ▼ │
┌──────────────────────────────┐ │
sso-manager container │◄──────────────────
OIDC provider (Express) bundled Redis (127.0.0.1:6379)
│ OpenLDAP (slapd) │ web UI :3001 (localhost only)
│ ldaps :636 (published)
───────────────────────────────┘
│ ldaps :636 (published to host) — legacy apps bind directly
┌──────────────────────────────┐
│ legacy apps (Gitea, Emby, …)
└──────────────────────────────┘
──────────────────────────────────────────────────────────┐
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)
└──────────────────────────────
ldap-client — enrolls real Linux hosts into the directory above
(PAM/SSSD login, sudo, SSH-key serving); also the
`ldap-test-host` fixture (opt-in: `--profile ldap-test`).
```
Both containers bundle their **own Redis** (the proxy hardcodes `127.0.0.1:6379`
in three places that ignore config; the SSO's models default to the same). Two
redis instances is the no-source-patch path and is fine at this scale.
All four services bundle their **own Redis** (sso-manager, proxy, jump-host
each run a 127.0.0.1:6379 instance) and share the `openbao` secrets store.
Direct LDAP binds against `:636` are first-class — that's how Linux hosts do
PAM/SSSD login, sudo, and SSH-key serving, and how LDAP-native apps
authenticate — not a fallback path.
### What's exposed, what's not
| Port | On host? | Purpose |
|------|----------|---------|
| `443` (proxy) | **yes** | the public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
| `80` (proxy) | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
| `4443` (proxy) | yes (optional) | alt HTTPS listener |
| `3000` (proxy) | localhost only | proxy mgmt UI/API (first-run convenience; fronted by 443 normally) |
| `636` (sso) | yes | LDAPS for legacy direct-LDAP clients |
| `3001` (sso) | localhost only | SSO web UI (first-run convenience; fronted by the proxy normally) |
| `389` (sso) | **no** | plain LDAP — internal only (app↔slapd over localhost) |
| Port | Service | On host? | Purpose |
|------|---------|----------|---------|
| `443` | proxy | **yes** | public entry point — OIDC login + proxied apps + the SSO/proxy UIs |
| `80` | proxy | **yes** | HTTP-01 for Let's Encrypt (and redirect to 443) |
| `4443` | proxy | yes (optional) | alt HTTPS listener |
| `3000` | proxy | localhost/LAN | proxy mgmt UI/API (fronted by 443 normally) |
| `3001` | sso-manager | localhost/LAN | SSO web UI (fronted by the proxy normally) |
| `636` | sso-manager | **yes** | LDAPS for direct-LDAP clients (Linux hosts, LDAP-native apps) |
| `389` | sso-manager | **no** | plain LDAP — internal only (app↔slapd over localhost) |
| `2222` | jump-host | **yes** | SSH front door |
| `3002` | jump-host | **yes** | jump-host web UI/API |
| `8080` | openbao | yes | OpenBao UI/API for the operator (apps use `openbao:8200` internally) |
---
## Secrets (OpenBao)
Every component loads its secrets from one OpenBao instance at boot, not
from scattered config files. OpenBao runs as the `openbao` container
(`http://openbao:8200` on theta-net, KV-v2 at `secret/`); each app gets a
**scoped token** (never the root token) whose OpenBao policy confines it to
the paths it needs:
| Service | env var | Policy | Access |
|---------|---------|--------|--------|
| sso-manager | `SSO_VAULT_TOKEN` | `sso-broker` | `secret/sso-manager/conf`, `secret/users/*`, `secret/apps/*`, `secret/plugins/*`; also mints per-user + per-app tokens |
| proxy | `PROXY_VAULT_TOKEN` | `proxy` | `secret/proxy/conf` (read) |
| jump-host | `JUMP_VAULT_TOKEN` | `jump-host` | `secret/jump-host/conf` (read) |
At boot each app calls `@simpleworkjs/bao-conf`'s `init()`, which deep-merges
its OpenBao path over the file-loaded `@simpleworkjs/conf` object — so OpenBao
is authoritative at runtime, with the `./config/*-secrets.js` file kept only as
an operator-edited seed and a fail-soft fallback (`init()` is fail-soft, so the
app still boots from the file if OpenBao is unreachable). The proxy and
jump-host consume `conf.oidc.clientSecret` at `require` time, so `init()`
runs *before* their models load (see each app's `bin/www`).
Beyond app config, OpenBao holds:
- **Per-user secret storage**`secret/users/<uid>/*`, browsed and edited in
the SSO UI's **My Secrets** page. Each user is confined to their own
namespace by a `user-<uid>` policy; admins see all of `secret/`.
- **External-app tokens** — an admin mints a scoped `app-<name>` token
(confined to `secret/apps/<name>/*`) from the SSO UI's **Apps** tab, so an
external app can read its own secrets over the OpenBao HTTP API.
`setup.sh` creates the policies + a `sso-broker` token role and mints the
per-app tokens on first run; the root token stays in `setup.env` for
seeding/maintenance only and is never passed to a service container. Full
details — the policy model, the `secret/apps/<app>/conf` convention, `curl`
+ Node examples, and the operator rotation procedure — are in
[Secrets](secrets.html).
---
@@ -82,7 +130,14 @@ actual work, running **inside the sso-manager container** (bind-mounted
read-only from this repo). It's deliberately self-contained — only Node
built-ins (`child_process`, `crypto`, `fs`) + global `fetch`, and it reads its
inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets.js`
(not from env):
(not from env).
OpenBao comes up first: `setup.sh` initializes and unseals it, writes the
policies and the `sso-broker` token role, mints the per-app scoped tokens into
`setup.env`, and idempotently seeds `secret/sso-manager/conf`,
`secret/proxy/conf`, and `secret/jump-host/conf` from the corresponding
`./config/*-secrets.js` files. The app containers then start with their scoped
`VAULT_TOKEN`. The SSO/LDAP/OIDC wiring that follows:
1. **Build + start sso-manager**, wait for `/health`.
2. **LDAP service account**`ldapadd` `cn=ldapclient,ou=people,<base>` (an
@@ -97,14 +152,16 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
5. **Register the proxy as an OIDC client** via `POST /api/oauth/client` (gated
by `app_sso_oauth_admin`, satisfied by step 3). The SSO **generates** the
`client_id`/`client_secret` (UUIDs) — supplied creds are ignored — so the
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`**
(the sso-manager mounts `./config` read-write for this; the proxy mounts it
read-only). If `proxy-secrets.js` already holds a `clientId`+`clientSecret`
matching an existing client, they are kept; if the client exists but the file
has no usable secret, the secret is rotated and written back.
6. **Build + start the proxy**, wait for `/health`. The proxy entrypoint points
`CONF_SECRETS` at `./config/proxy-secrets.js`, so `@simpleworkjs/conf`
(≥1.2.0) reads the OAuth creds + LDAP bind creds from the file.
bootstrap writes the generated creds **back into `./config/proxy-secrets.js`
and into OpenBao at `secret/proxy/conf`** (the sso-manager mounts `./config`
read-write for this; the proxy mounts it read-only). If `proxy-secrets.js`
already holds a `clientId`+`clientSecret` matching an existing client, they
are kept; if the client exists but the file has no usable secret, the
secret is rotated and written back.
6. **Build + start the proxy + jump-host**, wait for `/health`. Each
entrypoint points `CONF_SECRETS` at its `./config/*-secrets.js`, then
`@simpleworkjs/bao-conf` overlays the OpenBao path over it (the OAuth
clientSecret + LDAP bind creds come from OpenBao at runtime).
7. **Register `<SSO_HOST>` and `<PROXY_HOST>` as Host records in the proxy**
`setup.sh` runs a short script inside the proxy container that calls its
Host model directly (`Host.create({host, ip, targetPort, ...})`), rather
@@ -120,20 +177,25 @@ inputs from the bind-mounted `./config/sso-secrets.js` + `./config/proxy-secrets
`setup.sh` then prints the first-admin login + the public URLs.
### How config reaches the apps (no `.env`)
### How config reaches the apps
All config and secrets live in `./config/` (gitignored, bind-mounted). Each
entrypoint points the `CONF_SECRETS` env var (`@simpleworkjs/conf` >= 1.2.0)
at its file early, before the app starts:
Config and secrets live in two layers: an operator-edited
`./config/*-secrets.js` file (gitignored, bind-mounted) and the OpenBao
overlay over it. Each entrypoint points the `CONF_SECRETS` env var
(`@simpleworkjs/conf` >= 1.2.0) at its file early, before the app starts:
```
CONF_SECRETS=/config/sso-secrets.js (sso-manager, ./config RW)
CONF_SECRETS=/config/proxy-secrets.js (proxy, ./config RO)
CONF_SECRETS=/config/jump-secrets.js (jump-host, ./config RO)
```
`@simpleworkjs/conf` loads `conf/base.js → <env>.js → secrets file → app_*
env`, where **env beats the secrets file**. So compose passes **no `app_*` env
vars** (only `NODE_ENV`, `NODE_PORT`) — that makes the secrets file
env`, where **env beats the secrets file**. Then `@simpleworkjs/bao-conf`
deep-merges the app's OpenBao path over the result at boot — OpenBao is the
authoritative runtime layer; the file is the seed and fail-soft fallback. So
compose passes **no `app_*` config env vars** (only `NODE_ENV`, `NODE_PORT`,
`VAULT_ADDR`, and a scoped `VAULT_TOKEN`) — that keeps the secrets file + OpenBao
authoritative. The SSO entrypoint reads the few values it needs at startup
(LDAP base DN, admin password, JWT secret, cert CN) from `sso-secrets.js` via
an in-container `node` call.
@@ -151,12 +213,15 @@ end-to-end.
## Idempotency
Re-running `./setup.sh` converges to `./config/`:
Re-running `./setup.sh` converges to `./config/` + OpenBao:
- The LDAP service account + admin passwords are **reset to `./config/`**.
- Group membership is ensured (add is a no-op if already a member).
- The OAuth client is kept if `proxy-secrets.js` already holds its creds;
created or rotated otherwise, and the new creds written back.
created or rotated otherwise, and the new creds written back (to the file
and to OpenBao).
- OpenBao policies, token role, per-app tokens, and `secret/<app>/conf` seeds
are ensured (created if absent, left alone if present).
So `setup.sh` is safe to re-run after editing `./config/`, after a `docker
compose down`, or after restoring from backup.
@@ -165,17 +230,39 @@ compose down`, or after restoring from backup.
## Backups and restore
`./setup.sh` auto-snapshots `./config/` + LDAP + both Redis to
`./setup.sh` auto-snapshots `./config/` + LDAP + all the Redis instances to
`./backups/<timestamp>/` before each rebuild (keeps the last `BACKUP_KEEP`,
default 5). State lives on named volumes (`ldap-data`, `sso-data`, `proxy-data`)
and survives recreation; `down -v` wipes them. Redis is persisted with AOF +
RDB on those volumes. For the full manual-backup + restore runbook (full /
Redis-only / LDAP-only, with the AOF-vs-RDB note), see the *Backups and
restore* section of the [README](https://github.com/theta42/theta-env#backups-and-restore).
Quick LDAP backup:
default 5). State lives on named volumes (`ldap-data`, `ldap-certs`,
`sso-data`, `proxy-data`, `proxy-cache`, `proxy-logs`, `jump-data`,
`jump-redis-data`, `openbao-data`) and survives recreation; `down -v` wipes
them. Redis is persisted with AOF + RDB on those volumes. For the full
manual-backup + restore runbook (full / Redis-only / LDAP-only, with the
AOF-vs-RDB note), see the *Backups and restore* section of the
[README](https://github.com/theta42/theta-suite#backups-and-restore). OpenBao
holds the live secrets, so back up its volume too (`<project>_openbao-data`,
where `<project>` is your clone directory name — `theta-suite` for a fresh
clone). Quick LDAP backup:
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf -b "<base>" > backup.ldif
```
---
## Plugin Ecosystem
The SSO Manager utilizes a dynamic plugin registry (`nodejs/services/plugin_registry.js`) that automatically loads any `.js` file placed in the `nodejs/plugins/<category>` folders.
### Discovery Plugins
Discovery plugins (e.g., `nmap.js`, `proxmox.js`, `docker.js`) run on a defined cron schedule to sync external assets into the centralized directory catalog.
### Messaging Plugins
Messaging plugins (e.g., `twilio.js`, `webhook.js`) provide on-demand delivery capabilities for alerts, 2FA tokens, and notifications.
- **Universal REST Webhook:** Sends custom JSON payloads to platforms like Slack, Teams, or custom API endpoints securely.
- *Discord Example:* To send alerts to a Discord channel, create a new plugin instance of type "Universal REST Webhook". Set the **Webhook URL** to your Discord webhook URL (e.g., `https://discord.com/api/webhooks/...`), the **HTTP Method** to `POST`, and the **Payload Template** to `{"content": "Alert for {{to}}: {{message}}"}`. Leave the Headers and API Secret blank.
- **Twilio SMS:** Sends standard SMS codes.
- **Fallback:** If no messaging plugins are enabled, the system falls back to the legacy `voipms` integration configured in the SSO secrets.
Secrets belonging to plugins are automatically pushed to OpenBao (`secret/plugins/<id>/conf`) and are never written to the local database, following the global secrets architecture.
[← Back to Home](index.html)
+175
View File
@@ -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 50005009 in that order. Mail is `<first>.<last>@laptop-dev.vm42.us`
(service accounts use their uid, e.g. `monitoring@laptop-dev.vm42.us`).
## Groups
`groupOfNames`, owned by `cn=admin,...`, member of the department's users:
- `engineering` — schen, dkim, ppatel
- `finance` — mjohnson, lnguyen
- `support` — erodriguez, tbaker
- `management` — jwilson
- `app_sso_service_account` (built-in) — svc-monitoring, svc-backup
## Seeding users + groups
```sh
cd theta-env
docker cp bootstrap/seed-demo-users.sh sso-manager:/tmp/seed-demo-users.sh
docker compose exec -T sso-manager bash /tmp/seed-demo-users.sh
```
Idempotent — re-running skips anything that already exists. If you add a
fixture below, add it to `bootstrap/seed-demo-users.sh` too and keep the two
in sync.
## Proxy hosts
All under `*.laptop-dev.vm42.us`. `setup.sh` itself creates the first two
(sso, proxy) — everything else below is added by hand through Hosts → Add
host (Proxy UI, currently no seed script — see note at the bottom).
| Host | Target | Auth | Notes |
|---|---|---|---|
| `sso` | `sso-manager:3001` | — | created by `setup.sh` |
| `proxy` | `127.0.0.1:3000` | — | created by `setup.sh` |
| `jump` | `jump-host:3002` | — | created by `setup.sh` |
| `proxmox` | `10.0.10.5:8006` (HTTPS) | Basic — realm "Proxmox VE", users `dkim`, `schen` | |
| `pbs` | `10.0.10.6:8007` (HTTPS) | Basic — realm "Proxmox Backup Server", user `dkim` | |
| `grafana` | `10.0.10.12:3000` + LB target `10.0.10.13:3000` | SSO — group `engineering` | load-balancing example |
| `nextcloud` | `10.0.10.20:80` | SSO — any authenticated user | empty allow-lists |
| `ha` | `10.0.10.30:8123` | Basic — realm "Home Assistant", user `jwilson` | |
| `jenkins` | `10.0.10.40:8080` | SSO — group `engineering` | |
| `gitea` | `10.0.10.41:3000` | Off (public) | has its own login |
| `plex` | `10.0.10.50:32400` | Off (public) | has its own login |
| `nas` | `10.0.10.60:5001` (HTTPS) | Basic — realm "Synology NAS", user `jwilson` | |
| `pihole` | `10.0.10.61:80` | Basic — realm "Pi-hole Admin", user `dkim` | |
| `wiki` | `10.0.10.70:3000` | SSO — any authenticated user | |
| `invoices` | `10.0.10.80:8000` | SSO — group `finance` | small-business flavor |
| `helpdesk` | `10.0.10.81:3000` | SSO — group `support` | small-business flavor |
Basic-auth passwords used: `dkim:HomeLab!2024`, `schen:Engineering!24`,
`jwilson:HomeOwner!24`.
## Domain
`CFG_DOMAIN=laptop-dev.vm42.us` in `setup.env`, real public DNS (CNAME
through `718it.biz`) that resolves back to this machine. `CFG_LDAPS_HOST`
is pinned to the LAN IP of the interface holding the default route
(`ip route get 1.1.1.1`), not just any active interface — this machine had
two (wifi + USB ethernet) and only one was actually externally reachable
through the existing port-forward/prod-proxy setup.
A production reverse proxy in front of this host handles TLS/ACME for
`*.718it.biz`-family domains (to avoid hitting Let's Encrypt's rate limits
re-provisioning a cert every time this dev stack rebuilds) — if a fresh
rebuild's Host records don't resolve correctly from the public domain right
after `setup.sh`, that's the layer to check, not this stack's own nginx/lua
routing. `curl -sk -D - https://sso.laptop-dev.vm42.us/` from the host
machine is the fastest way to confirm whether the issue is server-side.
## Known-good login shortcuts
Skip SSO's self-signed-cert dance entirely for admin/screenshot work — every
app ships a local anti-lockout admin account for exactly this:
```sh
# SSO Manager admin (bootstrap account, uid "admin")
node -e "console.log(require('./config/sso-secrets.js').bootstrap.adminPass)"
# Proxy — username proxyadmin2
node -e "console.log(require('./config/proxy-secrets.js').auth.localAdminPass)"
# Jump-host — username jumpadmin
node -e "console.log(require('./config/jump-secrets.js').auth.localAdminPass)"
```
Ports (from `setup.env` — check it, these are operator-configurable):
SSO `3001`, Proxy management UI `3010` (`MGMT_PORT`), Jump-host `3002`.
A freshly-bootstrapped `admin` account hits the onboarding flow (accept ToS,
enter a DOB) before the rest of the UI is usable — expect that on a stack
that was just rebuilt from scratch.
## Jump-host access (SSO Directory resource)
Jump-host's dashboard ("Hosts you can reach") is **not** driven by Proxy's
Host records — it resolves access via the SSO Manager's own Directory
(`kind: host` resources), filtered by the logged-in user's LDAP group
membership. This is a completely separate system from Proxy's HTTP-routing
hosts above; a Proxy host existing does not make it SSH-reachable through
jump-host.
For a `dkim`-can-reach-something screenshot, one Directory host resource was
added:
- **Directory → Add Resource**: name `proxmox-node`, kind `Host`, IP
`10.0.10.5`, parent resource `local (site_local)`.
- **Associated LDAP Groups → `site_local_host_proxmox-node_access`
Members → Add member → `dkim`** (added the individual user directly, not
the `engineering` group — the resource's own auto-generated `_access`
group's member picker only offers individual users).
To reproduce: repeat those two steps for `proxmox-node` if it's missing, or
add more Directory host resources the same way for a richer "Hosts you can
reach" list.
**To screenshot as a real fixture user** (not the `jumpadmin` local
anti-lockout admin, whose "My hosts" list is always non-empty by virtue of
infra ownership, not a real access grant): log out, click "Log in with
Jump" on the login page, and sign in as `dkim` / `DemoPass123!` through the
real SSO flow. This exercises the actual OIDC redirect through
`sso.laptop-dev.vm42.us` — by this point in the session it worked cleanly in
the browser; if it doesn't (stale cookies/redirect loop from an earlier bad
state), see `docs/screenshots.md` §2 for the fallback.
## What's not yet automated
Proxy hosts are still added by hand (no `seed-demo-hosts.sh` equivalent) —
the Proxy UI has no simple LDIF-style bulk-import path the way LDAP does, and
scripting it means either driving the browser or reverse-engineering the
session-cookie login flow for curl. If this list changes often enough to be
annoying, that's the next thing worth building — a small node script run via
`docker compose exec proxy node ...` calling the `Host` model directly,
mirroring how `setup.sh`'s own step 7 registers the sso/proxy hosts.
## Screenshot workflow
See `docs/screenshots.md` for the full screenshot-capture workflow
(save-to-disk, where each doc image lives, the app.modal.js browser-cache
gotcha). Once fixtures match this doc, only re-screenshot pages whose UI
actually changed since the last pass — the data itself shouldn't be the
reason a screenshot looks different.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 310 KiB

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 332 KiB

+75 -42
View File
@@ -4,20 +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-env
# Theta suite
The whole theta42 identity + access 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 wires together two projects that already work on their own —
[SSO Manager](https://theta42.github.io/sso-manager-node/) (OIDC provider +
LDAP directory) and [Proxy](https://theta42.github.io/proxy/) (an
OIDC-protected reverse proxy that can also look users up directly in LDAP) —
and automates the fiddly part: registering the proxy as an OIDC client of the
SSO and pointing it at the right LDAP directory, with hostnames and secrets
generated from one `setup.env`. An optional third component, the
[Jump Host](https://theta42.github.io/jump-host/), adds directory-driven SSH
access to your machines through one public entry point.
## 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
@@ -29,44 +38,66 @@ 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
Each project works standalone, but they only become 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.
- **SSH Jump Host** *(optional)*`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. Enable with `CFG_JUMP_HOST_ENABLED=true`.
- **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.
- **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 & 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
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
cp setup.env.example setup.env # then edit setup.env: set CFG_DOMAIN to your domain
./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, and running each project standalone, see the
**[GitHub repository](https://github.com/theta42/theta-env)**.
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
@@ -74,5 +105,7 @@ architecture, and running each project standalone, see the
provider + LDAP directory this stack runs.
- **[Proxy](https://theta42.github.io/proxy/)** — the reverse proxy this
stack runs in front of it.
- **[Jump Host](https://theta42.github.io/jump-host/)** — the optional SSH jump
host this stack can bring up (`CFG_JUMP_HOST_ENABLED=true`).
- **[Jump Host](https://theta42.github.io/jump-host/)** — the SSH jump
host this stack brings up.
- **[ldap-client](https://theta42.github.io/ldap-client/)** — enrolls Linux
hosts into the directory this stack serves.
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<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 }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+122
View File
@@ -0,0 +1,122 @@
---
layout: default
title: Architecture
description: How the jump host authenticates users, resolves reachable hosts from the directory, injects per-user keys, and bridges SSH — plus the web UI and audit model.
---
# Architecture
The jump host is a Node.js service (using [`ssh2`](https://github.com/mscdex/ssh2)
as both an SSH **server** and **client**) with two faces: the SSH front door
(default `:2222`) and a web UI/API (`:3002`). It holds no user database of its
own — identity, authorization, and onward credentials all come from the shared
directory.
```
┌────────────────────── jump host ──────────────────────┐
ssh │ ssh2 Server (:2222) │ ssh2 Client
─────┼─▶ 1. authenticate user ──▶ LDAP (sshPublicKey / bind) │ ───────────▶ downstream
user │ 2. resolve target ──▶ SSO /api/discovery │ sshd (as the
│ 3. inject key ──▶ LDAP (add sshPublicKey) │ real user)
│ 4. bridge channels ◀───────────────────────────────▶ │
│ web UI/API (:3002) ──▶ audit + metrics (redis) │
└───────────────────────────────────────────────────────┘
```
## 1. Inbound authentication
When a user connects, the jump host authenticates them against LDAP:
- **Public key** — it looks up the user's `sshPublicKey` values in the directory
and matches the offered key (handling ssh2's probe-then-sign two-phase
publickey auth). The jump host's *own* injected key (identified by its comment
marker) is deliberately excluded from this match — only the jump host may hold
that private key, so accepting it inbound would be a bypass.
- **Password** — an LDAP simple bind as the user's DN. Policy is configurable:
`off` (keys only — recommended for a public host), `local` (passwords only
from loopback/RFC1918 clients, keys-only from the internet), or `all`.
Every attempt — success or failure, with method and reason — is audited.
## 2. Access & target resolution
The hosts a user may reach are computed from the directory, not a local list:
1. The user's LDAP group memberships (`(&(objectClass=groupOfNames)(member=…))`).
2. For each group, the SSO's
`GET /api/discovery/resources?group=<cn>` (authenticated with an API token),
unioned and filtered to `kind: host`.
Each host's dial address is `metadata.ip` (or the hostname from
`metadata.address`) and port `metadata.sshPort` (default 22). Results are cached
briefly per user and shared by both the grammar path and the TUI picker.
Target matching tries, in order: exact slug → `host_`-prefixed slug → display
name → IP → address hostname. A raw IP that isn't an accessible directory host
is refused unless explicitly allowed.
> The directory auto-creates `<slug>_access` / `<slug>_admin` groups for every
> 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}
To connect downstream *as the user* without asking them for a password, the jump host uses one of two methods (configured in `conf.ssh`):
**Option A: PKI Certificates (Recommended)**
The jump host securely calls the OpenBao (Vault) SSH Secrets Engine API to request a short-lived (e.g. 5-minute), signed SSH certificate for the target user.
- **Zero Touch on Target:** The target host simply trusts the OpenBao CA (`TrustedUserCAKeys /etc/ssh/ca.pub`). No public keys are synced or managed.
- **Ephemeral:** The certificate expires automatically.
- **Transparent:** The jump host passes `cert: signedCert` to `ssh2.Client`, authenticating instantly.
**Option B: Legacy LDAP Key Injection**
If PKI is not configured, the jump host falls back to its legacy method: it holds **one** keypair. On a user's first connection, the jump host appends its own public key to that user's `sshPublicKey` attribute in LDAP (comment-marked) so the downstream host's `AuthorizedKeysCommand` will accept it, then connects with its private key.
- Idempotent: the key is added once; a redis flag skips the LDAP round-trip afterwards.
- The jump host's bind account needs **write access** to the `sshPublicKey` attribute.
- Because the marker key is excluded from inbound auth (step 1), it grants only the jump host's onward path.
## 4. Bridging
Once the upstream connection is ready, the jump host splices SSH channels
between the two connections:
- **shell / exec** — piped both ways, with window-change and exit-status
forwarded.
- **SFTP subsystem** — the two subsystem channels are raw-piped as opaque bytes;
no SFTP protocol parsing is needed, which is why WinSCP and `sftp` work
unchanged.
- Channel requests that arrive before the upstream is ready are buffered and
replayed, so nothing is dropped during the connect.
- The downstream host key's SHA256 fingerprint is recorded in the audit event
(trust-on-use in v1).
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 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:
- `GET /health` — open; `{status, activeSessions, version}`
- `GET /api/sessions` — active sessions
- `GET /api/audit?page=&uid=&target=&status=` — the paged audit log
- `GET /api/metrics` — counters (total, failures, top users/hosts)
Audit events and counters live in redis. Each event captures: user, auth method,
mode (grammar/picker), target slug/address/port, channel type, client IP,
success + failure reason, downstream host-key fingerprint, timing, and bytes in/out.
## Where it sits in the stack
- **[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.
- **[Theta Proxy](../proxy/)** — fronts the jump host's web UI
under TLS.
- **theta-suite** — wires it all together.
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+102
View File
@@ -0,0 +1,102 @@
---
layout: default
title: Connecting
description: How to reach downstream hosts through the jump host — the username grammar, the interactive picker, SFTP/WinSCP, and what access you get.
---
# Connecting
You reach a downstream host two ways: name the target in your username, or log
in plain and pick it from a menu. Either way you authenticate **once**, to the
jump host, with your directory credentials.
## The username grammar
```
{uid}_-_{target}
```
- `{uid}` — your directory username.
- `_-_` — the separator (legal in an SSH username everywhere, including WinSCP).
- `{target}` — the host to reach: a directory **slug** (`host_web01` or just
`web01`), the host's **display name**, its **IP**, or the hostname in its
directory `address`.
```bash
ssh alice_-_web01@jump.example.com # by slug (host_ prefix optional)
ssh alice_-_10.0.0.10@jump.example.com # by IP (must be a host you can reach)
```
If the target matches a host your directory groups grant, you're bridged
straight to its `sshd` — same as if you'd SSH'd directly, but through the
audited jump host.
## SFTP / WinSCP / scp
Because the whole route is encoded in the username, file transfer tools that
only take one connection string work with no extra configuration:
```bash
sftp -P 2222 alice_-_web01@jump.example.com
scp -P 2222 file.txt alice_-_web01@jump.example.com:/tmp/
```
**WinSCP:** set Host name to `jump.example.com`, Port to `2222`, and User name
to `alice_-_web01`. SFTP is bridged as an opaque byte stream, so all operations
(browse, upload, download, rename) work normally.
## The interactive picker
Log in with just your username and you get a TUI list of every host you can
reach:
```bash
ssh alice@jump.example.com
```
- **↑ / ↓** move the selection
- **type** to filter the list incrementally
- **Enter** connect to the highlighted host
- **number keys** jump straight to that row
- **q** or **Ctrl-C** to quit
Pick a host and you're bridged into it. The picker only ever lists hosts your
directory access allows — it doubles as "what can I reach from here?"
## What you can reach
The set of hosts is computed per login: your LDAP group memberships intersected
with the SSO directory's hosts (via the `host_<name>_access` groups the
directory auto-creates for each machine). To get access to a new host, an admin
adds you to that host's access group in the SSO — nothing on the jump host
changes.
Targets that don't resolve to a host you're allowed to reach are refused (and
audited). Raw IPs that aren't a known directory host are denied by default.
## Authentication
The jump host authenticates **you** against the directory:
- **Public key** — matched against your `sshPublicKey` entries in LDAP. Use your
normal SSH key; the client picks it automatically.
- **Password** — your directory password (LDAP bind). Password auth is often
restricted to local networks or disabled entirely on a public jump host
(keys-only) — check with your operator.
You never manage a separate credential for the downstream host: the jump host
handles onward authentication for you (see
[Architecture](architecture.html#per-user-key-injection)).
## First connection to a host
The very first time you reach a given downstream host, the jump host provisions
its access key for you behind the scenes. If that first attempt races the
directory's key-cache refresh you may see a brief
```
jump-host: first-time key propagation, retrying…
```
and it reconnects automatically. Subsequent connections are immediate.
Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

+91
View File
@@ -0,0 +1,91 @@
---
layout: default
title: Home
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.
---
# Theta Gateway
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 Theta Directory are the people who can reach your machines — and only the
machines their directory groups grant.
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
<a href="images/login.png" target="_blank"><img src="images/login.png" alt="Login" width="49%"></a>
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Dashboard" width="49%"></a>
<a href="images/sessions.png" target="_blank"><img src="images/sessions.png" alt="Active sessions" width="49%"></a>
<a href="images/audit.png" target="_blank"><img src="images/audit.png" alt="Audit log" width="49%"></a>
*(click any screenshot to view full size)*
## Two ways to connect
**Direct (WinSCP/SFTP-friendly):**
```bash
ssh alice_-_web01@jump.example.com
sftp -P 2222 alice_-_web01@jump.example.com
```
The username grammar is `{uid}_-_{target}``target` is a directory host slug
(with or without the `host_` prefix), a bare hostname, or an IP. One username
string, no interactive step, so it works cleanly in WinSCP and scripts.
**Interactive picker:**
```bash
ssh alice@jump.example.com
```
A plain login shows a TUI list of the hosts you can reach; arrow-key or type to
filter, Enter to connect.
See **[Connecting](connecting.html)** for the full usage guide.
## Why a jump host (and why this one)
A bastion/jump host is the standard way to give SSH access to internal machines
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.
Theta Gateway answers both from your directory:
- **Authorization is your directory graph.** The hosts you can reach are the
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's `AuthorizedKeysCommand`), so nothing downstream needs
configuring.
## Features
- **Username-grammar routing** (`uid_-_target`) — straight-through to the host,
SFTP included (WinSCP works)
- **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 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
+64
View File
@@ -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 1254) 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.
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/favicon.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<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 }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+972
View File
@@ -0,0 +1,972 @@
---
layout: default
title: API Reference
description: The proxy's management REST API — hosts, DNS providers, users, groups, and permissions.
---
# API Documentation
[← Back to Home](index.html)
All API endpoints require authentication unless otherwise noted. Three
authentication methods are supported:
- **`auth-token` header** — a browser-session token from `POST /api/auth/login`
or the OIDC flow (below).
- **`Authorization: Bearer <token>` header** — a self-service API token (PAT,
see [API Tokens](#api-tokens)), for scripts/CI without a browser session.
- **OIDC (browser)** — if the proxy is configured as an OIDC client of an SSO
(`app_oidc__*` / `conf.oidc`, see [DEPLOYMENT.md](https://github.com/theta42/proxy/blob/master/DEPLOYMENT.md)),
users can log in via `GET /api/auth/oidc/start` instead of posting a
username/password.
The proxy can also be configured as a **direct LDAP client** (`app_ldap__*` /
`conf.ldap`) for looking up/validating users, independent of the OIDC flow —
see DEPLOYMENT.md for the full configuration reference.
Authenticated requests also carry **RBAC** (role-based access control):
global admins can manage everything; other users are scoped to `viewer` or
`manager` rights on specific domains via [Permissions](#permissions) and
[Groups](#groups).
Base URL: `https://your-proxy-host.com/api`
---
## Authentication
### Login
**POST** `/api/auth/login`
Authenticate a user and receive an auth token.
```bash
curl -H "Content-Type: application/json" \
-X POST \
-d '{"username": "myuser", "password": "mypassword"}' \
https://proxy-host.com/api/auth/login
```
**Responses:**
- `200` `{"login": true, "token": "027d3964-7d81-4462-a6f9-2c1f9b40b4be", "message": "myuser logged in!"}`
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}`
### Logout
**ALL** `/api/auth/logout`
Invalidate the current auth token.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/auth/logout
```
**Responses:**
- `200` `{"message": "Bye"}`
### OIDC Login (start)
**GET** `/api/auth/oidc/start`
Begin the OIDC authorization-code flow: creates a PKCE + state challenge and
redirects the browser to the configured SSO's authorize endpoint. Only
available when `conf.oidc.enabled` is true.
**Query Parameters:**
- `redirect` - Internal path to return to after login (optional; sanitized to same-origin)
```bash
curl -i "https://proxy-host.com/api/auth/oidc/start?redirect=/hosts"
```
**Responses:**
- `302` Redirect to the SSO's authorization endpoint
- `404` `{"name": "OidcDisabled", "message": "OIDC login is not enabled."}`
### OIDC Callback
**GET** `/api/auth/oidc/callback`
Redirect target for the SSO after login. Validates the one-time `state`,
exchanges the authorization `code` for tokens, reads identity from the
userinfo endpoint, establishes a session, and redirects the browser back to
the login page with the app's own `auth-token` in a URL fragment.
**Query Parameters:**
- `code` (required) - Authorization code from the SSO
- `state` (required) - State value from the `start` step
```bash
# Not called directly — the SSO redirects the browser here after login.
```
**Responses:**
- `302` Redirect to `/login#token=...&redirect=...`
- `400` `{"name": "OidcCallbackInvalid", "message": "Missing code or state."}` or expired/unknown state
---
## API Tokens
Self-service personal access tokens (PATs) for scripting/CI without a browser
session. Every endpoint is owner-scoped: a user only sees/manages tokens they
created. Mounted at `/api/api-token`.
### List API Tokens
**GET** `/api/api-token`
List the current user's API tokens.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": [{"id": "...", "name": "ci", ...}, ...]}`
### Create API Token
**POST** `/api/api-token`
Create a new API token. The raw token string is only returned once, at
creation.
**Parameters:**
- `name` (required) - Display name
- `description` (optional)
- `expires_in_days` (optional) - `0` or omitted means no expiry
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ci", "expires_in_days": 90}' \
https://proxy-host.com/api/api-token
```
**Responses:**
- `200` `{"results": {...}, "token": "prx_<id>_<secret>", "message": "API token 'ci' created. Save it now — it will not be shown again."}`
### Get API Token
**GET** `/api/api-token/:id`
Get a token's metadata (not the raw secret, which is never stored/returned again).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}}`
- `403` Not your token
### Update API Token
**PUT** `/api/api-token/:id`
Update a token's name/description/expiry.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "ci-updated"}' \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"results": {...}, "message": "API token 'ci-updated' updated."}`
### Delete (Revoke) API Token
**DELETE** `/api/api-token/:id`
Revoke a token immediately.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/api-token/<id>
```
**Responses:**
- `200` `{"id": "<id>", "message": "API token 'ci' revoked."}`
### Rotate API Token
**POST** `/api/api-token/:id/rotate`
Issue a new secret for an existing token (same id, new raw value shown once).
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/api-token/<id>/rotate
```
**Responses:**
- `200` `{"token": "prx_<id>_<new-secret>", "message": "API token 'ci' rotated. Save it — it will not be shown again."}`
---
## Users
All user endpoints require authentication. `GET /me` and `PUT /password`
(self-service) work for any authenticated user; everything else (listing,
creating, deleting users, resetting another user's password) requires global
admin.
### List Users
**GET** `/api/user`
Get list of all users. Admin only.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user
```
**Query Parameters:**
- `detail` - Include full user details (optional)
**Responses:**
- `200` `{"results": ["user1", "user2"]}`
- `200` `{"results": [{"username": "user1", ...}, ...]}` (with `?detail=true`)
- `403` Not an admin
### Get Current User
**GET** `/api/user/me`
Get the currently authenticated user's identity and effective RBAC rights
(drives the web UI's nav/button gating).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/user/me
```
**Responses:**
- `200` `{"username": "myuser", "groups": [...], "localGroups": [...], "externalGroups": [...], "isAdmin": false, "global": null, "domains": {...}}`
### Create User
**POST** `/api/user`
Create a new local user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "newuser", "password": "newpassword"}' \
https://proxy-host.com/api/user
```
**Responses:**
- `200` User created successfully
- `403` Not an admin
- `409` Username already exists
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error (also returned for weak passwords)
### Delete User
**DELETE** `/api/user/:username`
Delete a user account. Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/user/olduser
```
**Responses:**
- `200` `{"username": "olduser", "results": ...}`
- `403` Not an admin
- `404` User not found
### Change Password (Self)
**PUT** `/api/user/password`
Change the password for the currently authenticated user.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `422` Weak password rejected by the password policy
### Change Password (Other User)
**PUT** `/api/user/password/:username`
Change the password for another user. Admin only.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"password": "newpassword"}' \
https://proxy-host.com/api/user/password/otheruser
```
**Responses:**
- `200` `{"results": ...}` Password changed successfully
- `403` Not an admin
- `404` User not found
---
## Permissions
RBAC: grants a `viewer` or `manager` role to a user or group, either globally
or scoped to one domain. Global-admin-only. Mounted at `/api/permission`.
### List Permissions
**GET** `/api/permission`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"results": [{"id": "...", "subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com", ...}, ...]}`
### List Permission Subjects
**GET** `/api/permission/subjects`
Autocomplete source for the "Subject" field: known usernames plus known group
names (local groups, groups already used in permissions, and groups from
`conf.auth.adminGroups` / `conf.auth.groupRoleMap`).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/permission/subjects
```
**Responses:**
- `200` `{"users": ["alice", "bob"], "groups": ["ops", "sre"]}`
### Create Permission
**POST** `/api/permission`
Grant a role to a subject.
**Parameters:**
- `subjectType` (required) - `user` or `group`
- `subject` (required) - username or group name
- `role` (required) - `viewer` or `manager`
- `scope` (required) - `global` or `domain`
- `domain` (required if `scope` is `domain`)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"subjectType": "user", "subject": "alice", "role": "manager", "scope": "domain", "domain": "example.com"}' \
https://proxy-host.com/api/permission
```
**Responses:**
- `200` `{"message": "Granted manager to user \"alice\" on example.com.", ...}`
- `422` Validation error
### Delete Permission
**DELETE** `/api/permission/:id`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/permission/<id>
```
**Responses:**
- `200` `{"message": "Permission <id> removed."}`
---
## Groups
Local groups (independent of any SSO/LDAP groups) used as subjects for
permission grants. Global-admin-only. Mounted at `/api/group`.
### List Groups
**GET** `/api/group`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"results": [{"name": "ops", "members": ["alice", "bob"], ...}, ...]}`
### Create Group
**POST** `/api/group`
**Parameters:**
- `name` (required)
- `members` (optional) - array of usernames
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "ops", "members": ["alice"]}' \
https://proxy-host.com/api/group
```
**Responses:**
- `200` `{"message": "Group \"ops\" created.", ...}`
### Delete Group
**DELETE** `/api/group/:name`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops
```
**Responses:**
- `200` `{"message": "Group \"ops\" removed."}`
### Add Group Member
**POST** `/api/group/:name/members`
**Parameters:**
- `username` (required)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"username": "bob"}' \
https://proxy-host.com/api/group/ops/members
```
**Responses:**
- `200` `{"message": "Added \"bob\" to \"ops\".", ...}`
### Remove Group Member
**DELETE** `/api/group/:name/members/:username`
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/group/ops/members/bob
```
**Responses:**
- `200` `{"message": "Removed \"bob\" from \"ops\".", ...}`
---
## Hosts
Manage proxy host configurations.
### List Hosts
**GET** `/api/host`
Get list of all configured hosts.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host
```
**Query Parameters:**
- `detail` - Include full host details (optional)
**Responses:**
- `200` `{"results": ["example.com", "*.wildcard.com"]}`
- `200` `{"results": [{"host": "example.com", "ip": "192.168.1.10", ...}, ...]}` (with `?detail=true`)
### Get Host
**GET** `/api/host/:host`
Get configuration for a specific host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"item": "example.com", "results": {"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, ...}}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Lookup Host
**GET** `/api/host/lookup/:domain`
Test the host lookup algorithm (supports wildcard matching).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookup/sub.example.com
```
**Responses:**
- `200` `{"string": "sub.example.com", "results": {"host": "*.example.com", ...}}`
- `200` `{"string": "sub.example.com", "results": null}` (no match)
### Get Lookup Tree
**GET** `/api/host/lookupobj`
Get the internal lookup tree structure (for debugging).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/host/lookupobj
```
**Responses:**
- `200` `{"results": {"com": {"example": {...}}}}`
### Create Host
**POST** `/api/host`
Add a new host configuration.
**Parameters:**
- `host` (required) - Domain name (e.g., `example.com`, `*.example.com`)
- `ip` (required) - Target IP address or FQDN
- `targetPort` (required) - Target port number (1-65535)
- `forcessl` (optional) - Force HTTPS redirect (default: true)
- `targetssl` (optional) - Use HTTPS to backend (default: false)
- `challengeType` (optional) - For wildcards: `DNS-01-wildcard` or `wildcardChild`
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"host": "example.com", "ip": "192.168.1.10", "targetPort": 8080, "forcessl": true, "targetssl": false}' \
https://proxy-host.com/api/host
```
**Responses:**
- `200` `{"message": "\"example.com\" added.", "host": "example.com", ...}`
- `409` `{"name": "HostNameUsed", "message": "Host already exists"}`
- `422` `{"name": "ObjectValidateError", "message": ...}` Validation error
### Update Host
**PUT** `/api/host/:host`
Update an existing host configuration.
**Parameters:** Same as Create Host (all optional)
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"ip": "192.168.1.20", "targetPort": 9000}' \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "\"example.com\" updated.", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
- `422` Validation error
### Delete Host
**DELETE** `/api/host/:host`
Remove a host configuration.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/example.com
```
**Responses:**
- `200` `{"message": "example.com deleted", ...}`
- `404` `{"name": "HostNotFound", "message": "Host does not exists"}`
### Clear Host Cache
**DELETE** `/api/host/cache`
Remove all cached wildcard-subdomain host lookups. Cache entries are created on
demand when a wildcard host serves a subdomain; clearing them forces the next
request for each subdomain to be resolved fresh through the lookup tree.
Admin only.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/host/cache
```
**Responses:**
- `200` `{"message": "Cleared 3 cached hosts.", "count": 3}`
### Renew Wildcard Certificate
**PUT** `/api/host/:host/renew`
Manually trigger wildcard certificate renewal.
```bash
curl -H "auth-token: your-token-here" \
-X PUT \
https://proxy-host.com/api/host/*.example.com/renew
```
**Responses:**
- `200` `{"message": "Requesting wildcard cert for *.example.com"}`
- `404` Host not found
---
## DNS Providers
Manage DNS provider integrations for wildcard SSL certificates.
### List DNS Providers
**GET** `/api/dns`
Get list of configured DNS providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns
```
**Query Parameters:**
- `detail` - Include full provider details (optional)
**Responses:**
- `200` `{"results": ["provider-id-1", "provider-id-2"]}`
### List Available Provider Types
**OPTIONS** `/api/dns`
Get list of supported DNS provider types and their configuration requirements.
```bash
curl -H "auth-token: your-token-here" \
-X OPTIONS \
https://proxy-host.com/api/dns
```
**Responses:**
- `200` `{"results": [{"name": "Cloudflare", "fields": {...}}, {"name": "DigitalOcean", ...}, {"name": "PorkBun", ...}, {"name": "DuckDns", ...}]}`
### Create DNS Provider
**POST** `/api/dns`
Configure a new DNS provider.
**Cloudflare:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My Cloudflare", "dnsProvider": "Cloudflare", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**DigitalOcean:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DO", "dnsProvider": "DigitalOcean", "token": "your-api-token"}' \
https://proxy-host.com/api/dns
```
**PorkBun:**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My PorkBun", "dnsProvider": "PorkBun", "apiKey": "pk_xxx", "secretApiKey": "sk_xxx"}' \
https://proxy-host.com/api/dns
```
**DuckDNS (free):**
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"name": "My DuckDNS", "dnsProvider": "DuckDns", "token": "your-duckdns-token", "subdomains": "myhost,myhost2"}' \
https://proxy-host.com/api/dns
```
`subdomains` is a comma-separated list of the subdomains you've registered at
[duckdns.org](https://www.duckdns.org) (e.g. `myhost` for
`myhost.duckdns.org`), since DuckDNS has no API to list them for you.
DuckDNS only supports one A/AAAA record and one TXT record per domain (no
arbitrary sub-records) — enough for dynamic DNS and DNS-01 wildcard certs.
**Responses:**
- `200` `{"message": "\"provider-id\" added.", ...}`
- `422` Validation error or invalid API credentials
### Get DNS Provider
**GET** `/api/dns/:id`
Get a specific DNS provider configuration.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"item": "provider-id", "results": {...}}`
- `404` Provider not found
### Update DNS Provider
**PUT** `/api/dns/:id`
Update DNS provider configuration.
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X PUT \
-d '{"name": "Updated Name"}' \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "\"provider-id\" updated.", ...}`
- `404` Provider not found
### Delete DNS Provider
**DELETE** `/api/dns/:id`
Remove a DNS provider and all associated domains.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/provider-id
```
**Responses:**
- `200` `{"message": "provider-id deleted", ...}`
- `404` Provider not found
### List Domains
**GET** `/api/dns/domain`
List all domains from all configured providers.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain
```
**Query Parameters:**
- `detail` - Include full domain details (optional)
**Responses:**
- `200` `{"results": ["example.com", "test.com"]}`
### Get Domain
**GET** `/api/dns/domain/:domain`
Get details for a specific domain.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/domain/example.com
```
**Responses:**
- `200` `{"results": [{"domain": "example.com", "zoneId": "...", ...}]}`
- `404` Domain not found
### Refresh Domains
**POST** `/api/dns/domain/refresh/:providerId`
Refresh the domain list from a DNS provider's API.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/domain/refresh/provider-id
```
**Responses:**
- `200` `{"results": ...}` Updated domain list
- `404` Provider not found
### Dynamic DNS
A-records kept automatically pointed at this box's public (WAN) IP. All
`/api/dns/dynamic*` routes are viewer/manager scoped to the record's domain
(via [Permissions](#permissions)), not admin-only like the rest of `/api/dns`.
#### Get Current Public IP
**GET** `/api/dns/dynamic/ip`
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic/ip
```
**Responses:**
- `200` `{"ip": "203.0.113.5"}`
#### List Dynamic Records
**GET** `/api/dns/dynamic`
Lists records the caller may view (their own/granted domains, or all for admins).
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"results": [{"id": "...", "domain": "example.com", "name": "home", "last_status": "ok", ...}, ...]}`
#### Create Dynamic Record
**POST** `/api/dns/dynamic`
Requires `manager` rights on the target domain. Applies the record immediately
against the current public IP (best-effort — failures are recorded in
`last_status` and retried by the scheduler).
**Parameters:**
- `domain` (required)
- `name` (required) - sub-label, or `@` for the apex
```bash
curl -H "Content-Type: application/json" \
-H "auth-token: your-token-here" \
-X POST \
-d '{"domain": "example.com", "name": "home"}' \
https://proxy-host.com/api/dns/dynamic
```
**Responses:**
- `200` `{"message": "\"home.example.com\" added.", ...}`
- `403` Missing `manager` rights on the domain
- `422` Validation error
#### Refresh Dynamic Record
**POST** `/api/dns/dynamic/:id/refresh`
Force an immediate refresh of one record against the current public IP.
Requires `manager` rights on the record's domain.
```bash
curl -H "auth-token: your-token-here" \
-X POST \
https://proxy-host.com/api/dns/dynamic/<id>/refresh
```
**Responses:**
- `200` `{"message": "Refreshed \"home.example.com\".", "result": {...}}`
- `403` Missing `manager` rights on the domain
#### Delete Dynamic Record
**DELETE** `/api/dns/dynamic/:id`
Stop managing a record. Requires `manager` rights on the record's domain.
Leaves the provider's A record in place at its last value.
```bash
curl -H "auth-token: your-token-here" \
-X DELETE \
https://proxy-host.com/api/dns/dynamic/<id>
```
**Responses:**
- `200` `{"message": "home.example.com removed.", ...}`
- `403` Missing `manager` rights on the domain
---
## Certificates
Retrieve SSL certificate information.
### Get Certificate
**GET** `/api/cert/:host`
Get the SSL certificate for a host.
```bash
curl -H "auth-token: your-token-here" \
https://proxy-host.com/api/cert/example.com
```
**Responses:**
- `200` Certificate data including `cert_pem`, `fullchain_pem`, `privkey_pem`, expiry information
- `404` Certificate not found
---
## Error Responses
All endpoints may return the following error responses:
- `401` `{"name": "LoginFailed", "message": "Invalid Credentials, login failed."}` - Authentication required or invalid
- `404` `{"name": "NotFound", "message": "..."}` - Resource not found
- `422` `{"name": "ObjectValidateError", "message": [...], "keys": [...]}` - Validation errors
- `500` Internal server error
## Notes
- All timestamps are in milliseconds since epoch
- Authenticated endpoints accept either the `auth-token` header (browser
session / OIDC login) or an `Authorization: Bearer <token>` API token
- Host names support wildcards: `*` (single level) and `**` (multi-level)
- DNS providers are validated on creation - invalid API credentials will be rejected
- Wildcard certificates are automatically renewed 30 days before expiration
+291
View File
@@ -0,0 +1,291 @@
---
layout: default
title: Architecture
description: How the proxy's OIDC client, LDAP client, and OpenResty routing fit together.
---
# Architecture
[← Back to Home](index.html)
> Looking for a plainer explanation of hosts, HTTPS, or the local
> permission model instead of internals? See
> [Hosts & HTTPS](concepts-hosts.html) and
> [Users, Groups & Permissions](concepts-access.html).
## System Overview
The proxy system consists of three main components working together to provide high-performance reverse proxying with automated SSL management.
```
┌──────────────────────────────────────────────────────────────┐
│ Internet │
└─────────────────────────┬────────────────────────────────────┘
│ HTTPS/HTTP
┌──────────────────────────────────────────────────────────────┐
│ OpenResty/Nginx │
│ ┌────────────────┐ ┌──────────────┐ ┌─────────────────┐ │
│ │ SSL Termination│ │ Host Routing │ │ Request Proxying│ │
│ │ (lua-resty- │ │ (targetinfo. │ │ │ │
│ │ auto-ssl) │ │ lua) │ │ │ │
│ └────────────────┘ └──────┬───────┘ └─────────────────┘ │
└────────────┬──────────────────┼───────────────────────────┬──┘
│ │ │
Let's Encrypt 1. Check Redis FIRST Backend
HTTP-01 2. Unix Socket (fallback) Services
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────────────────┐
│ Redis │ │ Node.js Application │
│ (Primary Cache) │ │ ┌──────────────┐ ┌─────────┐ │
│ - Host configs ◄────┼──┼──┤ Services │ │ Routes │ │
│ - User accounts │ │ │ - host_lookup│ │ - /api/*│ │
│ - SSL certs │ │ │ - scheduler │ │ │ │
│ - Auth tokens │ │ └──────────────┘ └─────────┘ │
└──────────────────────┘ └─────────┬────────────────────────┘
┌──────────────────────┐
│ DNS Providers │
│ - Cloudflare │
│ - DigitalOcean │
│ - PorkBun │
│ - DuckDNS (free) │
│ (DNS-01 challenges) │
└──────────────────────┘
```
## Component Details
### OpenResty/Nginx (Frontend)
**Responsibilities:**
- Accept incoming HTTP/HTTPS requests
- SSL termination using lua-resty-auto-ssl
- Host-based routing decisions (Redis-first lookup)
- Proxy requests to backend services
**Key Features:**
- HTTP-01 ACME challenge handling for automatic SSL
- Redis-first host lookup with Node.js fallback via Unix socket
- High-performance event-driven architecture
- Support for WebSocket connections
- Continues serving cached hosts even if Node.js is down
**Configuration Files:**
- `/etc/openresty/nginx.conf` - Main configuration
- `/etc/openresty/autossl.conf` - Let's Encrypt integration
- `/etc/openresty/sites-enabled/000-proxy` - Proxy configuration
- `/usr/local/openresty/lualib/targetinfo.lua` - Host lookup module
### Node.js Application (Backend)
**Responsibilities:**
- API for host/user/DNS management
- Wildcard SSL certificate orchestration
- Host lookup tree maintenance
- User authentication and authorization
**Directory Structure:**
```
nodejs/
├── bin/www # Application entry point
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models
│ ├── host.js # Host configuration and lookup
│ ├── auth.js # Authentication logic
│ ├── user.js # User management
│ └── dns_provider/ # DNS provider implementations
├── routes/ # API endpoints
│ ├── host.js # Host CRUD operations
│ ├── dns.js # DNS provider management
│ ├── user.js # User management
│ ├── auth.js # Authentication (login + OIDC)
│ ├── permission.js # RBAC permission management
│ ├── group.js # Local group management
│ └── api_token.js # Self-service API (PAT) tokens
├── services/ # Background services
│ ├── host_lookup.js # Unix socket server
│ └── host_scheduler.js # Cert renewal scheduler
├── middleware/ # Express middleware
│ └── auth.js # Authentication middleware
└── utils/ # Utility modules
└── unix_socket_json.js # Unix socket server
```
### Redis (Data Store)
**ORM:** [model-redis](https://www.npmjs.com/package/model-redis) - A lightweight Redis ORM for Node.js with schema validation, relationships, and automatic key management.
**Stored Data:**
- Host configurations (domain, IP, port, SSL settings)
- User accounts and hashed passwords
- Authentication tokens
- SSL certificates (for wildcard domains)
- DNS provider credentials
- Domain-to-provider mappings
**Key Prefixes:**
```
proxy_Host_<hostname> # Host configuration
proxy_User_<username> # User account
proxy_AuthToken_<token> # Auth tokens
proxy_DnsProvider_<id> # DNS provider
proxy_Domain_<domain> # Domain info
<hostname>:latest # SSL certificate cache
```
## Request Flow
### Standard HTTP/HTTPS Request
1. **Client** sends HTTPS request to `app.example.com`
2. **OpenResty** receives request, terminates SSL
3. **Lua script** (`targetinfo.lua`) queries **Redis first** for host config
4. If **found in Redis**, jump to step 7 (Node.js not involved)
5. If **not in Redis**, Lua queries Node.js via Unix socket as fallback
6. **Node.js** performs host lookup (supports wildcards), caches result in Redis
7. **OpenResty** proxies request to backend service using target IP and port
8. **Response** proxied back to client
**Resilience**: If Node.js goes down, all hosts already cached in Redis continue to work. Only new/uncached hosts will fail until Node.js recovers.
### Wildcard SSL Certificate Request
1. **User** creates wildcard host (`*.example.com`) via API
2. **Node.js** validates domain has DNS provider configured
3. **Let's Encrypt** DNS-01 challenge initiated
4. **DNS provider** API creates TXT record (`_acme-challenge.example.com`)
5. **Let's Encrypt** validates TXT record
6. **Certificate** generated and stored in Redis
7. **DNS provider** cleans up TXT record
8. **Background scheduler** monitors expiration, renews 30 days before expiry
## Host Lookup Algorithm
The lookup tree enables sophisticated domain matching:
```
Input: "api.v1.example.com"
Tree Structure:
{
"com": {
"example": {
"*": { // Matches api.example.com
"#record": {...}
},
"v1": {
"api": { // Matches api.v1.example.com (exact)
"#record": {...}
}
}
}
}
}
Priority: Exact > Single wildcard (*) > Double wildcard (**)
```
**Wildcard Types:**
- `example.com` - Exact match only
- `*.example.com` - Matches `sub.example.com` (single level)
- `**.example.com` - Matches any depth (`sub.deep.example.com`)
- `api.*.example.com` - Matches `api.v1.example.com`, `api.v2.example.com`
## Security Architecture
### Authentication Flow
1. User sends credentials to `/api/auth/login`
2. Credentials validated against stored hash (bcrypt)
3. Token generated and stored in Redis with TTL
4. Token returned to client
5. Subsequent requests include token in `auth-token` header
6. Middleware validates token before processing request
### SSL Certificate Security
- **Private keys** stored only in Redis (memory/disk based on config)
- **Fallback certificates** used when SNI unavailable
- **Let's Encrypt** rate limiting respected
- **DNS provider credentials** marked as `isPrivate` (not returned in API)
### Unix Socket Communication
- Socket file: `/var/run/proxy_lookup.socket`
- Permissions: `777` (container-safe, single-use deployment)
- Protocol: JSON over Unix stream socket
- Buffer handling: Accumulates partial messages until complete JSON
## Performance Optimizations
### Caching Strategy
The system uses a multi-tier caching approach:
1. **Redis (L1 Cache)** - OpenResty checks Redis FIRST for every request
- Primary host configuration storage
- Survives Node.js restarts/failures
- Shared across all OpenResty workers
2. **Node.js Lookup Tree (L2 Cache)** - In-memory host lookup with wildcard matching
- Only queried when Redis has no entry
- Rebuilt automatically when hosts change
- Supports complex wildcard resolution
3. **Wildcard Parent Caching** - Resolved wildcard matches stored back to Redis
- Subsequent requests to `api.example.com` hit Redis directly
- No repeated wildcard resolution needed
### Unix Socket vs HTTP API
Unix socket chosen over HTTP for host lookups:
- **Lower latency** - No TCP overhead
- **Higher throughput** - No HTTP parsing
- **Simpler** - Direct JSON communication
- **Secure** - Filesystem permissions, no network exposure
## Scalability Considerations
### Current Architecture
- **Single instance** - OpenResty + Node.js + Redis on one server
- **Vertical scaling** - Add CPU/RAM as needed
- **Limitations** - Unix socket ties OpenResty to Node.js on same host
### Future Scaling Options
- **Redis cluster** - Distribute data storage
- **Multiple OpenResty instances** - Load balance incoming requests
- **Stateless Node.js** - Run multiple API instances
- **Replace Unix socket** - Use TCP/HTTP for cross-host communication
- **Separate cert management** - Dedicated service for wildcard SSL
## Monitoring and Observability
### Logs
- **OpenResty**: `/var/log/nginx/access.log`, `/var/log/nginx/error.log`
- **Node.js**: `journalctl -u proxy.service`
- **Redis**: `redis-cli MONITOR`
### Health Checks
- Node.js API: `curl http://localhost:3000/api/host`
- Redis: `redis-cli PING`
- OpenResty: `systemctl status openresty`
- Unix socket: `ls -la /var/run/proxy_lookup.socket`
### Metrics to Monitor
- Request rate and response times
- SSL certificate expiration dates
- Redis memory usage
- Host lookup cache hit rate
- Background service execution times
[← Back to Home](index.html)
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+17
View File
@@ -0,0 +1,17 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<!-- Background circle -->
<circle cx="50" cy="50" r="48" fill="#1a1a1a" stroke="#4a9eff" stroke-width="3"/>
<!-- Network nodes -->
<circle cx="30" cy="30" r="8" fill="#4a9eff"/>
<circle cx="70" cy="30" r="8" fill="#4a9eff"/>
<circle cx="50" cy="50" r="10" fill="#66b3ff"/>
<circle cx="30" cy="70" r="8" fill="#4a9eff"/>
<circle cx="70" cy="70" r="8" fill="#4a9eff"/>
<!-- Connection lines -->
<line x1="30" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="30" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="30" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
<line x1="70" y1="70" x2="50" y2="50" stroke="#4a9eff" stroke-width="2"/>
</svg>

After

Width:  |  Height:  |  Size: 788 B

+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+75
View File
@@ -0,0 +1,75 @@
---
layout: default
title: Users, Groups & Permissions
description: A plain-language guide to local admin accounts, groups, and the domain-scoped permission model in theta42/proxy.
---
# Users, Groups & Permissions
This page explains, in plain language, who can manage what in this app. For
the deeper system-design detail, see [Architecture](architecture.html).
## Two different ways to log in
Most people who use apps you've proxied through this app never see this
app's own login at all — they use whatever authentication you set up on
the *individual host* (basic auth, or single sign-on through your SSO
Manager). This page is about a different, smaller group: the people who
manage the proxy itself — adding hosts, registering DNS providers, and so
on.
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 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
controlled by permissions, described below.
## Groups
A **group** here is just a named list of local usernames, used to grant
the same permission to several people at once instead of one at a time.
If you're using SSO instead of local accounts, group membership normally
comes from your identity provider instead — local groups exist mainly for
the local-account case.
## Permissions: scope + role
Each **permission** entry grants one subject (a user or a group) one
**role**, at one **scope** — the two are independent choices:
**Scope** — *where* the role applies:
- **Domain** — only hosts under one specific domain (e.g. someone can
manage everything under `example.com`, but can't see or touch a
completely different domain you also proxy).
- **Global** — everywhere, across every domain this proxy manages.
**Role** — *what* they can do within that scope:
- **Viewer** — read-only. Can see hosts and their settings, but not
change anything.
- **Manager** — full control over hosts (create, edit, delete) within
that scope.
- **Admin** — same host control as Manager, **plus**, but *only when
granted at Global scope*, the ability to manage other people's
permissions, DNS providers, and local user accounts. An Admin role
granted at Domain scope instead of Global behaves exactly like Manager
for that one domain — it does not unlock those extra admin-only pages.
In practice: give someone **Manager** on just the domain(s) they're
responsible for to delegate day-to-day host management without handing
them the keys to everything. Reserve **Global Admin** for people who
should be able to change anything, anywhere, including who else has
access.
## Want more detail?
This page doesn't cover the exact permission-checking implementation or
how SSO group membership maps into this system internally — for that, see
[Architecture](architecture.html).
[← Back to Home](index.html)
+60
View File
@@ -0,0 +1,60 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in theta42/proxy.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](api.html).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that registers or updates hosts automatically (say, spinning up
a new service and wanting the proxy entry created for it without a
manual step).
- A monitoring or backup job that checks this app's health via its API.
- A configuration-management tool that keeps your host list in sync with
something else.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** [permissions](concepts-access.html)
— if you're only a Manager on one domain, a token you create can't touch
any other domain either. If you ever suspect a token has leaked (ended up
somewhere it shouldn't have, like a public script or log file), revoke it
immediately from your Profile page; it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](api.html).
[← Back to Home](index.html)
+49
View File
@@ -0,0 +1,49 @@
---
layout: default
title: DNS Providers
description: A plain-language guide to why theta42/proxy needs a DNS provider, and only for wildcard certificates.
---
# DNS Providers
This page explains, in plain language, what a "DNS provider" is for in this
app and when you actually need one. For setup steps, see
[Installation](installation.html).
## Do you need this at all?
**Only if you want a [wildcard host](concepts-hosts.html)** (something like
`*.example.com` covering every subdomain with one certificate). A normal,
single-name host doesn't need a DNS provider configured at all — skip this
page entirely if that's all you're setting up.
## Why a wildcard cert needs this extra step
To prove you actually own `example.com` before issuing a certificate that
covers *every* possible subdomain of it, Let's Encrypt needs to see a
specific, temporary DNS record appear on that domain — something only the
real owner of the domain could add. A normal single-host certificate
doesn't need this because it can prove ownership a simpler way (by
responding to a web request instead).
So: to get a wildcard certificate, this app needs to be able to add (and
later remove) that one temporary DNS record on your domain automatically,
which means it needs your domain registrar or DNS host's API credentials —
that's what registering a **DNS provider** here does.
## What you're actually giving it access to
A DNS provider entry only needs enough access to add/remove TXT records —
it's not given your registrar account's full login, and it can't do
anything to your domain besides that one narrow task (and, for some
providers, keeping a dynamic A record updated if you use that feature
separately). Check your specific provider's page in the
[Installation guide](installation.html) for exactly what kind of
credential to generate and how narrowly you can scope it.
## Want more detail?
For exact setup steps per provider (Cloudflare, DigitalOcean, Porkbun,
DuckDNS, etc.), see [Installation](installation.html).
[← Back to Home](index.html)
+81
View File
@@ -0,0 +1,81 @@
---
layout: default
title: Hosts & HTTPS
description: A plain-language guide to hosts, HTTPS certificates, and wildcards in theta42/proxy.
---
# Hosts & HTTPS
This page explains, in plain language, what a "host" is and how this app
gets you working HTTPS without you having to think about certificates. For
the deeper system-design detail, see [Architecture](architecture.html); for
step-by-step setup, see [Installation](installation.html).
## What's a "host"?
A **host** is one entry telling the proxy: "when someone requests *this*
public address, send them to *that* server." For example: requests for
`photos.example.com` get sent to the little box in your closet running your
photo app on port 8080. Each app or service you want to reach from outside
your network — a home automation dashboard, a media server, this proxy's
own management UI — gets its own host entry.
Two settings on a host are easy to mix up:
- **Incoming host name** — the public address people type in their
browser (`photos.example.com`).
- **Target IP/port** — where the proxy actually sends the request behind
the scenes (`10.0.0.5:8080`, or a hostname like `photo-server`).
Everything else on the host form (traffic limits, access rules,
authentication) is optional — a bare host with just those two fields
already works.
## HTTPS certificates: mostly automatic
Every public website needs an HTTPS certificate so browsers show the lock
icon instead of a scary warning. This app gets one for you automatically
from [Let's Encrypt](https://letsencrypt.org) the first time a host is
actually requested — you don't manually request, install, or renew
anything for a normal host. This happens behind the scenes using a method
called **HTTP-01**, and it's the default for every new host.
## Wildcards: one certificate for a whole family of hosts
Sometimes you want *every* subdomain under one name to work — `app1.`,
`app2.`, `anything.example.com` — without registering each one by hand and
waiting for its own certificate. That's what a **wildcard** host does: a
single host entry named `*.example.com` gets one certificate that covers
the whole family at once. Setting one up needs one extra piece of
information the automatic method above doesn't need — see
[DNS Providers](concepts-dns.html) for why.
Once a wildcard exists, you have two ways to actually use it:
- **Register nothing else, and turn on "Match any subdomain"** on the
wildcard host itself — *any* subdomain that doesn't already have its own
entry gets automatically routed to the wildcard's target the first time
it's requested. Convenient, but it means literal typos and random scan
traffic get routed too, not just the subdomains you meant to use.
- **Register each subdomain as its own host, as a "Parent Wildcard"
child** — more setup, but each subdomain can point at a different
target/server while still reusing the one wildcard certificate instead
of getting its own. This is the recommended default and is what
"Match only subdomains defined here" (the host form's default) does.
You'll see the **"Parent Wildcard"** option light up automatically on the
host form whenever the name you're entering already has a matching
wildcard available to reuse — including the wildcard's own bare base
domain (e.g. `example.com` itself, not just `something.example.com`).
## Load Balancing
If you have multiple servers running the same application, you can load balance traffic across them. When editing a host, you can specify **Additional Targets** (one `IP:port` per line). The proxy will automatically distribute incoming requests across your primary target and all additional targets using a round-robin strategy, providing simple high availability and load distribution without extra configuration.
## Want more detail?
This page skips the system-internals (Redis, OpenResty, the lookup service)
and the exact install steps. For those, see
[Architecture](architecture.html) and [Installation](installation.html).
[← Back to Home](index.html)
+344
View File
@@ -0,0 +1,344 @@
---
layout: default
title: Contributing
description: How to contribute to the proxy — dev setup, tests, and code conventions.
---
# Contributing Guide
[← Back to Home](index.html)
Thank you for considering contributing to the Proxy project! This guide will help you get started.
## Development Setup
### Prerequisites
- Node.js 18+ (18.x, 20.x, or 22.x recommended)
- Redis server
- Git
### Local Development
1. **Clone the repository**
```bash
git clone https://github.com/theta42/proxy.git
cd proxy/nodejs
```
2. **Install dependencies**
```bash
npm install
```
3. **Start Redis** (if not already running)
```bash
redis-server
```
4. **Run in development mode**
```bash
npm run dev
```
This starts the Node.js API with nodemon for auto-reload on file changes.
5. **Access the API**
- API: `http://localhost:3000/api`
- Web UI: `http://localhost:3000`
## Testing
The project uses Node.js built-in test runner (requires Node 18+).
### Running Tests
```bash
# Run all tests
npm test
# Run only unit tests
npm run test:unit
# Run only integration tests
npm run test:integration
# Watch mode for development
npm run test:watch
```
### Test Structure
```
test/
├── unit/ # Unit tests for isolated components
│ ├── basicauth.test.js
│ ├── callback_queue.test.js
│ ├── dynamic_record.test.js
│ ├── host_features.test.js
│ ├── host_lookup.test.js
│ ├── hostname_validate.test.js
│ ├── host_sso.test.js
│ ├── oidc.test.js
│ ├── password_policy.test.js
│ ├── roles.test.js
│ ├── safe_redirect.test.js
│ ├── unix_socket.test.js
│ └── wildcard_matchany.test.js
├── integration/ # Integration tests
│ └── dns_provider.test.js
└── helpers/ # Test utilities
└── dns_provider_contract.js
```
### Writing Tests
We test **custom logic**, not third-party libraries:
**DO test:**
- Host lookup algorithm
- Socket buffering logic
- DNS provider contracts
- Custom utility functions
**DON'T test:**
- Express.js routing
- Redis ORM
- External DNS APIs (use mocks instead)
### Adding DNS Provider Tests
When adding a new DNS provider, you **must** add contract tests:
```javascript
describe('NewProvider Provider', () => {
const NewProvider = require('../../models/dns_provider/newprovider');
test('should meet DNS provider contract', () => {
const mockCredentials = {api_key: 'mock-key'};
const instance = validateDnsProviderContract(NewProvider, mockCredentials);
assert.ok(instance);
});
test('should have valid method signatures', () => {
const instance = new NewProvider({api_key: 'mock'});
validateMethodSignatures(instance);
});
test('should validate key mapping', () => {
const instance = new NewProvider({api_key: 'mock'});
validateKeyMapping(instance);
});
test('should validate type checking', () => {
const instance = new NewProvider({api_key: 'mock'});
validateTypeChecking(instance);
});
});
```
See `test/integration/dns_provider.test.js` for examples.
## Code Style
### General Guidelines
- Use strict mode: `'use strict';`
- Use tabs for indentation
- Clear, descriptive variable names
- Comment complex logic
- No trailing whitespace
### File Organization
```javascript
'use strict';
// 1. Node.js built-ins
const fs = require('fs');
const path = require('path');
// 2. Third-party modules
const express = require('express');
const redis = require('redis');
// 3. Local modules
const {Host} = require('./models');
const middleware = require('./middleware/auth');
// 4. Code...
```
### Naming Conventions
- Classes: `PascalCase`
- Functions: `camelCase`
- Constants: `UPPER_SNAKE_CASE`
- Private methods: `__privateMethod` (double underscore prefix)
## Project Structure
Understanding the codebase:
```
nodejs/
├── conf/ # Configuration (base.js, environment overlays, secrets.js)
├── controller/ # App-level wiring (pubsub, startup)
├── migrations/ # One-off Redis data migration scripts
├── models/ # Data models (Host, User, DNS providers)
├── routes/ # API route handlers
├── services/ # Background services (lookup, scheduler)
├── middleware/ # Express middleware
├── utils/ # Utility functions
├── public/ # Static web assets
├── views/ # EJS templates
└── test/ # Test suite
```
## Pull Request Process
### Before Submitting
1. **Run tests** - Ensure all tests pass
```bash
npm test
```
2. **Test locally** - Verify your changes work
```bash
npm run dev
```
3. **Update documentation** - Keep docs in sync with code changes
4. **Commit messages** - Use clear, descriptive messages
```
Add DNS provider for Route53
- Implement Route53 DNS API client
- Add contract tests for Route53
- Update documentation with Route53 setup
```
### Submitting a PR
1. **Fork the repository**
2. **Create a feature branch**
```bash
git checkout -b feature/my-new-feature
```
3. **Make your changes**
4. **Commit your changes**
```bash
git add .
git commit -m "Description of changes"
```
5. **Push to your fork**
```bash
git push origin feature/my-new-feature
```
6. **Open a Pull Request** on GitHub
### PR Requirements
- All tests must pass (CI/CD runs automatically)
- Tests run on Node.js 18.x, 20.x, and 22.x
- No merge conflicts with `master`
- Code follows project conventions
- New features include tests
- Documentation updated if needed
### CI/CD Process
When you open a PR:
1. GitHub Actions automatically runs tests
2. Tests execute on multiple Node.js versions
3. PR cannot be merged until all checks pass
4. Review from maintainers
5. Merge to master
## Data Models
The project uses [model-redis](https://www.npmjs.com/package/model-redis) as the ORM for Redis data storage. All models extend the `Table` class and use a declarative schema via `_keyMap`.
**Example Model:**
```javascript
const Table = require('../utils/redis_model');
class Host extends Table {
static _key = 'host'; // Primary key field
static _keyMap = {
'host': {isRequired: true, type: 'string', min: 3, max: 500},
'ip': {isRequired: true, type: 'string', min: 3, max: 500},
'targetPort': {isRequired: true, type: 'number', min: 0, max: 65535},
'forcessl': {default: true, type: 'boolean'},
'created_on': {default: () => Date.now(), type: 'number'}
};
}
```
**Learn more:** [model-redis documentation](https://www.npmjs.com/package/model-redis)
## Adding Features
### Adding a DNS Provider
1. **Create provider file** in `models/dns_provider/yourprovider.js`
2. **Extend DnsApi base class**
```javascript
const {DnsApi} = require('./common');
class YourProvider extends DnsApi {
static _keyMap = {
api_key: {isRequired: true, type: 'string', isPrivate: true}
};
// Implement required methods
async listDomains() { }
async getRecords(domain, options) { }
async createRecord(domain, options) { }
async deleteRecords(domain, options) { }
}
```
3. **Add to provider list** in `models/dns_provider.js`
4. **Add contract tests** in `test/integration/dns_provider.test.js`
5. **Test your provider**
```bash
npm run test:integration
```
### Adding API Endpoints
1. **Add route** in appropriate file (`routes/`)
2. **Update API documentation** (`nodejs/api.md` and `docs/api.md` — keep them in sync)
3. **Test the endpoint** manually and add integration tests if needed
## Getting Help
- **Questions?** Open a [GitHub Discussion](https://github.com/theta42/proxy/discussions)
- **Bug reports** Use [GitHub Issues](https://github.com/theta42/proxy/issues)
- **Security issues** Email maintainers directly (see package.json)
## Code of Conduct
- Be respectful and inclusive
- Focus on constructive feedback
- Help others learn and grow
- Follow the project's technical direction
## License
By contributing, you agree that your contributions will be licensed under the MIT License.
---
[← Back to Home](index.html) | [View on GitHub](https://github.com/theta42/proxy)
Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 440 KiB

+51
View File
@@ -0,0 +1,51 @@
---
layout: default
title: Home
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.
---
# Theta Proxy
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.
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
<a href="images/hosts.png" target="_blank"><img src="images/hosts.png" alt="Host list" width="49%"></a>
<a href="images/host-auth-sso.png" target="_blank"><img src="images/host-auth-sso.png" alt="Per-host SSO auth" width="49%"></a>
Basic auth and SSO are mutually exclusive per host, with per-user password
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)*
## 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, 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
+22 -12
View File
@@ -1,7 +1,7 @@
---
layout: default
title: Quickstart
description: Step-by-step first run for theta-env — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
description: Step-by-step first run for theta-suite — prerequisites, setup.env, and bringing up the stack with ./setup.sh.
---
# Quickstart Guide
@@ -12,8 +12,7 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
## Prerequisites
- A Linux host with **Docker** + **Docker Compose** (the v2 plugin `docker
compose` or the v1 standalone `docker-compose` both work).
- A Linux host with **Docker + Docker Compose** (you must use the modern `docker compose` v2 plugin; the older `docker-compose` v1 standalone will fail on BuildKit images).
- Two hostnames that resolve to the host: one for the SSO UI (your `stack.ssoHost`),
one for the proxy mgmt UI (your `stack.proxyHost`). On a real network add DNS
records; for a local try, add them to `/etc/hosts`.
@@ -26,8 +25,8 @@ description: Step-by-step first run for theta-env — prerequisites, setup.env,
## 1. Clone
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
git clone --recursive https://github.com/theta42/theta-suite.git
cd theta-suite
```
`--recursive` fetches the two submodules (`sso-manager-node`, `proxy`) in one
@@ -60,8 +59,7 @@ setups `CFG_DOMAIN` is the only value you set:
| `CFG_ADMIN_UID` | `admin` | optional, defaults to `admin` |
| `CFG_ADMIN_EMAIL` | `admin@<proxyHost>` | optional |
| `CFG_BASE_DN` | `dc=lab,dc=local` | advanced: override the derived LDAP base DN |
| `CFG_JUMP_HOST_ENABLED` | `true` | optional: bring up the [SSH jump host](https://theta42.github.io/jump-host/) (default off) |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` |
| `CFG_JUMP_HOST` | `jump.lab.local` | optional, defaults to `jump.<domain>` (the [SSH jump host](https://theta42.github.io/jump-host/) is installed + started by default) |
| `JUMP_SSH_PORT` | `2222` | optional: host port for the jump host's SSH (never 22 by default) |
`setup.env` is used **only on the first run** to generate `./config/`; after
@@ -76,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
@@ -142,11 +145,19 @@ then converges the stack to your `./config/` values (LDAP service account + admi
passwords are reset to the config; the OAuth client is kept if `proxy-secrets.js`
already holds its creds).
> **Troubleshooting: "A newer version is available" after running setup.sh?**
> If the UI shows this warning immediately after you ran `./setup.sh`, the latest
> GitHub release tag might not yet be merged into the default tracking branch for
> the submodules, or Docker may have cached the `COPY` step if the `package.json`
> didn't change. You can force a clean rebuild by running
> `docker compose build --no-cache` and then re-running `./setup.sh`.
---
## Direct LDAP for legacy apps
## Direct LDAP for LDAP-native clients and Linux hosts
Legacy apps bind LDAP directly over LDAPS:
LDAP-native apps and Linux hosts (PAM/SSSD, sudo, SSH keys) bind LDAP directly
over LDAPS:
```bash
ldapsearch -x -H ldaps://<host>:636 \
@@ -165,7 +176,7 @@ or the admin DN. Use LDAPS (636), not plain LDAP.
before each rebuild (keeps the last `BACKUP_KEEP`, default 5). For manual
backups and the full restore runbook (full / Redis-only / LDAP-only, with the
AOF-vs-RDB note), see the *Backups and restore* section of the
[README](https://github.com/theta42/theta-env#backups-and-restore). Quick LDAP
[README](https://github.com/theta42/theta-suite#backups-and-restore). Quick LDAP
backup:
```bash
@@ -183,7 +194,6 @@ docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
under **API Tokens** in each UI, mint a personal access token and use it as
`Authorization: Bearer sso_…` (SSO) or `prx_…` (proxy). A token authenticates as
its creator with their permissions. See each submodule's DEPLOYMENT.
- See [Architecture](architecture.html) for how it all fits together, and
[Standalone](standalone.html) to run either project on its own.
- See [Architecture](architecture.html) for how it all fits together.
[← Back to Home](index.html)
+1 -1
View File
@@ -1,4 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://theta42.github.io/theta-env/sitemap.xml
Sitemap: https://theta42.github.io/theta-suite/sitemap.xml
+127
View File
@@ -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.
+274
View File
@@ -0,0 +1,274 @@
---
layout: default
title: Secrets (OpenBao)
description: theta-suite's central secrets architecture — OpenBao as the single store for all app, per-user, and external-app secrets, with scoped tokens and policies.
---
# Secrets — OpenBao as the central store
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 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
`setup.sh` creates the ACL policies and mints the per-app tokens
(idempotently — re-running keeps existing tokens and re-mints only expired
ones). The root token stays in `.env` for setup/maintenance **only** and is
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/*`, `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-*`,
`token_period=24h`. The SSO mints per-user and per-admin tokens *through*
this role at runtime, so it never needs the root token to issue scoped
access. The 24h period is fine here because the broker re-mints these from
its Redis cache transparently.
- `sso-app``allowed_policies_glob=app-*`, `token_period=768h`. External-app
tokens minted from the vault UI's Apps tab go through this role: they are
long-lived credentials, so they get a monthly period instead of a daily one.
- `theta-svc``allowed_policies=sso-broker,proxy,jump-host`,
`token_period=768h`. The services' own tokens (below).
### Token lifecycle — nothing expires by surprise
Periodic tokens never hit a max TTL, but they die if nothing renews them
inside a period window. Renewal is automated at every layer:
- **Service tokens** (`SSO_VAULT_TOKEN`, `PROXY_VAULT_TOKEN`,
`JUMP_VAULT_TOKEN`, minted via `theta-svc`, stored in `./.env`): the
`bao-renewer` sidecar (docker-compose) renews all three every 12 hours, and
every `setup.sh` re-run renews them too. A valid-but-non-periodic token from
an older install is detected, revoked, and re-minted as periodic on the next
`setup.sh` run.
- **External-app tokens** (minted in the SSO vault UI): the SSO stores each
token's **accessor** (which can renew/revoke but not authenticate) and
renews it every 6 hours and at boot — a downstream app's credential stays
valid as long as the SSO is running, with no renewal code in the downstream
app. Re-minting an app's token revokes the previous one via its accessor, so
exactly one credential per app is ever live.
- **Per-user / admin tokens**: 24h TTL by design; the broker re-mints them
transparently, so there is nothing to renew.
Worst case (the whole stack was down for >32 days): re-run `./setup.sh` — it
re-mints anything that lapsed; external-app tokens are re-minted from the
Apps tab (the app's policy and stored secrets are kept).
## Seeding
`setup.sh` seeds, on first run only (skipped if the path already exists):
- `secret/sso-manager/conf` — from `./config/sso-secrets.js` (operator-set
LDAP/SMTP/`jwtSecret`; the SSO has no bootstrap-generated creds, so the file
is the complete source of truth).
- `secret/proxy/conf` — from `./config/proxy-secrets.js` (placeholder OAuth
creds at this point).
- `secret/jump-host/conf` — from `./config/jump-secrets.js` after the bootstrap
writes it.
The **bootstrap** (`bootstrap/bootstrap.js`) then generates the real OAuth
client credentials and writes the *complete* `proxy-secrets.js` and
`jump-secrets.js` objects into `secret/proxy/conf` and `secret/jump-host/conf`
(POST, replacing the placeholder seed). After the first run, OpenBao is
authoritative; the `./config/*-secrets.js` files are operator-edit seed
artifacts and the fail-soft fallback.
## End-user personal secrets
Every logged-in user has a personal namespace `secret/users/<uid>/*`, reached
through the SSO UI at **Vault → My Secrets**. The SSO mints a `user-<uid>`
token on first access (cached in Redis for the token's lifetime) and proxies
`/api/vault` to OpenBao with **that** token injected server-side — the
client's SSO session token never reaches OpenBao.
- **Non-admins** see only their own namespace; the UI fixes the path prefix
to `users/<uid>/`. They can list, read, write, and delete secrets there.
- **Admins** (`app_sso_admin` / `app_super_admin`) get free-form access across
all of `secret/` plus an **Apps** tab (see below).
Scoping is enforced at **two** layers: the SSO's `scopeGuard` rejects any path
outside the subject's prefix with a 403 (defense-in-depth), and the token's
own OpenBao policy enforces the same at the API layer.
## External apps
An external (non-theta42) app gets scoped access to its own namespace,
`secret/apps/<name>/*`, via a token an admin mints once from the SSO UI's
**Vault → Apps** tab. The token is shown **once** (copy it immediately; it is
not stored retrievably) and confined by an `app-<name>` policy.
Convention:
- `secret/apps/<name>/conf` for config-style secrets, `secret/apps/<name>/*`
for arbitrary keys.
- The app authenticates with the header `X-Vault-Token: <minted token>`
against `http://<openbao-host>:8200/v1/secret/data/apps/<name>/...`.
Non-Node consumers (curl):
```bash
VAULT_ADDR=http://openbao:8200 # or your external-facing openbao address
# Write
curl -X POST "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" -H "Content-Type: application/json" \
-d '{"data":{"db_password":"..."}}'
# Read
curl -s "$VAULT_ADDR/v1/secret/data/apps/my-service/conf" \
-H "X-Vault-Token: <token>" | jq .data.data
```
Node consumers can use [@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/)
directly:
```js
const baoConf = require('@simpleworkjs/bao-conf');
const data = await baoConf.get('apps/my-service/conf'); // secret/data/apps/my-service/conf
await baoConf.set('apps/my-service/conf', { db_password: '...' });
```
## The theta-agent signing key
The SSO signs high-risk theta-agent commands (`reboot`, `configure_ldap`,
`arbitrary_bash`, …) with an Ed25519 key stored at
`secret/agent/signing-key`. Agents pin the matching public key in their
`agent.yml`, so the key **must** be stable: it used to be generated in memory at
process start, which meant it changed on every restart and no agent could
meaningfully verify anything.
If the SSO cannot read or write that path it refuses to send high-risk commands
rather than signing with a key no agent has seen — so an upgraded stack that has
not re-run `./setup.sh` (and therefore lacks `secret/agent/*` in the
`sso-broker` policy) will report `signingAvailable: false` on
`GET /api/agent/nodes` and reject those commands with a clear error.
## Plugin secrets
The SSO Manager's plugin system (configurable plugin instances you create,
edit, load/unload, and run from the **Plugins** page) stores each instance's
secrets in its own OpenBao namespace, `secret/plugins/<instance-id>/conf`,
rather than in the static `sso-secrets.js` `discovery.plugins` block. The
SSO reads and writes these server-side through the `sso-broker` token (the
plugin runs in-process as a BullMQ worker, so it needs no token of its own),
and the admin UI only ever sees masked (`********`) values.
- A **plugin type** is a module under `nodejs/plugins/<category>/<type>.js`
exporting a manifest (`configSchema` declares which fields are `secret`).
- A **plugin instance** is a configured, loadable/unloadable copy of a type,
tracked in the `PluginInstance` table; you can have multiple instances of the
same type (e.g. two Proxmox endpoints with their own tokens).
- Non-secret config lives in the DB row; only the `secret:true` field values
live in `secret/plugins/<instance-id>/conf`.
Deleting an instance removes both the DB row and its `secret/plugins/<id>/*`
namespace. Legacy `discovery.plugins` entries in `sso-secrets.js` are migrated
to instances automatically on the first boot of SSO Manager ≥ v1.17.0 (the
secret fields are copied into OpenBao at that point). See the SSO Manager
[plugins docs](https://theta42.github.io/sso-manager-node/plugins.html) for the
UI/API reference.
## Operator rotation
If a secret is exposed (or just on a routine schedule), rotate it at the
**provider** first (the LDAP server, the SMTP host, the OAuth `jwtSecret`,
etc.), then update OpenBao:
```bash
# Read the current sso-manager conf
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv get secret/sso-manager/conf
# Write a new value (KV-v2 POST replaces the data; merge carefully)
docker exec -e BAO_TOKEN="$VAULT_TOKEN" openbao bao kv put secret/sso-manager/conf \
ldap.bindPassword='<new>' smtp.password='<new>' oauth.jwtSecret='<new>'
```
Then restart the affected app so `bao-conf.init()` re-reads it
(`docker compose restart sso-manager`). Call-time readers pick up the change
on next read; require-time captures (OIDC `clientSecret`) need the restart.
> The SSO admin **Configuration** UI (`/api/conf`) writes `secret/sso-manager/conf`
> and updates the live conf immediately, so SMTP/discovery/oauth edits made
> there don't need a manual `bao kv put`.
## Backups
The OpenBao data volume `openbao-data` holds every secret. Back it up with the
rest of the stack (see the README's *Backups and restore* section). The
`./config/*-secrets.js` files are **not** a complete secret backup once OpenBao
is authoritative — they're the first-run seed and the fallback. A full disaster
recovery restores both the `openbao-data` volume (the authoritative store) and
`./config/` (the seed/fallback), then runs `./setup.sh` to unseal OpenBao and
re-mint the per-app tokens.
## What's not in scope yet
- **Renewal automation** — per-app/user tokens use OpenBao's default TTL and
are re-minted by `setup.sh` on expiry; a periodic renewal worker is a
follow-up.
- **History scrubbing** — if a secret was committed to git, rotating it is the
fix; scrubbing it from git history (BFG / `git filter-repo`) is a separate,
git-destructive operation you can opt into.
- **Per-app secrets beyond boot config** (e.g. the proxy's DNS-provider creds,
the jump host's per-user LDAP SSH keys) moving into OpenBao — only the
boot-critical `*-secrets.js` contents moved in this phase. (Plugin instance
secrets *are* in OpenBao, at `secret/plugins/<id>/conf` — see above.)
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<link rel="icon" type="image/svg+xml" href="{{ '/assets/img/theta42.svg' | relative_url }}">
{% seo title=false %}
<title>{% if page.title %}{{ page.title }} &middot; {% endif %}{{ site.title }}</title>
<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 }}">
</head>
<body class="d-flex flex-column min-vh-100">
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
<div class="container-fluid px-3">
<a class="navbar-brand d-flex align-items-center" href="{{ '/' | relative_url }}">
<img src="{{ '/assets/img/theta42.svg' | relative_url }}" height="28" class="me-2" alt="">
{{ site.title }}
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navMain" aria-controls="navMain" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse justify-content-end" id="navMain">
<ul class="navbar-nav">
{% for item in site.nav %}
<li class="nav-item">
{% if item.page %}
<a class="nav-link{% if page.url == item.page %} active{% endif %}" href="{{ item.page | relative_url }}">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% else %}
<a class="nav-link" href="{{ item.url }}" target="_blank" rel="noopener">
{% if item.icon %}<i class="fa-solid {{ item.icon }}"></i>{% endif %} {{ item.title }}
</a>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</nav>
<main class="flex-grow-1" style="margin-top: 4.5rem;">
<div class="container-fluid py-4 py-md-5">
<div class="row justify-content-center">
<div class="col-12 col-lg-10 col-xl-8">
<div class="card shadow-lg">
<div class="card-body p-4 p-md-5 site-content">
{{ content }}
</div>
</div>
</div>
</div>
</div>
</main>
<footer class="py-3 bg-dark text-light mt-auto">
<div class="container-fluid d-flex flex-wrap justify-content-between align-items-center small gap-2 px-3">
<span class="d-flex align-items-center gap-2">
<a href="https://theta42.com" target="_blank" rel="noopener">
<img width="40" src="{{ '/assets/img/theta42.svg' | relative_url }}" alt="theta42">
</a>
&copy; {{ 'now' | date: '%Y' }} theta42 &middot;
<a href="{{ site.github.repository_url }}/blob/master/LICENSE" target="_blank" rel="noopener" class="text-light">MIT License</a>
</span>
<span class="d-flex align-items-center gap-3">
<a href="{{ site.github.repository_url }}" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-brands fa-github"></i> GitHub
</a>
<a href="{{ site.github.repository_url }}/blob/master/CHANGELOG.md" target="_blank" rel="noopener" class="text-light text-decoration-none">
<i class="fa-solid fa-list"></i> Changelog
</a>
</span>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>
+88
View File
@@ -0,0 +1,88 @@
---
layout: default
title: Discovery Agents
nav_order: 5
---
# Discovery Agents
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
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
### Agent Skeleton
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
// 1. Fetch your data from an API
// const data = await fetch(...);
// 2. Map data to Resources
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
return { resources, edges };
}
};
```
## Configuration
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
```
## The Reconciliation Engine
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+116
View File
@@ -0,0 +1,116 @@
/* theta42 docs site shares the in-app dark navbar/footer + card look
(Bootstrap 5 + Font Awesome, same as the running apps) rather than a
generic Jekyll theme. */
body {
background-color: #f4f5f6;
}
.navbar-brand img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, .4));
}
.navbar-nav .nav-link.active {
color: #fff;
font-weight: 600;
}
/* Markdown content typography, scoped to the card body so it doesn't leak
into the nav/footer. */
.site-content h1:first-child {
margin-top: 0;
}
.site-content h1,
.site-content h2,
.site-content h3 {
font-weight: 700;
}
.site-content h2 {
margin-top: 2.5rem;
padding-bottom: .4rem;
border-bottom: 1px solid #e9ecef;
}
.site-content h3 {
margin-top: 1.75rem;
}
.site-content a {
color: #a3671f;
text-decoration-color: rgba(163, 103, 31, .35);
}
.site-content a:hover {
color: #8a5a16;
}
.site-content pre {
background-color: #212529;
color: #f8f9fa;
padding: 1rem 1.25rem;
border-radius: .375rem;
overflow-x: auto;
}
.site-content code {
color: #a3671f;
background-color: #f4f0e8;
padding: .15em .4em;
border-radius: .25rem;
font-size: .875em;
}
.site-content pre code {
color: inherit;
background: none;
padding: 0;
}
.site-content table {
display: block;
overflow-x: auto;
width: 100%;
border-collapse: collapse;
margin: 1.25rem 0;
}
.site-content table th,
.site-content table td {
border: 1px solid #dee2e6;
padding: .5rem .75rem;
text-align: left;
}
.site-content table th {
background-color: #f8f9fa;
}
.site-content blockquote {
border-left: 4px solid #C59341;
padding: .5rem 1rem;
margin: 1.25rem 0;
background-color: #f8f6f1;
color: #495057;
}
.site-content img {
max-width: 100%;
height: auto;
}
/* Screenshot grids in the markdown use width="49%" inline attrs for a
two-up desktop layout -- stack them on narrow screens instead of
squeezing to illegibility. */
@media (max-width: 576px) {
.site-content img[width] {
width: 100% !important;
margin-bottom: .75rem;
}
}
.site-content hr {
margin: 2rem 0;
border-top: 1px solid #e9ecef;
}
+51
View File
@@ -0,0 +1,51 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 400" width="100%" height="100%">
<defs>
<linearGradient id="gold-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#C59341" />
<stop offset="20%" stop-color="#E4B869" />
<stop offset="40%" stop-color="#FBF0B9" />
<stop offset="60%" stop-color="#DFB260" />
<stop offset="80%" stop-color="#BC8837" />
<stop offset="100%" stop-color="#A36F28" />
</linearGradient>
<linearGradient id="text-grad" x1="0%" y1="100%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#FFFFFF" />
<stop offset="40%" stop-color="#F5E3B5" />
<stop offset="70%" stop-color="#D4A343" />
<stop offset="100%" stop-color="#8A5A16" />
</linearGradient>
<filter id="drop-shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="8" stdDeviation="6" flood-color="#000000" flood-opacity="0.4"/>
</filter>
</defs>
<g filter="url(#drop-shadow)">
<g fill="url(#gold-grad)">
<path d="M 200,40
C 290,40 350,110 350,200
C 350,290 290,360 200,360
C 110,360 50,290 50,200
C 50,110 110,40 200,40 Z
M 200,75
C 130,75 88,130 88,200
C 88,270 130,325 200,325
C 270,325 312,270 312,200
C 312,130 270,75 200,75 Z"
fill-rule="evenodd" />
<path d="M 88,190 L 140,190 C 140,190 142,210 140,210 L 88,210 Z" />
<path d="M 260,190 L 312,190 C 312,190 310,210 260,210 Z" />
</g>
<text x="200" y="222"
font-family="system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="78"
font-weight="900"
fill="url(#text-grad)"
text-anchor="middle"
letter-spacing="-2">42</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+125
View File
@@ -0,0 +1,125 @@
---
layout: default
title: Accounts, Groups & Managers
description: A plain-language guide to users, service accounts, personal groups, and managers in Theta Directory.
---
# Accounts, Groups & Managers
This page explains the concepts behind the Users and Groups pages in plain
language. If you want the technical schema/attribute-level detail instead,
see the [LDAP reference](ldap.html).
## What's an account?
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
connected (Gitea, Home Assistant, your Wi-Fi, whatever) checks against these
same accounts instead of keeping its own separate list of users and
passwords.
## Two kinds of account: people and service accounts
Most accounts belong to an actual person — check **Users → People** to see
them. But sometimes you need an account for something that *isn't* a
person: a media server, a backup script, a bind account another app uses to
look people up. These are **service accounts**, listed separately under
**Users → Service Accounts**, and they're different from a person's account
in two ways that matter:
- **No email required.** A service account doesn't need a mailbox, so the
form doesn't ask for one.
- **A password is optional.** If you leave it blank, nobody can log in as
that account — which is exactly what you want for something that only
ever gets used programmatically (a script authenticating with an API
token, or another app binding with a fixed, separately-configured
password you set yourself). Only give it a password if the account
genuinely needs to log in or bind somewhere as itself.
Aside from those two differences, a service account is a completely normal
account under the hood — it can belong to groups, have a manager, and so
on, just like anyone else's.
## Groups: who can do what
A **group** is just a named list of accounts, used to control access. This
app has a handful of built-in groups that grant admin powers (e.g. only
people in the `app_sso_admin` group can see the Users/Groups/Directory/Overview
pages at all), but you can also make your own groups for any app you
connect — say, a group listing everyone who should be allowed into your
photo server. Once a group exists, add or remove members from the
**Groups** page, and point the other app's "who's allowed in" setting at
that group's name.
### Groups inside groups
A group can contain another group, not just people — the *Nested* tab on any
group card. Everyone in the inner group counts as a member of the outer one,
however many levels deep it goes.
This is mostly a way to stop repeating yourself. Make one `developers` group,
nest it into the handful of things developers should reach, and adding a new
developer to that one group grants all of them at once — instead of adding them
to each individually and slowly drifting out of sync. The app already does this
for itself: super admins are nested into every resource's admin group, and each
admin group into its access group, so "can administer it" always implies "can
use it".
Two things it won't let you do: put a group inside itself (directly or round a
longer loop), and empty a group completely — every group must keep at least one
member.
A note if you also manage the directory by hand: a group's member list shows
what is *directly* listed on it. Someone who gets in through a nested group is
a real member but won't appear there — the **Nested** tab shows what is nested,
and the API's `effective` view lists everyone who actually gets in.
## Every account's personal group
Separately from the groups above, every single account — person or
service account — automatically gets its own small, personal group when
it's created, named after the account itself. Most of the time you'll
never think about this; it exists so that, on a Linux system connected to
this directory, each account "owns" its own files by default the same way
a normal Unix user account would.
Occasionally you'll want to share that ownership with someone else — for
example, letting a second account also have write access to files a
service account owns. That's what the **"Members of `<uid>`'s group"**
section on a profile page is for: add another account there, and the
underlying Linux permissions treat them as if they belong to that same
personal group too.
## What's a "manager"?
Every account has one or more **managers** — the people allowed to edit
that account's profile (phone number, SSH key, home directory, and so on)
without needing full admin rights. By default, whoever created an account
(the admin who added it, or whoever sent the invite) becomes its first
manager, but you can add or remove managers later from the account's Edit
form.
This is useful for service accounts especially: if a service account
belongs to a particular project or person, make them its manager so they
can maintain it — rotate its SSH key, adjust its description — without
needing to be a full SSO administrator.
## Inviting someone vs. adding them yourself
From the Users page you can either fill in someone's details yourself
("Add new user"), or send them an **invite** — an email (or a link you copy
and send however you like) that lets them pick their own username and
password. Either way, the resulting account is identical; invites are just
a convenience so you don't have to know someone's preferred username or
handle their password directly.
## Want more detail?
This page deliberately leaves out LDAP schema names, attribute types, and
protocol-level detail. If you're connecting a third-party app directly to
the LDAP directory, or you just want to know exactly what's stored where,
see the [LDAP reference](ldap.html).
[← Back to Home](index.html)
+59
View File
@@ -0,0 +1,59 @@
---
layout: default
title: API Tokens
description: A plain-language guide to personal access tokens in Theta Directory.
---
# API Tokens
This page explains what an API token is and when you'd want one. For the
full list of API endpoints a token can call, see the
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
## What's an API token, in plain terms?
Normally, you interact with this app by logging in through a web browser.
An **API token** (also called a personal access token, or PAT) is an
alternative way in — a long, random string that a script, a scheduled job,
or another program can use instead of a username and password, to act on
your behalf without a human typing a login in each time.
If you've ever set up a script to talk to GitHub, GitLab, or a similar
service using a "token" instead of your real password, this is the same
idea.
## When would you actually need one?
Most people never need to create one of these — you'll only want a token
if you're automating something, for example:
- A script that syncs users or groups from somewhere else into this SSO
Manager on a schedule.
- A backup or monitoring job that checks this app's health via its API.
- A CI/CD pipeline that needs to register or update an OAuth client
automatically.
If you're not doing any of that, you don't need an API token — just log in
normally through the web UI.
## How it works
Create a token from your Profile page, give it a name so you remember what
it's for later, and optionally an expiry. You'll be shown the token's
value **exactly once** — copy it somewhere safe immediately, because it
can't be viewed again afterward (only revoked or rotated). Whatever script
or tool you're using it with sends it along with each request, the same
way a browser sends your login session.
A token acts **as you**, with **your** permissions — if you're not an
admin, a token you create can't do admin-only things either. If you ever
suspect a token has leaked (ended up somewhere it shouldn't have, like a
public script or log file), revoke it immediately from your Profile page;
it stops working right away.
## Want more detail?
This page doesn't attempt to list every API endpoint or show request/
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
[← Back to Home](index.html)
+79
View File
@@ -0,0 +1,79 @@
---
layout: default
title: Connecting Apps (Single Sign-On)
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 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 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)
instead of hunting down every app individually.
The technology behind this is called **OAuth 2.0** and **OpenID Connect
(OIDC)** — you'll see both names used, often together, referring to the
same thing. You don't need to understand the protocol to use this page;
what matters practically is the handful of concepts below.
## What's a "client"?
Every app you connect is registered here as a **client** — a single entry
in the Directory representing that one app. Registering a client
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 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 Theta Directory. If you ever suspect
it's leaked, rotate it from the client's card.
## What are "scopes"?
**Scopes** control what information a connected app is allowed to ask for
about the person logging in — their username, email, group memberships,
and so on. Most apps tell you exactly which scopes they need in their own
setup instructions; when in doubt, the default set (`openid`, `profile`,
`email`, `groups`) covers what nearly every app expects.
## "Restrict to Groups"
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
Theta Directory account still works everywhere else.
## Redirect URIs
A **Redirect URI** is the exact web address the connected app wants people
sent back to once they've logged in here — it's a security measure so an
attacker can't trick the login flow into redirecting somewhere else. The
app's own setup instructions will tell you this value; copy it in exactly
as given. If the app is reachable via more than one hostname (for example,
because it sits behind [theta42/proxy](https://theta42.github.io/proxy/)),
this field supports wildcard patterns — see the inline help under the
field itself for the exact syntax.
## Want more detail?
This page intentionally skips the protocol-level detail (exact endpoint
URLs, token formats, claim names). If you're troubleshooting a connection
or building something against the API directly, see the
[OAuth reference](oauth.html).
[← Back to Home](index.html)
+104
View File
@@ -0,0 +1,104 @@
---
layout: default
title: Configuration
description: Theta Directory's config layers — conf/base.js defaults, secrets.js overrides, and app_* environment variables.
---
# Configuration
[← Back to Home](index.html)
The app loads configuration via
[`@simpleworkjs/conf`](https://www.npmjs.com/package/@simpleworkjs/conf), which
deep-merges, in order (later wins):
1. `conf/base.js` — committed, generic defaults (`dc=example,dc=com`,
`localhost`, `SSO Manager`).
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
4. **`app_*` environment variables** — the highest-precedence layer.
Any env var whose name starts with `app_` overrides the merged config. The rest
of the name splits on **double-underscore** (`__`) into a nested path. Values are
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
raw strings otherwise.
## Examples
| Env var | Sets | Type |
|---------|------|------|
| `app_ldap__url=ldap://host:389` | `conf.ldap.url` | string |
| `app_ldap__bindPassword=secret` | `conf.ldap.bindPassword` | string |
| `app_ldap__userBase=ou=people,dc=…` | `conf.ldap.userBase` | string |
| `app_ldap__uidGidMin=1500` | `conf.ldap.uidGidMin` | number (new-user id floor) |
| `app_ldap__uidGidReservedFloor=9000` | `conf.ldap.uidGidReservedFloor` | number (ids at/above this are ignored when allocating) |
| `app_ldap__ldapsHost=ldap.internal.example.com` | `conf.ldap.ldapsHost` | string (hostname shown on `/integrations` for LDAPS binds; empty = derive from `oauth.issuer`) |
| `app_ldap__ldapsPort=636` | `conf.ldap.ldapsPort` | number (port shown on `/integrations`) |
| `app_oauth__jwtSecret=...` | `conf.oauth.jwtSecret` | string |
| `app_oauth__issuer=https://sso.example.com` | `conf.oauth.issuer` | string |
| `app_oauth__token_lifetime__access_token=3600` | `conf.oauth.token_lifetime.access_token` | number |
| `app_smtp__secure=false` | `conf.smtp.secure` | boolean |
| `app_smtp__host=smtp.example.com` | `conf.smtp.host` | string |
| `app_name=My SSO` | `conf.name` | string |
| `app_redis__host=redis.local` | `conf.redis.host` | string (external Redis) |
## The `app_*` env layer requires conf >= 1.1.0
The `app_*` environment-variable override layer was added in
`@simpleworkjs/conf` **1.1.0**. On 1.0.0 the app ignores all `app_*` vars and only
reads `base.js` / `<NODE_ENV>.js` / `secrets.js`. The Docker image will not honor
`app_*` env on 1.0.0. Refresh the lock from the `nodejs/` directory:
```bash
cd nodejs && npm install @simpleworkjs/conf@^1.1.0
```
## Inspecting the merged config
From the `nodejs/` directory:
```bash
node -e "console.log(require('@simpleworkjs/conf').ldap)"
node -e "console.log(require('@simpleworkjs/conf').oauth)"
node -e "console.log(require('@simpleworkjs/conf'))" # everything
```
Or, inside the running container:
```bash
docker compose exec sso-manager node -e "console.log(require('@simpleworkjs/conf').ldap)"
```
`app_*` env vars override `secrets.js`, which overrides `base.js` — if a value
isn't what you expect, check those layers in that order.
## Migrating an existing instance to the generic defaults
The committed `nodejs/conf/base.js` ships **generic** defaults
(`dc=example,dc=com`, `localhost`, `SSO Manager`). Previously it carried
Theta42-specific values (LDAP bind DN/bases, SMTP host/user/sender, OAuth
issuer). If you run an existing instance off this repo:
- Move per-deployment, non-secret values (bind DN, user/group bases, SMTP
host/user/sender, OAuth issuer, org name) from `base.js` into your gitignored
`conf/secrets.js`, **or** set them as `app_*` env vars.
- Secret values (LDAP bind password, SMTP password, JWT secret) already belong
in `secrets.js`.
## Troubleshooting `app_*` env vars
### `app_*` vars seem to do nothing
You're on `@simpleworkjs/conf` 1.0.0. Bump to 1.1.0+ (above).
### LDAP operations 401 / "Invalid Credentials"
Check the merged LDAP config the app actually sees:
```bash
cd nodejs && node -e "console.log(require('@simpleworkjs/conf').ldap)"
```
Confirm `url` / `bindDN` / `bindPassword` / `userBase` match your directory.
[← Back to Home](index.html)
+139
View File
@@ -0,0 +1,139 @@
---
layout: default
title: Directory Management
description: Managing your Home-Lab infrastructure, services, and LDAP access relationships via the SSO Directory API.
---
# Directory Management
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
The Directory models your homelab infrastructure using a parent-child graph (e.g. `Site -> Host -> Service`).
There are three primary **Kinds** of resources you can define:
- **Site**: A physical location, datacenter, or root node (e.g., `us-east`). Sites do not require parents.
- **Host**: A physical machine, Proxmox node, virtual machine, or LXC container. A Host **must** have a parent Site or another Host.
- **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, 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), 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)
For example, if you create a Service named "Emby" with the slug `app_emby`, the system will create the LDAP groups `app_emby_access` and `app_emby_admin`. You can then assign users to these groups, and they will immediately see the service populate on their "My Services" dashboard.
## Resource Metadata
Resources carry a flexible `metadata` JSON object that can store essential context for your applications. The UI natively supports the following metadata fields:
### Common Metadata
- **Sub Type**: Free-form text to categorize the resource (e.g., `proxmox_node`, `linux`, `lxc`, `web`).
- **IP Address**: The internal IP address of the resource.
- **MAC Address**: The hardware address of the primary interface.
- **Host / URI Address**: The FQDN or URL of the resource (e.g., `https://emby.home.arpa`).
- **Production Environment**: A boolean toggle indicating if the resource is in production.
### Host Metadata
- **VMID**: The hypervisor VM or Container ID (e.g. `101`).
- **OS**: The operating system name (e.g. `Ubuntu 22.04.3 LTS`).
- **Kernel**: The kernel version string (e.g. `5.15.0-100-generic`).
### Service Metadata
- **Internal Port**: The local port the service binds to (e.g. `8080`).
- **External Port**: The reverse-proxy or external port (defaults to Internal Port if left blank).
- **Public (No Auth)**: Indicates if the service is exposed publicly without authentication.
- **External Reachable**: Indicates if the service is accessible outside the VPN/local network.
- **Git Repo**: The source code repository for the service (e.g. `https://github.com/...`).
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
### Who sees which metadata
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
- **Never returned**`client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
## Catalog & access requests
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
- **Discover More** — everything else in the directory, with a **Request access** button.
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
## Slug conventions
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`
- **Services/apps**: a plain slug or `app_<name>` — e.g. `sso-manager`, `app_emby`
The auto-created LDAP groups derive from the slug (`<slug>_access` / `<slug>_admin`), so keep slugs stable once access groups are in use.
## Automatic registration
You don't have to build the graph by hand — the theta42 tooling registers itself:
### The stack itself (theta-env)
[theta-env](https://github.com/theta42/theta-env)'s `./setup.sh` seeds the directory on every run with the stack it deploys:
- 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 — 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.
### Linux hosts (ldap-client)
The `ldap-client` join script enrolls a Debian/Ubuntu machine for LDAP login (SSSD/PAM), LDAP-backed `sudo`, and SSH keys from the directory — and, when given an SSO API token, registers the machine as a `host_<hostname>` resource with its IP, MAC, OS, and kernel, parented to the site named by its configured location.
## Consumers of the directory
The inventory graph isn't just documentation — other components read it to make decisions:
- **[Jump Host](https://theta42.github.io/jump-host/)** — an SSH jump host that resolves which downstream machines a user may reach from their LDAP groups × the directory's `host` resources (`GET /api/discovery/resources?group=<cn>`), then bridges them in. The `host_<hostname>` slugs and `host_<slug>_access` groups this directory creates are exactly what it keys off; a host's `metadata.ip` / `metadata.sshPort` tell it where to connect. So a machine registered here (by theta-env or ldap-client) becomes reachable through the jump host the moment a user is in its access group.
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
- `POST /api/access-requests``{slug | resourceId, groupCn?, note?}`
- `GET /api/access-requests/mine` — the caller's own history
- `GET /api/access-requests` — pending requests the caller may decide
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

+52
View File
@@ -0,0 +1,52 @@
---
layout: default
title: 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.
---
# Theta Directory
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
— 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.
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
<a href="images/dashboard.png" target="_blank"><img src="images/dashboard.png" alt="Overview dashboard" width="49%"></a>
<a href="images/users.png" target="_blank"><img src="images/users.png" alt="User list" width="49%"></a>
<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)*
## Features
- **OpenID Connect / OAuth 2.0 provider** — your own access/refresh/ID
tokens; standard discovery document at `/.well-known/openid-configuration`.
- **Bundled OpenLDAP directory** — users, groups, POSIX accounts, SSH public
keys, and sudo roles, with `memberOf` + referential-integrity overlays.
- **Web management UI** — users, groups, and OAuth clients from a browser;
invite and password-reset flows over email; self-service profile + API
tokens.
- **Direct LDAP binds** — anything that binds LDAP directly (Linux hosts
via PAM/SSSD, Gitea, Emby, …) uses LDAPS/StartTLS against the same
directory.
- **[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`).
+432
View File
@@ -0,0 +1,432 @@
---
layout: default
title: LDAP
description: Theta Directory's bundled OpenLDAP directory — schema, service accounts, TLS, and connecting third-party apps directly.
---
# LDAP Directory
[← Back to Home](index.html)
> Looking for a plainer explanation of accounts, groups, and managers
> instead of schema/attribute detail? See
> [Accounts, Groups & Managers](concepts-accounts.html).
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
theta42/proxy, etc.
## Directory layout
```
dc=yourdomain,dc=com
├── ou=people users (inetOrgPerson + posixAccount + …)
├── ou=groups groups (groupOfNames)
└── ou=policies password policies (pwdPolicy)
└── cn=ppolicy default policy
```
### Users
User entries are `cn=<uid>,ou=people,<base>` and carry the objectClasses:
- `inetOrgPerson` (cn, sn, mail, …) — identity / contact attrs.
- `posixAccount` (uid, uidNumber, gidNumber, homeDirectory) — the SSO's
`userFilter` is `(objectClass=posixAccount)`, so a user is "a real account"
iff it has `posixAccount`.
- `ldapPublicKey` — SSH public keys (`sshPublicKey`).
- `sudoRole` — per-user sudo rules (`sudoCommand`, `sudoHost`, `sudoUser`).
- `theta42Person` (custom auxiliary; `dateOfBirth`).
Every user (person or service account) also carries a `manager` attribute
(the standard COSINE `manager`, `SUP distinguishedName`) — one or more DNs of
the people who created/administer that account. Set automatically to the
creator's DN on signup (whoever an admin was logged in as, or whoever sent
the invite), and reassignable later from the account's Edit form. Anyone
listed as a `manager` can edit that account (same fields an admin can:
mobile, description, SSH key, date of birth, home directory, login shell,
and the manager list itself) without needing `app_sso_admin`.
Passwords are stored as `{SSHA512}` (8-byte salt, sha512(pass+salt), base64),
verified by the `pw-sha2` module. The app's `hashPasswordSSHA512` is the
canonical hasher; if you provision users out-of-band, hash passwords the same
way or use `slappasswd -h '{SSHA512}'`.
### Groups
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
attribute listing member DNs. The `memberOf` overlay populates reverse
membership (`memberOf` on the user); `refint` keeps it consistent on
add/remove.
Note that `groupOfNames` requires **at least one member**, which has two
consequences worth knowing: whoever creates a group is automatically seeded
into it, and removing the last member (user *or* nested group) is refused with
a 409 rather than leaving an invalid entry behind.
### Nested groups
A `member` DN may be another group's, not just a user's — that is how nesting
is stored, with no extra schema. Everyone in the nested group is a member of
the outer one, at any depth. Manage it on the **Groups** page under each
group's *Nested* tab, or via the API:
```
PUT /api/group/:group/nested/:child nest :child inside :group
DELETE /api/group/:group/nested/:child un-nest
GET /api/group/:group/effective direct users, nested groups, and the
full transitive set of users
```
Cycles are refused (409) rather than truncated — a loop makes "who is in this
group" unanswerable. Two standing relationships are wired automatically: the
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
something implies being able to use it.
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
direct membership only. The bundled slapd is therefore built from source with
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
closure itself — same answers, more queries. Either way, **never read `memberOf`
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
which is correct in both modes.
### Personal groups
Every user (person or service account) also gets a **personal Unix group**
at creation — `cn=<uid>,ou=groups,<base>`, `objectClass: posixGroup` (RFC
2307), holding just `cn` and `gidNumber` (the user's primary GID). This is a
different schema than the `groupOfNames` groups above — its membership
attribute is `memberUid` (a bare username, not a DN), and unlike
`groupOfNames` it's valid with zero members. It's excluded from the
`/groups` page (which filters on `objectClass=groupOfNames`) and managed
instead from the owning user's own profile page ("Members of `<uid>`'s
group", admin-only) — add other accounts as supplementary members, e.g. to
share write access to files owned by this group.
The SSO seeds these groups automatically (entrypoint / `install.sh`):
| Group | Grants |
|-------|--------|
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
| `app_sso_admin` | full admin (users, groups, settings) |
| `app_sso_oauth_admin` | OAuth client management |
| `app_sso_invite` | invitation management |
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
## TLS (LDAPS / StartTLS)
The bundled slapd generates a **self-signed cert** on first start (CN =
`LDAP_CERT_CN`, valid 10y, SAN = CN + `localhost` + `127.0.0.1`) and listens on:
- `ldaps:///`**636**, TLS (the port to expose for direct-LDAP clients).
- `ldap:///`**389**, plain + StartTLS (not mapped to the host by default).
The cert lives on the `ldap-certs` volume so it persists across container
recreation.
### Trusting the self-signed cert
Copy it out and add it to the client's CA store:
```bash
docker compose cp sso-manager:/etc/openldap/certs/ldap.crt ./ldap.crt
```
…or, for quick LAN use, set `TLS_REQCERT never` on the client (the theta42/proxy
sets `app_ldap__tlsOptions__rejectUnauthorized=false` for the same effect).
### Using your own cert
Replace the `ldap-certs` named volume with a bind mount containing your own
`ldap.crt` + `ldap.key`:
```yaml
volumes:
- ./certs:/etc/openldap/certs # must contain ldap.crt + ldap.key
```
The entrypoint leaves existing certs untouched (idempotent).
## Choosing the LDAPS hostname
The `/integrations` page advertises an **LDAPS URL** for direct LDAP binds. By
default it derives that URL from the public OAuth issuer (e.g.
`https://sso.example.com``ldaps://sso.example.com:636`). That is convenient,
but it implies LDAP clients reach your directory through the same public
hostname — which usually means port-forwarding 636 through your router.
**Do not port-forward LDAPS (636) to the public internet.** LDAP simple binds
have no rate limiting and are a brute-force target. Instead, use one of these
internal-only patterns and set `conf.ldap.ldapsHost` (or
`app_ldap__ldapsHost`) so the `/integrations` page shows the right URL.
### 1. Same Docker / local network host (best for apps on this machine)
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
```
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'sso-manager',
ldapsPort: 636,
}
```
The proxy in theta-env already uses this internally. The bundled slapd cert
includes `sso-manager` in its SAN when `LDAP_CERT_CN` is left at its default,
so hostname verification works without extra setup.
### 2. LAN host behind your router (best for separate home-lan machines)
Create an internal-only DNS record — e.g. `ldap.internal.example.com`
`192.168.1.10` — using your router, Pi-hole, or a local `hosts` file. Then get
or generate a cert whose SAN/CN matches that internal name:
- **Let's Encrypt wildcard** (`*.internal.example.com`) works if you own the
public domain and can complete DNS-01 challenge; the record itself can stay
private/routable only inside your LAN.
- **Internal CA** is fine for a pure LAN: run a small CA, issue a cert for
`ldap.internal.example.com`, and distribute the CA cert to clients.
- **Self-signed** with `LDAP_CERT_CN=ldap.internal.example.com` also works; copy
the generated `ldap.crt` to each client and trust it.
In `conf/secrets.js`:
```javascript
ldap: {
ldapsHost: 'ldap.internal.example.com',
ldapsPort: 636,
}
```
The URL on `/integrations` becomes `ldaps://ldap.internal.example.com:636`.
### 3. Public hostname (acceptable only behind a VPN/firewall)
If a remote host must bind LDAP, put it behind a VPN (Tailscale, WireGuard,
etc.) or a tightly locked-down firewall rule. In that case the public hostname
may be appropriate, but the LDAPS port should still not be reachable from the
open internet.
### Why not just use the LDAP server's IP address?
TLS clients verify the server name against the certificate. Connecting to
`ldaps://192.168.1.10:636` with a cert issued for `*.internal.example.com`
will fail hostname verification unless you disable cert checks — which removes
most of the security benefit of LDAPS. Always use a hostname that matches the
cert.
## Service accounts
A service account is a normal `posixAccount` for something that isn't a
person: a media manager, a torrent client, a service like Emby, or a
read-only bind account an app uses to look users up — anything that needs a
real `uidNumber`/`gidNumber` to own files, or that other accounts join via a
group for write access (e.g. a `stuff_manager` group granting write rights
to a media library). There's only one kind — every account, person or
service, is a real `posixAccount` with a UID.
Create one from the **Users → Service Accounts** tab's "Add new user" form
with **This is a service account** checked — it skips the birthday/
Terms-of-Service fields a real person's account needs and asks for just an
account name. It's flagged (via membership in the `app_sso_service_account`
group) so it's listed separately from real people and excluded from "all
users" notification broadcasts.
Email and password are both optional for a service account:
- No `mail` is set unless you give it one (it never needs a mailbox).
- Leaving the password blank is fine — no `userPassword` attribute is set at
all, and an entry with no `userPassword` simply can't bind with any
password (standard LDAP simple-bind behavior). Only set a password if the
account actually needs to authenticate as itself (e.g. a bind-only account
an app uses to look users up).
theta-env's bootstrap creates its own `cn=ldapclient` bind account directly
against LDAP (independent of this app), and the proxy binds as it — that
account won't show up in the Service Accounts tab since it isn't managed
through this app, but it keeps working unchanged.
Either way: don't reuse the admin DN, and give a service account only the
group memberships and `manager`s it actually needs.
Example bind test (a service account with a password set):
```bash
ldapsearch -x -H ldaps://sso.example.com:636 \
-D "cn=ldapclient,ou=people,dc=yourdomain,dc=com" -W \
-b "ou=people,dc=yourdomain,dc=com" '(objectClass=posixAccount)' cn mail
```
## Connecting a 3rd-party app or container
Most self-hosted apps with an "LDAP authentication" settings page — Gitea,
Nextcloud, Grafana, Emby, Jenkins, etc. — or containers configured via
`LDAP_*` env vars, all ask for the same handful of values. These are the
`conf.ldap` values from [Configuration](configuration.html), applied to
*your* domain:
| Field the app asks for | Value |
|---|---|
| Host / URL | `ldaps://<your-sso-host>:636` (preferred), or `ldap://<host>:389` + StartTLS |
| Bind DN | a dedicated service account — e.g. `cn=ldapclient,ou=people,<base>` (see above) |
| Bind password | that service account's password |
| User search base | `ou=people,<base>` |
| User search filter | `(objectClass=posixAccount)` |
| Username attribute | `uid` |
| Email attribute | `mail` |
| Group search base | `ou=groups,<base>` |
| Group membership attribute | `memberOf` (on the user entry — populated by the `memberof` overlay) |
| TLS | required for 636 (LDAPS); if using the bundled self-signed cert, either trust it (see *TLS* above) or set the app's "don't verify cert" option for LAN-only use |
### Worked example: Gitea
Gitea's **Admin → Authentication Sources → Add Authentication Source** (type
LDAP, "Bind DN/Password") maps directly:
- Security Protocol: `LDAPS`
- Host / Port: your SSO host / `636`
- Bind DN: `cn=ldapclient,ou=people,dc=yourdomain,dc=com`
- Bind Password: the service account's password
- User Search Base: `ou=people,dc=yourdomain,dc=com`
- User Filter: `(&(objectClass=posixAccount)(uid=%s))`
- Username Attribute: `uid`
- E-mail Attribute: `mail`
Other apps with an LDAP settings UI follow the same shape — the field names
above are the constants; only the base DN and hostname change per deployment.
### Generic Docker container (`LDAP_*` env vars)
For images that take a flat env-var LDAP config (there's no single standard,
but most look like this):
```yaml
environment:
LDAP_URL: ldaps://sso.example.com:636
LDAP_BIND_DN: cn=ldapclient,ou=people,dc=yourdomain,dc=com
LDAP_BIND_PASSWORD: <service-account-password>
LDAP_USER_BASE: ou=people,dc=yourdomain,dc=com
LDAP_USER_FILTER: (objectClass=posixAccount)
LDAP_GROUP_BASE: ou=groups,dc=yourdomain,dc=com
```
Check the specific image's docs for its actual variable names — the values
you plug in are still the ones from the table above.
### Full Linux host auth (SSH, sudo, login) instead of a single app
If you want a *host* (not just one app) to authenticate logins, SSH keys, and
sudo against this LDAP directory — not just one application — that's a
different integration (SSSD + PAM + NSS, not a single bind). See
[theta42/ldap-client](https://github.com/theta42/ldap-client): a script that
configures SSSD on Ubuntu/Debian hosts against this directory, including
group-based access control and SSH public key retrieval from LDAP.
## Modules + overlays (external LDAP servers)
If you point the app at your own LDAP server instead of the bundled slapd, it
needs:
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
`ppolicy`, `memberof`, `refint`.
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
unreleased), so the bundled image builds slapd from a pinned upstream commit.
Without it the app resolves nesting itself and everything still works — leave
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
configure:
```
overlay nestgroup
nestgroup-base ou=groups,<base>
nestgroup-flags member-filter memberof-filter memberof-values
```
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
notation suggests is rejected. `member-values` is deliberately omitted — it
expands the `member` attribute when reading a group, which destroys the
distinction between "listed here" and "reachable through a nested group", and
the raw values are then unrecoverable. Transitive answers come from the filter
flags and from `GET /api/group/:group/effective`.
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
is a `slapcat``slapadd` reload, not a restart.
- **Custom schema:** the `theta42Person` auxiliary objectClass with
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
also nests it into the first three).
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
idempotently against a running slapd (auto-detects the database holding your
base DN, and verifies `pwdAccountLockedTime` is live — the attribute the app's
active/inactive toggle depends on).
## Backups and restore
`ops/backup.sh` automates this (LDAP + Redis + `./config/`, with retention)
— see the *Backups and restore* section of
`DEPLOYMENT.md`. The manual LDAP-only steps below are what it does under the
hood, useful if you want just the directory without Redis/config.
**Backup** (while slapd is running):
```bash
docker compose exec sso-manager slapcat -f /etc/openldap/slapd.conf \
-b "dc=yourdomain,dc=com" > ldap-backup-$(date +%F).ldif
```
Store the `.ldif` off the host — it contains every user's password hash.
**Restore** into a stopped directory. The SSO image uses a static `slapd.conf`
(slapd starts with `-f`, not cn=config `-F`), so restore uses `slapadd -f`:
```bash
docker compose stop sso-manager
docker compose run --rm --no-deps --entrypoint sh sso-manager -c \
'rm -f /var/lib/ldap/* && slapadd -f /etc/openldap/slapd.conf -l /dev/stdin' \
< ldap-backup-<date>.ldif
docker compose start sso-manager
```
Verify: `docker compose exec sso-manager ldapsearch -x -b "dc=yourdomain,dc=com"`.
Redis state (OAuth clients, tokens) and `./config/` secrets are backed up
separately — see the *Backups and restore* section of `DEPLOYMENT.md` for the
full (LDAP + Redis + secrets) runbook.
## Troubleshooting
### `503 OpenLDAP ppolicy overlay is not configured`
The ppolicy overlay isn't attached to the database holding your users, so the
active/inactive toggle can't set `pwdAccountLockedTime`:
```bash
sudo ./ops/ldap-setup.sh -p 'admin-password' -b dc=yourdomain,dc=com
```
### LDAP connection refused
```bash
docker compose exec sso-manager sh -c 'ldapsearch -x -H ldap://localhost:389 -b "" -s base'
systemctl status slapd # bare metal
```
[← Back to Home](index.html)
+139
View File
@@ -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).
+111
View File
@@ -0,0 +1,111 @@
---
layout: default
title: OAuth / OIDC
description: Theta Directory's OpenID Connect / OAuth 2.0 provider — discovery document, client registration, and token endpoints.
---
# OAuth 2.0 / OpenID Connect
[← Back to Home](index.html)
> Looking for a plainer explanation of clients/scopes/redirect URIs instead
> of endpoint-level detail? See
> [Connecting Apps (Single Sign-On)](concepts-oauth-apps.html).
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.
## Discovery
The provider publishes a standards-compliant discovery document:
```
GET https://<sso-host>/.well-known/openid-configuration
```
It advertises the `issuer`, `authorization_endpoint`, `token_endpoint`,
`userinfo_endpoint`, `end_session_endpoint`, supported scopes, and token
lifetimes. OIDC clients (e.g. the theta42/proxy) can read their endpoint URLs
from here rather than configuring each one.
The `issuer` advertised is `conf.oauth.issuer` — set it to the **browser-facing**
HTTPS URL the SSO is served at (e.g. `https://sso.example.com`), either in
`conf/secrets.js` or via `app_oauth__issuer` / `OAUTH_ISSUER`.
## OAuth clients
An OAuth client represents an app that authenticates against the SSO. Each has:
- `client_id` (UUID) + `client_secret` (bcrypt-hashed; the **raw secret is
shown once** when the client is created or rotated — save it immediately).
- `name`, `description`, `created_by` (the admin uid that created it).
- `redirect_uris` — allowed callback URLs. Each entry matches exactly, or may
use `*` (one hostname label) / `**` (any number of labels) as a wildcard —
e.g. `https://*.example.com/__proxy_auth/callback` covers every host
theta42/proxy fronts under `example.com`, so you don't have to register
each proxied host's callback individually.
- `scopes` — requested scopes (default `openid profile email groups`).
- `allowed_groups` — restrict the client to members of specific SSO groups
(empty = any valid user).
- `token_lifetime``access_token` / `refresh_token` lifetimes (seconds).
### Managing clients
Clients are managed directly from the **Directory** tab in the web UI. They are modeled as resources of `kind: oauth` and must belong to a parent Service.
| Action | How to do it |
|--------|--------------|
| **Create** | Click the green **+** on a parent Service to add a child resource. Choose **OAuth Integration**. The raw `client_secret` is shown once upon creation. |
| **Edit** | Click the edit pencil on the OAuth resource in the Directory list or tree. You can update redirect URIs, scopes, allowed groups, and token TTLs. |
| **Delete** | Click the trash can on the OAuth resource in the Directory list. |
| **Rotate Secret** | Open the edit modal for the OAuth resource and click **Rotate Client Secret**. The new raw secret is shown once. |
> All client-management actions use the standard Directory API (`/api/directory-admin/resources`) and are gated by the `app_sso_directory_admin` group.
<a href="images/oauth-clients.png" target="_blank"><img src="images/oauth-clients.png" alt="Editing an OAuth client resource" width="80%"></a>
## Scopes
| Scope | Claims / access |
|-------|-----------------|
| `openid` | OIDC ID token + discovery |
| `profile` | `preferred_username`, display name, etc. |
| `email` | the user's `mail` |
| `groups` | the user's group memberships (the `groups` claim) |
The `groups` claim is what relying parties (e.g. the proxy's
`app_auth__adminGroups`) use to map group membership to roles.
## Token lifetimes
Defaults (overridable per-client via `token_lifetime`, or globally via
`app_oauth__token_lifetime__access_token` /
`app_oauth__token_lifetime__refresh_token`):
- access token: 3600s (1 hour)
- refresh token: 2592000s (30 days)
## Admin gating
SSO admin actions are gated by LDAP group membership (checked via the group's
`member` list, not `memberOf` on the user):
- `app_sso_admin` — full admin (users, groups, settings).
- `app_sso_oauth_admin` — OAuth client management.
- `app_sso_invite` — invitation management.
The bootstrap in [theta-env](https://github.com/theta42/theta-env) creates your
first admin and adds them to `app_sso_admin` + `app_sso_oauth_admin`
automatically.
## JWT signing
Tokens are signed with `conf.oauth.jwtSecret` (`app_oauth__jwtSecret` /
`JWT_SECRET`). **Persist this secret** — if it changes, every issued token
stops validating. The all-in-one Docker image auto-generates one if none is set,
but that generated value does not survive container recreation unless you
persist it (set `JWT_SECRET` in your `.env`).
[← Back to Home](index.html)
+80
View File
@@ -0,0 +1,80 @@
---
layout: default
title: Geo-Location Scaling (Replication)
---
# Geo-Location Scaling (Replication)
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.
- **Low Latency**: Applications at a remote site can bind directly to their local LDAP server (`localhost` or LAN IP) instead of traversing the internet to query the primary site, making logins blazing fast.
- **Independent Failure Domains**: By replicating only the LDAP directory (the source of truth) and keeping session state (Redis) independent, you prevent complex "split-brain" scenarios in the web UI. A failure at Site A won't bring down Site B.
By default, the `sso-manager` Docker container runs a single, independent OpenLDAP instance. However, you can enable **N-Way Multi-Master Replication** via environment variables.
## How it works
In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (`slapd`).
- **Reads and Writes anywhere**: A user can change their password or update their profile at Site A, Site B, or Site C.
- **Conflict Resolution**: OpenLDAP's `syncrepl` engine uses Context Sequence Numbers (CSN) to track changes. If Site A goes offline and a user changes their password at Site B, Site A will automatically pull the newest changes the moment it rejoins the cluster.
- **Independent Redis**: Session data, API Tokens, and OAuth Clients are stored in Redis. By design, Redis is NOT replicated in this geographic setup. This ensures that a failure at Site A never causes Site B's Redis to become read-only, which would break the web UI at Site B. OAuth clients must be configured per-site.
## Configuration
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`.
### Automatic config via Multi-Site join
**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.
**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**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
## 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.
+45
View File
@@ -0,0 +1,45 @@
---
layout: default
title: Secrets Vault
nav_order: 6
---
# Secrets Vault
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 Theta Directory at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
## Architecture
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
## Accessing the Vault
The Theta Directory Vault can be accessed in two ways:
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
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## 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, 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 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.
-136
View File
@@ -1,136 +0,0 @@
---
layout: default
title: Standalone
description: Running the SSO Manager or the proxy on their own, without theta-env's orchestration.
---
# Running each project standalone
[← Back to Home](index.html)
theta-env composes the two projects but doesn't fork them — both work on their
own. The submodules in this repo are normal clones; you can also clone them
directly from GitHub.
---
## SSO Manager alone
The all-in-one image (`Dockerfile.openldap`) bundles the app + OpenLDAP + Redis:
```bash
git clone https://github.com/theta42/sso-manager-node.git
cd sso-manager-node
mkdir -p config && cp secrets.js.example config/sso-secrets.js # edit it
docker compose up -d --build
```
The entrypoint points the `CONF_SECRETS` env var at `config/sso-secrets.js` so
`@simpleworkjs/conf` reads it. Set `ldap.bindPassword`, `oauth.jwtSecret`, and
the `stack`/`bootstrap` keys (the app ignores the ones it doesn't use). Pass
**no `app_*` env** — env beats the secrets file, so `app_*` would silently
override your file.
- Web UI: `http://localhost:3001`
- Health: `http://localhost:3001/health`
- OIDC discovery: `http://localhost:3001/.well-known/openid-configuration`
- LDAPS: `ldaps://<host>:636`
Requires `@simpleworkjs/conf` >= 1.2.0. Full reference:
[SSO Manager deployment docs](https://theta42.github.io/sso-manager-node/deployment.html).
### Bare metal
```bash
sudo ./install.sh -p 'your-ldap-password' -b 'dc=yourdomain,dc=com' -n 'Your Org' -o 3001
sudo systemctl enable --now sso-manager
```
Idempotent — re-run to update. See the SSO Manager
[deployment guide](https://theta42.github.io/sso-manager-node/deployment.html).
---
## Proxy alone
The all-in-one image (`Dockerfile`) bundles OpenResty + the Node app + Redis:
```bash
git clone https://github.com/theta42/proxy.git
cd proxy
mkdir -p config && cp secrets.js.example config/proxy-secrets.js # edit it
docker compose up -d --build
```
The entrypoint points the `CONF_SECRETS` env var at `config/proxy-secrets.js`
so `@simpleworkjs/conf` reads it. Fill in `oidc` (your SSO's endpoints +
`clientId`/`clientSecret`/`redirectUri`), `ldap` (bind creds + search base), and
`auth` (admin groups/users). Pass **no `app_*` env** — env beats the secrets
file, so `app_*` would silently override your file.
- Proxy (public, auto-SSL): `https://<host>/`
- Mgmt UI / API: `http://127.0.0.1:3000/`
- Health: `http://127.0.0.1:3000/health`
Requires `@simpleworkjs/conf` >= 1.1.0. Full reference:
[proxy deployment docs](https://theta42.github.io/proxy/docker.html).
### The `auth.adminUsers` anti-lockout account
Both `setup.sh` and `config.example/proxy-secrets.js.example` write
`auth.adminUsers: ['proxyadmin2']` into `proxy-secrets.js`. This is a
**local, config-driven admin bypass** — the proxy grants full admin rights to
any logged-in OIDC user whose username (the `preferred_username` claim from
the SSO) matches an entry in `auth.adminUsers`, regardless of their LDAP group
membership (see `proxy/nodejs/utils/roles.js`, `resolveEffective()`). It exists
so an operator can't lock themselves out of the proxy mgmt UI if the SSO's
`app_sso_admin` group is ever misconfigured, deleted, or otherwise broken.
It is **not** derived from any `setup.env` value, and it does **not** create a
user by itself — the name is only a username match. To actually use the
bypass, create a user with uid `proxyadmin2` in the SSO (it does not need to
be in `app_sso_admin` or any other group) and log in through the proxy as that
user.
To change or disable it, edit `auth.adminUsers` directly in
`./config/proxy-secrets.js` after the first `./setup.sh` run (re-running
`setup.sh` will not overwrite an existing `proxy-secrets.js`):
- **Rename** it to a less guessable username: `adminUsers: ['your-break-glass-uid']`.
- **Add more** anti-lockout accounts: `adminUsers: ['proxyadmin2', 'another-admin']`.
- **Disable** it entirely: `adminUsers: []` (global admin then comes only from
`auth.adminGroups` membership — make sure at least one real admin group is
reachable before doing this).
### Bare metal
```bash
wget -O - https://raw.githubusercontent.com/theta42/proxy/master/ops/install.sh | sudo bash
```
See the proxy
[Docker guide](https://theta42.github.io/proxy/docker.html) /
[installation guide](https://theta42.github.io/proxy/installation.html).
---
## Mixing and matching
theta-env isn't required to use the two together — the four wiring steps are
documented in both projects' deployment guides:
1. One Docker network (or reachable hostnames) so the proxy can reach the SSO
internally for token/userinfo + LDAPS.
2. Set the SSO's `oauth.issuer` (in its `secrets.js`) to the browser-facing HTTPS
URL the proxy serves the SSO at.
3. Register the proxy as an OIDC client in the SSO, with `redirectUri` matching
the proxy's callback; put the resulting `clientId`/`clientSecret` in the
proxy's `secrets.js`.
4. Point the proxy's `ldap.url` at the SSO's LDAPS + create a dedicated
`cn=ldapclient` service account; set the same password as `bindPassword`.
theta-env just automates those four steps with `./setup.sh`. If you prefer to
do them by hand (or want the two on separate hosts), follow the standalone
guides above.
[← Back to Home](index.html)
+458
View File
@@ -0,0 +1,458 @@
#!/usr/bin/env bash
#
# LDAP Migration Script for theta42
#
# Migrates an existing OpenLDAP server to the theta42 stack.
# Exports data from source, transforms as needed, imports into theta42.
#
# Usage:
# ./migrate-ldap.sh --source-host <ldap-uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
#
# Example:
# ./migrate-ldap.sh --source-host ldap://192.168.1.10:389 --source-bind-dn "cn=admin,dc=example,dc=com" --source-bind-pass "secret" --target-domain "example.com"
#
set -euo pipefail
cd "$(dirname "$0")"
# ── Defaults ──────────────────────────────────────────────────────────────────
SOURCE_HOST=""
SOURCE_BIND_DN=""
SOURCE_BIND_PASS=""
TARGET_DOMAIN=""
BASE_DN=""
EXPORT_DIR="./ldap-migration-$(date +%Y%m%d-%H%M%S)"
THETA_ENV_DIR="$(cd "$(dirname "$0")" && pwd)"
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
info() { printf "${BLUE}[migrate]${NC} %s\n" "$*"; }
warn() { printf "${YELLOW}[migrate]${NC} %s\n" "$*" >&2; }
error() { printf "${RED}[migrate]${NC} %s\n" "$*" >&2; }
success() { printf "${GREEN}[migrate]${NC} %s\n" "$*" >&2; }
die() { error "$*"; exit 1; }
# ── Argument parsing ──────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case "$1" in
--source-host)
SOURCE_HOST="$2"
shift 2
;;
--source-bind-dn)
SOURCE_BIND_DN="$2"
shift 2
;;
--source-bind-pass)
SOURCE_BIND_PASS="$2"
shift 2
;;
--target-domain)
TARGET_DOMAIN="$2"
shift 2
;;
--export-dir)
EXPORT_DIR="$2"
shift 2
;;
--help|-h)
cat <<EOF
LDAP Migration Script for theta42
Usage: $0 --source-host <uri> --source-bind-dn <dn> --source-bind-pass <pass> --target-domain <domain>
Options:
--source-host Source LDAP URI (e.g., ldap://192.168.1.10:389 or ldaps://ldap.example.com:636)
--source-bind-dn Bind DN for source LDAP (e.g., cn=admin,dc=example,dc=com)
--source-bind-pass Bind password for source LDAP
--target-domain Target domain for theta42 (e.g., example.com)
--export-dir Directory for exports (default: ./ldap-migration-<timestamp>)
--help Show this help message
EOF
exit 0
;;
*)
die "Unknown option: $1"
;;
esac
done
# ── Validation ────────────────────────────────────────────────────────────────
[[ -n "$SOURCE_HOST" ]] || die "Missing --source-host"
[[ -n "$SOURCE_BIND_DN" ]] || die "Missing --source-bind-dn"
[[ -n "$SOURCE_BIND_PASS" ]] || die "Missing --source-bind-pass"
[[ -n "$TARGET_DOMAIN" ]] || die "Missing --target-domain"
# Derive base DN from domain (e.g., example.com -> dc=example,dc=com)
BASE_DN="$(echo "$TARGET_DOMAIN" | sed 's/\./,dc=/g; s/^/dc=/')"
info "Migration configuration:"
info " Source host: $SOURCE_HOST"
info " Source bind DN: $SOURCE_BIND_DN"
info " Target domain: $TARGET_DOMAIN"
info " Target base DN: $BASE_DN"
info " Export dir: $EXPORT_DIR"
# ── Prerequisites ─────────────────────────────────────────────────────────────
command -v ldapsearch >/dev/null 2>&1 || die "ldapsearch not found. Install ldap-utils."
command -v slapcat >/dev/null 2>&1 || die "slapcat not found."
command -v docker >/dev/null 2>&1 || die "docker not found."
command -v docker-compose >/dev/null 2>&1 || command -v docker compose >/dev/null 2>&1 || die "docker compose not found."
if [[ -d "$EXPORT_DIR" ]]; then
warn "Export directory already exists: $EXPORT_DIR"
read -p "Overwrite? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
info "Aborted."
exit 1
fi
fi
mkdir -p "$EXPORT_DIR"
# ── Phase 1: Export from source LDAP ─────────────────────────────────────────
info "Phase 1: Exporting data from source LDAP..."
# Export each subtree
export_subtree() {
local base="$1"
local outfile="$2"
info " Exporting $base -> $outfile"
# Use ldapsearch with -LLL for LDIF output
if ! ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
-b "$base" -s sub "(objectClass=*)" > "$outfile" 2>/dev/null; then
warn " No data or base DN not found: $base"
# Create empty file to signal "checked"
echo "# No data for $base" > "$outfile"
fi
}
# Export standard subtrees
export_subtree "ou=people,$BASE_DN" "$EXPORT_DIR/01-people.ldif"
export_subtree "ou=groups,$BASE_DN" "$EXPORT_DIR/02-groups.ldif"
export_subtree "ou=sudoers,$BASE_DN" "$EXPORT_DIR/03-sudoers.ldif"
export_subtree "ou=services,$BASE_DN" "$EXPORT_DIR/04-services.ldif"
# Also export cn=config for reference (read-only, won't import)
info " Exporting cn=config for reference..."
ldapsearch -x -H "$SOURCE_HOST" -D "$SOURCE_BIND_DN" -w "$SOURCE_BIND_PASS" \
-b "cn=config" -s sub "(objectClass=*)" > "$EXPORT_DIR/00-config-reference.ldif" 2>/dev/null || true
# Count entries
for f in "$EXPORT_DIR"/*.ldif; do
count=$(grep -c "^dn:" "$f" 2>/dev/null || echo 0)
info " $(basename "$f"): $count entries"
done
success "Export complete: $EXPORT_DIR"
# ── Phase 2: Transform LDIF ──────────────────────────────────────────────────
info "Phase 2: Transforming LDIF for theta42 compatibility..."
# Create transformation script
cat > "$EXPORT_DIR/transform.sh" <<'TRANSFORM_SCRIPT'
#!/usr/bin/env bash
# Transform exported LDIF for theta42 compatibility
INPUT="$1"
OUTPUT="$2"
BASE_DN="$3"
# theta42 requires certain objectClasses and attributes
# This script:
# 1. Ensures posixAccount has uidNumber, gidNumber, homeDirectory, loginShell
# 2. Ensures groupOfNames has at least one member
# 3. Adds ldapPublicKey objectClass where sshPublicKey exists
# 4. Normalizes password hash formats if needed
while IFS= read -r line || [[ -n "$line" ]]; do
echo "$line"
done < "$INPUT" > "$OUTPUT"
echo "Transform complete: $OUTPUT"
TRANSFORM_SCRIPT
chmod +x "$EXPORT_DIR/transform.sh"
# For now, we'll do a direct import. The transformation is minimal for most setups.
# If you have custom schemas, you may need to edit the LDIF manually.
# ── Phase 3: Prepare theta42 LDAP ────────────────────────────────────────────
info "Phase 3: Preparing theta42 LDAP..."
# Stop theta42 stack
COMPOSE_CMD=""
if docker compose version >/dev/null 2>&1; then
COMPOSE_CMD="docker compose"
elif command -v docker-compose >/dev/null 2>&1; then
COMPOSE_CMD="docker-compose"
else
die "docker compose not found"
fi
info " Stopping sso-manager container..."
$COMPOSE_CMD stop sso-manager 2>/dev/null || true
# Wait for container to stop
sleep 3
# ── Phase 4: Import into theta42 ─────────────────────────────────────────────
info "Phase 4: Importing data into theta42 LDAP..."
# Create import script that runs inside the container
cat > "$EXPORT_DIR/import-to-theta42.sh" <<'IMPORT_SCRIPT'
#!/bin/bash
# Run inside theta42 sso-manager container to import LDIF
set -e
EXPORT_DIR="$1"
BASE_DN="$2"
# Stop slapd if running
pkill slapd 2>/dev/null || true
sleep 2
# Clear existing data (but preserve structure)
info "Clearing existing LDAP data..."
rm -rf /var/lib/ldap/*
rm -rf /var/lib/ldap/db.*
# Initialize LDAP database with theta42 schema
info "Initializing LDAP database..."
# Create initial LDIF with base structure
cat > /tmp/base.ldif <<EOF
dn: $BASE_DN
objectClass: top
objectClass: dcObject
objectClass: organization
dc: $(echo $BASE_DN | sed 's/,dc=.*//; s/dc=//')
o: Organization
dn: ou=people,$BASE_DN
objectClass: organizationalUnit
ou: people
dn: ou=groups,$BASE_DN
objectClass: organizationalUnit
ou: groups
dn: ou=sudoers,$BASE_DN
objectClass: organizationalUnit
ou: sudoers
dn: ou=services,$BASE_DN
objectClass: organizationalUnit
ou: services
dn: cn=admin,$BASE_DN
objectClass: organizationalRole
cn: admin
description: LDAP Administrator
dn: cn=ldap-admin,ou=groups,$BASE_DN
objectClass: groupOfNames
cn: ldap-admin
member: cn=admin,$BASE_DN
EOF
# Import base structure
slapadd -c -l /tmp/base.ldif -b "$BASE_DN" 2>/dev/null || true
# Import user data
for f in "$EXPORT_DIR"/*.ldif; do
[[ -f "$f" ]] || continue
[[ "$(basename "$f")" == "00-config-reference.ldif" ]] && continue
info "Importing $f..."
# Use -c to continue on errors (some entries may already exist)
slapadd -c -l "$f" -b "$BASE_DN" 2>/dev/null || warn "Some entries in $f may have failed"
done
# Fix ownership
chown -R ldap:ldap /var/lib/ldap
# Start slapd
info "Starting slapd..."
exec /usr/sbin/slapd -h "ldap:/// ldaps:///" -u ldap -g ldap
IMPORT_SCRIPT
# Copy import script to export dir
cp "$EXPORT_DIR/import-to-theta42.sh" "$EXPORT_DIR/"
# Run the import inside the container
info "Running import inside sso-manager container..."
# First, start a temporary container to do the import
$COMPOSE_CMD up -d sso-manager 2>/dev/null || true
sleep 5
# Copy LDIF files into container
info "Copying LDIF files to container..."
for f in "$EXPORT_DIR"/*.ldif; do
[[ -f "$f" ]] || continue
docker cp "$f" sso-manager:/tmp/migration/ 2>/dev/null || {
docker exec sso-manager mkdir -p /tmp/migration
docker cp "$f" sso-manager:/tmp/migration/
}
done
# Run import
info "Executing import..."
docker exec sso-manager bash -c "
pkill slapd 2>/dev/null || true
sleep 2
# Clear data
rm -rf /var/lib/ldap/*
# Create base structure
slapadd -c -b '$BASE_DN' <<EOF
dn: $BASE_DN
objectClass: top
objectClass: dcObject
objectClass: organization
dc: $(echo $BASE_DN | cut -d',' -f1 | cut -d'=' -f2)
o: $TARGET_DOMAIN
dn: ou=people,$BASE_DN
objectClass: organizationalUnit
ou: people
dn: ou=groups,$BASE_DN
objectClass: organizationalUnit
ou: groups
dn: ou=sudoers,$BASE_DN
objectClass: organizationalUnit
ou: sudoers
dn: ou=services,$BASE_DN
objectClass: organizationalUnit
ou: services
EOF
# Import user data
for f in /tmp/migration/*.ldif; do
[[ \"\$(basename \$f)\" == \"00-config-reference.ldif\" ]] && continue
[[ -f \"\$f\" ]] || continue
echo \"Importing \$f...\"
slapadd -c -l \"\$f\" -b '$BASE_DN' 2>/dev/null || echo \"Warning: Some entries in \$f may have failed\"
done
# Fix ownership
chown -R ldap:ldap /var/lib/ldap
echo \"Import complete!\"
" || warn "Import had some errors - check output above"
# ── Phase 5: Create theta42 admin groups ─────────────────────────────────────
info "Phase 5: Creating theta42 admin groups..."
# Create LDIF for theta42-specific groups
cat > "$EXPORT_DIR/theta42-groups.ldif" <<EOF
# theta42 administrative groups
# These groups control access to various features
# Cross-app super admin - full admin in all apps
dn: cn=app_super_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_super_admin
description: Cross-app super administrators
# SSO Manager admin
dn: cn=app_sso_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_admin
description: SSO Manager administrators
# SSO invite - can invite users
dn: cn=app_sso_invite,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_invite
description: Can send invitations
# OAuth admin - manages OAuth clients
dn: cn=app_sso_oauth_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_oauth_admin
description: OAuth client administrators
# Service account marker
dn: cn=app_sso_service_account,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_sso_service_account
description: Service accounts (hidden from UI)
# Jump host admin - audit access only
dn: cn=app_jump_admin,ou=groups,$BASE_DN
objectClass: groupOfNames
objectClass: top
cn: app_jump_admin
description: Jump host audit administrators
EOF
# Import the theta42 groups
docker exec sso-manager bash -c "
slapadd -c -l /tmp/theta42-groups.ldif -b '$BASE_DN' 2>/dev/null || echo \"Groups may already exist\"
" <<EOF
$(cat "$EXPORT_DIR/theta42-groups.ldif")
EOF
# ── Phase 6: Restart and verify ──────────────────────────────────────────────
info "Phase 6: Restarting theta42 stack..."
$COMPOSE_CMD restart sso-manager
sleep 10
info "Waiting for sso-manager to be healthy..."
for i in $(seq 1 30); do
if docker exec sso-manager wget -q -O- http://localhost:3001/health >/dev/null 2>&1; then
success "sso-manager is healthy!"
break
fi
if (( i == 30 )); then
warn "sso-manager did not become healthy in 30s. Check logs with: docker compose logs sso-manager"
fi
sleep 2
done
# Verify import
info "Verifying import..."
dn_count=$(docker exec sso-manager ldapsearch -x -H "ldap://localhost" -b "$BASE_DN" -s sub "(objectClass=*)" dn 2>/dev/null | grep -c "^dn:" || echo 0)
info "Total entries in LDAP: $dn_count"
# ── Summary ───────────────────────────────────────────────────────────────────
echo ""
success "Migration complete!"
echo ""
info "Summary:"
info " - Exported data saved to: $EXPORT_DIR"
info " - Base DN: $BASE_DN"
info " - Total entries: $dn_count"
echo ""
info "Next steps:"
info " 1. Review the exported LDIF files in $EXPORT_DIR"
info " 2. Add users to theta42 admin groups as needed:"
info " docker exec sso-manager ldapmodify -x -H ldap://localhost -D 'cn=admin,$BASE_DN' -w <admin-pass>"
info " 3. Update your LDAP clients to point to theta42"
info " 4. Run ./setup.sh to complete theta42 bootstrap"
echo ""
warn "IMPORTANT: Update all LDAP clients to use the new theta42 LDAP server!"
warn " - SSSD: Update /etc/sssd/sssd.conf ldap_uri"
warn " - sudo: Update /etc/sudo-ldap.conf"
warn " - Apps: Update LDAP connection strings"
-1
View File
@@ -1 +0,0 @@
https://github.com/theta42/theta-env/pull/75
+1 -1
Submodule proxy updated: 2bfba93e00...59b8eb70e3
+98 -26
View File
@@ -1,5 +1,5 @@
# ─────────────────────────────────────────────────────────────────────────────
# setup.env — first-run setup for the theta-env stack.
# setup.env — first-run setup for the theta-suite stack.
#
# This file is used ONLY on the FIRST run of ./setup.sh, to generate
# ./config/sso-secrets.js + ./config/proxy-secrets.js with your domain filled
@@ -33,15 +33,14 @@ CFG_DOMAIN=example.com
#CFG_SSO_HOST=sso.example.com
#CFG_PROXY_HOST=proxy.example.com
# ── Optional SSH jump host ───────────────────────────────────────────────────
# Enable the theta42/jump-host component: a public SSH jump host that
# authenticates users against the directory and bridges them to downstream
# hosts (ssh uid_-_target@jump, or an interactive picker). Off by default.
# When true, setup.sh clones/builds the jump-host submodule, the bootstrap
# mints its directory API token + writes ./config/jump-secrets.js, and it's
# registered in the proxy + directory. See jump-host's README for the LDAP
# write-ACL note (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST_ENABLED=false
# ── SSH jump host (always installed) ─────────────────────────────────────────
# The theta42/jump-host component is installed and started by default — a
# public SSH jump host that authenticates users against the directory and
# bridges them to downstream hosts (ssh uid_-_target@jump, or an interactive
# picker). setup.sh clones/builds the jump-host submodule, the bootstrap mints
# its directory API token + writes ./config/jump-secrets.js, and it's registered
# in the proxy + directory. See jump-host's README for the LDAP write-ACL note
# (the bundled deployment binds as cn=admin).
#CFG_JUMP_HOST=jump.example.com # defaults to jump.<domain>
#JUMP_SSH_PORT=2222 # host port mapped to the jump host's SSH (never 22 by default)
@@ -49,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.
@@ -74,13 +127,6 @@ CFG_DOMAIN=example.com
# 'sso-manager' so clients don't need a public 636 port forward. See docs.
#CFG_LDAPS_HOST=
# Optional SMTP (outbound email from the SSO app). Leave blank to disable:
#CFG_SMTP_HOST=smtp.example.com
#CFG_SMTP_PORT=587
#CFG_SMTP_USER=noreply@example.com
#CFG_SMTP_PASS=your-smtp-password
#CFG_SMTP_FROM=SSO Manager <noreply@example.com>
# ── DO NOT put secrets here ──────────────────────────────────────────────────
# The LDAP admin password, JWT secret, admin password, LDAP service-account
# password, and the proxy's local admin password are all GENERATED (random)
@@ -91,14 +137,40 @@ CFG_DOMAIN=example.com
# CFG_LDAP_ADMIN_PASS / CFG_JWT_SECRET / CFG_ADMIN_PASS / CFG_SVC_PASS /
# CFG_PROXY_ADMIN_PASS here.
# ── 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.
# ── theta-agent Host Integration ─────────────────────────────────────────────
# Configure theta-agent integration with the local host. All options default to
# enabled (1). Set to 0 to disable.
#
# 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:
# Enable theta-agent installation and configuration on this host.
#CFG_THETA_AGENT_ENABLE=1
#
# Configure LDAP authentication for this host via ldap-client (SSSD/PAM).
#CFG_THETA_AGENT_LDAP_AUTH=1
#
# Allow theta-agent full control of this host (arbitrary_bash, service_control,
# reboot, configure_ldap capabilities).
#CFG_THETA_AGENT_FULL_CONTROL=1
# ── Geo-Location Scaling (N-Way Multi-Master LDAP) ───────────────────────────
# 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.
#
# 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"
#LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
# ── Proxy HTTP/HTTPS Defaults ────────────────────────────────────────────────
# If you are running the stack behind an external reverse proxy (like Cloudflare
# or another ingress) that handles TLS termination, you may want the internal
# proxy to serve everything over plain HTTP without forcing redirects to HTTPS.
# Set this to 1 to create all default proxy host entries with forcessl=false.
#CFG_CREATE_ALL_HTTP=1
+827 -59
View File
File diff suppressed because it is too large Load Diff
+54
View File
@@ -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
+227
View File
@@ -0,0 +1,227 @@
#!/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)
set -euo pipefail
# ── 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; }
# ── 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
# ── Test environment config (self-contained, no secrets.js needed) ────────────
export COMPOSE_PROJECT_NAME="theta-test"
TEST_CONTAINER="theta-test-sso-manager-1"
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
# ── 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
# ── 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
# ── 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
# ── 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!"
+40
View File
@@ -0,0 +1,40 @@
const test = require('node:test');
const assert = require('node:assert');
test('Integration Test Suite', async (t) => {
await t.test('SSO Manager should be running and healthy', async () => {
const res = await fetch('http://localhost:3001/health');
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.status, 'ok');
});
await t.test('Proxy should be running and route to SSO Manager', async () => {
// Testing the proxy routes traffic to SSO manager
const res = await fetch('http://sso.localtest.me/.well-known/openid-configuration');
assert.ok(res.status === 200 || res.status === 301);
const body = await res.json();
assert.ok(body.issuer);
});
await t.test('Proxy Management API should be running', async () => {
const res = await fetch('http://localhost:3000/health');
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.strictEqual(body.status, 'ok');
});
await t.test('OpenBao should be running and healthy', async () => {
// Port 8080 is mapped to OpenBao's 8200 in docker-compose.yml
const res = await fetch('http://localhost:8080/v1/sys/health');
assert.ok(res.status === 200 || res.status === 501); // 501 means not initialized/sealed, but responsive
});
await t.test('SSO Manager should proxy to OpenBao (integration test)', async () => {
// Test if SSO Manager proxies to OpenBao
// Without authentication, this should return 401 Unauthorized from SSO Manager's middleware
const res = await fetch('http://localhost:3001/api/vault/sys/health');
assert.strictEqual(res.status, 401);
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"name": "theta-env-integration-tests",
"version": "1.0.0",
"description": "Automated integration tests for theta-env projects",
"scripts": {
"test": "NODE_TLS_REJECT_UNAUTHORIZED=0 node --test *.test.js"
}
}
Submodule
+1
Submodule theta-agent added at ef9d4b004b