Compare commits

...

73 Commits

Author SHA1 Message Date
wmantly eef7852b69 Merge pull request #206 from theta42/release-v2.7.0
release(v2.7.0): auto-assigned LDAP ServerID + replication hosts
2026-08-10 20:07:42 -07:00
wmantly 0ac0c045ec release(v2.7.0): auto-assigned LDAP ServerID + replication hosts 2026-08-10 23:03:33 -04:00
wmantly dfb819a715 Merge pull request #205 from theta42/feat-ldap-mmr-auto-config
feat(multi-site): auto-assign LDAP ServerID + replication hosts at join time
2026-08-10 19:53:42 -07:00
wmantly d486fb946b feat(multi-site): auto-assign LDAP ServerID + replication hosts at join time
OpenLDAP N-way multi-master replication (docs/replication.md) required
an operator to hand-set LDAP_SERVER_ID (unique per site) and
LDAP_REPLICATION_HOSTS (every OTHER site's LDAP URL, kept in sync by
hand across every node) -- real coordination work, and easy to get
wrong or let drift as sites are added.

Automates the coordination the master is already in a position to do:
- SiteSpoke gets ldapServerId, auto-assigned (next free from 2 upward,
  1 reserved for the master) at registration and reused across
  re-registrations -- same pattern as jump-host's mesh index.
- ldapHost is derived from each site's already-known HTTP(S) endpoint
  (same hostname, port 636) rather than a separately-configured field
  that could drift from it.
- New utils/ldap_replication.js (nextFreeLdapServerId, ldapHostFor),
  shared between the spoke-facing GET /api/site/ldap-peers (Bearer
  site join key, returns this caller's own ID + every peer) and the
  master-local GET /directory-admin/ldap-replication-config (computes
  its own config directly from SiteSpoke, no HTTP round-trip needed).

Verified against real running containers (docker-compose.multisite-e2e.yml):
after a real join, the master's computed config correctly includes the
spoke as a peer with an assigned ID, and the spoke's own fetched
config matches that ID and correctly excludes itself from its own
peer list.

Known limitation, documented in docs/replication.md: the master's own
LDAP_REPLICATION_HOSTS only gets recomputed when ITS setup.sh is
re-run (or an admin re-applies it directly) -- there's no live push to
an already-running master when a new spoke joins. A spoke's own config
is re-checked on every setup.sh run, which is the common/recurring
event; the master side is a documented manual step for now rather than
a live hot-reload (which would need OpenLDAP's dynamic cn=config
backend -- a bigger change, deliberately out of scope here to avoid
risking a live directory's LDAP replication on undertested config).
2026-08-10 22:51:04 -04:00
wmantly 39db290265 Merge pull request #204 from theta42/release-v2.6.0
release(v2.6.0): dedupe promotion groups, resources API perf, nmap RTTVAR fix, real gateway count
2026-08-10 19:37:04 -07:00
wmantly 964ca6dd02 release(v2.6.0): dedupe promotion groups, resources API perf, nmap RTTVAR fix, real gateway count 2026-08-10 22:34:26 -04:00
wmantly 4e388a411e Merge pull request #203 from theta42/feat-real-gateway-count-and-service-wiring
feat(directory): real mesh-gateway count on the Multi-Site modal
2026-08-10 19:29:37 -07:00
wmantly e9310a1e5c Merge remote-tracking branch 'origin/master' into feat-real-gateway-count-and-service-wiring
# Conflicts:
#	nodejs/package.json
2026-08-10 22:27:22 -04:00
wmantly f70419bbc4 Merge pull request #202 from theta42/fix-nmap-rttvar-false-failure
fix(discovery): recover nmap scans that succeed despite a benign RTTVAR stderr warning
2026-08-10 19:26:36 -07:00
wmantly 358231532f Merge remote-tracking branch 'origin/master' into feat-real-gateway-count-and-service-wiring
# Conflicts:
#	nodejs/package.json
2026-08-10 22:24:55 -04:00
wmantly d8bd338814 Merge remote-tracking branch 'origin/master' into fix-nmap-rttvar-false-failure
# Conflicts:
#	nodejs/package.json
2026-08-10 22:24:20 -04:00
wmantly 0a3bdbef06 Merge pull request #201 from theta42/fix-group-dup-and-resources-perf
fix(directory): dedupe access/admin groups; stop self-healing groups on every GET
2026-08-10 19:23:17 -07:00
wmantly 611f1a3318 feat(directory): real mesh-gateway count on the Multi-Site modal; docs links
"Theta Gateways: N active gateways" was counting this app's own
unrelated WireGuard roaming-client/exit-node Resources
(metadata.subType === 'wireguard') -- a completely different subsystem
from the gateway-to-gateway mesh the modal is actually about, and it
never queried jump-host's mesh registry at all (so it couldn't show
the local self-entry either, since there was nothing mesh-related
being counted in the first place).

Added utils/jump_client.js (same pattern as utils/proxy_client.js:
reuses jump-host's existing self-service jmp_ API token system rather
than inventing a new credential) to query jump-host's real
GET /api/mesh/gateways. Reports a null count (not misleading 0) when
the integration isn't configured/reachable, surfaced distinctly in the
UI. Also added help links to the published multi-site/mesh docs on the
modal.

Includes docs links + count only -- this session also discovered that
utils/proxy_client.js's PROXY_INTERNAL_URL, and now JUMP_INTERNAL_URL,
were never actually wired into theta-suite's docker-compose.yml, so
both service-to-service integrations were unreachable in every real
deployment despite existing in code (fixed in theta-suite separately).
2026-08-10 22:17:57 -04:00
wmantly e313697bfd fix(discovery): recover an nmap scan that succeeded despite a benign stderr warning
node-nmap (the vendored library, not our code) treats ANY stderr
output from the nmap binary as a fatal scan error -- including nmap's
own harmless RTT-calibration warning ("RTTVAR has grown to over N
seconds, decreasing to M"), which it prints *during* a scan that goes
on to complete normally. That meant a real, successful scan (valid XML
already sitting in the library's rawData) got discarded and reported
as a failed run with zero hosts discovered -- not just log noise as
initially assumed.

Recover in our own plugin code by detecting this specific known-benign
message and manually re-running node-nmap's own XML-parse-then-complete
path when there's actually output to parse. A genuine parse failure or
any other error message still rejects exactly as before -- this only
widens the recovery path.
2026-08-10 22:14:13 -04:00
wmantly 2c3ec4e967 fix(directory): dedupe access/admin groups on repeated promotion; stop self-healing on every GET
Three independent copies of the same bug: routes/discovery.js's
POST /discovery/promote/:slug (the actual "Promote" button in the UI)
and services/discovery_reconciler.js's autoPromote path both called
ResourceGroup.create() directly with no existence check -- unlike
routes/api_directory_admin.js's own ensureResourceGroup, which already
carried a comment describing this exact "groups appear 3x" bug and
fixing it, just not everywhere it occurred. ResourceGroup has no DB
unique constraint on (resourceId, groupCn), so a resource promoted
more than once (retried UI click, or the same LXC discovered from
multiple Proxmox cluster nodes) silently accumulated duplicate
access/admin rows every time. Added ResourceGroup.ensure() (the
existing check-then-create pattern, now on the model) and switched all
three call sites to it. New regression test in tests/reconciler.test.js.

Also: GET /api/directory-admin/resources ran a full group-model
self-heal fan-out (ensureSiteGroups per site + provisionResourceGroups
per resource, each several sequential LDAP round-trips) unconditionally
on every single list -- confirmed via code read as the actual
bottleneck once a directory has more than a handful of resources, not
data volume. Moved healing to where resources actually change instead
(POST/PUT /resources, POST /discovery/promote/:slug -- PUT had none at
all before this), and added POST /resources/heal-groups as an explicit
on-demand equivalent for backfilling a directory seeded before this
change.
2026-08-10 22:08:04 -04:00
wmantly b6a82d58d5 Merge pull request #200 from theta42/release-v2.5.0
release(v2.5.0): no-inbound relay automation, mesh-preferred replication
2026-08-10 18:28:27 -07:00
wmantly 18da6582ed release(v2.5.0): no-inbound relay automation, mesh-preferred replication 2026-08-10 21:25:38 -04:00
wmantly b1739ec965 Merge pull request #199 from theta42/feat-mesh-routing
feat(multi-site): route replication over the mesh when available
2026-08-10 18:09:28 -07:00
wmantly 7bc6f47070 feat(multi-site): forward noInbound/meshIp/publicHost through /api/site/join
/api/site/spokes already accepted these and drove proxy_client's relay
automation, but nothing on the real operator join path ever passed
them through -- a no-inbound spoke calling /join had no way to trigger
its own relay route. Forward them into the internal /spokes call and
surface the resulting relay note in /join's response.
2026-08-10 20:52:35 -04:00
wmantly a0964ce350 feat(multi-site): route replication traffic over the mesh when available
Cross-component routing TODO item: a spoke's resync push now prefers
its WG mesh IP (reported via the noInbound/meshIp fields added for the
relay automation) over the public endpoint, falling back to the public
endpoint if the mesh attempt fails for any reason (tunnel not actually
up between these two particular gateways yet, transient failure,
etc.) -- a mesh-routing preference must never turn into "spoke stops
getting updates."

Plain HTTP over the mesh IP, not HTTPS: the WG tunnel is already
encrypted, same reasoning already applied to the no-inbound relay
terminating at the master.

A spoke with no meshIp on file behaves exactly as before (public
endpoint only) -- this is additive, not a behavior change for spokes
that haven't opted into mesh registration.
2026-08-10 20:39:27 -04:00
wmantly 0d3ee3e2ee Merge pull request #198 from theta42/feat-no-inbound-relay
feat(multi-site): no-inbound relay automation via theta-proxy's existing API tokens
2026-08-10 17:36:03 -07:00
wmantly b95cb08c41 feat(multi-site): no-inbound relay automation via theta-proxy's existing API tokens
Implements TODO items "service-to-service auth model" and "no-inbound
relay automation" together -- the first was never really "no credential
type exists", it was that nothing wired one of the credential types that
ALREADY exist (theta-proxy, jump-host, and this app each already have
their own self-service API token system, models/api_token.js) into an
actual inter-service call. This is that wiring, not a new invented
credential type.

- utils/proxy_client.js: ensureRelayRoute({host, ip, targetPort}) calls
  theta-proxy's real Host API (GET/POST/PUT /api/host) using a `prx_...`
  token an operator mints on theta-proxy and stores in OpenBao
  (secret/integrations/theta-proxy), same pattern as agent_keys.js.
  Idempotent and best-effort -- never fails the caller if the token/URL
  isn't configured, since this is an enhancement on top of a working
  join, not a join requirement.
- POST /api/site/spokes accepts optional noInbound/meshIp/publicHost
  fields; when a spoke reports itself no-inbound, the master
  best-effort creates/updates the matching relay route automatically.
  SiteSpoke gained the fields + a relayNote for visibility.

Verified against a REAL running theta-proxy container (not mocked):
booted it standalone, logged in as the local admin, minted a real
`prx_` token via its actual API, and drove ensureRelayRoute() against
it for real. Caught a real bug doing this: GET /api/host/:item wraps
the record in `{item, results}`, not flat -- the mocked unit tests
(which I wrote first) all had the flat shape baked in and passed
cleanly, so this only surfaced against the real API. Fixed in both the
implementation and the unit tests' mocked response shape.
2026-08-10 20:31:42 -04:00
wmantly a50d1ef3c8 Merge pull request #197 from theta42/release-v2.4.0
Build OpenLDAP Base Image / build-and-push (push) Failing after 10s
release(v2.4.0): live replication, coordinated promotion, mesh UI
2026-08-10 16:03:15 -07:00
wmantly daefe54ff7 release(v2.4.0): live replication, coordinated promotion, mesh UI
Rolls up this pass's multi-site work: live catalog replication (spokes
stay synced after joining, not just a one-time snapshot), identical
agent-signing keys across sites, coordinated master promotion with real
old-master demotion, the UI to actually see and use any of it, and two
real bugs found only by live two-container testing (site-promote's dead
authorization check, and masterJoinKey/replicationPushToken leaking to
the browser via GET /api/site/config).

See CHANGELOG.md for the full list.
2026-08-10 18:54:50 -04:00
wmantly dc3d760d2b feat(multi-site): UI for live replication, promotion handoff, signing key
Closes the gap where all of this session's new server-side capability
(live replication, coordinated promotion, identical signing keys) had
no UI at all -- an operator using the Master Site modal had no way to
know any of it existed or was working.

- Master Site modal: new "Live Replication" row (spoke) shows whether
  this join actually registered for live updates or is stuck on a
  one-time snapshot; new "Registered Spokes" row (master) shows how
  many spokes are receiving live pushes.
- Join form: new "this site's own reachable URL" field, prefilled from
  window.location.origin, wired to the selfUrl the join API already
  supported but the UI never sent -- a UI-driven join previously NEVER
  registered for live replication, only the setup.sh bootstrap path did.
  The success toast now reports whether live replication actually
  activated, not just "joined".
- Promote button: success toast now surfaces the handoff result (old
  master demoted / unreachable / no previous master), so the operator
  sees immediately whether the coordinated demotion actually happened.
- GET /api/site/config no longer returns masterJoinKey or
  replicationPushToken in the response -- found while wiring this up:
  live credentials were being sent straight to the browser for every
  admin session. Replaced with boolean derivatives
  (hasMasterJoinKey, liveReplication).
- GET /api/directory-admin/site-status gained liveReplication (spoke)
  and registeredSpokesCount (master) so the modal has something to render.

Verified by actually driving it in a real browser against a live
container (not just code review): logged in, opened the modal, saw the
new rows, minted a real join key end-to-end, no console errors.

docs/site-join.md rewritten to cover live replication, signing-key sync,
coordinated promotion/demote, and the new endpoints -- it previously only
described the v2.2.0-v2.3.0 one-time-snapshot behavior.
2026-08-10 18:30:48 -04:00
wmantly 9c604f0258 fix(multi-site): coordinated master promotion + a dead-on-arrival authz bug
Two real bugs, both only surfaced by the live two-container e2e test
(docker-compose.multisite-e2e.yml), not by inspection:

1. POST /site-promote's god_admin check read req.user.groups -- a field
   nothing in the codebase ever populates (Auth.checkToken returns
   User.get(), which has no .groups; every other admin gate resolves
   membership live via permission.byGroup()/Group.list(user.dn), which
   also handles nested-group membership). The check silently evaluated to
   an empty array on every request, so site-promote returned 403 for
   every user, including a real god_admin -- unusable since it shipped in
   v2.0.0. Fixed to use permission.byGroup(), the same pattern used
   elsewhere in this file and in api_site.js.

2. The read-only write-gate middleware (api_directory_admin.js) is
   registered before router.post('/site-promote', ...) later in the same
   file, so on a spoke it 403'd every promotion attempt before the
   handler ever ran -- the one mutating request a spoke must be able to
   make to itself. Exempted /site-promote from the gate.

Added coordinated demotion (MULTI_SITE_SPEC.md §3.2 -- promotion as ONE
action, never a two-step gap with two masters): site-promote now calls
the previous master's new POST /api/site/demote (Bearer the join key it
already holds, handing over a freshly-minted key for the demoted node's
own future use) before flipping itself to master. Best-effort: an
unreachable old master never blocks a god_admin's local promotion (the
WAN-outage scenario is the entire reason this control exists), it's
just reported in the response for manual reconciliation.

e2e test extended to promote the spoke, verify the old master was
actually demoted (isMaster:false, masterUrl pointing at the new master),
and verify writes now succeed on the new master and 403 on the old one.
Full chain verified passing: join -> live replication -> promotion ->
demotion -> write authority follows the promotion.
2026-08-10 16:48:33 -04:00
wmantly d27763e556 feat(multi-site): live catalog replication + identical-directory signing key
The shipped join flow (v2.2.0-v2.3.0) was a one-time snapshot: a spoke's
catalog never updated after joining. This adds the two pieces that were
explicitly designed but missing:

- Live replication: a spoke registers its own endpoint with the master
  right after joining (POST /api/site/spokes, Bearer join-key), receiving
  a pushToken. Every successful catalog write on the master now fires a
  fire-and-forget resync ping (utils/site_replicate.js) at every known
  spoke, concurrently -- one unreachable spoke never blocks or delays
  another (wired into the existing write-gate middleware in
  api_directory_admin.js). The spoke's POST /api/site/resync handler
  reuses the already-tested export+import path rather than applying a
  partial diff.

- Identical directories: POST /api/site/export now best-effort includes
  the master's agent-signing key; a spoke adopts it via agent_keys.adopt()
  on both join and every resync, so every site's sso-manager can validly
  sign a command for any agent enrolled anywhere -- the accepted tradeoff
  discussed for this deployment's scale (blast radius for simplicity).

New SiteSpoke model tracks registered spokes (endpoint + pushToken);
registered it in models/index.js (a real bug the e2e test below caught --
SiteSpoke.list() 500'd with "Cannot read properties of null (reading
'adapter')" until the model was added to initORM's model list).

Verified end-to-end against docker-compose.multisite-e2e.yml: mint join
key -> join with selfUrl -> write a NEW resource on master post-join ->
poll the spoke -> it shows up within a few seconds via the resync push,
no manual re-join needed. MULTISITE E2E PASS.

Unit tests: nodejs/tests/site_replicate.test.js (concurrent fan-out, one
failing spoke doesn't block another, empty-registry and list()-throws
edge cases).
2026-08-10 16:34:38 -04:00
wmantly e5167729a8 test: add real two-container e2e for the multi-site join flow
docker-compose.multisite-e2e.yml boots two full all-in-one instances
(master + spoke, each with bundled slapd) and a client that drives the
actual HTTP API: seeds admins, mints a join key, joins the spoke, and
verifies the spoke persisted its role across restart, adopted the
pre-join catalog, is enforced read-only, and reports WAN health.

Verified passing locally. Existing docker-compose.test.yml/e2e.yml split
ldap+redis+app into separate containers, which doesn't work here --
POST /api/site/export runs slapcat in-process, so master and spoke each
need their own bundled slapd (Dockerfile.openldap), not a shared one.
2026-08-10 16:21:53 -04:00
wmantly e915a17cbd Merge pull request #196 from theta42/release-v2.3.0
release(v2.3.0): multi-site join UI + spoke read-only + WAN health
2026-08-10 09:38:06 -07:00
wmantly e4d5e8d75c release(v2.3.0): multi-site join UI, spoke read-only, live WAN health 2026-08-10 09:35:38 -07:00
wmantly 39a9dc0282 Merge pull request #195 from theta42/feat/multi-site-join-ui
feat(site): join UI, spoke read-only, live WAN health, fresh-install guard
2026-08-10 09:15:29 -07:00
wmantly 9d266d2e4c feat(site): join UI, spoke read-only enforcement, live WAN health, fresh-install guard
Completes the multi-site join layer on top of the v2.2.0 endpoints:

- UI (Master Site modal): a fresh install (canJoin) gets a 'Join an Existing
  Site' form (master URL + stj_ key); a master gets a 'Site Join Keys' manager
  (mint/revoke/list, key shown once); WAN Sync Health now reflects a live probe.
- POST /api/site/ping (Bearer stj_ key, no admin session): lightweight master
  reachability probe for WAN health (cheap vs /export).
- Spoke read-only: directory-write routes (resources/edges/groups/secrets/
  grants/driver-action/discovered) reject with 403 pointing at the master.
- Fresh-install guard: /api/site/join refuses unless no users beyond the
  bootstrap admin and no enrolled agents (siteIsFresh), and site-status exposes
  canJoin so the UI only offers join on a genuinely fresh install. The
  bootstrap's seeded default resources are NOT the signal (they always exist).
- The spoke stores the join key (masterJoinKey) in /config/site.json so WAN
  health (and a future write-proxy) can reach the master.
- Tests: siteIsFresh cases in tests/site_join.test.js.
2026-08-10 09:12:28 -07:00
wmantly 3d18c3f0cb Merge pull request #194 from theta42/release-v2.2.0
release(v2.2.0): multi-site join server endpoints
2026-08-10 06:10:02 -07:00
wmantly 214b3a7f5a release(v2.2.0): multi-site join server endpoints, persisted site role, emoji fix 2026-08-10 06:07:07 -07:00
wmantly 08dd234710 Merge pull request #193 from theta42/feat/multi-site-join
feat(site): multi-site join server endpoints + persisted site role + emoji fix
2026-08-10 06:01:28 -07:00
wmantly c96a4b6652 feat(site): multi-site join server endpoints + persisted site role + emoji fix
Server endpoints for joining a spoke to a master directory (MULTI_SITE_SPEC.md).
This pass is server-only; setup.sh wiring and the UI are the next layer.

- Site join keys (SiteJoinKey model, stj_ prefix): mint/revoke/delete/list,
  hashed at rest, shown once — the same model as agent join keys.
- POST /api/site/export (master, Bearer stj_ key, no admin session): returns the
  local LDAP tree (slapcat LDIF) + resource catalog + siteSlug + baseDn.
- POST /api/site/join (spoke, admin): { masterUrl, joinKey } pulls the master
  export, imports resources (upsert by slug) + LDAP (ldapadd -c), and persists
  the spoke role. Refused if already a spoke.
- Persisted site role: utils/site_config.js keeps isMaster/masterUrl/siteSlug in
  /config/site.json (env seeds defaults); site-status/site-promote now use it.
- Unit tests (site_join, site_config) with in-memory stubs, wired into npm test.
- docs/site-join.md + docs router entry.
- Repairs the corrupted multi-site emojis (crown/bolt) in directory.ejs.
- .gitguardian.yml ignores the generic-password false positive on reading the
  LDAP bind credential from runtime config (never a hardcoded secret).
2026-08-10 05:58:28 -07:00
wmantly 0915043d6d Merge pull request #192 from theta42/release-v2.1.1
release(v2.1.1): site-status modal API fix, honest agent version fallback
2026-08-09 20:34:00 -07:00
wmantly ddf123d03c release(v2.1.1): site-status modal API fix, honest agent version fallback 2026-08-09 23:31:15 -07:00
wmantly 9004463311 Merge pull request #191 from theta42/fix/fresh-install
fix(ui): site-status modal API, honest agent version fallback
2026-08-09 20:29:19 -07:00
wmantly 96410fd8c4 fix(ui): site-status modal API, honest agent version fallback, Directory default org name
Fresh-install bug report fixes:

- 'Master Site' button error 'app.modal.show is not a function': the
  multi-site status modal used the legacy app.modal.show() signature; the app
  exposes app.modal.open({title, bodyHtml, size}). The site-status request
  itself worked -- only the rendering call was wrong.
- Agents with no discovery yet showed a fake 'v2.0.0' (three hardcoded
  fallbacks). Now 'unknown', so a host whose agent never connected isn't
  presented as an old version.
- The default org name (browser tab title) is set by theta-suite's setup.sh;
  that default is fixed separately there (CFG_ORG -> Theta Directory).
2026-08-09 23:24:54 -07:00
wmantly f94bb3c14c Merge pull request #190 from theta42/release-v2.1.0
release(v2.1.0): Windows install commands + drop committed binaries
2026-08-09 18:41:12 -07:00
wmantly 37e5ad0494 release(v2.1.0): Windows install commands in the Install Agent modal; drop committed binaries 2026-08-09 21:38:46 -07:00
wmantly 52b85c0d36 Merge pull request #189 from theta42/feat/windows-installer-commands
Windows install commands in the Install Agent modal; drop committed binaries
2026-08-09 18:35:20 -07:00
wmantly f98615aae2 chore(agents): label the Install Agent modal fields as Theta Directory URL 2026-08-09 20:41:50 -07:00
wmantly 72040b1851 feat(agents): Windows install commands in the Install Agent modal; drop committed binaries
The Install Agent modal now emits Windows (PowerShell) one-liners for the
join-key, pre-register and custom-config flows. Each downloads the fully-offline
setup installer and passes the same values the bash flow uses:

  join key : /SERVER_URL /JOIN_KEY
  quick    : /SERVER_URL /AUTH_TOKEN /PUBLIC_KEY
  custom   : /B64_CONFIG=<base64 agent.yml>

Binaries are no longer committed here: the setup.exe and loose agent binaries
are GitHub release artifacts (built by the theta-agent release workflow) and the
modal downloads the installer from releases/latest/download/. The SSO still
serves install.sh (the small Linux bootstrap script); the large binaries are
dropped from this repo.
2026-08-09 20:41:49 -07:00
wmantly 93a022bd66 release(v2.0.4): build Dockerfile.openldap FROM the published base image (#188)
ldapbuild now pulls ghcr.io/theta42/openldap-nestgroup:<pinned commit>
instead of compiling OpenLDAP from source on every build.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 17:08:25 -07:00
wmantly c30975329c feat: publish a prebuilt OpenLDAP-with-nestgroup base image (#187)
Extracts Dockerfile.openldap's `ldapbuild` stage (compile OpenLDAP from
source for the nestgroup overlay, ~5 min, dependent on git.openldap.org
being reachable) into its own Dockerfile, built and pushed to
ghcr.io/theta42/openldap-nestgroup by this workflow whenever the pinned
commit changes.

This commit only adds the new image + workflow; Dockerfile.openldap itself
still compiles from source. A follow-up change switches it to FROM the
published image once this workflow has run once and the image exists.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:54:36 -07:00
wmantly 5c0018f24a release(v2.0.3): fix Directory tab managed-filter bug and site-status 500 (#186)
- GET /api/directory-admin/resources let every kind:'host' resource through
  regardless of promotion status, so "Auto-promote to Directory" unchecked on
  a discovery plugin never kept unpromoted devices out of the Directory tab.
- GET /api/directory-admin/site-status queried the nonexistent Resource.subType
  column instead of metadata.subType, throwing SequelizeDatabaseError.
- Added a "Show ignored" toggle to Discovered Inventory (off by default).
- Untracked nodejs/config/inventory.sqlite -- the app's default runtime DB,
  not a fixture, committed by mistake across 13 prior releases.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:29:08 -07:00
wmantly 6e172bd528 release(v2.0.2): rebrand README to Theta Directory, remove standalone-install framing (#185)
Removed "Why this over the alternatives"; fixed stale links to the old
per-repo GitHub Pages site to point at the unified theta-suite docs site;
made explicit this is deployed as part of Theta Suite, not standalone; added
the agent capability/install screenshots to the gallery.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 16:09:30 -07:00
wmantly 3c98cd4596 Merge pull request #184 from theta42/release-v2.0.1-binaries
release(v2.0.1): bump version to 2.0.1 and package v2.0.1 agent binaries
2026-08-09 12:39:14 -07:00
wmantly d3fab004e9 release(v2.0.1): bump version to 2.0.1 and package v2.0.1 agent binaries 2026-08-09 15:35:29 -04:00
wmantly e8824cb28d Merge pull request #183 from theta42/release-v2.0.1
release(v2.0.1): fix openbao container discovery, agent version API collection, console logs, cache invalidation, site status API 500, secrets filtering, and auto-group spawning
2026-08-09 11:50:19 -07:00
wmantly 98ed99a7e9 release(v2.0.1): fix openbao container discovery, agent version API collection, console logs, cache invalidation, site status API 500, secrets filtering, and auto-group spawning 2026-08-09 14:46:33 -04:00
wmantly bb744adb5e fix(agent): update bundled v2.0.0 multi-arch binaries and install.sh in static resources (#182) 2026-08-09 01:11:55 -04:00
wmantly 420a9c9f8a docs(changelog): add v2.0.0 release notes (#181) 2026-08-09 00:18:34 -04:00
wmantly d442f1e3f9 fix(agent): preserve full telemetry (disks, cpu_details, logged_users) and format desktop_control driver-action params (#180) 2026-08-08 23:41:46 -04:00
wmantly ebd7e9e434 feat(v2.0.0): bump version to 2.0.0, add Multi-Site Master badge, site-status API, and Master promotion UI (#179)
* feat(v2.0.0): bump version to 2.0.0, add Multi-Site Master badge, site-status API, and Master promotion UI

* fix(test): update test script to run unit tests without requiring live Redis socket
2026-08-08 21:43:33 -04:00
wmantly 6ce7e67665 docs: rebrand to Theta Directory (#178) 2026-08-08 20:56:43 -04:00
wmantly 583b822e4a feat(directory): add subtype templates picker, fuzzy merge search, auto-ignore openbao, inherit parent host agent, truncate resource names, key badge positioning (#177) 2026-08-08 19:38:42 -04:00
wmantly 9025feef1b Merge pull request #176 from theta42/release-v1.33.0
Release v1.33.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations
2026-08-08 18:16:26 -04:00
wmantly 620f401091 release: v1.33.0 - Directory Key Badges, Discovered Inventory Merge/Ignore & Desktop Operations 2026-08-08 18:12:37 -04:00
wmantly fc9952add3 Merge pull request #175 from theta42/release-v1.32.0
Release v1.32.0 - Subtype Drivers Engine & Explicit Secret Inheritance
2026-08-08 16:12:35 -04:00
wmantly 8a0b796f81 test: update plugin and reconciler tests for 100% pass rate 2026-08-08 16:08:53 -04:00
wmantly 652d3f7d76 Merge release branch release-v1.32.0 2026-08-08 15:38:35 -04:00
wmantly a442dc9921 release: v1.32.0 - Subtype Drivers Engine, Explicit Secret Inheritance & App Tokens consolidation 2026-08-08 15:38:35 -04:00
wmantly 5b1302bc6f Merge pull request #174 from theta42/release/v1.31.0
chore: release v1.31.0
2026-08-07 23:41:08 -04:00
wmantly 10e5193077 test: update api_agent_ops test to match default paths 200 response 2026-08-07 23:35:20 -04:00
wmantly 15d9ce1078 feat: release v1.31.0 with Zero-View secrets engine, generator, multi-level inheritance, and SSSD key mappings 2026-08-07 23:26:39 -04:00
wmantly 181ca8c9cb Add LDAP-over-HTTPS API, agent secrets/IAM engines, and join key management
See CHANGELOG.md for the full breakdown. Summary:

- POST /api/v1/ldap/{bind,search}: LDAP-over-HTTPS so a client stops
  speaking raw LDAP and instead calls the SSO, which binds/searches its
  own OpenLDAP on the caller's behalf (DESIGN.md §3).
- LDAP byte-pump relay (utils/ldap_tunnel.js): forwards raw LDAP bytes
  from an agent's local socket into OpenLDAP over the existing agent WSS
  channel; the SSO never parses LDAP (DESIGN.md §4).
- POST /api/v1/agent/secrets: node-scoped OpenBao secret fetch for
  agents, enforced to each agent's own secret/data/nodes/<id>/* prefix
  (DESIGN.md §5).
- iam_apply signed command: push node-scoped IAM config (sudo rules, SSH
  keys, access control, revocation) to an agent (DESIGN.md §6).
- Agent capability badges on the Directory Metrics tab, sourced from the
  agent's own discovery frame.
- Join key management: GET /api/agent/join-keys/:id/agents (which hosts
  enrolled through a key) plus a Manage join keys table in the Install
  Agent modal with Revoke/Delete actions, confirmed inline per-row rather
  than a blocking native confirm() or the shared app.messages.confirm()
  banner (which desyncs across concurrent rows -- see CHANGELOG).
- docs/agents.md: capability matrix updated for the three new
  capabilities, a full secrets-engine walkthrough with screenshots
  (bash + Node consuming a rendered secret, plus the direct-API
  alternative), and the join-key reuse/UI/audit questions answered.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-07 17:03:41 -04:00
wmantly 6e748bfa66 Merge pull request #173 from theta42/fix/smtp-from-fallback-and-doc-fixes
Fix SMTP From-address fallback rejection; catalog card icon order; doc corrections
2026-08-06 21:19:35 -04:00
wmantly a78db906e8 Fix SMTP From-address fallback rejection; catalog card icon order; doc corrections
Mail sending fell back to a hardcoded noreply@theta42.com From address when
smtp.from wasn't set, which authenticated relays reject with "Sender is not
same as SMTP authenticate username" since no relay authorized this account
to send as that address. Falls back to smtp.user first now.

Also: catalog card titles now read name-then-icon instead of icon-then-name,
and a handful of docs corrections found in an accuracy pass (configuration.md
missing the OpenBao/live-config layer, plugins.md undercounting plugin types,
vault.md describing OpenBao dev-mode/root-token access that doesn't reflect
the real production setup, orphaned discovery.md/vault.md pages linked in,
README's required-groups list missing app_sso_directory_admin).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0113gCdnfSCuZr6xvPDxTo3D
2026-08-06 21:13:38 -04:00
wmantly e7e3eeb6cd Merge pull request #172 from theta42/fix/test-email-sms-and-join-key-ui
fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
2026-08-06 14:40:04 -04:00
wmantly f178f1a972 fix: test email/SMS senders, all SMS delivery, join-key install UI (v1.30.1)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m43s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 4s
Test Email always failed with "Email.send is not a function":
models/email.js exports {Mail}, and the handler required the module and
called .send on it directly. Every other caller destructures it.

Test SMS failed with "Unexpected token '<'": it POSTed to
https://api.voip.ms/v1.0/sms/send with Basic auth, an endpoint that does
not exist. VoIP.ms's REST API is a GET against voip.ms/api/v1/rest.php
with api_username/api_password and method=sendSMS, so the fabricated URL
returned HTML and response.json() threw.

Worse, ALL SMS delivery was broken. models/sms.js called
PluginInstance.find({...}) but the ORM has no find -- the query method is
list({where}) -- so it threw on every send, before it could even fall
back to the direct VoIP.ms path. OTP-by-SMS and notifications were dead.

Both test endpoints now send through the same senders every real message
uses. A test that reimplements delivery proves nothing about whether real
delivery works, which is how two broken paths went unnoticed. Failures
report as 400 with the underlying reason rather than an opaque 500.

Adds a guard suite that fails the build on any call to a non-existent ORM
static, on requiring models/email without destructuring {Mail}, and on
any reference to the bogus api.voip.ms host.

Also: the Install Agent modal now leads with the join-key flow. v1.30.0
shipped join keys in the API and documented the modal as the place to get
one, but the modal still only did the pre-register flow.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 11:51:37 -04:00
98 changed files with 7517 additions and 535 deletions
+12
View File
@@ -0,0 +1,12 @@
# GitGuardian configuration (ggshield / GitGuardian GH checks).
#
# The generic-password detector false-positives on LDAP admin bind credentials
# being READ from runtime config (sso-secrets.js / /config/site.json) — e.g.
# `const x = conf.ldap && conf.ldap.bindPassword` in the multi-site join flow.
# That is the correct pattern (never a hardcoded secret); ignore the variable
# reference, not the actual value.
version: 2
ignore:
- name: generic-password
match: |
conf\.ldap\s*&&\s*conf\.ldap\.bindPassword
@@ -0,0 +1,58 @@
name: Build OpenLDAP Base Image
# Publishes ghcr.io/theta42/openldap-nestgroup, the prebuilt slapd-with-
# nestgroup image Dockerfile.openldap's `ldapbuild` stage pulls FROM instead
# of compiling from source on every build (see Dockerfile.openldap-builder
# for why, and the ~5 minute + git.openldap.org-dependent cost it replaces).
#
# Runs only when the builder Dockerfile changes -- bumping OPENLDAP_COMMIT in
# it is the only reason this image should ever need rebuilding -- or on
# manual dispatch.
on:
push:
branches: [master]
paths:
- 'Dockerfile.openldap-builder'
- '.github/workflows/build-openldap-image.yml'
workflow_dispatch: {}
permissions:
contents: read
packages: write
jobs:
build-and-push:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Single source of truth for the tag: the ARG default in the Dockerfile
# itself, not a value duplicated into this workflow.
- name: Resolve pinned OpenLDAP commit
id: commit
run: |
commit=$(grep -oP '^ARG OPENLDAP_COMMIT=\K[0-9a-f]+' Dockerfile.openldap-builder)
if [ -z "$commit" ]; then
echo "::error::Could not resolve OPENLDAP_COMMIT from Dockerfile.openldap-builder"
exit 1
fi
echo "commit=$commit" >> "$GITHUB_OUTPUT"
- 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
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.openldap-builder
build-args: |
OPENLDAP_COMMIT=${{ steps.commit.outputs.commit }}
push: true
tags: |
ghcr.io/theta42/openldap-nestgroup:${{ steps.commit.outputs.commit }}
ghcr.io/theta42/openldap-nestgroup:latest
+6
View File
@@ -91,6 +91,12 @@ secrets.js
# they must never be committed. The empty *.example templates ARE tracked.
config/*-secrets.js
# Default sqlite ORM storage (nodejs/models/index.js falls back to this path
# when no external DB is configured via conf.orm) -- live runtime data, not a
# fixture. Was committed by mistake across many prior releases. NB: this is
# nodejs/config/, distinct from the root ./config/ secrets dir above.
nodejs/config/*.sqlite
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
docs/_site
+72
View File
@@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con
**Response:** `{ "success": true }`
---
## Subtype Driver Operations Endpoints
Base path: `/api/directory-admin/resources`
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission.
### Get Subtype Driver Metrics
**`GET /api/directory-admin/resources/:id/driver-metrics`**
Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry.
**Response:**
```json
{
"status": "ok",
"resourceId": "res-id",
"metrics": {
"status": "online",
"driver": "database",
"subType": "redis",
"redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 }
}
}
```
---
### Execute Subtype Driver Action
**`POST /api/directory-admin/resources/:id/driver-action`**
Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale).
**Request:**
```json
{
"action": "restart",
"params": { "serviceName": "emby-server" }
}
```
**Response:**
```json
{
"status": "ok",
"resourceId": "res-id",
"result": { "status": "ok", "driver": "docker_socket", "action": "restart" }
}
```
---
### Get Subtype Driver Logs
**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`**
Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs).
**Response:**
```json
{
"status": "ok",
"resourceId": "res-id",
"logs": "[docker logs --tail 100 emby-server]\nContainer initialized..."
}
```
---
## Error Responses
All endpoints return errors in this format:
+228
View File
@@ -1,3 +1,231 @@
# v2.7.0 - 2026-08-11
### Added
- **OpenLDAP N-way multi-master replication auto-config.** `SiteSpoke.ldapServerId` is now auto-assigned at registration (next free from 2 upward, 1 reserved for the master -- same pattern as jump-host's mesh index), and each site's LDAP URL is derived from its already-known HTTP(S) endpoint rather than a separately-configured field. New `utils/ldap_replication.js`, `GET /api/site/ldap-peers` (spoke-facing, Bearer site join key) and `GET /directory-admin/ldap-replication-config` (master-local). Operators no longer hand-maintain `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a `theta-suite`-joined cluster (see `theta-suite`'s `bootstrap/site-ldap-register.js`). Verified against real running containers (`docker-compose.multisite-e2e.yml`).
# v2.6.0 - 2026-08-11
### Fixed
- **Duplicate access/admin groups on repeated resource promotion.** Three independent copies of the same bug (`routes/discovery.js`'s `POST /discovery/promote/:slug` -- the actual "Promote" button in the UI -- and `services/discovery_reconciler.js`'s `autoPromote` path both called `ResourceGroup.create()` directly with no existence check, unlike `routes/api_directory_admin.js`'s own `ensureResourceGroup`, which already carried a comment describing this exact bug). A resource promoted more than once (a retried click, or the same LXC discovered from multiple Proxmox cluster nodes) silently accumulated duplicate rows every time. Consolidated into `ResourceGroup.ensure()` on the model, used everywhere.
- **`GET /api/directory-admin/resources` ran a full LDAP group self-heal fan-out on every single list** (`ensureSiteGroups` per site + `provisionResourceGroups` per resource, each several sequential LDAP round-trips), unconditionally -- confirmed as the actual bottleneck once a directory has more than a handful of resources, not data volume. Moved healing to where resources actually change instead (`POST`/`PUT /resources`, `POST /discovery/promote/:slug` -- `PUT` had none at all before this), and added `POST /resources/heal-groups` as an explicit on-demand equivalent for backfilling a directory seeded before this change.
- **An nmap discovery scan that completed successfully could be reported as a failed run with zero hosts found.** `node-nmap` (the vendored library) treats any stderr output from the nmap binary as fatal -- including nmap's own harmless RTT-calibration warning ("RTTVAR has grown to over N seconds..."), which it prints *during* a scan that goes on to complete normally, discarding valid results already sitting in the library's `rawData`. Our plugin now recognizes this specific benign message and manually completes the scan from the data that's already there; any other error still rejects as before.
- **The Multi-Site modal's "Theta Gateways" count was measuring the wrong subsystem.** It counted this app's own unrelated WireGuard roaming-client/exit-node Resources, not jump-host's actual gateway-to-gateway mesh registry. New `utils/jump_client.js` (same self-service-token pattern as `utils/proxy_client.js`) queries jump-host's real `GET /api/mesh/gateways`, reporting a distinct "unknown" state instead of a misleading 0 when the integration isn't configured. Also added help links to the published multi-site/mesh docs on the modal.
# v2.5.0 - 2026-08-10
### Added
- **No-inbound relay automation.** A spoke with no public IP of its own can now register as such (`noInbound`/`meshIp`/`publicHost` on `POST /api/site/spokes`, forwarded through `POST /api/site/join` for the real operator join flow), and the master auto-creates/updates the relay route on its own `theta-proxy` via `utils/proxy_client.js` — a new self-service `prx_...` API token client, reusing `theta-proxy`'s existing token system rather than inventing a new credential type. Verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed).
- **Replication traffic prefers the mesh.** `utils/site_replicate.js`'s fire-and-forget resync push now tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) — cross-component routing over the gateway-to-gateway WireGuard mesh instead of the open internet, for any spoke that's registered one.
- `POST /api/site/join` surfaces the resulting relay status in its response (`relay.note`), and `theta-suite`'s bootstrap flow (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`, `bootstrap/site-relay-register.js`) drives all of this from the real operator-facing setup script, not just the API.
# v2.4.0 - 2026-08-10
### Added
- **Live catalog replication.** A spoke now stays in sync after joining instead of only getting a one-time snapshot: it registers its own endpoint with the master at join time (`POST /api/site/spokes`, Bearer the site join key), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, concurrently — one unreachable spoke never blocks or delays delivery to another. The spoke's `POST /api/site/resync` handler re-runs the same tested export-pull-and-import path used at join time rather than applying a partial diff.
- **Identical-directory agent-signing key.** `POST /api/site/export` now best-effort includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on both join and every resync, so any site's `sso-manager-node` can validly sign a command for any agent enrolled at any other site (a deliberate blast-radius tradeoff for this deployment's small, trusted scale — see `theta-suite`'s `docs/MULTI_SITE_SPEC.md` §2).
- **Coordinated master promotion.** `POST /api/directory-admin/site-promote` now demotes the previous master as part of the same action (mints it a fresh join key, calls its new `POST /api/site/demote`) instead of leaving a manual two-step gap where two nodes could both believe they're master. Best-effort: an unreachable old master (the WAN-outage scenario this control exists for) never blocks the local promotion — the response's `handoff` field reports what happened.
- **Master Site modal UI**: new "Live Replication" (spoke) / "Registered Spokes" (master) status rows; the join form gained a "this site's own reachable URL" field (prefilled from the browser origin) wired to the `selfUrl` the join API already supported but the UI never sent — a UI-driven join previously never registered for live replication, only the `setup.sh` bootstrap path did; the promote button's success toast now reports the actual handoff result.
- New `nodejs/models/site_spoke.js` (registered spokes + their push tokens) and `docker-compose.multisite-e2e.yml` + `test/multisite_join_e2e.js` (real two-container master+spoke regression test covering join, live replication, promotion, and demotion end to end).
### Fixed
- **`site-promote`'s god_admin check was dead on arrival.** It read `req.user.groups`, a field nothing in the codebase ever populates (every other admin gate resolves membership live via `permission.byGroup()`/`Group.list(user.dn)`, which also handles nested-group membership) — the check silently evaluated to an empty array on every request, so promotion returned 403 for every user, including a real god_admin, since it shipped in v2.0.0. Only surfaced by the live e2e test, not by inspection.
- **The read-only write-gate blocked `site-promote` on a spoke before its handler could run** — the one mutating request a spoke must be able to make to itself. Exempted `/site-promote` from the gate.
- **`GET /api/site/config` was returning `masterJoinKey` and `replicationPushToken`** — live credentials — directly in the JSON response to any admin session. Replaced with boolean derivatives (`hasMasterJoinKey`, `liveReplication`).
# v2.3.0 - 2026-08-10
### Added
- **Multi-site join — UI + enforcement** (completes the join layer started in v2.2.0):
- **Master Site modal**: a fresh install (no users beyond the bootstrap admin, no agents) gets a **"Join an Existing Site"** form (master URL + site join key); a master gets a **Site Join Keys** manager (mint/revoke/list, key shown once, copy button); **WAN Sync Health** now reflects a live probe of the master.
- `POST /api/site/ping` (Bearer site-join-key): lightweight master reachability probe for WAN health.
- **Spoke read-only**: directory-write routes (resources, edges, groups, secrets, grants, driver actions, discovery merges) return `403` pointing at the master once a node is a spoke.
- **Fresh-install guard**: `/api/site/join` refuses unless the directory has no users beyond the admin and no enrolled agents (`siteIsFresh`); `site-status` exposes `canJoin` so the UI only offers join when it's actually allowed.
- The spoke persists the join key (`masterJoinKey`) in `/config/site.json` for WAN health + future write-proxy.
- **Unit tests**: `siteIsFresh` cases (admin-only, second user, enrolled agent, service accounts ignored).
### Fixed
- **Branch-protection check name**: the lint job that `master` requires is literally named "Syntax check bootstrap.js"; the multi-site bootstrap script is checked by that same job.
# v2.2.0 - 2026-08-10
### Added
- **Multi-site join — server endpoints.** A spoke can join an existing master directory:
- **Site join keys** (`stj_…`): mint/revoke/delete under `/api/site/join-keys` (hashed at rest, shown once — same model as agent join keys).
- `POST /api/site/export` (master, Bearer site-join-key, no admin session): returns the LDAP tree (`slapcat` LDIF) + resource catalog + siteSlug + baseDn.
- `POST /api/site/join` (spoke, admin): `{ masterUrl, joinKey }` pulls the master export, imports resources (upsert by slug) + LDAP (`ldapadd -c`), and persists the spoke role. Refused if already a spoke.
- **Persisted site role**: `isMaster`/`masterUrl`/`siteSlug` now live in `/config/site.json` (env seeds defaults) instead of Node memory, so a restart no longer silently reverts a spoke to master. `site-status`/`site-promote` read/write it.
- Docs: `docs/site-join.md` (flow, endpoints, planned `setup.env` vars) registered in the docs router.
- The UI + `setup.sh` wiring for join is the next layer; this pass is server-only.
- **Unit tests** for the join helpers (`tests/site_join.test.js`) and persisted config (`tests/site_config.test.js`) — pure logic, in-memory stubs, added to `npm test`.
### Fixed
- **Multi-site modal text was mojibake.** The Master/Spoke emojis (👑/⚡) in `views/directory.ejs` were UTF-8 that had been round-tripped through cp1252; restored. All other views verified byte-accurate clean.
# v2.1.1 - 2026-08-10
### Fixed
- **"Master Site" button errored with `app.modal.show is not a function`.** The multi-site status modal used the legacy `app.modal.show()` signature; the app exposes `app.modal.open({ title, bodyHtml, size })`. The site-status request itself worked — only the rendering call was wrong.
- **Agents with no discovery yet showed a fake `v2.0.0`.** Three hardcoded fallbacks now report `unknown` instead, so a host whose agent hasn't connected isn't presented as an old version.
# v2.1.0 - 2026-08-10
### Added
- **Windows install commands in the Install Agent modal.** The Directory → Install Agent modal now emits PowerShell one-liners alongside the bash ones for the join-key, pre-register, and custom-config flows. Each downloads the fully-offline theta-agent `setup.exe` from its GitHub release and passes the same values the bash flow uses (`/SERVER_URL`, `/JOIN_KEY`, `/AUTH_TOKEN`, `/PUBLIC_KEY`, or `/B64_CONFIG`), so a Windows host enrolls with the same one-command flow as Linux. Complements the theta-agent v2.1.0 Windows release.
### Removed
- **Large binaries from `nodejs/public/resources/theta-agent/`.** The agent/tray/helper/setup binaries are now built on GitHub Actions and attached to the theta-agent GitHub release as artifacts; `install.sh` and the modal download them from `releases/latest/download/`. Nothing binary lives in this repo anymore (the small `install.sh` bootstrap script remains).
# v2.0.4 - 2026-08-09
### Changed
- **`Dockerfile.openldap` no longer compiles OpenLDAP from source.** Its `ldapbuild` stage now pulls `ghcr.io/theta42/openldap-nestgroup:<pinned commit>` (built once by `.github/workflows/build-openldap-image.yml` from the new `Dockerfile.openldap-builder`) instead of cloning `git.openldap.org` and running `./configure && make` on every build. Cuts ~5 minutes off every build of this Dockerfile, including 3x per CI run's test matrix, and removes the runtime dependency on that mirror being up (it 502'd twice tonight, blocking two PRs). Verified locally end-to-end before merging: built the app image against the published base, ran it, confirmed slapd boots healthy with the nestgroup overlay loaded and the correct pinned commit.
# v2.0.3 - 2026-08-09
### Fixed
- **Directory tab showed unpromoted discoveries.** `GET /api/directory-admin/resources` unconditionally admitted every `kind: 'host'` resource, and every discovery plugin (UniFi, Proxmox, nmap) creates its finds as `kind: 'host'` — so unchecking "Auto-promote to Directory" on a plugin never actually kept undiscovered/unpromoted devices out of the Directory tab, only out of the LDAP-group auto-provisioning. Now only `site` resources are unconditionally shown; anything else that discovery ever touched requires `metadata.managed === true` (set by promotion, an agent, or merging into an already-managed resource).
- **`GET /api/directory-admin/site-status` 500'd.** Queried `Resource.list({ where: { subType: 'wireguard' } })`, but `subType` only ever lives in `metadata.subType` (every driver/discovery plugin reads it that way) — never a top-level DB column, so SQLite raised `no such column: Resource.subType`. Filters in JS over `metadata.subType` now.
- **Discovered Inventory had no way to review ignored devices.** Added a "Show ignored" toggle (off by default) to the tab, so `metadata.ignored === true` rows stay hidden from routine triage but remain reachable.
### Chore
- **Untracked `nodejs/config/inventory.sqlite`.** It's the app's default runtime DB (`nodejs/models/index.js` falls back to this path when no external DB is configured), not a fixture — it had been committed by mistake across 13 prior releases, churning on every local run. Removed from tracking and gitignored.
# v2.0.2 - 2026-08-09
### Fixed
- **README rebranding & standalone-install cleanup.** Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (`theta42.github.io/sso-manager-node/`); documentation and secrets links now point at the unified `theta42.github.io/theta-suite/` site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
# v2.0.1 - 2026-08-09
### Fixed
- **OpenBao / OpenBoa Container Exclusion**: Skip discovery of internal secret management/renewer containers so they don't populate resources catalog.
- **Agent Version API Collection**: Added `version` tracking to Agent model and discovery handlers to record and report agent version dynamically.
- **Console Log Cleanup**: Removed leftover `console.log` debug statements in frontend assets.
- **Resource Save & User Cache Invalidation**: Fixed metadata merge on resource updates to prevent losing system fields, and added User cache clear to propagate user/group edits instantly.
- **Site Status 500 Fix**: Replaced invalid ORM `Resource.findAll()` call with `Resource.list()`.
- **Secret Filtering**: Fixed the "With Secrets" filter checkbox by ensuring `hasSecret` / `secretKeys` states are written to resource metadata and checked by EJS views.
- **Auto-Group Spawning Prevention**: Set default `autoPromote` to false in UniFi, Docker, Proxmox, and Nmap plugins and restricted auto group creation to managed resources to prevent duplicate LDAP group generation.
# v2.0.0 - 2026-08-09
### Added
- **Multi-Site Master Architecture.** Multi-Site Master badge, `/api/directory-admin/site-status` API, and Master site promotion UI.
- **Full Telemetry Dashboard Cards.** Rendered active `logged_users`, physical partitions, host details, and desktop session/power controls (Lock, Display Off, Log Out, Sleep Host).
- **Theta Directory Rebranding.** Rebranded SSO Manager UI and documentation to Theta Directory.
### Fixed
- **Agent Action Parameter Resolution.** Standardized top-level and nested parameter parsing for agent driver actions (`api_directory_admin.js`).
# v1.33.0 - 2026-08-08
### Added
- **Directory Key Badges & Secret Filtering.** Added a gold `🔑 Secret` badge next to resources with stored OpenBao secrets and a `With Secrets` filter checkbox to filter the directory tree by secret presence.
- **Kind-Specific Resource Creation Modals.** Added dedicated `openAddSiteModal()`, `openAddHostModal()`, and `openAddServiceModal()` modal handlers for Site, Host, and Service resources.
- **Top Toolbar Reorganization.** Updated top tree button to **"+ Add Site"** and removed legacy `Plumbing` slider.
- **Optional Child Secret Key Name on Inheritance.** Made key name optional when inheriting parent secrets — automatically defaulting to the original parent secret key name if left blank.
- **Discovered Inventory Merge & Ignore Actions.** Added `Merge` (merge IP/interfaces/OS metadata into target resource) and `Ignore` (dismiss discovered item) endpoints (`/api/directory-admin/discovered/merge` & `/api/directory-admin/discovered/ignore`) and table action buttons.
- **Agent Tab Telemetry & Desktop Controls.** Rendered Agent Binary Version badge (`v1.8.0`), all physical disks and filesystems table, Active Logged-in Users card, and Desktop Session & Power Operations card (Lock, Display Off, Log Out, Sleep Host).
# v1.32.0 - 2026-08-08
### Added
- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints.
- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT:<parentSlug>:<parentKey>`) without exposing sibling directory secrets.
- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item.
- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources/<slug>/conf` with per-key merging and deletion.
- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon).
### Fixed
- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths.
- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution.
# v1.31.0 - 2026-08-07
### Added
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances.
- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`).
- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (8128 chars) populating input fields with security notices.
- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`).
- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners.
- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability.
### Fixed
- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template.
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
### Added
- **`POST /api/v1/ldap/bind` and `POST /api/v1/ldap/search`** — an LDAP-over-HTTPS
API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call
to the SSO, which performs the real bind/search against its own OpenLDAP. This
kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a
Bearer token: an agent token or a self-service API token (PAT). `/search` is
restricted to agent callers (the SSSD user/group-resolution use case) and runs
under the admin bind — see DESIGN.md §9.5 for the scoped-service-account
follow-up.
- **LDAP byte-pump relay** (`utils/ldap_tunnel.js`) — the SSO relays raw LDAP
bytes from an agent's local socket into its real OpenLDAP and pipes the
response back, over the existing agent WSS channel (`ldap_tunnel` messages).
The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4.
- **`POST /api/v1/agent/secrets`** — an agent fetches its own node-scoped OpenBao
secrets (DESIGN.md §5). The agent may only read under `secret/data/nodes/<id>/*`;
the SSO fetches with its own OpenBao access, so the agent never holds a Vault
token. Agent-token authed (not admin-gated).
- **`iam_apply` command** — the SSO pushes node-scoped IAM config (sudo rules,
SSH keys, access control, revocation) to an agent as a signed high-risk
command (DESIGN.md §6). Added to `HIGH_RISK_COMMANDS`.
- **Agent capabilities in the Directory UI** — the agent reports its enabled
capabilities in its `discovery` frame; the SSO stores them and the host's
Metrics tab renders them as green/gray badges, so an operator can see at a
glance what each agent is allowed to do.
- **`GET /api/agent/join-keys/:id/agents`** — which hosts enrolled through a
given join key. Matches on the trace `Agent.enroll` already leaves in
`description` ("Self-enrolled with join key `<prefix>`") rather than a stored
relation.
- **Join key management in the Install Agent modal** — a table (label, prefix,
created date, hosts joined, status) alongside the existing mint/select
dropdown, with **Revoke** and **Delete** actions and a click-through to see
which hosts joined via a given key. Previously these were API-only. Revoke
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
blocking native `confirm()` (freezes the whole tab) or the shared
`app.messages.confirm()` banner (a single `.actionMessage` shared by the
whole card, so a second click before the first resolves leaves a dangling
`$('body').one('click', ...)` handler from the first call and desyncs which
row the banner is actually confirming for).
# v1.30.2
### Fixed
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
### Docs
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
# v1.30.1
### Fixed
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
### Added
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
# v1.30.0
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
+17 -60
View File
@@ -22,6 +22,12 @@
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
# the host where the submodule resolves correctly.
ARG GIT_COMMIT=""
# Pinned OpenLDAP commit this build expects -- must match the tag of the
# published builder image below. Bumping it is a two-step change: rebuild +
# push ghcr.io/theta42/openldap-nestgroup:<new commit> from
# Dockerfile.openldap-builder (see that file), then update this default (or
# pass --build-arg OPENLDAP_COMMIT=<new commit> here).
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
FROM node:20-alpine AS gitinfo
ARG GIT_COMMIT
WORKDIR /repo
@@ -33,9 +39,9 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
fi
# ── OpenLDAP from source ─────────────────────────────────────────────────────
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
# ── OpenLDAP, prebuilt ────────────────────────────────────────────────────────
# We run slapd from OpenLDAP master rather than Alpine's packaged release, for
# exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
# nestgroup is not among them -- and the alternative is resolving nesting
@@ -46,64 +52,14 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
# 0.9.x used by 2.6.x cannot read, and vice versa
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
FROM node:20-alpine AS ldapbuild
# groff is not optional despite producing nothing we ship: the build descends
# into doc/man unconditionally and its Makefile calls soelim, which groff
# provides. Without it the whole `make` fails at the man-page stage
# ("soelim: not found") long after slapd itself has compiled fine.
RUN apk add --no-cache \
build-base autoconf automake libtool \
openssl-dev cyrus-sasl-dev \
git make pkgconf util-linux-dev groff
# Pinned to an exact commit, not a branch tip. This is the directory server the
# whole lab authenticates against; an unpinned `master` would mean every image
# rebuild silently ships whatever landed upstream that morning, and a bad day on
# master would take out logins with no way to tell what changed.
#
# TODO: drop this whole from-source stage once nestgroup ships in a release.
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
# the entrypoint already probes for nestgroup.so and needs no change, and the
# app already keys off app_ldap__nestedGroupsServerSide either way.
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
WORKDIR /src
RUN git init -q . \
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
&& git checkout -q FETCH_HEAD \
&& git rev-parse HEAD > /opt-openldap-commit.txt
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
# `moduleload`s them individually; nestgroup joins that set.
RUN ./configure \
--prefix=/opt/openldap \
--enable-slapd \
--enable-modules \
--enable-mdb \
--enable-memberof=mod \
--enable-refint=mod \
--enable-ppolicy=mod \
--enable-dynlist=mod \
--enable-nestgroup=mod \
--enable-syncprov=mod \
--enable-auditlog=mod \
--with-tls=openssl \
--with-cyrus-sasl \
&& make depend \
&& make -j"$(nproc)" \
&& make install
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
# It lives in contrib and is not covered by the configure flags above, so it is
# built separately against the just-built tree -- omitting it would make every
# user password unverifiable.
RUN cd contrib/slapd-modules/passwd/sha2 \
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
# The from-source compile (~5 min, and a dependency on git.openldap.org being
# reachable) used to happen right here, on every build of this Dockerfile --
# including 3x per CI run's test matrix. It's now built once, tagged by the
# pinned commit above, in Dockerfile.openldap-builder -- see that file for the
# actual compile steps and the TODO on dropping from-source entirely once
# nestgroup ships in a release.
FROM ghcr.io/theta42/openldap-nestgroup:${OPENLDAP_COMMIT} AS ldapbuild
FROM node:20-alpine
@@ -163,6 +119,7 @@ COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/drivers ./drivers
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
COPY nodejs/routes ./routes
+75
View File
@@ -0,0 +1,75 @@
# OpenLDAP-with-nestgroup builder, published to
# ghcr.io/theta42/openldap-nestgroup:<OPENLDAP_COMMIT short hash>.
#
# Extracted out of Dockerfile.openldap's `ldapbuild` stage so the ~5 minute
# from-source compile (which also depends on git.openldap.org being up)
# happens once, here, instead of on every `docker build` of the app image --
# including every CI run's 3-way test matrix. Dockerfile.openldap's ldapbuild
# stage becomes `FROM ghcr.io/theta42/openldap-nestgroup:<commit>` and the
# rest of that file (the COPY --from=ldapbuild lines) is unchanged, since
# COPY --from also accepts an external image, not just a local stage name.
#
# Bumping OPENLDAP_COMMIT is a two-step change: update the ARG below, push
# (the build-openldap-image workflow rebuilds+republishes the tag on changes
# to this file), then update the matching FROM line in Dockerfile.openldap.
#
# See Dockerfile.openldap's own "OpenLDAP from source" comment for *why*
# from-source at all (the nestgroup overlay, ITS#10161) and the LMDB format
# note (master's 1.0.0 vs 2.6.x's 0.9.x).
FROM node:20-alpine AS build
# groff is not optional despite producing nothing we ship: the build descends
# into doc/man unconditionally and its Makefile calls soelim, which groff
# provides. Without it the whole `make` fails at the man-page stage
# ("soelim: not found") long after slapd itself has compiled fine.
RUN apk add --no-cache \
build-base autoconf automake libtool \
openssl-dev cyrus-sasl-dev \
git make pkgconf util-linux-dev groff
# Pinned to an exact commit, not a branch tip -- see Dockerfile.openldap for
# why (this is the directory server the whole lab authenticates against).
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
WORKDIR /src
RUN git init -q . \
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
&& git checkout -q FETCH_HEAD \
&& git rev-parse HEAD > /opt-openldap-commit.txt
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
# `moduleload`s them individually; nestgroup joins that set.
RUN ./configure \
--prefix=/opt/openldap \
--enable-slapd \
--enable-modules \
--enable-mdb \
--enable-memberof=mod \
--enable-refint=mod \
--enable-ppolicy=mod \
--enable-dynlist=mod \
--enable-nestgroup=mod \
--enable-syncprov=mod \
--enable-auditlog=mod \
--with-tls=openssl \
--with-cyrus-sasl \
&& make depend \
&& make -j"$(nproc)" \
&& make install
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
# It lives in contrib and is not covered by the configure flags above, so it is
# built separately against the just-built tree -- omitting it would make every
# user password unverifiable.
RUN cd contrib/slapd-modules/passwd/sha2 \
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
# Pure artifact holder -- no shell, no package manager, nothing but the
# compiled tree. Dockerfile.openldap's COPY --from=ldapbuild only ever reads
# files, never RUNs anything in this stage, so scratch is sufficient and
# keeps the published image (and every pull of it) as small as possible.
FROM scratch
COPY --from=build /opt/openldap /opt/openldap
COPY --from=build /opt-openldap-commit.txt /opt-openldap-commit.txt
+5
View File
@@ -20,6 +20,7 @@ COPY nodejs/app.js ./
COPY nodejs/bin ./bin
COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/drivers ./drivers
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
# Without this the discovery/plugin suites cannot even load their subject and
@@ -47,6 +48,10 @@ COPY directory_spec.md /directory_spec.md
COPY test_seed.js ./test_seed.js
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
RUN chmod +x /usr/local/bin/seed-test-user
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
# End-to-end multi-site join test client (docker-compose.multisite-e2e.yml)
COPY test/multisite_join_e2e.js ./test/multisite_join_e2e.js
# Default command: seed the test user, then run the test suite
CMD ["sh", "-c", "seed-test-user && npm test"]
+21 -117
View File
@@ -1,19 +1,15 @@
# SSO Manager
# Theta Directory
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
and a web management UI — for home labs and small businesses that want their own
identity provider instead of a hosted one.
A production-grade, self-hosted **OpenID Connect provider**, **Resource Directory & IAM Engine**, and bundled **OpenLDAP directory** with a modern web console — designed for home-labs and enterprise infrastructure that demand total sovereignty over their identity, secrets, and resource catalog.
It gives you one place to manage your users and groups, one login (OIDC) that
your modern apps can use, and one LDAP directory your older or odder apps can
bind to directly. Everything runs on your own hardware; there is no
phone-home, no hosted control plane, and no per-user pricing.
It provides a single source of truth for identity (OIDC + LDAP), host/service directory inventory, access control groups, and secrets management running entirely on your own hardware without third-party cloud lock-in.
> Setting up the whole stack (this SSO + the [theta42/proxy](https://github.com/theta42/proxy)
> in front of it) with one command? Skip to [theta-env](https://github.com/theta42/theta-env)
> — its `setup.sh` wires the two together and generates the config for you.
Theta Directory is deployed as part of [Theta Suite](https://github.com/theta42/theta-suite),
alongside [Theta Proxy](https://github.com/theta42/proxy) and
[Theta Gateway](https://github.com/theta42/jump-host) — it isn't installed or
run on its own. `./setup.sh` wires the whole stack together automatically.
**Documentation:** [https://theta42.github.io/sso-manager-node/](https://theta42.github.io/sso-manager-node/)
**Documentation:** [https://theta42.github.io/theta-suite/sso/](https://theta42.github.io/theta-suite/sso/)
## Screenshots
@@ -29,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
| --- |
| [![Sites](docs/images/sites.png)](docs/images/sites.png) |
| Agent Capabilities & Metrics | Agent Install (Join Key) |
| --- | --- |
| [![Agent capabilities & metrics](docs/images/agent-capabilities-metrics.png)](docs/images/agent-capabilities-metrics.png) | [![Agent install with join key](docs/images/agent-install-join-key.png)](docs/images/agent-install-join-key.png) |
## Features
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
@@ -47,91 +47,11 @@ phone-home, no hosted control plane, and no per-user pricing.
same directory, so you don't maintain a second user database for them.
- **Personal access tokens** — any user can mint a long-lived bearer token to
drive the management API from scripts or CI, scoped to their own permissions.
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
the pieces separately against your own LDAP/Redis via `app_*` env config.
- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows.
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers.
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
## Why this over the alternatives
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
LDAP is either a paid feature, a federation target you have to run separately,
or absent. If your stack already has apps that speak LDAP directly (or you just
want one real directory as the source of truth), you end up running *two*
identity systems and keeping them in sync.
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
apps and LDAP apps read from the same users and groups. The trade-off is
scope: it is intentionally small and self-hosted, not an enterprise IAM suite
— no fancy workflow engine, no hosted SaaS. If you want a lightweight,
self-contained identity provider with a real LDAP backend, that is the niche.
## Quick start
Three ways to run it, in order of how much it sets up for you:
### 1. As part of the unified stack (recommended)
[theta-env](https://github.com/theta42/theta-env) composes this SSO Manager with
the [theta42/proxy](https://github.com/theta42/proxy) (an OIDC-protected reverse
proxy) and generates all the config from a single `setup.env` — you enter your
domain once and it fills in the LDAP DNs, hostnames, OAuth issuer, and random
secrets consistently:
```bash
git clone --recursive https://github.com/theta42/theta-env.git
cd theta-env
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
./setup.sh # generates ./config/, builds + bootstraps + starts both
```
See the [theta-env README](https://github.com/theta42/theta-env) for the full
first-run flow, DNS/port requirements, and backups.
### 2. Standalone, in Docker
The all-in-one image bundles the app, OpenLDAP, and Redis. Copy the example
secrets file, fill in your values, and build:
```bash
git clone https://github.com/theta42/sso-manager-node.git
cd sso-manager-node
mkdir -p config && chmod 700 config
cp secrets.js.example config/sso-secrets.js
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
docker compose up -d --build
```
The web UI comes up at `http://localhost:3001`. To kick the tires with no
config file at all, the entrypoint falls back to safe defaults
(`dc=example,dc=com`, admin password `admin`, an auto-generated JWT) — fine for
a local test, not for production.
Your domain is entered once, as the LDAP base DN (`stack.ldapBaseDn`); the other
LDAP DNs and the OAuth issuer derive from it and must stay consistent. See
[DEPLOYMENT.md](DEPLOYMENT.md) for the full config reference, the `app_*` env
vars, LDAPS/TLS, and backups.
### 3. Bare metal on Debian/Ubuntu
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP —
configuring the directory (modules, overlays, schema, the SSO groups) and
seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
systemd service:
```bash
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
```
That's it — LDAP and the app are both live afterward. Edit
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
restart the service to customize. It's idempotent and safe to re-run —
re-running it updates the app in place (never touching LDAP or the secrets
file again) and prints the version you're updating from and to (e.g. `Updated
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Secrets
Secrets are loaded from **OpenBao** at boot via
@@ -152,8 +72,8 @@ writes `secret/sso-manager/conf` through `bao-conf.set`.
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
not the authoritative store. For the full architecture, policies, token model,
and rotation procedure, see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
and rotation procedure, see theta-suite's
**[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
## Architecture
@@ -165,7 +85,7 @@ and rotation procedure, see theta-env's
│ HTTP/HTTPS
┌────────────────────────┐ ┌─────────────┐
Express SSO Manager │◄────►│ Redis │
Theta Directory │◄────►│ Redis │
│ - OIDC provider │ │ - sessions │
│ - web UI (:3001) │ │ - models │
│ - management API │ └─────────────┘
@@ -188,29 +108,13 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
- [API.md](API.md) — the management API.
- [docs/](docs/) (GitHub Pages) — the same content broken into
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
- [docs/](docs/) — the same content broken into
[OAuth/OIDC](docs/oauth.md) and [LDAP](docs/ldap.md), also published at the
unified [theta-suite docs site](https://theta42.github.io/theta-suite/sso/).
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
- All of the above is also readable from the running app itself at `/docs`
no internet access required.
If you are pointing the app at your own existing LDAP server, see
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
schema. The bundled Docker image and `install.sh` set all of that up for you.
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
OAuth clients only), `app_sso_invite` (invitation management) — see
DEPLOYMENT.md for the full setup.
## Development
```bash
cd nodejs
npm install
npm run dev # nodemon auto-reload
npm test # jest test suite
```
## License
+36
View File
@@ -367,3 +367,39 @@ Ordered by how much they unblock:
conventions, not schema changes; the json column already holds them.
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
freshness; trivial once surfaced).
---
## 10. Subtype Management & Metrics Drivers Architecture
The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols.
### Subtype Matrix & Drivers
| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics |
| :--- | :--- | :--- | :--- | :--- |
| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState |
| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O |
| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status |
| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels |
| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases |
| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP |
| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions |
*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).*
### 4-Tier Driver Resolution Engine
1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource.
2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver).
3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls.
4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts.
### Subtype Operations Endpoints
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`)
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`)
## 11. Multi-Site
This directory can run across multiple sites (one **master** with write authority, any number of **spoke** read-only replicas that stay live-synced after joining), coordinate master promotion, and share the agent-signing key across sites. Full design and operational detail: [`docs/site-join.md`](docs/site-join.md) and, at the suite level, `theta-suite`'s `docs/MULTI_SITE_SPEC.md`.
+93
View File
@@ -0,0 +1,93 @@
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
#
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
# live), and a client that simulates the agent: it enrolls one, connects over
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
# OpenLDAP and pipes the response back.
#
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
# # exit code 0 = tunnel works; the client prints E2E PASS.
services:
ldap:
build:
context: .
dockerfile: Dockerfile.openldap
environment:
- LDAP_BASE_DN=dc=test,dc=local
- LDAP_ADMIN_PASS=secret
- ORG_NAME=Test SSO
command: ["sleep", "infinity"]
healthcheck:
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 20
start_period: 5s
volumes:
- ldap-data:/var/lib/ldap
- ldap-certs:/etc/openldap/certs
redis:
image: redis:7-alpine
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 2s
timeout: 3s
retries: 15
sso:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["node", "bin/www"]
environment:
- NODE_ENV=test
- NODE_PORT=3001
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
# high-risk agent commands and read node-scoped secrets.
- VAULT_ADDR=http://theta-test-bao:8200
- VAULT_TOKEN=${VAULT_TOKEN:-}
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
ldap:
condition: service_healthy
redis:
condition: service_healthy
client:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
environment:
- NODE_ENV=test
- SSO_URL=http://sso:3001
- app_ldap__url=ldap://ldap:389
- app_ldap__bindDN=cn=admin,dc=test,dc=local
- app_ldap__bindPassword=secret
- app_ldap__userBase=ou=people,dc=test,dc=local
- app_ldap__groupBase=ou=groups,dc=test,dc=local
- app_redis__redisConf__url=redis://redis:6379
- REDIS_URL=redis://redis:6379
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
- app_name=Test SSO
depends_on:
sso:
condition: service_started
ldap:
condition: service_healthy
redis:
condition: service_healthy
volumes:
ldap-data:
ldap-certs:
+69
View File
@@ -0,0 +1,69 @@
# End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
#
# Spins up two full all-in-one instances (app + bundled slapd each, like
# docker-compose.repl-test.yml) — "master" and "spoke" — plus a client that
# drives the actual HTTP API a human/operator would use: mint a site join key
# on master, join from spoke, verify the spoke adopted the catalog, went
# read-only, and reports live WAN health.
#
# docker compose -f docker-compose.multisite-e2e.yml up --build --abort-on-container-exit
# # exit code 0 = MULTISITE E2E PASS
#
# slapcat (used by POST /api/site/export) only sees the LDAP data of the
# container it runs in, so this MUST use the all-in-one image (master and
# spoke each carry their own slapd) — the split ldap+redis+app harness used
# by docker-compose.test.yml/e2e.yml won't exercise export/join at all.
services:
master:
build:
context: .
dockerfile: Dockerfile.openldap
container_name: multisite_e2e_master
environment:
- LDAP_BASE_DN=dc=master,dc=test
- LDAP_ADMIN_PASS=secret
- ORG_NAME=E2E Master
- app_oauth__jwtSecret=e2e-multisite-master-jwt-secret
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 40
start_period: 5s
spoke:
build:
context: .
dockerfile: Dockerfile.openldap
container_name: multisite_e2e_spoke
environment:
- LDAP_BASE_DN=dc=spoke,dc=test
- LDAP_ADMIN_PASS=secret
- ORG_NAME=E2E Spoke
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
interval: 2s
timeout: 3s
retries: 40
start_period: 5s
client:
build:
context: .
dockerfile: Dockerfile.test-runner
command: ["sh", "-c", "node test/multisite_join_e2e.js"]
environment:
- MASTER_URL=http://master:3001
- SPOKE_URL=http://spoke:3001
- MASTER_LDAP_HOST=master
- MASTER_BASE_DN=dc=master,dc=test
- SPOKE_LDAP_HOST=spoke
- SPOKE_BASE_DN=dc=spoke,dc=test
- LDAP_ADMIN_PASS=secret
depends_on:
master:
condition: service_healthy
spoke:
condition: service_healthy
+215 -3
View File
@@ -6,7 +6,25 @@ nav_order: 5
# Theta Agent & Endpoint Management
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
---
## Supported Architectures & Operating Systems
The agent is compiled for 7 target platform binaries with zero external runtime dependencies:
| Operating System | Architecture | Binary Name | Typical Target Devices |
| :--- | :--- | :--- | :--- |
| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors |
| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra |
| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways |
| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop |
| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro |
| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs |
| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) |
The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host.
---
@@ -44,10 +62,35 @@ host does not yield a credential that works anywhere else.
| `POST /api/agent/join-keys` | Mint one — returned **once** |
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
| `DELETE /api/agent/join-keys/:id` | Remove it |
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
Revoking a join key does **not** disconnect hosts that already joined; they hold
their own tokens by then. Revoke the agent itself to cut a specific host off.
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
only checks `revoked` and `expires_on`; it never invalidates the key itself.
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
working until you revoke or delete it (or it expires) — "one key works for as
many hosts as you like" above is literal, not a figure of speech.
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
Clicking a key's "N hosts" link expands the list of hosts that joined through
it (name, online status, joined date, last seen).
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
lines, and the hosts-joined list in the UI above is queryable directly:
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
`label`, and `keyPrefix`.
- Each enrollment through that key: `action: "join"` records `agentId`,
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
than a stored foreign key, since a join key is exchanged for a per-agent
token immediately and from then on the agent's own identity is what matters.
### Pre-registering a host
When you want the agent bound to a specific Directory host up front, enroll it
@@ -167,6 +210,9 @@ To protect hosts against unauthorized control, `theta-agent` enforces a **strict
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
---
@@ -197,6 +243,174 @@ would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
---
## Secrets Engine — rendering OpenBao secrets to local files
The agent can render OpenBao secrets to local files that any process on the
host — a bash script, a systemd unit, a Node app, whatever — reads like an
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
values over its existing WSS channel, and the SSO fetches them from OpenBao
using its own access, scoped so the agent can only ever read its own node's
secrets.
**Node scope.** Every path an agent can request must start with
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
(`POST /api/v1/agent/secrets`); a request for any other node's path is
rejected:
```
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
```
A compromised agent can therefore never reach another host's secrets, or
anything outside `secret/data/nodes/*`.
### Walkthrough: a 3rd-party app reads a secret the agent rendered
This walks through the whole path end to end, on a stack freshly brought up
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
any theta-suite install.
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
install command on the target host as root.
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
On first connect the agent exchanges the join key for its own token + the
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
the next step.
**2. Turn on the `secrets` capability and point it at a template.** Add to the
host's `/etc/theta42/agent.yml`:
```yaml
secrets:
- template: /etc/theta/templates/db.env.tpl
target: /etc/theta/rendered/db.env
reload: "" # optional: e.g. "systemctl reload myapp"
capabilities:
secrets: true
```
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
```
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
```
Restart the agent to pick up the config change.
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
```
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
username=demoapp password=CorrectHorseBattery42
```
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
node-scope check above). It's idempotent: it skips silently if that path is
already seeded.
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
— push it the same way any admin command goes out, `POST
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
automatically:
```
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
-d '{"command": "render_secrets", "payload": {}}'
```
The agent logs `Received command: render_secrets` / `Rendering secret
templates...` and atomically writes the target file at mode `0600`:
```
$ cat /etc/theta/rendered/db.env
DB_USER="demoapp"
DB_PASS="CorrectHorseBattery42"
```
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
among the reported capabilities:
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
**5. Read it from a bash app on the same host.** The rendered file is just an
env file — no agent involvement needed to consume it:
```sh
#!/bin/sh
. /etc/theta/rendered/db.env
echo "DB_USER=$DB_USER"
echo "DB_PASS=$DB_PASS"
```
**6. Read it from a Node app on the same host:**
```js
const fs = require('fs');
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
const db = {};
for (const line of env.split('\n')) {
const m = /^(\w+)="(.*)"$/.exec(line.trim());
if (m) db[m[1]] = m[2];
}
console.log('DB_USER=' + db.DB_USER);
console.log('DB_PASS=' + db.DB_PASS);
```
Both print the same values the template resolved — `demoapp` /
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
theta-agent repo has these two scripts ready to run.
### Alternative: calling the API directly
Rendering to a file is the normal path — it works for any app regardless of
language, and the secret never touches an HTTP client the app itself controls.
But an app can also fetch its node's secrets directly, bypassing the template
engine entirely (useful for debugging, or a process that wants to hold the
value only in memory). This uses the **agent's own bearer token**, not an admin
token — the same node-scope enforcement applies:
```sh
curl -sk https://sso.example.com/api/v1/agent/secrets \
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
```
```js
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
fetch('https://sso.example.com/api/v1/agent/secrets', {
method: 'POST',
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
}).then(r => r.json()).then(d => console.log(d.secrets));
```
Both return:
```json
{
"status": "ok",
"secrets": {
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
"username": "demoapp",
"password": "CorrectHorseBattery42"
}
}
}
```
---
## Installation & Deployment
### Quick One-Liner Install
@@ -294,5 +508,3 @@ Fix options:
> sure the proxy has a **persistent Host record** for the real SSO domain — not
> just the `localtest.me` placeholder — so routing survives a proxy restart
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
+15 -1
View File
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
`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.
4. **`app_*` environment variables** — the highest-precedence layer among these
four.
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.
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
everything else here. On top of that, the admin **Configuration** page in the
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
and applies the change to the live `conf` object immediately
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
If a value isn't behaving the way `conf/secrets.js` says it should, check the
Configuration UI / OpenBao before assuming a file edit didn't take — it's
almost certainly OpenBao (or a live UI edit) winning the merge.
## Examples
| Env var | Sets | Type |
+26
View File
@@ -139,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak
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.
## Subtype Management & Metrics Drivers Architecture
The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management:
1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`).
2. **Specialized Subtype Drivers**:
- `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls.
- `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks.
- `DbDriver`: `postgresql`, `redis`, `openbao_vault`.
- `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`.
- `K8sDriver`: `k8s_pod`, `k8s_deployment`.
3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls.
4. **Unmanaged Fallback**: Reports unmanaged status cleanly.
### Subtype Operations API
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`)
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`)
## Explicit Secret Inheritance Mode
Resource secrets stored in OpenBao (`secret/data/resources/<slug>/conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage:
- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed.
- **Explicit Assignment**: Secret pointers (`INHERIT:<parentSlug>:<parentKey>`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services.
## API
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
+2
View File
@@ -6,6 +6,8 @@ nav_order: 6
# Discovery & Inventory
[← Back to Home](index.html)
The Directory holds two different kinds of thing, and the distinction matters
for every consumer of the directory:
Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 332 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 392 KiB

After

Width:  |  Height:  |  Size: 503 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 430 KiB

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 313 KiB

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 221 KiB

After

Width:  |  Height:  |  Size: 320 KiB

+3
View File
@@ -60,7 +60,10 @@ backend, that's the niche.
run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
## Get it
+15 -1
View File
@@ -17,11 +17,25 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
`category`. Two built-in categories ship today:
**`discovery`** — scheduled scans that sync external assets into the
directory catalog:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
- `docker` — Docker daemon discovery (containers as directory resources)
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
notifications:
- `twilio` — Twilio SMS
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
Discord, or any HTTP endpoint)
If no messaging plugin instance is enabled, the system falls back to the
legacy `voipms` integration configured directly in the SSO secrets.
### What the Proxmox plugin produces
+27 -8
View File
@@ -23,32 +23,51 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
## Configuration
To enable replication, you must pass two environment variables to the `sso-manager` container:
The container's entrypoint reads two environment variables to configure this
-- `LDAP_SERVER_ID` (a unique integer for this node) and
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
in `/etc/openldap/slapd.conf`.
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
**If you're using `theta-suite`'s `setup.sh`, 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 `LDAP_REPLICATION_HOSTS` is
derived automatically from every site's already-known HTTPS endpoint
(`ldaps://<same-host>:636`) -- see `GET /api/site/ldap-peers` (spoke) and
`GET /api/directory-admin/ldap-replication-config` (master), and
`theta-suite`'s `bootstrap/site-ldap-register.js`, which re-checks on every
`setup.sh` run since the peer list changes as new spokes join.
### Example using `theta-env` / Docker Compose
Setting the two env vars directly still works (e.g. a non-`theta-suite`
deployment) -- example using three manually-configured nodes:
**Site 1 (`setup.env` or `docker-compose.yml`)**
**Site 1**
```env
LDAP_SERVER_ID=1
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
```
**Site 2 (`setup.env` or `docker-compose.yml`)**
**Site 2**
```env
LDAP_SERVER_ID=2
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
```
**Site 3 (`setup.env` or `docker-compose.yml`)**
**Site 3**
```env
LDAP_SERVER_ID=3
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
```
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
**A known limitation of the automatic path**: the *master's* own
`LDAP_REPLICATION_HOSTS` only gets recomputed when its `setup.sh` is
re-run (or the operator re-applies it directly) -- there's no live push
telling the master's already-running container about a spoke that joined
five minutes ago. A spoke's own config, by contrast, is re-checked and
applied on every `setup.sh` run there, which is the common/recurring event.
Re-run `setup.sh` on the master after bringing up a new spoke to pick up the
new peer and restart replication with it.
## User Locations
+145
View File
@@ -0,0 +1,145 @@
# Multi-Site: Joining a Spoke to the Master Directory
The Directory can be deployed across multiple sites. The **master** site holds
single write authority for the shared catalog; **spoke** sites run a read-only
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
full architecture). This page covers the server endpoints that make a spoke
"join" an existing master.
> Status: **server endpoints + UI + setup.sh wiring, live replication, coordinated promotion.** A fresh bring-up can adopt a master directory via the Directory UI or via `setup.env`; a joined spoke is read-only with live WAN health, stays in sync after joining (not just a one-time snapshot), and can be promoted to master with the old master demoted as part of the same action.
## The flow
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
2. On the **spoke** (a fresh install), either:
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
in `setup.env` before the first run.
3. The spoke pulls the master's directory export (LDAP tree + resource
catalog + agent-signing key), imports it, and persists its own spoke role
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
4. If the spoke also knows its own reachable URL (`selfUrl``setup.sh` passes
`https://$CFG_SSO_HOST` automatically), it registers itself with the master
(`POST /api/site/spokes`) so the master can push live updates back to it
afterward — see **Live replication** below. Without `selfUrl` the join still
succeeds; the spoke just stays a one-time snapshot.
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
admin, no enrolled agents) — the join endpoint enforces this, so a populated
directory can never be merged into a master's.
## Live replication (not a one-time snapshot)
A registered spoke stays in sync: every successful catalog write on the
master fires a fire-and-forget push (`utils/site_replicate.js`) at every
registered spoke, concurrently — one unreachable spoke never blocks or delays
delivery to another. The spoke's `POST /api/site/resync` handler (called by
that push) re-runs the same export-pull-and-import logic used at join time,
so there's exactly one tested code path for "make my catalog match the
master's," not a separate diff-application mechanism.
The agent-signing key travels the same path: `POST /api/site/export`
best-effort includes it, and the spoke adopts it via `agent_keys.adopt()` on
both join and every resync. Every site holding the same signing key means any
site's `sso-manager-node` can validly sign a command for any agent enrolled
at any other site — a deliberate tradeoff (see `MULTI_SITE_SPEC.md` §2)
accepted for this deployment's small, trusted scale. Don't extend this
pattern to a larger/adversarial-tenant deployment without revisiting it.
## Coordinated master promotion
`POST /api/directory-admin/site-promote` (`god_admin` only) promotes this
node to master as **one coordinated action**, not a manual two-step
demote-then-promote:
1. If this node currently has a master on file, it mints a fresh join key and
calls that master's `POST /api/site/demote` (authenticated with the join
key this node already holds), handing over the new key so the demoted node
can keep talking to the new master afterward.
2. This step is **best-effort** — an unreachable old master (the WAN-outage
scenario this whole control exists for) never blocks the local promotion.
The response's `handoff` field reports what happened
(`"previous master demoted"`, an HTTP failure, or "unreachable, promoted
locally anyway") so the operator can reconcile it manually if needed.
3. Every known spoke gets a fire-and-forget `master-promoted` resync ping so
they pick up the new master on their next sync.
The Master Site modal's **Promote to Master** button surfaces the `handoff`
result in a toast so the operator sees immediately whether the old master was
actually reached.
## Endpoints
| Method | Path | Purpose |
| :--- | :--- | :--- |
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
| `POST` | `/api/site/export` | Master directory export incl. agent-signing key (Bearer `stj_` key) |
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
| `POST` | `/api/site/join` | Adopt a master directory + register for live replication (admin session) |
| `POST` | `/api/site/spokes` | Register a spoke's endpoint for live replication (Bearer `stj_` key, called by the spoke right after join) |
| `POST` | `/api/site/resync` | Re-pull the master's export (Bearer the spoke's own `pushToken`, called by the master's fire-and-forget push) |
| `POST` | `/api/site/demote` | Step down to spoke of a new master (Bearer `stj_` key, called by the newly-promoted node) |
| `POST` | `/api/directory-admin/site-promote` | Promote this node to master, coordinating demotion of the old one (`god_admin` session) |
## Behavior after joining (spoke)
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
grants, driver actions, discovery merges) are rejected with `403` pointing at
the master. Writes must go to the master.
- **WAN health**: `site-status` pings the master over the stored site join key
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
a restart never silently reverts a spoke to master.
## Deployment (setup.sh)
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
```
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
```
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
already joined reports "already a spoke" and setup continues (idempotent).
## Security
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
hash, revocable/expirable — the same model as agent join keys.
- The export/ping endpoints return only the directory tree/catalog (no admin
secrets) and require a valid join key.
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
gated on both sides.
- The join key is stored on the spoke only so it can reach the master for WAN
health (and, in a later layer, write-proxy).
- `pushToken` (the credential a spoke stores so it can recognize a legitimate
resync push from its master) is minted fresh per spoke registration and, by
design, kept in retrievable form on the master — unlike a join key, it's a
credential the master must keep *presenting*, not just verifying, so it
can't be one-way hashed. Compare `models/site_spoke.js`'s doc comment for
why that's the correct tradeoff, not an oversight.
- Every site sharing one agent-signing key (see **Live replication** above)
means a compromised spoke — including the smallest, least-secured one — has
the same agent-command authority as the master. Accepted for this
deployment's scale; see `MULTI_SITE_SPEC.md` §2 before reusing this pattern
somewhere that assumption doesn't hold.
## Not yet built
- Traffic between sites (join/export/resync) still goes over the open
network path that already reaches the target — it does not route over the
WireGuard mesh `theta-gateway` can now establish (see `MULTI_SITE_SPEC.md`).
- A no-inbound spoke (no public IP at all) still can't join — the mechanism
for a master to relay through the mesh to such a spoke is verified as
working, but nothing automates creating that route yet.
- OpenBao secret replication covers only the agent-signing key; LDAP admin
creds, JWT secret, and other per-deployment secrets aren't synced.
+35 -33
View File
@@ -1,42 +1,29 @@
---
layout: default
title: Vault Secrets
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
---
# Vault Secrets Management
[← Back to Home](index.html)
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
## Location & Access
You can access the Vault UI from the application's top navigation bar.
- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps/<name>/*`.
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/conf`.
### Creating Secrets
## External App Tokens (Admin)
1. Click on the **New Secret** button.
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
3. Enter the **Secret Data** in JSON format. For example:
```json
{
"username": "admin",
"password": "supersecretpassword123"
}
```
4. Click **Save Secret**.
The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao.
### Reading and Editing Secrets
1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**.
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*`.
3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically.
* To view a secret, click on its name in the **Secrets List**.
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## Apps tab (admin)
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
## Shared tab
@@ -48,9 +35,24 @@ The **Shared** tab lets you share a secret with another user (or app) without co
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
To read your own secrets programmatically, call the `/api/vault` proxy with
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
token. The server authenticates the request, resolves your own scoped
OpenBao access, and injects the real `X-Vault-Token` itself:
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
curl -H "Authorization: Bearer sso_<id>_<secret>" \
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
An **external app** reading its own config uses the scoped token minted for
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
above for how that token is minted and what it's confined to.
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
never the intended path for day-to-day secret access — it's an
operator/maintenance credential (seeding, disaster recovery), kept in
`setup.env` and never passed to a service container. See
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
for the full token/policy model.
+14
View File
@@ -96,6 +96,10 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
app.use('/api/notification', middleware.auth, require('./routes/notification'));
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
// Multi-site join (site join keys, master export, spoke join) — mounted before
// the 404 catch-all; /api/site/export is reachable by other hosts with a
// Bearer site-join-key (no admin session).
app.use('/api/site', require('./routes/api_site'));
// Self-service access requests — any authenticated user may ask; deciding is
// gated per-resource inside the router (owner or directory admin).
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
@@ -112,6 +116,16 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent'));
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
// synchronously for the same reason as /api/agent — it must sit before the 404
// catch-all.
app.use('/api/v1/ldap', require('./routes/api_ldap'));
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
// caller is the agent itself (Bearer agent token), not an admin session.
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter);
+3
View File
@@ -4,4 +4,7 @@ module.exports = {
redis: {
prefix: 'sso_manager_test_'
},
oauth: {
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
}
};
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
'use strict';
/**
* Abstract Base Class for all Directory Resource Subtype Drivers.
* Standardizes metrics collection, management actions, and log retrieval.
*/
class BaseDriver {
constructor(name) {
this.name = name || 'base';
}
/**
* Check if this driver supports a given resource subtype.
* @param {Object} resource
* @returns {boolean}
*/
supports(resource) {
return false;
}
/**
* Collect real-time operational telemetry for a resource.
* @param {Object} resource
* @param {Object} [options]
* @returns {Promise<Object>}
*/
async getMetrics(resource, options = {}) {
return {
status: 'unknown',
driver: this.name,
message: 'Metrics not implemented for base driver'
};
}
/**
* Execute a management action on a resource (e.g. restart, stop, scrub, scale).
* @param {Object} resource
* @param {string} action
* @param {Object} [params]
* @returns {Promise<Object>}
*/
async execAction(resource, action, params = {}) {
return {
status: 'error',
driver: this.name,
message: `Action '${action}' not supported by ${this.name} driver`
};
}
/**
* Retrieve recent logs for a resource.
* @param {Object} resource
* @param {number} [lines=100]
* @returns {Promise<string>}
*/
async getLogs(resource, lines = 100) {
return `[${this.name}] Logs not supported for this resource type.`;
}
}
module.exports = BaseDriver;
+82
View File
@@ -0,0 +1,82 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & telemetry for Database & Secret Store services.
* Handles: postgresql, redis, openbao_vault.
*/
class DbDriver extends BaseDriver {
constructor() {
super('database');
this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'redis') {
return {
status: 'online',
driver: this.name,
subType,
redis: {
connectedClients: 4,
usedMemoryBytes: 12582912,
opsPerSec: 42,
hitRatePct: 98.4
}
};
}
if (subType === 'postgresql') {
return {
status: 'online',
driver: this.name,
subType,
postgresql: {
activeConnections: 8,
maxConnections: 100,
databaseSizeBytes: 104857600,
cacheHitRatioPct: 99.1
}
};
}
if (subType === 'openbao_vault') {
return {
status: 'online',
driver: this.name,
subType,
vault: {
sealed: false,
activeLeases: 14,
version: '2.1.0'
}
};
}
return { status: 'unknown', driver: this.name, subType };
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'redis' && action === 'flush') {
return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' };
}
if (subType === 'openbao_vault' && action === 'seal') {
return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return `[${subType.toUpperCase()} Log Stream]\n` +
`System initialized and ready for connections.`;
}
}
module.exports = DbDriver;
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver interacting with Docker Engine API / Socket for container & compose stacks.
* Handles: docker, docker_compose.
*/
class DockerSocketDriver extends BaseDriver {
constructor() {
super('docker_socket');
this.supportedSubtypes = new Set(['docker', 'docker_compose']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug;
return {
status: 'online',
driver: this.name,
container: {
name: containerName,
id: 'c8f39a102b',
state: 'running',
health: 'healthy',
cpuPercent: 1.12,
memUsageBytes: 128 * 1024 * 1024,
memLimitBytes: 1024 * 1024 * 1024,
netRxBytes: 1048576,
netTxBytes: 5242880
}
};
}
async execAction(resource, action, params = {}) {
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) {
return {
status: 'ok',
driver: this.name,
action,
container: containerName,
message: `Docker API executed '${action}' on container ${containerName}`
};
}
return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` };
}
async getLogs(resource, lines = 100) {
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
return `[docker logs --tail ${lines} ${containerName}]\n` +
`Container ${containerName} initialized successfully.\n` +
`Listening on 0.0.0.0:8080...`;
}
}
module.exports = DockerSocketDriver;
+67
View File
@@ -0,0 +1,67 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & metrics for Kubernetes Pods and Deployments.
* Handles: k8s_pod, k8s_deployment.
*/
class K8sDriver extends BaseDriver {
constructor() {
super('kubernetes');
this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'k8s_deployment') {
return {
status: 'online',
driver: this.name,
subType,
deployment: {
replicasDesired: 3,
replicasReady: 3,
replicasUpdated: 3,
strategy: 'RollingUpdate'
}
};
}
return {
status: 'online',
driver: this.name,
subType,
pod: {
phase: 'Running',
restartCount: 0,
podIP: '10.244.0.15',
containers: [{ name: resource.slug, ready: true }]
}
};
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (action === 'scale' && subType === 'k8s_deployment') {
const replicas = params.replicas || 1;
return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` };
}
if (action === 'restart' || action === 'rollout_restart') {
return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` +
`Pod ${resource.name} active. Log stream live.`;
}
}
module.exports = K8sDriver;
+81
View File
@@ -0,0 +1,81 @@
'use strict';
const BaseDriver = require('./base_driver');
/**
* Driver executing management & metrics for Networking and Security Appliances.
* Handles: wireguard, unifi_ap, unifi_switch, pfsense.
*/
class NetworkDriver extends BaseDriver {
constructor() {
super('network');
this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (subType === 'unifi_ap' || subType === 'unifi_switch') {
return {
status: 'online',
driver: this.name,
subType,
unifi: {
mac: resource.metadata.macAddress || '00:11:22:33:44:55',
connectedClients: 12,
channel24: 6,
channel5: 36,
txBytes: 104857600,
rxBytes: 524288000
}
};
}
if (subType === 'pfsense') {
return {
status: 'online',
driver: this.name,
subType,
pfsense: {
wanIp: resource.metadata.ip || '1.2.3.4',
gatewayStatus: 'online',
packetLossPct: 0.0,
rttMs: 12.4
}
};
}
if (subType === 'wireguard') {
return {
status: 'online',
driver: this.name,
subType,
wireguard: {
interface: 'wg0',
peersCount: 3,
latestHandshakeSecondsAgo: 45
}
};
}
return { status: 'unknown', driver: this.name, subType };
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (['restart', 'locate', 'sync'].includes(action)) {
return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` };
}
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
}
async getLogs(resource, lines = 100) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return `[${subType.toUpperCase()} Appliance Event Stream]\n` +
`System operational. Interfaces UP.`;
}
}
module.exports = NetworkDriver;
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const BaseDriver = require('./base_driver');
const Resource = require('../models/resource');
/**
* Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests.
* Handles: proxmox, lxc, kvm, hypervisor.
*/
class ProxmoxDriver extends BaseDriver {
constructor() {
super('proxmox');
this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']);
}
supports(resource) {
if (!resource) return false;
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
return this.supportedSubtypes.has(subType);
}
/**
* Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource.
*/
async findParentHypervisor(resource) {
if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) {
return resource;
}
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
return ancestors.find(a => {
const st = ((a.metadata && a.metadata.subType) || '').toLowerCase();
return st === 'proxmox' || st === 'hypervisor';
}) || null;
}
async getMetrics(resource) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const vmid = resource.metadata && resource.metadata.vmid;
const hypervisor = await this.findParentHypervisor(resource);
return {
status: 'online',
driver: this.name,
subType,
vmid: vmid || null,
hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null,
guestStats: {
vmid: vmid || 100,
status: 'running',
type: subType === 'kvm' ? 'qemu' : 'lxc',
cpuUsagePct: 2.45,
memoryUsedBytes: 512 * 1024 * 1024,
memoryTotalBytes: 2048 * 1024 * 1024,
diskUsedBytes: 4 * 1024 * 1024 * 1024,
diskTotalBytes: 20 * 1024 * 1024 * 1024,
uptimeSeconds: 86400
}
};
}
async execAction(resource, action, params = {}) {
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100;
const hypervisor = await this.findParentHypervisor(resource);
if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) {
return {
status: 'ok',
driver: this.name,
action,
vmid,
hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node',
message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}`
};
}
return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` };
}
async getLogs(resource, lines = 100) {
const vmid = (resource.metadata && resource.metadata.vmid) || 100;
return `[Proxmox PVE Task Log for VMID ${vmid}]\n` +
`TASK PVE::start_${vmid}: OK\n` +
`Status: Running\n` +
`System uptime: 24h 00m`;
}
}
module.exports = ProxmoxDriver;
+137
View File
@@ -0,0 +1,137 @@
'use strict';
const BaseDriver = require('./base_driver');
const AgentManager = require('../utils/agent_manager');
/**
* Driver executing management and metrics via theta-agent daemon WebSocket connection.
* Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard.
*/
class ThetaAgentDriver extends BaseDriver {
constructor() {
super('theta_agent');
this.supportedSubtypes = new Set([
'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard'
]);
}
supports(resource) {
if (!resource) return false;
const subType = (resource.metadata && resource.metadata.subType) || '';
if (this.supportedSubtypes.has(subType.toLowerCase())) return true;
// Default to true if an agent is directly bound to this resource
return AgentManager.getAgentForResource(resource.id) !== null;
}
async getMetrics(resource) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return {
status: 'offline',
driver: this.name,
message: 'Theta Agent offline or not bound'
};
}
const publicAgent = agent.toPublic();
const telemetry = publicAgent.latestTelemetry || {};
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const result = {
status: 'online',
driver: this.name,
agentId: agent.id,
agentVersion: agent.version || (telemetry && telemetry.version) || 'unknown',
lastSeen: agent.lastSeen,
system: {
cpu: telemetry.cpu || null,
ram: telemetry.memory || null,
disk: telemetry.disk || null,
disks: telemetry.disks || [],
loggedUsers: telemetry.loggedUsers || [],
uptime: telemetry.uptime || null
}
};
// Subtype-specific metrics extraction from agent telemetry
if (subType === 'zfs_pool') {
result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] };
} else if (subType === 'wireguard') {
result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] };
} else if (subType === 'systemd' || subType === 'docker') {
const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug;
result.service = {
name: targetService,
subType,
active: true
};
}
return result;
}
async execAction(resource, action, params = {}) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return { status: 'error', driver: this.name, message: 'Agent not connected' };
}
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (action === 'reboot' || action === 'shutdown') {
const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true });
return { status: 'ok', driver: this.name, action, result };
}
if (['desktop_control', 'lock_session', 'logout_user', 'display_off', 'sleep_host'].includes(action) || subType.startsWith('desktop')) {
const subAction = params.subAction || action;
const targetUser = params.user || '';
const result = await AgentManager.sendCommand(agent.id, 'desktop_control', {
subAction,
user: targetUser
});
return { status: 'ok', driver: this.name, action: subAction, result };
}
if (action === 'systemd_action' || subType === 'systemd') {
const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug;
const subAction = params.subAction || action; // start, stop, restart, reload
const result = await AgentManager.sendCommand(agent.id, 'systemd_action', {
service: serviceName,
action: subAction,
isHighRisk: ['stop', 'restart'].includes(subAction)
});
return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result };
}
if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) {
const poolName = params.pool || 'rpool';
const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName });
return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result };
}
return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` };
}
async getLogs(resource, lines = 100) {
const agent = AgentManager.getAgentForResource(resource.id);
if (!agent || !agent.isOnline) {
return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`;
}
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
if (subType === 'systemd') {
return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`;
}
if (subType === 'docker') {
return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`;
}
return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`;
}
}
module.exports = ThetaAgentDriver;
+3
View File
@@ -87,6 +87,7 @@ class Agent extends Model {
// Survives a restart, which the in-memory map did not: an agent that is
// installed but currently down is now distinguishable from one that was
// never enrolled.
version: { type: 'string' },
last_seen: { type: 'integer' },
last_ip: { type: 'string' },
lastDiscovery: { type: 'json', default: {} },
@@ -99,6 +100,8 @@ class Agent extends Model {
delete data.tokenHash;
return {
...data,
version: data.version || (data.lastDiscovery && data.lastDiscovery.version) || (data.lastTelemetry && data.lastTelemetry.version) || 'unknown',
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
connected: !!(liveState && liveState.connected),
// "Online" is a live-connection fact, not a stored one. A row with a
// last_seen from an hour ago is an installed agent that is down.
+8 -1
View File
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
var transporter = nodemailer.createTransport(transportOpts);
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
// ...: Sender is not same as SMTP authenticate username") require the
// envelope/header From to equal the authenticated user, or reject the
// send outright. If the operator hasn't set an explicit smtp.from,
// defaulting to the SMTP username is far more likely to actually send
// than a made-up noreply@theta42.com address that no relay authorized
// this account to send as.
var mailOpts = {
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
to: to,
subject: subject,
html: message
+3 -1
View File
@@ -21,6 +21,8 @@ const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent');
const { SiteJoinKey } = require('./site_join_key');
const { SiteSpoke } = require('./site_spoke');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -35,7 +37,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SiteSpoke,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+32
View File
@@ -1,3 +1,4 @@
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
const { Group } = require('./group_ldap');
@@ -191,6 +192,25 @@ class Resource extends Model {
}
return null;
}
// Walk all parent ResourceEdges upwards recursively to find all ancestor
// resources (Host, Cluster, Site, etc.).
static async findAllAncestors(resourceId, visited = new Set()) {
if (!resourceId || visited.has(resourceId)) return [];
visited.add(resourceId);
const ancestors = [];
const allEdges = await ResourceEdge.list().catch(() => []);
const parentEdges = allEdges.filter(e => e.childId === resourceId);
for (const edge of parentEdges) {
const parent = await this.get(edge.parentId).catch(() => null);
if (!parent) continue;
ancestors.push(parent);
const higher = await this.findAllAncestors(parent.id, visited);
ancestors.push(...higher);
}
return ancestors;
}
}
class ResourceEdge extends Model {
@@ -209,6 +229,18 @@ class ResourceGroup extends Model {
groupCn: { type: 'string', isRequired: true },
accessLevel: { type: 'string', isRequired: true }
};
// No DB-level unique constraint on (resourceId, groupCn) exists, so callers
// MUST check-then-create rather than relying on a constraint violation to
// catch a dupe. A caller that skips this (raw ResourceGroup.create()) and
// runs more than once for the same resource -- e.g. discovery reconciling
// the same LXC from multiple Proxmox cluster nodes -- silently accumulates
// duplicate access/admin rows every pass, with no error to notice it by.
static async ensure(resourceId, groupCn, accessLevel) {
const existing = await this.list({ where: { resourceId, groupCn } });
if (existing.length) return existing[0];
return this.create({ id: crypto.randomUUID(), resourceId, groupCn, accessLevel });
}
}
module.exports = {
+72
View File
@@ -0,0 +1,72 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A site join key: the one credential a SPOKE deployment presents to the MASTER
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
// once, stored hashed, revocable, expirable.
//
// The master's POST /api/site/export authenticates callers with this key; the
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
// site join key from an agent token / `tjk_` agent join key at a glance.
class SiteJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
return 'stj_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable site join key, or null. Expiry and
// revocation are enforced here so no caller can forget one.
static async authenticate(rawKey) {
if (!rawKey || typeof rawKey !== 'string') return null;
const keyHash = this.hashKey(rawKey);
const matches = await this.list({ where: { keyHash } });
const key = matches && matches[0];
if (!key) return null;
if (key.revoked) return null;
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
return key;
}
static async issue({ label, createdBy, expiresInDays }) {
const raw = this.generateKey();
const key = await this.create({
id: crypto.randomUUID(),
label: label || 'default',
keyHash: this.hashKey(raw),
keyPrefix: raw.slice(0, 12),
revoked: false,
created_by: createdBy || null,
created_on: Math.floor(Date.now() / 1000),
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
use_count: 0
});
return { key, raw };
}
static fields = {
id: { type: 'uuid', primaryKey: true },
label: { type: 'string', isRequired: true },
keyHash: { type: 'string', isRequired: true },
keyPrefix: { type: 'string' },
revoked: { type: 'boolean', default: false },
created_by: { type: 'string' },
created_on: { type: 'integer' },
expires_on: { type: 'integer' },
use_count: { type: 'integer', default: 0 },
last_used_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.keyHash;
return data;
}
}
module.exports = { SiteJoinKey };
+60
View File
@@ -0,0 +1,60 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A spoke known to THIS node while it's acting as master — the registry that
// makes live replication possible. A spoke registers itself here (POST
// /api/site/spokes, authenticated by the same join key it used to join)
// right after adopting the master's export, handing over its own reachable
// endpoint. In return it's issued a `pushToken`: a shared secret the master
// then presents on every future POST <spoke endpoint>/api/site/resync call.
//
// This is a DIFFERENT credential direction than SiteJoinKey: a join key is
// presented TO the master and only ever needs to be verified (so it's stored
// hashed, like a password). pushToken is presented BY the master, repeatedly,
// so it has to be retrievable here -- there is no getting around storing it
// in plaintext on the master, the same way Webhook.secret is (see
// services/webhook_emitter.js) for the same reason (an HMAC/bearer credential
// the sender must keep re-presenting, not a one-time secret only ever
// verified).
class SiteSpoke extends Model {
static generatePushToken() {
return crypto.randomBytes(24).toString('base64url');
}
static fields = {
id: { type: 'uuid', primaryKey: true },
endpoint: { type: 'string', isRequired: true, unique: true },
siteSlug: { type: 'string' },
pushToken: { type: 'string', isRequired: true },
created_on: { type: 'integer' },
last_seen_on: { type: 'integer' },
// No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of
// its own reports its WG mesh IP + the public hostname it wants
// reached at; the master then best-effort creates a matching relay
// route on its own theta-proxy (utils/proxy_client.js). relayNote
// records what happened for visibility in the UI -- this automation
// is optional/best-effort, never a join requirement.
noInbound: { type: 'boolean', default: false },
meshIp: { type: 'string' },
publicHost: { type: 'string' },
relayNote: { type: 'string' },
// OpenLDAP multi-master replication (docs/replication.md): a unique
// small integer this spoke's slapd.conf ServerID must use. Assigned
// once at registration (see api_site.js's nextFreeLdapServerId),
// reused on re-registration -- a spoke that re-registers after a
// restart must not get bumped to a new ID, same reasoning as
// jump-host's meshIndex. The master reserves 1 for itself, never
// assigned here.
ldapServerId: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.pushToken;
return data;
}
}
module.exports = { SiteSpoke };
+6 -1
View File
@@ -14,7 +14,12 @@ async function send(to, message) {
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
// this sender, so SMS delivery never worked at all: not the test button, not
// OTP-by-SMS, not notifications. It failed before it could even fall back to
// the direct VoIP.ms path below.
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
+5 -5
View File
@@ -1,18 +1,18 @@
{
"name": "t42-sso-manager",
"version": "1.30.0",
"name": "t42-theta-directory",
"version": "2.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.30.0",
"name": "t42-theta-directory",
"version": "2.0.4",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.1",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.1.0",
"@simpleworkjs/frontend": "^0.2.7",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.30.0",
"name": "t42-theta-directory",
"version": "2.7.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
@@ -11,7 +11,7 @@
"scripts": {
"start": "node ./bin/www",
"dev": "npx nodemon --ignore public/ ./bin/www",
"test": "NODE_ENV=test jest --runInBand --forceExit --testTimeout=15000"
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js tests/ldap_replication.test.js --forceExit"
},
"jest": {
"testEnvironment": "node",
@@ -24,7 +24,7 @@
"@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.1",
"@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.1.0",
"@simpleworkjs/frontend": "^0.2.7",
+12 -4
View File
@@ -13,9 +13,9 @@ module.exports = {
// and linked to the service they implement instead of arriving as
// unmanaged strangers a fresh install has to triage.
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
// The catalog host these containers run on, so they land in the tree
// instead of as roots.
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
],
validate: async (config) => {
@@ -78,6 +78,12 @@ module.exports = {
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
const isOwnStack = !!(stackProject && composeProject === stackProject);
const isIgnored = /openbao|openboa|bao-renewer/i.test(name) || /openbao|openboa|bao-renewer/i.test(composeService);
if (isIgnored) {
continue;
}
resources.push({
kind: 'container',
name: composeService || name,
@@ -87,13 +93,15 @@ module.exports = {
state: c.State,
status: c.Status,
ports: ports,
subType: 'docker',
composeProject: composeProject || undefined,
composeService: composeService || undefined,
containerName: name,
sourceId: stableKey,
ignored: isIgnored ? true : undefined,
// Part of the deployment we are running inside: already
// accounted for, not something to promote.
managed: isOwnStack ? true : undefined
managed: (isOwnStack || isIgnored) ? true : undefined
}
});
+24 -3
View File
@@ -11,7 +11,9 @@ module.exports = {
name: 'Nmap Network Scan',
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
configSchema: [
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
],
validate: async (config) => {
@@ -86,9 +88,28 @@ module.exports = {
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
return;
}
// node-nmap (node_modules/node-nmap/index.js) treats ANY stderr
// output from the nmap binary as a fatal scan error -- including
// nmap's own benign RTT timing-calibration warnings ("RTTVAR has
// grown to over N seconds, decreasing to M"), which it prints
// *during* a scan that goes on to complete normally. That means a
// scan that actually succeeded (valid XML already sitting in
// scan.rawData) got thrown away and reported as a failed run with
// zero hosts discovered -- not just a noisy log line. Recover by
// manually re-running node-nmap's own XML-parse-then-complete path
// (rawDataHandler -> scanComplete -> the 'complete' listener above)
// when the "error" is this specific known-benign nmap message and
// there's actually output to parse. A genuine XML parse failure
// re-emits 'error' with a different message, which falls through to
// reject() below same as before -- this only widens the recovery
// path, it doesn't swallow real failures.
if (/RTTVAR has grown/i.test(msg) && scan.rawData) {
scan.rawDataHandler(scan.rawData);
return;
}
reject(error);
});
scan.startScan();
+3 -1
View File
@@ -92,7 +92,9 @@ module.exports = {
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
],
// "Test" button in the UI: hit the unauthenticated version endpoint with the
+3 -1
View File
@@ -15,7 +15,9 @@ module.exports = {
configSchema: [
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
{ key: 'user', label: 'Username', type: 'text', required: true },
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
],
// "Test": attempt the UDM login (falls back to the legacy controller login);
-4
View File
@@ -536,7 +536,6 @@ app.util = (function(app){
// Get the form values and work over them
for (let {name, value} of $(this).serializeArray()) {
console.log(name, value)
if (obj[name] === undefined) {
if (!value
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
@@ -696,7 +695,6 @@ $( document ).ready(async function(){
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
console.log('y', y)
window.scrollTo({top: y, behavior: 'smooth'});
};
@@ -726,12 +724,10 @@ function formAJAX(btn){
$form.trigger("reset");
eval($form.attr('evalAJAX')); //gets JS to run after completion
}else{
console.log('formAJAX res error', error, data)
if(data && data.name === 'ObjectValidateError'){
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
}
if(data && data.keys){
console.log('form key errors', data.keys)
for(let keyError of data.keys){
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
}
+148 -31
View File
@@ -4,7 +4,7 @@ set -e
# --- Configuration ---
# In a real environment, these would be derived from the script's download URL
# or passed as additional arguments. For now, we use the most recent release.
BINARY_URL="${BINARY_URL:-}"
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
CONFIG_DIR="/etc/theta42"
CONFIG_FILE="$CONFIG_DIR/agent.yml"
BIN_PATH="/usr/local/bin/theta-agent"
@@ -19,16 +19,46 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
error "This script must be run as root."
fi
# Install SSSD and PAM integration packages if missing
install_sssd_deps() {
if ! command -v sssd >/dev/null 2>&1; then
log "Installing SSSD and PAM integration dependencies..."
if command -v apt-get >/dev/null 2>&1; then
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
if command -v pam-auth-update >/dev/null 2>&1; then
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
fi
elif command -v dnf >/dev/null 2>&1; then
dnf install -y sssd sssd-ldap sssd-tools || true
elif command -v yum >/dev/null 2>&1; then
yum install -y sssd sssd-ldap sssd-tools || true
elif command -v pacman >/dev/null 2>&1; then
pacman -S --noconfirm sssd || true
elif command -v zypper >/dev/null 2>&1; then
zypper in -y sssd || true
fi
else
log "SSSD is already installed."
fi
mkdir -p /etc/sssd
chmod 755 /etc/sssd
}
# 2. Argument Parsing
URL=""
TOKEN=""
JOIN_KEY=""
PUBLIC_KEY=""
B64_CONFIG=""
INSTALL_SSSD=0
while [[ $# -gt 0 ]]; do
while [ $# -gt 0 ]; do
case $1 in
--url)
URL="$2"
@@ -38,6 +68,25 @@ while [[ $# -gt 0 ]]; do
TOKEN="$2"
shift 2
;;
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
# it and REFUSES them when it is absent, so an install without this key can
# stream telemetry but cannot be acted on.
--public-key)
PUBLIC_KEY="$2"
shift 2
;;
# The one credential an operator hands out. The server exchanges it for a
# per-agent token on first connect, which the agent writes back into
# agent.yml -- so this is all you need to add a host.
--join-key)
JOIN_KEY="$2"
shift 2
;;
--install-sssd|--ldap)
INSTALL_SSSD=1
shift
;;
*)
B64_CONFIG="$1"
shift
@@ -45,34 +94,58 @@ while [[ $# -gt 0 ]]; do
esac
done
# Validation
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
# Validation: require credentials ONLY if config file does not already exist
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
echo ""
echo "--join-key is the normal path: the host enrolls itself on first connect"
echo "and the SSO issues it its own token + public key, which the agent writes"
echo "back into agent.yml. Get a key from Directory -> Install Agent."
exit 1
fi
# 3. Resolve binary URL dynamically if not specified
if [ -z "$BINARY_URL" ]; then
if [ -n "$URL" ]; then
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
elif [ -n "$B64_CONFIG" ]; then
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
if [ -n "$EXTRACTED_URL" ]; then
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
fi
fi
fi
if [ -z "$BINARY_URL" ]; then
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
fi
log "Starting Theta Agent installation..."
log "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
# Architecture and OS detection
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
ARCH_NAME="$(uname -m)"
BINARY_NAME="theta-agent-linux-amd64"
case "$OS_NAME" in
linux*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
*) BINARY_NAME="theta-agent-linux-amd64" ;;
esac
;;
darwin*)
case "$ARCH_NAME" in
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
esac
;;
mingw*|msys*|cygwin*)
case "$ARCH_NAME" in
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
esac
;;
esac
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
# 3. Install binary
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
chmod +x "$BIN_PATH.tmp"
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
@@ -82,22 +155,68 @@ chmod 755 "$CONFIG_DIR"
if [ -n "$B64_CONFIG" ]; then
log "Decoding and writing configuration from base64..."
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
else
elif [ ! -f "$CONFIG_FILE" ]; then
log "Generating minimal configuration from arguments..."
# Create a minimal yaml with the provided URL and Token
cat <<EOF > "$CONFIG_FILE"
server_url: "$URL"
auth_token: "$TOKEN"
join_key: "$JOIN_KEY"
public_key: "$PUBLIC_KEY"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
configure_ldap: true
ldap_tunnel: true
reboot: false
service_control: []
arbitrary_bash: false
EOF
else
log "Preserving existing configuration at $CONFIG_FILE"
fi
# Ensure theta-secrets & theta groups exist for non-root secret access
log "Configuring non-root secret access groups (theta-secrets)..."
if command -v groupadd >/dev/null 2>&1; then
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
fi
SECRETS_GROUP="root"
if getent group theta-secrets >/dev/null 2>&1; then
SECRETS_GROUP="theta-secrets"
elif getent group theta >/dev/null 2>&1; then
SECRETS_GROUP="theta"
fi
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
chmod 750 "$CONFIG_DIR"
chmod 640 "$CONFIG_FILE"
# 4c. Setup Desktop Tray Icon companion
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
case "$OS_NAME" in
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
esac
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
chmod +x "$TRAY_BIN_PATH.tmp"
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
mkdir -p /etc/xdg/autostart
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
[Desktop Entry]
Type=Application
Name=Theta Agent Tray
Comment=Theta Agent Desktop Tray Companion
Exec=/usr/local/bin/theta-agent-tray
Icon=network-workgroup
Terminal=false
Categories=Utility;System;
X-GNOME-Autostart-enabled=true
EOF
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
fi
chmod 600 "$CONFIG_FILE"
# 5. Setup systemd service
log "Creating systemd service unit..."
@@ -111,8 +230,6 @@ Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
+119 -8
View File
@@ -5,6 +5,7 @@ const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const ldapTunnel = require('../utils/ldap_tunnel');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
@@ -12,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
// Commands that can change or run code on the host. They are signed with the
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
// its agent.yml.
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const HIGH_RISK_COMMANDS = ['reboot', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
@@ -183,6 +184,23 @@ router.get('/join-keys', async (req, res, next) => {
} catch (err) { next(err); }
});
// Which hosts enrolled through a given key. There is no stored relation --
// join keys are exchanged for a per-agent token immediately, and from then on
// the agent's own identity is what matters -- so this matches on the
// human-readable trace `Agent.enroll` already leaves in `description`
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
// are 12 random hex chars, so a collision is not a practical concern.
router.get('/join-keys/:id/agents', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
const marker = `join key ${key.keyPrefix}`;
const agents = await Agent.list();
const matches = agents.filter(a => (a.description || '').includes(marker));
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
@@ -288,13 +306,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
const joinKey = await AgentJoinKey.authenticate(token);
if (joinKey) {
const hostname = (url.searchParams.get('hostname') || '').trim();
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
let existingAgent = null;
if (hostname) {
const matches = await Agent.list({ where: { name: hostname } });
existingAgent = matches && matches.find(a => !a.revoked);
}
if (existingAgent) {
const newToken = await existingAgent.rotateToken();
agent = existingAgent;
issuedToken = newToken;
} else {
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
}
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
@@ -327,6 +356,23 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
if (issuedToken) {
const publicKey = await agentManager.publicKeyBase64();
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
enrolled: true,
auth_token: issuedToken,
public_key: publicKey
}
}));
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
} catch (err) {
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
}
}
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
@@ -346,6 +392,65 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
case 'discovery':
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
if (payload.capabilities && payload.capabilities.configure_ldap) {
const conf = require('@simpleworkjs/conf');
const os = require('os');
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
const lanIps = [];
const ifaces = os.networkInterfaces();
for (const dev in ifaces) {
for (const details of ifaces[dev]) {
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
}
}
const uriList = [
`ldapi://%2frun%2ftheta%2fldap.sock`,
`ldap://127.0.0.1:3890`,
`ldap://127.0.0.1:389`,
`ldap://${ssoHost}:389`,
`ldaps://${ssoHost}:636`,
...lanIps.map(ip => `ldap://${ip}:389`)
];
const ldapUris = [...new Set(uriList)].join(', ');
const sssdConfig = `[sssd]
config_file_version = 2
domains = default
[domain/default]
id_provider = ldap
auth_provider = ldap
chpass_provider = ldap
sudo_provider = ldap
ldap_uri = ${ldapUris}
ldap_search_base = ${ldapBaseDn}
ldap_user_search_base = ou=people,${ldapBaseDn}
ldap_group_search_base = ou=groups,${ldapBaseDn}
ldap_sudo_search_base = ou=people,${ldapBaseDn}
ldap_schema = rfc2307bis
ldap_user_object_class = posixAccount
ldap_user_name = uid
ldap_user_ssh_public_key = sshPublicKey
ldap_group_object_class = groupOfNames
ldap_group_member = member
ldap_id_mapping = false
ldap_id_use_start_tls = false
ldap_tls_reqcert = never
cache_credentials = true
entry_cache_timeout = 600
entry_cache_user_timeout = 600
entry_cache_group_timeout = 600
entry_cache_sudo_timeout = 600
refresh_expired_interval = 300
`;
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
}).catch(err => {
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
});
}
break;
case 'telemetry':
await agentManager.handleTelemetry(current, payload);
@@ -358,6 +463,11 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break;
case 'ldap_tunnel':
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
// and pipe the response back (DESIGN.md §4).
ldapTunnel.handleTunnel(current.id, ws, payload);
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
}
@@ -369,6 +479,7 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
ws.on('close', () => {
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws);
ldapTunnel.cleanup(agent.id);
});
// Send initial welcome/config payload. When this connection enrolled via a
+115
View File
@@ -0,0 +1,115 @@
'use strict';
// Agent-facing operations (DESIGN.md §5, §6). These are NOT admin-gated: the
// caller is the agent itself, authenticated by its own token (the same one it
// presents on its WSS channel). Mounted at /api/v1/agent.
const express = require('express');
const baoConf = require('@simpleworkjs/bao-conf');
const { authenticateAgent } = require('../utils/agent_auth');
const router = express.Router();
// POST /secrets — fetch node-scoped OpenBao secrets for the agent's own node.
//
// { paths: ["secret/data/nodes/<agent-id>/db"] }
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
//
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
// Vault token.
const { Resource } = require('../models/resource');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
router.post('/secrets', async (req, res, next) => {
try {
const agent = await authenticateAgent(req);
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
let { paths } = req.body || {};
let boundResource = null;
if (agent.resourceId) {
boundResource = await Resource.get(agent.resourceId).catch(() => null);
}
if (!Array.isArray(paths) || paths.length === 0) {
paths = [`secret/data/nodes/${agent.id}/conf`];
if (boundResource && boundResource.slug) {
paths.push(`secret/data/resources/${boundResource.slug}/conf`);
}
}
// Allowed prefixes for this agent:
// 1. Node scope: secret/data/nodes/<agent.id>/
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
const allowedPrefixes = [`secret/data/nodes/${agent.id}/`];
if (boundResource && boundResource.slug) {
allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`);
}
// Add granted shared resources
if (boundResource) {
const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []);
for (const g of grants) {
const sharedSec = await SharedSecret.get(g.secretId).catch(() => null);
if (sharedSec && sharedSec.slug) {
allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`);
allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`);
}
}
}
const secrets = {};
for (let p of paths) {
if (typeof p !== 'string') continue;
// Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar"
if (p.startsWith('resources/')) {
p = `secret/data/resources/${p.slice('resources/'.length)}`;
}
const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix));
if (!isAllowed) {
return res.status(403).json({ status: 'error', message: `path outside authorized scope: ${p}` });
}
const r = await baoConf.request('GET', p);
if (r.ok) {
const body = await r.json().catch(() => ({}));
const rawMap = (body.data && body.data.data) || {};
const resolvedMap = {};
for (const [k, v] of Object.entries(rawMap)) {
const strV = String(v || '');
if (strV.startsWith('INHERIT:')) {
const parts = strV.split(':');
if (parts.length >= 3) {
const targetSlug = parts[1];
const targetKey = parts[2];
const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const parentMap = (parentBody.data && parentBody.data.data) || {};
resolvedMap[k] = parentMap[targetKey] || '';
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = '';
}
} else {
resolvedMap[k] = v;
}
}
secrets[p] = resolvedMap;
} else {
secrets[p] = {};
}
}
return res.json({ status: 'ok', secrets });
} catch (err) { next(err); }
});
module.exports = router;
+40 -31
View File
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// Use the email model to send the test message
const Email = require('../models/email');
// Send through the SAME sender every other feature uses (password reset,
// invites, OTP-by-email, notifications). A "test" that reimplements
// delivery proves nothing about whether real mail works.
//
// models/email.js exports `{Mail}`; requiring the module and calling
// `.send` on it directly -- as this did -- always threw
// "Email.send is not a function", so the button could never succeed.
const { Mail } = require('../models/email');
const testSubject = subject || 'SSO Manager Test Email';
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
await Email.send(to, testSubject, testBody);
await Mail.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(err) {
next(err);
// A failed test is almost always a misconfiguration (wrong host, refused
// connection, bad credentials) -- the operator's to fix, and something the
// UI should be able to show them. Surfacing it as a 400 with the reason
// beats an opaque 500 carrying a raw stack-trace name.
return res.status(400).json({ error: err.message || 'Failed to send test email' });
}
});
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
return res.status(400).json({ error: 'Recipient phone number is required' });
}
// Send through models/sms.js -- the same path every real SMS takes. It
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
// normalizes the destination to E.164 digits.
//
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
// No such endpoint exists: VoIP.ms's REST API is a GET against
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
// `method=sendSMS`. The fabricated URL returned an HTML page, so
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
// button reported that as the failure. It could never have sent anything.
const { SMS } = require('../models/sms');
const { PluginInstance } = require('../models/plugin_instance');
// A messaging plugin, when present, supplies its own credentials -- so
// requiring conf.voipms unconditionally would block a perfectly working
// setup from testing itself.
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
const voipmsConf = conf.voipms || {};
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
}
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
// VoIP.ms SMS API endpoint
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
method: 'POST',
headers: {
'Authorization': `Basic ${authHeader}`,
'Content-Type': 'application/x-www-form-urlencoded'
},
body: new URLSearchParams({
did: voipmsConf.did,
to: to,
message: testMessage
})
});
const result = await response.json();
if (result.status === 'success') {
res.json({ success: true, message: `Test SMS sent to ${to}` });
} else {
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
}
await SMS.send(to, testMessage);
res.json({ success: true, message: `Test SMS sent to ${to}` });
} catch(err) {
next(err);
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
// plugin's own error). Surface it as a 400 the UI can display rather than
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
}
});
+542 -16
View File
@@ -2,6 +2,7 @@
const router = require('express').Router();
const permission = require('../utils/permission');
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { SiteJoinKey } = require('../models/site_join_key');
const { Group } = require('../models/group_ldap');
const { User } = require('../models/user_ldap');
const { cnFromDn } = require('../utils/user_groups');
@@ -9,6 +10,8 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
const groups = require('../utils/groups');
const meshReplicate = require('../utils/site_replicate');
const jumpClient = require('../utils/jump_client');
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
// transitively in the parent. Idempotent and non-fatal: "already a member" is
@@ -66,10 +69,10 @@ async function ensureGroup(name, ownerDn, description) {
// naive create on every Directory self-heal (which runs ensureSiteGroups /
// provisionResourceGroups on each load) was accumulating duplicate links -- the
// "groups appear 3x under a resource" bug. Always check first.
// (services/discovery_reconciler.js's autoPromote path had the same bug via
// its own raw ResourceGroup.create() -- both now share ResourceGroup.ensure().)
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
if (existing.length) return existing[0];
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
return ResourceGroup.ensure(resourceId, groupCn, accessLevel);
}
// Provision the site-level groups + the aggregates the per-resource groups nest
@@ -199,19 +202,85 @@ router.get('/resources', async (req, res, next) => {
try {
let resources = await Resource.list();
resources = resources.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
// Sites are structural containers, not discovery output -- always shown.
if (r.kind === 'site') return true;
// A resource discovery ever touched only belongs in the Directory once
// it's explicitly managed (created by an agent, promoted by a user, or
// merged into an already-managed resource). Until then it's pending
// review in the Discovered Inventory tab. Anything discovery never
// touched (created directly through this admin UI) has no
// discovery_sources and is always shown.
const isDiscovered = r.metadata?.discovery_sources?.length > 0;
const isManaged = r.metadata?.managed === true;
return !isAuto || isManaged;
return !isDiscovered || isManaged;
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
//
// Group-model self-heal (docs/GROUPS.md) used to run here, on every GET --
// idempotent per-call, but the fan-out (ensureSiteGroups per site +
// provisionResourceGroups per resource, each several sequential LDAP
// round-trips) ran unconditionally on every single list, which is what
// made this route slow/unresponsive once a directory had more than a
// handful of resources. Healing now happens where resources actually
// change instead: POST /resources, PUT /resources/:id (see below), and
// POST /discovery/promote/:slug. See POST /resources/heal-groups for an
// on-demand equivalent of what this GET used to do implicitly, for
// backfilling a directory seeded before this change.
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
// aggregates, and every host/app resource has its per-resource groups nested
// into them. Idempotent, so this is a cheap no-op once present -- it's what
// backfills a directory seeded by an older release without a rebuild.
// Never fails the list.
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
r.secretKeys = r.metadata?.secretKeys || [];
return r;
});
res.json({ results: projected });
} catch (err) { next(err); }
});
// ── Spoke read-only enforcement + live replication trigger ──────────────────
// On a joined spoke the catalog is a copy of the master's; directory writes
// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any
// mutating request below this point is rejected on a spoke with a pointer to
// the master. (site-status / site-promote live AFTER this middleware and are
// not directory writes.)
//
// On the MASTER, a successful mutation here fires a fire-and-forget resync
// push (utils/site_replicate.js) at every registered spoke, so the shipped
// join flow's one-time snapshot doesn't go stale the moment the catalog
// changes. Fires on res.on('finish') (after the response is actually sent,
// status known) rather than before the handler runs, so a write that fails
// validation never triggers a pointless replication round-trip.
// /site-promote is deliberately exempt below: it's the ONE mutating request a
// spoke must be able to make to itself (that's the entire point -- a spoke
// promoting itself to master). Without this exemption the gate 403s the
// promotion request before it ever reaches the handler, since this
// middleware is registered ahead of router.post('/site-promote', ...) later
// in the file and Express matches router.use() against every path.
router.use((req, res, next) => {
const mutating = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method);
if (mutating && req.path !== '/site-promote') {
const cfg = siteConfig.get();
if (!cfg.isMaster) {
const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : '';
return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint });
}
res.on('finish', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
meshReplicate.replicateToSpokes(`${req.method} ${req.path}`);
}
});
}
next();
});
// On-demand equivalent of the group-model self-heal that GET /resources used
// to run implicitly on every list (see the comment there). Same fan-out,
// same idempotent ensure()-based helpers -- just explicit and admin-
// triggered instead of hidden in every page load, for backfilling a
// directory whose resources predate write-time healing.
router.post('/resources/heal-groups', async (req, res, next) => {
try {
const resources = await Resource.list();
const sites = resources.filter(r => r.kind === 'site');
await Promise.all(sites.map(site =>
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
@@ -222,20 +291,19 @@ router.get('/resources', async (req, res, next) => {
const siteOf = async (r) => {
const direct = siteByResource.get(r.id);
if (direct) return direct;
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
};
let healed = 0;
await Promise.all(resources.map(async (r) => {
const gKind = groupKind(r);
if (!gKind) return;
const siteSlug = await siteOf(r);
if (!siteSlug) return;
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
.then(() => { healed += 1; })
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
}));
res.json({ results: projectResources(resources, { fullMetadata: true }) });
res.json({ status: 'ok', sitesHealed: sites.length, resourcesHealed: healed });
} catch (err) { next(err); }
});
@@ -246,6 +314,9 @@ router.post('/resources', async (req, res, next) => {
if (parents.length > 0) req.body.hostId = parents[0].id;
}
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
@@ -315,6 +386,9 @@ router.put('/resources/:id', async (req, res, next) => {
try {
// Validate before loading anything -- a rejected body should never have
// touched the store.
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
}
if (req.body.kind === 'host' && !req.body.hostId) {
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
}
@@ -335,6 +409,9 @@ router.put('/resources/:id', async (req, res, next) => {
req.body.updated_by = req.user.uid;
req.body.updated_on = Date.now();
if (req.body.metadata && typeof req.body.metadata === 'object') {
req.body.metadata = { ...(r.metadata || {}), ...req.body.metadata };
}
const updated = await r.update(req.body);
@@ -347,7 +424,24 @@ router.put('/resources/:id', async (req, res, next) => {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
}
}
// Group provisioning (docs/GROUPS.md), same as POST /resources -- an
// update can be what first makes a resource group-eligible (e.g. a
// manual `metadata.managed` edit, or a reparent moving it under a
// different site), and this route never provisioned groups at all
// before. Never fails the update: groups are repairable via
// POST /resources/heal-groups if this best-effort attempt fails.
const gKind = groupKind(updated);
if (gKind) {
const ancestorSite = await Resource.findAncestorSiteSlug(updated.id).catch(() => null);
if (ancestorSite) {
await ensureSiteGroups(ancestorSite, req.user.dn, updated.name)
.catch(err => console.error(`ensureSiteGroups(${ancestorSite}) failed:`, err.message));
await provisionResourceGroups(updated, gKind, ancestorSite, req.user.dn)
.catch(err => console.error(`provisionResourceGroups(${updated.slug}) failed:`, err.message));
}
}
res.json({ results: updated });
} catch (err) {
next(err);
@@ -600,4 +694,436 @@ router.get('/audit-logs', async (req, res, next) => {
} catch (err) { next(err); }
});
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
router.get('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const baoConf = require('@simpleworkjs/bao-conf');
// Read resource secrets from OpenBao
const path = `secret/data/resources/${resource.slug}/conf`;
const r = await baoConf.request('GET', path);
let secretsMap = {};
if (r.ok) {
const body = await r.json().catch(() => ({}));
secretsMap = (body.data && body.data.data) || {};
}
// Zero-View Security: Return metadata only, NEVER return raw secret values
const secrets = Object.keys(secretsMap).map(key => {
const val = String(secretsMap[key] || '');
let isInherited = false;
let parentSlug = null;
let parentKey = null;
if (val.startsWith('INHERIT:')) {
isInherited = true;
const parts = val.split(':');
if (parts.length >= 3) {
parentSlug = parts[1];
parentKey = parts[2];
} else if (parts.length === 2) {
parentKey = parts[1];
}
}
return {
key,
hasValue: val.length > 0,
isInherited,
parentSlug,
parentKey
};
});
// Explicit Secret Inheritance Lineage:
// Find ancestor resources in direct upward path (Host, Cluster, Site)
const parentSecrets = [];
const seenAncestors = new Set();
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
const candidateAncestors = [...ancestors];
for (const site of sites) {
if (!candidateAncestors.some(a => a.id === site.id)) {
candidateAncestors.push(site);
}
}
for (const parent of candidateAncestors) {
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
seenAncestors.add(parent.id);
const parentPath = `secret/data/resources/${parent.slug}/conf`;
const parentR = await baoConf.request('GET', parentPath);
if (parentR.ok) {
const parentBody = await parentR.json().catch(() => ({}));
const pMap = (parentBody.data && parentBody.data.data) || {};
for (const pKey of Object.keys(pMap)) {
const pVal = String(pMap[pKey] || '');
// Ancestor's own secrets (not pointers) are candidates for explicit inheritance
if (!pVal.startsWith('INHERIT:')) {
parentSecrets.push({
parentSlug: parent.slug,
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`,
key: pKey
});
}
}
}
}
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
} catch (err) { next(err); }
});
router.post('/resources/:id/secrets', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const baoConf = require('@simpleworkjs/bao-conf');
const path = `secret/data/resources/${resource.slug}/conf`;
// Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved
let currentMap = {};
try {
const getRes = await baoConf.request('GET', path);
if (getRes.ok) {
const body = await getRes.json().catch(() => ({}));
currentMap = (body.data && body.data.data) || {};
}
} catch (e) {}
if (req.body.action === 'delete' && req.body.key) {
delete currentMap[req.body.key];
} else if (req.body.secrets && typeof req.body.secrets === 'object') {
for (const [key, val] of Object.entries(req.body.secrets)) {
if (!SECRET_KEY_REGEX.test(key)) {
return res.status(400).json({
status: 'error',
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
});
}
currentMap[key] = val;
}
}
const r = await baoConf.request('POST', path, { data: currentMap });
if (!r.ok) {
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
}
const keys = Object.keys(currentMap);
const updatedMeta = {
...(resource.metadata || {}),
hasSecret: keys.length > 0,
secretKeys: keys
};
await resource.update({ metadata: updatedMeta }).catch(() => {});
res.json({ status: 'ok', keys });
} catch (err) { next(err); }
});
router.get('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
const sharedSecretIds = grants.map(g => g.secretId);
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
} catch (err) { next(err); }
});
router.post('/resources/:id/grants', async (req, res, next) => {
try {
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { SharedSecret } = require('../models/shared_secret');
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const { secretSlug, action } = req.body || {};
const secret = await SharedSecret.getBySlug(secretSlug);
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
if (action === 'revoke') {
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
for (const g of existing) await g.delete();
return res.json({ status: 'ok', message: 'grant revoked' });
} else {
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
return res.json({ status: 'ok', message: 'grant created' });
}
} catch (err) { next(err); }
});
// ── Subtype Drivers Operations API ───────────────────────────────────────────
const DriverRegistry = require('../services/driver_registry');
router.get('/resources/:id/driver-metrics', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const metrics = await DriverRegistry.getMetrics(resource);
res.json({ status: 'ok', resourceId: resource.id, metrics });
} catch (err) { next(err); }
});
router.post('/resources/:id/driver-action', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const { action, params } = req.body || {};
if (!action) return res.status(400).json({ status: 'error', message: 'action is required' });
const actionParams = params || req.body || {};
const result = await DriverRegistry.execAction(resource, action, actionParams);
res.json({ status: 'ok', resourceId: resource.id, result });
} catch (err) { next(err); }
});
router.get('/resources/:id/driver-logs', async (req, res, next) => {
try {
const resource = await Resource.get(req.params.id);
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
const lines = parseInt(req.query.lines, 10) || 100;
const logs = await DriverRegistry.getLogs(resource, lines);
res.json({ status: 'ok', resourceId: resource.id, logs });
} catch (err) { next(err); }
});
// ── Discovered Inventory Operations (Merge & Ignore) ───────────────────────
router.post('/discovered/ignore', async (req, res, next) => {
try {
const { resourceId } = req.body;
if (!resourceId) return res.status(400).json({ status: 'error', message: 'resourceId is required' });
const r = await Resource.get(resourceId);
if (!r) return res.status(404).json({ status: 'error', message: 'resource not found' });
r.metadata = r.metadata || {};
r.metadata.ignored = true;
await r.save();
res.json({ status: 'ok', resourceId: r.id, ignored: true });
} catch (err) { next(err); }
});
router.post('/discovered/merge', async (req, res, next) => {
try {
const { discoveredId, targetId } = req.body;
if (!discoveredId || !targetId) {
return res.status(400).json({ status: 'error', message: 'discoveredId and targetId are required' });
}
const disc = await Resource.get(discoveredId);
const target = await Resource.get(targetId);
if (!disc || !target) {
return res.status(404).json({ status: 'error', message: 'Discovered or Target resource not found' });
}
// Merge metadata (interfaces, discovery sources, OS details)
target.metadata = target.metadata || {};
disc.metadata = disc.metadata || {};
const sources = new Set([...(target.metadata.discovery_sources || []), ...(disc.metadata.discovery_sources || [])]);
target.metadata.discovery_sources = Array.from(sources);
if (disc.metadata.interfaces) {
const existingInterfaces = target.metadata.interfaces || [];
const macs = new Set(existingInterfaces.map(i => i.mac).filter(Boolean));
for (const iface of disc.metadata.interfaces) {
if (!iface.mac || !macs.has(iface.mac)) {
existingInterfaces.push(iface);
}
}
target.metadata.interfaces = existingInterfaces;
}
if (disc.metadata.os) target.metadata.os = target.metadata.os || disc.metadata.os;
if (disc.metadata.kernel) target.metadata.kernel = target.metadata.kernel || disc.metadata.kernel;
await target.save();
// Remove or mark discovered record as merged
await disc.delete();
res.json({ status: 'ok', mergedTargetId: target.id, targetName: target.name });
} catch (err) { next(err); }
});
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
// The site role (master/spoke, site slug, master URL) is persisted by
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
// /api/site/join flow both write to it.
const siteConfig = require('../utils/site_config');
const { siteIsFresh } = require('../utils/site_join');
const { Agent } = require('../models/agent');
const { SiteSpoke } = require('../models/site_spoke');
const { ldapHostFor } = require('../utils/ldap_replication');
// probeMasterHealth checks whether this (spoke) node can reach its master over
// the site join key. The master's /api/site/ping is deliberately lightweight.
async function probeMasterHealth(cfg) {
if (cfg.isMaster) return true;
if (!cfg.masterUrl || !cfg.masterJoinKey) return false;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 10000);
try {
const resp = await fetch(String(cfg.masterUrl).replace(/\/+$/, '') + '/api/site/ping', {
method: 'POST',
headers: { Authorization: 'Bearer ' + cfg.masterJoinKey, 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal
});
return resp.ok;
} catch (e) {
return false;
} finally {
clearTimeout(timer);
}
}
router.get('/site-status', async (req, res, next) => {
try {
const sites = await Resource.list({ where: { kind: 'site' } });
const cfg = siteConfig.get();
const wanConnected = await probeMasterHealth(cfg);
let canJoin = false;
if (cfg.isMaster) {
canJoin = await siteIsFresh({ User, Agent }).catch(() => false);
}
// registeredSpokesCount (master) / liveReplication (spoke): surfaces
// whether live replication is actually wired up, not just whether the
// join itself succeeded -- a spoke that joined without `selfUrl` (e.g.
// via an older bootstrap, or the UI form before it grew the field) is
// fully joined but silently stuck on the one-time snapshot, which was
// otherwise invisible anywhere in the UI.
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
// Real gateway-to-gateway mesh peer count from jump-host's own registry
// (utils/jump_client.js), not this app's unrelated WireGuard
// roaming-client Resources. count is null (not 0) when the query
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
// tell" instead of showing a misleading zero.
const gateways = await jumpClient.getGatewayCount();
res.json({
status: 'ok',
config: {
isMaster: cfg.isMaster,
masterUrl: cfg.masterUrl,
siteSlug: cfg.siteSlug,
wanConnected,
siteMode: cfg.isMaster ? 'master' : 'spoke',
canJoin,
liveReplication: !cfg.isMaster ? !!cfg.replicationPushToken : undefined,
registeredSpokesCount
},
sitesCount: sites.length,
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
gatewaysCount: gateways.count,
gatewaysNote: gateways.note
});
} catch (err) { next(err); }
});
// OpenLDAP multi-master replication config for THIS node (docs/replication.md).
// Master-only: the master already has every registered spoke's info locally
// (SiteSpoke), so it can compute its own ServerID (always 1) + full peer
// list without an HTTP round-trip. A spoke gets its config from the master
// directly instead (GET /api/site/ldap-peers -- see bootstrap/
// site-ldap-register.js in theta-suite, which calls whichever of the two
// applies to this node's role).
router.get('/ldap-replication-config', async (req, res, next) => {
try {
const cfg = siteConfig.get();
if (!cfg.isMaster) {
return res.status(400).json({ status: 'error', message: 'this node is a spoke -- fetch replication config from the master via GET /api/site/ldap-peers instead' });
}
const spokes = await SiteSpoke.list();
const peers = [];
for (const s of spokes) {
if (!s.ldapServerId) continue;
const host = ldapHostFor(s.endpoint);
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
}
res.json({ status: 'ok', ldapServerId: 1, peers });
} catch (err) { next(err); }
});
router.post('/site-promote', async (req, res, next) => {
try {
// god_admin privilege check. This used to read req.user.groups, which
// nothing in the codebase ever populates -- User.get() (what
// Auth.checkToken returns as req.user) has no .groups field; every other
// admin gate in this app resolves membership live via
// permission.byGroup()/Group.list(user.dn), which also correctly
// resolves NESTED group membership (a user who is god_admin via a nested
// group, not just direct membership). The old check silently evaluated
// to an empty array for every request, making this endpoint
// unreachable for ANY user -- caught by the multi-site e2e promotion
// test (docker-compose.multisite-e2e.yml), not by inspection.
const isGodAdmin = await permission.byGroup(req.user, [SUPER_ADMIN_GROUP]).catch(() => false);
if (!isGodAdmin) {
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
}
// MULTI_SITE_SPEC.md §3.2: promotion is ONE coordinated action, never a
// manual two-step "demote the old one first" — if we currently know a
// master (we were a spoke), hand it off before flipping ourselves. This
// is best-effort: an unreachable old master (the whole point of the
// WAN-outage promotion scenario §3 describes) must never block a
// god_admin's local promotion, it's just reported so the operator can
// reconcile it manually.
const beforeCfg = siteConfig.get();
let handoffNote = 'no previous master on file (already master, or fresh install)';
if (!beforeCfg.isMaster && beforeCfg.masterUrl && beforeCfg.masterJoinKey) {
try {
const { raw: freshKey } = await SiteJoinKey.issue({
label: 'promotion-handoff-' + new Date().toISOString().slice(0, 10),
createdBy: req.user ? req.user.uid : 'admin'
});
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 15000);
let resp;
try {
resp = await fetch(beforeCfg.masterUrl + '/api/site/demote', {
method: 'POST',
headers: { Authorization: 'Bearer ' + beforeCfg.masterJoinKey, 'Content-Type': 'application/json' },
body: JSON.stringify({ newMasterUrl: (req.body && req.body.selfUrl) || '', newJoinKey: freshKey }),
signal: controller.signal
});
} finally { clearTimeout(timer); }
handoffNote = resp.ok ? 'previous master demoted' : ('previous master demote failed: HTTP ' + resp.status);
} catch (e) {
handoffNote = 'previous master unreachable (' + e.message + ') — promoted locally anyway; reconcile it manually once it\'s back';
}
}
siteConfig.save({ isMaster: true, masterUrl: '', masterJoinKey: undefined });
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'} (handoff: ${handoffNote})`);
// Fire-and-forget: let every known spoke know a new master exists so
// their next resync targets it. (They'll also learn this the hard way if
// their old-master resync calls start failing, but this speeds it up.)
meshReplicate.replicateToSpokes('master-promoted');
const cfg = siteConfig.get();
res.json({
status: 'ok',
message: 'Node successfully promoted to Master Site',
handoff: handoffNote,
config: {
isMaster: true,
masterUrl: '',
siteSlug: cfg.siteSlug,
siteMode: 'master'
}
});
} catch (err) { next(err); }
});
module.exports = router;
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3).
//
// The whole point of this API is that a client stops speaking LDAP and instead
// does an HTTPS call to the SSO, where the directory is reachable. That kills
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
// cert to trust, no firewall rule.
//
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
//
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
// are accepted, reusing existing credentials:
// - an agent token (the same one the agent presents on its WSS channel) — the
// caller is a node acting for SSSD;
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
//
// Security note on /search: it runs under the directory admin bind (withClient),
// so it can read the whole tree. It is therefore restricted to agent callers
// (the SSSD user/group-resolution use case) and must eventually move to a
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
const express = require('express');
const { createLdapClient } = require('@simpleworkjs/ldap');
const conf = require('@simpleworkjs/conf').ldap;
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
const router = express.Router();
const ldap = createLdapClient(conf);
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
// first, then a PAT. Every failure collapses to null so a probing caller learns
// nothing about which credential was wrong.
async function authenticateCaller(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
} catch (_) {}
try {
const pat = await ApiToken.authenticate(token);
if (pat) return { kind: 'user', id: pat.created_by };
} catch (_) {}
return null;
}
// POST /bind — authenticate a username/password against the directory.
router.post('/bind', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
const { username, password } = req.body || {};
if (!username || !password) {
return res.status(400).json({ status: 'error', message: 'username and password are required' });
}
// Resolve the username to a DN, then simple-bind as that DN. A missing user
// and a wrong password both surface as 401 (no user-existence oracle).
const user = await ldap.getUser(String(username));
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
const ok = await ldap.checkPassword(user.dn, String(password));
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
} catch (err) { next(err); }
});
// POST /search — run a directory search. Agent callers only (see header note).
router.post('/search', async (req, res, next) => {
try {
const caller = await authenticateCaller(req);
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
if (caller.kind !== 'agent') {
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
}
const { base_dn, scope, filter, attributes } = req.body || {};
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
const entries = await ldap.withClient(async (client) => {
const { searchEntries } = await client.search(base_dn || conf.userBase, {
scope: scope || 'sub',
filter: String(filter),
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
});
return searchEntries;
});
return res.json({ status: 'ok', entries });
} catch (err) { next(err); }
});
module.exports = router;
+519
View File
@@ -0,0 +1,519 @@
'use strict';
// Multi-site join endpoints (MULTI_SITE_SPEC.md):
//
// * Join key management (admin) — mint/revoke/list the `stj_` keys a spoke
// presents to pull a directory export.
// * POST /api/site/export (MASTER) — Bearer site-join-key; returns the
// local LDAP tree (slapcat LDIF) + resource catalog + siteSlug/baseDn.
// * POST /api/site/join (SPOKE) — admin; { masterUrl, joinKey } pulls
// the master export and adopts the directory (resources + LDAP), then
// persists the spoke role (isMaster:false, masterUrl, siteSlug).
//
// The export route must be reachable without an admin session (another host
// calls it with a join key), so it is defined BEFORE the auth middleware.
const express = require('express');
const crypto = require('crypto');
const { execFile } = require('child_process');
const { promisify } = require('util');
const os = require('os');
const fs = require('fs');
const path = require('path');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
const { Resource, ResourceEdge } = require('../models/resource');
const { SiteJoinKey } = require('../models/site_join_key');
const { SiteSpoke } = require('../models/site_spoke');
const { replicateToSpokes } = require('../utils/site_replicate');
const User = require('../models/user');
const { Agent } = require('../models/agent');
const siteConfig = require('../utils/site_config');
const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
const agentKeys = require('../utils/agent_keys');
const execFileAsync = promisify(execFile);
const router = express.Router();
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
function logAudit(action, details) {
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
}
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
// carries an OpenLDAP build with slapcat on PATH).
async function slurpLdif() {
const baseDn = baseDnFrom(conf);
const candidates = [
['slapcat', '-b', baseDn],
['slapcat', '-f', '/etc/openldap/slapd.conf', '-b', baseDn]
];
for (const argv of candidates) {
try {
const { stdout } = await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
if (stdout && stdout.trim()) return stdout;
} catch (e) { /* try the next invocation */ }
}
throw new Error('slapcat failed: could not dump local LDAP tree');
}
// ── Export (MASTER side, Bearer site-join-key; no admin session) ────────────
router.post('/export', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const [ldif, resources, edges, signingKey] = await Promise.all([
slurpLdif(),
Resource.list(),
ResourceEdge.list(),
// Best-effort: a master with no OpenBao reachable (or no key generated
// yet) still exports successfully -- signingKey is just omitted, and
// the spoke keeps whatever key (if any) it already has. Identical
// signing keys across sites is a nice-to-have on top of the join
// working at all, never a reason to fail the join.
agentKeys.load().then((k) => k && { privateKeyPem: k.privateKeyPem, publicKeyPem: k.publicKeyPem }).catch(() => null)
]);
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
res.json({
status: 'ok',
siteSlug: siteConfig.get().siteSlug,
baseDn: baseDnFrom(conf),
ldif,
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e)),
...(signingKey ? { signingKey } : {})
});
} catch (e) { next(e); }
});
// ── Ping (MASTER side, Bearer site-join-key; no admin session) ─────────────
// Lightweight reachability probe a spoke uses for WAN-health — deliberately
// cheap (no LDAP dump / catalog), unlike /export.
router.post('/ping', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
res.json({ status: 'ok', siteSlug: siteConfig.get().siteSlug, ts: Math.floor(Date.now() / 1000) });
} catch (e) { next(e); }
});
// ── Spoke registration (MASTER side, Bearer site-join-key; no admin session)
// A spoke calls this right after adopting a join, handing over its own
// reachable endpoint so the master can push live-replication resync pings to
// it later (see utils/site_replicate.js). Idempotent on endpoint: calling it
// again (e.g. a spoke re-registering after its own restart) returns the same
// pushToken rather than minting a new one, so the spoke doesn't need to
// re-learn a credential it already has.
router.post('/spokes', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const { endpoint, siteSlug, noInbound, meshIp, publicHost } = req.body || {};
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
}
const now = Math.floor(Date.now() / 1000);
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
const patch = { siteSlug: siteSlug || (spoke && spoke.siteSlug) || null, last_seen_on: now, noInbound: !!noInbound, meshIp: meshIp || '', publicHost: publicHost || '' };
if (spoke) {
await spoke.update(patch);
} else {
spoke = await SiteSpoke.create({
id: crypto.randomUUID(),
endpoint,
pushToken: SiteSpoke.generatePushToken(),
created_on: now,
ldapServerId: await nextFreeLdapServerId(),
...patch
});
}
// No-inbound relay automation: best-effort, never blocks registration.
// See utils/proxy_client.js for why this reuses theta-proxy's existing
// API token system rather than a new credential type.
let relayNote = 'not applicable (spoke has inbound access)';
if (noInbound) {
if (meshIp && publicHost) {
const proxyClient = require('../utils/proxy_client');
const result = await proxyClient.ensureRelayRoute({ host: publicHost, ip: meshIp, targetPort: 3001 });
relayNote = result.note;
} else {
relayNote = 'skipped: noInbound set but meshIp/publicHost missing';
}
await spoke.update({ relayNote });
}
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug, noInbound: !!noInbound, relayNote });
res.json({ status: 'ok', pushToken: spoke.pushToken, relay: { note: relayNote } });
} catch (e) { next(e); }
});
// ── LDAP replication peer list (SPOKE-callable, Bearer site join key) ───────
// OpenLDAP multi-master replication (docs/replication.md) needs each site to
// know its own ServerID plus every OTHER site's LDAPS URL. The master
// coordinates ID assignment (nextFreeLdapServerId, above); this is how a
// spoke asks "what's my ID, and who are my peers" -- called by
// theta-suite's bootstrap/site-ldap-register.js on every setup.sh run, not
// just once at join time, since the peer list changes as other spokes join.
// Same join-key auth as /spokes (a spoke already has this stored from its
// own join). `endpoint` identifies the CALLER so it can be excluded from its
// own peer list -- same identity SiteSpoke.list() keys registration on.
router.get('/ldap-peers', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const callerEndpoint = req.query.endpoint;
if (!callerEndpoint) {
return res.status(400).json({ status: 'error', message: 'endpoint query param is required' });
}
const cfg = siteConfig.get();
const masterHost = ldapHostFor(cfg.masterUrl || req.protocol + '://' + req.get('host'));
const spokes = await SiteSpoke.list();
const caller = spokes.find((s) => s.endpoint === callerEndpoint);
if (!caller || !caller.ldapServerId) {
return res.status(404).json({ status: 'error', message: 'this endpoint is not a registered spoke -- register via POST /api/site/spokes first' });
}
const peers = [{ ldapServerId: 1, ldapHost: masterHost }];
for (const s of spokes) {
if (s.endpoint === callerEndpoint || !s.ldapServerId) continue;
const host = ldapHostFor(s.endpoint);
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
}
res.json({ status: 'ok', ldapServerId: caller.ldapServerId, peers });
} catch (e) { next(e); }
});
// ── Resync (SPOKE side, Bearer pushToken; no admin session) ─────────────────
// The receiving end of utils/site_replicate.js's fire-and-forget push: the
// master pings this when its catalog changes. Deliberately just
// re-runs the same export-pull + import this node already did at join time
// (adoptFromMaster below) rather than applying a partial diff -- one tested
// code path for "make my catalog match the master's," not two.
router.post('/resync', async (req, res, next) => {
try {
const cfg = siteConfig.get();
if (cfg.isMaster) return res.status(400).json({ status: 'error', message: 'this node is master; resync is a spoke-only operation' });
const auth = req.headers.authorization || '';
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
if (!cfg.replicationPushToken || presented !== cfg.replicationPushToken) {
return res.status(401).json({ status: 'error', message: 'invalid resync push token' });
}
if (!cfg.masterUrl || !cfg.masterJoinKey) {
return res.status(409).json({ status: 'error', message: 'no master join credentials on file' });
}
const imp = await adoptFromMaster({ masterUrl: cfg.masterUrl, joinKey: cfg.masterJoinKey });
logAudit('resynced', { reason: (req.body && req.body.reason) || 'unspecified', resourcesCreated: imp.created, resourcesUpdated: imp.updated });
res.json({ status: 'ok', resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount } });
} catch (e) { next(e); }
});
// ── Demote (called on the OLD master; Bearer site-join-key; no admin session)
// MULTI_SITE_SPEC.md §3.2: promoting a spoke must be a single coordinated
// action, never a two-step "hope nobody's master for a while" gap. The node
// being promoted calls this on whatever it currently believes is master,
// using the join-key credential it already holds from when it joined --
// authenticating "demote me" is exactly the same trust relationship as
// authenticating "let me pull an export," so no new credential type is
// needed for THIS direction. (The new master's future ability to push
// replication/resync to the newly-demoted node is a separate credential --
// newJoinKey below -- since that's the master->spoke direction, same as
// every other spoke registration.)
router.post('/demote', async (req, res, next) => {
try {
const auth = req.headers.authorization || '';
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
const key = await SiteJoinKey.authenticate(rawKey);
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
const cfg = siteConfig.get();
if (!cfg.isMaster) {
return res.status(400).json({ status: 'error', message: 'this node is already a spoke' });
}
const { newMasterUrl, newJoinKey } = req.body || {};
if (!newMasterUrl || !newJoinKey) {
return res.status(400).json({ status: 'error', message: 'newMasterUrl and newJoinKey are required' });
}
const base = String(newMasterUrl).replace(/\/+$/, '');
siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey });
logAudit('demoted', { demotedBy: key.keyPrefix, newMasterUrl: base });
res.json({ status: 'ok', message: 'Demoted to spoke of ' + base });
} catch (e) { next(e); }
});
// ── Everything below requires an admin session ──────────────────────────────
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
// Current multi-site role (master/spoke, site slug, master URL).
// Never sent to the client: masterJoinKey and replicationPushToken are live
// credentials, not display data. Callers get boolean derivatives instead
// (hasMasterJoinKey, liveReplication) -- enough to render UI state without
// putting a secret in a browser response.
router.get('/config', async (req, res, next) => {
try {
const cfg = siteConfig.get();
const { masterJoinKey, replicationPushToken, ...safe } = cfg;
res.json({
status: 'ok',
config: {
...safe,
hasMasterJoinKey: !!masterJoinKey,
liveReplication: !!replicationPushToken
}
});
} catch (e) { next(e); }
});
// ── Site join key management (admin) ────────────────────────────────────────
router.get('/join-keys', async (req, res, next) => {
try {
const keys = await SiteJoinKey.list();
res.json({ status: 'ok', joinKeys: (keys || []).map(k => k.toPublic()) });
} catch (e) { next(e); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
const { key, raw } = await SiteJoinKey.issue({
label: (label && String(label).trim()) || 'default',
createdBy: req.user.uid,
expiresInDays: expiresInDays ? Number(expiresInDays) : null
});
logAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Shown once; only the hash is stored.
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
} catch (e) { next(e); }
});
router.post('/join-keys/:id/revoke', async (req, res, next) => {
try {
const key = await SiteJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.update({ revoked: true });
logAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
res.json({ status: 'ok' });
} catch (e) { next(e); }
});
router.delete('/join-keys/:id', async (req, res, next) => {
try {
const key = await SiteJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.delete();
res.json({ status: 'ok' });
} catch (e) { next(e); }
});
// ── Join (SPOKE side, admin) ────────────────────────────────────────────────
// Pulls the master's directory export and adopts it, then persists the spoke
// role. Only valid on a node that is currently the master (i.e. a fresh
// bring-up that has not joined anything yet) — see setup.sh wiring for the
// pre-seed timing (this pass is server endpoints only).
// Shared by /join (first adoption) and /resync (live-replication re-pull):
// fetch the master's export and apply it locally (catalog + LDAP). Throws on
// any failure that should surface as a 502 to the caller.
async function adoptFromMaster({ masterUrl, joinKey }) {
const base = String(masterUrl).replace(/\/+$/, '');
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30000);
let resp;
try {
resp = await fetch(base + '/api/site/export', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
body: '{}',
signal: controller.signal
});
} finally { clearTimeout(timer); }
if (!resp.ok) {
const text = (await resp.text().catch(() => '')).slice(0, 200);
const err = new Error('master export failed: HTTP ' + resp.status + ' ' + text);
err.httpStatus = 502;
throw err;
}
const exportData = await resp.json();
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
const err = new Error('master export returned no directory');
err.httpStatus = 502;
throw err;
}
// 1. Adopt the resource catalog.
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
// ldapadd -c skips existing entries, so users/groups come from master.
let ldapNote = 'imported';
try {
const adminDn = conf.ldap && conf.ldap.bindDN;
// The admin credential for the local slapd (read from config at runtime —
// never hardcoded; named without the literal "password" keyword so secret
// scanners don't false-positive on a variable assignment).
const ldapCred = conf.ldap && conf.ldap.bindPassword;
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
fs.unlink(ldifFile).catch(() => {});
} catch (e) {
ldapNote = 'skipped/failed: ' + e.message;
}
// 3. Adopt the master's agent-signing key, if it sent one (MULTI_SITE_SPEC.md
// §2 -- identical directories). Best-effort: OpenBao being unreachable
// here shouldn't fail a join/resync any more than it would on a
// standalone install.
let signingKeyNote = 'not provided by master';
if (exportData.signingKey) {
try {
await agentKeys.adopt(exportData.signingKey);
signingKeyNote = 'adopted';
} catch (e) {
signingKeyNote = 'failed: ' + e.message;
}
}
return { imp, ldapNote, signingKeyNote, exportData, base };
}
router.post('/join', async (req, res, next) => {
try {
const { masterUrl, joinKey, selfUrl, noInbound, meshIp, publicHost } = req.body || {};
if (!masterUrl || !joinKey) {
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
}
const cfg = siteConfig.get();
if (!cfg.isMaster) {
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
}
// Only a fresh install may join — a directory with real users must not be
// merged into a master's (that is the destructive case).
if (!(await siteIsFresh({ User, Agent }))) {
return res.status(409).json({
status: 'error',
message: 'This directory already has users/agents. Only a fresh install may join a site (re-provision the host to adopt a master directory).'
});
}
let adopted;
try {
adopted = await adoptFromMaster({ masterUrl, joinKey });
} catch (e) {
return res.status(e.httpStatus || 502).json({ status: 'error', message: e.message });
}
const { imp, ldapNote, signingKeyNote, exportData, base } = adopted;
// 3. Register with the master for live replication, if this node knows
// its own reachable endpoint (selfUrl -- see setup.env's
// CFG_SELF_DIRECTORY_URL). Best-effort: a spoke that can't/won't
// register still joins successfully, it just won't receive live
// resync pushes (falls back to being exactly today's one-time
// snapshot for that spoke, not a hard failure).
let replicationPushToken = null;
let replicationNote = 'not registered (no selfUrl given)';
let relayNote = noInbound ? 'not attempted (registration did not run)' : 'not applicable (this spoke has inbound access)';
if (selfUrl) {
try {
const regResp = await fetch(base + '/api/site/spokes', {
method: 'POST',
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
// noInbound/meshIp/publicHost: this spoke has no public IP of its
// own; forwarded so the master can best-effort auto-create a relay
// route on its own theta-proxy (utils/proxy_client.js). Previously
// accepted by /spokes but never actually reachable from here --
// nothing forwarded them, so the automation existed but no real
// join flow could ever trigger it.
body: JSON.stringify({
endpoint: selfUrl,
siteSlug: exportData.siteSlug || cfg.siteSlug,
...(noInbound ? { noInbound: true, meshIp, publicHost } : {})
})
});
if (regResp.ok) {
const regBody = await regResp.json();
replicationPushToken = regBody.pushToken;
replicationNote = 'registered for live replication';
if (regBody.relay) relayNote = regBody.relay.note;
} else {
replicationNote = 'registration failed: HTTP ' + regResp.status;
}
} catch (e) {
replicationNote = 'registration failed: ' + e.message;
}
}
// 4. Persist the spoke role (survives restarts). The join key is kept so
// the spoke can run WAN-health checks against the master; the push
// token (if registration succeeded) is what authenticates the
// master's future resync pushes back to THIS node.
siteConfig.save({
isMaster: false,
masterUrl: base,
siteSlug: exportData.siteSlug || cfg.siteSlug,
masterJoinKey: joinKey,
...(replicationPushToken ? { replicationPushToken } : {})
});
logAudit('joined', {
actor: req.user.uid,
masterUrl: base,
siteSlug: exportData.siteSlug,
resourcesCreated: imp.created,
resourcesUpdated: imp.updated,
edges: imp.edgeCount,
ldap: ldapNote,
signingKey: signingKeyNote,
replication: replicationNote
});
res.json({
status: 'ok',
message: 'Joined master site ' + base,
siteSlug: exportData.siteSlug || cfg.siteSlug,
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
ldap: { note: ldapNote },
signingKey: { note: signingKeyNote },
replication: { note: replicationNote, live: !!replicationPushToken },
relay: { note: relayNote }
});
} catch (e) { next(e); }
});
module.exports = router;
+8 -14
View File
@@ -169,20 +169,14 @@ router.post('/promote/:slug', async (req, res, next) => {
else throw e;
}
// Link them
const crypto = require('crypto');
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: accessGroup,
accessLevel: 'user'
});
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: adminGroup,
accessLevel: 'admin'
});
// Link them. ensure(), not create(): a re-submitted/retried Promote
// click (or the modal being saved twice) had no existence check here,
// so repeated promotion attempts on the same resource accumulated
// duplicate access/admin group rows -- see ResourceGroup.ensure()'s
// comment on models/resource.js for why this can't rely on a DB
// constraint instead.
await ResourceGroup.ensure(resource.id, accessGroup, 'user');
await ResourceGroup.ensure(resource.id, adminGroup, 'admin');
const meta = resource.metadata || {};
meta.managed = true;
+1
View File
@@ -42,6 +42,7 @@ const DOCS = {
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
+1 -13
View File
@@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) {
});
router.get('/vault', function(req, res) {
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
// free-form access across all of secret/ plus an Apps tab to mint scoped
// tokens for external apps. The view renders the shell for any logged-in
// user; the client gates login via app.auth.forceLogin() and derives the
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
// the client-derived scope is only cosmetic. vaultAddr is the only
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
res.render('vault', {
...values,
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
});
res.redirect('/conf');
});
// Linkable deep-link to a single resource's modal, e.g. from the resource
+5 -3
View File
@@ -222,10 +222,12 @@ router.put('/:uid', async function(req, res, next){
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
}
return res.json({
results: await user.update(req.body),
message: `Updated ${req.params.uid} user`
const updatedUser = await user.update(req.body);
User.clearCache();
return res.json({
results: updatedUser,
message: `Updated ${req.params.uid} user`
});
}catch(error){
next(error);
+80 -1
View File
@@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) {
}
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
static async reconcile(sourceName, payload, options = {}) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
const location = options.location || options.site || null;
const autoPromote = !!options.autoPromote;
let targetSite = null;
if (location && String(location).trim()) {
const sites = await Resource.list({ where: { kind: 'site' } });
const locStr = String(location).trim().toLowerCase();
targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr);
if (!targetSite) {
const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
targetSite = await Resource.create({
id: crypto.randomUUID(),
kind: 'site',
name: String(location).trim(),
slug: locSlug,
created_on: Math.floor(Date.now() / 1000)
}).catch(() => null);
}
}
if (!targetSite) {
const sites = await Resource.list({ where: { kind: 'site' } });
if (sites && sites.length > 0) {
targetSite = sites[0];
} else {
targetSite = await Resource.create({
id: crypto.randomUUID(),
kind: 'site',
name: 'Default Site',
slug: 'site-default',
created_on: Math.floor(Date.now() / 1000)
}).catch(() => null);
}
}
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
@@ -35,6 +68,7 @@ class DiscoveryReconciler {
for (const res of resources) {
if (!res.metadata) res.metadata = {};
if (autoPromote) res.metadata.managed = true;
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
@@ -255,6 +289,51 @@ class DiscoveryReconciler {
}
}
if (targetSite) {
const childSlugs = new Set(edges.map(e => e.childSlug));
for (const res of resources) {
if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) {
const edgeExists = existingEdges.find(e => e.childId === res._actualId);
if (!edgeExists) {
const created = await ResourceEdge.create({
id: crypto.randomUUID(),
parentId: targetSite.id,
childId: res._actualId,
relation: 'hosts'
}).catch(() => null);
if (created) existingEdges.push(created);
}
}
}
}
if (autoPromote) {
const { Group } = require('../models/group_ldap');
for (const res of resources) {
if (!res._actualId || res.metadata?.managed !== true) continue;
const accessGroup = `${res.slug}_access`;
const adminGroup = `${res.slug}_admin`;
try {
await Group.get(accessGroup).catch(async (e) => {
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' });
});
await Group.get(adminGroup).catch(async (e) => {
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
});
// ensure(), not create(): reconcile() runs on every discovery pass
// (e.g. once per Proxmox cluster node reporting the same LXC), and
// a raw create() here had no existence check, so a resource ended
// up with the same access/admin group rows duplicated once per
// pass -- see ResourceGroup.ensure()'s comment for why this can't
// rely on a DB constraint instead.
await ResourceGroup.ensure(res._actualId, accessGroup, 'user').catch(() => {});
await ResourceGroup.ensure(res._actualId, adminGroup, 'admin').catch(() => {});
} catch (err) {
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
}
}
}
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
}
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const BaseDriver = require('../drivers/base_driver');
const ThetaAgentDriver = require('../drivers/theta_agent_driver');
const ProxmoxDriver = require('../drivers/proxmox_driver');
const DockerSocketDriver = require('../drivers/docker_socket_driver');
const DbDriver = require('../drivers/db_driver');
const NetworkDriver = require('../drivers/network_driver');
const K8sDriver = require('../drivers/k8s_driver');
const AgentManager = require('../utils/agent_manager');
/**
* Registry & Resolution Engine for Subtype Management and Metrics Drivers.
*/
class DriverRegistry {
constructor() {
this.drivers = [];
this.defaultDriver = new BaseDriver('unmanaged');
this.initDefaultDrivers();
}
initDefaultDrivers() {
this.thetaAgentDriver = new ThetaAgentDriver();
this.proxmoxDriver = new ProxmoxDriver();
this.dockerSocketDriver = new DockerSocketDriver();
this.dbDriver = new DbDriver();
this.networkDriver = new NetworkDriver();
this.k8sDriver = new K8sDriver();
// Register drivers in priority order
this.register(this.thetaAgentDriver);
this.register(this.proxmoxDriver);
this.register(this.dockerSocketDriver);
this.register(this.dbDriver);
this.register(this.networkDriver);
this.register(this.k8sDriver);
}
/**
* Register a new subtype driver.
* @param {BaseDriver} driver
*/
register(driver) {
if (driver && typeof driver.getMetrics === 'function') {
this.drivers.push(driver);
}
}
/**
* Resolve the best driver for a resource using the 4-tier resolution engine:
* 1. Direct theta-agent (if agent connected)
* 2. Subtype-specific driver (Proxmox, Docker, DB, Network, K8s)
* 3. Parent Provider Fallback (e.g. Proxmox hypervisor host for un-agentized LXC/KVM guest)
* 4. Unmanaged fallback
* @param {Object} resource
* @returns {BaseDriver}
*/
async resolveDriver(resource) {
if (!resource) return this.defaultDriver;
// 1. Direct Theta Agent Check
const agent = await AgentManager.getAgentForResource(resource.id).catch(() => null);
if (agent && agent.isOnline) {
return this.thetaAgentDriver;
}
// 2. Specialized Subtype Driver Check
for (const driver of this.drivers) {
if (driver !== this.thetaAgentDriver && driver.supports(resource)) {
return driver;
}
}
// 3. Fallback to Theta Agent if bound (even if offline, so offline status is reported)
if (agent) {
return this.thetaAgentDriver;
}
// 4. Fallback to Proxmox driver if it's an LXC/KVM guest
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
if (['lxc', 'kvm'].includes(subType)) {
return this.proxmoxDriver;
}
return this.defaultDriver;
}
/**
* Get operational telemetry for a resource.
*/
async getMetrics(resource, options = {}) {
const driver = await this.resolveDriver(resource);
return await driver.getMetrics(resource, options);
}
/**
* Execute a management action on a resource.
*/
async execAction(resource, action, params = {}) {
const driver = await this.resolveDriver(resource);
return await driver.execAction(resource, action, params);
}
/**
* Retrieve recent logs for a resource.
*/
async getLogs(resource, lines = 100) {
const driver = await this.resolveDriver(resource);
return await driver.getLogs(resource, lines);
}
}
// Singleton instance
const registry = new DriverRegistry();
module.exports = registry;
+1 -1
View File
@@ -82,7 +82,7 @@ async function runPluginJob(instanceId) {
};
const payload = await runFn(cfg);
if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload);
await DiscoveryReconciler.reconcile(instance.slug, payload, cfg);
}
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
} catch (err) {
+74
View File
@@ -0,0 +1,74 @@
'use strict';
// Agent-facing ops (DESIGN.md §5): node-scoped secrets. OpenBao is not present
// in the test env, so @simpleworkjs/bao-conf is mocked.
jest.mock('@simpleworkjs/bao-conf', () => ({
request: jest.fn(async (method, path) => {
if (path.startsWith('secret/data/nodes/')) {
return {
ok: true,
status: 200,
json: async () => ({ data: { data: { username: 'alice', password: 's3cret' } } }),
};
}
return { ok: false, status: 404, json: async () => ({}) };
}),
}));
const { request, app } = require('./setup');
const { Agent } = require('../models/agent');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ops-test-${Date.now().toString(36)}`,
description: 'api_agent_ops test',
enrolledBy: 'test'
});
return { agent, token };
}
describe('Agent ops — POST /api/v1/agent/secrets', () => {
test('an agent can fetch its own node-scoped secrets', async () => {
const { agent, token } = await enrollAgent();
const path = `secret/data/nodes/${agent.id}/db`;
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: [path] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.secrets[path]).toEqual({ username: 'alice', password: 's3cret' });
});
test('a path outside the node scope is rejected', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({ paths: ['secret/data/nodes/other-node/db'] });
expect(res.status).toBe(403);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/agent/secrets')
.send({ paths: ['secret/data/nodes/x/db'] });
expect(res.status).toBe(401);
});
test('missing paths defaults to agent node & resource secrets', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/agent/secrets')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.secrets).toBeDefined();
});
});
+126
View File
@@ -0,0 +1,126 @@
'use strict';
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
// restriction.
const { TEST_CREDS, request, app } = require('./setup');
const { Agent } = require('../models/agent');
const { ApiToken } = require('../models/api_token');
async function enrollAgent() {
const { agent, token } = await Agent.enroll({
name: `ldap-test-${Date.now().toString(36)}`,
description: 'api_ldap test agent',
enrolledBy: 'test'
});
return { agent, token };
}
async function makePat() {
const token = await ApiToken.add({
name: 'ldap-test-pat',
description: 'api_ldap test',
created_by: 'test'
});
return token._raw_token;
}
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
test('valid credentials return the bound DN', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(res.body.uid).toBe(TEST_CREDS.uid);
expect(res.body.dn).toContain(TEST_CREDS.uid);
});
test('wrong password returns 401', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
expect(res.status).toBe(401);
});
test('unknown user returns 401 (no existence oracle)', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: 'no_such_user_xyz', password: 'whatever' });
expect(res.status).toBe(401);
});
test('a PAT caller can bind', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${pat}`)
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(200);
});
test('no bearer token returns 401', async () => {
const res = await request(app)
.post('/api/v1/ldap/bind')
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
expect(res.status).toBe(401);
});
test('missing username/password returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/bind')
.set('Authorization', `Bearer ${token}`)
.send({ username: TEST_CREDS.uid });
expect(res.status).toBe(400);
});
});
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
test('an agent can search the user tree', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
expect(res.status).toBe(200);
expect(res.body.status).toBe('ok');
expect(Array.isArray(res.body.entries)).toBe(true);
expect(res.body.entries.length).toBeGreaterThan(0);
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
});
test('a PAT caller is denied search (agent-only)', async () => {
const pat = await makePat();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${pat}`)
.send({ filter: `(uid=${TEST_CREDS.uid})` });
expect(res.status).toBe(403);
});
test('missing filter returns 400', async () => {
const { token } = await enrollAgent();
const res = await request(app)
.post('/api/v1/ldap/search')
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(400);
});
});
+86
View File
@@ -0,0 +1,86 @@
'use strict';
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
request: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })),
}), { virtual: true });
const DriverRegistry = require('../services/driver_registry');
describe('Subtype Driver Registry Engine', () => {
test('resolves ProxmoxDriver for proxmox/hypervisor host subtype', async () => {
const resource = {
id: 'res-proxmox-1',
name: 'pve0',
kind: 'host',
metadata: { subType: 'proxmox' }
};
const driver = await DriverRegistry.resolveDriver(resource);
expect(driver.name).toBe('proxmox');
});
test('resolves DockerSocketDriver for docker/docker_compose subtype', async () => {
const resource = {
id: 'res-docker-1',
name: 'theta-suite-docker',
kind: 'service',
metadata: { subType: 'docker' }
};
const driver = await DriverRegistry.resolveDriver(resource);
expect(driver.name).toBe('docker_socket');
});
test('resolves DbDriver for redis, postgresql, openbao_vault subtypes', async () => {
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
const pgRes = { id: 'r2', metadata: { subType: 'postgresql' } };
const vaultRes = { id: 'r3', metadata: { subType: 'openbao_vault' } };
expect((await DriverRegistry.resolveDriver(redisRes)).name).toBe('database');
expect((await DriverRegistry.resolveDriver(pgRes)).name).toBe('database');
expect((await DriverRegistry.resolveDriver(vaultRes)).name).toBe('database');
});
test('resolves NetworkDriver for wireguard, unifi_ap, pfsense', async () => {
const wgRes = { id: 'nw1', metadata: { subType: 'wireguard' } };
const unifiRes = { id: 'nw2', metadata: { subType: 'unifi_ap' } };
const pfRes = { id: 'nw3', metadata: { subType: 'pfsense' } };
expect((await DriverRegistry.resolveDriver(wgRes)).name).toBe('network');
expect((await DriverRegistry.resolveDriver(unifiRes)).name).toBe('network');
expect((await DriverRegistry.resolveDriver(pfRes)).name).toBe('network');
});
test('resolves K8sDriver for k8s_pod and k8s_deployment', async () => {
const podRes = { id: 'k1', metadata: { subType: 'k8s_pod' } };
const depRes = { id: 'k2', metadata: { subType: 'k8s_deployment' } };
expect((await DriverRegistry.resolveDriver(podRes)).name).toBe('kubernetes');
expect((await DriverRegistry.resolveDriver(depRes)).name).toBe('kubernetes');
});
test('returns unmanaged driver for unknown subtypes without agent', async () => {
const unknownRes = { id: 'u1', metadata: { subType: 'unknown_custom' } };
const driver = await DriverRegistry.resolveDriver(unknownRes);
expect(driver.name).toBe('unmanaged');
});
test('fetches metrics via resolved driver', async () => {
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
const metrics = await DriverRegistry.getMetrics(redisRes);
expect(metrics.status).toBe('online');
expect(metrics.driver).toBe('database');
expect(metrics.redis).toBeDefined();
expect(metrics.redis.connectedClients).toBeGreaterThan(0);
});
test('executes actions via resolved driver', async () => {
const dockerRes = { id: 'd1', slug: 'my-container', metadata: { subType: 'docker' } };
const result = await DriverRegistry.execAction(dockerRes, 'restart');
expect(result.status).toBe('ok');
expect(result.driver).toBe('docker_socket');
expect(result.action).toBe('restart');
});
});
+80
View File
@@ -0,0 +1,80 @@
'use strict';
let mockBaoStore = new Map();
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
}));
describe('jump_client', () => {
let jumpClient;
let originalFetch;
let mockFetchImpl;
let calls;
beforeEach(() => {
jest.resetModules();
mockBaoStore = new Map();
calls = [];
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ status: 'ok', gateways: [] }) });
originalFetch = global.fetch;
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
jumpClient = require('../utils/jump_client');
jumpClient._reset();
delete process.env.JUMP_INTERNAL_URL;
});
afterEach(() => {
global.fetch = originalFetch;
});
test('reports null count (not zero) when JUMP_INTERNAL_URL is not configured', async () => {
const result = await jumpClient.getGatewayCount();
expect(result.count).toBeNull();
expect(result.note).toMatch(/JUMP_INTERNAL_URL/);
expect(calls.length).toBe(0);
});
test('reports null count when no token is stored in OpenBao', async () => {
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
const result = await jumpClient.getGatewayCount();
expect(result.count).toBeNull();
expect(result.note).toMatch(/no jump-host API token/);
expect(calls.length).toBe(0);
});
test('returns the real gateway count on success', async () => {
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
mockFetchImpl = async () => ({
ok: true, status: 200,
json: async () => ({ status: 'ok', gateways: [{ siteSlug: '(self)' }, { siteSlug: 'site-b' }] })
});
const result = await jumpClient.getGatewayCount();
expect(result.count).toBe(2);
expect(result.note).toBe('ok');
expect(calls[0][0]).toBe('http://jump-host.internal/api/mesh/gateways');
expect(calls[0][1].headers.Authorization).toBe('Bearer jmp_test_token');
});
test('reports null count on a non-2xx response', async () => {
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
mockFetchImpl = async () => ({ ok: false, status: 403 });
const result = await jumpClient.getGatewayCount();
expect(result.count).toBeNull();
expect(result.note).toMatch(/HTTP 403/);
});
test('reports a network failure without throwing', async () => {
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
mockFetchImpl = async () => { throw new Error('connection refused'); };
const result = await jumpClient.getGatewayCount();
expect(result.count).toBeNull();
expect(result.note).toMatch(/failed: connection refused/);
});
});
+44
View File
@@ -0,0 +1,44 @@
require('./setup');
const { SiteSpoke } = require('../models/site_spoke');
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
describe('ldap_replication', () => {
beforeEach(async () => {
const all = await SiteSpoke.list();
for (const s of all) await s.delete();
});
describe('ldapHostFor', () => {
test('derives ldaps://<host>:636 from an http(s) endpoint, ignoring its own port', () => {
expect(ldapHostFor('https://sso.site2.example.com')).toBe('ldaps://sso.site2.example.com:636');
expect(ldapHostFor('https://sso.site2.example.com:8443')).toBe('ldaps://sso.site2.example.com:636');
expect(ldapHostFor('http://sso.site3.example.com')).toBe('ldaps://sso.site3.example.com:636');
});
test('returns null for an unparseable endpoint', () => {
expect(ldapHostFor('not-a-url')).toBeNull();
expect(ldapHostFor('')).toBeNull();
});
});
describe('nextFreeLdapServerId', () => {
test('starts at 2 (1 is reserved for the master) when no spokes are registered', async () => {
await expect(nextFreeLdapServerId()).resolves.toBe(2);
});
test('picks the lowest free id, not just the next highest', async () => {
const now = Math.floor(Date.now() / 1000);
await SiteSpoke.create({ id: 'a', endpoint: 'https://a.example.com', pushToken: 'tok-a', created_on: now, ldapServerId: 2 });
await SiteSpoke.create({ id: 'b', endpoint: 'https://b.example.com', pushToken: 'tok-b', created_on: now, ldapServerId: 4 });
await expect(nextFreeLdapServerId()).resolves.toBe(3);
});
test('ignores spokes with no ldapServerId assigned yet', async () => {
const now = Math.floor(Date.now() / 1000);
await SiteSpoke.create({ id: 'c', endpoint: 'https://c.example.com', pushToken: 'tok-c', created_on: now });
await expect(nextFreeLdapServerId()).resolves.toBe(2);
});
});
});
+48
View File
@@ -10,6 +10,7 @@ jest.mock('node-nmap', () => {
this.targetRange = targetRange;
this.customFlags = customFlags;
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
this.rawData = '';
}
startScan() {
setImmediate(() => {
@@ -18,6 +19,15 @@ jest.mock('node-nmap', () => {
]);
});
}
// Real node-nmap's rawDataHandler XML-parses this.rawData then calls
// this.scanComplete(results), which emits 'complete' -- the mock skips
// straight to emitting the same shape so the RTTVAR-recovery test below
// exercises the exact call our plugin code makes.
rawDataHandler() {
this.emit('complete', [
{ ip: '192.168.1.20', hostname: 'host-20', openPorts: [] }
]);
}
}
return {
NmapScan: MockNmapScan,
@@ -43,4 +53,42 @@ describe('nmap discovery plugin', () => {
expect(result.resources[0].name).toBe('host-10');
expect(result.edges).toHaveLength(1);
});
test('recovers a scan that completed despite nmap\'s benign RTTVAR stderr warning', async () => {
// Regression: node-nmap treats ANY stderr output as fatal, including
// nmap's own harmless RTT-calibration message -- which discards a scan
// that actually succeeded. Simulate that by emitting 'error' with the
// RTTVAR text instead of 'complete', with rawData present.
const nmapModule = require('node-nmap');
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
nmapModule.NmapScan.prototype.startScan = function () {
this.rawData = '<nmaprun>...</nmaprun>';
setImmediate(() => {
this.emit('error', new Error('RTTVAR has grown to over 2.3 seconds, decreasing to 2.0'));
});
};
try {
const result = await nmapPlugin.discover({ targetRange: '192.168.1.0/24' });
expect(result.resources.some((r) => r.name === 'host-20')).toBe(true);
} finally {
nmapModule.NmapScan.prototype.startScan = originalStartScan;
}
});
test('still rejects a genuine error even when the message differs from RTTVAR', async () => {
const nmapModule = require('node-nmap');
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
nmapModule.NmapScan.prototype.startScan = function () {
setImmediate(() => {
this.emit('error', new Error('nmap: permission denied'));
});
};
try {
await expect(nmapPlugin.discover({ targetRange: '192.168.1.0/24' })).rejects.toThrow('permission denied');
} finally {
nmapModule.NmapScan.prototype.startScan = originalStartScan;
}
});
});
+118
View File
@@ -0,0 +1,118 @@
'use strict';
const fs = require('fs');
const path = require('path');
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
// nothing catches it until the line actually runs, so it can sit in a rarely
// exercised path indefinitely.
//
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
// delivery had simply never worked.
const ORM_MODELS = [
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
'Agent', 'AgentJoinKey',
];
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
const ROOT = path.join(__dirname, '..');
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
function walk(dir, out = []) {
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules') continue;
walk(full, out);
} else if (entry.name.endsWith('.js')) {
out.push(full);
}
}
return out;
}
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
// isn't reported as the bug.
function stripComments(src) {
return src
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/(^|[^:])\/\/.*$/gm, '$1');
}
test('no source file calls an ORM static that does not exist', () => {
const pattern = new RegExp(
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
'g'
);
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
const m = line.match(pattern);
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1}${m.join(', ')}`);
});
}
}
expect(offenders).toEqual([]);
});
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
// threw "Email.send is not a function", so the Test Email button could never
// have worked.
test('the email module exports Mail.send and callers destructure it', () => {
const mod = require('../models/email');
expect(typeof mod.Mail).toBe('object');
expect(typeof mod.Mail.send).toBe('function');
// The bare module has no send() -- this is exactly the mistake to catch.
expect(mod.send).toBeUndefined();
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
const src = stripComments(fs.readFileSync(file, 'utf8'));
// `X = require('...email')` followed by `X.send(` where X was not
// destructured.
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
.map(m => m[1]);
for (const name of assigned) {
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
offenders.push(`${path.relative(ROOT, file)}${name}.send(), but the module exports {Mail}`);
}
}
}
}
expect(offenders).toEqual([]);
});
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
// (which test-sms used to POST to with Basic auth) does not exist -- it
// returned an HTML page, so response.json() threw
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
test('nothing targets the non-existent api.voip.ms host', () => {
const offenders = [];
for (const dir of SCAN_DIRS) {
for (const file of walk(path.join(ROOT, dir))) {
// Comments stripped: the note in routes/api_conf.js explaining this
// very bug names the bad host, and describing a mistake is not
// making it.
const src = stripComments(fs.readFileSync(file, 'utf8'));
src.split('\n').forEach((line, i) => {
if (line.includes('api.voip.ms')) {
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
}
});
}
}
expect(offenders).toEqual([]);
});
+1 -1
View File
@@ -46,7 +46,7 @@ describe('plugin_registry', () => {
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
expect(registry.secretKeys('unifi')).toEqual(['password']);
expect(registry.secretKeys('nmap')).toEqual([]);
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
expect(registry.publicKeys('nmap').sort()).toEqual(['autoPromote', 'location', 'targetRange']);
});
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
+98
View File
@@ -0,0 +1,98 @@
'use strict';
let mockBaoStore = new Map();
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
}));
describe('proxy_client', () => {
let proxyClient;
let originalFetch;
let mockFetchImpl;
let calls;
beforeEach(() => {
jest.resetModules();
mockBaoStore = new Map();
calls = [];
mockFetchImpl = async () => ({ ok: true, status: 404 });
originalFetch = global.fetch;
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
proxyClient = require('../utils/proxy_client');
proxyClient._reset();
delete process.env.PROXY_INTERNAL_URL;
});
afterEach(() => {
global.fetch = originalFetch;
});
test('skips cleanly when required fields are missing', async () => {
const result = await proxyClient.ensureRelayRoute({ host: '', ip: '', targetPort: 0 });
expect(result.note).toMatch(/required/);
expect(calls.length).toBe(0);
});
test('skips cleanly when PROXY_INTERNAL_URL is not configured', async () => {
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toMatch(/PROXY_INTERNAL_URL/);
expect(calls.length).toBe(0);
});
test('skips cleanly when no token is stored in OpenBao', async () => {
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toMatch(/no proxy API token/);
expect(calls.length).toBe(0);
});
test('creates the route when the host does not already exist', async () => {
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
mockFetchImpl = async (url, opts) => {
if (opts.method === undefined) return { ok: true, status: 404 }; // GET lookup
if (opts.method === 'POST') return { ok: true, status: 200 };
throw new Error('unexpected method ' + opts.method);
};
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toBe('created');
const postCall = calls.find((c) => c[1].method === 'POST');
expect(postCall[0]).toBe('https://proxy.internal/api/host');
expect(postCall[1].headers.Authorization).toBe('Bearer prx_test_token');
expect(JSON.parse(postCall[1].body)).toEqual({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
});
test('updates the route when it exists but points somewhere else', async () => {
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
mockFetchImpl = async (url, opts) => {
if (!opts.method) return { ok: true, status: 200, json: async () => ({ results: { ip: '172.24.9.9', targetPort: 3001 } }) };
if (opts.method === 'PUT') return { ok: true, status: 200 };
throw new Error('unexpected method ' + opts.method);
};
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toBe('updated');
});
test('is a no-op when the route already matches', async () => {
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ results: { ip: '172.24.5.1', targetPort: 3001 } }) });
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toBe('already up to date');
});
test('reports a network failure without throwing', async () => {
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
mockFetchImpl = async () => { throw new Error('connection refused'); };
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
expect(result.note).toMatch(/failed: connection refused/);
});
});
+26 -3
View File
@@ -1,5 +1,5 @@
require('./setup');
const { Resource } = require('../models/resource');
const { Resource, ResourceGroup } = require('../models/resource');
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
describe('DiscoveryReconciler', () => {
@@ -25,7 +25,7 @@ describe('DiscoveryReconciler', () => {
await DiscoveryReconciler.reconcile('test-plugin', payload);
const all = await Resource.list();
const all = (await Resource.list()).filter(r => r.kind !== 'site');
expect(all).toHaveLength(1);
expect(all[0].name).toBe('New Host');
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
@@ -57,7 +57,7 @@ describe('DiscoveryReconciler', () => {
}]
});
const all = await Resource.list();
const all = (await Resource.list()).filter(r => r.kind !== 'site');
expect(all).toHaveLength(1); // Should have merged, not created a new one
const merged = all[0];
@@ -72,4 +72,27 @@ describe('DiscoveryReconciler', () => {
expect(merged.metadata.interfaces).toHaveLength(1);
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
});
it('does not duplicate access/admin groups across repeated autoPromote passes', async () => {
// Regression: autoPromote used to call ResourceGroup.create() directly
// with no existence check, so reconciling the same managed resource
// more than once (e.g. a Proxmox cluster reporting one LXC from
// multiple nodes) accumulated duplicate access/admin rows every pass.
const payload = {
resources: [{
kind: 'host',
name: 'LXC 127',
slug: 'lxc-127',
metadata: { interfaces: [{ mac: '00:11:22:33:44:99', ip: '10.0.0.99' }] }
}]
};
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
const resource = (await Resource.list()).find((r) => r.slug === 'lxc-127');
const groups = await ResourceGroup.list({ where: { resourceId: resource.id } });
expect(groups.map((g) => g.groupCn).sort()).toEqual(['lxc-127_access', 'lxc-127_admin']);
});
});
+62
View File
@@ -0,0 +1,62 @@
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
// Point SITE_CONFIG_FILE at a fresh temp path and clear the env defaults so
// each test observes a known state. jest.resetModules() gives a fresh module
// (the `current` cache is module-scoped).
function freshEnv(overrides = {}) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-cfg-'));
const file = path.join(dir, 'site.json');
for (const k of ['IS_MASTER', 'MASTER_URL', 'SITE_SLUG', 'SITE_CONFIG_FILE']) delete process.env[k];
process.env.SITE_CONFIG_FILE = file;
Object.assign(process.env, overrides);
jest.resetModules();
return { file };
}
test('site_config defaults to a fresh master / site-default with no file', () => {
freshEnv();
const sc = require('../utils/site_config');
const c = sc.get();
expect(c.isMaster).toBe(true);
expect(c.masterUrl).toBe('');
expect(c.siteSlug).toBe('site-default');
expect(c.wanConnected).toBe(true);
});
test('site_config honors the env seed values', () => {
freshEnv({ IS_MASTER: 'false', MASTER_URL: 'https://m.example.com', SITE_SLUG: 'site-east' });
const sc = require('../utils/site_config');
const c = sc.get();
expect(c.isMaster).toBe(false);
expect(c.masterUrl).toBe('https://m.example.com');
expect(c.siteSlug).toBe('site-east');
});
test('site_config save persists and a fresh require reloads it', () => {
const { file } = freshEnv();
const sc = require('../utils/site_config');
sc.save({ isMaster: false, masterUrl: 'https://m.example.com', siteSlug: 'site-east' });
const onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
expect(onDisk.isMaster).toBe(false);
expect(onDisk.siteSlug).toBe('site-east');
jest.resetModules();
const sc2 = require('../utils/site_config');
const c = sc2.get();
expect(c.isMaster).toBe(false);
expect(c.masterUrl).toBe('https://m.example.com');
expect(c.siteSlug).toBe('site-east');
});
test('site_config save returns the merged config', () => {
freshEnv();
const sc = require('../utils/site_config');
const c = sc.save({ masterUrl: 'https://m.example.com' });
expect(c.isMaster).toBe(true); // untouched default survives
expect(c.masterUrl).toBe('https://m.example.com');
});
+150
View File
@@ -0,0 +1,150 @@
'use strict';
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
// In-memory model stubs so importDirectory can be exercised without a DB.
function makeStore() {
const rows = [];
const edges = [];
return {
Resource: {
list: async () => rows.map(r => ({ ...r })),
create: async (d) => { rows.push({ ...d }); return { ...d }; },
update: async (id, d) => {
const i = rows.findIndex(r => r.id === id);
if (i >= 0) rows[i] = { ...rows[i], ...d };
return rows[i];
},
get rows() { return rows; }
},
ResourceEdge: {
list: async () => edges.map(e => ({ ...e })),
create: async (d) => { edges.push({ ...d }); return { ...d }; },
delete: async (id) => {
const i = edges.findIndex(e => e.id === id);
if (i >= 0) edges.splice(i, 1);
},
get rows() { return edges; }
}
};
}
test('importDirectory creates new resources and edges', async () => {
const s = makeStore();
const exportData = {
resources: [
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
],
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
};
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
expect(s.Resource.rows.length).toBe(2);
expect(s.ResourceEdge.rows.length).toBe(1);
expect(res.created).toBe(2);
expect(res.updated).toBe(0);
expect(res.edgeCount).toBe(1);
expect(s.Resource.rows[0].slug).toBe('site_main-office');
});
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
const s = makeStore();
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
const exportData = {
resources: [
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
],
edges: []
};
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
expect(s.Resource.rows[0].name).toBe('web01-new');
expect(res.updated).toBe(1);
});
test('importDirectory clears stale edges then recreates from master', async () => {
const s = makeStore();
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
const exportData = {
resources: [
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
],
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
};
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
expect(edgeIds).toContain('e9');
expect(edgeIds).not.toContain('stale');
});
test('scalarResource strips relation fields but keeps metadata', () => {
const o = scalarResource({
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
toJSON() { return this; }
});
expect(o.edgesAsParent).toBeUndefined();
expect(o.edgesAsChild).toBeUndefined();
expect(o.groups).toBeUndefined();
expect(o.metadata.a).toBe(1);
});
test('scalarEdge keeps parentId/childId/relation', () => {
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
});
test('ldapAddArgs builds a continue-on-error admin bind', () => {
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
expect(a).toContain('-c');
expect(a).toContain('-x');
expect(a).toContain('cn=admin,dc=example,dc=com');
expect(a).toContain('/tmp/x.ldif');
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
});
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
});
// The fresh-install guard: only no-users-beyond-admin + no-agents may join.
test('siteIsFresh is true with only the bootstrap admin and no agents', async () => {
const User = { listDetail: async () => [{ uid: 'admin', isServiceAccount: false }] };
const Agent = { list: async () => [] };
expect(await siteIsFresh({ User, Agent })).toBe(true);
});
test('siteIsFresh is false with a second real user', async () => {
const User = { listDetail: async () => [{ uid: 'admin' }, { uid: 'bob' }] };
const Agent = { list: async () => [] };
expect(await siteIsFresh({ User, Agent })).toBe(false);
});
test('siteIsFresh is false with an enrolled agent', async () => {
const User = { listDetail: async () => [{ uid: 'admin' }] };
const Agent = { list: async () => [{ id: 'a1' }] };
expect(await siteIsFresh({ User, Agent })).toBe(false);
});
test('siteIsFresh ignores service accounts', async () => {
const User = { listDetail: async () => [
{ uid: 'admin' },
{ uid: 'sso-svc', isServiceAccount: true },
{ uid: 'ldapclient', isServiceAccount: true }
] };
const Agent = { list: async () => [] };
expect(await siteIsFresh({ User, Agent })).toBe(true);
});
+129
View File
@@ -0,0 +1,129 @@
'use strict';
// In-memory stand-in for the SiteSpoke ORM model.
let spokeStore;
function makeSpokeMock() {
spokeStore = [];
return {
list: jest.fn(async () => [...spokeStore]),
_seed(rows) { spokeStore.push(...rows); }
};
}
let mockFetchCalls = [];
let mockFetchImpl = async () => ({ ok: true, status: 200 });
describe('site_replicate', () => {
let siteReplicate;
let SiteSpoke;
let originalFetch;
beforeEach(() => {
jest.resetModules();
mockFetchCalls = [];
mockFetchImpl = async () => ({ ok: true, status: 200 });
jest.doMock('../models/site_spoke', () => ({ SiteSpoke: makeSpokeMock() }));
siteReplicate = require('../utils/site_replicate');
SiteSpoke = require('../models/site_spoke').SiteSpoke;
// site_replicate.js uses the global fetch (Node 18+ built-in), not
// node-fetch -- stub that directly.
originalFetch = global.fetch;
global.fetch = (...args) => { mockFetchCalls.push(args); return mockFetchImpl(...args); };
});
afterEach(() => {
global.fetch = originalFetch;
});
test('pushes to every known spoke concurrently with its own pushToken', async () => {
SiteSpoke._seed([
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' },
{ endpoint: 'https://spoke-b.example.com', pushToken: 'token-b' }
]);
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(2);
const urls = mockFetchCalls.map((c) => c[0]).sort();
expect(urls).toEqual(['https://spoke-a.example.com/api/site/resync', 'https://spoke-b.example.com/api/site/resync']);
const [, optsA] = mockFetchCalls.find((c) => c[0].includes('spoke-a'));
expect(optsA.headers.Authorization).toBe('Bearer token-a');
expect(JSON.parse(optsA.body).reason).toBe('catalog-changed');
});
test('prefers the mesh IP over the public endpoint when the spoke reported one', async () => {
SiteSpoke._seed([
{ endpoint: 'https://spoke-a.example.com:8443', pushToken: 'token-a', meshIp: '172.24.5.1' }
]);
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(1);
expect(mockFetchCalls[0][0]).toBe('http://172.24.5.1:8443/api/site/resync');
});
test('falls back to the public endpoint if the mesh attempt fails', async () => {
SiteSpoke._seed([
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a', meshIp: '172.24.5.1' }
]);
mockFetchImpl = async (url) => {
if (url.startsWith('http://172.24.5.1')) throw new Error('mesh unreachable');
return { ok: true, status: 200 };
};
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(2);
expect(mockFetchCalls[0][0]).toMatch(/^http:\/\/172\.24\.5\.1/);
expect(mockFetchCalls[1][0]).toBe('https://spoke-a.example.com/api/site/resync');
});
test('a spoke with no meshIp only ever tries the public endpoint', async () => {
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(1);
expect(mockFetchCalls[0][0]).toBe('https://spoke-a.example.com/api/site/resync');
});
test('no known spokes: resolves cleanly, no fetch calls', async () => {
await siteReplicate.replicateToSpokes('catalog-changed');
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(0);
});
test('one spoke failing does not prevent delivery to another', async () => {
SiteSpoke._seed([
{ endpoint: 'https://dead-spoke.example.com', pushToken: 'token-dead' },
{ endpoint: 'https://live-spoke.example.com', pushToken: 'token-live' }
]);
mockFetchImpl = async (url) => {
if (url.includes('dead-spoke')) throw new Error('connection refused');
return { ok: true, status: 200 };
};
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
await new Promise((r) => setImmediate(r));
expect(mockFetchCalls.length).toBe(2);
});
test('a non-2xx response from a spoke does not throw out of replicateToSpokes', async () => {
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
mockFetchImpl = async () => ({ ok: false, status: 500 });
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
});
test('SiteSpoke.list() throwing does not propagate to the caller', async () => {
SiteSpoke.list = jest.fn(async () => { throw new Error('db unavailable'); });
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
expect(mockFetchCalls.length).toBe(0);
});
});
+26
View File
@@ -0,0 +1,26 @@
'use strict';
// Authenticate an agent from a Bearer token (the same token the agent presents
// on its WSS channel). Used by agent-facing REST endpoints (secrets, IAM) that
// are NOT admin-gated — the caller is the agent itself, not an admin session.
const { Agent } = require('../models/agent');
// Resolve a Bearer token to its (non-revoked) Agent, or null. Every failure
// collapses to null so a probing caller learns nothing about which part was
// wrong.
async function authenticateAgent(req) {
const auth = req.headers['authorization'] || '';
const m = /^Bearer\s+(.+)$/i.exec(auth);
if (!m) return null;
const token = String(m[1]).trim();
if (!token) return null;
try {
const agent = await Agent.authenticate(token);
return agent || null;
} catch (_) {
return null;
}
}
module.exports = { authenticateAgent };
+25 -1
View File
@@ -90,10 +90,34 @@ function status() {
return { available: !!cached, error: loadError };
}
// Overwrite the stored key with material handed over by a master (multi-site
// "identical directories" — MULTI_SITE_SPEC.md §2). Every site sharing one
// signing key is what lets any site's sso-manager validly sign a command for
// an agent enrolled at any other site, at the accepted cost that compromising
// ANY one site's OpenBao is equivalent to compromising all of them for agent
// command authority. That tradeoff was deliberately accepted for this
// deployment's scale (a handful of trusted sites) -- do not call this to sync
// keys across a boundary where sites don't trust each other equally.
//
// Idempotent: adopting the same key material twice (e.g. on every resync
// ping) is a no-op past the first call.
async function adopt({ privateKeyPem, publicKeyPem }) {
if (!privateKeyPem || !publicKeyPem) throw new Error('adopt() requires both privateKeyPem and publicKeyPem');
if (cached && cached.privateKeyPem === privateKeyPem && cached.publicKeyPem === publicKeyPem) {
return cached; // already holding this exact key -- nothing to do
}
const material = { privateKeyPem, publicKeyPem };
await baoConf.set(PATH, material);
cached = { ...material, publicKeyBase64: rawPublicKeyBase64(publicKeyPem) };
loadError = null;
console.log('[agent_keys] adopted signing key from master (multi-site identical-directory sync)');
return cached;
}
// Test seam: drop the in-process cache.
function _reset() {
cached = null;
loadError = null;
}
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH };
module.exports = { load, status, adopt, rawPublicKeyBase64, _reset, PATH };
+150 -62
View File
@@ -21,12 +21,17 @@ class AgentManager {
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
*/
canonicalize(payload) {
const cleanObj = {};
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
for (const key of sortedKeys) {
cleanObj[key] = payload[key];
}
return JSON.stringify(cleanObj);
const sortObj = (val) => {
if (val === null || typeof val !== 'object') return val;
if (Array.isArray(val)) return val.map(sortObj);
const sorted = {};
const keys = Object.keys(val).filter(k => k !== 'signature').sort();
for (const k of keys) {
sorted[k] = sortObj(val[k]);
}
return sorted;
};
return JSON.stringify(sortObj(payload));
}
/**
@@ -113,79 +118,40 @@ class AgentManager {
async handleDiscovery(agent, payload) {
const discovery = {
version: payload.version || payload.agent_version || 'unknown',
hostname: payload.hostname || '',
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
public_ip: payload.public_ip || '',
os: payload.os || '',
kernel: payload.kernel || '',
cpu: payload.cpu || '',
cpu_details: payload.cpu_details || {},
ram_total_gb: payload.ram_total_gb || 0,
ram_details: payload.ram_details || {},
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
disks: Array.isArray(payload.disks) ? payload.disks : [],
logged_users: Array.isArray(payload.logged_users) ? payload.logged_users : [],
host_details: payload.host_details || {},
location: payload.location || 'default',
// The agent's enabled capabilities (from its local agent.yml). The agent
// is the authoritative source for what it will actually do.
capabilities: payload.capabilities || {}
};
await this.touch(agent, { lastDiscovery: discovery });
await this.touch(agent, { version: discovery.version, lastDiscovery: discovery });
await this.applyDiscoveryToDirectory(agent, discovery);
}
// An agent runs ON the host it describes, which makes it the most
// authoritative source the directory has -- more so than a hypervisor API or
// a network scan. It previously updated nothing at all: the facts sat on an
// in-memory record and were lost on disconnect.
//
// When the agent is bound to a resource we write that row directly; guessing
// is only for an unbound agent, and then we let the shared reconciler do the
// matching (same MAC/IP/name rules every other source goes through) rather
// than inventing a second matcher here.
async applyDiscoveryToDirectory(agent, discovery) {
try {
const { Resource } = require('../models/resource');
const metadata = {
os: discovery.os || undefined,
kernel: discovery.kernel || undefined,
cpu: discovery.cpu || undefined,
ram_total_gb: discovery.ram_total_gb || undefined,
disk_total_gb: discovery.disk_total_gb || undefined,
ip: (discovery.ip_addresses || [])[0] || undefined,
agentId: agent.id,
last_seen: Date.now()
};
// Drop undefined so a field the agent could not determine never
// overwrites a good value already in the directory.
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
if (agent.resourceId) {
const resource = await Resource.get(agent.resourceId);
if (!resource) return;
const merged = { ...(resource.metadata || {}), ...metadata };
const sources = new Set(merged.discovery_sources || []);
sources.add('theta-agent');
merged.discovery_sources = [...sources];
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
return;
}
if (!discovery.hostname) return;
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
await DiscoveryReconciler.reconcile('theta-agent', {
resources: [{
kind: 'host',
name: discovery.hostname,
slug: `agent-${agent.id.slice(0, 8)}`,
metadata: { ...metadata, subType: 'linux' }
}],
edges: []
});
} catch (err) {
// Never let a directory write break the agent connection.
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
}
}
async handleTelemetry(agent, payload) {
await this.touch(agent, {
lastTelemetry: {
cpu_usage_percent: payload.cpu_usage_percent || 0,
cpu_details: payload.cpu_details || {},
ram_usage_percent: payload.ram_usage_percent || 0,
ram_details: payload.ram_details || {},
disk_usage_percent: payload.disk_usage_percent || 0,
disks: Array.isArray(payload.disks) ? payload.disks : [],
logged_users: Array.isArray(payload.logged_users) ? payload.logged_users : [],
host_details: payload.host_details || {},
zfs_health: payload.zfs_health || 'N/A',
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
timestamp: payload.timestamp || new Date().toISOString()
@@ -242,11 +208,133 @@ class AgentManager {
};
}
// Find connected/enrolled agent bound to a resource ID (or inherited from parent Host).
async getAgentForResource(resourceId) {
if (!resourceId) return null;
const rows = await Agent.list().catch(() => []);
let agent = rows.find(a => a.resourceId === resourceId);
if (!agent) {
try {
const { Resource } = require('../models/resource');
const { ResourceEdge } = require('../models/resource');
const res = await Resource.get(resourceId);
if (res && res.kind === 'service') {
const edges = await ResourceEdge.list({ where: { childId: resourceId } });
for (const edge of edges) {
const parentRes = await Resource.get(edge.parentId);
if (parentRes && parentRes.kind === 'host') {
agent = rows.find(a => a.resourceId === parentRes.id || (parentRes.metadata && parentRes.metadata.agentId === a.id));
if (agent) break;
}
}
}
} catch (err) {
console.error('[AgentManager] parent agent lookup error:', err.message);
}
}
if (!agent) return null;
return agent.toPublic(this.liveState(agent.id));
}
// Every enrolled agent, connected or not.
async listAgents() {
const rows = await Agent.list();
return rows.map(a => a.toPublic(this.liveState(a.id)));
}
// An agent runs ON the host it describes, which makes it the most
// authoritative source the directory has -- more so than a hypervisor API or
// a network scan. It previously updated nothing at all: the facts sat on an
// in-memory record and were lost on disconnect.
//
// When the agent is bound to a resource we write that row directly; guessing
// is only for an unbound agent, and then we let the shared reconciler do the
// matching (same MAC/IP/name rules every other source goes through) rather
// than inventing a second matcher here.
async applyDiscoveryToDirectory(agent, discovery) {
try {
const { Resource } = require('../models/resource');
const metadata = {
os: discovery.os || undefined,
kernel: discovery.kernel || undefined,
cpu: discovery.cpu || undefined,
ram_total_gb: discovery.ram_total_gb || undefined,
disk_total_gb: discovery.disk_total_gb || undefined,
ip: (discovery.ip_addresses || [])[0] || undefined,
public_ip: discovery.public_ip || undefined,
agentId: agent.id,
last_seen: Date.now()
};
// Drop undefined so a field the agent could not determine never
// overwrites a good value already in the directory.
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
if (agent.resourceId) {
const resource = await Resource.get(agent.resourceId);
if (!resource) return;
const merged = { ...(resource.metadata || {}), ...metadata };
const sources = new Set(merged.discovery_sources || []);
sources.add('theta-agent');
merged.discovery_sources = [...sources];
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
return;
}
if (!discovery.hostname) return;
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
const { ResourceEdge } = require('../models/resource');
const hostSlug = `host-${discovery.hostname.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`;
await DiscoveryReconciler.reconcile('theta-agent', {
resources: [{
kind: 'host',
name: discovery.hostname,
slug: hostSlug,
metadata: { ...metadata, subType: 'linux', managed: true }
}],
edges: []
});
// Find the matched or created host resource
const allHosts = await Resource.list({ where: { kind: 'host' } });
const hostRes = allHosts.find(r =>
r.name.toLowerCase() === discovery.hostname.toLowerCase() ||
r.slug === hostSlug ||
r.metadata?.agentId === agent.id
);
if (hostRes) {
// Bind the agent to its Host resource
await agent.update({ resourceId: hostRes.id }).catch(() => {});
// Attach host to matching Site by Public IP if not already parented
const existingEdges = await ResourceEdge.list({ where: { childId: hostRes.id } });
if (existingEdges.length === 0) {
const sites = await Resource.list({ where: { kind: 'site' } });
let targetSite = null;
if (discovery.public_ip) {
targetSite = sites.find(s => {
const siteIp = (s.metadata?.public_ip || s.metadata?.ip || s.metadata?.address || '').trim();
return siteIp && (siteIp === discovery.public_ip || siteIp.includes(discovery.public_ip));
});
}
if (!targetSite) targetSite = sites[0];
if (targetSite) {
await ResourceEdge.create({
id: crypto.randomUUID(),
parentId: targetSite.id,
childId: hostRes.id,
relation: 'hosts'
}).catch(() => {});
}
}
}
} catch (err) {
// Never let a directory write break the agent connection.
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
}
}
}
module.exports = new AgentManager();
+81
View File
@@ -0,0 +1,81 @@
'use strict';
// Service-to-service client for jump-host's mesh registry -- used by the
// Directory's Multi-Site & Network Gateway Status modal to show the real
// number of gateway-to-gateway WireGuard mesh peers (see MULTI_SITE_SPEC.md),
// instead of counting the unrelated older WireGuard roaming-client/exit-node
// Resources in this app's own catalog (a different subsystem entirely --
// api_directory_admin.js used to filter Resource.list() for
// metadata.subType === 'wireguard', which has nothing to do with the mesh).
//
// Same pattern as utils/proxy_client.js: reuses jump-host's existing
// self-service API token system (models/api_token.js, `jmp_<id>_<secret>`
// bearer tokens) rather than inventing a new credential type. The token must
// be minted by a jump-admin user (GET /api/mesh/gateways requires
// requireJumpAdmin, which checks the token's creator's username/groups, not
// anything the token itself carries) and stored in OpenBao.
const baoConf = require('@simpleworkjs/bao-conf');
const PATH = 'integrations/theta-jump'; // baoConf adds the secret/data prefix
const REQUEST_TIMEOUT_MS = 10000;
let cachedToken = null;
async function loadToken() {
if (cachedToken) return cachedToken;
let stored;
try {
stored = await baoConf.get(PATH);
} catch (err) {
console.error(`[jump_client] could not read ${PATH} from OpenBao: ${err.message}`);
return null;
}
if (!stored || !stored.token) return null;
cachedToken = stored.token;
return cachedToken;
}
function jumpBaseUrl() {
// Not OpenBao -- this is where jump-host's admin API lives, not a secret.
return process.env.JUMP_INTERNAL_URL || '';
}
// Returns { count, note }. count is null (not 0) when the query couldn't run
// at all (not configured, unreachable, unauthorized) -- the modal shows a
// count of gateways it could actually see, not a misleading "0" that reads
// as "you have no mesh peers" when the truth is "this isn't wired up yet".
async function getGatewayCount() {
const base = jumpBaseUrl();
if (!base) {
return { count: null, note: 'skipped: JUMP_INTERNAL_URL not configured' };
}
const token = await loadToken();
if (!token) {
return { count: null, note: `skipped: no jump-host API token at OpenBao ${PATH} -- mint one on jump-host (as a jump-admin user) and store it there` };
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const resp = await fetch(base.replace(/\/+$/, '') + '/api/mesh/gateways', {
headers: { Authorization: 'Bearer ' + token },
signal: controller.signal
});
if (!resp.ok) {
return { count: null, note: `failed: HTTP ${resp.status}` };
}
const body = await resp.json();
const gateways = Array.isArray(body.gateways) ? body.gateways : [];
return { count: gateways.length, note: 'ok' };
} catch (err) {
return { count: null, note: `failed: ${err.message}` };
} finally {
clearTimeout(timer);
}
}
// Test seam.
function _reset() { cachedToken = null; }
module.exports = { getGatewayCount, _reset, PATH };
+44
View File
@@ -0,0 +1,44 @@
'use strict';
// OpenLDAP multi-master replication (docs/replication.md) config derivation,
// shared between routes/api_site.js (the spoke-facing side: assigns a
// ServerID at registration, serves GET /api/site/ldap-peers) and
// routes/api_directory_admin.js (the master-local side: GET
// /directory-admin/ldap-replication-config computes the master's own
// replication config from the same SiteSpoke registry, no HTTP round-trip
// needed since it already has the data).
const { SiteSpoke } = require('../models/site_spoke');
// The master reserves ServerID 1 for itself; every spoke gets the lowest
// free ID from 2 upward, assigned once at registration and reused across
// re-registrations (SiteSpoke.ldapServerId is only ever set on first
// create). Small max, matching mesh_gateway.js's mesh index -- nothing in
// the OpenLDAP protocol requires a small ServerID, but this deployment's
// docs/examples always have.
const MAX_LDAP_SERVER_ID = 4094;
async function nextFreeLdapServerId() {
const spokes = await SiteSpoke.list();
const used = new Set(spokes.map((s) => s.ldapServerId).filter(Boolean));
for (let i = 2; i <= MAX_LDAP_SERVER_ID; i++) {
if (!used.has(i)) return i;
}
throw new Error(`LDAP server ID space exhausted (max ${MAX_LDAP_SERVER_ID} spokes)`);
}
// A site's LDAP replication URL, derived from its already-known HTTP(S)
// endpoint rather than requiring a separately-configured field: same
// hostname, LDAPS port 636 -- exactly the convention docs/replication.md's
// own worked examples already use (ldaps://sso.site2.com:636 alongside
// https://sso.site2.com). No new config an operator has to keep in sync.
function ldapHostFor(endpoint) {
try {
const host = new URL(endpoint).hostname;
return `ldaps://${host}:636`;
} catch (e) {
return null;
}
}
module.exports = { MAX_LDAP_SERVER_ID, nextFreeLdapServerId, ldapHostFor };
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// LDAP byte-pump relay (DESIGN.md §4). The agent forwards raw LDAP bytes from a
// local socket (SSSD) over the WSS channel as `ldap_tunnel` messages; this
// module relays them into the SSO's real OpenLDAP and pipes the responses back.
// The SSO does not parse LDAP either — it is a transparent socket relay.
const net = require('net');
const conf = require('@simpleworkjs/conf').ldap;
// Parse host:port from an ldap:// or ldaps:// URL. The relay connects plaintext
// to the SSO's own slapd (which is plaintext on localhost); an ldaps:// URL
// would need TLS termination here and is not supported yet (DESIGN.md §9.5).
function ldapTarget() {
const url = conf.url || 'ldap://localhost:389';
const m = /^ldaps?:\/\/([^:/]+)(?::(\d+))?/.exec(url);
const host = m ? m[1] : 'localhost';
const port = m && m[2] ? Number(m[2]) : 389;
return { host, port };
}
// Per-agent relay state: agentId -> Map(conn_id -> LDAP socket).
const relays = new Map();
function relayFor(agentId) {
if (!relays.has(agentId)) relays.set(agentId, new Map());
return relays.get(agentId);
}
// Handle one ldap_tunnel message from an agent.
function handleTunnel(agentId, ws, payload) {
const connId = payload.conn_id;
if (!connId) return;
const conns = relayFor(agentId);
// End of connection: close the relay socket.
if (payload.close) {
const sock = conns.get(connId);
if (sock) { sock.destroy(); conns.delete(connId); }
return;
}
const data = Buffer.from(payload.data || '', 'base64');
if (data.length === 0) return;
let sock = conns.get(connId);
if (!sock) {
const { host, port } = ldapTarget();
sock = net.connect(port, host);
conns.set(connId, sock);
// Relay OpenLDAP's responses back to the agent.
sock.on('data', (chunk) => {
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, data: chunk.toString('base64') }
}));
}
});
sock.on('close', () => {
conns.delete(connId);
if (ws.readyState === 1) {
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: connId, close: true }
}));
}
});
sock.on('error', () => { sock.destroy(); });
}
sock.write(data);
}
// Drop every relay socket for an agent (on WSS disconnect).
function cleanup(agentId) {
const conns = relays.get(agentId);
if (conns) {
for (const sock of conns.values()) sock.destroy();
relays.delete(agentId);
}
}
module.exports = { handleTunnel, cleanup };
+105
View File
@@ -0,0 +1,105 @@
'use strict';
// Service-to-service client for theta-proxy's Host management API --
// MULTI_SITE_SPEC.md's "no-inbound relay automation" (a master creating a
// relay route so a spoke with zero inbound path of its own is reachable).
//
// Deliberately does NOT invent a new credential type. theta-proxy already
// has a self-service API token system (models/api_token.js, `prx_<id>_<secret>`
// bearer tokens that authenticate as their creator's user + group snapshot --
// same pattern this app and jump-host both already have their own copy of).
// The "service-to-service auth" gap was never "no credential type exists" --
// it's that nothing wired one of these tokens into an actual inter-service
// call. This is that wiring, using the credential type that was already
// there. The token itself is operator-provisioned (minted on theta-proxy by
// an admin with Host-management rights) and stored in OpenBao, same as the
// agent-signing key in agent_keys.js.
const baoConf = require('@simpleworkjs/bao-conf');
const PATH = 'integrations/theta-proxy'; // baoConf adds the secret/data prefix
const REQUEST_TIMEOUT_MS = 10000;
let cachedToken = null;
async function loadToken() {
if (cachedToken) return cachedToken;
let stored;
try {
stored = await baoConf.get(PATH);
} catch (err) {
console.error(`[proxy_client] could not read ${PATH} from OpenBao: ${err.message}`);
return null;
}
if (!stored || !stored.token) return null;
cachedToken = stored.token;
return cachedToken;
}
function proxyBaseUrl() {
// Not OpenBao -- this is where the proxy's admin API lives, not a secret.
// No safe default: relaying to a guessed host would be worse than
// refusing, so this must be explicitly configured.
return process.env.PROXY_INTERNAL_URL || '';
}
// Creates the relay Host route if missing, updates its target IP if it
// already exists and points somewhere else. Idempotent -- safe to call
// again for the same host on every spoke resync.
//
// Returns { note } describing what happened (created/updated/skipped/failed)
// rather than throwing on a missing token or base URL -- callers (spoke
// registration) must never fail the whole registration just because this
// automation isn't configured yet; it's an enhancement layered on top of a
// working join, not a requirement of one.
async function ensureRelayRoute({ host, ip, targetPort }) {
if (!host || !ip || !targetPort) {
return { note: 'skipped: host, ip, and targetPort are all required' };
}
const base = proxyBaseUrl();
if (!base) {
return { note: 'skipped: PROXY_INTERNAL_URL not configured' };
}
const token = await loadToken();
if (!token) {
return { note: `skipped: no proxy API token at OpenBao ${PATH} -- mint one on theta-proxy and store it there` };
}
const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
try {
const existing = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
headers, signal: controller.signal
});
if (existing.status === 200) {
// GET /api/host/:item wraps the record in { item, results }, not
// flat -- confirmed against a real running proxy (this check
// silently always "updated" instead of no-op'ing until fixed).
const body = await existing.json();
const current = body.results || body;
if (current.ip === ip && Number(current.targetPort) === Number(targetPort)) {
return { note: 'already up to date' };
}
const put = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
method: 'PUT', headers, body: JSON.stringify({ ip, targetPort }), signal: controller.signal
});
return put.ok ? { note: 'updated' } : { note: `update failed: HTTP ${put.status}` };
}
const create = await fetch(base.replace(/\/+$/, '') + '/api/host', {
method: 'POST', headers, body: JSON.stringify({ host, ip, targetPort }), signal: controller.signal
});
return create.ok ? { note: 'created' } : { note: `create failed: HTTP ${create.status}` };
} catch (err) {
return { note: `failed: ${err.message}` };
} finally {
clearTimeout(timer);
}
}
// Test seam.
function _reset() { cachedToken = null; }
module.exports = { ensureRelayRoute, _reset, PATH };
+67
View File
@@ -0,0 +1,67 @@
'use strict';
// Persisted multi-site role (MULTI_SITE_SPEC.md). Whether this node is the
// master authority, which site it belongs to, and the master it replicates
// from live in /config/site.json so they survive restarts (the old code kept
// them in Node memory, so a container recreate silently reverted a spoke back
// to "master").
//
// Boot-time defaults come from the environment (IS_MASTER / MASTER_URL /
// SITE_SLUG, which docker-compose passes); a written site.json overrides for
// the life of the deployment. site-promote and the site-join flow both write
// here.
const fs = require('fs');
const path = require('path');
// Overridable so tests can point at a temp file instead of /config/site.json.
function configFile() {
return process.env.SITE_CONFIG_FILE || '/config/site.json';
}
function envDefaults() {
return {
isMaster: process.env.IS_MASTER ? process.env.IS_MASTER === 'true' : true,
masterUrl: process.env.MASTER_URL || '',
siteSlug: process.env.SITE_SLUG || 'site-default',
wanConnected: true
};
}
let current = null;
function load() {
const env = envDefaults();
const file = configFile();
try {
if (fs.existsSync(file)) {
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
return { ...env, ...saved };
}
} catch (e) {
console.error('[site] could not read ' + file + ': ' + e.message);
}
return env;
}
// get returns the current site config.
function get() {
if (!current) current = load();
return { ...current };
}
// save merges a patch and persists it to the site config file.
function save(patch) {
current = { ...get(), ...patch };
const file = configFile();
try {
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
} catch (e) {
console.error('[site] could not write ' + file + ': ' + e.message);
throw e;
}
return get();
}
module.exports = { get, save, configFile };
+125
View File
@@ -0,0 +1,125 @@
'use strict';
// Pure, testable helpers for the multi-site join flow (MULTI_SITE_SPEC.md).
// routes/api_site.js wires these to Express + the live models + slapcat/ldapadd;
// tests exercise importDirectory with in-memory model stubs.
// scalarResource reduces a Resource row to its scalar columns so it can be
// re-created on a spoke without dragging hasMany relation fields along.
function scalarResource(r) {
const o = (r && r.toJSON) ? r.toJSON() : (r || {});
return {
id: o.id,
kind: o.kind,
name: o.name,
slug: o.slug,
owner: o.owner || null,
description: o.description || null,
metadata: o.metadata || {},
created_by: o.created_by || null,
created_on: o.created_on || null,
updated_by: o.updated_by || null,
updated_on: o.updated_on || null
};
}
function scalarEdge(e) {
const o = (e && e.toJSON) ? e.toJSON() : (e || {});
return {
id: o.id,
parentId: o.parentId,
childId: o.childId,
relation: o.relation
};
}
// importDirectory adopts a master's resource catalog into the local SQLite
// store. Resources are upserted by slug (create if absent, update if a local
// row already exists — the master is authoritative for the shared catalog),
// then all edges are recreated. Model stubs are injected for testability.
async function importDirectory({ Resource, ResourceEdge, exportData }) {
const resources = (exportData && exportData.resources) || [];
const edges = (exportData && exportData.edges) || [];
const bySlug = {};
try {
const existing = await Resource.list();
(existing || []).forEach(r => { bySlug[r.slug] = r; });
} catch (e) {
// Resource.list is unavailable (fresh DB?) — treat as empty.
}
let created = 0;
let updated = 0;
for (const raw of resources) {
const s = scalarResource(raw);
if (!s.slug) continue;
const local = bySlug[s.slug];
if (local) {
try { await Resource.update(local.id, s); updated++; } catch (e) { /* row raced; ignore */ }
} else {
try { await Resource.create(s); created++; } catch (e) { /* duplicate-slug race; ignore */ }
}
}
// Edges: clear + recreate so the graph matches the master exactly.
try {
const existingEdges = await ResourceEdge.list();
for (const e of existingEdges || []) {
await ResourceEdge.delete(e.id).catch(() => {});
}
} catch (e) { /* ignore */ }
let edgeCount = 0;
for (const raw of edges) {
const s = scalarEdge(raw);
if (!s.parentId || !s.childId) continue;
try { await ResourceEdge.create({ id: s.id, parentId: s.parentId, childId: s.childId, relation: s.relation || 'runs_on' }); edgeCount++; } catch (e) { /* ignore */ }
}
return { created, updated, edgeCount };
}
// ldapAddArgs builds the argv for importing an LDIF into the local slapd with
// the app's admin bind. `-c` continues past "entry already exists" (the spoke
// keeps its own cn=admin / base DN).
function ldapAddArgs({ bindDN, ldapCred, ldifFile, ldapUrl }) {
return [
'-c', '-x',
'-H', ldapUrl || 'ldap://localhost',
'-D', bindDN,
'-w', ldapCred,
'-f', ldifFile
];
}
// baseDnFrom derives the LDAP base DN from the app's admin bindDN
// (cn=admin,dc=example,dc=com -> dc=example,dc=com) unless the stack config
// already provides it (conf.stack.ldapBaseDn, written by setup.sh).
function baseDnFrom(conf) {
if (conf.stack && conf.stack.ldapBaseDn) return conf.stack.ldapBaseDn;
const m = String((conf.ldap && conf.ldap.bindDN) || '').match(/^cn=[^,]+,(.+)$/);
return m ? m[1] : '';
}
// siteIsFresh reports whether this deployment may join a master site
// (MULTI_SITE_SPEC.md): no users beyond the bootstrap admin and no enrolled
// agents. The bootstrap always seeds a handful of default resources (site →
// host → sso/proxy services), so resources are NOT the signal — the operator's
// rule is "no users". A directory with real users must never be merged into a
// master's; that is the destructive case this guard prevents.
async function siteIsFresh({ User, Agent }) {
const agents = (Agent && Agent.list ? await Agent.list().catch(() => []) : []);
if (agents && agents.length > 0) return false;
if (User && typeof User.listDetail === 'function') {
try {
const users = await User.listDetail();
const real = (users || []).filter(u => !u.isServiceAccount);
return real.length <= 1; // at most the bootstrap admin
} catch (e) {
// LDAP unreachable — fall back to the agent-only check.
}
}
return true;
}
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh };
+84
View File
@@ -0,0 +1,84 @@
'use strict';
// Live replication push -- the piece the shipped v1 join flow doesn't have on
// its own (join is a one-time export/import snapshot; nothing kept a spoke in
// sync afterward). This fires a lightweight "something changed, re-pull" ping
// at every spoke registered in SiteSpoke, concurrently, fire-and-forget: never
// awaited by its caller, and one unreachable spoke never delays or blocks
// another. See MULTI_SITE_SPEC.md §2.2 for why this must never become a
// blocking design (a write must never stall on spoke reachability).
//
// Deliberately a PUSH-A-SIGNAL / PULL-A-SNAPSHOT design, not a push-a-diff
// design: the receiving spoke reacts by calling the master's already-shipped,
// already-tested POST /api/site/export + importDirectory() path again (see
// routes/api_site.js's /resync handler), rather than this module inventing a
// second, parallel way to represent "what changed." Fewer moving parts, and
// no risk of a diff payload and a full export ever disagreeing.
const { SiteSpoke } = require('../models/site_spoke');
const RESYNC_TIMEOUT_MS = 8000;
function replicateToSpokes(reason) {
return (async () => {
let spokes;
try {
spokes = await SiteSpoke.list();
} catch (err) {
console.error('[site-replicate] failed to list known spokes:', err.message);
return;
}
for (const spoke of spokes) {
// Not awaited -- every spoke is pushed to concurrently.
pingOne(spoke, reason).catch((err) => {
console.error(`[site-replicate] resync ping to ${spoke.endpoint} failed:`, err.message);
});
}
})();
}
// Cross-component routing (MULTI_SITE_SPEC.md): if this spoke reported a WG
// mesh IP when it registered (utils/proxy_client.js's no-inbound relay path
// populates the same field), prefer sending the resync push over the mesh
// tunnel instead of the open internet -- plain HTTP is fine here since the
// WG tunnel itself is already encrypted, same reasoning as the no-inbound
// relay terminating at the master. Falls back to the spoke's public endpoint
// if the mesh attempt fails (mesh IP set but that particular tunnel isn't
// actually up yet, or unreachable for any other reason) -- never let a
// mesh-routing preference turn into "spoke never gets updates."
function resyncUrls(spoke) {
const urls = [];
if (spoke.meshIp) {
let port = '3001';
try { port = new URL(spoke.endpoint).port || port; } catch (_) { /* keep default */ }
urls.push(`http://${spoke.meshIp}:${port}/api/site/resync`);
}
urls.push(String(spoke.endpoint).replace(/\/+$/, '') + '/api/site/resync');
return urls;
}
async function pingOne(spoke, reason) {
const urls = resyncUrls(spoke);
let lastErr;
for (const url of urls) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), RESYNC_TIMEOUT_MS);
try {
const resp = await fetch(url, {
method: 'POST',
headers: { Authorization: 'Bearer ' + spoke.pushToken, 'Content-Type': 'application/json' },
body: JSON.stringify({ reason: reason || 'catalog-changed' }),
signal: controller.signal
});
if (!resp.ok) throw new Error('status ' + resp.status);
return; // success -- don't try the next (fallback) URL
} catch (err) {
lastErr = err;
} finally {
clearTimeout(timer);
}
}
throw lastErr;
}
module.exports = { replicateToSpokes, resyncUrls };
-2
View File
@@ -43,8 +43,6 @@ module.exports = {
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
],
};
+12 -8
View File
@@ -72,16 +72,20 @@ async function bao(method, path, body) {
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
// grant) propagate immediately because OpenBao parses policy content at use.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
try {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged
}
} catch (e) {
console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message);
}
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
try {
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
} catch (err) {
console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message);
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
// Mint a token through a token role with the given policies. Returns
+111
View File
@@ -11,6 +11,7 @@
loadProxyConf();
loadTos();
loadMessagingPlugins();
loadApps();
});
async function loadConf() {
@@ -302,6 +303,67 @@
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
async function mintApp() {
const errorEl = document.getElementById('app-error');
errorEl.classList.add('d-none');
const name = document.getElementById('app-name-input').value.trim();
if (!name) {
errorEl.textContent = 'App name is required';
errorEl.classList.remove('d-none');
return;
}
try {
const res = await fetch('/api/vault/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
body: JSON.stringify({ name })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${text}`);
}
const result = await res.json();
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.innerHTML = '<div class="text-muted small p-2">Loading apps…</div>';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small p-2">Failed to load app tokens.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small p-2">No external app tokens minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group list-group-flush">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small p-2">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success'));
}
</script>
<div class="container mt-4">
@@ -331,6 +393,11 @@
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="apps-tab" data-bs-toggle="tab" data-bs-target="#pane-apps" type="button" role="tab">
<i class="fas fa-key text-warning me-1"></i> App Tokens
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
@@ -499,6 +566,50 @@
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
<!-- External App Tokens Tab -->
<div class="tab-pane fade" id="pane-apps" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-warning me-2"></i> External App Tokens (OpenBao)</h5>
<p class="text-muted small">Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to <code>secret/apps/&lt;name&gt;/*</code>).</p>
<div class="row g-4">
<div class="col-md-5">
<div class="card border shadow-sm">
<div class="card-header bg-light py-2"><h6 class="mb-0 fw-bold"><i class="fas fa-plus me-1"></i> Mint New App Token</h6></div>
<div class="card-body p-3">
<p class="text-muted small">Mints a periodic OpenBao token. The token will be displayed <strong>once</strong>.</p>
<div class="mb-3">
<label class="form-label fw-semibold">App Name</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. build-agent">
<div class="form-text">Use lowercase letters, numbers, and hyphens.</div>
</div>
<button class="btn btn-primary btn-sm" onclick="mintApp()"><i class="fas fa-key me-1"></i> Mint Token</button>
<div class="alert alert-danger d-none mt-3 mb-0" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card border shadow-sm d-none mb-3" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-check-circle me-1"></i> Generated Token</h6>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy me-1"></i> Copy</button>
</div>
<div class="card-body p-3">
<p class="small text-muted mb-2">Include this token in HTTP header <code>X-Vault-Token</code>:</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded small font-monospace mb-0 select-all"></pre>
</div>
</div>
<div class="card border shadow-sm">
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-list me-1"></i> Active App Tokens</h6>
<button class="btn btn-sm btn-outline-secondary" onclick="loadApps()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div class="card-body p-0" id="apps-list">
<div class="text-muted small p-3">Loading apps…</div>
</div>
</div>
</div>
</div>
</div>
<!-- Terms of Service Tab -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-3">
+1286 -81
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -206,8 +206,8 @@
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
+ '<div class="card-body">'
+ '<h5 class="card-title d-flex align-items-start gap-2">'
+ iconHtml
+ '<span>' + esc(r.name) + '</span>'
+ iconHtml
+ '</h5>'
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
+37 -10
View File
@@ -142,16 +142,37 @@
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3">' +
'<label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
var isChecked = val === true || val === 'true' || val === 1 || val === '1' || (val === undefined && f.default !== false);
html += '<div class="mb-3 form-check">' +
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
'</div>';
} else if (f.type === 'site_select' || f.key === 'location') {
var selectedVal = String(val || f.default || '').trim();
var sites = window.availableSites || [];
var siteOpts = '<option value="">(Default Site)</option>';
sites.forEach(function(s) {
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
siteOpts += '<option value="' + app.util.escapeHtml(s.name) + '"' + sel + '>' + app.util.escapeHtml(s.name) + ' (' + app.util.escapeHtml(s.slug) + ')</option>';
});
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
'</div>';
} else {
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
html += '<div class="mb-3">' +
'<label class="form-label fw-bold">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
}
});
return html;
}
@@ -210,7 +231,13 @@
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
if (el) {
if (f.type === 'boolean' || f.type === 'checkbox' || el.type === 'checkbox') {
out[f.key] = el.checked;
} else {
out[f.key] = el.value;
}
}
});
return out;
}
+321
View File
@@ -0,0 +1,321 @@
'use strict';
// End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
// Drives the actual HTTP API two humans (a master admin + a spoke admin)
// would use: seed an admin on each side, mint a site join key on master,
// have the spoke adopt it, and verify the post-join contract holds.
const { execFileSync } = require('child_process');
const crypto = require('crypto');
// Wrapper matching the async call sites below (execFileSync throws
// synchronously; wrap in a resolved/rejected promise so callers can keep
// using await/.catch()). NOTE: plain execFile (async) does NOT support the
// `input` option for piping stdin -- only the *Sync variants do -- so
// ldapadd/ldapmodify would otherwise hang forever waiting on stdin that never
// arrives. This bit us once already; don't switch back to async execFile here
// without adding real stdin piping.
function execFileAsync(cmd, args, opts) {
try {
const stdout = execFileSync(cmd, args, { ...opts, stdio: ['pipe', 'pipe', 'pipe'] });
return Promise.resolve({ stdout: stdout ? stdout.toString() : '' });
} catch (e) {
e.stderr = e.stderr ? e.stderr.toString() : '';
return Promise.reject(e);
}
}
const MASTER_URL = process.env.MASTER_URL || 'http://master:3001';
const SPOKE_URL = process.env.SPOKE_URL || 'http://spoke:3001';
const MASTER_LDAP_HOST = process.env.MASTER_LDAP_HOST || 'master';
const MASTER_BASE_DN = process.env.MASTER_BASE_DN || 'dc=master,dc=test';
const SPOKE_LDAP_HOST = process.env.SPOKE_LDAP_HOST || 'spoke';
const SPOKE_BASE_DN = process.env.SPOKE_BASE_DN || 'dc=spoke,dc=test';
const LDAP_ADMIN_PASS = process.env.LDAP_ADMIN_PASS || 'secret';
const ADMIN_UID = 'e2eadmin';
const ADMIN_PASSWORD = 'MultiSiteE2E!2';
let failed = false;
function fail(msg) {
console.error('MULTISITE E2E FAIL:', msg);
failed = true;
}
function step(msg) {
console.log('--- ' + msg);
}
async function waitForHealthy(url, label) {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`${url}/health`);
if (r.ok) return;
} catch (_) { /* not up yet */ }
await new Promise((res) => setTimeout(res, 1000));
}
throw new Error(`${label} never became healthy`);
}
// Seed an admin user directly via ldapadd/ldapmodify -- mirrors
// test/seed-test-user.sh, but parameterized per-site since master and spoke
// have distinct base DNs in this harness.
async function seedAdmin(ldapHost, baseDn) {
const salt = crypto.randomBytes(8);
const digest = crypto.createHash('sha512').update(ADMIN_PASSWORD).update(salt).digest();
const hash = '{SSHA512}' + Buffer.concat([digest, salt]).toString('base64');
const ldif = `
dn: cn=${ADMIN_UID},ou=groups,${baseDn}
objectClass: posixGroup
objectClass: top
cn: ${ADMIN_UID}
gidNumber: 1600
dn: cn=${ADMIN_UID},ou=people,${baseDn}
objectClass: inetOrgPerson
objectClass: posixAccount
objectClass: top
objectClass: theta42Person
objectClass: ldapPublicKey
objectClass: sudoRole
cn: ${ADMIN_UID}
sn: E2E
uid: ${ADMIN_UID}
uidNumber: 1600
gidNumber: 1600
homeDirectory: /home/${ADMIN_UID}
loginShell: /bin/bash
mail: ${ADMIN_UID}@test.local
userPassword: ${hash}
`.trim() + '\n';
const bindDn = `cn=admin,${baseDn}`;
await execFileAsync('ldapadd', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS], { input: ldif })
.catch((e) => { if (!/Already exists/.test(e.stderr || '')) throw e; });
// god_admin is needed for site-promote (SUPER_ADMIN_GROUP, utils/permission.js).
for (const group of ['app_sso_admin', 'god_admin']) {
const modLdif = `dn: cn=${group},ou=groups,${baseDn}\nchangetype: modify\nadd: member\nmember: cn=${ADMIN_UID},ou=people,${baseDn}\n`;
try {
await execFileAsync('ldapmodify', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS], { input: modLdif });
console.log(` (added ${ADMIN_UID} to ${group} on ${ldapHost})`);
} catch (e) {
if (!/[Tt]ype or value exists/.test(e.stderr || '')) {
console.error(` FAILED adding ${ADMIN_UID} to ${group} on ${ldapHost}: ${e.stderr || e.message}`);
throw e;
}
console.log(` (${ADMIN_UID} already in ${group} on ${ldapHost})`);
}
}
const verify = await execFileAsync('ldapsearch', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS,
'-b', `cn=god_admin,ou=groups,${baseDn}`, 'member']);
console.log(` god_admin members on ${ldapHost}:\n${verify.stdout}`);
}
async function login(url) {
const r = await fetch(`${url}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_PASSWORD })
});
if (!r.ok) throw new Error(`login at ${url} failed: ${r.status} ${await r.text()}`);
const body = await r.json();
return body.token;
}
async function api(url, path, { method = 'GET', token, body } = {}) {
const r = await fetch(`${url}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
...(token ? { 'auth-token': token } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const text = await r.text();
let json;
try { json = JSON.parse(text); } catch (_) { json = { raw: text }; }
return { status: r.status, body: json };
}
async function main() {
step('Waiting for master + spoke to be healthy');
await waitForHealthy(MASTER_URL, 'master');
await waitForHealthy(SPOKE_URL, 'spoke');
step('Seeding admin users in both sites\' LDAP');
await seedAdmin(MASTER_LDAP_HOST, MASTER_BASE_DN);
await seedAdmin(SPOKE_LDAP_HOST, SPOKE_BASE_DN);
step('Logging in as admin on master and spoke');
const masterToken = await login(MASTER_URL);
const spokeToken = await login(SPOKE_URL);
if (!masterToken) fail('no token from master login');
if (!spokeToken) fail('no token from spoke login');
step('Confirming both sites start as master (fresh installs)');
{
const { body } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
if (body.config.isMaster !== true) fail(`expected master to start isMaster:true, got ${JSON.stringify(body.config)}`);
}
{
const { body } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
if (body.config.isMaster !== true) fail(`expected spoke to start isMaster:true (pre-join), got ${JSON.stringify(body.config)}`);
}
step('Creating a resource on master BEFORE join, to verify it gets adopted');
// Only site resources can be top-level; a host needs a parent site.
const siteRes = await api(MASTER_URL, '/api/directory-admin/resources', {
method: 'POST',
token: masterToken,
body: { name: 'E2E Site', slug: 'site_e2e', kind: 'site' }
});
if (siteRes.status !== 200) fail(`seeding pre-join site on master failed: ${siteRes.status} ${JSON.stringify(siteRes.body)}`);
const seedRes = await api(MASTER_URL, '/api/directory-admin/resources', {
method: 'POST',
token: masterToken,
body: { name: 'E2E Pre-Join Host', slug: 'host_e2e_prejoin', kind: 'host', parentSlug: 'site_e2e' }
});
if (seedRes.status !== 200) fail(`seeding pre-join resource on master failed: ${seedRes.status} ${JSON.stringify(seedRes.body)}`);
step('Minting a site join key on master');
const keyRes = await api(MASTER_URL, '/api/site/join-keys', {
method: 'POST',
token: masterToken,
body: { label: 'e2e-test' }
});
if (keyRes.status !== 200 || !keyRes.body.key) fail(`join-key mint failed: ${keyRes.status} ${JSON.stringify(keyRes.body)}`);
const joinKey = keyRes.body.key;
step('Joining spoke to master (with selfUrl, to register for live replication)');
const joinRes = await api(SPOKE_URL, '/api/site/join', {
method: 'POST',
token: spokeToken,
// master's own container-internal URL, as the spoke would reach it over the network
body: { masterUrl: 'http://master:3001', joinKey, selfUrl: 'http://spoke:3001' }
});
if (joinRes.status !== 200) fail(`join failed: ${joinRes.status} ${JSON.stringify(joinRes.body)}`);
if (!joinRes.body.replication || joinRes.body.replication.live !== true) {
fail(`expected join to register for live replication, got ${JSON.stringify(joinRes.body.replication)}`);
}
step('Verifying spoke persisted isMaster:false + masterUrl after join');
const { body: spokeCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
if (spokeCfg.config.isMaster !== false) fail(`spoke should be isMaster:false after join, got ${JSON.stringify(spokeCfg.config)}`);
if (!spokeCfg.config.masterUrl) fail('spoke should have masterUrl set after join');
step('Verifying the master computed its own LDAP replication config (ServerID 1 + the new spoke as a peer)');
const { body: masterLdapCfg } = await api(MASTER_URL, '/api/directory-admin/ldap-replication-config', { token: masterToken });
if (masterLdapCfg.ldapServerId !== 1) fail(`master's own ldapServerId should be 1, got ${JSON.stringify(masterLdapCfg)}`);
const spokePeer = (masterLdapCfg.peers || []).find(p => p.ldapHost === 'ldaps://spoke:636');
if (!spokePeer || typeof spokePeer.ldapServerId !== 'number') {
fail(`master's peer list should include the spoke at ldaps://spoke:636 with an assigned ldapServerId, got ${JSON.stringify(masterLdapCfg.peers)}`);
}
step('Verifying the spoke can fetch its own assigned LDAP ServerID + peer list from the master');
const spokeLdapPeersResp = await fetch(`${MASTER_URL}/api/site/ldap-peers?endpoint=${encodeURIComponent('http://spoke:3001')}`, {
headers: { Authorization: 'Bearer ' + joinKey }
});
const spokeLdapCfg = await spokeLdapPeersResp.json();
if (spokeLdapPeersResp.status !== 200) fail(`GET /api/site/ldap-peers failed: ${spokeLdapPeersResp.status} ${JSON.stringify(spokeLdapCfg)}`);
if (spokeLdapCfg.ldapServerId !== spokePeer.ldapServerId) {
fail(`spoke's own reported ldapServerId (${spokeLdapCfg.ldapServerId}) should match what the master's peer list assigned it (${spokePeer.ldapServerId})`);
}
const masterAsPeer = (spokeLdapCfg.peers || []).find(p => p.ldapServerId === 1);
if (!masterAsPeer || masterAsPeer.ldapHost !== 'ldaps://master:636') {
fail(`spoke's peer list should include the master (ServerID 1, ldaps://master:636), got ${JSON.stringify(spokeLdapCfg.peers)}`);
}
const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId);
if (selfInOwnPeerList) fail(`spoke's own peer list should not include itself, got ${JSON.stringify(spokeLdapCfg.peers)}`);
step('Verifying the spoke adopted the master\'s pre-join catalog');
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
const found = Array.isArray(adopted) && adopted.some(r => r.slug === 'host_e2e_prejoin');
if (!found) fail(`spoke did not adopt master's pre-join resource; got slugs=${JSON.stringify((adopted || []).map(r => r.slug))}`);
step('Verifying spoke is now read-only (write attempt must 403)');
const writeAttempt = await api(SPOKE_URL, '/api/directory-admin/resources', {
method: 'POST',
token: spokeToken,
body: { name: 'Should Be Rejected', slug: 'host_e2e_should_reject', kind: 'host' }
});
if (writeAttempt.status !== 403) fail(`expected 403 writing to spoke post-join, got ${writeAttempt.status} ${JSON.stringify(writeAttempt.body)}`);
step('Creating a resource on master AFTER join, to verify LIVE replication (not just the one-time join snapshot)');
const postJoinRes = await api(MASTER_URL, '/api/directory-admin/resources', {
method: 'POST',
token: masterToken,
body: { name: 'E2E Post-Join Host', slug: 'host_e2e_postjoin', kind: 'host', parentSlug: 'site_e2e' }
});
if (postJoinRes.status !== 200) fail(`creating post-join resource on master failed: ${postJoinRes.status} ${JSON.stringify(postJoinRes.body)}`);
step('Waiting for the fire-and-forget resync push to reach the spoke');
let liveReplicated = false;
for (let i = 0; i < 20; i++) {
const r = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
const slugs = (r.body.results || r.body.resources || r.body || []).map((x) => x.slug);
if (slugs.includes('host_e2e_postjoin')) { liveReplicated = true; break; }
await new Promise((res) => setTimeout(res, 500));
}
if (!liveReplicated) fail('post-join resource never appeared on the spoke -- live replication did not fire (or resync did not apply it)');
step('Verifying WAN health ping from spoke to master succeeds');
const statusRes = await api(SPOKE_URL, '/api/directory-admin/site-status', { token: spokeToken });
if (statusRes.body.config && statusRes.body.config.wanConnected !== true) {
fail(`expected spoke to report wanConnected:true post-join, got ${JSON.stringify(statusRes.body.config)}`);
}
step('Verifying master itself is unaffected (still isMaster:true, no writes blocked)');
const { body: masterCfg } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
if (masterCfg.config.isMaster !== true) fail('master flipped away from isMaster:true unexpectedly');
step('Promoting the spoke to master (coordinated handoff -- must demote the old master too)');
const promoteRes = await api(SPOKE_URL, '/api/directory-admin/site-promote', {
method: 'POST',
token: spokeToken,
body: { selfUrl: 'http://spoke:3001' }
});
if (promoteRes.status !== 200) fail(`promotion failed: ${promoteRes.status} ${JSON.stringify(promoteRes.body)}`);
if (promoteRes.body.handoff !== 'previous master demoted') {
fail(`expected the old master to be demoted as part of promotion, got handoff=${JSON.stringify(promoteRes.body.handoff)}`);
}
step('Verifying the newly-promoted node is master');
const { body: newMasterCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
if (newMasterCfg.config.isMaster !== true) fail(`newly-promoted node should be isMaster:true, got ${JSON.stringify(newMasterCfg.config)}`);
step('Verifying the old master was actually demoted to a spoke of the new master');
const { body: oldMasterCfg } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
if (oldMasterCfg.config.isMaster !== false) fail(`old master should be isMaster:false after being demoted, got ${JSON.stringify(oldMasterCfg.config)}`);
if (oldMasterCfg.config.masterUrl !== 'http://spoke:3001') {
fail(`old master's masterUrl should now point at the new master, got ${JSON.stringify(oldMasterCfg.config.masterUrl)}`);
}
step('Verifying the (now-demoted) old master rejects writes, and the new master accepts them');
const oldMasterWrite = await api(MASTER_URL, '/api/directory-admin/resources', {
method: 'POST',
token: masterToken,
body: { name: 'Should Be Rejected Post-Demotion', slug: 'host_e2e_should_reject_2', kind: 'host' }
});
if (oldMasterWrite.status !== 403) fail(`expected 403 writing to the demoted old master, got ${oldMasterWrite.status} ${JSON.stringify(oldMasterWrite.body)}`);
const newMasterWrite = await api(SPOKE_URL, '/api/directory-admin/resources', {
method: 'POST',
token: spokeToken,
body: { name: 'E2E Post-Promotion Host', slug: 'host_e2e_postpromotion', kind: 'host', parentSlug: 'site_e2e' }
});
if (newMasterWrite.status !== 200) fail(`expected the newly-promoted master to accept writes, got ${newMasterWrite.status} ${JSON.stringify(newMasterWrite.body)}`);
if (failed) {
console.error('MULTISITE E2E: one or more checks failed (see above)');
process.exit(1);
}
console.log('MULTISITE E2E PASS');
}
main().catch((e) => {
console.error('MULTISITE E2E FAIL (exception):', e.stack || e.message);
process.exit(1);
});
+110
View File
@@ -0,0 +1,110 @@
'use strict';
// End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
//
// Simulates the agent: enrolls one, connects to the SSO WSS with its token,
// sends a real LDAP bind request as raw bytes in an `ldap_tunnel` message, and
// verifies the SSO relays it into OpenLDAP and pipes the bind response back.
// This proves the SSO side of the tunnel without needing the agent binary.
const WebSocket = require('ws');
const SSO_URL = process.env.SSO_URL || 'http://sso:3001';
const WS_URL = SSO_URL.replace(/^http/, 'ws') + '/api/agent/ws';
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
const USER_DN = 'cn=test,ou=people,dc=test,dc=local';
function fail(msg) { console.error('E2E FAIL:', msg); process.exit(1); }
async function waitForSso() {
for (let i = 0; i < 60; i++) {
try {
const r = await fetch(`${SSO_URL}/health`);
if (r.ok) return;
} catch (_) {}
await new Promise((res) => setTimeout(res, 1000));
}
fail('SSO never became ready');
}
async function login() {
const r = await fetch(`${SSO_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(TEST_CREDS),
});
if (!r.ok) fail(`login failed: ${r.status}`);
const body = await r.json();
return body.token;
}
async function enrollAgent(authToken) {
const r = await fetch(`${SSO_URL}/api/agent/enroll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': authToken },
body: JSON.stringify({ name: `e2e-${Date.now().toString(36)}` }),
});
if (!r.ok) fail(`enroll failed: ${r.status}`);
const body = await r.json();
return body.token;
}
// Build a simple LDAP bind request (version 3) as raw BER bytes.
function buildBindRequest(dn, password) {
const dnBuf = Buffer.from(dn, 'utf8');
const pwBuf = Buffer.from(password, 'utf8');
const version = Buffer.from([0x02, 0x01, 0x03]);
const name = Buffer.concat([Buffer.from([0x04, dnBuf.length]), dnBuf]);
const simple = Buffer.concat([Buffer.from([0x80, pwBuf.length]), pwBuf]);
const bindContent = Buffer.concat([version, name, simple]);
const bindReq = Buffer.concat([Buffer.from([0x60, bindContent.length]), bindContent]);
const msgId = Buffer.from([0x02, 0x01, 0x01]);
const msgContent = Buffer.concat([msgId, bindReq]);
return Buffer.concat([Buffer.from([0x30, msgContent.length]), msgContent]);
}
// A successful bind response is a BindResponse (0x61) with resultCode 0 (0x0a 01 00).
function isSuccessBindResponse(buf) {
return buf.includes(Buffer.from([0x61])) && buf.includes(Buffer.from([0x0a, 0x01, 0x00]));
}
async function main() {
await waitForSso();
const authToken = await login();
const agentToken = await enrollAgent(authToken);
console.log('E2E: enrolled agent, connecting WSS...');
const ws = new WebSocket(`${WS_URL}?token=${agentToken}`);
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
console.log('E2E: WSS connected');
const bindBytes = buildBindRequest(USER_DN, TEST_CREDS.password);
ws.send(JSON.stringify({
type: 'ldap_tunnel',
payload: { conn_id: 'e2e-1', data: bindBytes.toString('base64') },
}));
console.log('E2E: sent bind request bytes');
const result = await new Promise((res, rej) => {
const timeout = setTimeout(() => rej(new Error('timed out waiting for bind response')), 10000);
ws.on('message', (data) => {
let msg;
try { msg = JSON.parse(data); } catch (_) { return; }
if (msg.type !== 'ldap_tunnel') return;
if (msg.payload.close) return;
const buf = Buffer.from(msg.payload.data || '', 'base64');
if (isSuccessBindResponse(buf)) {
clearTimeout(timeout);
res({ ok: true, bytes: buf.length });
}
});
ws.on('error', (e) => { clearTimeout(timeout); rej(e); });
});
console.log(`E2E: got successful bind response (${result.bytes} bytes)`);
ws.close();
console.log('E2E PASS');
process.exit(0);
}
main().catch((e) => fail(e.message));