Compare commits

..

82 Commits

Author SHA1 Message Date
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
wmantly 03605267bc Merge pull request #171 from theta42/feat/agent-join-keys-directory-fixes
feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
2026-08-06 10:40:02 -04:00
wmantly 7e9a271090 feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m25s
Pull Request Tests / Run Tests (20.x) (push) Failing after 26s
Pull Request Tests / Run Tests (22.x) (push) Failing after 30s
Pull Request Tests / Test Summary (push) Failing after 5s
JOIN KEYS

v1.29.0 required an admin to pre-register every machine before its agent
would be spoken to. The security model was right; the workflow was not --
installing the agent should be enough to add a host.

POST /api/agent/join-keys mints one credential an operator hands out. A
host presenting it is enrolled automatically and immediately issued its
OWN per-agent token plus the public key it must pin, delivered in the
config frame. The join key is a bootstrap credential, never the host's
identity, so one key stays convenient without becoming a fleet-wide
skeleton key: every host remains individually revocable.

DIRECTORY

Collapsing the tree did nothing. applyTreeCollapse found the caret with
`.tree-caret i` and returned early when absent -- Font Awesome's SVG mode
rewrites <i> to <svg>, so that selector matched nothing and the early
return skipped setting hideBelowDepth. State now lives on the caret
button and is rotated by CSS.

The Discovery Plugins delete button called deleteDiscoveryPlugin(), which
was never defined. The pane also had no .actionMessage, and confirmations
render into one -- without it the promise never settles, so an awaited
confirmation hangs forever and the action silently never happens.

Plugin instances can now be edited.

DISCOVERY

A fresh install presented its own five containers as unmanaged
discoveries. The Docker plugin now recognises the stack's compose project
and attaches each container to the service it implements. Container slugs
came from the container id, which changes on recreate, so every deploy
minted a new resource and orphaned the old one.

DOCS

/docs/discovery 404'd (no slug entry) and `agents` pointed at plugins.md,
leaving docs/agents.md unreachable. Adds docs/discovery.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 10:33:34 -04:00
wmantly b28a18064a Merge pull request #170 from theta42/fix/directory-discovery-profile
sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
2026-08-05 18:55:15 -04:00
wmantly 87339da1b2 sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 23s
Pull Request Tests / Run Tests (22.x) (push) Failing after 37s
Pull Request Tests / Test Summary (push) Failing after 4s
SECURITY

/api/agent/ws authenticated nothing. There was no agent registry, so any
client reaching the SSO could register as a node, publish discovery and
telemetry into the admin view, and receive commands -- including a signed
arbitrary_bash -- addressed to a token it guessed. Tokens were generated
in the BROWSER and never recorded server-side, so there was nothing to
validate against and no way to revoke one.

Agents are now rows in a new Agent table, authenticated by SHA-256 token
hash before the connection is registered or the welcome payload is sent.
Tokens are minted by POST /api/agent/enroll and shown once. Revoke and
rotate drop the live socket immediately. All agent actions are audited.

The Ed25519 command-signing key was generated in the AgentManager
constructor, so it changed on every restart and the public_key pinned in
an agent's agent.yml stopped matching. It now lives in OpenBao at
secret/agent/signing-key; if it cannot be loaded the SSO refuses to send
high-risk commands rather than signing with a key no agent has seen.

DIRECTORY

Agents bind to a host resource instead of being matched by hostname, and
a bound agent's discovery is written onto that resource -- previously the
one source running ON the host contributed nothing to the directory.

The resource tree is collapsible, with state persisted per browser.

DISCOVERY

The Proxmox plugin zipped MACs and IPs from two flat lists by index,
attributing addresses to the wrong NIC on multi-NIC guests. NICs are now
keyed by MAC. Adds an endpoint resource parenting each node, sourceId/
vmid/node identity, container-interface filtering, node IP/MAC, and
offline-node handling.

The reconciler could make a resource its own parent, named hosts after
their MAC address, had a dead isIp() regex (\\. matches a backslash),
merged across kinds, and re-read the whole inventory per resource.

Dockerfile.test-runner never copied nodejs/plugins, so every plugin test
suite failed in CI as "Cannot find module".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:44:37 -04:00
wmantly 49100c9b68 fix: agent REST router mounted before 404; promote opens pre-filled modal; Directory refresh; Vault OpenBao (v1.28.0) (#169)
- api_agent: REST router mounted synchronously in app.js (was post-listen, behind
  the 404 catch-all -> /api/agent/* 404'd); WS init stays on onListen
- directory.ejs: promote opens a pre-filled resource modal (Save confirms);
  addEdge/removeEdge call loadResources() (was undefined loadData -> stale table);
  addGroup/removeGroup refresh the Access column
- vault.ejs: 'Powered by OpenBao' header badge
2026-08-05 02:59:58 -04:00
wmantly e8d04203c3 fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 23:00:25 -04:00
wmantly 8db00f0ed6 fix: drop legacy app_super_admin -- SUPER_ADMIN_GROUP is now god_admin (v1.26.1) (#167)
god_admin now exists at boot (seeded by docker-entrypoint), so the canonical
cross-resource super group nested into every resource's _admin group is god_admin,
not the legacy app_super_admin. docker-entrypoint no longer seeds or nests
app_super_admin (god_admin nests into the app_sso_* groups directly). isSuperAdmin
still recognizes a pre-existing app_super_admin as a migration alias until rebuild.
2026-08-04 19:31:52 -04:00
wmantly 8a9de94d24 release/v1.26.0: complete group model, enforce naming, fix docs + status dots (#166)
* feat: complete the group model (god_admin, site groups, aggregates), enforce naming, fix docs 500s + status dots (v1.26.0)

- seed god_admin + nest into app_super_admin; auto-provision site groups (S_super_admin, S_hosts_*/S_apps_* aggregates, S_everyone) on site create + self-heal on Directory load
- map service resources to the app kind (site_local_app_<slug>_*); nest per-resource groups into site aggregates (physical inheritance lattice)
- enforce the group naming convention server-side on POST /groups; surface god_admin + site groups on the site resource modal
- fix in-app /docs/<slug> 500s (Dockerfile never copied docs/); serve doc images at /docs/images
- fix Directory status dots (neutral grey when agent endpoint unreachable); align Profile/API cards full-width
- group resolver: keep the site slug verbatim (site_local not re-slugified)
- bump to 1.26.0

* fix: use verbatim resource slugs in group names (matches access-request tests + live convention)

The group naming inserts a kind segment (resourceGroupCns(site, kind, slug, level)),
but the access-request tests + the live directory convention are verbatim
({site}_{slug}_{level} -- the kind is carried in the resource slug, e.g. host_theta-env).
For bare test slugs this produced site_x_host_artest-host_x_access instead of the
expected site_x_artest-host_x_access, so the requester was never removed from the
auto-provisioned access group and every request 409'd. resourceGroupCns is now
(site, slug, level) with the verbatim slug; the kind is used only to pick the
aggregate the group nests into.
2026-08-04 19:07:51 -04:00
wmantly 512a28d1f5 Merge pull request #165 from theta42/fix/version-1.25.0
chore: bump package.json to 1.25.0
2026-08-04 16:47:15 -04:00
wmantly 398b64f5e3 chore: bump package.json + lockfile to 1.25.0
Keep the release version in sync with the v1.25.0 tag (the changelog was bumped
but package.json was left at 1.23.0, which would trigger a false update-check
banner).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:43:08 -04:00
wmantly 8d6c7dffd0 Merge pull request #164 from theta42/release/v1.25.0
feat: hierarchical group & permission model (v1.25.0)
2026-08-04 16:42:20 -04:00
wmantly 50d093f28b Merge branch 'master' into release/v1.25.0 2026-08-04 16:37:57 -04:00
wmantly f00d311029 fix: keep SUPER_ADMIN_GROUP as app_super_admin so resource auto-provisioning nesting works
api_directory_admin nests permission.SUPER_ADMIN_GROUP into every new resource's
_admin group. Changing it to the not-yet-existing 'god_admin' made that nesting
no-op, leaving the creator as the sole member (so the access_request test's
beforeAll could not remove the last member of a groupOfNames). Revert it to
'app_super_admin' and recognize 'god_admin' separately in isSuperAdmin + isAdmin.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 16:31:00 -04:00
wmantly 88b2255d5a Merge pull request #160 from theta42/dependabot/npm_and_yarn/nodejs/undici-6.28.0
chore(deps): bump undici from 6.27.0 to 6.28.0 in /nodejs
2026-08-04 16:24:38 -04:00
wmantly b0819e81e6 Merge branch 'master' into dependabot/npm_and_yarn/nodejs/undici-6.28.0 2026-08-04 16:13:52 -04:00
wmantly d41915f955 Merge pull request #158 from theta42/dependabot/npm_and_yarn/nodejs/ip-address-10.4.0
chore(deps): bump ip-address from 10.2.0 to 10.4.0 in /nodejs
2026-08-04 16:12:57 -04:00
wmantly be8ccf66e9 Merge branch 'master' into dependabot/npm_and_yarn/nodejs/undici-6.28.0 2026-08-04 16:08:38 -04:00
wmantly 58597ac8fd Merge branch 'master' into dependabot/npm_and_yarn/nodejs/ip-address-10.4.0 2026-08-04 16:08:36 -04:00
wmantly d02ba32925 Merge pull request #156 from theta42/dependabot/npm_and_yarn/nodejs/multi-e855e31deb
chore(deps): bump brace-expansion in /nodejs
2026-08-04 16:07:47 -04:00
wmantly 6d9c2f05ba feat: hierarchical group & permission model (v1.25.0)
- Add utils/groups.js: the group schema + inheritance resolver (god_admin,
  {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, per-resource
  admin/access/<capability>, meta everyone/{site}_everyone). admin implies
  access; capabilities explicit; hosts/apps orthogonal; cross-site isolated.
- permission.js: recognize god_admin (legacy app_super_admin aliased) and add
  onResource/requireResource for resource-level checks + everyone meta grants.
- user.js isAdmin: recognize god_admin + site-scoped super/app-admin groups.
- Remove the standalone Groups page (nav + route + view); groups are managed on
  adopted Directory resources. Add a /docs/groups help link in the Directory
  toolbar (GROUPS.md copied into the SSO docs).
- tests/groups.test.js: full resolver coverage (15 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 15:03:36 -04:00
wmantly bbcc235b68 feat: Directory agent status + plugin modal rework, Vault restyle, navbar (v1.24.0)
- Merge theta-agent into Directory: remove the Agents page; add green/yellow/red
  status dots to host rows and a Metrics tab (telemetry + discovery) to the
  resource modal, joined to hosts by hostname, live via socket.io + 30s refresh.
- Discovery Plugins New-plugin modal: slug derived from name (field removed),
  cron dropdown (hourly/daily/weekly/custom), configSchema-driven settings
  (Proxmox url/tokenId/tokenSecret) sent as a populated config.
- Directory resource slug now read-only + derived from name.
- Vault page restyled to match the site.
- Navbar: username no longer underlined; only the active link is bold+underlined.
- docs/agents.md: document the Directory status/metrics + NAT troubleshooting.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 13:26:25 -04:00
dependabot[bot] 93c47751db chore(deps): bump brace-expansion in /nodejs
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 2.1.2 to 2.1.4
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

Updates `brace-expansion` from 1.1.16 to 1.1.18
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

Updates `brace-expansion` from 5.0.7 to 5.0.9
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v2.1.2...v2.1.4)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.18
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.4
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 5.0.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:47 +00:00
dependabot[bot] 9a438bd30e chore(deps): bump undici from 6.27.0 to 6.28.0 in /nodejs
Bumps [undici](https://github.com/nodejs/undici) from 6.27.0 to 6.28.0.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v6.27.0...v6.28.0)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 6.28.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:41 +00:00
dependabot[bot] c618e75a22 chore(deps): bump ip-address from 10.2.0 to 10.4.0 in /nodejs
Bumps [ip-address](https://github.com/beaugunderson/ip-address) from 10.2.0 to 10.4.0.
- [Release notes](https://github.com/beaugunderson/ip-address/releases)
- [Commits](https://github.com/beaugunderson/ip-address/compare/v10.2.0...v10.4.0)

---
updated-dependencies:
- dependency-name: ip-address
  dependency-version: 10.4.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-04 04:21:35 +00:00
wmantly 69434d06ec Merge pull request #163 from theta42/release/v1.23.0-vault-proxy-fix
fix vault 403 for real (v1.23.0)
2026-08-04 00:19:16 -04:00
wmantly dd24257640 fix vault 403 for real (v1.23.0)
- /api/vault proxy now injects X-Vault-Token: the proxy declared its request
  hook with http-proxy-middleware v3 syntax (on: { proxyReq }), which the
  installed HPM v2 silently ignores — so every vault call reached OpenBao
  unauthenticated (the recurring 403). Rewritten as v2 onProxyReq.
- Header injection ordered before fixRequestBody (the body write flushes
  headers; setting X-Vault-Token after it failed on every POST/PUT).
- initORM add-only schema heal: sequelize.sync() never ALTERs, so newer columns
  (PluginInstance.lastLog) are now added via describeTable + addColumn.
- Long-lived external-app tokens via sso-app role (768h periodic); VaultAppToken
  stores each app token's accessor and renews it at boot + every 6h; re-minting
  revokes the previous token via its accessor.
- Wire-level tests for the vault proxy + app-token accessor lifecycle.
- package.json + lockfile bumped to 1.23.0.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-04 00:11:51 -04:00
wmantly b06aeca363 Merge pull request #162 from theta42/feat/agents-page-v1.22.0
feat: Agents page + secure /api/agent REST (v1.22.0)
2026-08-03 23:14:49 -04:00
wmantly ccf3122668 feat: Agents page + secure /api/agent REST (v1.22.0)
- New admin Agents page (nav + /agents route + views/agents.ejs): live list of
  connected theta-agent hosts with CPU/RAM/disk/ZFS/GPU telemetry and online
  status, updated live over socket.io ('agent.telemetry'/'agent.discovery').
- Auth + admin-gate the /api/agent REST router (it was mounted without
  middleware.auth — anyone could list nodes / send commands). The agent
  WebSocket (/api/agent/ws) is unaffected (handled by the raw wss upgrade with
  its own token auth).
- package.json + lockfile bumped to 1.22.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 23:10:16 -04:00
wmantly 5aad6c13bf Merge pull request #161 from theta42/fix/vault-403-shared-secrets
fix vault 403 + shared secrets (v1.21.0)
2026-08-03 22:23:44 -04:00
wmantly b46b3bed80 fix: use app.messages.confirm instead of native confirm() in vault Shared tab
The no_native_dialogs regression test forbids native alert/confirm/prompt in
views (they block browser events). Replace the native confirm() in deleteShared
with app.messages.confirm().
2026-08-03 22:19:38 -04:00
wmantly 948fef4adc fix vault 403 + shared secrets (v1.21.0)
- vault_broker: always reconcile policy content before serving a cached
  token (compare-and-skip), so stale stored policies can't cause a recurring
  403 'permission denied'; policy content is parsed live by OpenBao, so edits
  apply to existing tokens immediately.
- Shared secrets: publish to secret/shared/<owner>/<slug>; grant read to users
  and apps by editing the grantee's policy content (live-applied). New
  SharedSecret/SharedSecretGrant ORM models, /api/shared-secrets router, and a
  Shared tab in the vault UI.
- package.json + lockfile bumped to 1.21.0 to match the tag.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-03 22:13:58 -04:00
wmantly 2612b0e3ab Merge pull request #159 from theta42/fix/sync-version-v1.20.2
fix: sync package version to v1.20.2 tag
2026-08-03 21:35:14 -04:00
wmantly bf471c2e19 fix: sync package version to v1.20.2 tag
The v1.20.2 release tag was created but nodejs/package.json (and the
lockfile) were left at 1.20.1, so the deployed app's buildVersion lagged
its own release tag and the update-check banner falsely reported a newer
version. Bump the version fields to match the tag.
2026-08-03 21:26:55 -04:00
wmantly d802c399a3 Merge pull request #157 from theta42/fix/sso-vault-conf-directory-v1.20.2
fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
2026-08-03 15:30:49 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
wmantly 70b76c6ed5 fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
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 5s
2026-08-03 13:54:53 -04:00
wmantly 8143ef8ca8 Merge pull request #154 from theta42/feature/v1.20.0-package-version-and-docs
release: v1.20.0 package version & docs update
2026-08-03 02:41:47 -04:00
wmantly 2b8b7a96e0 release: v1.20.0 - bump package.json version and update docs 2026-08-03 02:37:52 -04:00
wmantly 7a364bfb8e Merge pull request #151 from theta42/feature/v1.20.0-theta-agent-c2
feat: Protocol v1.1.0 theta-agent C2 integration & install wizard
2026-08-03 02:21:50 -04:00
wmantly b7aac2d2ba feat: Protocol v1.1.0 theta-agent C2 integration, agent install wizard, OpenBao 403 fix, nmap discovery fix, and restored documentation 2026-08-03 02:18:09 -04:00
wmantly 4945dec9c2 Merge pull request #150 from theta42/release/v1.19.6
Release v1.19.6
2026-08-02 22:49:52 -04:00
wmantly 59d68c0269 chore: release v1.19.6 - UI nav auth, SMTP UI-only, test messages, directory.md
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m6s
Pull Request Tests / Run Tests (20.x) (push) Failing after 29s
Pull Request Tests / Run Tests (22.x) (push) Failing after 29s
Pull Request Tests / Test Summary (push) Failing after 4s
### Fixed
- **Navbar shows Catalog/Vault for unauthenticated users** — Changed nav
  gating from `groups: []` (always visible) to `groups: ['login']` and
  added synthetic 'login' group handling in app-base.js.
- **500 ENOENT: no such file or directory, open '/docs/directory.md'** —
  Created the missing documentation file.

### Changed
- **SMTP configuration UI-only** — Removed SMTP from static config files
  (conf/base.js, sso-secrets.js, setup.env.example). SMTP is now only
  configurable via the runtime UI at /conf.

### Added
- **Test email/SMS capability** — Added POST /api/conf/test-email and
  POST /api/conf/test-sms endpoints with UI buttons in the Configuration
  page. Saves config first, then sends test message to verify settings.

### theta-env setup.sh
- **Non-interactive theta-agent configuration** — Added CFG_THETA_AGENT_ENABLE,
  CFG_THETA_AGENT_LDAP_AUTH, and CFG_THETA_AGENT_FULL_CONTROL variables to
  setup.env (all default to 1/enabled).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-02 21:30:47 -04:00
wmantly ef2207ed72 fix(vault): correctly rewrite paths for vault API proxy (#149) 2026-08-02 19:47:07 -04:00
wmantly 7782cf8973 Merge pull request #148 from theta42/bump-1.19.5
chore: bump version to 1.19.5
2026-08-02 19:07:19 -04:00
wmantly 80317d1b7e chore: bump version to 1.19.5
Pull Request Tests / Run Tests (18.x) (push) Failing after 6m7s
Pull Request Tests / Run Tests (20.x) (push) Failing after 24s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 19:02:17 -04:00
wmantly ded6a1b0d5 Merge pull request #147 from theta42/fix-68
fix: enforce pwdAccountLockedTime check in app and LDAP
2026-08-02 19:01:43 -04:00
wmantly a6c24850d4 chore: update sqlite test fixture schema
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m32s
Pull Request Tests / Run Tests (20.x) (push) Failing after 27s
Pull Request Tests / Run Tests (22.x) (push) Failing after 23s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 18:57:13 -04:00
wmantly 7da5050ce3 fix: correct path-to-regexp syntax 2026-08-02 18:50:31 -04:00
wmantly 1cb693a1eb fix: resolve discovery, plugins, and vault issues 2026-08-02 18:45:16 -04:00
wmantly 5c3a8cefe1 fix: enforce pwdAccountLockedTime check in app and LDAP (#68) 2026-08-02 18:15:28 -04:00
wmantly 15b3a424bc Merge pull request #146 from theta42/bump-1.19.4
chore: bump version to 1.19.4
2026-08-02 14:10:40 -04:00
wmantly 6cb309b6d9 chore: bump version to 1.19.4 2026-08-02 14:06:45 -04:00
wmantly f0ceb750a8 Merge pull request #145 from theta42/bump-version
chore: bump version to 1.19.3
2026-08-02 14:06:20 -04:00
wmantly 6e95defcf5 chore: bump version to 1.19.3 2026-08-02 14:02:00 -04:00
wmantly 92c2e8a03b Merge pull request #144 from theta42/fix/discovery-edges
fix: resolve discovery and UI bugs
2026-08-02 13:23:56 -04:00
wmantly 230e5be2fd fix: regex syntax error in proxmox plugin 2026-08-02 13:20:15 -04:00
wmantly 0331cb976a fix: resolve discovery and UI bugs 2026-08-02 12:55:19 -04:00
wmantly 90cf65e920 Merge pull request #143 from theta42/fix/discovery-edges
fix: process edges during discovery reconciliation
2026-08-02 12:10:04 -04:00
wmantly 2d202b4979 chore: release v1.19.2 2026-08-02 12:06:09 -04:00
wmantly 8f04c20cd7 fix: process edges during discovery reconciliation to correctly link merged resources 2026-08-02 12:05:46 -04:00
wmantly df330c6c0f Merge pull request #141 from theta42/release-v1.19.0
Release v1.19.0
2026-08-02 11:20:49 -04:00
wmantly 522093e898 fix: remove missing documentation files from Docker build context
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m36s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-02 02:14:30 -04:00
wmantly 7b84a10420 fix: regex syntax error in docker discovery plugin 2026-08-02 02:09:19 -04:00
wmantly c461723ec7 chore: release v1.19.0 2026-08-02 01:54:00 -04:00
wmantly b948cd8625 feat: add websocket server endpoint for theta-agent 2026-08-02 01:39:45 -04:00
wmantly 36aa114d7c docs: remove standalone deployment and docs folder 2026-08-02 00:51:30 -04:00
wmantly fbce59b1be feat: integrate proxy config with OpenBao for secure secret storage 2026-08-02 00:37:48 -04:00
wmantly 554a0999ab test: add tests for Proxy OpenBao configuration endpoint 2026-08-02 00:34:56 -04:00
wmantly 3ca221d64d chore: release v1.18.0 2026-08-02 00:16:18 -04:00
wmantly 75b133f610 feat: Add messaging plugins, Docker discovery, fix reconciliation 2026-08-02 00:16:17 -04:00
wmantly f1d52601de Merge pull request #140 from theta42/feature/v1.17.2-fixes
v1.17.2: post-deploy fixes + SMS/TOS on /conf
2026-08-01 22:51:55 -04:00
wmantly ecd21c4984 feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf
- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:47:37 -04:00
wmantly 5ba2ace835 Merge pull request #139 from theta42/feature/conf-secret-masking
v1.17.1: mask SMTP/OAuth secrets + leave-blank-to-keep on /conf
2026-08-01 21:19:05 -04:00
wmantly 25b0d57a97 feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1)
GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext
(masked to ********). POST treats a blank or ******** secret submission as
"keep the stored value," so editing the From address or token lifetimes no
longer requires re-entering or leaks the SMTP password / JWT secret. The /conf
form fields carry a leave-unchanged hint. Storage stays in OpenBao at
secret/sso-manager/conf (unchanged); no theta-suite policy change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:15:16 -04:00
wmantly 320e7594e4 Merge pull request #138 from theta42/feature/plugin-system
v1.17.0: real plugin system (loadable instances + OpenBao secrets)
2026-08-01 20:50:58 -04:00
wmantly cec0d92c25 feat: real plugin system with loadable instances + OpenBao secrets (v1.17.0)
Generalize the half-built discovery plugins into a real plugin system: plugin
TYPES (the plugins/<category>/<type>.js modules with manifests) and loadable,
configurable, multi-copy plugin INSTANCES (PluginInstance ORM model) managed
from a dedicated /plugins page and /api/plugins API, with per-instance secrets
in OpenBao at secret/plugins/<id>/conf.

- plugin_registry.js: getTypes/getModule/splitConfig/mask + required-field helpers
- PluginInstance model (Sequelize): id/pluginType/category/name/slug(unique)/
  enabled/cron/config(json, non-secret)/lastRun*; registered in models/index.js
- plugin_secrets.js: read/write/remove/mergeForRun over @simpleworkjs/bao-conf
- scheduler.js: schedules from the DB registry; per-instance stable BullMQ
  JobScheduler ids (plugin:<id>) for load/unload; legacy migration from
  conf.discovery.plugins on first boot (idempotent, empty-table-guarded)
- api_plugins.js (replaces routes/plugins.js): types/list/get/create/update/
  secrets/test/load/unload/run/delete/runs; admin-gated; secrets always masked
- /plugins page (plugins.ejs) + nav; Agents & Scheduler tab removed from
  /directory; /docs/agents aliased to /docs/plugins
- proxmox/unifi/nmap gained manifests (configSchema/validate/run alias)
- tests/plugins.test.js: registry unit + plugin_secrets (mocked bao-conf) +
  PluginInstance model round-trip/unique-slug
- docs (plugins.md, vault.md, _config.yml, API.md) + 1.16.1 -> 1.17.0

Requires theta-suite >= v1.30.1 for the sso-broker secret/plugins/* grant;
fails-soft with a clear error if absent.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 20:33:57 -04:00
wmantly 21a56dce50 v1.16.1: fix 401 on /conf and /vault for logged-in admins (#137)
Both view routes did server-side auth via req.user, but this app's auth-token is
a header set by client JS (localStorage), not a cookie — so req.user is
undefined on a browser navigation. permission.byGroup(undefined,...) throws
status 401, and the middleware.auth gate on /vault threw Auth.errors.login()
(401) for the same reason.

Both routes now render the shell unconditionally (like /users, /directory) and
gate client-side. conf.ejs already called app.auth.forceLogin; vault.ejs now
derives isAdmin + personal namespace from /api/user/me after forceLogin
instead of server-rendering them. /api/conf and /api/vault still enforce
app_sso_admin + OpenBao scope server-side — only the view-route gating moved
client-side where the session lives. Also removed a dead duplicate /conf route.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 18:42:51 -04:00
89 changed files with 9299 additions and 1314 deletions
+3
View File
@@ -19,6 +19,9 @@
!API.md
!directory_spec.md
!docs/**/*.md
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
# in Dockerfile.openldap needs these present in the build context.
!docs/images/**
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
# nodejs/tests/
+137
View File
@@ -1199,6 +1199,143 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
---
## Plugin Endpoints
Base path: `/api/plugins`
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
### List Plugin Types
**`GET /api/plugins/types`**
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
**Response:**
```json
{
"results": [
{
"type": "proxmox",
"category": "discovery",
"name": "Proxmox VE",
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
"configSchema": [
{ "key": "url", "label": "API URL", "type": "url", "required": true },
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
]
}
]
}
```
---
### List Plugin Instances
**`GET /api/plugins/`**
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
---
### Get One Instance
**`GET /api/plugins/:id`** — same shape as a list entry.
---
### Create Instance
**`POST /api/plugins/`**
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
**Request:**
```json
{
"pluginType": "proxmox",
"name": "Proxmox — Home Lab",
"slug": "proxmox-homelab",
"cron": "0 * * * *",
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
}
```
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
---
### Update Instance
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
---
### Update Secrets
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
---
### Test Instance
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
---
### Load / Unload / Run Now
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
---
### Last Run Status
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
---
### Delete Instance
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
## Configuration Endpoints
Base path: `/api/conf`
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
### Get Configuration
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
**Response:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"discovery": { },
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
### Save Configuration
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
**Request:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
**Response:** `{ "success": true }`
## Error Responses
All endpoints return errors in this format:
+357
View File
@@ -1,9 +1,362 @@
# 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.
### theta-agent — enrollment without pre-registering
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
### Directory
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
### Discovery
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
### Docs
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
# v1.29.0
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
### Security — theta-agent channel
- **sec: `/api/agent/ws` accepted any token.** There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token it guessed. Tokens were generated in the *browser* (`generateRandomHexToken`) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with `4001` and audited.
- **sec: the command signing key was ephemeral.** `AgentManager` generated an Ed25519 pair in its constructor, so it changed on every process start and the `public_key` an agent pinned in `agent.yml` stopped matching immediately. The key now lives in OpenBao at `secret/agent/signing-key` and survives restarts. If it cannot be loaded the SSO **refuses** to send high-risk commands rather than signing with a key no agent has seen (`signingAvailable: false` on `GET /api/agent/nodes`).
- **sec: commands are addressed by agent id, not token.** A credential has no business in a URL, an access log or browser history.
- **sec: agent actions are audited.** Enroll, update, rotate, revoke, delete, every command (with `signed`), and every rejected connection are emitted as structured `"component":"agent"` log records carrying the acting user.
### theta-agent — enrollment & resource binding
- feat: `POST /api/agent/enroll` mints the token server-side and returns it **once**; only its SHA-256 is stored. Plus `PUT /nodes/:id` (rename/rebind), `POST /nodes/:id/rotate`, `POST /nodes/:id/revoke`, `DELETE /nodes/:id`. Rotate, revoke and delete drop the live socket immediately (`4004`/`4003`) instead of waiting for a reconnect.
- feat: an agent binds to a **host resource** (`resourceId`). The Directory reads that link instead of guessing by hostname — the old `agentsByHost[name]` match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one.
- feat: **agent discovery reaches the Directory.** A bound agent's facts (`os`, `kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`) are written onto its host resource, tagged `discovery_sources: ["theta-agent"]` with an `agentId` back-reference. An unbound agent goes through the normal reconciler. Previously `handleDiscovery` wrote to an in-memory record and updated nothing — the one source actually running *on* the host contributed nothing to the directory.
- feat: agent state is persisted, so an agent that is installed but **offline** is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
- feat: the Install Agent modal enrolls first and builds the install command from the result, including `--public-key`. `public_key` was never emitted into the generated `agent.yml` before, so no installed agent could verify anything.
- fix: `registerAgent` is synchronous. Awaiting a database write before attaching the WebSocket `message` listener lost every agent's first `discovery` frame, which it sends the instant the socket opens (`ws` drops events emitted with no listener attached).
### Directory
- feat: **the resource tree is collapsible.** Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
- fix: **the Proxmox plugin mismatched MAC addresses to IPs.** It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
- feat: Proxmox discovery emits an **endpoint resource** (named from `/cluster/status`) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent.
- feat: discovered guests carry `sourceId` (`<node>/qemu/<vmid>`), `node`, `vmid` and `macAddress`, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources and `sourceId` to 54/54.
- fix: Proxmox interfaces belonging to something running *inside* a guest (`docker0`, `veth*`, `br-*`, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches.
- fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox **nodes** report their own IP/MAC (recovered from `enx<mac>` predictable names, since `/nodes/*/network` carries no `hwaddr`). Offline nodes are recorded with `status` instead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week.
- fix: **the reconciler could make a resource its own parent.** Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
- fix: **hosts were named after their MAC address.** `bestName` preferred the *longer* name, so UniFi's `ac:16:2d:b3:da:80` (17 chars) beat Proxmox's real hostname `dl380-0` (7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank.
- fix: `isIp` never matched anything — `\\.` inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered.
- fix: a discovered device can only merge into a resource of the same kind. A VM named `gitea-runner` could match a hand-created *service* of the same name on the name rule and overwrite it.
- perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read `metadata.ip` while any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and shows `vmid`, slug, `sourceId` and per-interface MAC/name.
### Profile
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's `.container`.
### Build & docs
- fix: `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29.
- docs: `docs/agents.md` rewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a corrected `public_key` example (the documented `MCowBQYDK2VwAyEA...` was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32).
- docs: `docs/directory.md` covers the collapsible tree and the corrected seed hierarchy; `docs/plugins.md` documents what the Proxmox plugin produces and why the endpoint has no IP.
# v1.28.0
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
# v1.27.0
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function``update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
# v1.26.1
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
# v1.26.0
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
# v1.25.0
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
# v1.24.0
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
- feat: Directory resource slug is now read-only and derived from the name
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
# v1.23.0
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
# v1.22.0
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
# v1.21.0
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
- feat: shared-secrets API + Shared tab in the vault UI
# v1.20.0
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
## v1.19.0
- Added WebSocket endpoint for theta-agent C2
# v1.18.0
- feat: Add messaging plugins, Docker discovery, fix reconciliation
# Changelog
All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
### Fixed
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
`views/plugins.ejs`)
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
(nested paths), never on the directory path itself — so listing a directory's
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
already-created `user-<uid>` policies pick up the new grant on the next
vault-page visit. The matching `sso-admin` mount-root grant ships in
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
always-write so re-running `./setup.sh` applies policy edits.
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
buttons. The header/label are now populated by JS (the `Members` label
already had a setter pointing at a missing id); the Admin Actions block is
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but `PUT /:id` only saves
non-secret config — so an edited secret was silently dropped. The Edit modal
now shows **non-secret fields only** (secrets have their own Edit-Secrets
modal), removing the confusion. (`views/plugins.ejs`)
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
binary was not installed in the app image. `Dockerfile.openldap` now `apk
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
the opaque node-nmap spawn-missing error into an actionable `lastError`.
### Added
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
config files. It now has an SMS card on `/conf` (API username, DID, API
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
the API password masked (`********`) and leave-blank-to-keep — mirroring the
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
saved change takes effect live without a restart. (`routes/api_conf.js`,
`views/conf.ejs`)
- **Terms of Service editor moved to `/conf`** from the admin Overview
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
### Notes
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
policies self-heal on the next vault-page visit.
## [1.17.1] - 2026-08-01
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
(unchanged) — only how they're surfaced to the admin changes.
### Changed
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
(was: returned in cleartext). Non-secret fields (host, port, user, from,
secure, issuer, token lifetimes) are returned as before.
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
as "keep the current stored value" — so an admin editing the From address or
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
secret. Only a genuinely new, non-blank value overwrites. The preserved values
are re-applied to live `conf` immediately, as before.
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
copy notes secret fields are masked. No JSON-textarea editing is involved —
SMTP is and remains configured through structured form fields.
### Notes
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
this release (via `POST /api/conf``baoConf.set('sso-manager/conf')`, and
overlaid back at boot by `bao-conf.init`). This release closes the
cleartext-exposure gap; it does not move the storage path.
- No theta-suite policy change required — `secret/sso-manager/conf` was already
granted to the `sso-broker` policy.
## [1.17.0] - 2026-08-01
A real **plugin system**: the half-built discovery plugins (statically
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
**configurable, loadable/unloadable plugin instances** you manage from a
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
copies of each type and per-instance secrets stored in OpenBao.
### Added
- **Plugin instances** — a new `PluginInstance` ORM model
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
configured, scheduled plugin copies. Each has a `pluginType`, a unique
`slug` (the discovery source name), a cron schedule, an `enabled` flag
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
instances of the same type are supported.
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
non-secret), `mask`, and required-field helpers for the UI/API.
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
the DB. The UI only ever sees masked (`********`) values. Plugins run
in-process (BullMQ workers), so they need no OpenBao token of their own — the
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
with a clear error if absent.
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
test/load/unload/run/delete/runs. Admin-only
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
rendered from each type's `configSchema`.
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
### Changed
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
upsert/remove one schedule without disturbing the rest. Discovery plugins
reconcile results under the instance's `slug`.
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
secret.
- The `/plugins` page route renders the page instead of redirecting to
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
(plugins are now managed on the Plugins page). The `/docs/agents` link is
aliased to `/docs/plugins`.
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
(Plugin Endpoints section) document the new system.
### Legacy migration
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
`conf.discovery.plugins` has entries, one instance per configured type is seeded
automatically (secret fields copied into OpenBao). After that the static
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
empty-table check).
### Prerequisite
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
plugin secrets fails with a clear error.
## [1.16.1] - 2026-08-01
Fix: the Configuration (`/conf`) and Vault (`/vault`) pages returned **401** for
a logged-in admin. Both view routes did server-side auth using `req.user`, but
this app's auth-token is a header set by client-side JS (localStorage), not a
cookie — so `req.user` is undefined on a plain browser navigation.
`permission.byGroup(undefined, …)` throws status 401, and the `middleware.auth`
gate on `/vault` threw `Auth.errors.login()` (401) for the same reason.
Both routes now render the shell unconditionally (like `/users`, `/directory`,
`/overview`) and gate client-side: `conf.ejs` already called
`app.auth.forceLogin(['admin','app_sso_admin'])`; `vault.ejs` now derives
`isAdmin` + the personal namespace from `/api/user/me` after `forceLogin()`
instead of server-rendering them. The `/api/conf` and `/api/vault` endpoints
still enforce `app_sso_admin` + the OpenBao scope server-side, so protection is
unchanged — only the view-route gating moved client-side where the session
actually lives. Also removed a dead duplicate `/conf` route definition.
## [1.16.0] - 2026-08-01
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
@@ -457,3 +810,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
## [1.19.6] - 2026-08-02
### Fixed
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
+5 -1
View File
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
dumb-init \
bash \
redis \
nmap \
&& rm -rf /var/cache/apk/*
COPY --from=ldapbuild /opt/openldap /opt/openldap
@@ -181,9 +182,12 @@ COPY tos.md /tos.md
# without internet access. Same flattened-path convention as tos.md above.
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
# fs.readFileSync in routes/docs.js (files missing from the image).
COPY docs /docs
# Baked commit hash from the gitinfo stage (see build_info.js).
+4 -2
View File
@@ -22,6 +22,10 @@ COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
# Without this the discovery/plugin suites cannot even load their subject and
# fail as "Cannot find module ../plugins/discovery/..." -- plugin code was
# effectively untested in CI.
COPY nodejs/plugins ./plugins
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
@@ -36,10 +40,8 @@ RUN mkdir -p /app/config
COPY tos.md /tos.md
COPY README.md /README.md
COPY CHANGELOG.md /CHANGELOG.md
COPY DEPLOYMENT.md /DEPLOYMENT.md
COPY API.md /API.md
COPY directory_spec.md /directory_spec.md
COPY docs /docs
# Seed script and utility
COPY test_seed.js ./test_seed.js
+2 -1
View File
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
`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
OAuth clients only), `app_sso_invite` (invitation management),
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
DEPLOYMENT.md for the full setup.
## Development
+12 -8
View File
@@ -355,7 +355,11 @@ EOF
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
# app_sso_service_account is a marker (not a permission gate) for
# non-person accounts -- see the Users page.
for group in app_super_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
#
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
# group-inheritance lattice. It is seeded here so it exists from first boot;
# the theta-suite bootstrap puts the first admin person into it.
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
objectClass: groupOfNames
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
EOF
done
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
# hold those rights by membership rather than by a special case in app code.
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
# anything binding LDAP directly -- instead of only to callers that happen
# to route through utils/permission.js.
# Nest god_admin into the SSO admin groups, so god admins hold those rights
# by membership rather than by a special case in app code. This is what makes
# the privilege visible to every consumer -- SSSD, sudo, anything binding
# LDAP directly -- instead of only to callers that happen to route through
# utils/permission.js.
#
# app_sso_service_account is deliberately excluded: it is a marker for
# non-person accounts, not a permission, and nesting admins into it would
@@ -381,10 +385,10 @@ EOF
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
changetype: modify
add: member
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
EOF
done
info "Nested app_super_admin into the SSO admin groups"
info "Nested god_admin into the SSO admin groups"
fi
info "LDAP directory initialized"
+3
View File
@@ -34,6 +34,9 @@ nav:
- title: Directory
page: /directory.html
icon: fa-server
- title: Plugins
page: /plugins.html
icon: fa-plug
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
# api.html for it — link the source directly, same as the Changelog.
- title: API
+278 -68
View File
@@ -1,88 +1,298 @@
---
layout: default
title: Discovery Agents
title: Theta Agent & Endpoint Management
nav_order: 5
---
# Discovery Agents
# Theta Agent & Endpoint Management
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
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.
## Writing a Custom Agent
---
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
## Enrollment
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
An agent is only real if the SSO issued its credential. **Tokens the server did
not issue are rejected** at the WebSocket handshake.
### Agent Skeleton
There are two ways to get a host enrolled, and the first is the normal one.
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
### Join key — install the agent and the host appears
// 1. Fetch your data from an API
// const data = await fetch(...);
Hand the machine a **join key** and nothing else. On first connect the SSO
enrolls the host, issues it its own per-agent token plus the public key it must
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
key. From then on it authenticates as itself.
// 2. Map data to Resources
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
return { resources, edges };
}
};
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
--url "https://<SSO_HOST>" --join-key "tjk_..."
```
## Configuration
That is the whole procedure — no pre-registering the machine, no copying a
public key by hand. `setup.sh` mints a key and configures the stack's own host
this way automatically.
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
The join key is a *bootstrap* credential, not the host's identity. That
distinction is what keeps one key convenient without making it a fleet-wide
skeleton key: every host still ends up individually revocable, and a compromised
host does not yield a credential that works anywhere else.
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
| Endpoint | Purpose |
| :--- | :--- |
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
| `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 |
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.
### Pre-registering a host
When you want the agent bound to a specific Directory host up front, enroll it
from **Directory → Install Agent**:
1. Give the agent a name and **bind it to a host resource**. The binding is what
links telemetry, status and commands to a Directory entry.
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
SHA-256, and shows the raw value **once**.
3. Copy the generated install command — it already carries the token and the
server's public key.
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
`PUT /api/agent/nodes/:id` or from the Directory.
Or via the API:
```bash
curl -X POST https://<SSO_HOST>/api/agent/enroll \
-H "Authorization: Bearer <admin-api-token>" \
-H 'Content-Type: application/json' \
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
```
## The Reconciliation Engine
The response contains `token` (once only) and `publicKey`.
| Endpoint | Purpose |
| :--- | :--- |
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
| `POST /api/agent/enroll` | Mint an agent + token |
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
Revoke, rotate and delete **drop any live connection immediately** — they do not
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
token: a token is a credential and has no business in a URL or a log.
Enrollment, revocation, rotation, every command, and every rejected connection
are written to the application log as structured `"component":"agent"` records
with the acting user.
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
> the agent to issue a new one.
---
## Core Functionality
### 1. Host Discovery & Inventory
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
### 2. Real-Time Telemetry Streaming
Every 30 seconds, the agent streams real-time performance metrics:
- **CPU Load**: System-wide CPU utilization percentage.
- **Memory Utilization**: RAM usage percentage and available memory.
- **Disk Utilization**: Root filesystem usage percentage.
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
---
## Viewing in the SSO Manager
Agent status and telemetry live on the **Directory** page — there is no separate
Agents page. For each **host** resource that has a connected theta-agent, the
Directory shows a status dot in the row:
| Color | Meaning |
| :--- | :--- |
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
Red and grey used to be the same colour, which made an ordinary directory of
hosts look like an outage. Because the enrollment now outlives the connection,
"installed but down" is distinguishable from "never had an agent".
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
without a binding fall back to matching their reported hostname against the
resource name — the old behaviour, kept only as a fallback, because it silently
failed whenever a Directory name differed from the machine's hostname and
aliased two hosts that happened to share one.
### Agent discovery feeds the Directory
A bound agent's discovery payload is written onto its host resource (`os`,
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
runs *on* the host it describes, so it is the most authoritative source the
directory has. An unbound agent goes through the normal discovery reconciler
instead, matching like any other source.
---
## Local-First Security & Capability Matrix
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
| Capability | Config Key | Risk Level | Description & Impact |
| :--- | :--- | :--- | :--- |
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
| **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). |
---
## High-Risk Command Verification (Protocol v1.2.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
no HTML escaping, `signature` omitted).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
**The signing key is persistent.** It lives in OpenBao at
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
`agent.yml` keeps matching. (It used to be generated in memory at boot and
changed on every restart, which made pinning impossible.) If the SSO cannot load
or store a key it **refuses** to send high-risk commands rather than signing with
one no agent has seen — `GET /api/agent/nodes` reports this as
`signingAvailable: false`.
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
`./setup.sh` from theta-suite if you are upgrading.
**Verification is fail-closed on the agent.** An agent with no `public_key`
configured rejects every high-risk command. Earlier versions logged "skipping
signature verification" and executed them, so an agent installed without a key
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
---
## Installation & Deployment
### Quick One-Liner Install
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
Both values come from enrollment. The **Install Agent** modal builds this line
for you with them already filled in. Omitting `--public-key` leaves the agent
able to report telemetry but unable to accept any high-risk command.
### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```
---
## Configuration File Example (`/etc/theta42/agent.yml`)
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
# Issued by the SSO. Left empty when installing with a join key -- the agent
# fills it in itself once the server enrolls it.
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
# agent once it has its own token.
join_key: ""
location: "dc-01-rack-12"
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
# command if this is wrong.
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
capabilities:
telemetry: true
configure_ldap: true
reboot: false
service_control: ["nginx", "docker", "sssd"]
arbitrary_bash: false
```
---
## Troubleshooting: agent is rejected (`close 4001`)
If the agent logs that the server rejected its token, the enrollment — not the
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
with an application code:
| Code | Meaning | Fix |
| :--- | :--- | :--- |
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
| `4003` | Enrollment revoked or deleted | Re-enroll |
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
every 5 seconds — a credential that is wrong will not fix itself, and hammering
the SSO only floods its audit log.
An agent installed before protocol v1.2.0 carries a token generated in the
browser that the server never recorded, so it will be rejected with `4001` until
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
`join_key` and blank `auth_token` — it will re-enroll itself on the next
reconnect.
---
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
connecting to `wss://<sso-host>/api/agent/ws`, the WebSocket path is usually
fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind
the same NAT that owns the SSO often cannot reach its own **public IP** (no
hairpin/loopback NAT on many home routers), so the TCP dial times out even
though the same address works from outside.
Fix options:
1. Point `agent.yml` `server_url` at an address the host can reach directly —
e.g. the SSO host's LAN IP (`http://<lan-ip>` or `http://<lan-ip>:3001` for a
no-TLS direct path).
2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach
their own public IP:443.
3. Add a local route/firewall rule on the agent host for its public IP.
> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make
> 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).
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+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 |
+22 -2
View File
@@ -78,7 +78,19 @@ Requests are decided by the resource's `owner`, or by any directory admin. Mark
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
The Directory Management interface nests your resources as a tree, making it easy
to comprehend your network topography at a glance. You can filter, search, and
sort your entire infrastructure inventory. Click the green `+` icon next to any
resource to add a child resource beneath it.
**Collapsing the tree.** Any resource with children carries a caret; click it to
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
everything at once. Collapsed state is remembered per browser, so the shape you
arrange survives a refresh (and the self-heal reload that follows most edits).
While a search filter is active every match is shown regardless of collapsed
ancestors — otherwise searching for something inside a folded subtree would
silently return nothing. Clearing the box restores your saved shape.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
@@ -102,9 +114,17 @@ You don't have to build the graph by hand — the theta42 tooling registers itse
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
Services are parented to the host that actually runs them: Proxy and OpenResty
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
rest under the stack host. Installs seeded before this was fixed had all of them
under the stack host, leaving the two purpose-made host resources childless; the
seed re-parents those on its next run, and only when the current parent is the
one the old code set, so a layout you arranged deliberately is left alone.
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
+117
View File
@@ -0,0 +1,117 @@
---
layout: default
title: Discovery & Inventory
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:
- **Catalog resources** — what you have declared. Created by hand, seeded by
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
groups, appear in the Catalog, and are the only hosts the
[jump host](https://github.com/theta42/jump-host) will connect you to.
- **Discovered resources** — what the network reports. Produced by
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
tab. They are a queue of "this exists, do you want to manage it?", not
infrastructure you have committed to.
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
and it has never been promoted. Promoting sets `metadata.managed = true`, at
which point it becomes catalog content like any other resource.
> Nothing grants access to a discovered resource. It carries no groups until it
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
> guest is not a jump target.
---
## Where discovered data comes from
| Source | What it reports |
| :--- | :--- |
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
| [nmap](plugins.html) | Hosts and open ports on a target range |
| [Docker](plugins.html) | Containers on a local or remote daemon |
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
An agent is the most authoritative of these: it runs *on* the machine it
describes. A network scan is the least — it only knows what answered.
---
## How results are matched to existing resources
Every source runs through one reconciler, so two sources seeing the same
machine converge on one resource instead of creating duplicates. Matching is
tried in order of precision:
1. **MAC address** — the strongest signal, compared across every interface.
2. **IP address** — any address on any interface, plus `metadata.address`.
3. **Slug, name, or base hostname** — last resort.
A candidate must also be **the same kind**. Without that guard a discovered VM
named `gitea-runner` would match a hand-created *service* of the same name on
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
template is the same machine.)
When a match is found the metadata is merged, interfaces are unioned by MAC, and
the source is added to `discovery_sources` — so a resource can legitimately read
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
### Naming
Sources disagree about names, so the most human one wins: a **hostname** beats
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
tie-break within a rank. This is why a device UniFi knows only as
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
### Relationships
Plugins emit edges as well as resources (a Proxmox node under its cluster
endpoint, a guest under its node). The reconciler refuses any edge that would
make a resource its own parent, or that would close a loop — a cycle renders as
an infinitely nested tree and breaks every ancestor walk in the app.
---
## Promoting a discovered resource
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
pre-filled with what was discovered — name, kind, address, subtype — so you can
correct it before committing. Saving marks it managed and provisions its
[LDAP groups](groups.html).
Each row shows what the directory knows about the device: its source(s), its
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
looks wrong, that detail is where to start.
---
## Stale results
Resources that are *only* auto-discovered are garbage-collected: if a source
stops reporting one for long enough it is marked
`lifecycle_state: "archived"` rather than deleted. Anything you created or
promoted is never touched — `manual` in `discovery_sources` exempts it.
A Proxmox node that is powered off is still reported (with its `status`), so
downtime does not look like decommissioning.
---
## What the stack discovers about itself
`setup.sh` seeds its own components as catalog resources — the site, the stack
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
discovery plugin then finds the containers backing them. Containers belonging to
the theta-suite compose project are recognised and attached to the service they
implement rather than appearing as unmanaged strangers, so a fresh install has an
empty Discovered Inventory rather than five things demanding attention.
+312
View File
@@ -0,0 +1,312 @@
---
layout: default
title: Group & Permission Model
nav_order: 3
---
# Theta42 Group & Permission Model
This is the canonical reference for how **groups and permissions work** across the
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
hosts** should read and use them. It is written to be implementable by both humans
and LLM agents.
Everything below assumes LDAP is the single source of truth for identity and group
membership. Group membership is managed in the **SSO Manager Directory**, generated
from adopted resources — there is **no standalone "Groups" page**.
---
## 1. Principles
1. **Groups are a projection of the resource graph.** Every adopted host and app
in the Directory gets its own groups, auto-created from its identity. Group
membership is managed on the resource's modal.
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
hosts; an app administers apps. They do not inherit from each other.
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
`admin` implies `access`. Capabilities are explicit and never implied by
`admin`.
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
the site slug.
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
bloat. Only the meta groups are never imported by hosts.
6. **The directory is the only place groups are created.** `god_admin` is the sole
group that does not belong to a resource or site.
---
## 2. Group schema
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
`<capability>` = an opaque, app-defined capability token (see §4).
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
| `S_host_<host>_access` | host | Access to host `<host>`. |
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
| `S_apps_access` | site | Access to **all apps** at `S`. |
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
| `S_app_<app>_access` | app | Access to app `<app>`. |
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
### Meta groups (implicit membership — not POSIX, no gidNumber)
| Group | Scope | Meaning |
| :--- | :--- | :--- |
| `everyone` | global | **All authenticated users**, any site. |
| `S_everyone` | site | **All authenticated users** at site `S`. |
These are resolved by the directory (any authenticated user passes), never
enumerated as LDAP members, and cannot be used as Unix groups.
---
## 3. Naming, normalization & reserved rules
- The **structural delimiter is `_`**. It appears only between the fixed segments
of a group name.
- **Site, host, and app slugs never contain `_`.** Normalize to lowercase;
spaces and `_``-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a
site `Main Office` produce slugs `web-01` and `main-office`.
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
if a host were named `admin` (that host would be `S_host_admin_admin`).
- **The last segment is the level.** If it is `admin` or `access` it is a known
level; any other value is an **opaque capability** owned by a downstream app.
- **Total length budget:** keep a group cn under ~120 chars; reject group
creation that would exceed it.
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
generated on the host by SSSD for only the groups that host imports (see §8).
---
## 4. Levels and opaque capabilities
- **`admin`** — manage (create/update/delete/config) the resource.
- **`access`** — use/read the resource.
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
manages membership and exposes the group to the app; **the downstream app
defines and enforces what the capability means** (e.g. `emby_admin`,
`gitea_maintain`, `reboot`, `backup`).
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
Everything else on a resource group is treated as an opaque capability group and
passed through to consumers.
---
## 5. Permission resolution (inheritance)
Define a user's **effective permission** on a resource by checking, from most
specific to most general, whether they are a member of any applicable group. The
rule: a higher group implies everything below it.
### On host `H` at site `S`
| Wanted | Granted if the user is a member of **any** of |
| :--- | :--- |
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
### On app `A` at site `S`
Identical, with `app`/`apps` substituted for `host`/`hosts`.
### Management console (SSO / Proxy / Jump-Host)
Each console is registered as an **app** on its site, so console admin is:
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
### Pseudocode
```
def effective(resource, level_or_cap, site):
if user in "god_admin": return True
if user in f"{site}_super_admin": return True
if level_or_cap in ("admin","access"):
agg = f"{site}_{resource.kind}s_{level_or_cap}"
if user in agg: return True
specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}"
if user in specific: return True
if level_or_cap == "access": return effective(resource, "admin", site)
if level_or_cap == "admin": return False # access does not imply admin
return False
```
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
---
## 6. Where groups live — the Directory, generated from adopted resources
- There is **no standalone Groups page.** Group creation/management happens on an
**adopted resource** in the Directory.
- When a host or app is **adopted** (promoted from Discovered Inventory to
managed), the directory auto-creates its `_admin` and `_access` groups (and
site aggregates if configured). Capability groups are created on demand.
- Membership (add/remove users) and capability grants are managed on that
resource's modal.
- Deleting a resource removes its per-resource groups.
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
global `god_admin`/`everyone` are managed at the site level (not on a single
host/app resource).
---
## 7. Multi-site isolation
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
`everyone` cross site boundaries.
---
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
There are hundreds of them and only a handful matter on any given host, so we do
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
This keeps the directory clean and the per-host surface tiny.
### SSSD — generate GIDs on the fly, import only what you need
```ini
[domain/example]
id_provider = ldap
auth_provider = ldap
ldap_uri = ldaps://ldap.example
ldap_search_base = dc=example,dc=com
# groupOfNames (RFC 2307bis) schema
ldap_schema = rfc2307bis
ldap_group_object_class = groupOfNames
ldap_group_member = member
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
ldap_id_mapping = true
ldap_group_uuid = entryUUID
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
```
Key ideas:
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
stable GID for any group it imports, so **no `gidNumber` attribute is required**
in LDAP.
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
match, discarding the other hundreds. After changing the filter, clear the
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
`getent group <cn>`.
### What filter to use — the naming convention is the answer
A host should import its **own** resource groups (plus any explicitly granted
ones). Because the schema is predictable, `ldap-client` can generate the per-host
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
at site `main-office` imports:
```
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
(cn=main-office_host_web01_admin)
(cn=main-office_host_web01_sudo)))
```
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
### Aliasing an LDAP group into a local group (e.g. `input`)
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
Two host-side mechanisms cover it:
- **pam_exec** — a script in the login stack adds the user to the local group for
the session:
```sh
#!/bin/bash
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
```
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
`/etc/pam.d/common-session`.
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
members regardless of the local GID.
### Meta groups
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
implicit membership and are resolved by the directory only.
---
## 9. Downstream-app consumption guide
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
membership from LDAP and interprets it as follows:
1. **Discover the user's groups** — bind with the user's credentials (or use a
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
the `memberOf` reverse attribute on the user's entry.
2. **Match each group to a scope:**
- `god_admin` → the user is a global administrator.
- `{site}_super_admin` → site administrator for that site.
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
- `everyone` / `{site}_everyone` → the user is implicitly a member.
3. **Interpret the last segment:**
- `admin` → full control of that resource.
- `access` → read/use.
- anything else → a capability **you** define; act on it or ignore it.
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
`{site}_app_emby_emby_admin` administers Emby.
The app must **never** treat an unknown last segment as `admin` or `access`.
---
## 10. Migration from the legacy `app_*` groups
The current global groups (`app_sso_admin`, `app_super_admin`,
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
| Legacy | New |
| :--- | :--- |
| `app_super_admin` | `god_admin` |
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
| `app_sso_directory_admin` | `S_app_sso_admin` |
| `app_jump_admin` | `S_app_jump_admin` |
During the transition the legacy groups may be kept as short-lived aliases that
resolve to the same effective permission; once everything is moved, remove them.
---
## 11. The management consoles are apps
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
self-consistent: the SSO is "just another app."
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

+4
View File
@@ -60,6 +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
+184
View File
@@ -0,0 +1,184 @@
# Plugins
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
## Plugin types
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`. 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
One endpoint becomes one subtree:
```
Proxmox endpoint (cluster name, or the endpoint hostname)
└── node (hypervisor)
├── VM / template
└── LXC / template
```
The endpoint resource stands for the cluster, not a machine, so it carries the
API URL and a `sourceId` but deliberately no IP — giving it the address it is
reached at made the reconciler merge it with the node answering on that address,
which produced a resource that was its own parent.
Every guest carries:
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
The MAC and the address on it are read from the same source, so they cannot be
mismatched (an earlier version collected MACs and IPs into two flat lists and
zipped them by index, which attributed addresses to the wrong NIC on any
multi-NIC guest).
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
has an address.
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
a directory row traces back to the exact guest on the exact node.
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
172.x addresses would otherwise give the reconciler spurious matches.
A stopped VM still reports its MAC (read from the VM config rather than the
guest agent), and a DHCP-configured LXC gets its address from the running
container's interface list. Offline nodes are recorded with `status` rather than
skipped, so a hypervisor that is down does not look decommissioned and get
garbage-collected after a week.
A module exports a **manifest**:
```javascript
module.exports = {
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { … },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
discovery: {
plugins: {
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
};
```
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+67 -20
View File
@@ -1,39 +1,86 @@
---
layout: default
title: Secrets Vault
nav_order: 6
title: Vault Secrets
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
---
# Secrets Vault
# Vault Secrets Management
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
[← Back to Home](index.html)
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
The Vault 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.
## Architecture
## Usage
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
You can access the Vault UI from the application's top navigation bar.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
### Creating Secrets
## Accessing the Vault
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 SSO Manager Vault can be accessed in two ways:
### Reading and Editing Secrets
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
* 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.
### API Example
### OpenBao Integration
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
mode, which auto-unseals with an in-memory store and loses everything on
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
way as the rest of the app (session cookie or a personal API token) — the
server resolves your OpenBao access itself and injects the right scoped
token; you never see or handle a raw OpenBao token as a UI user.
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## Apps tab (admin)
## Namespaces and Paths
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.
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
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.
## Plugin Integration
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
When building custom Agents or integrations, they can utilize the local Vault to retrieve API tokens instead of hardcoding them. Always use the `/api/vault` proxy to ensure permissions are consistently enforced.
## Shared tab
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
## API Access
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 (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.
+19 -3
View File
@@ -43,7 +43,11 @@ app.onListen.push(function(){
// socket.broadcast.emit('P2PSub', msg);
});
});
});
// Initialize Theta Agent WebSockets. The REST router is already mounted
// synchronously above (see the /api/agent mount); this hook only wires the WS.
require('./routes/api_agent').initAgentWebSockets(app);
});
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
// uncompressed vendor JS/CSS files on every full page navigation (a
@@ -69,7 +73,8 @@ app.locals.ui = require('./utils/ui');
// Have express server static content( images, CSS, browser JS) from the public
// local folder. maxAge is short since this is the app's own JS/CSS, which
// changes on every deploy and isn't cache-busted/fingerprinted.
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
// Routes for front end content.
app.use('/', require('./routes/index'));
@@ -101,13 +106,22 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
// not from an onListen hook — a router registered post-listen would sit behind
// the terminal 404 handler and make every /api/agent/* request 404. The agent
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery);
app.use('/api/webhook', require('./routes/webhook'));
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
// Plugin instances — loadable/unloadable, configurable plugin copies with
// per-instance secrets in OpenBao (secret/plugins/*). Admin-only (gated inside
// the router to app_sso_admin / app_sso_directory_admin).
app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
// OpenBao vault API. The broker mints a server-side scoped token per user
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
@@ -119,6 +133,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
const vaultBroker = require('./utils/vault_broker');
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
// Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called.
+20
View File
@@ -25,6 +25,19 @@ var server = http.createServer(app);
var io = require('socket.io')(server);
app.io = io;
const WebSocket = require('ws');
const wss = new WebSocket.Server({ noServer: true });
server.on('upgrade', (request, socket, head) => {
// We only handle upgrade for /api/agent/ws.
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
if (request.url.startsWith('/api/agent/ws')) {
wss.handleUpgrade(request, socket, head, (ws) => {
wss.emit('connection', ws, request);
});
}
});
app.wss = wss;
const models = require('../models');
/**
@@ -47,6 +60,13 @@ models.initORM().then(() => {
initScheduler(conf.discovery).catch(err => {
console.error('Failed to initialize scheduler:', err);
});
// Keep external-app vault tokens alive: renew every stored accessor now and
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
if (process.env.VAULT_TOKEN) {
require('../utils/vault_broker').startAppTokenRenewal();
}
}).catch(err => {
console.error('Failed to initialize ORM:', err);
process.exit(1);
-8
View File
@@ -57,14 +57,6 @@ module.exports = {
password: '__in secrets file__',
did: '__in secrets file__',
},
smtp: {
host: 'localhost',
port: 587,
secure: false,
user: 'noreply@example.com',
pass: '__in secrets file__',
from: 'SSO Manager <noreply@example.com>',
},
directory: {
// Public SSH jump host fronting the lab, if there is one (the jump-host
// component). When set, a host card in the catalog shows the real
Binary file not shown.
+110 -42
View File
@@ -1,64 +1,132 @@
# Plugins & Scheduler
# Plugins
The SSO Manager includes a flexible background task runner and discovery system. Plugins are defined statically in your deployment configuration (`sso-secrets.js`) and run based on their defined `cron` schedule.
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
## Writing Custom Plugins
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
You can write custom plugins to discover resources, manage internal state, or run automated scripts. Plugins must be placed in the `plugins/discovery/` directory of the SSO Manager node codebase.
## Plugin types
A plugin file must export a `discover` method.
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/`:
**Example Plugin (`plugins/discovery/my_plugin.js`):**
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
A module exports a **manifest**:
```javascript
module.exports = {
discover: async function(config) {
// The config object contains any keys passed in sso-secrets.js for this plugin.
// Perform discovery logic, hit external APIs, etc.
const resources = [
{
slug: 'my-custom-resource-1',
name: 'My Resource 1',
kind: 'Host',
metadata: {
ip: '10.0.0.100',
source: 'My Custom Plugin'
}
}
];
// Return the discovered resources array. The discovery reconciler will
// automatically save these to the Network Discovery database.
return resources;
}
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { … },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
## Configuring Plugins
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
In your `sso-secrets.js` file, add your plugin to the `discovery.plugins` object:
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
// ...
discovery: {
plugins: {
my_plugin: {
enabled: true,
cron: "0 * * * *", // Run every hour
my_custom_key: "my_custom_value" // Passed to the config argument in discover()
}
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
// ...
};
```
### Overriding Timing and Enable/Disable
From the **Plugins & Scheduler** tab in the Directory Dashboard, you can override the schedule and enable/disable state for each plugin. These overrides take precedence over `sso-secrets.js` and are stored internally.
## Scheduler Internals
The scheduler uses BullMQ backed by Redis to manage execution. It automatically performs garbage collection on stale network resources (resources not updated in > 7 days) and triggers your plugins at the defined intervals.
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+182
View File
@@ -0,0 +1,182 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A theta-agent enrolled against this SSO.
//
// Before this model existed the "agent token" was generated in the browser and
// never recorded anywhere, so the server had no way to tell an agent it issued
// from one someone invented -- /api/agent/ws accepted any string, and there was
// no way to revoke a token or to know that an agent existed while it was
// offline. The row is now the authority: an agent is only real if it is here.
//
// The raw token is shown exactly once, at enrollment. Only its SHA-256 lands in
// the database, so a database disclosure does not hand over working agent
// credentials. `tokenPrefix` is the first 8 characters, kept in the clear so the
// UI and logs can identify an agent without holding the secret.
class Agent extends Model {
// Tokens are compared by hash on every WebSocket connect. SHA-256 (not
// bcrypt) is deliberate: this runs on the connection path and the token is a
// 256-bit random value, not a human-chosen password, so there is nothing for
// a slow KDF to protect against here.
static hashToken(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
// Resolve a presented token to its (non-revoked) agent, or null. Every
// caller that authenticates an agent must go through here.
static async authenticate(rawToken) {
if (!rawToken || typeof rawToken !== 'string') return null;
const tokenHash = this.hashToken(rawToken);
const matches = await this.list({ where: { tokenHash } });
const agent = matches && matches[0];
if (!agent) return null;
if (agent.revoked) return null;
return agent;
}
// Enroll a new agent and return { agent, token }. The caller is responsible
// for showing `token` to the operator once and never storing it.
static async enroll({ name, resourceId, enrolledBy, description }) {
const token = this.generateToken();
const agent = await this.create({
id: crypto.randomUUID(),
name: name || 'theta-agent',
description: description || null,
tokenHash: this.hashToken(token),
tokenPrefix: token.slice(0, 8),
resourceId: resourceId || null,
revoked: false,
enrolled_by: enrolledBy || null,
enrolled_on: Math.floor(Date.now() / 1000)
});
return { agent, token };
}
// Issue a fresh token for an existing agent, invalidating the old one.
async rotateToken() {
const token = Agent.generateToken();
await this.update({
tokenHash: Agent.hashToken(token),
tokenPrefix: token.slice(0, 8),
revoked: false
});
return token;
}
static fields = {
id: { type: 'uuid', primaryKey: true },
name: { type: 'string', isRequired: true },
description: { type: 'text' },
// Never the raw token. See hashToken above.
tokenHash: { type: 'string', isRequired: true },
tokenPrefix: { type: 'string' },
// The host this agent runs on. Nullable so an agent can be enrolled
// before its host exists in the Directory, but the UI pushes for it:
// without this link there is nothing to hang resource control off, and
// the old code had to guess by matching hostnames to slugs.
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
revoked: { type: 'boolean', default: false },
enrolled_by: { type: 'string' },
enrolled_on: { type: 'integer' },
// 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.
last_seen: { type: 'integer' },
last_ip: { type: 'string' },
lastDiscovery: { type: 'json', default: {} },
lastTelemetry: { type: 'json', default: {} }
};
// The shape the admin API returns. Never includes tokenHash.
toPublic(liveState) {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.tokenHash;
return {
...data,
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.
isOnline: !!(liveState && liveState.connected),
lastResponse: (liveState && liveState.lastResponse) || null
};
}
}
// A join key: the one credential an operator hands out so a host can enroll
// itself. Requiring an admin to pre-register every machine before the agent
// would talk to them made adding a host a two-system chore -- installing the
// agent should be enough.
//
// A join key is NOT the agent's long-term credential. On first connect the
// server auto-enrolls the host and issues it a unique per-agent token, which
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
// operator experience to "one key" while still giving every host its own
// revocable identity -- revoking a single agent means something, and a host
// that is compromised does not hand over the credential for the whole fleet.
class AgentJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
// `tjk_` so an operator can tell a join key from an agent token at a
// glance -- they are handled very differently.
return 'tjk_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable join key, or null. Expiry and
// revocation are both 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 = { Agent, AgentJoinKey };
+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
+39 -1
View File
@@ -16,6 +16,11 @@ const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -29,16 +34,49 @@ async function initORM() {
await init({
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook,
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
console.log('[initORM] ORM initialized successfully');
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
await healSchema();
} catch (err) {
console.error('[initORM] ORM initialization failed:', err.message);
throw err;
}
}
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
// which creates missing tables but never touches existing ones — so a column
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
// in an upgraded deployment's database and every query on the model fails
// ("no such column"). This walks each Sequelize model and ADDs any attribute
// missing from its table. Strictly additive (never drops or retypes), works on
// any dialect via the query interface, and fail-soft per column so one bad
// attribute can't take the boot down.
async function healSchema() {
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
if (!adapter || !adapter.sequelize) return;
const sequelize = adapter.sequelize;
const qi = sequelize.getQueryInterface();
for (const SM of Object.values(sequelize.models)) {
const table = SM.getTableName();
let existing;
try { existing = await qi.describeTable(table); }
catch (e) { continue; } // no table yet — sync() handles creation
for (const [name, attr] of Object.entries(SM.getAttributes())) {
const col = attr.field || name;
if (existing[col]) continue;
try {
await qi.addColumn(table, col, attr);
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
} catch (e) {
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
}
}
}
}
module.exports.initORM = initORM;
+83
View File
@@ -0,0 +1,83 @@
'use strict';
// PluginInstance — the registry of configured, loadable plugin copies.
//
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
// type. You can have several instances of the same type (e.g. two Proxmox
// endpoints with their own URLs + tokens), each on its own schedule.
//
// This table holds the *non-secret* per-instance state: which type it is, its
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
// the DB. The DB row's `config` JSON column holds only non-secret field values.
//
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
// so a discovery instance's resources are attributed to a stable, human-chosen
// name rather than its uuid. Unique, so two instances can't shadow each other
// in the resource graph's `discovery_sources`.
//
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
// handler stamps created_by/on + updated_by/on explicitly on every write (see
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
const { Model } = require('@simpleworkjs/orm');
const STATUS = {
OK: 'ok',
ERROR: 'error',
RUNNING: 'running',
};
class PluginInstance extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// A registered plugin type slug (matches a manifest `type`). Validated
// against the registry before a row is created.
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
// create time so the scheduler can dispatch without re-reading the registry
// on every run (and so a later type removal still shows what the instance was).
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
// Human label for the instance.
name: { type: 'string', isRequired: true, min: 1, max: 120 },
// Stable handle: discovery source name + unique constraint. Lowercase
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
enabled: { type: 'boolean', default: true },
// Cron schedule (5-field). The scheduler turns this into a BullMQ
// repeatable JobScheduler.
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
// Non-secret configSchema field values. Secret fields are NOT here.
config: { type: 'json', default: {} },
// Last-run bookkeeping, updated by the scheduler worker.
lastRunAt: { type: 'integer' },
lastStatus: { type: 'string' },
lastError: { type: 'text' },
lastLog: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All instances the scheduler should run: enabled only. Loaded fresh each
// boot / load; not cached on the model (the scheduler is the source of truth
// for what's actually scheduled).
static async listEnabled() {
return this.list({ where: { enabled: true } });
}
// Look up by slug — used by tests + the reconciler when only a slug is known.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { PluginInstance, STATUS };
+3 -1
View File
@@ -96,11 +96,13 @@ class Resource extends Model {
return false;
}
let maxUpdated = 0;
resObjs.forEach(r => {
r.metadata.isProduction = checkProd(r.id);
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
});
return { resources: resObjs, edges };
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
}
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
+56
View File
@@ -0,0 +1,56 @@
'use strict';
// SharedSecret — a secret the owner has published to the shared namespace so it
// can be shared with other users and/or downstream apps.
//
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
// never in the DB. This row is metadata only (owner + slug + description) and is
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
// edited to add `read` on the exact shared path (see vault_broker.js — policy
// content is parsed live at token use, so a grant takes effect immediately with
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
//
// `slug` is unique and immutable in practice — it is embedded in the shared path
// and in grantee policy rules, so changing it would require rewriting policies.
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
// ORM on create.
const { Model } = require('@simpleworkjs/orm');
class SharedSecret extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
// Unique so two owners can't collide on the same shared path.
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The publishing user's uid — also the shared path's namespace segment.
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
// Optional human description shown in the Shared tab.
description: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
static pathFor(ownerUid, slug) {
return `shared/${ownerUid}/${slug}`;
}
path() {
return SharedSecret.pathFor(this.ownerUid, this.slug);
}
// Look up by slug (unique). Returns the row or null.
static async getBySlug(slug) {
const rows = await this.list({ where: { slug } });
return rows[0] || null;
}
}
module.exports = { SharedSecret };
+53
View File
@@ -0,0 +1,53 @@
'use strict';
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
// <secretId>".
//
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
// content live at token use, the grant applies to the grantee's existing token
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
//
// granteeType distinguishes the two principal kinds:
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
// capability is currently always 'read' (grantees are read-only); the column is
// a string so later capabilities could be added without a migration.
//
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
const { Model } = require('@simpleworkjs/orm');
const GRANTEE_TYPES = ['user', 'app'];
const CAPABILITIES = ['read'];
class SharedSecretGrant extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// FK to SharedSecret.id.
secretId: { type: 'string', isRequired: true, min: 1 },
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
granteeType: { type: 'string', isRequired: true, min: 1 },
// The grantee's uid (for 'user') or app name (for 'app').
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
// Access level — 'read' today.
capability: { type: 'string', isRequired: true, default: 'read' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
updated_by: { type: 'string' },
updated_on: { type: 'integer' },
};
// All grants for a given grantee (user uid or app name). Used to rebuild the
// grantee's policy content so every granted shared path is present/absent.
static async listForGrantee(granteeType, granteeId) {
return this.list({ where: { granteeType, granteeId } });
}
}
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
+20
View File
@@ -10,6 +10,26 @@ function toE164Digits(number) {
}
async function send(to, message) {
const { PluginInstance } = require('./plugin_instance');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
// @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);
if (manifest && manifest.sendMessage) {
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
const config = { ...inst.config, ...secrets };
return manifest.sendMessage(config, { to, message });
}
}
const params = new URLSearchParams({
api_username: conf.username,
api_password: conf.password,
+9 -2
View File
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
]);
} else {
await client.modify(this.dn, [
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
]);
}
});
@@ -788,7 +788,7 @@ User.setActive = async function(active) {
throw e;
}
}
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
this.isActive = active ? 'active' : '';
this.isInactive = active ? '' : 'inactive';
cache.clear();
@@ -907,6 +907,13 @@ User.login = async function(data){
}
let user = await this.get(data.uid || data.username);
if (user.pwdAccountLockedTime) {
let error = new Error('Invalid Credentials, login failed.');
error.name = 'LDAPLoginFailed';
error.status = 401;
throw error;
}
const loginClient = makeClient();
try {
await loginClient.bind(user.dn, data.password);
+43
View File
@@ -0,0 +1,43 @@
'use strict';
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
// from the vault UI (Apps tab), so sso can keep the token alive.
//
// The token itself is shown ONCE at mint and never stored (a stolen accessor
// cannot authenticate — it can only look up, renew, or revoke its token, and
// only the sso broker's policy grants those endpoints). App tokens are minted
// through the sso-app role as PERIODIC tokens: they live forever, but only if
// something renews them inside every period window. That something is sso's
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
// stays valid as long as sso itself is running, with no renewal code needed in
// the downstream app.
//
// One row per app name: re-minting an app's token revokes the previous token
// via its accessor (no zombie credentials) and replaces the row.
const { Model } = require('@simpleworkjs/orm');
class VaultAppToken extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
// The external app's name — also its policy (app-<name>) and KV namespace
// (secret/apps/<name>/). Unique: one live token per app.
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
// The minted token's accessor (renew/revoke handle, cannot authenticate).
accessor: { type: 'string', isRequired: true, max: 128 },
// Renewal bookkeeping, updated by the renewal loop.
lastRenewedAt: { type: 'integer' },
lastError: { type: 'text' },
// Audit stamps (set by the route handler, not by an ORM hook).
created_by: { type: 'string' },
created_on: { type: 'integer' },
};
static async getByName(name) {
const rows = await this.list({ where: { name } });
return rows[0] || null;
}
}
module.exports = { VaultAppToken };
+19 -18
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.30.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.30.2",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
@@ -42,6 +42,7 @@
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15"
},
"devDependencies": {
@@ -2343,9 +2344,9 @@
}
},
"node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
@@ -4293,9 +4294,9 @@
"license": "MIT"
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -5965,16 +5966,16 @@
}
},
"node_modules/nodemon/node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/nodemon/node_modules/debug": {
@@ -7725,9 +7726,9 @@
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "1.1.16",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -7917,9 +7918,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "6.27.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
"version": "6.28.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
"license": "MIT",
"optional": true,
"engines": {
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.16.0",
"version": "1.30.2",
"description": "A very simple LDAP management and SSO system",
"author": [
{
@@ -54,6 +54,7 @@
"nodemailer": "^9.0.0",
"p2psub": "^0.2.0",
"socket.io": "^4.8.3",
"ws": "^8.21.1",
"xss": "^1.0.15"
},
"license": "MIT",
+123
View File
@@ -0,0 +1,123 @@
const http = require('http');
module.exports = {
type: 'docker',
category: 'discovery',
name: 'Docker Daemon',
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
// Containers in this compose project are the stack's own. They are already
// represented in the catalog as services, so they are recorded as managed
// 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>' }
],
validate: async (config) => {
if (!config.socketPath && !config.tcpHost) {
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
}
return { ok: true };
},
discover: async (config) => {
const isTcp = !!config.tcpHost;
const requestOptions = {
path: '/containers/json',
method: 'GET'
};
if (isTcp) {
const url = new URL(config.tcpHost);
requestOptions.host = url.hostname;
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
requestOptions.protocol = url.protocol;
} else {
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
}
return new Promise((resolve, reject) => {
const req = http.request(requestOptions, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
}
try {
const containers = JSON.parse(body);
const resources = [];
const edges = [];
const stackProject = (config.stackProject || '').trim();
const hostSlug = (config.hostSlug || '').trim();
for (const c of containers) {
const labels = c.Labels || {};
const composeProject = labels['com.docker.compose.project'] || '';
const composeService = labels['com.docker.compose.service'] || '';
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
// A container id changes every time the container is recreated,
// so an id-derived slug made `docker compose up` mint a brand-new
// resource on every deploy and orphan the previous one. Prefer
// identifiers that survive a recreate: the compose project+service
// it belongs to, else its name.
const stableKey = composeProject && composeService
? `${composeProject}-${composeService}`
: (name || c.Id.substring(0, 12));
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
const isOwnStack = !!(stackProject && composeProject === stackProject);
resources.push({
kind: 'container',
name: composeService || name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports,
composeProject: composeProject || undefined,
composeService: composeService || undefined,
containerName: name,
sourceId: stableKey,
// Part of the deployment we are running inside: already
// accounted for, not something to promote.
managed: isOwnStack ? true : undefined
}
});
// Attach the container to the service it implements when the
// catalog already has one under that slug (the bootstrap seeds
// `sso-manager`, `proxy`, `jump-host`, … using the same names
// compose uses). The reconciler drops an edge whose parent does
// not resolve, so an unmatched name is simply not linked.
if (isOwnStack && composeService) {
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
} else if (hostSlug) {
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
}
}
resolve({ resources, edges });
} catch (e) {
reject(new Error(`Failed to parse Docker response: ${e.message}`));
}
});
});
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
req.end();
});
}
};
+57 -7
View File
@@ -2,21 +2,57 @@ const nmap = require('node-nmap');
nmap.nmapLocation = "nmap"; // default
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
// not secret (it's a network range to scan), so it lives in the DB row, not
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
// the range parses — running a real scan is what `run` does.
type: 'nmap',
category: 'discovery',
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' }
],
validate: async (config) => {
const { targetRange } = config;
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
// only sanity-check shape here — reject anything with shell metacharacters
// or whitespace, since node-nmap passes this straight to the nmap binary.
if (/\s|[;|&$`<>]/.test(targetRange)) {
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
}
return { ok: true };
},
discover: async (config) => {
const { targetRange } = config;
if (!targetRange) throw new Error("Missing targetRange for Nmap");
return new Promise((resolve, reject) => {
const scan = new nmap.OsAndPortScan(targetRange);
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
// -F: fast scan (100 top ports)
// --min-rate 100: speed up scan rate
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
const scan = new nmap.NmapScan(targetRange, customFlags);
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
scan.on('complete', function(data) {
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
const resources = [];
const edges = [];
for (const host of data) {
if (!host.mac || !host.ip) continue;
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
if (!host.ip) continue;
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
const hostSlug = `nmap-host-${hostId}`;
const interfaces = [{ mac: host.mac, ip: host.ip }];
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
resources.push({
kind: 'host',
@@ -27,7 +63,7 @@ module.exports = {
if (host.openPorts && host.openPorts.length > 0) {
for (const port of host.openPorts) {
const svcSlug = `nmap-svc-${host.mac.replace(/:/g, '')}-${port.port}`;
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
resources.push({
kind: 'service',
name: `${port.service} on ${port.port}`,
@@ -42,10 +78,24 @@ module.exports = {
});
scan.on('error', function(error) {
reject(error);
// node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
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);
}
});
scan.startScan();
});
}
},
// Generalized plugin contract alias for `discover`. See proxmox.js for why
// this references module.exports rather than `this`.
run: async (config) => module.exports.discover(config)
};
+278 -35
View File
@@ -6,9 +6,111 @@ const agent = new https.Agent({
rejectUnauthorized: false
});
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
// the pairing honest -- the previous code collected MACs and IPs into two flat
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
class Interfaces {
constructor() { this.byMac = new Map(); this.anonymous = []; }
// Interfaces that belong to something running INSIDE the guest -- container
// engines, overlay networks, VPNs -- rather than to the guest itself. A
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
// alongside its one real NIC, which is noise in the directory and, worse,
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
// Only applied to guests; a hypervisor's own bridges are how you reach it.
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
static isVirtualName(name) {
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
}
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
// the only place the Proxmox node network API exposes a physical NIC's MAC
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
// rather than leaving every hypervisor MAC-less.
static macFromIfaceName(name) {
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
if (!m) return null;
return m[1].toLowerCase().match(/.{2}/g).join(':');
}
static normalizeMac(mac) {
const m = (mac || '').toLowerCase().trim();
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
if (m === '00:00:00:00:00:00') return null;
return m;
}
// `ips` are the addresses observed on this one NIC (may be empty for a
// stopped guest, where only the MAC is known).
add(mac, ips, name) {
const key = Interfaces.normalizeMac(mac);
const addrs = (ips || []).filter(Boolean);
if (!key) {
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
return;
}
const existing = this.byMac.get(key);
if (existing) {
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
existing.ip = existing.ips[0] || null;
if (!existing.name && name) existing.name = name;
return;
}
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
}
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
// The address/MAC the directory shows in its single-value columns, and what
// the reconciler matches on. Prefer a NIC that actually has an address.
primaryIp() {
const withIp = this.toArray().find(i => i.ip);
return withIp ? withIp.ip : null;
}
primaryMac() {
const withIp = this.toArray().find(i => i.ip && i.mac);
if (withIp) return withIp.mac;
const first = this.toArray().find(i => i.mac);
return first ? first.mac : null;
}
}
module.exports = {
discover: async (config) => {
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
// drives the admin UI form and validation; fields flagged `secret:true` are
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
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 }
],
// "Test" button in the UI: hit the unauthenticated version endpoint with the
// API token to confirm the URL + token are valid before scheduling runs.
validate: async (config) => {
const { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
try {
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
},
discover: async (config) => {
let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config");
}
@@ -16,19 +118,112 @@ module.exports = {
const headers = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
};
// Ensure URL has no trailing slash
url = url.endsWith('/') ? url.slice(0, -1) : url;
const resources = [];
const edges = [];
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
// several unrelated roots in the Directory tree and nothing says where any
// of them came from. Every node discovered below is parented to this, so
// one endpoint == one subtree.
const endpointHost = (() => {
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
})();
const clusterName = await (async () => {
// /cluster/status names the cluster when one exists; a standalone node
// has no cluster entry, in which case the endpoint hostname is the name.
try {
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
if (!res.ok) return null;
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
return entry ? entry.name : null;
} catch (e) { return null; }
})();
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
resources.push({
kind: 'host',
name: clusterName || `Proxmox (${endpointHost})`,
slug: endpointSlug,
metadata: {
subType: 'proxmox',
address: url,
os: 'Proxmox VE',
isProduction: true,
sourceId: url,
// Deliberately NO `ip`/`interfaces`: this resource stands for the
// cluster (the API endpoint), not for a machine. Giving it the address
// it is reached at made the reconciler match it to the very node that
// answers on that address -- the endpoint and the node collapsed into
// one row, which then became its own parent. The cluster is identified
// by slug + sourceId instead, which nothing else can collide with.
interfaces: []
}
});
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
if(!resNodes.ok) {
const errText = await resNodes.text();
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
}
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
if (node.status !== 'online') continue;
// An offline node is still a real hypervisor that belongs in the
// directory -- skipping it entirely used to make it look decommissioned
// and let the reconciler's garbage collector archive it after a week of
// downtime. Record it, mark it down, and skip only the guest enumeration
// (which needs the node to answer).
const online = node.status === 'online';
const nodeSlug = `pve-node-${node.node}`;
// A hypervisor with no address is not actionable. Read its bridges/NICs
// so the node lands in the directory reachable and MAC-identified like
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
// normally the address you actually reach the hypervisor on.
const nodeIfaces = new Interfaces();
try {
const netRes = online
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
: { ok: false };
if (netRes.ok) {
const ifaceList = (await netRes.json()).data || [];
for (const iface of ifaceList) {
if (iface.iface === 'lo') continue;
const ip = iface.address || iface.cidr;
// This endpoint has no hwaddr field, so the MAC has to be recovered
// from a predictable interface name -- either this interface's own
// or, for a bridge, one of the physical ports beneath it.
let mac = Interfaces.macFromIfaceName(iface.iface);
if (!mac) {
for (const alt of (iface.altnames || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
}
if (!mac && iface.bridge_ports) {
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
mac = Interfaces.macFromIfaceName(port);
if (mac) break;
// The port may itself only carry the MAC in an altname.
const portDef = ifaceList.find(i => i.iface === port);
for (const alt of ((portDef && portDef.altnames) || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
if (mac) break;
}
}
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
}
}
} catch (e) {}
resources.push({
kind: 'host',
name: node.node,
@@ -37,9 +232,19 @@ module.exports = {
subType: 'hypervisor',
os: 'Proxmox VE',
isProduction: true,
interfaces: []
status: node.status,
sourceId: `${node.node}`,
node: node.node,
interfaces: nodeIfaces.toArray(),
macAddress: nodeIfaces.primaryMac(),
ip: nodeIfaces.primaryIp()
}
});
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
// Everything below asks the node itself; an offline node answers none of
// it, and its guests are already recorded from previous runs.
if (!online) continue;
// 2. Get VMs for this node
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
@@ -49,10 +254,12 @@ module.exports = {
const vmSlug = `vm-${vm.vmid}`;
const isTemplate = vm.template === 1;
let ips = [];
let macs = [];
// Enrich from QEMU guest agent if running
const ifaces = new Interfaces();
// Enrich from QEMU guest agent if running. The agent is the only source
// that knows which IP sits on which NIC, so pair them here rather than
// accumulating two flat lists (zipping those by index attributed IPs to
// the wrong MAC on any guest with more than one NIC).
if (vm.status === 'running') {
try {
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
@@ -60,35 +267,35 @@ module.exports = {
const agentData = (await agentRes.json()).data;
if (agentData && agentData.result) {
for (const iface of agentData.result) {
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
if (iface['ip-addresses']) {
for (const ip of iface['ip-addresses']) {
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
ips.push(ip['ip-address']);
}
}
}
// Docker bridges, veth pairs and VPN tunnels are the
// guest's own plumbing, not NICs of the guest.
if (Interfaces.isVirtualName(iface.name)) continue;
const ips = (iface['ip-addresses'] || [])
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
.map(ip => ip['ip-address']);
ifaces.add(iface['hardware-address'], ips, iface.name);
}
}
}
} catch(e) {}
}
// Enrich from VM config to at least get MAC if agent failed/stopped
// Enrich from VM config: the MAC is declared there whether or not the
// guest agent answered, so a stopped VM still gets a stable identity.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
if(m) macs.push(m[1].toLowerCase());
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
if(m) ifaces.add(m[1], [], `net${i}`);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
@@ -97,9 +304,14 @@ module.exports = {
metadata: {
subType: isTemplate ? 'template' : 'vm',
vmid: vm.vmid,
// The Proxmox-side identity, so a resource can be traced back to the
// exact guest on the exact node it was discovered from.
sourceId: `${node.node}/qemu/${vm.vmid}`,
node: node.node,
isProduction: vm.status === 'running',
interfaces,
ip: ips[0] || null
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
@@ -113,26 +325,45 @@ module.exports = {
const lxcSlug = `lxc-${lxc.vmid}`;
const isTemplate = lxc.template === 1;
let ips = [];
let macs = [];
// Enrich from LXC config
const ifaces = new Interfaces();
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
// so read them off the same line instead of into parallel lists.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
if(ipMatch) ips.push(ipMatch[1]);
const line = confData[`net${i}`];
if (!line) continue;
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
const nameMatch = line.match(/\bname=([^,]+)/);
if (hwMatch || ipMatch) {
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
// A DHCP-configured container has no IP in its config. Ask the running
// container's interface list so it lands in the directory addressable
// instead of as an IP-less row.
if (lxc.status === 'running' && !ifaces.primaryIp()) {
try {
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
if (ifRes.ok) {
for (const iface of ((await ifRes.json()).data || [])) {
if (Interfaces.isVirtualName(iface.name)) continue;
const ip = (iface.inet || '').split('/')[0];
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
}
}
} catch(e) {}
}
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
@@ -141,9 +372,12 @@ module.exports = {
metadata: {
subType: isTemplate ? 'template' : 'lxc',
vmid: lxc.vmid,
sourceId: `${node.node}/lxc/${lxc.vmid}`,
node: node.node,
isProduction: lxc.status === 'running',
interfaces,
ip: ips[0] || null
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
@@ -151,5 +385,14 @@ module.exports = {
}
return { resources, edges };
}
},
// The generalized plugin contract calls `run`; the discovery plugins keep
// `discover` as their implementation name for back-compat, and `run` is just
// an alias. Referenced via module.exports (not `this`) so it survives being
// detached and called as a bare function reference.
run: async (config) => module.exports.discover(config),
// Exported for unit tests only -- not part of the plugin contract.
_Interfaces: Interfaces
};
+40 -1
View File
@@ -6,6 +6,41 @@ const agent = new https.Agent({
});
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `password` is
// secret and stored in OpenBao (secret/plugins/<instance-id>/conf).
type: 'unifi',
category: 'discovery',
name: 'UniFi Network',
description: 'Discover UniFi network devices and clients from a UniFi Controller / UDM endpoint.',
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 }
],
// "Test": attempt the UDM login (falls back to the legacy controller login);
// succeeds only if one of the two login endpoints returns 200.
validate: async (config) => {
const { url, user, password } = config;
if (!url || !user || !password) return { ok: false, error: 'Missing url, user, or password' };
try {
let loginRes = await fetch(`${url}/api/auth/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }), agent
});
if (!loginRes.ok) {
loginRes = await fetch(`${url}/api/login`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }), agent
});
}
if (!loginRes.ok) return { ok: false, error: `UniFi auth failed (${loginRes.status})` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
},
discover: async (config) => {
const { url, user, password } = config;
if (!url || !user || !password) {
@@ -91,5 +126,9 @@ module.exports = {
}
return { resources, edges };
}
},
// Generalized plugin contract alias for `discover`. See proxmox.js for why
// this references module.exports rather than `this`.
run: async (config) => module.exports.discover(config)
};
+61
View File
@@ -0,0 +1,61 @@
const https = require('https');
module.exports = {
type: 'twilio',
category: 'messaging',
name: 'Twilio SMS',
description: 'Send SMS messages (like 2FA codes) via Twilio.',
configSchema: [
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
],
validate: async (config) => {
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
const data = new URLSearchParams();
data.append('To', to);
data.append('From', config.fromNumber);
data.append('Body', message);
const postData = data.toString();
const options = {
hostname: 'api.twilio.com',
port: 443,
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
};
+79
View File
@@ -0,0 +1,79 @@
const https = require('https');
const http = require('http');
module.exports = {
type: 'webhook',
category: 'messaging',
name: 'Universal REST Webhook',
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
configSchema: [
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
],
validate: async (config) => {
if (!config.url) return { ok: false, error: 'URL is required' };
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
try {
if (config.headers) JSON.parse(config.headers);
} catch (e) {
return { ok: false, error: 'Headers must be valid JSON' };
}
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
let payloadStr = config.payloadTemplate || '{}';
// Replace template variables safely
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
// If there is an API secret, replace {{secret}} in the headers or url
let headersObj = {};
if (config.headers) {
try {
const parsed = JSON.parse(config.headers);
for (const [k, v] of Object.entries(parsed)) {
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
}
} catch(e) {}
}
if (!headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
const urlObj = new URL(config.url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: config.method || 'POST',
headers: headersObj
};
const client = urlObj.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body });
} else {
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(payloadStr);
req.end();
});
}
};
+6
View File
@@ -3,6 +3,12 @@ nav.navbar{
padding-right: 1em;
}
/* Only the active top-nav link is bold + underlined; the username is plain. */
.top-nav a.active{
font-weight: bold;
text-decoration: underline;
}
body {
display: flex;
flex-direction: column;
+15 -1
View File
@@ -615,9 +615,10 @@ app.util = (function(app){
// Reveal every .group-required-<cn> element the current user's groups entitle
// them to. Elements carrying .group-required start hidden (styles.css), so a
// user who is in no groups — or who isn't logged in — simply never sees them.
// The synthetic 'login' group is special: it's true for any authenticated user.
app.auth.applyGroupVisibility = function(user){
var groups = app.auth.groupCNs(user);
if(!groups.length) return;
var isLoggedIn = !!user;
var style = document.getElementById('group-required-rules');
if(!style){
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
// A group whose CN isn't a usable CSS identifier just gates nothing.
}
}
// The 'login' group is synthetic — it means "any authenticated user".
// Reveal .group-required-login for any logged-in user.
if(isLoggedIn){
try{
style.sheet.insertRule(
`.group-required-login { display: revert !important; }`,
style.sheet.cssRules.length
);
}catch(error){
// Ignore CSS escape errors.
}
}
};
$( document ).ready(async function(){
@@ -0,0 +1,130 @@
#!/bin/bash
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:-}"
CONFIG_DIR="/etc/theta42"
CONFIG_FILE="$CONFIG_DIR/agent.yml"
BIN_PATH="/usr/local/bin/theta-agent"
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
NC='\033[0m' # No Color
log() { echo -e "${GREEN}[+]${NC} $1"; }
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
# 1. Root check
if [ "$EUID" -ne 0 ]; then
error "This script must be run as root."
fi
# 2. Argument Parsing
URL=""
TOKEN=""
B64_CONFIG=""
while [[ $# -gt 0 ]]; do
case $1 in
--url)
URL="$2"
shift 2
;;
--token)
TOKEN="$2"
shift 2
;;
*)
B64_CONFIG="$1"
shift
;;
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."
echo "Usage examples:"
echo " sh install.sh \"BASE64_CONFIG\""
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
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 "Downloading binary from $BINARY_URL..."
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
chmod +x "$BIN_PATH"
# 4. Setup configuration
log "Preparing configuration directory $CONFIG_DIR..."
mkdir -p "$CONFIG_DIR"
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
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"
location: "unknown"
capabilities:
telemetry: true
configure_ldap: false
reboot: false
service_control: []
arbitrary_bash: false
EOF
fi
chmod 600 "$CONFIG_FILE"
# 5. Setup systemd service
log "Creating systemd service unit..."
cat <<EOF > "$SERVICE_FILE"
[Unit]
Description=Theta Agent Unified Endpoint Management
After=network.target
[Service]
Type=simple
ExecStart=$BIN_PATH
Restart=always
RestartSec=5
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=theta-agent
[Install]
WantedBy=multi-user.target
EOF
# 6. Start the agent
log "Enabling and starting Theta Agent..."
systemctl daemon-reload
systemctl enable theta-agent
systemctl start theta-agent
log "Theta Agent installation complete!"
log "Verify status with: systemctl status theta-agent"
log "Check logs with: journalctl -u theta-agent -f"
Binary file not shown.
+394
View File
@@ -0,0 +1,394 @@
'use strict';
const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
// 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'];
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It
// must NOT be mounted from the onListen hook (which runs after the 404
// catch-all is already on the stack): a router registered behind that terminal
// handler would make every /api/agent/* request 404, no matter the WS server
// state. The WebSocket handler is separate (initAgentWebSockets below) and is
// the only part that needs the post-listen onListen hook.
const router = express.Router();
// Structured audit line for anything that reaches a host. The agent channel can
// run arbitrary bash, so "who told which host to do what" has to be recoverable
// after the fact; previously nothing was recorded at all.
function logAgentAudit(action, details) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
component: 'agent',
action,
...details
}));
}
// The agent WebSocket (/api/agent/ws) authenticates its own token against the
// Agent table (see initAgentWebSockets). These REST routes are admin-facing, so
// they're auth + admin gated.
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);
}
});
// --- Fleet ---
router.get('/nodes', async (req, res, next) => {
try {
const keyStatus = agentKeys.status();
res.json({
status: 'ok',
agents: await agentManager.listAgents(),
// Base64 of the raw 32-byte key: what goes into agent.yml's `public_key`.
publicKey: await agentManager.publicKeyBase64(),
publicKeyPem: await agentManager.publicKeyPem(),
signingAvailable: agentKeys.status().available,
signingError: keyStatus.error || null
});
} catch (err) { next(err); }
});
// --- Enrollment ---
// The token is minted HERE, not in the browser. It is returned exactly once;
// only its hash is stored, so it cannot be recovered afterwards -- rotate to
// get a new one.
router.post('/enroll', async (req, res, next) => {
try {
const { name, resourceId, description } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ status: 'error', message: 'name is required' });
}
if (resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
const { agent, token } = await Agent.enroll({
name: String(name).trim(),
description,
resourceId: resourceId || null,
enrolledBy: req.user.uid
});
logAgentAudit('enroll', { actor: req.user.uid, agentId: agent.id, agentName: agent.name, resourceId: resourceId || null });
const publicKey = await agentManager.publicKeyBase64();
res.json({
status: 'ok',
agent: agent.toPublic(agentManager.liveState(agent.id)),
// Shown once. The UI must make that clear.
token,
publicKey,
signingAvailable: agentKeys.status().available
});
} catch (err) { next(err); }
});
router.put('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const patch = {};
if (req.body.name !== undefined) patch.name = req.body.name;
if (req.body.description !== undefined) patch.description = req.body.description;
if (req.body.resourceId !== undefined) {
if (req.body.resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(req.body.resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
patch.resourceId = req.body.resourceId || null;
}
const updated = await agent.update(patch);
logAgentAudit('update', { actor: req.user.uid, agentId: agent.id, fields: Object.keys(patch) });
res.json({ status: 'ok', agent: updated.toPublic(agentManager.liveState(agent.id)) });
} catch (err) { next(err); }
});
// Revoke: the token stops authenticating immediately and any live socket is
// dropped, so revocation takes effect without waiting for a reconnect.
router.post('/nodes/:id/revoke', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
await agent.update({ revoked: true });
agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
logAgentAudit('revoke', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.post('/nodes/:id/rotate', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const token = await agent.rotateToken();
// The old token is dead the moment it is replaced; drop the socket that was
// using it so the agent reconnects with the new one.
agentManager.disconnect(agent.id, 4004, 'Token rotated');
logAgentAudit('rotate', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok', token, publicKey: await agentManager.publicKeyBase64() });
} catch (err) { next(err); }
});
router.delete('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
agentManager.disconnect(agent.id, 4003, 'Enrollment deleted');
await agent.delete();
logAgentAudit('delete', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Join keys ---
// One key an operator hands out; hosts that present it enroll themselves and
// are immediately issued their own per-agent token. Listing never returns the
// key itself -- only its prefix and usage.
router.get('/join-keys', async (req, res, next) => {
try {
const keys = await AgentJoinKey.list();
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
const { key, raw } = await AgentJoinKey.issue({
label: (label && String(label).trim()) || 'default',
createdBy: req.user.uid,
expiresInDays: expiresInDays ? Number(expiresInDays) : null
});
logAgentAudit('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 (err) { next(err); }
});
router.post('/join-keys/:id/revoke', 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' });
await key.update({ revoked: true });
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Agents already enrolled keep working -- they hold their own tokens now,
// which is the whole point of exchanging the join key rather than using it
// as the long-term credential.
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.delete('/join-keys/:id', 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' });
await key.delete();
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Commands ---
// Addressed by agent id, not by token: a token is a credential and has no
// business travelling in a URL, being logged, or sitting in browser history.
router.post('/nodes/:id/command', async (req, res, next) => {
const { command, payload, isHighRisk } = req.body || {};
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
if (agent.revoked) return res.status(403).json({ status: 'error', message: 'agent enrollment is revoked' });
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = await agentManager.sendCommand(agent, command, payload || {}, requiresSigning);
logAgentAudit('command', {
actor: req.user.uid,
agentId: agent.id,
agentName: agent.name,
resourceId: agent.resourceId || null,
command,
signed: requiresSigning
});
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
logAgentAudit('command_failed', { actor: req.user && req.user.uid, agentId: req.params.id, command, error: err.message });
res.status(400).json({ status: 'error', message: err.message });
}
});
module.exports = router;
module.exports.HIGH_RISK_COMMANDS = HIGH_RISK_COMMANDS;
module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// WebSocket handler only needs the WS server; runs from the onListen hook.
if (!app.wss) return;
// Warm the signing key at boot so a misconfigured OpenBao policy is a loud
// startup error rather than a surprise the first time someone reboots a host.
agentKeys.load().then(keys => {
if (!keys) console.error(`[Theta Agent] signing key unavailable — high-risk commands will be refused. ${agentKeys.status().error || ''}`);
});
app.wss.on('connection', async (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
const remoteAddr = req.socket.remoteAddress;
// Authenticate BEFORE doing anything else: no registration, no welcome
// payload, no acknowledgement that the token was close. Until this passes
// the peer is an anonymous stranger, and the old code treated it as a
// trusted node purely for presenting a non-empty string.
let agent = null;
let issuedToken = null; // set when this connection auto-enrolled
try {
agent = await Agent.authenticate(token);
// Not a known agent token -- try it as a join key. This is what makes
// "install the agent with a key and the host appears" work without an
// admin pre-registering every machine. The join key is exchanged for a
// per-agent token below, so it never becomes the host's long-term
// credential.
if (!agent) {
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;
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
}).catch(() => {});
logAgentAudit('join', {
agentId: agent.id, agentName: agent.name, remoteAddr,
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
});
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
}
}
} catch (err) {
console.error('[Theta Agent] authentication lookup failed:', err.message);
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
return;
}
if (!agent) {
// Deliberately indistinguishable for unknown vs revoked vs missing: a
// caller probing tokens learns nothing about which part was wrong.
logAgentAudit('auth_rejected', { remoteAddr, tokenPrefix: token ? String(token).slice(0, 8) : null });
try { ws.close(4001, 'Unauthorized'); } catch (e) {}
return;
}
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) connected from ${remoteAddr}`);
logAgentAudit('connected', { agentId: agent.id, agentName: agent.name, remoteAddr });
// Must stay synchronous, and the listeners below must be attached in this
// same tick: the agent sends `discovery` the instant the socket opens, and
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (!data || typeof data.type !== 'string') return;
// Re-read the row per message so a revoke mid-session takes effect on
// the next thing the agent says, not only on reconnect.
const current = await Agent.get(agent.id).catch(() => null);
if (!current || current.revoked) {
try { ws.close(4003, 'Enrollment revoked'); } catch (e) {}
return;
}
const payload = data.payload || {};
switch (data.type) {
case 'discovery':
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
break;
case 'telemetry':
await agentManager.handleTelemetry(current, payload);
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
break;
case 'heartbeat':
await agentManager.handleHeartbeat(current, payload, ws);
break;
case 'response':
await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
}
} catch (err) {
console.error('[Theta Agent] Error handling message:', err);
}
});
ws.on('close', () => {
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws);
});
// Send initial welcome/config payload. When this connection enrolled via a
// join key it also carries the credentials the agent should persist and use
// from now on: its own token, and the public key it must pin to verify
// signed commands. Handing the public key over here is what removes the
// last manual step -- an agent installed with only a join key ends up fully
// configured without anyone copying values between two machines.
try {
const payload = {
message: 'Connected to SSO Manager C2',
protocol_version: '1.2.0',
agent_id: agent.id
};
if (issuedToken) {
payload.enrolled = true;
payload.auth_token = issuedToken;
payload.public_key = await agentManager.publicKeyBase64();
}
ws.send(JSON.stringify({ type: 'config', payload }));
} catch (e) {}
});
};
+152 -9
View File
@@ -12,12 +12,33 @@ router.use(async (req, res, next) => {
}
});
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
// mask-valued submission preserves the stored value so an admin editing an
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
const out = JSON.parse(JSON.stringify(obj));
for (const [grp, key] of SECRET_PATHS) {
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
}
return out;
}
router.get('/', async (req, res) => {
const editable = {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {}
};
oauth: conf.oauth || {},
voipms: conf.voipms || {}
});
res.json(editable);
});
@@ -38,23 +59,145 @@ function applyToLiveConf(src) {
router.post('/', async (req, res, next) => {
try {
const existing = await baoConf.get('sso-manager/conf') || {};
// Deep merge req.body into existing
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
const incoming = req.body || {};
// Preserve secret fields the admin left blank (or left showing the mask):
// drop them from the incoming merge so the stored value survives. Only a
// genuinely new, non-blank, non-mask value overwrites.
for (const [grp, key] of SECRET_PATHS) {
if (incoming[grp] && incoming[grp][key] !== undefined) {
const submitted = incoming[grp][key];
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
}
}
// Deep merge incoming into existing
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = req.body[key];
existing[key] = incoming[key];
}
}
await baoConf.set('sso-manager/conf', existing);
// Reflect the saved values in the live conf immediately (the next boot's
// bao-conf.init() would pick them up too, but this keeps running readers
// current without a restart, as the old conf_manager did).
// current without a restart, as the old conf_manager did). `existing`
// carries the preserved secret values, so live conf keeps them too.
applyToLiveConf(existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
router.get('/proxy', async (req, res, next) => {
try {
const proxyConf = await baoConf.get('proxy/conf') || {};
const editable = JSON.parse(JSON.stringify(proxyConf));
if (editable.oidc && editable.oidc.clientSecret) editable.oidc.clientSecret = MASK;
if (editable.ldap && editable.ldap.bindPassword) editable.ldap.bindPassword = MASK;
res.json(editable);
} catch(err) {
next(err);
}
});
router.post('/proxy', async (req, res, next) => {
try {
const existing = await baoConf.get('proxy/conf') || {};
const incoming = req.body || {};
if (incoming.oidc && incoming.oidc.clientSecret !== undefined) {
if (incoming.oidc.clientSecret === '' || incoming.oidc.clientSecret === MASK) delete incoming.oidc.clientSecret;
}
if (incoming.ldap && incoming.ldap.bindPassword !== undefined) {
if (incoming.ldap.bindPassword === '' || incoming.ldap.bindPassword === MASK) delete incoming.ldap.bindPassword;
}
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = incoming[key];
}
}
await baoConf.set('proxy/conf', existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
// Send a test email to verify SMTP configuration
router.post('/test-email', async (req, res, next) => {
try {
const { to, subject, body } = req.body || {};
if (!to) {
return res.status(400).json({ error: 'Recipient email address is required' });
}
// 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 Mail.send(to, testSubject, testBody);
res.json({ success: true, message: `Test email sent to ${to}` });
} catch(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' });
}
});
// Send a test SMS to verify VoIP.ms configuration
router.post('/test-sms', async (req, res, next) => {
try {
const { to, message } = req.body || {};
if (!to) {
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 (!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 SMS configuration is working correctly.`;
await SMS.send(to, testMessage);
res.json({ success: true, message: `Test SMS sent to ${to}` });
} catch(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' });
}
});
module.exports = router;
+238 -42
View File
@@ -8,10 +8,11 @@ const { cnFromDn } = require('../utils/user_groups');
const { projectResources } = require('@simpleworkjs/directory-schema');
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
const groups = require('../utils/groups');
// 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
// the goal state, and a missing group (e.g. app_super_admin absent on a
// the goal state, and a missing group (e.g. god_admin absent on a
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
// caller's real work.
async function nestGroup(childCn, parentCn) {
@@ -29,6 +30,160 @@ async function nestGroup(childCn, parentCn) {
}
}
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
// The directory is the single place groups are created, as a projection of the
// resource graph. These helpers materialize the group-inheritance lattice for
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
// All of them are idempotent, so calling them again for a resource a newer
// release is backfilling is a no-op.
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
// host -> host; service -> app (services/consoles are the group model's "apps");
// site gets site-level groups (handled separately); oauth/container get no
// per-resource groups (oauth clients hang off their owning service).
function groupKind(resource) {
if (resource.kind === 'host') return 'host';
if (resource.kind === 'service') return 'app';
return null;
}
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
// seeds the mandatory first member. Returns true when created.
async function ensureGroup(name, ownerDn, description) {
try {
await Group.add({ name, owner: ownerDn, description });
return true;
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`ensureGroup: failed to create ${name}:`, err);
}
return false;
}
}
// Link a group to a resource only if that link doesn't already exist. The
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
// 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.
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 });
}
// Provision the site-level groups + the aggregates the per-resource groups nest
// into. Idempotent -- called on every directory list so a site seeded by an
// older release gets its groups without a rebuild:
//
// god_admin -> {site}_super_admin
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
//
// `{site}_everyone` is created for completeness; it has implicit membership and
// is granted to a resource as a grantee, never enumerated.
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
if (!siteSlug) return;
// Link a site group to the site resource (so it shows + is member-manageable
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
// groups as member.
const link = async (cn, isAdmin) => {
if (!siteResourceId) return;
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
};
const sAdmin = groups.siteSuperAdminCns(siteSlug);
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
await link(sAdmin, true);
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
// them), but are NOT linked to the site resource: a site carries only the god
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
// aggregates have no modal home; site-wide access is granted via S_super_admin
// and per-resource access via the host/app groups.
for (const kind of ['host', 'app']) {
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
}
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
await link(groups.siteEveryoneCns(siteSlug), false);
// god_admin is the global group; surface it on the site modal so its members
// can be managed from the Directory (it has no home on a single resource).
await link(groups.GOD_ADMIN, true);
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
// binding directly) resolve it transitively, not just utils/permission.js.
// nestGroup(child, parent) makes child a member of parent -- membership flows
// child -> parent ("up"), so a group's members inherit what its parents hold.
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
for (const kind of ['host', 'app']) {
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
}
}
// Provision the per-resource groups for a host/app and nest them into the site
// aggregates (so a site/aggregate admin reaches this resource by membership).
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
//
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
const nameSlug = groups.resourceNameSlug(resource.slug);
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
// Link both groups to the resource so the Directory can show/revoke them.
await ensureResourceGroup(resource.id, accessCn, 'member');
await ensureResourceGroup(resource.id, adminCn, 'owner');
await nestGroup(adminCn, accessCn); // administering implies using
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
}
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
// §2/§3). This is what "force the correct naming convention" means: a group
// linked to a resource must be one that parses for consumers -- the resource's
// own specific groups, its site's aggregates, site-level groups, or the global
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
// capability groups following the same shapes.
function validGroupCnsForResource(resource, siteSlug) {
const valid = new Set();
// A site resource only carries god_admin (added by the route) + the site-wide
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
// specific groups belong to host/app resources, not to the site.
if (resource.kind === 'site') {
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
}
const kind = groupKind(resource); // 'host'|'app'|null
if (kind) {
const nameSlug = groups.resourceNameSlug(resource.slug);
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
}
// oauth/container etc. — only the global god_admin makes sense to pin here.
valid.add(groups.siteSuperAdminCns(siteSlug));
return { valid, capRe: null };
}
// Require the admin group
router.use(async (req, res, next) => {
try {
@@ -50,6 +205,36 @@ router.get('/resources', async (req, res, next) => {
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally.
// 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 sites = resources.filter(r => r.kind === 'site');
await Promise.all(sites.map(site =>
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
));
const siteByResource = new Map();
for (const site of sites) siteByResource.set(site.id, site.slug);
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);
};
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)
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
}));
res.json({ results: projectResources(resources, { fullMetadata: true }) });
} catch (err) { next(err); }
});
@@ -95,46 +280,25 @@ router.post('/resources', async (req, res, next) => {
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
}
if (r.kind === 'host' || r.kind === 'service') {
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
const createGroup = async (suffix, accessLevel) => {
const cn = groupCn(suffix);
try {
await Group.add({
name: cn,
owner: req.user.dn,
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
});
} catch (err) {
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
console.error(`Failed to create LDAP group ${cn}:`, err);
}
}
try {
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
} catch(err) { /* ignore duplicate links */ }
};
await createGroup('access', 'member');
await createGroup('admin', 'owner');
// Wire up the two standing relationships every resource has, as nesting
// rather than as membership that has to be maintained per resource:
//
// app_super_admin -> <slug>_admin cross-app super admins administer
// every resource, automatically
// <slug>_admin -> <slug>_access administering something implies
// being able to use it
//
// Before nesting, both of these could only be expressed by adding every
// super admin to every new group by hand -- which nobody does, so the
// groups drifted. A failure here must not fail resource creation: the
// resource and its groups already exist and the nesting is repairable.
await nestGroup(groupCn('admin'), groupCn('access'));
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
// Materialize the group-model for the new resource. Site resources get the
// site-level groups; host/app resources get their per-resource groups nested
// into the site aggregates. Idempotent -- safe for a resource created by an
// older release. A provisioning failure must not fail resource creation: the
// resource already exists and the groups are repairable (re-run ensures them).
//
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
// the kind prefix themselves.
const gKind = groupKind(r);
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
if (r.kind === 'site') {
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
} else if (gKind && ancestorSite) {
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
}
res.json({ results: r });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
@@ -201,6 +365,16 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => {
}
});
router.post('/resources/:id/service-token', async (req, res, next) => {
try {
const { ServiceToken } = require('../models/token');
const token = await ServiceToken.issue(req.params.id, req.user.uid);
res.json({ results: { token: token.token } });
} catch (err) {
next(err);
}
});
router.delete('/resources/:id', async (req, res, next) => {
try {
const r = await Resource.get(req.params.id);
@@ -255,7 +429,29 @@ router.get('/groups', async (req, res, next) => {
router.post('/groups', async (req, res, next) => {
try {
const g = await ResourceGroup.create(req.body);
const { resourceId, groupCn } = req.body;
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
// be a valid group for this resource; reject free-form names so the groups
// consumers read are always parseable. god_admin is always allowed (it is
// the global group and is managed from a site's modal).
const resource = await Resource.get(resourceId);
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
// site resource's own slug is its site; a host/app uses its ancestor site.
const siteSlug = resource && resource.kind === 'site'
? resource.slug
: await Resource.findAncestorSiteSlug(resourceId);
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
err.status = 400;
throw err;
}
}
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
res.json({ results: g });
} catch (err) { next(err); }
});
@@ -303,7 +499,7 @@ router.get('/access-summary', async (req, res, next) => {
//
// Counts come from the transitive closure, not from `member`. Reading the
// attribute would report only who is listed on the group, missing anyone
// who reaches it through a nested group -- and since app_super_admin is
// who reaches it through a nested group -- and since god_admin is
// nested into every resource's _admin group, that is not an edge case.
let members = [];
if (group) {
+293
View File
@@ -0,0 +1,293 @@
'use strict';
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
//
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
// config via a Redis hash). Here every plugin is a PluginInstance row (see
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
//
// Gated router-wide to the same admin groups as the directory admin API, so
// existing directory admins keep access. Secrets are never returned in
// cleartext — only masked (`********`) — and never persisted in the DB.
const router = require('express').Router();
const permission = require('../utils/permission');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const { PluginInstance, STATUS } = require('../models/plugin_instance');
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch (err) { next(err); }
});
// Plain object for the wire, with masked secret values attached under
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
async function serialize(instance) {
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
obj.secrets = registry.mask(instance.pluginType, secrets);
return obj;
}
// Validate a create/update payload against a plugin type's configSchema.
// Returns an error string or null. `flat` is the merged config + secret values
// (the UI sends one flat object; the API splits it).
function validateFields(type, flat) {
const required = registry.requiredKeys(type);
for (const key of required) {
const v = flat && flat[key];
if (v === undefined || v === null || v === '') {
return `Missing required field: ${key}`;
}
}
return null;
}
// --- Plugin types (for the create-instance picker + form) ---
router.get('/types', (req, res) => {
res.json({ results: registry.getTypes() });
});
// --- List instances ---
router.get('/', async (req, res, next) => {
try {
const instances = await PluginInstance.list();
const out = [];
for (const inst of instances) out.push(await serialize(inst));
res.json({ results: out });
} catch (err) { next(err); }
});
router.get('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: await serialize(inst) });
} catch (err) { next(err); }
});
// --- Create instance ---
router.post('/', async (req, res, next) => {
try {
const { pluginType, name, slug, cron } = req.body;
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' });
// Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret +
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
const fieldErr = validateFields(pluginType, flat);
if (fieldErr) return res.status(400).json({ error: fieldErr });
const manifest = registry.getManifest(pluginType);
const { config, secrets } = registry.splitConfig(pluginType, flat);
const enabled = req.body.enabled !== false; // default true
const now = Date.now();
const instance = await PluginInstance.create({
pluginType,
category: manifest.category,
name,
slug: finalSlug,
enabled,
cron: cron || '0 * * * *',
config,
created_by: req.user.uid,
created_on: now,
updated_by: req.user.uid,
updated_on: now
});
try {
await pluginSecrets.write(instance.id, secrets);
} catch (err) {
// Most likely the sso-broker policy lacks secret/plugins/* — the
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
// secret write doesn't strand a half-created instance.
await instance.delete().catch(() => {});
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
}
if (enabled) {
await scheduleInstance(instance);
await runInstanceNow(instance.id);
}
res.json({ results: await serialize(instance) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update instance (name/cron/enabled/non-secret config) ---
router.put('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
const updates = {};
if (req.body.name !== undefined) updates.name = req.body.name;
if (req.body.cron !== undefined) {
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
updates.cron = req.body.cron;
}
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
// Non-secret config: split the client's flat config so secret fields are
// never written to the DB. Secrets are changed via PUT /:id/secrets.
if (req.body.config !== undefined && typeof req.body.config === 'object') {
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
updates.config = config;
}
updates.updated_by = req.user.uid;
updates.updated_on = Date.now();
const updated = await inst.update(updates);
// Re-schedule if the schedule-relevant fields moved.
if (updates.cron !== undefined || updates.enabled !== undefined) {
await scheduleInstance(updated);
}
res.json({ results: await serialize(updated) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update secrets only ---
router.put('/:id/secrets', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
// values so an unchanged masked field is a no-op.
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
await pluginSecrets.write(inst.id, secrets);
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Test (validate) ---
router.post('/:id/test', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const mod = registry.getModule(inst.pluginType);
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
const cfg = await pluginSecrets.mergeForRun(inst);
const result = await mod.validate(cfg);
if (result && result.ok) return res.json(result);
return res.status(400).json(result || { ok: false, error: 'validation failed' });
} catch (err) {
return res.status(400).json({ ok: false, error: err.message });
}
});
// --- Load (enable + schedule + run now) ---
router.post('/:id/load', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
await scheduleInstance(updated);
await runInstanceNow(updated.id);
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Unload (unschedule + disable) ---
router.post('/:id/unload', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Run now (regardless of enabled) ---
router.post('/:id/run', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await runInstanceNow(inst.id);
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Last-run status ---
router.get('/:id/runs', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
} catch (err) { next(err); }
});
// --- Delete (unschedule + remove secrets + delete row) ---
router.delete('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
await pluginSecrets.remove(inst.id); // best-effort
await inst.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
module.exports = router;
+213
View File
@@ -0,0 +1,213 @@
'use strict';
// Shared-secrets API.
//
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
// OpenBao policy content is edited to add read on the exact shared path (see
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
// immediately, with no token re-mint.
//
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
// them through the existing /api/vault proxy using the requester's own session
// token, so OpenBao ACL enforces read access per-request. This router handles
// metadata CRUD + grant management; KV writes (create/update/delete) are made
// server-side using the acting user's scoped token.
const express = require('express');
const baoConf = require('@simpleworkjs/bao-conf');
const permission = require('../utils/permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const vaultBroker = require('../utils/vault_broker');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
// only reject values that can't be a sane secret path segment (spaces, slashes,
// leading non-alnum, too long).
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const router = express.Router();
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
// /api/vault proxy — personal, per-user secret management only).
router.use((req, res, next) => {
if (req.user && req.user.isMachine) {
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
}
next();
});
async function isAdmin(user) {
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
catch (e) { return false; }
}
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
// sso-admin token (R/W on secret/*).
async function actorToken(user, ownerUid) {
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
return null;
}
// Does this user manage the given shared secret? Owner or admin.
async function canManage(user, secret) {
if (user.uid === secret.ownerUid) return true;
return isAdmin(user);
}
async function loadSecret(req, res) {
const secret = await SharedSecret.get(req.params.id);
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
return secret;
}
// ── List: mine + shared-with-me ─────────────────────────────────────────────
router.get('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
const grants = await SharedSecretGrant.listForGrantee('user', uid);
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
const granted = granteeSecretIds.length
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
for (const g of granted) {
if (byId.has(g.id)) continue; // already owner
byId.set(g.id, { role: 'grantee', ...g });
}
// The `{ role, ...s }` spread above copies only own properties, so the
// instance method `path()` is dropped -- call the static builder instead.
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
} catch (e) { next(e); }
});
// ── Create ──────────────────────────────────────────────────────────────────
router.post('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const slug = String(req.body.slug || '').trim().toLowerCase();
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
const description = String(req.body.description || '').trim();
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
if (await SharedSecret.getBySlug(slug)) {
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
}
const token = await actorToken(req.user, uid);
if (!token) return res.status(403).json({ error: 'not allowed' });
const path = SharedSecret.pathFor(uid, slug);
await baoConf.set(path, data, { token });
const secret = await SharedSecret.create({
slug, ownerUid: uid, description,
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
});
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
} catch (e) { next(e); }
});
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
router.get('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
const uid = req.user.uid;
const admin = await isAdmin(req.user);
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Update data / description ───────────────────────────────────────────────
router.put('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
const update = {};
if (req.body && typeof req.body.data === 'object') {
await baoConf.set(secret.path(), req.body.data, { token });
}
if (req.body && req.body.description !== undefined) {
update.description = String(req.body.description).trim();
}
if (Object.keys(update).length) {
update.updated_by = req.user.uid;
update.updated_on = Date.now();
await secret.update(update);
}
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
} catch (e) { next(e); }
});
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
router.delete('/:id', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
const token = await actorToken(req.user, secret.ownerUid);
// Revoke all grants first so grantees' policies drop the path.
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
// Delete the KV data (metadata delete removes all versions), then the row.
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
await secret.delete();
res.status(204).end();
} catch (e) { next(e); }
});
// ── Grants: list ────────────────────────────────────────────────────────────
router.get('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
} catch (e) { next(e); }
});
// ── Grants: create ──────────────────────────────────────────────────────────
router.post('/:id/grants', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const granteeType = String(req.body.granteeType || '').trim();
const granteeId = String(req.body.granteeId || '').trim();
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
if (granteeId === secret.ownerUid && granteeType === 'user') {
return res.status(400).json({ error: 'the owner already has access' });
}
// Idempotent: skip if the grant already exists.
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
} catch (e) { next(e); }
});
// ── Grants: revoke ──────────────────────────────────────────────────────────
router.delete('/:id/grants/:grantId', async (req, res, next) => {
try {
const secret = await loadSecret(req, res);
if (!secret) return;
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
const grant = await SharedSecretGrant.get(req.params.grantId);
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
res.status(204).end();
} catch (e) { next(e); }
});
module.exports = router;
+42 -2
View File
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
res.json(envelope({
resources: projectResources(graph.resources, { fullMetadata }),
edges: graph.edges,
updated_on: graph.updated_on
}));
} catch (err) { next(err); }
});
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err); }
});
// GET /api/discovery/access/:uid[/:slug]
// Answers per-user access for a machine caller (e.g. jump-host).
router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => {
try {
const { fullMetadata } = await callerView(req);
if (!req.user || (!req.user.isMachine && !fullMetadata)) {
return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' }));
}
const { User } = require('../models/user_ldap');
const { groupCns } = require('../utils/user_groups');
const targetUser = await User.get(req.params.uid).catch(() => null);
if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' }));
const groups = await groupCns(targetUser);
const ids = new Set();
if (groups.length) {
const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } });
for (const rg of rgs) ids.add(rg.resourceId);
}
let all = await Resource.list();
if (req.params.slug) all = all.filter(r => r.slug === req.params.slug);
let accessible = all.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
if (isAuto && !isManaged) return false;
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
});
accessible = await Resource.withResolvedAddress(accessible);
res.json(envelope(projectResources(accessible, { fullMetadata })));
} catch (err) { next(err); }
});
// POST /api/discovery/sync
// Used by external agents (e.g. ldap-client) to push discovery data.
router.post('/sync', async (req, res, next) => {
@@ -149,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await resource.update({ metadata: meta });
// `Resource.update` is not a static — `update` is an instance method
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
const inst = await Resource.get(resource.id);
await inst.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
+8 -2
View File
@@ -34,8 +34,14 @@ const DOCS = {
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
agents: {title: 'Agents & Scheduler', file: path.join(__dirname, '../../docs/agents.md')},
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
// guide the Directory links to -- was unreachable in the app.
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
// The Discovery tab's help icon links here; without an entry it 404'd.
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')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
@@ -53,7 +59,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
// only resolves correctly on GitHub. Serve that same folder here and rewrite
// the rendered markup to point at it absolutely, so the images work when
// read from /docs/overview too.
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
function fixImagePaths(html) {
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
}
+18 -29
View File
@@ -11,8 +11,6 @@ const {Tos} = require('../models/tos');
const conf = require('@simpleworkjs/conf');
const buildInfo = require('../utils/build_info');
const { mountStaticModules } = require('@simpleworkjs/app-stack');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const values ={
title: conf.environment !== 'production' ? `dev` : '',
@@ -66,13 +64,15 @@ router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
router.get('/conf', async function(req, res, next) {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
res.render('conf', {...values});
} catch(err) {
next(err);
}
router.get('/conf', function(req, res) {
// Admin-only Configuration page. The view renders the shell for anyone
// (like /users, /directory, etc.); the client gates access with
// app.auth.forceLogin(['admin','app_sso_admin']) and the /api/conf endpoint
// enforces app_sso_admin server-side. The previous server-side
// permission.byGroup(req.user,…) 401'd on a browser navigation because this
// app's auth-token is a header set by client JS (localStorage), not a
// cookie — so req.user is undefined on a plain page load.
res.render('conf', {...values});
});
router.get('/directory', function(req, res) {
@@ -83,25 +83,22 @@ router.get('/discovery', function(req, res, next) {
res.redirect('/directory');
});
router.get('/plugins', function(req, res, next) {
router.get('/plugins', function(req, res, next) {
res.redirect('/directory');
});
router.get('/vault', middleware.auth, async 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 /api/vault proxy enforces the same scoping
// server-side (scopeGuard + the token's own OpenBao policy).
let isAdmin = false;
try {
await permission.byGroup(req.user, ['app_sso_admin']);
isAdmin = true;
} catch (e) { /* non-admin: personal namespace only */ }
// 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,
vaultUid: req.user.uid,
vaultIsAdmin: isAdmin,
vaultBase: isAdmin ? '' : `users/${req.user.uid}/`,
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
});
});
@@ -137,10 +134,6 @@ router.get('/users', async function(req, res, next) {
res.render('users', {...values});
});
router.get('/conf', async function(req, res, next) {
res.render('conf', {...values});
});
router.get('/login', async function(req, res, next) {
res.render('login', {...values, redirect: req.query.redirect});
});
@@ -195,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) {
res.render('profile', {...values});
});
router.get('/groups', function(req, res, next) {
res.render('groups', {...values});
});
router.get('/token', function(req, res, next) {
res.render('token', {...values});
});
-59
View File
@@ -1,59 +0,0 @@
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const permission = require('../utils/permission');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
const Redis = require('ioredis');
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null });
const { initScheduler } = require('../services/scheduler');
router.get('/', async (req, res) => {
const plugins = conf.discovery && conf.discovery.plugins ? conf.discovery.plugins : {};
let overrides = {};
try {
const data = await connection.hgetall('discovery_plugins');
for (const [k, v] of Object.entries(data)) {
overrides[k] = JSON.parse(v);
}
} catch(e) {}
// Mask secrets before sending
const masked = JSON.parse(JSON.stringify(plugins));
for (const name in masked) {
masked[name] = { ...masked[name], ...(overrides[name] || {}) };
if (masked[name].tokenSecret) masked[name].tokenSecret = '********';
if (masked[name].password) masked[name].password = '********';
}
res.json({ results: masked });
});
router.put('/:name', async (req, res) => {
const name = req.params.name;
const updates = req.body;
let current = {};
try {
const data = await connection.hget('discovery_plugins', name);
if (data) current = JSON.parse(data);
} catch(e) {}
if (updates.cron !== undefined) current.cron = updates.cron;
if (updates.enabled !== undefined) current.enabled = updates.enabled === true || updates.enabled === 'true';
await connection.hset('discovery_plugins', name, JSON.stringify(current));
// Re-init scheduler to apply changes
await initScheduler(conf.discovery).catch(console.error);
res.json({ success: true, message: 'Plugin updated' });
});
module.exports = router;
+7 -1
View File
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
// same answer in both modes.
const groups = await groupCns(user);
user.groups = groups;
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
// legacy app_sso_admin/app_super_admin during migration.
user.isAdmin = groups.some((g) =>
g === 'app_sso_admin' || g === 'app_super_admin' ||
g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP ||
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
return res.json(user);
}catch(error){
+171 -26
View File
@@ -2,41 +2,87 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { WebhookEmitter } = require('./webhook_emitter');
const crypto = require('crypto');
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
// edge that would close a loop. Carries its own visited set so it terminates
// even if the stored graph already contains a cycle from an older release.
function isDescendant(candidateId, rootId, edges) {
const seen = new Set();
const stack = [rootId];
while (stack.length) {
const id = stack.pop();
if (id === candidateId) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
}
return false;
}
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Read the inventory ONCE, not once per incoming resource. A Proxmox
// cluster reports ~55 resources against an inventory of similar size, so
// the per-iteration Resource.list() was doing quadratic full-table reads
// every discovery run. Newly created rows are pushed onto this list as we
// go, so later resources in the same payload still match against them.
const allRes = await Resource.list();
for (const res of resources) {
if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
// Attempt matching by MAC if available
// A discovered device may only merge into a resource of the same kind
// (or into a placeholder from an earlier, kind-less discovery). Without
// this a VM called "gitea-runner" matches a hand-created *service* of
// the same name on rule 3 and silently overwrites it -- the discovered
// host's metadata lands on a service row, and the operator's entry is
// gone. `template` counts as `host`: a VM converted to a template is the
// same device, and it should update in place rather than fork a row.
const kindClass = (k) => (k === 'template' ? 'host' : k);
const incomingKind = kindClass(res.kind || 'unmanaged_device');
const kindCompatible = (r) => {
const k = kindClass(r.kind);
if (k === 'unmanaged_device' || incomingKind === 'unmanaged_device') return true;
return k === incomingKind;
};
const candidates = allRes.filter(kindCompatible);
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r =>
r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac))
existing = candidates.find(r =>
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
);
}
}
// Fallback matching by IP if no MAC match (weaker)
// 2. Fallback matching by IP address
let ipsToMatch = [];
if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
}
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
}
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => {
existing = candidates.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -45,14 +91,17 @@ class DiscoveryReconciler {
return false;
});
}
// Fallback matching by Slug or Name
// 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list();
existing = allRes.find(r =>
(res.slug && r.slug === res.slug) ||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
);
const inputName = normalizeHost(res.name || res.slug);
existing = candidates.find(r => {
if (res.slug && r.slug === res.slug) return true;
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
}
if (existing) {
@@ -65,7 +114,10 @@ class DiscoveryReconciler {
const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip
for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip));
const idx = existingIntfs.findIndex(ei =>
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
(ni.ip && ei.ip && ei.ip === ni.ip)
);
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni);
}
@@ -79,16 +131,46 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now();
// Pick the most human name across sources. Rank first, length only as
// a tie-break within a rank -- comparing lengths alone let a UniFi
// client named after its MAC ("ac:16:2d:b3:da:80", 17 chars) beat the
// hypervisor's real hostname from Proxmox ("dl380-0", 7), so the
// Directory listed MAC addresses where host names belong.
//
// NB: `\\.` inside a regex LITERAL matches a backslash, not a dot, so
// the old isIp returned false for every input and IP-shaped names were
// never replaced either. It is `\.` here.
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
// 2 = a real name, 1 = an IP (at least routable/recognizable), 0 = a
// MAC or nothing (pure machine identifier, the worst thing to show).
const nameRank = (str) => {
if (!str || !String(str).trim()) return 0;
if (isMac(str)) return 0;
if (isIp(str)) return 1;
return 2;
};
let bestName = existing.name;
if (res.name) {
const incoming = nameRank(res.name);
const current = nameRank(bestName);
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
bestName = res.name;
}
}
await existing.update({
name: res.name || existing.name,
name: bestName,
description: res.description || existing.description,
metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000)
});
res._actualId = existing.id;
} else {
// Create new
const sources = [sourceName];
res.metadata.discovery_sources = sources;
const sources = new Set([sourceName]);
res.metadata.discovery_sources = [...sources];
res.metadata.last_seen = Date.now();
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
@@ -103,12 +185,75 @@ class DiscoveryReconciler {
});
newDevices++;
res._actualId = created.id; // Map original slug to actual ID
// Make it visible to the rest of THIS payload: a Proxmox run reports
// the endpoint, then its nodes, then their guests, and two of them can
// legitimately share a MAC/IP. Without this the same device could be
// created twice in a single run.
allRes.push(created);
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
// or we just trust the plugins to give us explicit parent-child mappings by slug.
// Now process edges. `allRes` above is already current -- rows created in
// the loop were pushed onto it -- so no second full read is needed.
const existingEdges = await ResourceEdge.list();
for (const edge of edges) {
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
let parentId = null;
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
if (parentResInPayload && parentResInPayload._actualId) {
parentId = parentResInPayload._actualId;
} else {
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
if (parentResInDb) parentId = parentResInDb.id;
}
// Find child ID
let childId = null;
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
if (childResInPayload && childResInPayload._actualId) {
childId = childResInPayload._actualId;
} else {
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
if (childResInDb) childId = childResInDb.id;
}
// Two slugs in one payload can resolve to the SAME resource once the
// matcher has merged them -- a Proxmox endpoint reached at the address
// of the node that answers for it is the case that produced this. The
// edge would then make a resource its own parent, which renders as an
// infinitely nested tree and defeats every ancestor walk in the app
// (findAncestorSiteSlug, withResolvedAddress) that relies on a cycle
// guard to terminate rather than to be correct.
if (parentId && childId && parentId === childId) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping self-edge on ${edge.parentSlug} -> ${edge.childSlug} (both resolved to the same resource)`);
continue;
}
// Likewise refuse an edge that closes a loop: if the proposed parent is
// already a descendant of the proposed child, adding this makes a cycle.
if (parentId && childId && isDescendant(parentId, childId, existingEdges)) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping ${edge.parentSlug} -> ${edge.childSlug} (would create a cycle)`);
continue;
}
if (parentId && childId) {
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
if (!edgeExists) {
const created = await ResourceEdge.create({
id: crypto.randomUUID(),
parentId,
childId,
relation: edge.relation
});
// Keep the in-memory edge list current so the cycle check above sees
// edges added earlier in this same payload.
existingEdges.push(created);
}
}
}
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
+172
View File
@@ -0,0 +1,172 @@
'use strict';
// Plugin type registry.
//
// A **plugin type** is a module under nodejs/plugins/<category>/<type>.js
// exporting a manifest:
//
// { type, category, name, description, configSchema[], validate(), run() }
//
// `configSchema` is an array of field descriptors that drive the admin UI form
// and API validation. Fields with `secret: true` are stored in OpenBao
// (secret/plugins/<instance-id>/conf via utils/plugin_secrets.js); all other
// field values live in the PluginInstance DB row's `config` JSON column.
//
// `run(cfg)` does the work; the discovery plugins keep their historical
// `discover(cfg)` name and add `run` as an alias (the loader uses `run`).
//
// A **plugin instance** (models/plugin_instance.js) is a configured, loadable
// copy of a type — you can have several of the same type. This registry only
// knows about *types*; instances live in the DB.
//
// The scan happens once at require time (the set of installed .js files does
// not change without a redeploy). Runtime load/unload is per-instance, not
// per-type — adding a new plugin type still needs a restart.
const fs = require('fs');
const path = require('path');
const pluginsRoot = path.join(__dirname, '../plugins');
const MASK = '********';
// type -> module. Built once.
const _modules = new Map();
// type -> manifest summary (a safe, serializable subset for the UI/API).
const _summaries = [];
function loadAll() {
_modules.clear();
_summaries.length = 0;
if (!fs.existsSync(pluginsRoot)) return;
for (const category of fs.readdirSync(pluginsRoot)) {
const catDir = path.join(pluginsRoot, category);
const stat = fs.statSync(catDir);
if (!stat.isDirectory()) continue;
for (const file of fs.readdirSync(catDir)) {
if (!file.endsWith('.js')) continue;
const type = path.basename(file, '.js');
// require fresh-ish: a plugin file should be idempotent to load. Clear
// from the cache so a future re-scan (e.g. in tests) picks up edits.
const full = path.join(catDir, file);
delete require.cache[require.resolve(full)];
const mod = require(full);
// Backfill manifest defaults so older plugins (only exporting discover)
// still register with a usable summary.
const manifest = {
type: mod.type || type,
category: mod.category || category,
name: mod.name || type,
description: mod.description || '',
configSchema: Array.isArray(mod.configSchema) ? mod.configSchema : [],
validate: typeof mod.validate === 'function' ? mod.validate : null,
run: typeof mod.run === 'function' ? mod.run
: typeof mod.discover === 'function' ? mod.discover : null
};
_modules.set(manifest.type, { mod, manifest });
_summaries.push({
type: manifest.type,
category: manifest.category,
name: manifest.name,
description: manifest.description,
configSchema: manifest.configSchema
});
}
}
}
loadAll();
// All registered plugin types, as serializable summaries (no functions).
// Used by GET /api/plugins/types to build the "New Plugin" picker + form.
function getTypes() {
return _summaries.map(s => ({ ...s }));
}
// The raw module for a type (has run/validate/discover). Throws if unknown.
function getModule(type) {
const entry = _modules.get(type);
if (!entry) {
const err = new Error(`Unknown plugin type: ${type}`);
err.status = 400;
throw err;
}
return entry.mod;
}
// The manifest summary for a type. Returns null if unknown (callers gate on
// this to validate a pluginType before creating an instance).
function getManifest(type) {
const entry = _modules.get(type);
return entry ? entry.manifest : null;
}
// Keys of the secret fields in a type's configSchema.
function secretKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => f.secret).map(f => f.key);
}
// Non-secret field keys in a type's configSchema.
function publicKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => !f.secret).map(f => f.key);
}
// All declared field keys (secret + non-secret) — for required-field validation.
function fieldKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.map(f => f.key);
}
// Required field keys.
function requiredKeys(type) {
const m = getManifest(type);
if (!m) return [];
return m.configSchema.filter(f => f.required).map(f => f.key);
}
// Replace each present secret value with MASK, keeping the keys so the UI can
// render a prefilled (masked) password field. Non-secret values are passed
// through unchanged. `values` is a plain object of field->value.
function mask(type, values) {
if (!values || typeof values !== 'object') return values;
const sk = new Set(secretKeys(type));
const out = {};
for (const [k, v] of Object.entries(values)) {
out[k] = sk.has(k) && v ? MASK : v;
}
return out;
}
// Split a flat {field: value} object (as the UI/API sends it) into non-secret
// config (for the DB row) and secret values (for OpenBao). Unknown keys are
// dropped — only declared configSchema fields are kept.
function splitConfig(type, flat) {
const manifest = getManifest(type);
const config = {};
const secrets = {};
if (!manifest || !flat) return { config, secrets };
for (const f of manifest.configSchema) {
if (!(f.key in flat)) continue;
if (f.secret) secrets[f.key] = flat[f.key];
else config[f.key] = flat[f.key];
}
return { config, secrets };
}
module.exports = {
getTypes,
getModule,
getManifest,
secretKeys,
publicKeys,
fieldKeys,
requiredKeys,
mask,
splitConfig,
// for tests
_reload: loadAll
};
+187 -59
View File
@@ -1,5 +1,27 @@
'use strict';
// Discovery / plugin scheduler.
//
// Generalized from the one-shot discovery-plugin loader: plugin *types* live
// under nodejs/plugins/<category>/<type>.js (see services/plugin_registry.js),
// and configured, loadable/unloadable *instances* live in the PluginInstance
// table (models/plugin_instance.js). This module schedules enabled instances
// on cron via BullMQ JobSchedulers and runs them in a Worker.
//
// Each instance owns a stable JobScheduler id (`plugin:<instanceId>`) so load/
// unload can add/remove a single schedule without disturbing the others —
// `upsertJobScheduler`/`removeJobScheduler` (BullMQ v6) take that id directly.
//
// Per-instance secrets are merged in from OpenBao (utils/plugin_secrets.js) at
// run time; the plugin's run()/discover() receives the combined non-secret
// config + secret values as a single `config` object, exactly as the legacy
// static-config path did.
const { Queue, Worker } = require('bullmq');
const { DiscoveryReconciler } = require('./discovery_reconciler');
const pluginRegistry = require('./plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const { PluginInstance, STATUS } = require('../models/plugin_instance');
const Redis = require('ioredis');
// Ensure Redis connection works for BullMQ
@@ -8,80 +30,186 @@ const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379',
const discoveryQueue = new Queue('discovery', { connection });
// Load plugins
const fs = require('fs');
const path = require('path');
const pluginsDir = path.join(__dirname, '../plugins/discovery');
let plugins = {};
if (fs.existsSync(pluginsDir)) {
fs.readdirSync(pluginsDir).forEach(file => {
if (file.endsWith('.js')) {
const name = path.basename(file, '.js');
plugins[name] = require(path.join(pluginsDir, file));
}
});
}
const RUN = 'run_plugin';
const GC = 'garbage_collect';
function pluginSchedulerId(id) { return `plugin:${id}`; }
const worker = new Worker('discovery', async job => {
if (job.name === 'run_plugin') {
const { pluginName, config } = job.data;
if (plugins[pluginName]) {
console.log(`[Scheduler] Running plugin: ${pluginName}`);
try {
const payload = await plugins[pluginName].discover(config);
await DiscoveryReconciler.reconcile(pluginName, payload);
} catch (err) {
console.error(`[Scheduler] Plugin ${pluginName} failed:`, err);
}
}
} else if (job.name === 'garbage_collect') {
console.log(`[Scheduler] Running garbage collection`);
if (job.name === RUN) {
await runPluginJob(job.data && job.data.instanceId);
} else if (job.name === GC) {
console.log('[Scheduler] Running garbage collection');
await DiscoveryReconciler.garbageCollect();
}
}, { connection });
// Function to start scheduling
async function initScheduler(discoveryConfig) {
// Clear old repeatable jobs (BullMQ v6 uses JobSchedulers)
try {
const schedulers = await discoveryQueue.getJobSchedulers();
for (const job of schedulers) {
await discoveryQueue.removeJobScheduler(job.id);
}
} catch (e) {
console.log('[Scheduler] Could not clear old job schedulers (may not be supported or none exist)');
// Run one plugin instance. Loads the row (skip silently if it was deleted or
// disabled after the job was enqueued), merges its OpenBao secrets into its
// config, calls the plugin's run()/discover(), and — for discovery plugins —
// reconciles the result into the resource graph under the instance's slug.
// Bookkeeping (lastRunAt/lastStatus/lastError) is stamped on the row so the UI
// can show run state without querying BullMQ.
async function runPluginJob(instanceId) {
if (!instanceId) { console.warn('[Scheduler] run_plugin job with no instanceId'); return; }
const instance = await PluginInstance.get(instanceId);
if (!instance) { console.warn(`[Scheduler] instance ${instanceId} gone — skipping`); return; }
if (!instance.enabled) { console.warn(`[Scheduler] instance ${instance.slug} (${instanceId}) disabled — skipping`); return; }
let mod;
try { mod = pluginRegistry.getModule(instance.pluginType); }
catch (err) {
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} unavailable:`, err.message);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: `plugin type unavailable: ${instance.pluginType}` });
return;
}
// Schedule Garbage Collection
await discoveryQueue.add('garbage_collect', {}, { repeat: { pattern: '0 0 * * *' } }); // Daily
const runFn = mod.run || mod.discover;
if (typeof runFn !== 'function') {
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} has no run()/discover()`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: 'plugin type has no run()/discover()' });
return;
}
// Load plugin overrides from Redis
let overrides = {};
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
let logs = [];
try {
const data = await connection.hgetall('discovery_plugins');
for (const [k, v] of Object.entries(data)) {
overrides[k] = JSON.parse(v);
const cfg = await pluginSecrets.mergeForRun(instance);
cfg.log = (msg) => {
logs.push(`[${new Date().toISOString()}] ${msg}`);
console.log(`[Plugin ${instance.slug}] ${msg}`);
if (logs.length > 1000) logs.shift();
};
const payload = await runFn(cfg);
if (instance.category === 'discovery') {
await DiscoveryReconciler.reconcile(instance.slug, payload);
}
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
} catch (err) {
console.error('[Scheduler] Failed to load plugin overrides from Redis', err);
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
}
}
// Schedule Plugins based on config + overrides
if (discoveryConfig && discoveryConfig.plugins) {
for (const [name, config] of Object.entries(discoveryConfig.plugins)) {
const mergedConfig = { ...config, ...(overrides[name] || {}) };
if (mergedConfig.enabled && plugins[name]) {
const cron = mergedConfig.cron || '0 * * * *'; // Default hourly
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig }, { repeat: { pattern: cron } });
console.log(`[Scheduler] Scheduled plugin ${name} with cron ${cron}`);
// Also run once immediately
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig });
}
// Schedule one instance: upsert a repeatable JobScheduler keyed by its id. Does
// NOT trigger an immediate run — call runInstanceNow(id) separately for that
// (used on boot and on "load"). Safe to call repeatedly (upsert is idempotent
// and will update the cron if it changed).
async function scheduleInstance(instance) {
if (!instance || !instance.id) return;
if (!instance.enabled) { await unscheduleInstance(instance.id); return; }
const cron = instance.cron || '0 * * * *';
await discoveryQueue.upsertJobScheduler(pluginSchedulerId(instance.id), { pattern: cron }, {
name: RUN,
data: { instanceId: instance.id }
});
console.log(`[Scheduler] Scheduled instance ${instance.slug} with cron ${cron}`);
}
// Remove an instance's repeatable schedule. No-op if it had none.
async function unscheduleInstance(id) {
if (!id) return;
try { await discoveryQueue.removeJobScheduler(pluginSchedulerId(id)); }
catch (err) { /* missing scheduler is fine */ }
}
// Enqueue a single immediate run for an instance (the "Run now" button / boot
// kick). Runs once regardless of enabled, on top of any schedule.
async function runInstanceNow(id) {
if (!id) return;
await discoveryQueue.add(RUN, { instanceId: id });
}
// One-time legacy migration: if the PluginInstance table is empty AND
// conf.discovery.plugins has entries (the old static-config shape), seed one
// instance per configured type and copy its secret fields into OpenBao. After
// the first boot, the table is non-empty and the static config is ignored.
// Idempotent (guarded by the empty-table check).
async function migrateLegacyPlugins(discoveryConfig) {
const existing = await PluginInstance.list();
if (existing && existing.length) return;
const legacy = discoveryConfig && discoveryConfig.plugins;
if (!legacy || typeof legacy !== 'object') return;
const names = Object.keys(legacy);
if (!names.length) return;
console.log(`[Scheduler] Migrating ${names.length} legacy discovery plugin(s) to instances…`);
for (const name of names) {
const entry = legacy[name] || {};
const manifest = pluginRegistry.getManifest(name);
if (!manifest) {
console.warn(`[Scheduler] legacy plugin '${name}' has no registered type — skipping`);
continue;
}
// splitConfig keeps only declared configSchema fields and separates secret
// from non-secret. Legacy `enabled`/`cron` are not in configSchema, so they
// are dropped here and read from the entry directly below.
const { config, secrets } = pluginRegistry.splitConfig(name, entry);
const instance = await PluginInstance.create({
pluginType: name,
category: manifest.category,
name: manifest.name,
slug: name,
enabled: entry.enabled !== false,
cron: entry.cron || '0 * * * *',
config,
created_by: 'legacy-migration'
});
try {
await pluginSecrets.write(instance.id, secrets);
console.log(`[Scheduler] migrated '${name}' -> instance ${instance.id} (slug ${instance.slug})`);
} catch (err) {
// The instance row exists; if we can't write secrets (e.g. the sso-broker
// policy predates theta-suite v1.30.1) the operator gets a clear error
// from the API on edit, and the instance still runs with its non-secret
// config. Don't delete the row — the operator just needs to re-run
// setup.sh and edit/save the secrets.
console.error(`[Scheduler] migrated '${name}' row but FAILED to write secrets:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: `secret migration failed: ${err.message}` });
}
}
}
module.exports = { initScheduler, discoveryQueue, connection };
// Boot-time initialization: clear stale schedulers, schedule garbage collection,
// migrate any legacy static-config plugins, then schedule every enabled
// instance and kick one immediate run for each.
async function initScheduler(discoveryConfig) {
// Clear stale plugin/gc schedulers from a previous boot. Other-named
// schedulers (none in this app) are left alone.
try {
const schedulers = await discoveryQueue.getJobSchedulers();
for (const s of schedulers) {
if (s.name === RUN || s.name === GC) {
await discoveryQueue.removeJobScheduler(s.key || s.id);
}
}
} catch (e) {
console.log('[Scheduler] Could not clear old job schedulers:', e.message);
}
// Daily garbage collection of stale discovery resources.
await discoveryQueue.upsertJobScheduler(GC, { pattern: '0 0 * * *' }, { name: GC, data: {} });
try {
await migrateLegacyPlugins(discoveryConfig);
} catch (err) {
console.error('[Scheduler] legacy migration failed:', err.message);
}
const enabled = await PluginInstance.listEnabled();
for (const instance of enabled) {
await scheduleInstance(instance);
await runInstanceNow(instance.id); // boot kick
}
console.log(`[Scheduler] initialized — ${enabled.length} instance(s) scheduled`);
}
module.exports = {
initScheduler,
scheduleInstance,
unscheduleInstance,
runInstanceNow,
discoveryQueue,
connection
};
-12
View File
@@ -1,12 +0,0 @@
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use('/', createProxyMiddleware({
target: 'http://localhost:8080',
on: {
proxyRes: (proxyRes, req, res) => {
delete proxyRes.headers['x-frame-options'];
}
}
}));
app.listen(3004);
+7 -5
View File
@@ -44,9 +44,10 @@ beforeAll(async () => {
expect(host.status).toBe(200);
hostId = host.body.results.id;
// Creating a host auto-provisions <site>_<slug>_access / _admin.
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
// (docs/GROUPS.md §2 — the kind is part of the name).
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
// The creator is seeded into both groups -- groupOfNames requires at least
// one member, so Group.add puts the owner's DN there -- and _admin is nested
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
expect(host.status).toBe(200);
// Same as the top-level setup: step out of the auto-created groups the
// creator is seeded into, or this is a request for access already held.
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
// or this is a request for access already held.
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
await request(app)
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
.set('auth-token', token);
+206
View File
@@ -0,0 +1,206 @@
'use strict';
const crypto = require('crypto');
// In-memory stand-in for OpenBao. The signing key lives at secret/agent/
// signing-key in production; here we only need it to persist across calls so
// the "same key every time" property is actually exercised rather than mocked
// away.
const 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); }),
request: jest.fn(async () => ({ ok: true, status: 200 }))
}));
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
// The manager is now keyed by enrolled Agent rows rather than by a bare token
// string, so these use a stub row with the same surface the real model gives:
// an id, and an update() that records what would be persisted.
function stubAgent(overrides = {}) {
const row = {
id: overrides.id || crypto.randomUUID(),
name: overrides.name || 'test-agent',
resourceId: overrides.resourceId || null,
revoked: false,
persisted: {},
...overrides
};
row.update = jest.fn(async (patch) => {
Object.assign(row.persisted, patch);
Object.assign(row, patch);
return row;
});
return row;
}
describe('AgentManager PROTOCOL.md v1.2.0 Compliance', () => {
let mockWs;
let sentMessages;
let agent;
beforeEach(() => {
sentMessages = [];
mockWs = {
readyState: 1, // OPEN
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
close: jest.fn()
};
agent = stubAgent();
});
test('registers an agent and reports it as connected', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const state = agentManager.liveState(agent.id);
expect(state.connected).toBe(true);
expect(state.ipAddress).toBe('192.168.1.100');
expect(agentManager.isConnected(agent.id)).toBe(true);
});
// registerAgent must not be async: the WS `message` listener is attached in
// the same tick, and `ws` drops events emitted before a listener exists. An
// awaited DB write here swallowed every agent's first discovery frame, which
// is the one it sends immediately on connect.
test('registerAgent is synchronous so no message can be missed', () => {
const result = agentManager.registerAgent(agent, mockWs, '10.0.0.1');
expect(result).toBeUndefined();
expect(agentManager.isConnected(agent.id)).toBe(true);
});
test('persists discovery to the agent row (Section 3.1)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleDiscovery(agent, {
hostname: 'node-01.local',
ip_addresses: ['192.168.1.100', '10.0.0.5'],
os: 'Ubuntu 24.04 LTS',
kernel: '6.8.0-31-generic',
cpu: 'AMD EPYC 7763',
ram_total_gb: 32.0,
disk_total_gb: 500.0,
location: 'dc-chicago-rack-4'
});
const saved = agent.persisted.lastDiscovery;
expect(saved.hostname).toBe('node-01.local');
expect(saved.os).toBe('Ubuntu 24.04 LTS');
expect(saved.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
// Durable, not just in memory: an agent that goes offline keeps its facts.
expect(agent.persisted.last_seen).toEqual(expect.any(Number));
});
test('persists telemetry to the agent row (Section 3.2)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleTelemetry(agent, {
cpu_usage_percent: 14.5,
ram_usage_percent: 42.1,
disk_usage_percent: 68.0,
zfs_health: 'ONLINE',
gpu_usage_percent: -1.0,
timestamp: new Date().toISOString()
});
expect(agent.persisted.lastTelemetry.cpu_usage_percent).toBe(14.5);
expect(agent.persisted.lastTelemetry.zfs_health).toBe('ONLINE');
});
test('responds to heartbeat with heartbeat_ack (Section 3.3)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleHeartbeat(agent, { timestamp: new Date().toISOString() }, mockWs);
expect(mockWs.send).toHaveBeenCalled();
const lastMsg = sentMessages[sentMessages.length - 1];
expect(lastMsg.type).toBe('heartbeat_ack');
expect(lastMsg.payload.timestamp).toBeDefined();
});
test('canonicalizes and signs high-risk commands with Ed25519 (Section 5)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const rawPayload = { script: 'uptime', location: 'datacenter' };
const msg = await agentManager.sendCommand(agent, 'arbitrary_bash', rawPayload, true);
expect(msg.type).toBe('arbitrary_bash');
expect(typeof msg.payload.signature).toBe('string');
const keys = await agentKeys.load();
const isValid = crypto.verify(
null,
Buffer.from(agentManager.canonicalize(rawPayload), 'utf8'),
crypto.createPublicKey(keys.publicKeyPem),
Buffer.from(msg.payload.signature, 'base64')
);
expect(isValid).toBe(true);
});
// The canonical form has to match the Go agent's byte for byte. Go's
// encoding/json escapes <, > and & by default and JSON.stringify does not, so
// the agent uses SetEscapeHTML(false); this pins the server's half of that
// contract. See theta-agent TestCanonicalizeMatchesServerForm.
test('canonical form is sorted, unescaped, and omits the signature', () => {
const canonical = agentManager.canonicalize({
script: 'echo a > b && c',
comment: 'x&y',
signature: 'should-not-appear'
});
expect(canonical).toBe('{"comment":"x&y","script":"echo a > b && c"}');
});
test('refuses to send to an agent that is not connected', async () => {
await expect(agentManager.sendCommand(agent, 'reload_config', {}, false))
.rejects.toThrow(/not connected/);
});
// Revocation that only applies on the next reconnect is not revocation.
test('disconnect drops the live socket immediately', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
expect(agentManager.isConnected(agent.id)).toBe(true);
const dropped = agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
expect(dropped).toBe(true);
expect(mockWs.close).toHaveBeenCalledWith(4003, 'Enrollment revoked');
expect(agentManager.isConnected(agent.id)).toBe(false);
});
test('a second connection for the same agent supersedes the first', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const secondWs = { readyState: 1, send: jest.fn(), close: jest.fn() };
agentManager.registerAgent(agent, secondWs, '192.168.1.101');
expect(mockWs.close).toHaveBeenCalledWith(4002, 'Superseded by new connection');
expect(agentManager.liveState(agent.id).ipAddress).toBe('192.168.1.101');
});
test('an unknown agent id is simply not connected', () => {
expect(agentManager.isConnected('no-such-agent')).toBe(false);
expect(agentManager.liveState('no-such-agent')).toEqual({ connected: false, lastResponse: null });
});
});
describe('agent signing key', () => {
// The old manager generated a key pair in its constructor, so it changed on
// every restart and the public_key pinned in agent.yml stopped matching.
test('the same key is returned across repeated loads', async () => {
const first = await agentKeys.load();
const second = await agentKeys.load();
expect(first.publicKeyBase64).toBe(second.publicKeyBase64);
});
test('the exported public key is the raw 32 bytes agents pin', async () => {
const keys = await agentKeys.load();
expect(Buffer.from(keys.publicKeyBase64, 'base64')).toHaveLength(32);
});
test('rawPublicKeyBase64 strips the SPKI wrapper', () => {
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
const raw = Buffer.from(agentKeys.rawPublicKeyBase64(publicKey), 'base64');
expect(raw).toHaveLength(32);
// and it is the tail of the DER encoding
const der = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
expect(raw.equals(der.subarray(der.length - 32))).toBe(true);
});
});
+69
View File
@@ -0,0 +1,69 @@
const request = require('supertest');
const express = require('express');
// Mock dependencies before requiring the route
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
}));
jest.mock('../utils/permission', () => ({
byGroup: jest.fn().mockResolvedValue(true),
}));
jest.mock('@simpleworkjs/conf', () => ({}));
const baoConf = require('@simpleworkjs/bao-conf');
const apiConf = require('../routes/api_conf');
const app = express();
app.use(express.json());
// Add a mock user for the permission check
app.use((req, res, next) => {
req.user = { uid: 'testadmin' };
next();
});
app.use('/api/conf', apiConf);
describe('Proxy Conf API (Vault Integration)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('GET /api/conf/proxy returns proxy conf with masked secrets', async () => {
baoConf.get.mockResolvedValueOnce({
oidc: { issuer: 'https://test', clientId: 'cid', clientSecret: 'real_secret' },
ldap: { bindPassword: 'real_ldap_password' }
});
const res = await request(app).get('/api/conf/proxy');
expect(res.status).toBe(200);
expect(res.body.oidc.issuer).toBe('https://test');
expect(res.body.oidc.clientSecret).toBe('********'); // MASKED
expect(res.body.ldap.bindPassword).toBe('********'); // MASKED
expect(baoConf.get).toHaveBeenCalledWith('proxy/conf');
});
it('POST /api/conf/proxy merges configuration securely to OpenBao', async () => {
baoConf.get.mockResolvedValueOnce({
oidc: { clientSecret: 'old_secret' },
ldap: { bindPassword: 'old_ldap' }
});
const payload = {
oidc: { issuer: 'https://new', clientSecret: '********' }, // Admin left it unchanged
ldap: { bindPassword: 'new_password' }
};
const res = await request(app)
.post('/api/conf/proxy')
.send(payload);
expect(res.status).toBe(200);
expect(baoConf.set).toHaveBeenCalledTimes(1);
const saved = baoConf.set.mock.calls[0][1];
expect(saved.oidc.issuer).toBe('https://new');
expect(saved.oidc.clientSecret).toBe('old_secret'); // Preserved because incoming was mask
expect(saved.ldap.bindPassword).toBe('new_password'); // Overwritten because incoming was new
});
});
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// Pure-logic coverage for the two reconciler rules that real Proxmox + UniFi
// data broke. Both were found by running discovery against a live cluster:
// the directory came back listing MAC addresses as host names, and one
// resource ended up as its own parent.
// Mirrors the ranking in services/discovery_reconciler.js. Kept here (rather
// than exported) because it is a few lines of predicate that the reconciler
// applies inline while merging; if it grows, export it and drop this copy.
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
const nameRank = (str) => {
if (!str || !String(str).trim()) return 0;
if (isMac(str)) return 0;
if (isIp(str)) return 1;
return 2;
};
function bestNameOf(existingName, incomingName) {
let best = existingName;
if (incomingName) {
const a = nameRank(incomingName);
const b = nameRank(best);
if (a > b || (a === b && incomingName.length > (best || '').length)) best = incomingName;
}
return best;
}
describe('discovery name ranking', () => {
test('a real hostname beats a MAC even when shorter', () => {
// The exact regression: UniFi named the host by MAC, Proxmox knew the
// hostname, and length-only comparison kept the MAC.
expect(bestNameOf('ac:16:2d:b3:da:80', 'dl380-0')).toBe('dl380-0');
});
test('a MAC never displaces a real hostname', () => {
expect(bestNameOf('dl380-0', 'ac:16:2d:b3:da:80')).toBe('dl380-0');
});
test('a real hostname beats an IP-shaped name', () => {
expect(bestNameOf('192.168.1.27', 'hass.io')).toBe('hass.io');
});
test('an IP beats a MAC', () => {
expect(bestNameOf('bc:24:11:3f:cd:c8', '192.168.1.27')).toBe('192.168.1.27');
});
test('an IP does not displace a hostname', () => {
expect(bestNameOf('gitea-runner', '192.168.1.176')).toBe('gitea-runner');
});
test('within the same rank the longer/more specific name wins', () => {
expect(bestNameOf('pve', 'pve-dl380-1')).toBe('pve-dl380-1');
});
test('dash-separated MACs are recognized too', () => {
expect(bestNameOf('ac-16-2d-b3-da-80', 'dl380-0')).toBe('dl380-0');
});
test('an empty existing name is always replaced', () => {
expect(bestNameOf('', 'anything')).toBe('anything');
expect(bestNameOf(null, 'ac:16:2d:b3:da:80')).toBe('ac:16:2d:b3:da:80');
});
});
// Mirrors isDescendant() in the reconciler.
function isDescendant(candidateId, rootId, edges) {
const seen = new Set();
const stack = [rootId];
while (stack.length) {
const id = stack.pop();
if (id === candidateId) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
}
return false;
}
describe('discovery edge cycle guard', () => {
const edges = [
{ parentId: 'cluster', childId: 'node1' },
{ parentId: 'node1', childId: 'vm1' },
];
test('detects a direct parent/child inversion', () => {
// Proposing node1 -> cluster when cluster -> node1 already exists.
expect(isDescendant('node1', 'cluster', edges)).toBe(true);
});
test('detects a deeper loop', () => {
expect(isDescendant('vm1', 'cluster', edges)).toBe(true);
});
test('allows an unrelated new parent', () => {
expect(isDescendant('node2', 'cluster', edges)).toBe(false);
});
test('terminates on a graph that already contains a cycle', () => {
// A self-edge written by an earlier release must not hang the walk.
const cyclic = [{ parentId: 'a', childId: 'a' }, { parentId: 'a', childId: 'b' }];
expect(isDescendant('zzz', 'a', cyclic)).toBe(false);
});
});
+130
View File
@@ -0,0 +1,130 @@
'use strict';
const {
slugify,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
GOD_ADMIN,
} = require('../utils/groups');
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
describe('slugify', () => {
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
expect(slugify('Web 01')).toBe('web-01');
expect(slugify('Main Office')).toBe('main-office');
expect(slugify('my_host')).toBe('my-host');
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
expect(slugify('')).toBe('');
});
});
describe('group cn builders', () => {
test('per-resource names the kind + name slug (docs §2)', () => {
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
});
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
});
test('aggregate uses the plural kind', () => {
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
});
test('site super admin + everyone', () => {
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
});
test('a directory site slug with a kind prefix is kept verbatim', () => {
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
});
test('invalid kind throws', () => {
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
});
});
describe('levels', () => {
test('admin/access known; capabilities opaque', () => {
expect(isKnownLevel('admin')).toBe(true);
expect(isKnownLevel('access')).toBe(true);
expect(isKnownLevel('reboot')).toBe(false);
expect(isKnownLevel('emby_admin')).toBe(false);
});
test('admin implies access; access does not imply admin', () => {
expect(levelGrants('admin', 'access')).toBe(true);
expect(levelGrants('access', 'admin')).toBe(false);
});
});
describe('hasPermission — inheritance', () => {
test('god_admin grants everything everywhere', () => {
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
});
test('site super admin grants everything on its site, not other sites', () => {
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
});
test('aggregate (all hosts) grants on any host at the site', () => {
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
});
test('specific host group grants only that host', () => {
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
});
test('admin implies access; access does not imply admin', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
});
test('capabilities are exact — admin does not grant a capability', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
});
test('hosts and apps are orthogonal namespaces', () => {
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
});
test('a service maps to the app kind (docs §11)', () => {
// The directory `service` kind is the group model's `app`.
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
});
test('cross-site isolation', () => {
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
});
});
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const nmapPlugin = require('../plugins/discovery/nmap');
jest.mock('node-nmap', () => {
const EventEmitter = require('events');
class MockNmapScan extends EventEmitter {
constructor(targetRange, customFlags) {
super();
this.targetRange = targetRange;
this.customFlags = customFlags;
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
}
startScan() {
setImmediate(() => {
this.emit('complete', [
{ ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] }
]);
});
}
}
return {
NmapScan: MockNmapScan,
nmapLocation: 'nmap'
};
});
describe('nmap discovery plugin', () => {
test('discover passes custom flags (-Pn, -sT, -F, --min-rate) to constructor', async () => {
const logs = [];
const result = await nmapPlugin.discover({
targetRange: '192.168.1.0/24',
log: (msg) => { logs.push(msg); }
});
const startLog = logs.find(l => l.startsWith('Starting nmap scan'));
expect(startLog).toBeDefined();
expect(startLog).toContain('-Pn');
expect(startLog).toContain('-sT');
expect(startLog).toContain('-F');
expect(startLog).toContain('--min-rate 100');
expect(result.resources).toHaveLength(2); // host + service
expect(result.resources[0].name).toBe('host-10');
expect(result.edges).toHaveLength(1);
});
});
+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([]);
});
+179
View File
@@ -0,0 +1,179 @@
'use strict';
// Tests for the plugin system:
// - plugin_registry: pure type discovery + configSchema helpers (no ORM, no
// OpenBao, no LDAP) — the registry just requires the plugins/discovery/*.js
// modules, which are real deps (node-fetch, node-nmap).
// - plugin_secrets: OpenBao read/write/mergeForRun, with @simpleworkjs/bao-conf
// mocked so no live OpenBao is needed.
// - PluginInstance model: ORM round-trip against the same sqlite store the
// rest of the suite uses (initORM), incl. the unique-slug constraint and
// listEnabled. Like resource_site_slug.test.js, this is direct model use
// rather than the LDAP-gated HTTP routes.
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
request: jest.fn(),
}));
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const baoConf = require('@simpleworkjs/bao-conf');
const { PluginInstance } = require('../models/plugin_instance');
describe('plugin_registry', () => {
test('getTypes lists the built-in discovery plugins', () => {
const types = registry.getTypes();
const byType = Object.fromEntries(types.map(t => [t.type, t]));
expect(byType.proxmox).toBeDefined();
expect(byType.unifi).toBeDefined();
expect(byType.nmap).toBeDefined();
expect(byType.proxmox.category).toBe('discovery');
expect(byType.proxmox.configSchema.length).toBeGreaterThan(0);
});
test('configSchema marks secret fields', () => {
const m = registry.getManifest('proxmox');
const secret = m.configSchema.find(f => f.key === 'tokenSecret');
expect(secret.secret).toBe(true);
expect(secret.required).toBe(true);
expect(m.configSchema.find(f => f.key === 'url').secret).toBeFalsy();
});
test('requiredKeys / secretKeys / publicKeys split correctly', () => {
expect(registry.requiredKeys('proxmox').sort()).toEqual(['tokenId', 'tokenSecret', 'url']);
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
expect(registry.secretKeys('unifi')).toEqual(['password']);
expect(registry.secretKeys('nmap')).toEqual([]);
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
});
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
const { config, secrets } = registry.splitConfig('proxmox', {
url: 'https://pve:8006',
tokenId: 'u@pam!t',
tokenSecret: 'shh',
enabled: true, // not in configSchema -> dropped
cron: '0 * * * *' // not in configSchema -> dropped
});
expect(config).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t' });
expect(secrets).toEqual({ tokenSecret: 'shh' });
});
test('mask redacts only secret values', () => {
const masked = registry.mask('proxmox', { url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
expect(masked.url).toBe('https://pve:8006');
expect(masked.tokenId).toBe('u@pam!t');
expect(masked.tokenSecret).toBe('********');
});
test('getModule throws for an unknown type', () => {
expect(() => registry.getModule('does-not-exist')).toThrow(/Unknown plugin type/);
});
test('getModule returns a module with run()/discover()', () => {
const mod = registry.getModule('proxmox');
expect(typeof mod.run).toBe('function');
expect(typeof mod.discover).toBe('function');
expect(typeof mod.validate).toBe('function');
});
});
describe('plugin_secrets', () => {
const VALID_ID = '11111111-1111-4111-8111-111111111111';
beforeEach(() => { baoConf.get.mockReset(); baoConf.set.mockReset(); baoConf.request.mockReset(); });
test('read returns the data object', async () => {
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
const out = await pluginSecrets.read(VALID_ID);
expect(out).toEqual({ tokenSecret: 'shh' });
expect(baoConf.get).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`);
});
test('read returns {} when none stored', async () => {
baoConf.get.mockResolvedValue(null);
expect(await pluginSecrets.read(VALID_ID)).toEqual({});
});
test('write drops blank and masked placeholder values', async () => {
await pluginSecrets.write(VALID_ID, { tokenSecret: 'new', keep: '********', blank: '' });
expect(baoConf.set).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`, { tokenSecret: 'new' });
});
test('mergeForRun layers secrets over the row config', async () => {
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
const instance = { id: VALID_ID, config: { url: 'https://pve:8006', tokenId: 'u@pam!t' } };
const cfg = await pluginSecrets.mergeForRun(instance);
expect(cfg).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
});
test('read rejects a non-uuid id', async () => {
await expect(pluginSecrets.read('not-a-uuid')).rejects.toThrow(/invalid plugin instance id/);
});
test('remove is best-effort (404 is fine)', async () => {
baoConf.request.mockResolvedValue({ status: 404 });
await expect(pluginSecrets.remove(VALID_ID)).resolves.toBeUndefined();
});
});
describe('PluginInstance model', () => {
const marker = 'test_plugin_' + Date.now();
const created = [];
async function makeInstance(slug, extra = {}) {
const r = await PluginInstance.create({
pluginType: 'proxmox',
category: 'discovery',
name: 'Test ' + slug,
slug: `${marker}_${slug}`,
enabled: true,
cron: '0 * * * *',
config: { url: 'https://pve:8006' },
...extra
});
created.push(r);
return r;
}
beforeAll(async () => {
const { initORM } = require('../models');
await initORM();
});
afterAll(async () => {
for (const r of created) {
try { await r.delete(); } catch (_) {}
}
});
test('create generates a uuid id and round-trips json config', async () => {
const r = await makeInstance('a');
expect(r.id).toMatch(/^[0-9a-f-]{36}$/i);
const fetched = await PluginInstance.get(r.id);
expect(fetched.slug).toBe(`${marker}_a`);
expect(fetched.config).toEqual({ url: 'https://pve:8006' });
});
test('slug is unique', async () => {
await makeInstance('dup');
await expect(makeInstance('dup')).rejects.toThrow(/Validation error|SequelizeUniqueConstraint/i);
});
test('getBySlug resolves', async () => {
const r = await makeInstance('bySlug');
const found = await PluginInstance.getBySlug(`${marker}_bySlug`);
expect(found.id).toBe(r.id);
});
test('listEnabled returns only enabled instances', async () => {
const on = await makeInstance('on', { enabled: true });
const off = await makeInstance('off', { enabled: false });
const enabled = await PluginInstance.listEnabled();
const slugs = enabled.map(e => e.slug);
expect(slugs).toContain(on.slug);
expect(slugs).not.toContain(off.slug);
});
});
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const { _Interfaces: Interfaces } = require('../plugins/discovery/proxmox');
// Regression coverage for the MAC/IP mismatch: the plugin used to collect MACs
// and IPs into two flat lists and zip them by index, so on a multi-NIC guest
// -- or any guest where one NIC had no address -- the directory recorded an IP
// against the wrong MAC. Interfaces keys by MAC so a pairing can only come from
// the source that observed both together.
describe('proxmox Interfaces', () => {
test('keeps each IP on the NIC it was observed on', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', ['10.0.0.5'], 'eth0');
i.add('AA:BB:CC:00:00:02', ['192.168.9.7'], 'eth1');
expect(i.toArray()).toEqual([
{ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5', ips: ['10.0.0.5'], name: 'eth0' },
{ mac: 'aa:bb:cc:00:00:02', ip: '192.168.9.7', ips: ['192.168.9.7'], name: 'eth1' },
]);
});
test('a NIC with no address does not steal the next NIC\'s IP', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', [], 'eth0'); // stopped/unconfigured
i.add('AA:BB:CC:00:00:02', ['10.0.0.9'], 'eth1');
const byMac = Object.fromEntries(i.toArray().map(x => [x.mac, x.ip]));
expect(byMac['aa:bb:cc:00:00:01']).toBeNull();
expect(byMac['aa:bb:cc:00:00:02']).toBe('10.0.0.9');
});
test('merges the config MAC with the agent-reported address for the same NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5'], 'eth0'); // guest agent
i.add('AA:BB:CC:00:00:01', [], 'net0'); // VM config, same NIC
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0]).toMatchObject({ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5' });
});
test('collects multiple addresses on one NIC without inventing a second NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5', '10.0.0.6'], 'eth0');
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0].ips).toEqual(['10.0.0.5', '10.0.0.6']);
expect(i.primaryIp()).toBe('10.0.0.5');
});
test('ignores placeholder and malformed MACs', () => {
const i = new Interfaces();
i.add('00:00:00:00:00:00', [], 'eth0');
i.add('not-a-mac', [], 'eth1');
i.add('', [], 'eth2');
expect(i.toArray()).toEqual([]);
expect(i.primaryMac()).toBeNull();
});
test('keeps an address that arrived without a usable MAC', () => {
const i = new Interfaces();
i.add(null, ['10.0.0.5'], 'eth0');
expect(i.primaryIp()).toBe('10.0.0.5');
expect(i.primaryMac()).toBeNull();
});
test('primary values prefer a NIC that actually has an address', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'eth0');
i.add('aa:bb:cc:00:00:02', ['10.0.0.9'], 'eth1');
expect(i.primaryIp()).toBe('10.0.0.9');
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:02');
});
test('a fully unaddressed guest still reports its MAC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'net0');
expect(i.primaryIp()).toBeNull();
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:01');
});
});
+204
View File
@@ -0,0 +1,204 @@
'use strict';
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(),
set: jest.fn(),
request: jest.fn(),
}));
jest.mock('redis', () => ({
createClient: () => ({
on: jest.fn(),
connect: jest.fn().mockResolvedValue(),
get: jest.fn().mockResolvedValue(null),
set: jest.fn().mockResolvedValue(),
})
}));
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
// can run without a database.
jest.mock('../models/shared_secret', () => ({
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/shared_secret_grant', () => ({
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
}));
jest.mock('../models/vault_app_token', () => {
const rows = [];
const VaultAppToken = {
_rows: rows,
list: jest.fn(async () => rows),
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
create: jest.fn(async (data) => {
const row = {
...data,
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
};
rows.push(row);
return row;
}),
};
return { VaultAppToken };
});
const baoConf = require('@simpleworkjs/bao-conf');
const vaultBroker = require('../utils/vault_broker');
const { VaultAppToken } = require('../models/vault_app_token');
describe('vault_broker admin policy', () => {
beforeEach(() => {
baoConf.request.mockReset();
});
test('getOrCreateAdminToken ensures sso-admin policy with list capabilities on metadata', async () => {
baoConf.request.mockImplementation(async (method, path, body) => {
if (method === 'GET' && path === 'sys/policies/acl/sso-admin') {
return { status: 404, text: async () => '' };
}
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
return { status: 204, ok: true };
}
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
return {
ok: true,
json: async () => ({ auth: { client_token: 'test-admin-token', lease_duration: 3600 } })
};
}
return { status: 200, ok: true, json: async () => ({}) };
});
const token = await vaultBroker.getOrCreateAdminToken('adminuser');
expect(token).toBe('test-admin-token');
expect(baoConf.request).toHaveBeenCalledWith('PUT', 'sys/policies/acl/sso-admin', expect.objectContaining({
policy: expect.stringContaining('path "secret/metadata/"')
}));
});
});
describe('app token lifecycle (accessor storage + renewal)', () => {
beforeEach(() => {
baoConf.request.mockReset();
VaultAppToken._rows.length = 0;
});
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
baoConf.request.mockImplementation(async (method, path, body) => {
if (path.startsWith('sys/policies/acl/')) {
if (method === 'GET') return { status: 404, text: async () => '' };
return { status: 204, ok: true };
}
if (path === 'auth/token/create/sso-app') {
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
}
if (path === 'auth/token/renew-accessor') {
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
}
if (path === 'auth/token/revoke-accessor') {
return { ok: true, status: 204, text: async () => '' };
}
return { status: 200, ok: true, json: async () => ({}) };
});
}
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
mockBao({ mintAccessor: 'acc-old' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
mockBao({ mintAccessor: 'acc-new' });
await vaultBroker.mintAppToken('demo', 'adminuser');
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
expect(VaultAppToken._rows).toHaveLength(1);
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
});
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
mockBao();
await vaultBroker.mintAppToken('demo', 'adminuser');
VaultAppToken._rows[0].lastRenewedAt = 0;
await vaultBroker.renewAppTokens();
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
expect(VaultAppToken._rows[0].lastError).toBeNull();
});
test('renewAppTokens records the failure on the row without throwing', async () => {
mockBao({ renewOk: false });
await vaultBroker.mintAppToken('demo', 'adminuser');
await vaultBroker.renewAppTokens();
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
});
});
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
// This exists because the proxy once shipped with a hook shape the installed
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
// request 403'd. A unit test on options can't catch that — only a wire test can.
describe('vaultProxy wire behavior', () => {
const http = require('http');
const express = require('express');
let target; // fake OpenBao
let seen; // last request the fake OpenBao received
let app; // sso app fragment: scopeGuard stub + vaultProxy
let server;
beforeAll((done) => {
target = http.createServer((req, res) => {
let body = '';
req.on('data', (c) => { body += c; });
req.on('end', () => {
seen = { method: req.method, url: req.url, headers: req.headers, body };
res.setHeader('content-type', 'application/json');
res.end('{"ok":true}');
});
});
target.listen(0, '127.0.0.1', () => {
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
jest.resetModules();
const broker = require('../utils/vault_broker');
app = express();
app.use(express.json());
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
server = app.listen(0, '127.0.0.1', done);
});
});
afterAll((done) => {
server.close(() => target.close(done));
});
function call(path, opts = {}) {
const port = server.address().port;
return fetch(`http://127.0.0.1:${port}${path}`, opts);
}
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
});
expect(res.status).toBe(200);
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(seen.headers['auth-token']).toBeUndefined();
expect(seen.headers['authorization']).toBeUndefined();
});
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
const res = await call('/api/vault/secret/data/users/alice/foo', {
method: 'POST',
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
body: JSON.stringify({ data: { hello: 'world' } }),
});
expect(res.status).toBe(200);
expect(seen.method).toBe('POST');
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
});
});
+99
View File
@@ -0,0 +1,99 @@
'use strict';
// The Ed25519 key pair the SSO signs high-risk agent commands with, stored in
// OpenBao at `secret/agent/signing-key`.
//
// This used to be generated in the AgentManager constructor and kept only in
// memory, which made the whole signing scheme decorative: every SSO restart
// produced a new key, so the `public_key` pinned in an agent's agent.yml stopped
// matching and the agent either rejected everything or (because it skips
// verification when no key is configured) executed everything unverified. A
// trust anchor that changes on restart is not a trust anchor.
//
// Requires the sso-broker OpenBao policy to grant `secret/agent/*`
// (theta-suite setup.sh). Without it the load fails and signing is reported as
// unavailable -- we deliberately do NOT fall back to an ephemeral key, because
// signing with a key no agent has ever seen is worse than refusing: it looks
// like it worked.
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const PATH = 'agent/signing-key'; // baoConf adds the secret/data prefix
let cached = null; // { privateKeyPem, publicKeyPem, publicKeyBase64 }
let loadError = null;
// Agents pin the raw 32-byte Ed25519 public key, base64-encoded (see the Go
// client's verifySignature, which base64-decodes cfg.public_key and expects
// ed25519.PublicKeySize bytes). Node hands us SPKI PEM, so strip the 12-byte
// DER prefix to get the raw key the agent actually wants.
function rawPublicKeyBase64(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
return Buffer.from(der.subarray(der.length - 32)).toString('base64');
}
function generate() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
return { privateKeyPem: privateKey, publicKeyPem: publicKey };
}
// Load the stored key pair, generating and persisting one on first run.
// Idempotent and safe to call repeatedly; the result is cached in-process.
async function load() {
if (cached) return cached;
let stored = null;
try {
stored = await baoConf.get(PATH);
} catch (err) {
loadError = `could not read ${PATH} from OpenBao: ${err.message}`;
console.error(`[agent_keys] ${loadError}`);
return null;
}
if (stored && stored.privateKeyPem && stored.publicKeyPem) {
cached = {
privateKeyPem: stored.privateKeyPem,
publicKeyPem: stored.publicKeyPem,
publicKeyBase64: rawPublicKeyBase64(stored.publicKeyPem)
};
loadError = null;
return cached;
}
// First run: mint one and persist it before use, so a crash between
// generating and storing can't leave agents pinned to a key we forgot.
const fresh = generate();
try {
await baoConf.set(PATH, fresh);
} catch (err) {
loadError = `could not persist a signing key to ${PATH}: ${err.message}. `
+ 'Re-run ./setup.sh so the sso-broker policy grants secret/agent/*.';
console.error(`[agent_keys] ${loadError}`);
return null;
}
cached = {
...fresh,
publicKeyBase64: rawPublicKeyBase64(fresh.publicKeyPem)
};
loadError = null;
console.log('[agent_keys] generated and stored a new agent signing key');
return cached;
}
function status() {
return { available: !!cached, error: loadError };
}
// Test seam: drop the in-process cache.
function _reset() {
cached = null;
loadError = null;
}
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH };
+253
View File
@@ -0,0 +1,253 @@
'use strict';
const crypto = require('crypto');
const agentKeys = require('./agent_keys');
const { Agent } = require('../models/agent');
// Tracks the live WebSocket for each enrolled agent and brokers commands to it.
//
// The durable facts about an agent (identity, host binding, last seen, last
// discovery/telemetry) live in the Agent table; this class holds only what
// cannot be persisted -- the open socket. That split is what makes an installed
// -but-offline agent visible, and what stops a restart from erasing the fleet.
class AgentManager {
constructor() {
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
this.live = new Map();
}
/**
* Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5:
* 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);
}
/**
* Sign payload using the persisted Ed25519 private key. Throws when no key is
* available rather than minting a throwaway one -- an agent verifies against
* the key pinned in its agent.yml, so a signature from a key it has never
* seen is not a weaker signature, it is a broken command that looks fine from
* this side.
*/
async signPayload(payload) {
const keys = await agentKeys.load();
if (!keys) {
const { error } = agentKeys.status();
throw new Error(`agent command signing is unavailable: ${error || 'no signing key'}`);
}
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
}
async publicKeyBase64() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyBase64 : null;
}
async publicKeyPem() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyPem : null;
}
// Bind a freshly authenticated socket to an enrolled agent. `agent` is an
// Agent row that Agent.authenticate() has already vouched for -- this method
// never sees a raw token and must never be called with an unauthenticated one.
// Synchronous by design. The caller must attach its `message` listener in the
// same tick as the connection is accepted: `ws` drops events emitted before a
// listener exists, and the agent sends `discovery` immediately on open, so
// awaiting a database round-trip here silently lost every agent's first
// discovery frame. The connect timestamp is persisted in the background.
registerAgent(agent, ws, remoteAddress) {
const existing = this.live.get(agent.id);
if (existing && existing.ws && existing.ws !== ws) {
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
}
this.live.set(agent.id, {
ws,
ipAddress: remoteAddress,
connectedAt: new Date().toISOString(),
lastResponse: null
});
agent.update({
last_seen: Math.floor(Date.now() / 1000),
last_ip: remoteAddress || null
}).catch(err => console.error(`[AgentManager] could not record connect for ${agent.id}:`, err.message));
}
unregisterAgent(agentId, ws) {
const state = this.live.get(agentId);
if (state && state.ws === ws) this.live.delete(agentId);
}
// Drop an agent's live socket now. Revocation that only takes effect on the
// next reconnect is not revocation -- a connected agent would keep receiving
// commands indefinitely.
disconnect(agentId, code = 4003, reason = 'Disconnected by server') {
const state = this.live.get(agentId);
if (!state || !state.ws) return false;
try { state.ws.close(code, reason); } catch (e) {}
this.live.delete(agentId);
return true;
}
isConnected(agentId) {
const state = this.live.get(agentId);
return !!(state && state.ws && state.ws.readyState === 1);
}
async touch(agent, extra = {}) {
await agent.update({
last_seen: Math.floor(Date.now() / 1000),
...extra
}).catch(err => console.error(`[AgentManager] could not persist agent ${agent.id}:`, err.message));
}
async handleDiscovery(agent, payload) {
const discovery = {
hostname: payload.hostname || '',
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
os: payload.os || '',
kernel: payload.kernel || '',
cpu: payload.cpu || '',
ram_total_gb: payload.ram_total_gb || 0,
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
};
await this.touch(agent, { 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,
ram_usage_percent: payload.ram_usage_percent || 0,
disk_usage_percent: payload.disk_usage_percent || 0,
zfs_health: payload.zfs_health || 'N/A',
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
timestamp: payload.timestamp || new Date().toISOString()
}
});
}
async handleHeartbeat(agent, payload, ws) {
await this.touch(agent);
try {
ws.send(JSON.stringify({
type: 'heartbeat_ack',
payload: { timestamp: new Date().toISOString() }
}));
} catch (e) {}
}
async handleResponse(agent, payload) {
const state = this.live.get(agent.id);
if (state) {
state.lastResponse = {
status: payload.status || 'ok',
message: payload.message || '',
output: payload.output || '',
timestamp: new Date().toISOString()
};
}
await this.touch(agent);
}
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
const state = this.live.get(agent.id);
if (!state || !state.ws || state.ws.readyState !== 1) {
throw new Error(`Agent "${agent.name}" is not connected`);
}
const finalPayload = { ...payload };
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
const message = { type: commandType, payload: finalPayload };
state.ws.send(JSON.stringify(message));
return message;
}
// Live view for one agent, for merging into its row.
liveState(agentId) {
const state = this.live.get(agentId);
if (!state) return { connected: false, lastResponse: null };
return {
connected: !!(state.ws && state.ws.readyState === 1),
ipAddress: state.ipAddress,
connectedAt: state.connectedAt,
lastResponse: state.lastResponse || null
};
}
// Every enrolled agent, connected or not.
async listAgents() {
const rows = await Agent.list();
return rows.map(a => a.toPublic(this.liveState(a.id)));
}
}
module.exports = new AgentManager();
module.exports.AgentManager = AgentManager;
+141
View File
@@ -0,0 +1,141 @@
'use strict';
// Theta42 group & permission model.
//
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
// parseable structure. The structural delimiter is `_`; site/host/app slugs
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
// uses the singular (host/app).
//
// god_admin global — everything, everywhere
// {site}_super_admin everything on the site
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
// {site}_hosts_<level>
// {site}_host_<slug>_<level> admin/access/capability on ONE host
// {site}_apps_<level> ... on ALL apps at the site
// {site}_app_<slug>_<level> ... on ONE app
// {site}_everyone / everyone meta groups (implicit membership)
//
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
// `access`; capabilities are explicit and never implied by `admin`. Groups are
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
//
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
// supply the user's group memberships (e.g. from Group.list(user.dn)).
const GOD_ADMIN = 'god_admin';
const KNOWN_LEVELS = ['admin', 'access'];
const KINDS = ['host', 'app'];
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
// contains '_' (the structural delimiter), so group names parse unambiguously.
function slugify(name) {
return String(name || '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
// Validate a kind (host/app) — throw on anything else.
function assertKind(kind) {
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
}
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
// `theta-env`), leaving the resource's name slug. Services are stored bare
// (`sso-manager`), so this is a no-op for them.
function resourceNameSlug(slug) {
return String(slug || '').replace(/^(site|host|app)_/, '');
}
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
// `site_local_app_sso-manager_access`.
function resourceGroupCns(site, kind, nameSlug, level) {
assertKind(kind);
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
}
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
function aggregateGroupCns(site, kind, level) {
assertKind(kind);
return `${site}_${kind}s_${level}`;
}
// {site}_super_admin
function siteSuperAdminCns(site) {
return `${site}_super_admin`;
}
// {site}_everyone
function siteEveryoneCns(site) {
return `${site}_everyone`;
}
// True if `level` is a known admin/access level (not an opaque capability).
function isKnownLevel(level) {
return KNOWN_LEVELS.includes(level);
}
// True if holding `level` grants `wanted` (admin implies access).
function levelGrants(level, wanted) {
if (level === wanted) return true;
return level === 'admin' && wanted === 'access';
}
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
// `level` on a resource. Applies the inheritance lattice:
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
//
// memberOf: array of group cns the user is a member of.
// resource: { site, kind: 'host'|'app', slug }.
// level: 'admin' | 'access' | an opaque capability token.
//
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
// are resource-level grants, resolved by the caller against the resource's own
// granted groups (see permission.onResource). This keeps the function pure over
// the user's membership only.
function hasPermission(memberOf, resource, level) {
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
// apps); `nameSlug` is the resource name with any kind prefix stripped.
const site = resource && resource.site;
const rawKind = resource && resource.kind;
const kind = rawKind === 'service' ? 'app' : rawKind;
const nameSlug = resourceNameSlug(resource && resource.slug);
const set = new Set(memberOf || []);
if (set.has(GOD_ADMIN)) return true;
if (set.has(siteSuperAdminCns(site))) return true;
if (isKnownLevel(level)) {
// admin / access
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
return false;
}
// Opaque capability — exact aggregate or specific grant only.
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
return false;
}
module.exports = {
GOD_ADMIN,
KNOWN_LEVELS,
KINDS,
slugify,
resourceNameSlug,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
siteEveryoneCns,
isKnownLevel,
levelGrants,
hasPermission,
};
+64 -5
View File
@@ -1,10 +1,27 @@
'use strict';
const {Group} = require('../models/group_ldap');
const groups = require('./groups');
const SUPER_ADMIN_GROUP = 'app_super_admin';
// The group nested into every resource's _admin group by api_directory_admin
// (cross-resource super-admin administration). This is `god_admin` -- the global
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
// It used to be the legacy `app_super_admin`, which existed while god_admin
// didn't; now that god_admin is created at boot, the provisioning nests it.
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
// the migration, so an existing deployment isn't stripped of rights until it's
// rebuilt.
const SUPER_ADMIN_GROUP = 'god_admin';
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
let byGroup = async function(user, groups, ownerOf){
// True if the user (by resolved member cns) is a global god/super admin.
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
async function isSuperAdmin(memberOfCns) {
return memberOfCns.includes(groups.GOD_ADMIN) ||
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
}
let byGroup = async function(user, checkGroups, ownerOf){
// Membership is resolved once, transitively: a user placed in an admin group
// through a nested group is as much a member as one listed on it directly.
// Checking `group.member.includes(user.dn)` per group -- as this used to --
@@ -17,9 +34,9 @@ let byGroup = async function(user, groups, ownerOf){
// they still catch direct membership if the resolver is unavailable.
}
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
if(await isSuperAdmin(memberOfCns)) return true;
for(let group of groups){
for(let group of checkGroups){
if(memberOfCns.includes(group)) return true;
}
@@ -42,4 +59,46 @@ let byGroup = async function(user, groups, ownerOf){
throw error;
}
module.exports = {byGroup, SUPER_ADMIN_GROUP};
// Resolve whether a user has `level` on a directory resource under the group
// model (see utils/groups.js). Applies the inheritance lattice and the
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
//
// user: the auth user ({ dn, isMachine }).
// resource:{ site, kind: 'host'|'app', slug }.
// level: 'admin' | 'access' | an opaque capability token.
// grantedGroups: optional array of the resource's granted group cns (used only
// for meta `everyone` handling). Omit to skip meta grants.
async function onResource(user, resource, level, grantedGroups) {
let memberOfCns = [];
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
if (await isSuperAdmin(memberOfCns)) return true;
if (groups.hasPermission(memberOfCns, resource, level)) return true;
// Meta grants: `everyone` / `{site}_everyone` confer access to any
// authenticated (non-machine) user when the resource grants them.
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
const siteEveryone = groups.siteEveryoneCns(resource.site);
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
}
return false;
}
// Like onResource but throws Insufficient Permission when denied — for guards.
async function requireResource(user, resource, level, grantedGroups) {
if (await onResource(user, resource, level, grantedGroups)) return;
const error = new Error('Insufficient Permission');
error.name = 'Insufficient Permission';
error.status = 401;
throw error;
}
module.exports = {
byGroup,
onResource,
requireResource,
isSuperAdmin,
SUPER_ADMIN_GROUP,
LEGACY_SUPER_ADMIN_ALIASES,
...groups, // group schema builders (slugify, resourceGroupCns, ...)
};
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// Per-instance plugin secrets, stored in OpenBao at `secret/plugins/<id>/conf`.
//
// Plugins run in-process (as BullMQ workers in the SSO Node process), so they
// need no OpenBao token of their own — the SSO reads/writes their secrets
// server-side through the `sso-broker` token (@simpleworkjs/bao-conf), exactly
// like it reads its own `secret/sso-manager/conf`. This mirrors the per-user
// (`secret/users/<uid>/*`) and per-app (`secret/apps/<name>/*`) namespaces.
//
// Only the configSchema fields flagged `secret:true` are stored here; the rest
// of an instance's config lives in the PluginInstance DB row. The admin UI
// only ever sees these masked (`********`).
//
// Requires theta-suite >= v1.30.1: the sso-broker policy must grant
// `secret/data/plugins/*` + `secret/metadata/plugins/*`. Without it, write/
// read fail with a 403 — the API surfaces that as a clear error so the operator
// knows to re-run `./setup.sh`.
const baoConf = require('@simpleworkjs/bao-conf');
// Instance ids are ORM-generated uuids, so this is defense-in-depth against a
// bogus id ever being interpolated into a secret path. 404s are expected
// (no secret written yet); other malformed input is rejected hard.
function assertId(id) {
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
const err = new Error('invalid plugin instance id for secret path');
err.status = 400;
throw err;
}
}
function path(id) {
return `plugins/${id}/conf`; // baoConf.get/set add the secret/data prefix
}
// Read the secret field values for an instance. Returns {} when none are
// stored yet (a brand-new instance, or one with no secret fields). A 404 from
// OpenBao is normal — anything else propagates.
async function read(id) {
assertId(id);
try {
const data = await baoConf.get(path(id));
return (data && typeof data === 'object') ? data : {};
} catch (err) {
// bao-conf treats a missing KV path as null/empty, but a 403 means the
// sso-broker policy lacks secret/plugins/* — surface that distinctly.
if (err && /403|permission/i.test(err.message)) throw err;
return {};
}
}
// Write (replace) the secret field values for an instance. `secrets` is a flat
// {field: value} object of only the secret configSchema fields. Empty/blank
// values are dropped so we never store a masked placeholder back as a secret.
async function write(id, secrets) {
assertId(id);
const clean = {};
for (const [k, v] of Object.entries(secrets || {})) {
if (v === undefined || v === null || v === '' || v === '********') continue;
clean[k] = v;
}
await baoConf.set(path(id), clean);
}
// Merge the stored secret field values over the instance's non-secret config,
// producing the single `config` object the plugin's run()/validate() receive.
// Non-secret values come from the DB row; secret values come from OpenBao.
async function mergeForRun(instance) {
if (!instance) return {};
const config = (instance.config && typeof instance.config === 'object') ? instance.config : {};
const secrets = await read(instance.id);
return { ...config, ...secrets };
}
// Best-effort delete of the instance's secret namespace. Called when an
// instance is deleted. A 404 (already gone / never written) is fine; anything
// else is logged and swallowed so a stuck OpenBao can't strand an instance row.
async function remove(id) {
assertId(id);
try {
const res = await baoConf.request('DELETE', `secret/metadata/plugins/${id}/conf`);
if (res && res.status && res.status !== 404 && !res.ok) {
console.error(`[plugin_secrets] delete for ${id} returned ${res.status}`);
}
} catch (err) {
console.error(`[plugin_secrets] failed to delete secrets for ${id}:`, err.message);
}
}
module.exports = { read, write, remove, mergeForRun };
+4 -6
View File
@@ -38,15 +38,13 @@ module.exports = {
// app-base.js, which reveals .group-required-<cn> for each group the user is
// in (plus the synthetic `admin` group when user/me reports isAdmin).
nav: [
// Ungated on purpose: the catalog is the one page that exists for
// ordinary users. Before this, every nav item was admin-only and a
// non-admin had no signposted destination at all.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
// Catalog requires login - it's the end-user view of their accessible resources.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_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']},
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
// 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']},
],
};
+267 -46
View File
@@ -4,15 +4,25 @@
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
// `sso-broker` token role created by theta-env/setup.sh.
//
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/* admin UI sessions (sso-admin policy)
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
// secret/* admin UI sessions (sso-admin policy)
//
// The sso-broker policy grants update on auth/token/create/sso-broker and on
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
// create the per-subject policies and mint their tokens. Per-user/admin tokens
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
//
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
// live at token use (only the SET of policy names on a token is fixed at mint),
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
// — cached or freshly minted. That way a stale cached token immediately gains
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
// an existing grantee token with no re-mint. The Redis cache only short-circuits
// token MINTING, never policy reconciliation.
const baoConf = require('@simpleworkjs/bao-conf');
const { createClient } = require('redis');
@@ -20,6 +30,9 @@ const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const conf = require('@simpleworkjs/conf');
const permission = require('./permission');
const { SharedSecret } = require('../models/shared_secret');
const { SharedSecretGrant } = require('../models/shared_secret_grant');
const { VaultAppToken } = require('../models/vault_app_token');
const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
@@ -54,51 +67,104 @@ async function bao(method, path, body) {
return res;
}
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
// content and only PUT when it differs. `bao policy write` is an idempotent
// 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) return;
if (existing.status !== 404) {
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
}
if (existing.status === 200) {
const body = await existing.json().catch(() => null);
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
// Mint a token through the sso-broker role with the given policies. Returns
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
async function mintToken(policies) {
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
// Mint a token through a token role with the given policies. Returns
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
// from cache) and sso-app (768h period — long-lived external-app credentials,
// kept alive via their stored accessor by the renewal loop below).
async function mintToken(policies, role = ROLE) {
const res = await bao('POST', `auth/token/create/${role}`, { policies });
const json = await res.json();
const token = json && json.auth && json.auth.client_token;
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
return { token, ttl };
return { token, accessor: json.auth.accessor, ttl };
}
// ── Shared-secret policy rules ───────────────────────────────────────────────
// Returns the HCL rules granting `read` on every shared secret the given
// grantee (a user uid or an app name) has been granted. Enforcement is
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
// effect for the grantee's existing token immediately (no re-mint).
async function sharedPolicyRules(granteeType, granteeId) {
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
if (!grants.length) return '';
const secretIds = [...new Set(grants.map(g => g.secretId))];
const secrets = secretIds.length
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
const byId = new Map(secrets.map(s => [s.id, s]));
const rules = [];
for (const g of grants) {
const sec = byId.get(g.secretId);
if (!sec) continue;
const p = sec.path(); // shared/<ownerUid>/<slug>
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
}
return rules.join('\n');
}
// ── Per-user token ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
async function userPolicyHcl(uid) {
const granted = await sharedPolicyRules('user', uid);
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
// Re-minted when the cache entry expires (a little before the token's own TTL).
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
// (compare-and-skip) before the cache is consulted, so a cached token can never
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
// when the cache entry expires (a little before the token's own TTL).
async function getOrCreateUserToken(uid) {
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
const cacheKey = `vault_token:${uid}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
const { token, ttl } = await mintToken([`user-${uid}`]);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
}
// ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() {
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
}
async function getOrCreateAdminToken(uid) {
await ensurePolicy('sso-admin', adminPolicyHcl());
const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
@@ -108,24 +174,144 @@ async function getOrCreateAdminToken(uid) {
}
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
async function appPolicyHcl(name) {
const granted = await sharedPolicyRules('app', name);
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
${granted}`.trim();
}
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately.
async function mintAppToken(name) {
// tokens. The caller must record it in the external app immediately. Later
// grants to the app edit app-<name> policy content (live-applied to this token).
//
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
// token alive and lets a re-mint revoke the app's previous token so exactly
// one credential per app is ever live.
async function mintAppToken(name, actorUid) {
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
}
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
// App tokens are long-lived credentials: mint via the sso-app role (768h
// period) so a renewal inside every 32-day window keeps them alive forever.
// Fall back to the broker's own 24h role on deployments whose setup.sh
// predates the sso-app role (re-running setup.sh creates it).
let minted;
try {
minted = await mintToken([`app-${name}`], 'sso-app');
} catch (e) {
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
minted = await mintToken([`app-${name}`]);
}
const { token, accessor, ttl } = minted;
// Replace the app's accessor row; revoke the superseded token (best-effort —
// it may already be expired) so re-minting never leaves a zombie credential.
try {
const existing = await VaultAppToken.getByName(name);
if (existing) {
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
await existing.delete();
}
if (accessor) {
await VaultAppToken.create({
name, accessor,
lastRenewedAt: Date.now(),
created_by: actorUid, created_on: Date.now(),
});
}
} catch (e) {
// Accessor bookkeeping must never block handing the token out; without a
// row the token simply isn't auto-renewed (it still lives one full period).
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
}
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── App-token renewal loop ──────────────────────────────────────────────────
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
// far inside both possible periods (24h fallback and 768h), so a downstream
// app's token stays valid for as long as sso is running. Failures are recorded
// on the row (visible to admins in the DB / future UI) and never throw.
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
let renewTimer;
async function renewAppTokens() {
let rows;
try { rows = await VaultAppToken.list(); }
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
for (const row of rows) {
try {
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
if (res.ok) {
await row.update({ lastRenewedAt: Date.now(), lastError: null });
} else {
const text = await res.text().catch(() => '');
// 400 "invalid accessor" = token expired or was revoked out-of-band;
// keep the row + error so the admin can see the app needs a re-mint.
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
}
} catch (e) {
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
}
}
}
// Start the loop (idempotent). unref() so an open handle never blocks exit.
function startAppTokenRenewal() {
if (renewTimer) return renewTimer;
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
renewTimer = setInterval(() => {
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
}, RENEW_INTERVAL_MS);
if (renewTimer.unref) renewTimer.unref();
return renewTimer;
}
// ── Grant / revoke shared-secret access ─────────────────────────────────────
// Creating a grant writes the DB row and then edits the grantee's policy content
// to add read on the shared path; revoking removes both. Because OpenBao parses
// policy content live, the change applies to the grantee's existing token
// immediately — no token re-mint, no cache invalidation needed.
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
const grant = await SharedSecretGrant.create({
secretId, granteeType, granteeId, capability: 'read',
created_by: actorUid, created_on: Date.now(),
updated_by: actorUid, updated_on: Date.now(),
});
await reconcileGrantee(granteeType, granteeId);
return grant;
}
async function revokeSharedSecret(grantId, actorUid) {
const grant = await SharedSecretGrant.get(grantId);
if (!grant) return null;
const { granteeType, granteeId } = grant;
await grant.delete();
await reconcileGrantee(granteeType, granteeId);
return grant;
}
// Recompute and rewrite a grantee's policy content after a grant/revoke.
async function reconcileGrantee(granteeType, granteeId) {
if (granteeType === 'user') {
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
} else if (granteeType === 'app') {
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
} else {
throw new Error(`invalid granteeType: ${granteeType}`);
}
}
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
// nothing). The guard mints a server-side token for the user (per-user or
@@ -134,11 +320,12 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) {
try {
await permission.byGroup(user, [ADMIN_GROUP]);
await permission.byGroup(user, ADMIN_GROUPS);
return true;
} catch (e) {
return false;
@@ -167,17 +354,13 @@ async function scopeGuard(req, res, next) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
}
// Defense-in-depth: confirm the requested path is within the subject's
// namespace. Admins roam all of secret/; users are confined to
// secret/users/<uid>/. (The token's own policy enforces the same at the
// OpenBao layer; this catches a buggy/malicious client early with a clear
// 403 instead of an opaque OpenBao denial.)
const norm = normalizeVaultPath(req.path);
if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' });
}
const base = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/');
const userBase = `/secret/users/${uid}`;
const sharedBase = `/secret/shared`;
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' });
}
@@ -191,16 +374,21 @@ function vaultProxy() {
return createProxyMiddleware({
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/': '/v1/' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
},
pathRewrite: { '^/api/vault': '/v1' },
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
// which shipped this proxy with NO token injection — every /api/vault
// call reached OpenBao unauthenticated and 403'd.
onProxyReq(proxyReq, req, res, options) {
// Header ops MUST precede fixRequestBody: it write()s the parsed body
// onto proxyReq, which flushes headers — setHeader after that throws
// (swallowed upstream), silently dropping the token on every write.
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
fixRequestBody(proxyReq, req, res, options);
},
});
}
@@ -214,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
const result = await mintAppToken(name);
const result = await mintAppToken(name, req.user && req.user.uid);
res.json(result);
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
@@ -222,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
}
});
// List the minted external-app tokens (metadata only — the token itself is shown
// once at mint and never stored; the accessor is a renewal/revoke handle and is
// never exposed). Lets the Apps tab show what has been minted instead of a
// credential vanishing into the void.
mintAppRouter.get('/', async (req, res, next) => {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const rows = await VaultAppToken.list();
res.json({ apps: rows.map((r) => ({
name: r.name,
createdBy: r.created_by,
createdOn: r.created_on,
lastRenewedAt: r.lastRenewedAt || null,
lastError: r.lastError || null,
})) });
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
next(e);
}
});
module.exports = {
getOrCreateUserToken,
getOrCreateAdminToken,
@@ -230,4 +439,16 @@ module.exports = {
scopeGuard,
vaultProxy,
mintAppRouter,
};
// app-token lifecycle
renewAppTokens,
startAppTokenRenewal,
VaultAppToken,
// sharing
SharedSecret,
SharedSecretGrant,
userPolicyHcl,
appPolicyHcl,
grantSharedSecret,
revokeSharedSecret,
reconcileGrantee,
};
+441 -79
View File
@@ -1,9 +1,16 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() {
loadConf();
loadProxyConf();
loadTos();
loadMessagingPlugins();
});
async function loadConf() {
@@ -28,6 +35,13 @@
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
}
}
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
}
@@ -35,8 +49,8 @@
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
smtp: {
host: $('#smtp-host').val(),
@@ -53,19 +67,87 @@
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
}
}
async function sendTestEmail() {
const to = $('#test-email-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient email address', 'warning');
return;
}
const btn = $('#btn-test-email');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val('');
} catch (error) {
app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
async function sendTestSms() {
const to = $('#test-sms-to').val().trim();
if (!to) {
app.messages.toast('Please enter a recipient phone number', 'warning');
return;
}
const btn = $('#btn-test-sms');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try {
const payload = {
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
await app.api.post('conf', payload);
const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val('');
} catch (error) {
app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger');
} finally {
btn.prop('disabled', false).html(originalHtml);
}
}
function togglePassword(id) {
const el = document.getElementById(id);
if (el.type === 'password') {
@@ -74,87 +156,367 @@
el.type = 'password';
}
}
async function loadProxyConf() {
try {
const data = await app.api.get('conf/proxy');
if (data.oidc) {
$('#proxy-issuer').val(data.oidc.issuer || '');
$('#proxy-client-id').val(data.oidc.clientId || '');
$('#proxy-client-secret').val(data.oidc.clientSecret || '');
}
if (data.ldap) {
$('#proxy-ldap-bindpass').val(data.ldap.bindPassword || '');
}
} catch (error) {
console.error('Failed to load Proxy conf:', error);
}
}
async function saveProxyConf() {
const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = {
oidc: {
issuer: $('#proxy-issuer').val(),
clientId: $('#proxy-client-id').val(),
clientSecret: $('#proxy-client-secret').val()
},
ldap: {
bindPassword: $('#proxy-ldap-bindpass').val()
}
};
try {
await app.api.post('conf/proxy', payload);
app.messages.toast('Proxy configuration saved securely to OpenBao!', 'success');
} catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
}
}
async function loadTos() {
try {
const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
</script>
<div class="container py-4">
<div class="row mb-4">
<div class="col d-flex justify-content-between align-items-center">
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings and OAuth parameters.
These secrets are stored securely in OpenBao Vault.
</p>
</div>
<div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
</div>
</div>
</div>
<div class="container mt-4">
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" id="smtp-host">
</div>
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
<div class="col-12">
<div class="card shadow">
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<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="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
</button>
</li>
</ul>
<div>
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
<div class="card-body p-4">
<div class="tab-content" id="confTabContent">
<!-- OAuth & JWT Tab -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access">
</div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
<!-- SMTP Tab -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="p-3 bg-light rounded border mb-4">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button>
</div>
</div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div id="messaging-plugins-list"></div>
</div>
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div>
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<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>
<!-- 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">
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
</div>
</div>
+1164 -106
View File
File diff suppressed because it is too large Load Diff
+6 -9
View File
@@ -21,11 +21,6 @@
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="all">All Resources</option>
<option value="unmanaged" selected>Unmanaged Only</option>
<option value="managed">Managed Only</option>
</select>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
@@ -132,10 +127,12 @@
// Name search
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Managed filter
// Always hide items that have been committed to the catalog (managed)
const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false;
if(isManaged) return false;
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
if(!isAuto) return false;
return true;
});
@@ -161,7 +158,7 @@
return;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderTable();
loadResources();
});
}
-406
View File
@@ -1,406 +0,0 @@
<%- include('top') %>
<script type="text/javascript">
var userlist;
var allGroups = [];
// A member DN under the groups base is a nested group, not a person. Both
// live in the same `member` attribute, so they have to be told apart here --
// otherwise a nested group renders as a user whose name happens to be the
// group's, and its remove button calls the user endpoint and 404s.
function isGroupDn(dn){
return /,ou=groups,/i.test(String(dn));
}
function processGroup(value){
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
// Split before anything else consumes `member`.
value.nested = value.member.filter(isGroupDn).map(function(dn){
return {
dn: dn,
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
groupCN: value.cn
};
});
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
value.nestedCount = value.nested.length;
value.hasNested = value.nestedCount > 0;
// Candidates to nest: every other group not already nested here. Self is
// excluded; deeper loops are refused server-side by Group.wouldCycle,
// which is the only place that can see the whole graph.
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
value.toNest = allGroups.filter(function(g){
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
value.toAdd = userlist.filter(function(user){
return !value.member.includes(user.dn);
});
value.toAddOwner = userlist.filter(function(user){
return !value.owner.includes(user.dn);
});
value.member = value.member.map(function(user){
return {
dn: user,
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
};
});
value.owner = value.owner.map(function(user){
return {
dn: user,
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
};
});
value.memberCount = value.member.length;
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
value.groupCN = value.cn;
return value;
}
// app_sso_service_account is a marker group: membership hides an account
// from the Users page's People tab entirely (see users.ejs), which is
// exactly right for a non-person account but has silently made a real
// person's account look "gone" before (nothing else about it changes).
// Everywhere else in this dropdown just fires the PUT directly; only
// this one group gets a confirmation first.
function addMemberClick(event, groupCN, uid, el){
event.preventDefault();
const $el = $(el);
(async function(){
if (groupCN === 'app_sso_service_account') {
const ok = await app.messages.confirm(
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
$el.closest('.card'), 'warning'
);
if (!ok) return;
}
try {
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
await addedUser(data.message, groupCN, uid, $el);
} catch(e) {
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
}
})();
return false;
}
async function addedUser(message, group, user, $form){
let data = await app.group.get(group);
$.scope.groupCard.update('cn', group, processGroup(data.results));
app.messages.action(message, $("#group-card-"+group), 'success');
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
}
function applySort() {
const sort = $('#groupSort').val();
const scope = $.scope.groupCard;
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
}
function matchesSearch(g) {
const q = $('#groupSearch').val().toLowerCase().trim();
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
}
function applyFilters() {
applySort();
const groups = allGroups.filter(matchesSearch);
$.scope.groupCard.empty();
$.scope.groupCard.push(...groups);
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
}
async function tableAJAX(revealCn) {
let data = await app.group.list();
// processGroup builds each card's "nest a group" list from allGroups, so
// it has to see the full set before the map runs -- assigning only the
// mapped result would leave every dropdown empty on first load (and one
// render stale thereafter). The raw entries carry the cn/dn it needs.
allGroups = data.results;
allGroups = data.results.map(processGroup);
applyFilters();
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
}
function addNestedClick(event, groupCN, childCN, el){
event.preventDefault();
const $card = $('#group-card-' + groupCN);
(async function(){
try {
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $card, 'success');
} catch(e) {
// 409 here is the cycle guard or an already-nested group -- both
// carry a specific server message worth showing verbatim.
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
}
})();
}
async function removeNested(groupCN, childCN, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
$item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to un-nest group', $('#group-card-' + groupCN), 'danger');
}
}
async function removeMember(groupCN, uid, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
}
}
async function removeOwner(groupCN, uid, btn) {
const $item = $(btn).closest('li');
$item.addClass('list-group-item-warning');
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
try {
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
const groupData = await app.group.get(groupCN);
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
} catch(e) {
$item.removeClass('list-group-item-warning');
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
}
}
async function deleteGroup(cn, btn) {
const $card = $(btn).closest('.card');
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
if (!confirmed) return;
try {
await app.api.delete(`group/${cn}`);
$.scope.groupCard.remove('cn', cn);
} catch(e) {
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
}
}
app.auth.forceLogin(['app_sso_admin', 'admin']);
$(document).ready(async function(){
userlist = (await app.user.list()).results;
tableAJAX();
});
</script>
<div class="container mt-4">
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
<div class="input-group" style="flex: 1 1 200px;">
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
</div>
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
<option value="name-asc">Name A → Z</option>
<option value="name-desc">Name Z → A</option>
<option value="members-desc">Most members</option>
<option value="members-asc">Fewest members</option>
</select>
<span id="groupCount" class="text-muted text-nowrap small"></span>
</div>
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
<div class="col">
<div class="card shadow">
<div class="card-header">
<i class="fa-solid fa-object-group"></i>
Add new group
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
<div class="mb-3">
<label class="form-label">Name</label>
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
</div>
<button type="submit" class="btn btn-outline-dark">Add</button>
</form>
</div>
</div>
</div>
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
<div class="card shadow col">
<div class="card-header">
<h5>
<i class="fa-solid fa-arrows-down-to-people"></i>
Group: {{ cn }}
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
</h5>
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
<li class="nav-item">
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
<i class="fa-solid fa-users"></i>
Members
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
<i class="fa-solid fa-layer-group"></i>
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
</a>
</li>
<li class="nav-item">
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
<i class="fa-solid fa-user-tie"></i>
Owners
</a>
</li>
<li class="nav-item float-end">
</li>
</ul>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="card-body">
<p>
{{ description }}
</p>
<div class="tab-content" id="myTabContent">
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
<p>
<ul class="list-group">
{{ #member }}
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
<i class="fa-solid fa-user"></i> {{ uid }}
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-user-slash"></i>
</button>
</li>
{{ /member }}
</ul>
</p>
<div class="dropdown">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
{{ #toAdd }}{{#.}}
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
<i class="fa-solid fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAdd }}
</div>
</div>
</div>
<div class="tab-pane fade" id="group-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
<p class="text-muted small mb-2">
Everyone in a nested group is a member of this one, at any depth.
</p>
<ul class="list-group">
{{ #nested }}
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
<i class="fa-solid fa-layer-group"></i> {{ cn }}
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-link-slash"></i>
</button>
</li>
{{ /nested }}
{{ ^hasNested }}
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
{{ /hasNested }}
</ul>
<div class="dropdown mt-2">
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-diagram-project"></i> Nest a group
</button>
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
{{ #toNest }}
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
{{ /toNest }}
</div>
</div>
</div>
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
<p>
<ul class="list-group">
{{ #owner }}
<li class="list-group-item shadow">
<i class="fa-solid fa-user"></i> {{ uid }}
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
<i class="fa-solid fa-user-slash"></i>
</button>
</li>
{{ /owner }}
</ul>
</p>
<div class="dropdown float-start">
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="fa-solid fa-user-plus"></i>
</button>
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
{{ #toAddOwner }}{{#.}}
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
<i class="fa-solid fa-user"></i> {{uid}}
</a>
{{/.}}{{ /toAddOwner }}
</div>
</div>
</div>
</div>
</div>
<div class="card-footer">
<div class="float-end">
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
<i class="fa-solid fa-edit"></i>
</button>
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
<i class="fa-solid fa-trash"></i>
</button>
</div>
<div>
Created: {{createTimestamp}}<br />
Last Modified: {{modifyTimestamp}}
</div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+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>'
-64
View File
@@ -162,50 +162,10 @@
}
}
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() {
loadDashboard();
loadHistory();
toggleFilterInputs();
loadTos();
loadMetrics();
});
</script>
@@ -385,30 +345,6 @@
</div>
</div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
+365 -53
View File
@@ -3,54 +3,73 @@
<div class="container mt-4">
<div class="row">
<div class="col-12">
<ul class="nav nav-tabs mb-3">
<li class="nav-item">
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
</li>
</ul>
<div class="card shadow border-top-0">
<div class="card shadow">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-plug"></i> Plugins & Scheduler
<i class="fa-solid fa-plug"></i> Plugins
</div>
<div class="d-flex gap-2 align-items-center">
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewPluginModal()">
<i class="fas fa-plus"></i> New Plugin
</button>
</div>
</div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> View configured background plugins and scheduler status. Note: Plugins are configured statically in <code>sso-secrets.js</code>.
<i class="fa-solid fa-circle-info"></i> Configured plugin instances. Each is a loadable, scheduled copy of a
plugin type (e.g. Proxmox, UniFi, Nmap) — you can run several of the same type with different settings.
Secrets are stored in OpenBao and shown masked. <a href="/docs/plugins">Learn more</a>.
</div>
<div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Plugin Name</th>
<th>Cron Schedule</th>
<th>Status</th>
<th>Details</th>
<th class="ps-3">Name</th>
<th>Type</th>
<th>Schedule</th>
<th>State</th>
<th>Last Run</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="plugins-list" jq-repeat="plugins">
<tr>
<td class="ps-3 fw-bold">{{name}}</td>
<tr id="plugin-row-{{id}}">
<td class="ps-3">
<strong>{{name}}</strong>
<div class="small text-muted font-monospace">{{slug}}</div>
</td>
<td><span class="badge bg-secondary">{{pluginType}}</span></td>
<td><code class="text-dark">{{cron}}</code></td>
<td>
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
{{#enabled}}<span class="badge bg-success">Loaded</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Unloaded</span>{{/enabled}}
</td>
<td class="small text-muted font-monospace">
{{details}}
<td class="small">
{{#lastRunAt}}<span title="{{lastRunAt}}">{{lastRunFmt}}</span>{{/lastRunAt}}
{{^lastRunAt}}<span class="text-muted">never</span>{{/lastRunAt}}
{{#lastStatus}}
{{#isOk}}<span class="badge bg-success-subtle text-success-emphasis ms-1">ok</span>{{/isOk}}
{{#isError}}<span class="badge bg-danger-subtle text-danger-emphasis ms-1" title="{{lastError}}">error</span>{{/isError}}
{{#isRunning}}<span class="badge bg-info-subtle text-info-emphasis ms-1">running</span>{{/isRunning}}
{{/lastStatus}}
</td>
<td>
<button class="btn btn-sm btn-primary" title="Edit" onclick="openEditModal('{{id}}')"><i class="fa-solid fa-pen"></i></button>
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
</td>
</tr>
</tbody>
<tbody id="empty-state" style="display: none;">
<tbody id="plugins-empty-state" style="display: none;">
<tr>
<td colspan="4" class="text-center py-4 text-muted">
No plugins configured in sso-secrets.js
<td colspan="6" class="text-center py-5 text-muted">
<i class="fa-solid fa-plug fs-2 mb-3 text-black-50"></i>
<h5>No plugin instances</h5>
<p>Click <strong>New Plugin</strong> to configure one.</p>
</td>
</tr>
</tbody>
@@ -64,39 +83,332 @@
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
// type -> manifest (configSchema etc.), loaded once for the New-plugin form.
var pluginTypes = {};
// id -> instance (plain), kept current after each load so modals can resolve a row.
var pluginsById = {};
$(document).ready(function() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + (err.message || err), 'danger'); return; }
(res.results || []).forEach(function(t) { pluginTypes[t.type] = t; });
});
loadPlugins();
});
function fmtRun(ms) {
if (!ms) return '';
var d = new Date(Number(ms));
return moment(d).fromNow();
}
function loadPlugins() {
app.api.get('plugins', function(err, res) {
if(err) {
app.messages.toast("Error loading plugins: " + (err.message || err));
return;
}
const plugins = res.results || {};
const pluginNames = Object.keys(plugins);
if (err) { app.messages.toast('Error loading plugins: ' + (err.message || err), 'danger'); return; }
var list = res.results || [];
pluginsById = {};
$.scope.plugins.empty();
if(pluginNames.length === 0) {
if (!list.length) {
$('#plugins-list').hide();
$('#empty-state').show();
$('#plugins-empty-state').show();
} else {
pluginNames.forEach(name => {
const config = plugins[name];
const details = Object.entries(config)
.filter(([k, v]) => k !== 'enabled' && k !== 'cron')
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
$.scope.plugins.push({
name: name,
cron: config.cron || 'N/A',
enabled: config.enabled,
details: details
});
list.forEach(function(p) {
pluginsById[p.id] = p;
p.lastRunFmt = fmtRun(p.lastRunAt);
p.isOk = p.lastStatus === 'ok';
p.isError = p.lastStatus === 'error';
p.isRunning = p.lastStatus === 'running';
$.scope.plugins.push(p);
});
$('#plugins-list').show();
$('#empty-state').hide();
$('#plugins-empty-state').hide();
}
});
});
}
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {};
var html = '';
schema.forEach(function(f) {
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>';
});
return html;
}
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
});
return out;
}
function typeOptionsHtml(selected) {
var opts = '<option value="">Select a plugin type…</option>';
Object.keys(pluginTypes).sort().forEach(function(t) {
opts += '<option value="' + t + '"' + (t === selected ? ' selected' : '') + '>' + pluginTypes[t].name + ' (' + t + ')</option>';
});
return opts;
}
// --- New Plugin modal ---
function openNewPluginModal() {
app.modal.open({
title: 'New Plugin',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Plugin Type <span class="text-danger">*</span></label>' +
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('np-', '0 * * * *') +
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
});
}
function renderNewPluginFields() {
var type = document.getElementById('np-type').value;
document.getElementById('np-config-fields').innerHTML = configFormHtml(type, 'np-');
}
async function saveNewPlugin() {
var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim();
var cron = cronFromForm('np-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-');
try {
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to create plugin', app.modal.body(), 'danger');
}
}
// --- Edit (non-secret) modal ---
function openEditModal(id) {
var p = pluginsById[id];
if (!p) return;
app.modal.open({
title: 'Edit — ' + p.name,
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit(\'' + id + '\')', saveLabel: 'Save' })
}
});
}
async function saveEdit(id) {
var p = pluginsById[id];
if (!p) return;
var name = document.getElementById('ed-name').value.trim();
var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-');
try {
await app.api.put('plugins/' + id, { name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin saved.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
// --- Edit Secrets modal ---
function openSecretsModal(id) {
var p = pluginsById[id];
if (!p) return;
var masked = p.secrets || {};
// Render only the secret fields, prefilled with the masked values.
var schema = (pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema) || [];
var secretFields = schema.filter(function(f) { return f.secret; });
var html = '<div class="actionMessage mb-3" style="display:none"></div>' +
'<p class="text-muted small">Stored in OpenBao. Leave a field blank to keep its current value.</p>';
if (!secretFields.length) {
html += '<p class="text-muted">This plugin has no secret fields.</p>';
} else {
secretFields.forEach(function(f) {
var val = masked[f.key] || '';
html += '<div class="mb-3"><label class="form-label">' + f.label + '</label>' +
'<input type="password" class="form-control" id="sec-' + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '" placeholder="' + (val ? '******** (unchanged)' : 'new value') + '"></div>';
});
}
app.modal.open({
title: 'Edit Secrets — ' + p.name,
bodyHtml: html,
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
});
}
async function saveSecrets(id) {
var p = pluginsById[id];
if (!p) return;
var schema = pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema;
var secrets = {};
if (schema) {
schema.forEach(function(f) {
if (!f.secret) return;
var el = document.getElementById('sec-' + f.key);
if (el) secrets[f.key] = el.value;
});
}
try {
await app.api.put('plugins/' + id + '/secrets', secrets);
app.modal.close();
app.messages.toast('Secrets saved.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to save secrets', app.modal.body(), 'danger');
}
}
async function testPlugin(id) {
try {
var res = await app.api.post('plugins/' + id + '/test', {});
app.messages.toast('Test passed.', 'success');
} catch (err) {
app.messages.toast('Test failed: ' + (err.message || 'validation failed'), 'danger');
}
}
async function runNow(id) {
try {
await app.api.post('plugins/' + id + '/run', {});
app.messages.toast('Run enqueued. Refresh shortly for status.', 'info');
setTimeout(loadPlugins, 3000);
} catch (err) {
app.messages.toast('Failed to run: ' + (err.message || err), 'danger');
}
}
async function showLogs(id) {
var p = pluginsById[id];
if (!p) return;
try {
const res = await app.api.get('plugins/' + id + '/runs');
const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.';
app.modal.open({
title: 'Logs — ' + p.name,
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '&lt;').replace(/>/g, '&gt;') + '</pre>',
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
});
} catch (err) {
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
}
}
async function togglePlugin(id, enable) {
try {
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
app.messages.toast(enable ? 'Plugin loaded.' : 'Plugin unloaded.', 'success');
loadPlugins();
} catch (err) {
app.messages.toast('Failed: ' + (err.message || err), 'danger');
}
}
async function deletePlugin(id) {
var p = pluginsById[id];
if (!p) return;
var ok = await app.messages.confirm('Delete plugin "' + p.name + '"? Its schedule and OpenBao secrets will be removed.', app.modal.body ? app.modal.body() : null, 'danger');
if (!ok) return;
try {
await app.api.delete('plugins/' + id);
app.messages.toast('Plugin deleted.', 'success');
loadPlugins();
} catch (err) {
app.messages.toast('Failed to delete: ' + (err.message || err), 'danger');
}
}
</script>
<%- include('bottom') %>
<%- include('bottom') %>
+29 -25
View File
@@ -9,6 +9,7 @@
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid);
$('#profile-uid-header').text(user.uid);
$.scope.user.update(user);
};
@@ -241,7 +242,7 @@
<div class="card-header shadow d-flex justify-content-between align-items-center">
<div>
<i class="fa-regular fa-id-card"></i>
Profile: <strong>{{user.uid}}</strong>
Profile: <strong id="profile-uid-header"></strong>
</div>
<div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
@@ -278,7 +279,7 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
</button>
</li>
</ul>
@@ -329,27 +330,27 @@
<p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p>
</div>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
</div>
</div>
</div>
</div>
@@ -655,9 +656,12 @@
}
</script>
<div id="own-api-tokens-section" style="display:none">
<div class="row mt-3 justify-content-center">
<div class="col-md-8">
<!-- Wrapped in the same `.container` as the profile/edit cards above (which
closes before this block): without it the API Tokens card renders
full-bleed and is visibly wider than every other card on the site. -->
<div id="own-api-tokens-section" class="container" style="display:none">
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-lg">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-key me-1"></i> API Tokens</span>
+1 -1
View File
@@ -49,7 +49,7 @@
</ul>
<div class="form-inline mt-2 mt-md-0">
<% if(ui.profileUrl){ %>
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
</a>
<% } else { %>
+360 -43
View File
@@ -1,31 +1,33 @@
<%- include('top') %>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2><i class="fas fa-lock"></i>
<% if (vaultIsAdmin) { %> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>
<% } else { %> My Secrets <small class="text-muted">(personal namespace)</small><% } %>
</h2>
<ul class="nav nav-pills" id="vault-tabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
<% if (vaultIsAdmin) { %>
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
<% } %>
</ul>
</div>
<div class="tab-content">
<div class="container mt-4">
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
</ul>
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
</div>
<div class="card-body p-0">
<div class="tab-content">
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-secrets">
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary" onclick="showCreateModal()">
<i class="fas fa-plus"></i> New Secret
</button>
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
<div class="d-flex align-items-center gap-2">
<a href="/docs/vault" class="text-reset" title="Vault help &amp; documentation"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
</div>
</div>
<div class="row">
<div class="p-3">
<div class="row">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="card-header"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="list-group list-group-flush" id="secrets-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
@@ -33,7 +35,7 @@
</div>
<div class="col-md-8">
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
@@ -49,18 +51,20 @@
<h4>Select a secret to view its details</h4>
</div>
</div>
</div>
</div>
</div>
<!-- ── Apps tab (admin only) ───────────────────────────────────────── -->
<% if (vaultIsAdmin) { %>
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
<div class="tab-pane fade" id="tab-apps">
<div class="row">
<div class="p-3">
<div class="row">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-header"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-body">
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/&lt;name&gt;/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
<div class="mb-3">
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
@@ -72,7 +76,7 @@
</div>
<div class="col-md-7">
<div class="card shadow-sm d-none" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">App token</h5>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
</div>
@@ -87,9 +91,124 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/&lt;name&gt;/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<% } %>
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade" id="tab-shared">
<div class="p-3">
<div class="row">
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">My shared secrets</h5>
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
</div>
<div class="list-group list-group-flush" id="shared-mine-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm">
<div class="card-header"><h5 class="card-title mb-0">Shared with me</h5></div>
<div class="list-group list-group-flush" id="shared-granted-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create Shared Secret Modal -->
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">New Shared Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">Name (slug)</label>
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
</div>
<div class="mb-3">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
"key": "value"
}</textarea>
</div>
<div class="alert alert-danger d-none" id="shared-create-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
</div>
</div>
</div>
</div>
<!-- Manage Grants Modal -->
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Share</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="row g-2 mb-3">
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
</div>
<div class="alert alert-danger d-none" id="grants-error"></div>
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- View Shared Secret Modal -->
<div class="modal fade" id="sharedViewModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="shared-view-title">Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
</div>
</div>
</div>
@@ -103,10 +222,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">
<% if (vaultIsAdmin) { %>Secret path (under secret/)<% } else { %>Secret name (in your personal namespace)<% } %>
</label>
<input type="text" class="form-control" id="secret-path-input" placeholder="<% if (vaultIsAdmin) { %>e.g. apps/my-service/conf<% } else { %>e.g. database-creds<% } %>">
<label class="form-label" id="secret-path-label">Secret name (in your personal namespace)</label>
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
@@ -126,14 +243,15 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
<script>
app.auth.forceLogin();
// Server-derived scoping. VAULT_BASE is '' for admins (free-form under
// secret/) or 'users/<uid>/' for everyone else (confined to their personal
// namespace). The /api/vault proxy enforces the same server-side; these only
// drive the UI.
const VAULT_BASE = <%- JSON.stringify(vaultBase) %>;
const IS_ADMIN = <%- JSON.stringify(vaultIsAdmin) %>;
// Login gate + client-derived scoping. VAULT_BASE is '' for admins
// (free-form under secret/) or 'users/<uid>/' for everyone else (confined
// to their personal namespace). The /api/vault proxy enforces the same
// server-side (scopeGuard + the token's OpenBao policy), so this only
// drives the UI. Resolved in init() after forceLogin loads the user — the
// previous version read these server-side from req.user, which is undefined
// on a browser navigation (auth-token is a client-set header, not a cookie).
let VAULT_BASE = '';
let IS_ADMIN = false;
let currentSecretPath = null;
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
@@ -142,7 +260,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`;
let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
}
function apiCall(method, path, body = null) {
@@ -164,7 +287,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
async function loadSecrets() {
try {
const res = await apiCall('GET', vpath('metadata', '?list=true'));
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
@@ -299,17 +423,210 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
// List the minted external-app tokens (metadata only). Makes the Apps tab show
// what's been minted instead of a credential that vanishes after the once-only
// token display.
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.textContent = 'Loading…';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group shadow-sm">' + 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">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
loadSecrets();
// ── Shared secrets tab ──────────────────────────────────────────────
let currentShared = null;
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
function sharedApi(path, method = 'GET', body = null) {
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
if (body) opts.body = JSON.stringify(body);
return fetch('/api/shared-secrets' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
if (res.status === 204) return null;
return res.json();
});
}
async function loadShared() {
try {
const res = await sharedApi('/');
const items = (res && res.items) || [];
renderSharedMine(items.filter(i => i.role === 'owner'));
renderSharedGranted(items.filter(i => i.role === 'grantee'));
} catch (err) {
document.getElementById('shared-mine-list').innerHTML =
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
}
}
function renderSharedMine(items) {
const el = document.getElementById('shared-mine-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
<div class="btn-group">
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
</div>`;
el.appendChild(row);
});
}
function renderSharedGranted(items) {
const el = document.getElementById('shared-granted-list');
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
el.innerHTML = '';
items.forEach(s => {
const row = document.createElement('a');
row.href = '#';
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
el.appendChild(row);
});
}
function showCreateSharedModal() {
currentShared = null;
document.getElementById('shared-slug-input').value = '';
document.getElementById('shared-desc-input').value = '';
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
document.getElementById('shared-create-error').classList.add('d-none');
sharedCreateModal.show();
}
async function saveSharedSecret() {
const err = document.getElementById('shared-create-error');
err.classList.add('d-none');
let data;
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
try {
await sharedApi('/', 'POST', {
slug: document.getElementById('shared-slug-input').value.trim(),
description: document.getElementById('shared-desc-input').value.trim(),
data
});
sharedCreateModal.hide();
await loadShared();
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function viewShared(s) {
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
document.getElementById('shared-view-content').textContent = 'Loading...';
sharedViewModal.show();
try {
const res = await apiCall('GET', 'secret/data/' + s.path);
document.getElementById('shared-view-content').textContent =
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
} catch (e) {
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
}
}
async function openGrants(id) {
currentShared = id;
document.getElementById('grants-error').classList.add('d-none');
document.getElementById('grant-id-input').value = '';
sharedGrantsModal.show();
try {
const res = await sharedApi('/' + id + '/grants');
const grants = (res && res.grants) || [];
const el = document.getElementById('grants-list');
el.innerHTML = '';
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
grants.forEach(g => {
const row = document.createElement('div');
row.className = 'list-group-item d-flex justify-content-between align-items-center';
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
el.appendChild(row);
});
} catch (e) {
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
}
}
async function addGrant() {
const err = document.getElementById('grants-error');
err.classList.add('d-none');
try {
await sharedApi('/' + currentShared + '/grants', 'POST', {
granteeType: document.getElementById('grant-type-input').value,
granteeId: document.getElementById('grant-id-input').value.trim()
});
document.getElementById('grant-id-input').value = '';
openGrants(currentShared);
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
}
async function revokeGrant(grantId) {
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
}
async function deleteShared(id) {
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
if (!confirmed) return;
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
}
(async function init() {
const user = await app.auth.forceLogin();
if (!user) return; // not logged in — forceLogin redirected to /login
IS_ADMIN = app.auth.isAdmin();
VAULT_BASE = IS_ADMIN ? '' : 'users/' + user.uid + '/';
if (IS_ADMIN) {
document.getElementById('vault-apps-tab').style.display = '';
document.getElementById('vault-title').innerHTML =
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
loadApps();
}
loadSecrets();
loadShared();
})();
</script>
<%- include('bottom') %>