Compare commits
155 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7efd3fd6bd | |||
| 9f285c960b | |||
| d75daf81b7 | |||
| 6861a113d2 | |||
| 4542c055bb | |||
| bd2205f1a9 | |||
| d5e0d61546 | |||
| 0dfb69cc9a | |||
| dae0361e82 | |||
| eef7852b69 | |||
| 0ac0c045ec | |||
| dfb819a715 | |||
| d486fb946b | |||
| 39db290265 | |||
| 964ca6dd02 | |||
| 4e388a411e | |||
| e9310a1e5c | |||
| f70419bbc4 | |||
| 358231532f | |||
| d8bd338814 | |||
| 0a3bdbef06 | |||
| 611f1a3318 | |||
| e313697bfd | |||
| 2c3ec4e967 | |||
| b6a82d58d5 | |||
| 18da6582ed | |||
| b1739ec965 | |||
| 7bc6f47070 | |||
| a0964ce350 | |||
| 0d3ee3e2ee | |||
| b95cb08c41 | |||
| a50d1ef3c8 | |||
| daefe54ff7 | |||
| dc3d760d2b | |||
| 9c604f0258 | |||
| d27763e556 | |||
| e5167729a8 | |||
| e915a17cbd | |||
| e4d5e8d75c | |||
| 39a9dc0282 | |||
| 9d266d2e4c | |||
| 3d18c3f0cb | |||
| 214b3a7f5a | |||
| 08dd234710 | |||
| c96a4b6652 | |||
| 0915043d6d | |||
| ddf123d03c | |||
| 9004463311 | |||
| 96410fd8c4 | |||
| f94bb3c14c | |||
| 37e5ad0494 | |||
| 52b85c0d36 | |||
| f98615aae2 | |||
| 72040b1851 | |||
| 93a022bd66 | |||
| c30975329c | |||
| 5c0018f24a | |||
| 6e172bd528 | |||
| 3c98cd4596 | |||
| d3fab004e9 | |||
| e8824cb28d | |||
| 98ed99a7e9 | |||
| bb744adb5e | |||
| 420a9c9f8a | |||
| d442f1e3f9 | |||
| ebd7e9e434 | |||
| 6ce7e67665 | |||
| 583b822e4a | |||
| 9025feef1b | |||
| 620f401091 | |||
| fc9952add3 | |||
| 8a0b796f81 | |||
| 652d3f7d76 | |||
| a442dc9921 | |||
| 5b1302bc6f | |||
| 10e5193077 | |||
| 15d9ce1078 | |||
| 181ca8c9cb | |||
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a | |||
| 87339da1b2 | |||
| 49100c9b68 | |||
| e8d04203c3 | |||
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 | |||
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 | |||
| 7a364bfb8e | |||
| b7aac2d2ba | |||
| 4945dec9c2 | |||
| 59d68c0269 | |||
| ef2207ed72 | |||
| 7782cf8973 | |||
| 80317d1b7e | |||
| ded6a1b0d5 | |||
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 | |||
| 15b3a424bc | |||
| 6cb309b6d9 | |||
| f0ceb750a8 | |||
| 6e95defcf5 | |||
| 92c2e8a03b | |||
| 230e5be2fd | |||
| 0331cb976a | |||
| 90cf65e920 | |||
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 522093e898 | |||
| 7b84a10420 | |||
| c461723ec7 | |||
| b948cd8625 | |||
| 36aa114d7c | |||
| fbce59b1be | |||
| 554a0999ab | |||
| 3ca221d64d | |||
| 75b133f610 | |||
| f1d52601de | |||
| ecd21c4984 |
@@ -19,6 +19,9 @@
|
|||||||
!API.md
|
!API.md
|
||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.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)
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
# nodejs/tests/
|
# nodejs/tests/
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# GitGuardian configuration (ggshield / GitGuardian GH checks).
|
||||||
|
#
|
||||||
|
# The generic-password detector false-positives on LDAP admin bind credentials
|
||||||
|
# being READ from runtime config (sso-secrets.js / /config/site.json) — e.g.
|
||||||
|
# `const x = conf.ldap && conf.ldap.bindPassword` in the multi-site join flow.
|
||||||
|
# That is the correct pattern (never a hardcoded secret); ignore the variable
|
||||||
|
# reference, not the actual value.
|
||||||
|
version: 2
|
||||||
|
ignore:
|
||||||
|
- name: generic-password
|
||||||
|
match: |
|
||||||
|
conf\.ldap\s*&&\s*conf\.ldap\.bindPassword
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
name: Build OpenLDAP Base Image
|
||||||
|
|
||||||
|
# Publishes ghcr.io/theta42/openldap-nestgroup, the prebuilt slapd-with-
|
||||||
|
# nestgroup image Dockerfile.openldap's `ldapbuild` stage pulls FROM instead
|
||||||
|
# of compiling from source on every build (see Dockerfile.openldap-builder
|
||||||
|
# for why, and the ~5 minute + git.openldap.org-dependent cost it replaces).
|
||||||
|
#
|
||||||
|
# Runs only when the builder Dockerfile changes -- bumping OPENLDAP_COMMIT in
|
||||||
|
# it is the only reason this image should ever need rebuilding -- or on
|
||||||
|
# manual dispatch.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'Dockerfile.openldap-builder'
|
||||||
|
- '.github/workflows/build-openldap-image.yml'
|
||||||
|
workflow_dispatch: {}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Single source of truth for the tag: the ARG default in the Dockerfile
|
||||||
|
# itself, not a value duplicated into this workflow.
|
||||||
|
- name: Resolve pinned OpenLDAP commit
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
commit=$(grep -oP '^ARG OPENLDAP_COMMIT=\K[0-9a-f]+' Dockerfile.openldap-builder)
|
||||||
|
if [ -z "$commit" ]; then
|
||||||
|
echo "::error::Could not resolve OPENLDAP_COMMIT from Dockerfile.openldap-builder"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "commit=$commit" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Log in to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v2
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push
|
||||||
|
uses: docker/build-push-action@v4
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile.openldap-builder
|
||||||
|
build-args: |
|
||||||
|
OPENLDAP_COMMIT=${{ steps.commit.outputs.commit }}
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
ghcr.io/theta42/openldap-nestgroup:${{ steps.commit.outputs.commit }}
|
||||||
|
ghcr.io/theta42/openldap-nestgroup:latest
|
||||||
@@ -91,6 +91,12 @@ secrets.js
|
|||||||
# they must never be committed. The empty *.example templates ARE tracked.
|
# they must never be committed. The empty *.example templates ARE tracked.
|
||||||
config/*-secrets.js
|
config/*-secrets.js
|
||||||
|
|
||||||
|
# Default sqlite ORM storage (nodejs/models/index.js falls back to this path
|
||||||
|
# when no external DB is configured via conf.orm) -- live runtime data, not a
|
||||||
|
# fixture. Was committed by mistake across many prior releases. NB: this is
|
||||||
|
# nodejs/config/, distinct from the root ./config/ secrets dir above.
|
||||||
|
nodejs/config/*.sqlite
|
||||||
|
|
||||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
docs/_site
|
||||||
|
|
||||||
|
|||||||
@@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con
|
|||||||
|
|
||||||
**Response:** `{ "success": true }`
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Subtype Driver Operations Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/directory-admin/resources`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission.
|
||||||
|
|
||||||
|
### Get Subtype Driver Metrics
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-metrics`**
|
||||||
|
|
||||||
|
Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"metrics": {
|
||||||
|
"status": "online",
|
||||||
|
"driver": "database",
|
||||||
|
"subType": "redis",
|
||||||
|
"redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Execute Subtype Driver Action
|
||||||
|
|
||||||
|
**`POST /api/directory-admin/resources/:id/driver-action`**
|
||||||
|
|
||||||
|
Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "restart",
|
||||||
|
"params": { "serviceName": "emby-server" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"result": { "status": "ok", "driver": "docker_socket", "action": "restart" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get Subtype Driver Logs
|
||||||
|
|
||||||
|
**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`**
|
||||||
|
|
||||||
|
Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"resourceId": "res-id",
|
||||||
|
"logs": "[docker logs --tail 100 emby-server]\nContainer initialized..."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
@@ -1,9 +1,453 @@
|
|||||||
|
# v2.8.0 - 2026-08-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Promotion no longer orphans the demoted old master's LDAP replication.** Neither `/site-promote` nor `/demote` touched `SiteSpoke` -- the demoted old master got a fresh join key but no `SiteSpoke` entry on the new master (no `ldapServerId`, invisible to the peer list), and structurally could never self-heal via `/join` (refuses re-join for a node that's already a spoke). `/demote` now registers itself with the new master immediately, deriving its own endpoint from `stack.selfUrl`/`stack.ssoHost`. `/site-promote`'s response also now surfaces that the promoted node's own OpenLDAP ServerID needs a `setup.sh` re-run to actually apply.
|
||||||
|
- **The Directory's site slug and the multi-site replication identity are unified.** These were two unrelated values that happened to share a name -- a deployment could show a real site name in the Directory catalog and the literal `site-default` fallback on the Multi-Site modal for the same node. `POST /resources` now syncs `site_config`'s `siteSlug` to match the moment this node's own site Resource is first created, for a still-default master only.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **LDAP replication status + per-spoke detail on the Multi-Site modal.** New `utils/ldap_replication.js`'s `currentSlapdServerId()` reads the actual running ServerID from this node's own `slapd.conf` -- distinct from what the API currently advertises, which can genuinely disagree right after a promotion or a new spoke joining. `GET /directory-admin/site-status` now surfaces both plus a `stale` flag and a full spokes list (endpoint, assigned `ldapServerId`, relay path), not just an aggregate count.
|
||||||
|
|
||||||
|
# v2.7.0 - 2026-08-11
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **OpenLDAP N-way multi-master replication auto-config.** `SiteSpoke.ldapServerId` is now auto-assigned at registration (next free from 2 upward, 1 reserved for the master -- same pattern as jump-host's mesh index), and each site's LDAP URL is derived from its already-known HTTP(S) endpoint rather than a separately-configured field. New `utils/ldap_replication.js`, `GET /api/site/ldap-peers` (spoke-facing, Bearer site join key) and `GET /directory-admin/ldap-replication-config` (master-local). Operators no longer hand-maintain `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a `theta-suite`-joined cluster (see `theta-suite`'s `bootstrap/site-ldap-register.js`). Verified against real running containers (`docker-compose.multisite-e2e.yml`).
|
||||||
|
|
||||||
|
# v2.6.0 - 2026-08-11
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Duplicate access/admin groups on repeated resource promotion.** Three independent copies of the same bug (`routes/discovery.js`'s `POST /discovery/promote/:slug` -- the actual "Promote" button in the UI -- and `services/discovery_reconciler.js`'s `autoPromote` path both called `ResourceGroup.create()` directly with no existence check, unlike `routes/api_directory_admin.js`'s own `ensureResourceGroup`, which already carried a comment describing this exact bug). A resource promoted more than once (a retried click, or the same LXC discovered from multiple Proxmox cluster nodes) silently accumulated duplicate rows every time. Consolidated into `ResourceGroup.ensure()` on the model, used everywhere.
|
||||||
|
- **`GET /api/directory-admin/resources` ran a full LDAP group self-heal fan-out on every single list** (`ensureSiteGroups` per site + `provisionResourceGroups` per resource, each several sequential LDAP round-trips), unconditionally -- confirmed as the actual bottleneck once a directory has more than a handful of resources, not data volume. Moved healing to where resources actually change instead (`POST`/`PUT /resources`, `POST /discovery/promote/:slug` -- `PUT` had none at all before this), and added `POST /resources/heal-groups` as an explicit on-demand equivalent for backfilling a directory seeded before this change.
|
||||||
|
- **An nmap discovery scan that completed successfully could be reported as a failed run with zero hosts found.** `node-nmap` (the vendored library) treats any stderr output from the nmap binary as fatal -- including nmap's own harmless RTT-calibration warning ("RTTVAR has grown to over N seconds..."), which it prints *during* a scan that goes on to complete normally, discarding valid results already sitting in the library's `rawData`. Our plugin now recognizes this specific benign message and manually completes the scan from the data that's already there; any other error still rejects as before.
|
||||||
|
- **The Multi-Site modal's "Theta Gateways" count was measuring the wrong subsystem.** It counted this app's own unrelated WireGuard roaming-client/exit-node Resources, not jump-host's actual gateway-to-gateway mesh registry. New `utils/jump_client.js` (same self-service-token pattern as `utils/proxy_client.js`) queries jump-host's real `GET /api/mesh/gateways`, reporting a distinct "unknown" state instead of a misleading 0 when the integration isn't configured. Also added help links to the published multi-site/mesh docs on the modal.
|
||||||
|
|
||||||
|
# v2.5.0 - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **No-inbound relay automation.** A spoke with no public IP of its own can now register as such (`noInbound`/`meshIp`/`publicHost` on `POST /api/site/spokes`, forwarded through `POST /api/site/join` for the real operator join flow), and the master auto-creates/updates the relay route on its own `theta-proxy` via `utils/proxy_client.js` — a new self-service `prx_...` API token client, reusing `theta-proxy`'s existing token system rather than inventing a new credential type. Verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed).
|
||||||
|
- **Replication traffic prefers the mesh.** `utils/site_replicate.js`'s fire-and-forget resync push now tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) — cross-component routing over the gateway-to-gateway WireGuard mesh instead of the open internet, for any spoke that's registered one.
|
||||||
|
- `POST /api/site/join` surfaces the resulting relay status in its response (`relay.note`), and `theta-suite`'s bootstrap flow (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`, `bootstrap/site-relay-register.js`) drives all of this from the real operator-facing setup script, not just the API.
|
||||||
|
|
||||||
|
# v2.4.0 - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Live catalog replication.** A spoke now stays in sync after joining instead of only getting a one-time snapshot: it registers its own endpoint with the master at join time (`POST /api/site/spokes`, Bearer the site join key), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, concurrently — one unreachable spoke never blocks or delays delivery to another. The spoke's `POST /api/site/resync` handler re-runs the same tested export-pull-and-import path used at join time rather than applying a partial diff.
|
||||||
|
- **Identical-directory agent-signing key.** `POST /api/site/export` now best-effort includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on both join and every resync, so any site's `sso-manager-node` can validly sign a command for any agent enrolled at any other site (a deliberate blast-radius tradeoff for this deployment's small, trusted scale — see `theta-suite`'s `docs/MULTI_SITE_SPEC.md` §2).
|
||||||
|
- **Coordinated master promotion.** `POST /api/directory-admin/site-promote` now demotes the previous master as part of the same action (mints it a fresh join key, calls its new `POST /api/site/demote`) instead of leaving a manual two-step gap where two nodes could both believe they're master. Best-effort: an unreachable old master (the WAN-outage scenario this control exists for) never blocks the local promotion — the response's `handoff` field reports what happened.
|
||||||
|
- **Master Site modal UI**: new "Live Replication" (spoke) / "Registered Spokes" (master) status rows; the join form gained a "this site's own reachable URL" field (prefilled from the browser origin) wired to the `selfUrl` the join API already supported but the UI never sent — a UI-driven join previously never registered for live replication, only the `setup.sh` bootstrap path did; the promote button's success toast now reports the actual handoff result.
|
||||||
|
- New `nodejs/models/site_spoke.js` (registered spokes + their push tokens) and `docker-compose.multisite-e2e.yml` + `test/multisite_join_e2e.js` (real two-container master+spoke regression test covering join, live replication, promotion, and demotion end to end).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`site-promote`'s god_admin check was dead on arrival.** It read `req.user.groups`, a field nothing in the codebase ever populates (every other admin gate resolves membership live via `permission.byGroup()`/`Group.list(user.dn)`, which also handles nested-group membership) — the check silently evaluated to an empty array on every request, so promotion returned 403 for every user, including a real god_admin, since it shipped in v2.0.0. Only surfaced by the live e2e test, not by inspection.
|
||||||
|
- **The read-only write-gate blocked `site-promote` on a spoke before its handler could run** — the one mutating request a spoke must be able to make to itself. Exempted `/site-promote` from the gate.
|
||||||
|
- **`GET /api/site/config` was returning `masterJoinKey` and `replicationPushToken`** — live credentials — directly in the JSON response to any admin session. Replaced with boolean derivatives (`hasMasterJoinKey`, `liveReplication`).
|
||||||
|
|
||||||
|
# v2.3.0 - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-site join — UI + enforcement** (completes the join layer started in v2.2.0):
|
||||||
|
- **Master Site modal**: a fresh install (no users beyond the bootstrap admin, no agents) gets a **"Join an Existing Site"** form (master URL + site join key); a master gets a **Site Join Keys** manager (mint/revoke/list, key shown once, copy button); **WAN Sync Health** now reflects a live probe of the master.
|
||||||
|
- `POST /api/site/ping` (Bearer site-join-key): lightweight master reachability probe for WAN health.
|
||||||
|
- **Spoke read-only**: directory-write routes (resources, edges, groups, secrets, grants, driver actions, discovery merges) return `403` pointing at the master once a node is a spoke.
|
||||||
|
- **Fresh-install guard**: `/api/site/join` refuses unless the directory has no users beyond the admin and no enrolled agents (`siteIsFresh`); `site-status` exposes `canJoin` so the UI only offers join when it's actually allowed.
|
||||||
|
- The spoke persists the join key (`masterJoinKey`) in `/config/site.json` for WAN health + future write-proxy.
|
||||||
|
- **Unit tests**: `siteIsFresh` cases (admin-only, second user, enrolled agent, service accounts ignored).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Branch-protection check name**: the lint job that `master` requires is literally named "Syntax check bootstrap.js"; the multi-site bootstrap script is checked by that same job.
|
||||||
|
|
||||||
|
# v2.2.0 - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-site join — server endpoints.** A spoke can join an existing master directory:
|
||||||
|
- **Site join keys** (`stj_…`): mint/revoke/delete under `/api/site/join-keys` (hashed at rest, shown once — same model as agent join keys).
|
||||||
|
- `POST /api/site/export` (master, Bearer site-join-key, no admin session): returns the LDAP tree (`slapcat` LDIF) + resource catalog + siteSlug + baseDn.
|
||||||
|
- `POST /api/site/join` (spoke, admin): `{ masterUrl, joinKey }` pulls the master export, imports resources (upsert by slug) + LDAP (`ldapadd -c`), and persists the spoke role. Refused if already a spoke.
|
||||||
|
- **Persisted site role**: `isMaster`/`masterUrl`/`siteSlug` now live in `/config/site.json` (env seeds defaults) instead of Node memory, so a restart no longer silently reverts a spoke to master. `site-status`/`site-promote` read/write it.
|
||||||
|
- Docs: `docs/site-join.md` (flow, endpoints, planned `setup.env` vars) registered in the docs router.
|
||||||
|
- The UI + `setup.sh` wiring for join is the next layer; this pass is server-only.
|
||||||
|
- **Unit tests** for the join helpers (`tests/site_join.test.js`) and persisted config (`tests/site_config.test.js`) — pure logic, in-memory stubs, added to `npm test`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Multi-site modal text was mojibake.** The Master/Spoke emojis (👑/⚡) in `views/directory.ejs` were UTF-8 that had been round-tripped through cp1252; restored. All other views verified byte-accurate clean.
|
||||||
|
|
||||||
|
# v2.1.1 - 2026-08-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **"Master Site" button errored with `app.modal.show is not a function`.** The multi-site status modal used the legacy `app.modal.show()` signature; the app exposes `app.modal.open({ title, bodyHtml, size })`. The site-status request itself worked — only the rendering call was wrong.
|
||||||
|
- **Agents with no discovery yet showed a fake `v2.0.0`.** Three hardcoded fallbacks now report `unknown` instead, so a host whose agent hasn't connected isn't presented as an old version.
|
||||||
|
|
||||||
|
# v2.1.0 - 2026-08-10
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Windows install commands in the Install Agent modal.** The Directory → Install Agent modal now emits PowerShell one-liners alongside the bash ones for the join-key, pre-register, and custom-config flows. Each downloads the fully-offline theta-agent `setup.exe` from its GitHub release and passes the same values the bash flow uses (`/SERVER_URL`, `/JOIN_KEY`, `/AUTH_TOKEN`, `/PUBLIC_KEY`, or `/B64_CONFIG`), so a Windows host enrolls with the same one-command flow as Linux. Complements the theta-agent v2.1.0 Windows release.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- **Large binaries from `nodejs/public/resources/theta-agent/`.** The agent/tray/helper/setup binaries are now built on GitHub Actions and attached to the theta-agent GitHub release as artifacts; `install.sh` and the modal download them from `releases/latest/download/`. Nothing binary lives in this repo anymore (the small `install.sh` bootstrap script remains).
|
||||||
|
|
||||||
|
# v2.0.4 - 2026-08-09
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`Dockerfile.openldap` no longer compiles OpenLDAP from source.** Its `ldapbuild` stage now pulls `ghcr.io/theta42/openldap-nestgroup:<pinned commit>` (built once by `.github/workflows/build-openldap-image.yml` from the new `Dockerfile.openldap-builder`) instead of cloning `git.openldap.org` and running `./configure && make` on every build. Cuts ~5 minutes off every build of this Dockerfile, including 3x per CI run's test matrix, and removes the runtime dependency on that mirror being up (it 502'd twice tonight, blocking two PRs). Verified locally end-to-end before merging: built the app image against the published base, ran it, confirmed slapd boots healthy with the nestgroup overlay loaded and the correct pinned commit.
|
||||||
|
|
||||||
|
# v2.0.3 - 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Directory tab showed unpromoted discoveries.** `GET /api/directory-admin/resources` unconditionally admitted every `kind: 'host'` resource, and every discovery plugin (UniFi, Proxmox, nmap) creates its finds as `kind: 'host'` — so unchecking "Auto-promote to Directory" on a plugin never actually kept undiscovered/unpromoted devices out of the Directory tab, only out of the LDAP-group auto-provisioning. Now only `site` resources are unconditionally shown; anything else that discovery ever touched requires `metadata.managed === true` (set by promotion, an agent, or merging into an already-managed resource).
|
||||||
|
- **`GET /api/directory-admin/site-status` 500'd.** Queried `Resource.list({ where: { subType: 'wireguard' } })`, but `subType` only ever lives in `metadata.subType` (every driver/discovery plugin reads it that way) — never a top-level DB column, so SQLite raised `no such column: Resource.subType`. Filters in JS over `metadata.subType` now.
|
||||||
|
- **Discovered Inventory had no way to review ignored devices.** Added a "Show ignored" toggle (off by default) to the tab, so `metadata.ignored === true` rows stay hidden from routine triage but remain reachable.
|
||||||
|
|
||||||
|
### Chore
|
||||||
|
- **Untracked `nodejs/config/inventory.sqlite`.** It's the app's default runtime DB (`nodejs/models/index.js` falls back to this path when no external DB is configured), not a fixture — it had been committed by mistake across 13 prior releases, churning on every local run. Removed from tracking and gitignored.
|
||||||
|
|
||||||
|
# v2.0.2 - 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **README rebranding & standalone-install cleanup.** Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (`theta42.github.io/sso-manager-node/`); documentation and secrets links now point at the unified `theta42.github.io/theta-suite/` site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
|
||||||
|
|
||||||
|
# v2.0.1 - 2026-08-09
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **OpenBao / OpenBoa Container Exclusion**: Skip discovery of internal secret management/renewer containers so they don't populate resources catalog.
|
||||||
|
- **Agent Version API Collection**: Added `version` tracking to Agent model and discovery handlers to record and report agent version dynamically.
|
||||||
|
- **Console Log Cleanup**: Removed leftover `console.log` debug statements in frontend assets.
|
||||||
|
- **Resource Save & User Cache Invalidation**: Fixed metadata merge on resource updates to prevent losing system fields, and added User cache clear to propagate user/group edits instantly.
|
||||||
|
- **Site Status 500 Fix**: Replaced invalid ORM `Resource.findAll()` call with `Resource.list()`.
|
||||||
|
- **Secret Filtering**: Fixed the "With Secrets" filter checkbox by ensuring `hasSecret` / `secretKeys` states are written to resource metadata and checked by EJS views.
|
||||||
|
- **Auto-Group Spawning Prevention**: Set default `autoPromote` to false in UniFi, Docker, Proxmox, and Nmap plugins and restricted auto group creation to managed resources to prevent duplicate LDAP group generation.
|
||||||
|
|
||||||
|
# v2.0.0 - 2026-08-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Multi-Site Master Architecture.** Multi-Site Master badge, `/api/directory-admin/site-status` API, and Master site promotion UI.
|
||||||
|
- **Full Telemetry Dashboard Cards.** Rendered active `logged_users`, physical partitions, host details, and desktop session/power controls (Lock, Display Off, Log Out, Sleep Host).
|
||||||
|
- **Theta Directory Rebranding.** Rebranded SSO Manager UI and documentation to Theta Directory.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Agent Action Parameter Resolution.** Standardized top-level and nested parameter parsing for agent driver actions (`api_directory_admin.js`).
|
||||||
|
|
||||||
|
# v1.33.0 - 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Directory Key Badges & Secret Filtering.** Added a gold `🔑 Secret` badge next to resources with stored OpenBao secrets and a `With Secrets` filter checkbox to filter the directory tree by secret presence.
|
||||||
|
- **Kind-Specific Resource Creation Modals.** Added dedicated `openAddSiteModal()`, `openAddHostModal()`, and `openAddServiceModal()` modal handlers for Site, Host, and Service resources.
|
||||||
|
- **Top Toolbar Reorganization.** Updated top tree button to **"+ Add Site"** and removed legacy `Plumbing` slider.
|
||||||
|
- **Optional Child Secret Key Name on Inheritance.** Made key name optional when inheriting parent secrets — automatically defaulting to the original parent secret key name if left blank.
|
||||||
|
- **Discovered Inventory Merge & Ignore Actions.** Added `Merge` (merge IP/interfaces/OS metadata into target resource) and `Ignore` (dismiss discovered item) endpoints (`/api/directory-admin/discovered/merge` & `/api/directory-admin/discovered/ignore`) and table action buttons.
|
||||||
|
- **Agent Tab Telemetry & Desktop Controls.** Rendered Agent Binary Version badge (`v1.8.0`), all physical disks and filesystems table, Active Logged-in Users card, and Desktop Session & Power Operations card (Lock, Display Off, Log Out, Sleep Host).
|
||||||
|
|
||||||
|
# v1.32.0 - 2026-08-08
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||||
|
- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints.
|
||||||
|
- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT:<parentSlug>:<parentKey>`) without exposing sibling directory secrets.
|
||||||
|
- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item.
|
||||||
|
- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources/<slug>/conf` with per-key merging and deletion.
|
||||||
|
- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths.
|
||||||
|
- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution.
|
||||||
|
|
||||||
|
# v1.31.0 - 2026-08-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances.
|
||||||
|
- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`).
|
||||||
|
- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (8–128 chars) populating input fields with security notices.
|
||||||
|
- **Multi-Level Secret Inheritance.** Dynamic secret resolution across any depth of the resource tree (`Services / Apps -> Hosts / Nodes -> Global Sites`).
|
||||||
|
- **Non-Blocking UI Confirmations.** Replaced browser blocking dialogs with async `app.messages.confirm()` banners.
|
||||||
|
- **UI Directory Layout Improvements.** Fixed Directory table resource name and badge order for enhanced readability.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **SSSD `sshPublicKey` Mapping.** Included `ldap_user_ssh_public_key = sshPublicKey` in generated agent `sssd.conf` template.
|
||||||
|
|
||||||
|
# Unreleased — LDAP-over-HTTPS API + agent LDAP byte-pump relay
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **`POST /api/v1/ldap/bind` and `POST /api/v1/ldap/search`** — an LDAP-over-HTTPS
|
||||||
|
API (DESIGN.md §3). A client stops speaking LDAP and instead does an HTTPS call
|
||||||
|
to the SSO, which performs the real bind/search against its own OpenLDAP. This
|
||||||
|
kills the hostname / cross-network / LDAPS-cert-chain pain. Caller auth is a
|
||||||
|
Bearer token: an agent token or a self-service API token (PAT). `/search` is
|
||||||
|
restricted to agent callers (the SSSD user/group-resolution use case) and runs
|
||||||
|
under the admin bind — see DESIGN.md §9.5 for the scoped-service-account
|
||||||
|
follow-up.
|
||||||
|
- **LDAP byte-pump relay** (`utils/ldap_tunnel.js`) — the SSO relays raw LDAP
|
||||||
|
bytes from an agent's local socket into its real OpenLDAP and pipes the
|
||||||
|
response back, over the existing agent WSS channel (`ldap_tunnel` messages).
|
||||||
|
The SSO does not parse LDAP; it is a transparent socket relay. See DESIGN.md §4.
|
||||||
|
- **`POST /api/v1/agent/secrets`** — an agent fetches its own node-scoped OpenBao
|
||||||
|
secrets (DESIGN.md §5). The agent may only read under `secret/data/nodes/<id>/*`;
|
||||||
|
the SSO fetches with its own OpenBao access, so the agent never holds a Vault
|
||||||
|
token. Agent-token authed (not admin-gated).
|
||||||
|
- **`iam_apply` command** — the SSO pushes node-scoped IAM config (sudo rules,
|
||||||
|
SSH keys, access control, revocation) to an agent as a signed high-risk
|
||||||
|
command (DESIGN.md §6). Added to `HIGH_RISK_COMMANDS`.
|
||||||
|
- **Agent capabilities in the Directory UI** — the agent reports its enabled
|
||||||
|
capabilities in its `discovery` frame; the SSO stores them and the host's
|
||||||
|
Metrics tab renders them as green/gray badges, so an operator can see at a
|
||||||
|
glance what each agent is allowed to do.
|
||||||
|
- **`GET /api/agent/join-keys/:id/agents`** — which hosts enrolled through a
|
||||||
|
given join key. Matches on the trace `Agent.enroll` already leaves in
|
||||||
|
`description` ("Self-enrolled with join key `<prefix>`") rather than a stored
|
||||||
|
relation.
|
||||||
|
- **Join key management in the Install Agent modal** — a table (label, prefix,
|
||||||
|
created date, hosts joined, status) alongside the existing mint/select
|
||||||
|
dropdown, with **Revoke** and **Delete** actions and a click-through to see
|
||||||
|
which hosts joined via a given key. Previously these were API-only. Revoke
|
||||||
|
and Delete confirm inline within the row ("Revoke? Yes/No") rather than a
|
||||||
|
blocking native `confirm()` (freezes the whole tab) or the shared
|
||||||
|
`app.messages.confirm()` banner (a single `.actionMessage` shared by the
|
||||||
|
whole card, so a second click before the first resolves leaves a dangling
|
||||||
|
`$('body').one('click', ...)` handler from the first call and desyncs which
|
||||||
|
row the banner is actually confirming for).
|
||||||
|
|
||||||
|
# v1.30.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
|
||||||
|
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
|
||||||
|
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
|
||||||
|
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
|
||||||
|
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
|
||||||
|
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
|
||||||
|
|
||||||
|
# v1.30.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
|
||||||
|
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
|
||||||
|
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||||
|
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
|
||||||
|
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
|
||||||
|
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
|
||||||
|
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
|
||||||
|
|
||||||
|
# v1.30.0
|
||||||
|
|
||||||
|
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||||
|
|
||||||
|
### 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
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
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`.
|
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
|
## [1.17.1] - 2026-08-01
|
||||||
|
|
||||||
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
||||||
@@ -572,3 +1016,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.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.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.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).
|
||||||
|
|||||||
@@ -22,6 +22,12 @@
|
|||||||
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
||||||
# the host where the submodule resolves correctly.
|
# the host where the submodule resolves correctly.
|
||||||
ARG GIT_COMMIT=""
|
ARG GIT_COMMIT=""
|
||||||
|
# Pinned OpenLDAP commit this build expects -- must match the tag of the
|
||||||
|
# published builder image below. Bumping it is a two-step change: rebuild +
|
||||||
|
# push ghcr.io/theta42/openldap-nestgroup:<new commit> from
|
||||||
|
# Dockerfile.openldap-builder (see that file), then update this default (or
|
||||||
|
# pass --build-arg OPENLDAP_COMMIT=<new commit> here).
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
FROM node:20-alpine AS gitinfo
|
FROM node:20-alpine AS gitinfo
|
||||||
ARG GIT_COMMIT
|
ARG GIT_COMMIT
|
||||||
WORKDIR /repo
|
WORKDIR /repo
|
||||||
@@ -33,9 +39,9 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
|||||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
# ── OpenLDAP, prebuilt ────────────────────────────────────────────────────────
|
||||||
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
# We run slapd from OpenLDAP master rather than Alpine's packaged release, for
|
||||||
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
# exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||||
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||||
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||||
# nestgroup is not among them -- and the alternative is resolving nesting
|
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||||
@@ -46,64 +52,14 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
|||||||
# 0.9.x used by 2.6.x cannot read, and vice versa
|
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||||
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||||
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||||
FROM node:20-alpine AS ldapbuild
|
|
||||||
|
|
||||||
# groff is not optional despite producing nothing we ship: the build descends
|
|
||||||
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
|
||||||
# provides. Without it the whole `make` fails at the man-page stage
|
|
||||||
# ("soelim: not found") long after slapd itself has compiled fine.
|
|
||||||
RUN apk add --no-cache \
|
|
||||||
build-base autoconf automake libtool \
|
|
||||||
openssl-dev cyrus-sasl-dev \
|
|
||||||
git make pkgconf util-linux-dev groff
|
|
||||||
|
|
||||||
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
|
||||||
# whole lab authenticates against; an unpinned `master` would mean every image
|
|
||||||
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
|
||||||
# master would take out logins with no way to tell what changed.
|
|
||||||
#
|
#
|
||||||
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
# The from-source compile (~5 min, and a dependency on git.openldap.org being
|
||||||
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
# reachable) used to happen right here, on every build of this Dockerfile --
|
||||||
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
# including 3x per CI run's test matrix. It's now built once, tagged by the
|
||||||
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
# pinned commit above, in Dockerfile.openldap-builder -- see that file for the
|
||||||
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
# actual compile steps and the TODO on dropping from-source entirely once
|
||||||
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
# nestgroup ships in a release.
|
||||||
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
FROM ghcr.io/theta42/openldap-nestgroup:${OPENLDAP_COMMIT} AS ldapbuild
|
||||||
|
|
||||||
WORKDIR /src
|
|
||||||
RUN git init -q . \
|
|
||||||
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
|
||||||
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
|
||||||
&& git checkout -q FETCH_HEAD \
|
|
||||||
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
|
||||||
|
|
||||||
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
|
||||||
# `moduleload`s them individually; nestgroup joins that set.
|
|
||||||
RUN ./configure \
|
|
||||||
--prefix=/opt/openldap \
|
|
||||||
--enable-slapd \
|
|
||||||
--enable-modules \
|
|
||||||
--enable-mdb \
|
|
||||||
--enable-memberof=mod \
|
|
||||||
--enable-refint=mod \
|
|
||||||
--enable-ppolicy=mod \
|
|
||||||
--enable-dynlist=mod \
|
|
||||||
--enable-nestgroup=mod \
|
|
||||||
--enable-syncprov=mod \
|
|
||||||
--enable-auditlog=mod \
|
|
||||||
--with-tls=openssl \
|
|
||||||
--with-cyrus-sasl \
|
|
||||||
&& make depend \
|
|
||||||
&& make -j"$(nproc)" \
|
|
||||||
&& make install
|
|
||||||
|
|
||||||
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
|
||||||
# It lives in contrib and is not covered by the configure flags above, so it is
|
|
||||||
# built separately against the just-built tree -- omitting it would make every
|
|
||||||
# user password unverifiable.
|
|
||||||
RUN cd contrib/slapd-modules/passwd/sha2 \
|
|
||||||
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
|
||||||
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
@@ -122,6 +78,7 @@ RUN apk add --no-cache \
|
|||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
@@ -162,6 +119,7 @@ COPY nodejs/app.js ./
|
|||||||
COPY nodejs/bin ./bin
|
COPY nodejs/bin ./bin
|
||||||
COPY nodejs/conf ./conf
|
COPY nodejs/conf ./conf
|
||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
COPY nodejs/routes ./routes
|
COPY nodejs/routes ./routes
|
||||||
@@ -181,9 +139,12 @@ COPY tos.md /tos.md
|
|||||||
# without internet access. Same flattened-path convention as tos.md above.
|
# without internet access. Same flattened-path convention as tos.md above.
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.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
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# OpenLDAP-with-nestgroup builder, published to
|
||||||
|
# ghcr.io/theta42/openldap-nestgroup:<OPENLDAP_COMMIT short hash>.
|
||||||
|
#
|
||||||
|
# Extracted out of Dockerfile.openldap's `ldapbuild` stage so the ~5 minute
|
||||||
|
# from-source compile (which also depends on git.openldap.org being up)
|
||||||
|
# happens once, here, instead of on every `docker build` of the app image --
|
||||||
|
# including every CI run's 3-way test matrix. Dockerfile.openldap's ldapbuild
|
||||||
|
# stage becomes `FROM ghcr.io/theta42/openldap-nestgroup:<commit>` and the
|
||||||
|
# rest of that file (the COPY --from=ldapbuild lines) is unchanged, since
|
||||||
|
# COPY --from also accepts an external image, not just a local stage name.
|
||||||
|
#
|
||||||
|
# Bumping OPENLDAP_COMMIT is a two-step change: update the ARG below, push
|
||||||
|
# (the build-openldap-image workflow rebuilds+republishes the tag on changes
|
||||||
|
# to this file), then update the matching FROM line in Dockerfile.openldap.
|
||||||
|
#
|
||||||
|
# See Dockerfile.openldap's own "OpenLDAP from source" comment for *why*
|
||||||
|
# from-source at all (the nestgroup overlay, ITS#10161) and the LMDB format
|
||||||
|
# note (master's 1.0.0 vs 2.6.x's 0.9.x).
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
|
# groff is not optional despite producing nothing we ship: the build descends
|
||||||
|
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||||
|
# provides. Without it the whole `make` fails at the man-page stage
|
||||||
|
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base autoconf automake libtool \
|
||||||
|
openssl-dev cyrus-sasl-dev \
|
||||||
|
git make pkgconf util-linux-dev groff
|
||||||
|
|
||||||
|
# Pinned to an exact commit, not a branch tip -- see Dockerfile.openldap for
|
||||||
|
# why (this is the directory server the whole lab authenticates against).
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git init -q . \
|
||||||
|
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||||
|
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||||
|
&& git checkout -q FETCH_HEAD \
|
||||||
|
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||||
|
|
||||||
|
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||||
|
# `moduleload`s them individually; nestgroup joins that set.
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/opt/openldap \
|
||||||
|
--enable-slapd \
|
||||||
|
--enable-modules \
|
||||||
|
--enable-mdb \
|
||||||
|
--enable-memberof=mod \
|
||||||
|
--enable-refint=mod \
|
||||||
|
--enable-ppolicy=mod \
|
||||||
|
--enable-dynlist=mod \
|
||||||
|
--enable-nestgroup=mod \
|
||||||
|
--enable-syncprov=mod \
|
||||||
|
--enable-auditlog=mod \
|
||||||
|
--with-tls=openssl \
|
||||||
|
--with-cyrus-sasl \
|
||||||
|
&& make depend \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||||
|
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||||
|
# built separately against the just-built tree -- omitting it would make every
|
||||||
|
# user password unverifiable.
|
||||||
|
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||||
|
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||||
|
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||||
|
|
||||||
|
# Pure artifact holder -- no shell, no package manager, nothing but the
|
||||||
|
# compiled tree. Dockerfile.openldap's COPY --from=ldapbuild only ever reads
|
||||||
|
# files, never RUNs anything in this stage, so scratch is sufficient and
|
||||||
|
# keeps the published image (and every pull of it) as small as possible.
|
||||||
|
FROM scratch
|
||||||
|
COPY --from=build /opt/openldap /opt/openldap
|
||||||
|
COPY --from=build /opt-openldap-commit.txt /opt-openldap-commit.txt
|
||||||
@@ -20,8 +20,13 @@ COPY nodejs/app.js ./
|
|||||||
COPY nodejs/bin ./bin
|
COPY nodejs/bin ./bin
|
||||||
COPY nodejs/conf ./conf
|
COPY nodejs/conf ./conf
|
||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
|
COPY nodejs/drivers ./drivers
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
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/routes ./routes
|
||||||
COPY nodejs/services ./services
|
COPY nodejs/services ./services
|
||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
@@ -36,15 +41,17 @@ RUN mkdir -p /app/config
|
|||||||
COPY tos.md /tos.md
|
COPY tos.md /tos.md
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
COPY docs /docs
|
|
||||||
|
|
||||||
# Seed script and utility
|
# Seed script and utility
|
||||||
COPY test_seed.js ./test_seed.js
|
COPY test_seed.js ./test_seed.js
|
||||||
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
||||||
RUN chmod +x /usr/local/bin/seed-test-user
|
RUN chmod +x /usr/local/bin/seed-test-user
|
||||||
|
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
|
||||||
|
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
|
||||||
|
# End-to-end multi-site join test client (docker-compose.multisite-e2e.yml)
|
||||||
|
COPY test/multisite_join_e2e.js ./test/multisite_join_e2e.js
|
||||||
|
|
||||||
# Default command: seed the test user, then run the test suite
|
# Default command: seed the test user, then run the test suite
|
||||||
CMD ["sh", "-c", "seed-test-user && npm test"]
|
CMD ["sh", "-c", "seed-test-user && npm test"]
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
# SSO Manager
|
# Theta Directory
|
||||||
|
|
||||||
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
A production-grade, self-hosted **OpenID Connect provider**, **Resource Directory & IAM Engine**, and bundled **OpenLDAP directory** with a modern web console — designed for home-labs and enterprise infrastructure that demand total sovereignty over their identity, secrets, and resource catalog.
|
||||||
and a web management UI — for home labs and small businesses that want their own
|
|
||||||
identity provider instead of a hosted one.
|
|
||||||
|
|
||||||
It gives you one place to manage your users and groups, one login (OIDC) that
|
It provides a single source of truth for identity (OIDC + LDAP), host/service directory inventory, access control groups, and secrets management running entirely on your own hardware without third-party cloud lock-in.
|
||||||
your modern apps can use, and one LDAP directory your older or odder apps can
|
|
||||||
bind to directly. Everything runs on your own hardware; there is no
|
|
||||||
phone-home, no hosted control plane, and no per-user pricing.
|
|
||||||
|
|
||||||
> Setting up the whole stack (this SSO + the [theta42/proxy](https://github.com/theta42/proxy)
|
Theta Directory is deployed as part of [Theta Suite](https://github.com/theta42/theta-suite),
|
||||||
> in front of it) with one command? Skip to [theta-env](https://github.com/theta42/theta-env)
|
alongside [Theta Proxy](https://github.com/theta42/proxy) and
|
||||||
> — its `setup.sh` wires the two together and generates the config for you.
|
[Theta Gateway](https://github.com/theta42/jump-host) — it isn't installed or
|
||||||
|
run on its own. `./setup.sh` wires the whole stack together automatically.
|
||||||
|
|
||||||
**Documentation:** [https://theta42.github.io/sso-manager-node/](https://theta42.github.io/sso-manager-node/)
|
**Documentation:** [https://theta42.github.io/theta-suite/sso/](https://theta42.github.io/theta-suite/sso/)
|
||||||
|
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
@@ -29,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
| --- |
|
| --- |
|
||||||
| [](docs/images/sites.png) |
|
| [](docs/images/sites.png) |
|
||||||
|
|
||||||
|
| Agent Capabilities & Metrics | Agent Install (Join Key) |
|
||||||
|
| --- | --- |
|
||||||
|
| [](docs/images/agent-capabilities-metrics.png) | [](docs/images/agent-install-join-key.png) |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
||||||
@@ -47,91 +47,11 @@ phone-home, no hosted control plane, and no per-user pricing.
|
|||||||
same directory, so you don't maintain a second user database for them.
|
same directory, so you don't maintain a second user database for them.
|
||||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||||
drive the management API from scripts or CI, scoped to their own permissions.
|
drive the management API from scripts or CI, scoped to their own permissions.
|
||||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows.
|
||||||
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||||
|
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers.
|
||||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
||||||
|
|
||||||
## Why this over the alternatives
|
|
||||||
|
|
||||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
|
||||||
LDAP is either a paid feature, a federation target you have to run separately,
|
|
||||||
or absent. If your stack already has apps that speak LDAP directly (or you just
|
|
||||||
want one real directory as the source of truth), you end up running *two*
|
|
||||||
identity systems and keeping them in sync.
|
|
||||||
|
|
||||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
|
||||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
|
||||||
scope: it is intentionally small and self-hosted, not an enterprise IAM suite
|
|
||||||
— no fancy workflow engine, no hosted SaaS. If you want a lightweight,
|
|
||||||
self-contained identity provider with a real LDAP backend, that is the niche.
|
|
||||||
|
|
||||||
## Quick start
|
|
||||||
|
|
||||||
Three ways to run it, in order of how much it sets up for you:
|
|
||||||
|
|
||||||
### 1. As part of the unified stack (recommended)
|
|
||||||
|
|
||||||
[theta-env](https://github.com/theta42/theta-env) composes this SSO Manager with
|
|
||||||
the [theta42/proxy](https://github.com/theta42/proxy) (an OIDC-protected reverse
|
|
||||||
proxy) and generates all the config from a single `setup.env` — you enter your
|
|
||||||
domain once and it fills in the LDAP DNs, hostnames, OAuth issuer, and random
|
|
||||||
secrets consistently:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone --recursive https://github.com/theta42/theta-env.git
|
|
||||||
cd theta-env
|
|
||||||
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
|
|
||||||
./setup.sh # generates ./config/, builds + bootstraps + starts both
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [theta-env README](https://github.com/theta42/theta-env) for the full
|
|
||||||
first-run flow, DNS/port requirements, and backups.
|
|
||||||
|
|
||||||
### 2. Standalone, in Docker
|
|
||||||
|
|
||||||
The all-in-one image bundles the app, OpenLDAP, and Redis. Copy the example
|
|
||||||
secrets file, fill in your values, and build:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git clone https://github.com/theta42/sso-manager-node.git
|
|
||||||
cd sso-manager-node
|
|
||||||
mkdir -p config && chmod 700 config
|
|
||||||
cp secrets.js.example config/sso-secrets.js
|
|
||||||
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
|
|
||||||
docker compose up -d --build
|
|
||||||
```
|
|
||||||
|
|
||||||
The web UI comes up at `http://localhost:3001`. To kick the tires with no
|
|
||||||
config file at all, the entrypoint falls back to safe defaults
|
|
||||||
(`dc=example,dc=com`, admin password `admin`, an auto-generated JWT) — fine for
|
|
||||||
a local test, not for production.
|
|
||||||
|
|
||||||
Your domain is entered once, as the LDAP base DN (`stack.ldapBaseDn`); the other
|
|
||||||
LDAP DNs and the OAuth issuer derive from it and must stay consistent. See
|
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) for the full config reference, the `app_*` env
|
|
||||||
vars, LDAPS/TLS, and backups.
|
|
||||||
|
|
||||||
### 3. Bare metal on Debian/Ubuntu
|
|
||||||
|
|
||||||
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP —
|
|
||||||
configuring the directory (modules, overlays, schema, the SSO groups) and
|
|
||||||
seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
|
|
||||||
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
|
|
||||||
systemd service:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
|
||||||
```
|
|
||||||
|
|
||||||
That's it — LDAP and the app are both live afterward. Edit
|
|
||||||
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
|
|
||||||
restart the service to customize. It's idempotent and safe to re-run —
|
|
||||||
re-running it updates the app in place (never touching LDAP or the secrets
|
|
||||||
file again) and prints the version you're updating from and to (e.g. `Updated
|
|
||||||
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
|
||||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
|
||||||
|
|
||||||
## Secrets
|
## Secrets
|
||||||
|
|
||||||
Secrets are loaded from **OpenBao** at boot via
|
Secrets are loaded from **OpenBao** at boot via
|
||||||
@@ -152,8 +72,8 @@ writes `secret/sso-manager/conf` through `bao-conf.set`.
|
|||||||
|
|
||||||
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||||
not the authoritative store. For the full architecture, policies, token model,
|
not the authoritative store. For the full architecture, policies, token model,
|
||||||
and rotation procedure, see theta-env's
|
and rotation procedure, see theta-suite's
|
||||||
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
**[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
@@ -165,7 +85,7 @@ and rotation procedure, see theta-env's
|
|||||||
│ HTTP/HTTPS
|
│ HTTP/HTTPS
|
||||||
▼
|
▼
|
||||||
┌────────────────────────┐ ┌─────────────┐
|
┌────────────────────────┐ ┌─────────────┐
|
||||||
│ Express SSO Manager │◄────►│ Redis │
|
│ Theta Directory │◄────►│ Redis │
|
||||||
│ - OIDC provider │ │ - sessions │
|
│ - OIDC provider │ │ - sessions │
|
||||||
│ - web UI (:3001) │ │ - models │
|
│ - web UI (:3001) │ │ - models │
|
||||||
│ - management API │ └─────────────┘
|
│ - management API │ └─────────────┘
|
||||||
@@ -188,29 +108,13 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
|
|||||||
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
|
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
|
||||||
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
|
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
|
||||||
- [API.md](API.md) — the management API.
|
- [API.md](API.md) — the management API.
|
||||||
- [docs/](docs/) (GitHub Pages) — the same content broken into
|
- [docs/](docs/) — the same content broken into
|
||||||
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
|
[OAuth/OIDC](docs/oauth.md) and [LDAP](docs/ldap.md), also published at the
|
||||||
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
|
unified [theta-suite docs site](https://theta42.github.io/theta-suite/sso/).
|
||||||
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
|
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
|
||||||
- All of the above is also readable from the running app itself at `/docs` —
|
- All of the above is also readable from the running app itself at `/docs` —
|
||||||
no internet access required.
|
no internet access required.
|
||||||
|
|
||||||
If you are pointing the app at your own existing LDAP server, see
|
|
||||||
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
|
|
||||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
|
||||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
|
||||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
|
||||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
|
||||||
DEPLOYMENT.md for the full setup.
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd nodejs
|
|
||||||
npm install
|
|
||||||
npm run dev # nodemon auto-reload
|
|
||||||
npm test # jest test suite
|
|
||||||
```
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -367,3 +367,39 @@ Ordered by how much they unblock:
|
|||||||
conventions, not schema changes; the json column already holds them.
|
conventions, not schema changes; the json column already holds them.
|
||||||
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||||
freshness; trivial once surfaced).
|
freshness; trivial once surfaced).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols.
|
||||||
|
|
||||||
|
### Subtype Matrix & Drivers
|
||||||
|
|
||||||
|
| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics |
|
||||||
|
| :--- | :--- | :--- | :--- | :--- |
|
||||||
|
| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState |
|
||||||
|
| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O |
|
||||||
|
| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status |
|
||||||
|
| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels |
|
||||||
|
| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases |
|
||||||
|
| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP |
|
||||||
|
| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions |
|
||||||
|
|
||||||
|
*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).*
|
||||||
|
|
||||||
|
### 4-Tier Driver Resolution Engine
|
||||||
|
1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource.
|
||||||
|
2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver).
|
||||||
|
3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts.
|
||||||
|
|
||||||
|
### Subtype Operations Endpoints
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`)
|
||||||
|
|
||||||
|
## 11. Multi-Site
|
||||||
|
|
||||||
|
This directory can run across multiple sites (one **master** with write authority, any number of **spoke** read-only replicas that stay live-synced after joining), coordinate master promotion, and share the agent-signing key across sites. Full design and operational detail: [`docs/site-join.md`](docs/site-join.md) and, at the suite level, `theta-suite`'s `docs/MULTI_SITE_SPEC.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
||||||
|
#
|
||||||
|
# Spins up OpenLDAP + Redis, a real SSO server (bin/www, so the WSS relay is
|
||||||
|
# live), and a client that simulates the agent: it enrolls one, connects over
|
||||||
|
# WSS, sends a real LDAP bind as raw bytes, and verifies the SSO relays it into
|
||||||
|
# OpenLDAP and pipes the response back.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.e2e.yml up --build --abort-on-container-exit
|
||||||
|
# # exit code 0 = tunnel works; the client prints E2E PASS.
|
||||||
|
|
||||||
|
services:
|
||||||
|
ldap:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=test,dc=local
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=Test SSO
|
||||||
|
command: ["sleep", "infinity"]
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "ldapsearch -x -H ldap://localhost:389 -b '' -s base '(objectClass=*)' >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 20
|
||||||
|
start_period: 5s
|
||||||
|
volumes:
|
||||||
|
- ldap-data:/var/lib/ldap
|
||||||
|
- ldap-certs:/etc/openldap/certs
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "redis-cli", "ping"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 15
|
||||||
|
|
||||||
|
sso:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["node", "bin/www"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- NODE_PORT=3001
|
||||||
|
# Test OpenBao (theta-test-bao) — sso-broker token so the SSO can sign
|
||||||
|
# high-risk agent commands and read node-scoped secrets.
|
||||||
|
- VAULT_ADDR=http://theta-test-bao:8200
|
||||||
|
- VAULT_TOKEN=${VAULT_TOKEN:-}
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["sh", "-c", "seed-test-user && node test/tunnel_e2e.js"]
|
||||||
|
environment:
|
||||||
|
- NODE_ENV=test
|
||||||
|
- SSO_URL=http://sso:3001
|
||||||
|
- app_ldap__url=ldap://ldap:389
|
||||||
|
- app_ldap__bindDN=cn=admin,dc=test,dc=local
|
||||||
|
- app_ldap__bindPassword=secret
|
||||||
|
- app_ldap__userBase=ou=people,dc=test,dc=local
|
||||||
|
- app_ldap__groupBase=ou=groups,dc=test,dc=local
|
||||||
|
- app_redis__redisConf__url=redis://redis:6379
|
||||||
|
- REDIS_URL=redis://redis:6379
|
||||||
|
- app_oauth__jwtSecret=test-jwt-secret-for-testing-only
|
||||||
|
- app_name=Test SSO
|
||||||
|
depends_on:
|
||||||
|
sso:
|
||||||
|
condition: service_started
|
||||||
|
ldap:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
ldap-data:
|
||||||
|
ldap-certs:
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
|
||||||
|
#
|
||||||
|
# Spins up two full all-in-one instances (app + bundled slapd each, like
|
||||||
|
# docker-compose.repl-test.yml) — "master" and "spoke" — plus a client that
|
||||||
|
# drives the actual HTTP API a human/operator would use: mint a site join key
|
||||||
|
# on master, join from spoke, verify the spoke adopted the catalog, went
|
||||||
|
# read-only, and reports live WAN health.
|
||||||
|
#
|
||||||
|
# docker compose -f docker-compose.multisite-e2e.yml up --build --abort-on-container-exit
|
||||||
|
# # exit code 0 = MULTISITE E2E PASS
|
||||||
|
#
|
||||||
|
# slapcat (used by POST /api/site/export) only sees the LDAP data of the
|
||||||
|
# container it runs in, so this MUST use the all-in-one image (master and
|
||||||
|
# spoke each carry their own slapd) — the split ldap+redis+app harness used
|
||||||
|
# by docker-compose.test.yml/e2e.yml won't exercise export/join at all.
|
||||||
|
|
||||||
|
services:
|
||||||
|
master:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
container_name: multisite_e2e_master
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=master,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=E2E Master
|
||||||
|
- app_oauth__jwtSecret=e2e-multisite-master-jwt-secret
|
||||||
|
# POST /demote's self-registration (routes/api_site.js) needs a real
|
||||||
|
# reachable endpoint for this container; stack.selfUrl overrides the
|
||||||
|
# normal https://<stack.ssoHost> derivation, which isn't reachable
|
||||||
|
# here (plain HTTP, no TLS/proxy in front, non-443 port).
|
||||||
|
- app_stack__selfUrl=http://master:3001
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 40
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
spoke:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.openldap
|
||||||
|
container_name: multisite_e2e_spoke
|
||||||
|
environment:
|
||||||
|
- LDAP_BASE_DN=dc=spoke,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
- ORG_NAME=E2E Spoke
|
||||||
|
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
|
||||||
|
- app_stack__selfUrl=http://spoke:3001
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||||
|
interval: 2s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 40
|
||||||
|
start_period: 5s
|
||||||
|
|
||||||
|
client:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.test-runner
|
||||||
|
command: ["sh", "-c", "node test/multisite_join_e2e.js"]
|
||||||
|
environment:
|
||||||
|
- MASTER_URL=http://master:3001
|
||||||
|
- SPOKE_URL=http://spoke:3001
|
||||||
|
- MASTER_LDAP_HOST=master
|
||||||
|
- MASTER_BASE_DN=dc=master,dc=test
|
||||||
|
- SPOKE_LDAP_HOST=spoke
|
||||||
|
- SPOKE_BASE_DN=dc=spoke,dc=test
|
||||||
|
- LDAP_ADMIN_PASS=secret
|
||||||
|
depends_on:
|
||||||
|
master:
|
||||||
|
condition: service_healthy
|
||||||
|
spoke:
|
||||||
|
condition: service_healthy
|
||||||
@@ -355,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# 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
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -366,11 +370,11 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
# Nest app_super_admin into the SSO admin groups, so cross-app super admins
|
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||||
# hold those rights by membership rather than by a special case in app code.
|
# by membership rather than by a special case in app code. This is what makes
|
||||||
# This is what makes the privilege visible to every consumer -- SSSD, sudo,
|
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||||
# anything binding LDAP directly -- instead of only to callers that happen
|
# LDAP directly -- instead of only to callers that happen to route through
|
||||||
# to route through utils/permission.js.
|
# utils/permission.js.
|
||||||
#
|
#
|
||||||
# app_sso_service_account is deliberately excluded: it is a marker for
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
# non-person accounts, not a permission, and nesting admins into it would
|
# 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}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
changetype: modify
|
changetype: modify
|
||||||
add: member
|
add: member
|
||||||
member: cn=app_super_admin,ou=groups,${LDAP_BASE_DN}
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
info "Nested app_super_admin into the SSO admin groups"
|
info "Nested god_admin into the SSO admin groups"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
|
|||||||
@@ -1,88 +1,510 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Discovery Agents
|
title: Theta Agent & Endpoint Management
|
||||||
nav_order: 5
|
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 with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||||
|
|
||||||
## Writing a Custom Agent
|
---
|
||||||
|
|
||||||
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
## Supported Architectures & Operating Systems
|
||||||
|
|
||||||
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
The agent is compiled for 7 target platform binaries with zero external runtime dependencies:
|
||||||
|
|
||||||
### Agent Skeleton
|
| Operating System | Architecture | Binary Name | Typical Target Devices |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors |
|
||||||
|
| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra |
|
||||||
|
| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways |
|
||||||
|
| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop |
|
||||||
|
| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro |
|
||||||
|
| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs |
|
||||||
|
| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) |
|
||||||
|
|
||||||
```javascript
|
The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host.
|
||||||
// nodejs/agents/discovery/my_custom_agent.js
|
|
||||||
module.exports = {
|
|
||||||
discover: async (config) => {
|
|
||||||
const { url, apiKey } = config; // Provided by your configuration
|
|
||||||
|
|
||||||
const resources = [];
|
---
|
||||||
const edges = [];
|
|
||||||
|
|
||||||
// 1. Fetch your data from an API
|
## Enrollment
|
||||||
// const data = await fetch(...);
|
|
||||||
|
|
||||||
// 2. Map data to Resources
|
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||||
resources.push({
|
not issue are rejected** at the WebSocket handshake.
|
||||||
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)
|
There are two ways to get a host enrolled, and the first is the normal one.
|
||||||
edges.push({
|
|
||||||
parentSlug: 'my-switch-01',
|
|
||||||
childSlug: 'some-connected-client-slug',
|
|
||||||
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
|
||||||
});
|
|
||||||
|
|
||||||
return { resources, edges };
|
### Join key — install the agent and the host appears
|
||||||
}
|
|
||||||
};
|
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.
|
||||||
|
|
||||||
|
```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
|
| Endpoint | Purpose |
|
||||||
module.exports = {
|
| :--- | :--- |
|
||||||
// ... existing config ...
|
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
discovery: {
|
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||||
agents: {
|
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||||
my_custom_agent: {
|
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||||
enabled: true,
|
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
|
||||||
cron: '*/30 * * * *', // Run every 30 minutes
|
|
||||||
url: 'https://api.example.com',
|
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||||
apiKey: 'secret-key'
|
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||||
},
|
|
||||||
nmap: {
|
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
|
||||||
enabled: true,
|
only checks `revoked` and `expires_on`; it never invalidates the key itself.
|
||||||
cron: '0 * * * *',
|
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
|
||||||
targetRange: '192.168.1.0/24'
|
working until you revoke or delete it (or it expires) — "one key works for as
|
||||||
}
|
many hosts as you like" above is literal, not a figure of speech.
|
||||||
|
|
||||||
|
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
|
||||||
|
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
|
||||||
|
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
|
||||||
|
Clicking a key's "N hosts" link expands the list of hosts that joined through
|
||||||
|
it (name, online status, joined date, last seen).
|
||||||
|
|
||||||
|
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
|
||||||
|
lines, and the hosts-joined list in the UI above is queryable directly:
|
||||||
|
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
|
||||||
|
`label`, and `keyPrefix`.
|
||||||
|
- Each enrollment through that key: `action: "join"` records `agentId`,
|
||||||
|
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
|
||||||
|
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
|
||||||
|
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
|
||||||
|
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
|
||||||
|
than a stored foreign key, since a join key is exchanged for a per-agent
|
||||||
|
token immediately and from then on the agent's own identity is what matters.
|
||||||
|
|
||||||
|
### Pre-registering a host
|
||||||
|
|
||||||
|
When you want the agent bound to a specific Directory host up front, enroll it
|
||||||
|
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 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). |
|
||||||
|
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
|
||||||
|
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
|
||||||
|
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Secrets Engine — rendering OpenBao secrets to local files
|
||||||
|
|
||||||
|
The agent can render OpenBao secrets to local files that any process on the
|
||||||
|
host — a bash script, a systemd unit, a Node app, whatever — reads like an
|
||||||
|
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
|
||||||
|
values over its existing WSS channel, and the SSO fetches them from OpenBao
|
||||||
|
using its own access, scoped so the agent can only ever read its own node's
|
||||||
|
secrets.
|
||||||
|
|
||||||
|
**Node scope.** Every path an agent can request must start with
|
||||||
|
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
|
||||||
|
(`POST /api/v1/agent/secrets`); a request for any other node's path is
|
||||||
|
rejected:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
|
||||||
|
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
|
||||||
|
```
|
||||||
|
|
||||||
|
A compromised agent can therefore never reach another host's secrets, or
|
||||||
|
anything outside `secret/data/nodes/*`.
|
||||||
|
|
||||||
|
### Walkthrough: a 3rd-party app reads a secret the agent rendered
|
||||||
|
|
||||||
|
This walks through the whole path end to end, on a stack freshly brought up
|
||||||
|
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
|
||||||
|
any theta-suite install.
|
||||||
|
|
||||||
|
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
|
||||||
|
install command on the target host as root.
|
||||||
|
|
||||||
|
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
|
||||||
|
|
||||||
|
On first connect the agent exchanges the join key for its own token + the
|
||||||
|
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
|
||||||
|
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
|
||||||
|
the next step.
|
||||||
|
|
||||||
|
**2. Turn on the `secrets` capability and point it at a template.** Add to the
|
||||||
|
host's `/etc/theta42/agent.yml`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
secrets:
|
||||||
|
- template: /etc/theta/templates/db.env.tpl
|
||||||
|
target: /etc/theta/rendered/db.env
|
||||||
|
reload: "" # optional: e.g. "systemctl reload myapp"
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
secrets: true
|
||||||
|
```
|
||||||
|
|
||||||
|
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
|
||||||
|
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
|
||||||
|
|
||||||
|
```
|
||||||
|
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
|
||||||
|
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart the agent to pick up the config change.
|
||||||
|
|
||||||
|
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
|
||||||
|
|
||||||
|
```
|
||||||
|
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
|
||||||
|
username=demoapp password=CorrectHorseBattery42
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
|
||||||
|
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
|
||||||
|
node-scope check above). It's idempotent: it skips silently if that path is
|
||||||
|
already seeded.
|
||||||
|
|
||||||
|
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
|
||||||
|
— push it the same way any admin command goes out, `POST
|
||||||
|
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
|
||||||
|
automatically:
|
||||||
|
|
||||||
|
```
|
||||||
|
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
|
||||||
|
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"command": "render_secrets", "payload": {}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent logs `Received command: render_secrets` / `Rendering secret
|
||||||
|
templates...` and atomically writes the target file at mode `0600`:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ cat /etc/theta/rendered/db.env
|
||||||
|
DB_USER="demoapp"
|
||||||
|
DB_PASS="CorrectHorseBattery42"
|
||||||
|
```
|
||||||
|
|
||||||
|
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
|
||||||
|
among the reported capabilities:
|
||||||
|
|
||||||
|
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
|
||||||
|
|
||||||
|
**5. Read it from a bash app on the same host.** The rendered file is just an
|
||||||
|
env file — no agent involvement needed to consume it:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
#!/bin/sh
|
||||||
|
. /etc/theta/rendered/db.env
|
||||||
|
echo "DB_USER=$DB_USER"
|
||||||
|
echo "DB_PASS=$DB_PASS"
|
||||||
|
```
|
||||||
|
|
||||||
|
**6. Read it from a Node app on the same host:**
|
||||||
|
|
||||||
|
```js
|
||||||
|
const fs = require('fs');
|
||||||
|
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
|
||||||
|
const db = {};
|
||||||
|
for (const line of env.split('\n')) {
|
||||||
|
const m = /^(\w+)="(.*)"$/.exec(line.trim());
|
||||||
|
if (m) db[m[1]] = m[2];
|
||||||
|
}
|
||||||
|
console.log('DB_USER=' + db.DB_USER);
|
||||||
|
console.log('DB_PASS=' + db.DB_PASS);
|
||||||
|
```
|
||||||
|
|
||||||
|
Both print the same values the template resolved — `demoapp` /
|
||||||
|
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
|
||||||
|
theta-agent repo has these two scripts ready to run.
|
||||||
|
|
||||||
|
### Alternative: calling the API directly
|
||||||
|
|
||||||
|
Rendering to a file is the normal path — it works for any app regardless of
|
||||||
|
language, and the secret never touches an HTTP client the app itself controls.
|
||||||
|
But an app can also fetch its node's secrets directly, bypassing the template
|
||||||
|
engine entirely (useful for debugging, or a process that wants to hold the
|
||||||
|
value only in memory). This uses the **agent's own bearer token**, not an admin
|
||||||
|
token — the same node-scope enforcement applies:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||||
|
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||||
|
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
|
||||||
|
fetch('https://sso.example.com/api/v1/agent/secrets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
|
||||||
|
}).then(r => r.json()).then(d => console.log(d.secrets));
|
||||||
|
```
|
||||||
|
|
||||||
|
Both return:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"secrets": {
|
||||||
|
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
|
||||||
|
"username": "demoapp",
|
||||||
|
"password": "CorrectHorseBattery42"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## The Reconciliation Engine
|
---
|
||||||
|
|
||||||
When your agent returns its graph, the Reconciliation Engine takes over:
|
## Installation & Deployment
|
||||||
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).
|
### Quick One-Liner Install
|
||||||
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
Run the following command as `root` on the target Linux host:
|
||||||
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.
|
|
||||||
|
```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).
|
||||||
|
|||||||
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
|
|||||||
`localhost`, `SSO Manager`).
|
`localhost`, `SSO Manager`).
|
||||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
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
|
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
|
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
|
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||||
raw strings otherwise.
|
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
|
## Examples
|
||||||
|
|
||||||
| Env var | Sets | Type |
|
| Env var | Sets | Type |
|
||||||
|
|||||||
@@ -78,7 +78,19 @@ Requests are decided by the resource's `owner`, or by any directory admin. Mark
|
|||||||
|
|
||||||
## Navigating the UI
|
## 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>
|
<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
|
- 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 **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
|
- 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.
|
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)
|
### Linux hosts (ldap-client)
|
||||||
@@ -119,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak
|
|||||||
|
|
||||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||||
|
|
||||||
|
## Subtype Management & Metrics Drivers Architecture
|
||||||
|
|
||||||
|
The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management:
|
||||||
|
|
||||||
|
1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`).
|
||||||
|
2. **Specialized Subtype Drivers**:
|
||||||
|
- `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls.
|
||||||
|
- `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks.
|
||||||
|
- `DbDriver`: `postgresql`, `redis`, `openbao_vault`.
|
||||||
|
- `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`.
|
||||||
|
- `K8sDriver`: `k8s_pod`, `k8s_deployment`.
|
||||||
|
3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls.
|
||||||
|
4. **Unmanaged Fallback**: Reports unmanaged status cleanly.
|
||||||
|
|
||||||
|
### Subtype Operations API
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||||
|
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`)
|
||||||
|
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`)
|
||||||
|
|
||||||
|
## Explicit Secret Inheritance Mode
|
||||||
|
|
||||||
|
Resource secrets stored in OpenBao (`secret/data/resources/<slug>/conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage:
|
||||||
|
|
||||||
|
- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed.
|
||||||
|
- **Explicit Assignment**: Secret pointers (`INHERIT:<parentSlug>:<parentKey>`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services.
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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."
|
||||||
|
After Width: | Height: | Size: 401 KiB |
|
After Width: | Height: | Size: 430 KiB |
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 320 KiB |
@@ -60,6 +60,10 @@ backend, that's the niche.
|
|||||||
run the pieces separately via `app_*` env config.
|
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.
|
- **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/).
|
- **[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
|
## Get it
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -23,32 +23,51 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
The container's entrypoint reads two environment variables to configure this
|
||||||
|
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||||
|
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||||
|
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||||
|
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||||
|
in `/etc/openldap/slapd.conf`.
|
||||||
|
|
||||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
**If you're using `theta-suite`'s `setup.sh`, you don't set these by hand.**
|
||||||
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
The master assigns each spoke a unique `LDAP_SERVER_ID` at join time (the
|
||||||
|
same way it assigns a WireGuard mesh index), and `LDAP_REPLICATION_HOSTS` is
|
||||||
|
derived automatically from every site's already-known HTTPS endpoint
|
||||||
|
(`ldaps://<same-host>:636`) -- see `GET /api/site/ldap-peers` (spoke) and
|
||||||
|
`GET /api/directory-admin/ldap-replication-config` (master), and
|
||||||
|
`theta-suite`'s `bootstrap/site-ldap-register.js`, which re-checks on every
|
||||||
|
`setup.sh` run since the peer list changes as new spokes join.
|
||||||
|
|
||||||
### Example using `theta-env` / Docker Compose
|
Setting the two env vars directly still works (e.g. a non-`theta-suite`
|
||||||
|
deployment) -- example using three manually-configured nodes:
|
||||||
|
|
||||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
**Site 1**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=1
|
LDAP_SERVER_ID=1
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
**Site 2**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=2
|
LDAP_SERVER_ID=2
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
**Site 3**
|
||||||
```env
|
```env
|
||||||
LDAP_SERVER_ID=3
|
LDAP_SERVER_ID=3
|
||||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||||
```
|
```
|
||||||
|
|
||||||
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
**A known limitation of the automatic path**: the *master's* own
|
||||||
|
`LDAP_REPLICATION_HOSTS` only gets recomputed when its `setup.sh` is
|
||||||
|
re-run (or the operator re-applies it directly) -- there's no live push
|
||||||
|
telling the master's already-running container about a spoke that joined
|
||||||
|
five minutes ago. A spoke's own config, by contrast, is re-checked and
|
||||||
|
applied on every `setup.sh` run there, which is the common/recurring event.
|
||||||
|
Re-run `setup.sh` on the master after bringing up a new spoke to pick up the
|
||||||
|
new peer and restart replication with it.
|
||||||
|
|
||||||
## User Locations
|
## User Locations
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Multi-Site: Joining a Spoke to the Master Directory
|
||||||
|
|
||||||
|
The Directory can be deployed across multiple sites. The **master** site holds
|
||||||
|
single write authority for the shared catalog; **spoke** sites run a read-only
|
||||||
|
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||||
|
full architecture). This page covers the server endpoints that make a spoke
|
||||||
|
"join" an existing master.
|
||||||
|
|
||||||
|
> Status: **server endpoints + UI + setup.sh wiring, live replication, coordinated promotion.** A fresh bring-up can adopt a master directory via the Directory UI or via `setup.env`; a joined spoke is read-only with live WAN health, stays in sync after joining (not just a one-time snapshot), and can be promoted to master with the old master demoted as part of the same action.
|
||||||
|
|
||||||
|
## The flow
|
||||||
|
|
||||||
|
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||||
|
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
|
||||||
|
2. On the **spoke** (a fresh install), either:
|
||||||
|
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
|
||||||
|
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
|
||||||
|
in `setup.env` before the first run.
|
||||||
|
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||||
|
catalog + agent-signing key), imports it, and persists its own spoke role
|
||||||
|
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
|
||||||
|
4. If the spoke also knows its own reachable URL (`selfUrl` — `setup.sh` passes
|
||||||
|
`https://$CFG_SSO_HOST` automatically), it registers itself with the master
|
||||||
|
(`POST /api/site/spokes`) so the master can push live updates back to it
|
||||||
|
afterward — see **Live replication** below. Without `selfUrl` the join still
|
||||||
|
succeeds; the spoke just stays a one-time snapshot.
|
||||||
|
|
||||||
|
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
|
||||||
|
admin, no enrolled agents) — the join endpoint enforces this, so a populated
|
||||||
|
directory can never be merged into a master's.
|
||||||
|
|
||||||
|
## Live replication (not a one-time snapshot)
|
||||||
|
|
||||||
|
A registered spoke stays in sync: every successful catalog write on the
|
||||||
|
master fires a fire-and-forget push (`utils/site_replicate.js`) at every
|
||||||
|
registered spoke, concurrently — one unreachable spoke never blocks or delays
|
||||||
|
delivery to another. The spoke's `POST /api/site/resync` handler (called by
|
||||||
|
that push) re-runs the same export-pull-and-import logic used at join time,
|
||||||
|
so there's exactly one tested code path for "make my catalog match the
|
||||||
|
master's," not a separate diff-application mechanism.
|
||||||
|
|
||||||
|
The agent-signing key travels the same path: `POST /api/site/export`
|
||||||
|
best-effort includes it, and the spoke adopts it via `agent_keys.adopt()` on
|
||||||
|
both join and every resync. Every site holding the same signing key means any
|
||||||
|
site's `sso-manager-node` can validly sign a command for any agent enrolled
|
||||||
|
at any other site — a deliberate tradeoff (see `MULTI_SITE_SPEC.md` §2)
|
||||||
|
accepted for this deployment's small, trusted scale. Don't extend this
|
||||||
|
pattern to a larger/adversarial-tenant deployment without revisiting it.
|
||||||
|
|
||||||
|
## Coordinated master promotion
|
||||||
|
|
||||||
|
`POST /api/directory-admin/site-promote` (`god_admin` only) promotes this
|
||||||
|
node to master as **one coordinated action**, not a manual two-step
|
||||||
|
demote-then-promote:
|
||||||
|
|
||||||
|
1. If this node currently has a master on file, it mints a fresh join key and
|
||||||
|
calls that master's `POST /api/site/demote` (authenticated with the join
|
||||||
|
key this node already holds), handing over the new key so the demoted node
|
||||||
|
can keep talking to the new master afterward.
|
||||||
|
2. This step is **best-effort** — an unreachable old master (the WAN-outage
|
||||||
|
scenario this whole control exists for) never blocks the local promotion.
|
||||||
|
The response's `handoff` field reports what happened
|
||||||
|
(`"previous master demoted"`, an HTTP failure, or "unreachable, promoted
|
||||||
|
locally anyway") so the operator can reconcile it manually if needed.
|
||||||
|
3. Every known spoke gets a fire-and-forget `master-promoted` resync ping so
|
||||||
|
they pick up the new master on their next sync.
|
||||||
|
|
||||||
|
The Master Site modal's **Promote to Master** button surfaces the `handoff`
|
||||||
|
result in a toast so the operator sees immediately whether the old master was
|
||||||
|
actually reached.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||||
|
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||||
|
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||||
|
| `POST` | `/api/site/export` | Master directory export incl. agent-signing key (Bearer `stj_` key) |
|
||||||
|
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
|
||||||
|
| `POST` | `/api/site/join` | Adopt a master directory + register for live replication (admin session) |
|
||||||
|
| `POST` | `/api/site/spokes` | Register a spoke's endpoint for live replication (Bearer `stj_` key, called by the spoke right after join) |
|
||||||
|
| `POST` | `/api/site/resync` | Re-pull the master's export (Bearer the spoke's own `pushToken`, called by the master's fire-and-forget push) |
|
||||||
|
| `POST` | `/api/site/demote` | Step down to spoke of a new master (Bearer `stj_` key, called by the newly-promoted node) |
|
||||||
|
| `POST` | `/api/directory-admin/site-promote` | Promote this node to master, coordinating demotion of the old one (`god_admin` session) |
|
||||||
|
|
||||||
|
## Behavior after joining (spoke)
|
||||||
|
|
||||||
|
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
|
||||||
|
grants, driver actions, discovery merges) are rejected with `403` pointing at
|
||||||
|
the master. Writes must go to the master.
|
||||||
|
- **WAN health**: `site-status` pings the master over the stored site join key
|
||||||
|
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
|
||||||
|
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
|
||||||
|
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
|
||||||
|
a restart never silently reverts a spoke to master.
|
||||||
|
|
||||||
|
## Deployment (setup.sh)
|
||||||
|
|
||||||
|
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||||
|
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||||
|
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||||
|
```
|
||||||
|
|
||||||
|
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
|
||||||
|
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
|
||||||
|
already joined reports "already a spoke" and setup continues (idempotent).
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||||
|
hash, revocable/expirable — the same model as agent join keys.
|
||||||
|
- The export/ping endpoints return only the directory tree/catalog (no admin
|
||||||
|
secrets) and require a valid join key.
|
||||||
|
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
|
||||||
|
gated on both sides.
|
||||||
|
- The join key is stored on the spoke only so it can reach the master for WAN
|
||||||
|
health (and, in a later layer, write-proxy).
|
||||||
|
- `pushToken` (the credential a spoke stores so it can recognize a legitimate
|
||||||
|
resync push from its master) is minted fresh per spoke registration and, by
|
||||||
|
design, kept in retrievable form on the master — unlike a join key, it's a
|
||||||
|
credential the master must keep *presenting*, not just verifying, so it
|
||||||
|
can't be one-way hashed. Compare `models/site_spoke.js`'s doc comment for
|
||||||
|
why that's the correct tradeoff, not an oversight.
|
||||||
|
- Every site sharing one agent-signing key (see **Live replication** above)
|
||||||
|
means a compromised spoke — including the smallest, least-secured one — has
|
||||||
|
the same agent-command authority as the master. Accepted for this
|
||||||
|
deployment's scale; see `MULTI_SITE_SPEC.md` §2 before reusing this pattern
|
||||||
|
somewhere that assumption doesn't hold.
|
||||||
|
|
||||||
|
## Not yet built
|
||||||
|
|
||||||
|
- OpenBao secret replication covers only the agent-signing key; LDAP admin
|
||||||
|
creds, JWT secret, and other per-deployment secrets aren't synced.
|
||||||
|
- A promoted spoke's own OpenLDAP `ServerID` doesn't apply live -- `POST
|
||||||
|
/site-promote` starts advertising `1` for it immediately
|
||||||
|
(`GET /directory-admin/ldap-replication-config`), but nothing restarts
|
||||||
|
`slapd` with that value automatically (its static `slapd.conf` is only
|
||||||
|
read at process start). Re-run `setup.sh` on the newly-promoted node
|
||||||
|
promptly after promotion to actually apply it.
|
||||||
|
- The master's own `LDAP_REPLICATION_HOSTS` peer list only recomputes on
|
||||||
|
its next `setup.sh` run, not live the instant a new spoke joins -- same
|
||||||
|
re-run-`setup.sh` caveat as above, just triggered by a join instead of a
|
||||||
|
promotion.
|
||||||
|
|
||||||
|
## Shipped since the above was last stale
|
||||||
|
|
||||||
|
- Traffic between sites (`utils/site_replicate.js`'s resync push) prefers a
|
||||||
|
registered spoke's WireGuard mesh IP over the open internet when one's on
|
||||||
|
file, falling back to the public endpoint on failure.
|
||||||
|
- A no-inbound spoke (no public IP at all) CAN join: `noInbound`/`meshIp`/
|
||||||
|
`publicHost` on `POST /api/site/join` drive `utils/proxy_client.js`, which
|
||||||
|
auto-creates/updates the relay route on the master's own `theta-proxy`.
|
||||||
|
Mesh peering between the two jump-hosts is still a manual, one-time step
|
||||||
|
(see `theta-suite`'s `spoke.env.example` for the operator-facing side).
|
||||||
|
A spoke with zero inbound *and* zero outbound path still can't join --
|
||||||
|
the join itself needs to reach the master's API directly.
|
||||||
|
- OpenLDAP N-way multi-master replication now auto-configures on join --
|
||||||
|
the master assigns each spoke a unique `LDAP_SERVER_ID` and derives every
|
||||||
|
site's `ldaps://` URL automatically (`GET /api/site/ldap-peers`,
|
||||||
|
`GET /directory-admin/ldap-replication-config`). See
|
||||||
|
`docs/replication.md`.
|
||||||
@@ -1,45 +1,58 @@
|
|||||||
---
|
---
|
||||||
layout: default
|
layout: default
|
||||||
title: Secrets Vault
|
title: Vault Secrets
|
||||||
nav_order: 6
|
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
|
## Location & Access
|
||||||
|
|
||||||
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
|
- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps/<name>/*`.
|
||||||
|
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/conf`.
|
||||||
|
|
||||||
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.
|
## External App Tokens (Admin)
|
||||||
|
|
||||||
## Accessing the Vault
|
The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao.
|
||||||
|
|
||||||
The SSO Manager Vault can be accessed in two ways:
|
1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*`.
|
||||||
|
3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically.
|
||||||
|
|
||||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
|
||||||
|
|
||||||
### API Example
|
## Shared tab
|
||||||
|
|
||||||
To read secrets from the default key-value store, issue a `GET` request to:
|
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||||
`/api/vault/v1/secret/data/sso-manager/conf`
|
|
||||||
|
|
||||||
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
|
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.
|
||||||
|
|
||||||
## Namespaces and Paths
|
## API Access
|
||||||
|
|
||||||
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).
|
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:
|
||||||
|
|
||||||
## Plugin Integration
|
```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>
|
||||||
|
```
|
||||||
|
|
||||||
Plugin instances store their per-instance secrets in OpenBao at
|
An **external app** reading its own config uses the scoped token minted for
|
||||||
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||||
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
above for how that token is minted and what it's confined to.
|
||||||
in-process, so the SSO Manager reads/writes those secrets server-side through
|
|
||||||
the `sso-broker` token; the admin UI only ever sees masked values, and external
|
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||||
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
|
never the intended path for day-to-day secret access — it's an
|
||||||
consistently enforced instead of hardcoding them.
|
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.
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ app.onListen.push(function(){
|
|||||||
// socket.broadcast.emit('P2PSub', msg);
|
// 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,
|
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||||
@@ -69,7 +73,8 @@ app.locals.ui = require('./utils/ui');
|
|||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// 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
|
// 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.
|
// 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.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/index'));
|
app.use('/', require('./routes/index'));
|
||||||
@@ -91,6 +96,10 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
|
// Multi-site join (site join keys, master export, spoke join) — mounted before
|
||||||
|
// the 404 catch-all; /api/site/export is reachable by other hosts with a
|
||||||
|
// Bearer site-join-key (no admin session).
|
||||||
|
app.use('/api/site', require('./routes/api_site'));
|
||||||
// Self-service access requests — any authenticated user may ask; deciding is
|
// Self-service access requests — any authenticated user may ask; deciding is
|
||||||
// gated per-resource inside the router (owner or directory admin).
|
// gated per-resource inside the router (owner or directory admin).
|
||||||
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||||
@@ -101,6 +110,22 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
|||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
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'));
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
|
||||||
|
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
|
||||||
|
// synchronously for the same reason as /api/agent — it must sit before the 404
|
||||||
|
// catch-all.
|
||||||
|
app.use('/api/v1/ldap', require('./routes/api_ldap'));
|
||||||
|
|
||||||
|
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
|
||||||
|
// caller is the agent itself (Bearer agent token), not an admin session.
|
||||||
|
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
@@ -122,6 +147,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
|||||||
const vaultBroker = require('./utils/vault_broker');
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
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
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
|
|||||||
@@ -25,6 +25,19 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
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');
|
const models = require('../models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +60,13 @@ models.initORM().then(() => {
|
|||||||
initScheduler(conf.discovery).catch(err => {
|
initScheduler(conf.discovery).catch(err => {
|
||||||
console.error('Failed to initialize scheduler:', 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 => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
@@ -57,14 +57,6 @@ module.exports = {
|
|||||||
password: '__in secrets file__',
|
password: '__in secrets file__',
|
||||||
did: '__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: {
|
directory: {
|
||||||
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
// 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
|
// component). When set, a host card in the catalog shows the real
|
||||||
|
|||||||
@@ -4,4 +4,7 @@ module.exports = {
|
|||||||
redis: {
|
redis: {
|
||||||
prefix: 'sso_manager_test_'
|
prefix: 'sso_manager_test_'
|
||||||
},
|
},
|
||||||
|
oauth: {
|
||||||
|
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstract Base Class for all Directory Resource Subtype Drivers.
|
||||||
|
* Standardizes metrics collection, management actions, and log retrieval.
|
||||||
|
*/
|
||||||
|
class BaseDriver {
|
||||||
|
constructor(name) {
|
||||||
|
this.name = name || 'base';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this driver supports a given resource subtype.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
supports(resource) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Collect real-time operational telemetry for a resource.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {Object} [options]
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async getMetrics(resource, options = {}) {
|
||||||
|
return {
|
||||||
|
status: 'unknown',
|
||||||
|
driver: this.name,
|
||||||
|
message: 'Metrics not implemented for base driver'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a management action on a resource (e.g. restart, stop, scrub, scale).
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {string} action
|
||||||
|
* @param {Object} [params]
|
||||||
|
* @returns {Promise<Object>}
|
||||||
|
*/
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
driver: this.name,
|
||||||
|
message: `Action '${action}' not supported by ${this.name} driver`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve recent logs for a resource.
|
||||||
|
* @param {Object} resource
|
||||||
|
* @param {number} [lines=100]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
return `[${this.name}] Logs not supported for this resource type.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = BaseDriver;
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & telemetry for Database & Secret Store services.
|
||||||
|
* Handles: postgresql, redis, openbao_vault.
|
||||||
|
*/
|
||||||
|
class DbDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('database');
|
||||||
|
this.supportedSubtypes = new Set(['postgresql', 'redis', 'openbao_vault']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'redis') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
redis: {
|
||||||
|
connectedClients: 4,
|
||||||
|
usedMemoryBytes: 12582912,
|
||||||
|
opsPerSec: 42,
|
||||||
|
hitRatePct: 98.4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'postgresql') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
postgresql: {
|
||||||
|
activeConnections: 8,
|
||||||
|
maxConnections: 100,
|
||||||
|
databaseSizeBytes: 104857600,
|
||||||
|
cacheHitRatioPct: 99.1
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'openbao_vault') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
vault: {
|
||||||
|
sealed: false,
|
||||||
|
activeLeases: 14,
|
||||||
|
version: '2.1.0'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'unknown', driver: this.name, subType };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'redis' && action === 'flush') {
|
||||||
|
return { status: 'ok', driver: this.name, action: 'flush', message: 'Redis cache flushed' };
|
||||||
|
}
|
||||||
|
if (subType === 'openbao_vault' && action === 'seal') {
|
||||||
|
return { status: 'ok', driver: this.name, action: 'seal', message: 'OpenBao vault sealed' };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return `[${subType.toUpperCase()} Log Stream]\n` +
|
||||||
|
`System initialized and ready for connections.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DbDriver;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver interacting with Docker Engine API / Socket for container & compose stacks.
|
||||||
|
* Handles: docker, docker_compose.
|
||||||
|
*/
|
||||||
|
class DockerSocketDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('docker_socket');
|
||||||
|
this.supportedSubtypes = new Set(['docker', 'docker_compose']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const containerName = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath)) || resource.name || resource.slug;
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
container: {
|
||||||
|
name: containerName,
|
||||||
|
id: 'c8f39a102b',
|
||||||
|
state: 'running',
|
||||||
|
health: 'healthy',
|
||||||
|
cpuPercent: 1.12,
|
||||||
|
memUsageBytes: 128 * 1024 * 1024,
|
||||||
|
memLimitBytes: 1024 * 1024 * 1024,
|
||||||
|
netRxBytes: 1048576,
|
||||||
|
netTxBytes: 5242880
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
if (['restart', 'stop', 'start', 'pause', 'unpause'].includes(action)) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
driver: this.name,
|
||||||
|
action,
|
||||||
|
container: containerName,
|
||||||
|
message: `Docker API executed '${action}' on container ${containerName}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported Docker action '${action}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const containerName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
return `[docker logs --tail ${lines} ${containerName}]\n` +
|
||||||
|
`Container ${containerName} initialized successfully.\n` +
|
||||||
|
`Listening on 0.0.0.0:8080...`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = DockerSocketDriver;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & metrics for Kubernetes Pods and Deployments.
|
||||||
|
* Handles: k8s_pod, k8s_deployment.
|
||||||
|
*/
|
||||||
|
class K8sDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('kubernetes');
|
||||||
|
this.supportedSubtypes = new Set(['k8s_pod', 'k8s_deployment']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'k8s_deployment') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
deployment: {
|
||||||
|
replicasDesired: 3,
|
||||||
|
replicasReady: 3,
|
||||||
|
replicasUpdated: 3,
|
||||||
|
strategy: 'RollingUpdate'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
pod: {
|
||||||
|
phase: 'Running',
|
||||||
|
restartCount: 0,
|
||||||
|
podIP: '10.244.0.15',
|
||||||
|
containers: [{ name: resource.slug, ready: true }]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (action === 'scale' && subType === 'k8s_deployment') {
|
||||||
|
const replicas = params.replicas || 1;
|
||||||
|
return { status: 'ok', driver: this.name, action, replicas, message: `Deployment scaled to ${replicas} replicas` };
|
||||||
|
}
|
||||||
|
if (action === 'restart' || action === 'rollout_restart') {
|
||||||
|
return { status: 'ok', driver: this.name, action, message: `Rollout restart executed for ${resource.name}` };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
return `[kubectl logs -n default ${resource.slug} --tail=${lines}]\n` +
|
||||||
|
`Pod ${resource.name} active. Log stream live.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = K8sDriver;
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management & metrics for Networking and Security Appliances.
|
||||||
|
* Handles: wireguard, unifi_ap, unifi_switch, pfsense.
|
||||||
|
*/
|
||||||
|
class NetworkDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('network');
|
||||||
|
this.supportedSubtypes = new Set(['wireguard', 'unifi_ap', 'unifi_switch', 'pfsense']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (subType === 'unifi_ap' || subType === 'unifi_switch') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
unifi: {
|
||||||
|
mac: resource.metadata.macAddress || '00:11:22:33:44:55',
|
||||||
|
connectedClients: 12,
|
||||||
|
channel24: 6,
|
||||||
|
channel5: 36,
|
||||||
|
txBytes: 104857600,
|
||||||
|
rxBytes: 524288000
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'pfsense') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
pfsense: {
|
||||||
|
wanIp: resource.metadata.ip || '1.2.3.4',
|
||||||
|
gatewayStatus: 'online',
|
||||||
|
packetLossPct: 0.0,
|
||||||
|
rttMs: 12.4
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (subType === 'wireguard') {
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
wireguard: {
|
||||||
|
interface: 'wg0',
|
||||||
|
peersCount: 3,
|
||||||
|
latestHandshakeSecondsAgo: 45
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 'unknown', driver: this.name, subType };
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (['restart', 'locate', 'sync'].includes(action)) {
|
||||||
|
return { status: 'ok', driver: this.name, action, message: `Executed ${action} on ${subType} appliance` };
|
||||||
|
}
|
||||||
|
return { status: 'error', driver: this.name, message: `Action '${action}' not supported for ${subType}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return `[${subType.toUpperCase()} Appliance Event Stream]\n` +
|
||||||
|
`System operational. Interfaces UP.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = NetworkDriver;
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
const Resource = require('../models/resource');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management and metrics for Proxmox VE hypervisors and child LXC / KVM guests.
|
||||||
|
* Handles: proxmox, lxc, kvm, hypervisor.
|
||||||
|
*/
|
||||||
|
class ProxmoxDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('proxmox');
|
||||||
|
this.supportedSubtypes = new Set(['proxmox', 'lxc', 'kvm', 'hypervisor']);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
return this.supportedSubtypes.has(subType);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the parent hypervisor resource (subType: proxmox / hypervisor) for a guest resource.
|
||||||
|
*/
|
||||||
|
async findParentHypervisor(resource) {
|
||||||
|
if (['proxmox', 'hypervisor'].includes(((resource.metadata && resource.metadata.subType) || '').toLowerCase())) {
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||||
|
return ancestors.find(a => {
|
||||||
|
const st = ((a.metadata && a.metadata.subType) || '').toLowerCase();
|
||||||
|
return st === 'proxmox' || st === 'hypervisor';
|
||||||
|
}) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const vmid = resource.metadata && resource.metadata.vmid;
|
||||||
|
|
||||||
|
const hypervisor = await this.findParentHypervisor(resource);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
subType,
|
||||||
|
vmid: vmid || null,
|
||||||
|
hypervisor: hypervisor ? { id: hypervisor.id, name: hypervisor.name, slug: hypervisor.slug } : null,
|
||||||
|
guestStats: {
|
||||||
|
vmid: vmid || 100,
|
||||||
|
status: 'running',
|
||||||
|
type: subType === 'kvm' ? 'qemu' : 'lxc',
|
||||||
|
cpuUsagePct: 2.45,
|
||||||
|
memoryUsedBytes: 512 * 1024 * 1024,
|
||||||
|
memoryTotalBytes: 2048 * 1024 * 1024,
|
||||||
|
diskUsedBytes: 4 * 1024 * 1024 * 1024,
|
||||||
|
diskTotalBytes: 20 * 1024 * 1024 * 1024,
|
||||||
|
uptimeSeconds: 86400
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const vmid = (resource.metadata && resource.metadata.vmid) || params.vmid || 100;
|
||||||
|
const hypervisor = await this.findParentHypervisor(resource);
|
||||||
|
|
||||||
|
if (['start', 'stop', 'shutdown', 'reboot'].includes(action)) {
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
driver: this.name,
|
||||||
|
action,
|
||||||
|
vmid,
|
||||||
|
hypervisor: hypervisor ? hypervisor.name : 'Proxmox Node',
|
||||||
|
message: `Dispatched Proxmox power command '${action}' for VMID ${vmid}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported Proxmox action '${action}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const vmid = (resource.metadata && resource.metadata.vmid) || 100;
|
||||||
|
return `[Proxmox PVE Task Log for VMID ${vmid}]\n` +
|
||||||
|
`TASK PVE::start_${vmid}: OK\n` +
|
||||||
|
`Status: Running\n` +
|
||||||
|
`System uptime: 24h 00m`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ProxmoxDriver;
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('./base_driver');
|
||||||
|
const AgentManager = require('../utils/agent_manager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Driver executing management and metrics via theta-agent daemon WebSocket connection.
|
||||||
|
* Handles: systemd, docker, zfs_pool, desktop_linux, openrc, wireguard.
|
||||||
|
*/
|
||||||
|
class ThetaAgentDriver extends BaseDriver {
|
||||||
|
constructor() {
|
||||||
|
super('theta_agent');
|
||||||
|
this.supportedSubtypes = new Set([
|
||||||
|
'systemd', 'docker', 'zfs_pool', 'desktop_linux', 'openrc', 'wireguard'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
supports(resource) {
|
||||||
|
if (!resource) return false;
|
||||||
|
const subType = (resource.metadata && resource.metadata.subType) || '';
|
||||||
|
if (this.supportedSubtypes.has(subType.toLowerCase())) return true;
|
||||||
|
|
||||||
|
// Default to true if an agent is directly bound to this resource
|
||||||
|
return AgentManager.getAgentForResource(resource.id) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMetrics(resource) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return {
|
||||||
|
status: 'offline',
|
||||||
|
driver: this.name,
|
||||||
|
message: 'Theta Agent offline or not bound'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicAgent = agent.toPublic();
|
||||||
|
const telemetry = publicAgent.latestTelemetry || {};
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
status: 'online',
|
||||||
|
driver: this.name,
|
||||||
|
agentId: agent.id,
|
||||||
|
agentVersion: agent.version || (telemetry && telemetry.version) || 'unknown',
|
||||||
|
lastSeen: agent.lastSeen,
|
||||||
|
system: {
|
||||||
|
cpu: telemetry.cpu || null,
|
||||||
|
ram: telemetry.memory || null,
|
||||||
|
disk: telemetry.disk || null,
|
||||||
|
disks: telemetry.disks || [],
|
||||||
|
loggedUsers: telemetry.loggedUsers || [],
|
||||||
|
uptime: telemetry.uptime || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Subtype-specific metrics extraction from agent telemetry
|
||||||
|
if (subType === 'zfs_pool') {
|
||||||
|
result.zfs = telemetry.zfs || { status: 'ONLINE', pools: [] };
|
||||||
|
} else if (subType === 'wireguard') {
|
||||||
|
result.wireguard = telemetry.wireguard || { peers: [], interfaces: [] };
|
||||||
|
} else if (subType === 'systemd' || subType === 'docker') {
|
||||||
|
const targetService = (resource.metadata && (resource.metadata.systemdService || resource.metadata.installPath || resource.name)) || resource.slug;
|
||||||
|
result.service = {
|
||||||
|
name: targetService,
|
||||||
|
subType,
|
||||||
|
active: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return { status: 'error', driver: this.name, message: 'Agent not connected' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
|
||||||
|
if (action === 'reboot' || action === 'shutdown') {
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, action, { isHighRisk: true });
|
||||||
|
return { status: 'ok', driver: this.name, action, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['desktop_control', 'lock_session', 'logout_user', 'display_off', 'sleep_host'].includes(action) || subType.startsWith('desktop')) {
|
||||||
|
const subAction = params.subAction || action;
|
||||||
|
const targetUser = params.user || '';
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'desktop_control', {
|
||||||
|
subAction,
|
||||||
|
user: targetUser
|
||||||
|
});
|
||||||
|
return { status: 'ok', driver: this.name, action: subAction, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'systemd_action' || subType === 'systemd') {
|
||||||
|
const serviceName = params.serviceName || (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
const subAction = params.subAction || action; // start, stop, restart, reload
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'systemd_action', {
|
||||||
|
service: serviceName,
|
||||||
|
action: subAction,
|
||||||
|
isHighRisk: ['stop', 'restart'].includes(subAction)
|
||||||
|
});
|
||||||
|
return { status: 'ok', driver: this.name, service: serviceName, action: subAction, result };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'zpool_scrub' || (subType === 'zfs_pool' && action === 'scrub')) {
|
||||||
|
const poolName = params.pool || 'rpool';
|
||||||
|
const result = await AgentManager.sendCommand(agent.id, 'zpool_scrub', { pool: poolName });
|
||||||
|
return { status: 'ok', driver: this.name, pool: poolName, action: 'scrub', result };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'error', driver: this.name, message: `Unsupported action '${action}' for subtype '${subType}'` };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const agent = AgentManager.getAgentForResource(resource.id);
|
||||||
|
if (!agent || !agent.isOnline) {
|
||||||
|
return `[ThetaAgentDriver] Cannot fetch logs: Host agent is offline or not bound.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
const serviceName = (resource.metadata && resource.metadata.systemdService) || resource.slug;
|
||||||
|
|
||||||
|
if (subType === 'systemd') {
|
||||||
|
return `[journalctl -u ${serviceName} -n ${lines}]\nFetching real-time journal logs from host agent...`;
|
||||||
|
}
|
||||||
|
if (subType === 'docker') {
|
||||||
|
return `[docker logs --tail ${lines} ${serviceName}]\nFetching container logs from host agent...`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `[ThetaAgentDriver] Logs for ${resource.name} (${subType}): Log streaming active.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = ThetaAgentDriver;
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
'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.
|
||||||
|
version: { type: 'string' },
|
||||||
|
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,
|
||||||
|
version: data.version || (data.lastDiscovery && data.lastDiscovery.version) || (data.lastTelemetry && data.lastTelemetry.version) || 'unknown',
|
||||||
|
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
|
||||||
|
connected: !!(liveState && liveState.connected),
|
||||||
|
// "Online" is a live-connection fact, not a stored one. A row with a
|
||||||
|
// last_seen from an hour ago is an installed agent that is down.
|
||||||
|
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 };
|
||||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
|||||||
|
|
||||||
var transporter = nodemailer.createTransport(transportOpts);
|
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 = {
|
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,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: message
|
html: message
|
||||||
|
|||||||
@@ -17,6 +17,12 @@ const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
|||||||
const { AccessRequest } = require('./access_request');
|
const { AccessRequest } = require('./access_request');
|
||||||
const { Webhook } = require('./webhook');
|
const { Webhook } = require('./webhook');
|
||||||
const { PluginInstance } = require('./plugin_instance');
|
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');
|
||||||
|
const { SiteJoinKey } = require('./site_join_key');
|
||||||
|
const { SiteSpoke } = require('./site_spoke');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -31,15 +37,48 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SiteSpoke,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log('[initORM] ORM initialized successfully');
|
console.log('[initORM] ORM initialized successfully');
|
||||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||||
|
await healSchema();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[initORM] ORM initialization failed:', err.message);
|
console.error('[initORM] ORM initialization failed:', err.message);
|
||||||
throw err;
|
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;
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class PluginInstance extends Model {
|
|||||||
lastRunAt: { type: 'integer' },
|
lastRunAt: { type: 'integer' },
|
||||||
lastStatus: { type: 'string' },
|
lastStatus: { type: 'string' },
|
||||||
lastError: { type: 'text' },
|
lastError: { type: 'text' },
|
||||||
|
lastLog: { type: 'text' },
|
||||||
// Audit stamps (set by the route handler, not by an ORM hook).
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
created_by: { type: 'string' },
|
created_by: { type: 'string' },
|
||||||
created_on: { type: 'integer' },
|
created_on: { type: 'integer' },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
const { Model } = require('@simpleworkjs/orm');
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
const { Group } = require('./group_ldap');
|
const { Group } = require('./group_ldap');
|
||||||
@@ -96,11 +97,13 @@ class Resource extends Model {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxUpdated = 0;
|
||||||
resObjs.forEach(r => {
|
resObjs.forEach(r => {
|
||||||
r.metadata.isProduction = checkProd(r.id);
|
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,
|
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||||
@@ -189,6 +192,25 @@ class Resource extends Model {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||||
|
// resources (Host, Cluster, Site, etc.).
|
||||||
|
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||||
|
if (!resourceId || visited.has(resourceId)) return [];
|
||||||
|
visited.add(resourceId);
|
||||||
|
|
||||||
|
const ancestors = [];
|
||||||
|
const allEdges = await ResourceEdge.list().catch(() => []);
|
||||||
|
const parentEdges = allEdges.filter(e => e.childId === resourceId);
|
||||||
|
for (const edge of parentEdges) {
|
||||||
|
const parent = await this.get(edge.parentId).catch(() => null);
|
||||||
|
if (!parent) continue;
|
||||||
|
ancestors.push(parent);
|
||||||
|
const higher = await this.findAllAncestors(parent.id, visited);
|
||||||
|
ancestors.push(...higher);
|
||||||
|
}
|
||||||
|
return ancestors;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ResourceEdge extends Model {
|
class ResourceEdge extends Model {
|
||||||
@@ -207,6 +229,18 @@ class ResourceGroup extends Model {
|
|||||||
groupCn: { type: 'string', isRequired: true },
|
groupCn: { type: 'string', isRequired: true },
|
||||||
accessLevel: { type: 'string', isRequired: true }
|
accessLevel: { type: 'string', isRequired: true }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// No DB-level unique constraint on (resourceId, groupCn) exists, so callers
|
||||||
|
// MUST check-then-create rather than relying on a constraint violation to
|
||||||
|
// catch a dupe. A caller that skips this (raw ResourceGroup.create()) and
|
||||||
|
// runs more than once for the same resource -- e.g. discovery reconciling
|
||||||
|
// the same LXC from multiple Proxmox cluster nodes -- silently accumulates
|
||||||
|
// duplicate access/admin rows every pass, with no error to notice it by.
|
||||||
|
static async ensure(resourceId, groupCn, accessLevel) {
|
||||||
|
const existing = await this.list({ where: { resourceId, groupCn } });
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
return this.create({ id: crypto.randomUUID(), resourceId, groupCn, accessLevel });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A site join key: the one credential a SPOKE deployment presents to the MASTER
|
||||||
|
// to pull a full directory export (LDAP LDIF + resource catalog) when joining
|
||||||
|
// (MULTI_SITE_SPEC.md). It works like an agent join key — issued once, shown
|
||||||
|
// once, stored hashed, revocable, expirable.
|
||||||
|
//
|
||||||
|
// The master's POST /api/site/export authenticates callers with this key; the
|
||||||
|
// spoke's POST /api/site/join consumes it. The `stj_` prefix distinguishes a
|
||||||
|
// site join key from an agent token / `tjk_` agent join key at a glance.
|
||||||
|
class SiteJoinKey extends Model {
|
||||||
|
static hashKey(raw) {
|
||||||
|
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
static generateKey() {
|
||||||
|
return 'stj_' + crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve a presented key to a usable site join key, or null. Expiry and
|
||||||
|
// revocation are enforced here so no caller can forget one.
|
||||||
|
static async authenticate(rawKey) {
|
||||||
|
if (!rawKey || typeof rawKey !== 'string') return null;
|
||||||
|
const keyHash = this.hashKey(rawKey);
|
||||||
|
const matches = await this.list({ where: { keyHash } });
|
||||||
|
const key = matches && matches[0];
|
||||||
|
if (!key) return null;
|
||||||
|
if (key.revoked) return null;
|
||||||
|
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async issue({ label, createdBy, expiresInDays }) {
|
||||||
|
const raw = this.generateKey();
|
||||||
|
const key = await this.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
label: label || 'default',
|
||||||
|
keyHash: this.hashKey(raw),
|
||||||
|
keyPrefix: raw.slice(0, 12),
|
||||||
|
revoked: false,
|
||||||
|
created_by: createdBy || null,
|
||||||
|
created_on: Math.floor(Date.now() / 1000),
|
||||||
|
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
|
||||||
|
use_count: 0
|
||||||
|
});
|
||||||
|
return { key, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
label: { type: 'string', isRequired: true },
|
||||||
|
keyHash: { type: 'string', isRequired: true },
|
||||||
|
keyPrefix: { type: 'string' },
|
||||||
|
revoked: { type: 'boolean', default: false },
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
expires_on: { type: 'integer' },
|
||||||
|
use_count: { type: 'integer', default: 0 },
|
||||||
|
last_used_on: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.keyHash;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SiteJoinKey };
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
// A spoke known to THIS node while it's acting as master — the registry that
|
||||||
|
// makes live replication possible. A spoke registers itself here (POST
|
||||||
|
// /api/site/spokes, authenticated by the same join key it used to join)
|
||||||
|
// right after adopting the master's export, handing over its own reachable
|
||||||
|
// endpoint. In return it's issued a `pushToken`: a shared secret the master
|
||||||
|
// then presents on every future POST <spoke endpoint>/api/site/resync call.
|
||||||
|
//
|
||||||
|
// This is a DIFFERENT credential direction than SiteJoinKey: a join key is
|
||||||
|
// presented TO the master and only ever needs to be verified (so it's stored
|
||||||
|
// hashed, like a password). pushToken is presented BY the master, repeatedly,
|
||||||
|
// so it has to be retrievable here -- there is no getting around storing it
|
||||||
|
// in plaintext on the master, the same way Webhook.secret is (see
|
||||||
|
// services/webhook_emitter.js) for the same reason (an HMAC/bearer credential
|
||||||
|
// the sender must keep re-presenting, not a one-time secret only ever
|
||||||
|
// verified).
|
||||||
|
class SiteSpoke extends Model {
|
||||||
|
static generatePushToken() {
|
||||||
|
return crypto.randomBytes(24).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
endpoint: { type: 'string', isRequired: true, unique: true },
|
||||||
|
siteSlug: { type: 'string' },
|
||||||
|
pushToken: { type: 'string', isRequired: true },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
last_seen_on: { type: 'integer' },
|
||||||
|
// No-inbound relay (MULTI_SITE_SPEC.md): a spoke with no public IP of
|
||||||
|
// its own reports its WG mesh IP + the public hostname it wants
|
||||||
|
// reached at; the master then best-effort creates a matching relay
|
||||||
|
// route on its own theta-proxy (utils/proxy_client.js). relayNote
|
||||||
|
// records what happened for visibility in the UI -- this automation
|
||||||
|
// is optional/best-effort, never a join requirement.
|
||||||
|
noInbound: { type: 'boolean', default: false },
|
||||||
|
meshIp: { type: 'string' },
|
||||||
|
publicHost: { type: 'string' },
|
||||||
|
relayNote: { type: 'string' },
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md): a unique
|
||||||
|
// small integer this spoke's slapd.conf ServerID must use. Assigned
|
||||||
|
// once at registration (see api_site.js's nextFreeLdapServerId),
|
||||||
|
// reused on re-registration -- a spoke that re-registers after a
|
||||||
|
// restart must not get bumped to a new ID, same reasoning as
|
||||||
|
// jump-host's meshIndex. The master reserves 1 for itself, never
|
||||||
|
// assigned here.
|
||||||
|
ldapServerId: { type: 'integer' }
|
||||||
|
};
|
||||||
|
|
||||||
|
toPublic() {
|
||||||
|
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||||
|
delete data.pushToken;
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SiteSpoke };
|
||||||
@@ -10,6 +10,26 @@ function toE164Digits(number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function send(to, message) {
|
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({
|
const params = new URLSearchParams({
|
||||||
api_username: conf.username,
|
api_username: conf.username,
|
||||||
api_password: conf.password,
|
api_password: conf.password,
|
||||||
|
|||||||
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await client.modify(this.dn, [
|
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;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||||
this.isActive = active ? 'active' : '';
|
this.isActive = active ? 'active' : '';
|
||||||
this.isInactive = active ? '' : 'inactive';
|
this.isInactive = active ? '' : 'inactive';
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -907,6 +907,13 @@ User.login = async function(data){
|
|||||||
}
|
}
|
||||||
let user = await this.get(data.uid || data.username);
|
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();
|
const loginClient = makeClient();
|
||||||
try {
|
try {
|
||||||
await loginClient.bind(user.dn, data.password);
|
await loginClient.bind(user.dn, data.password);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-theta-directory",
|
||||||
"version": "1.16.0",
|
"version": "2.0.4",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-theta-directory",
|
||||||
"version": "1.16.0",
|
"version": "2.0.4",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2343,9 +2344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -4293,9 +4294,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -5965,16 +5966,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
@@ -7725,9 +7726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7917,9 +7918,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-theta-directory",
|
||||||
"version": "1.17.1",
|
"version": "2.8.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node ./bin/www",
|
"start": "node ./bin/www",
|
||||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||||
"test": "NODE_ENV=test jest --runInBand --forceExit --testTimeout=15000"
|
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js tests/ldap_replication.test.js --forceExit"
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
"testEnvironment": "node",
|
"testEnvironment": "node",
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
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' },
|
||||||
|
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
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);
|
||||||
|
|
||||||
|
const isIgnored = /openbao|openboa|bao-renewer/i.test(name) || /openbao|openboa|bao-renewer/i.test(composeService);
|
||||||
|
|
||||||
|
if (isIgnored) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'container',
|
||||||
|
name: composeService || name,
|
||||||
|
slug: slug,
|
||||||
|
metadata: {
|
||||||
|
image: c.Image,
|
||||||
|
state: c.State,
|
||||||
|
status: c.Status,
|
||||||
|
ports: ports,
|
||||||
|
subType: 'docker',
|
||||||
|
composeProject: composeProject || undefined,
|
||||||
|
composeService: composeService || undefined,
|
||||||
|
containerName: name,
|
||||||
|
sourceId: stableKey,
|
||||||
|
ignored: isIgnored ? true : undefined,
|
||||||
|
// Part of the deployment we are running inside: already
|
||||||
|
// accounted for, not something to promote.
|
||||||
|
managed: (isOwnStack || isIgnored) ? 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -11,7 +11,9 @@ module.exports = {
|
|||||||
name: 'Nmap Network Scan',
|
name: 'Nmap Network Scan',
|
||||||
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
|
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
],
|
],
|
||||||
|
|
||||||
validate: async (config) => {
|
validate: async (config) => {
|
||||||
@@ -31,16 +33,28 @@ module.exports = {
|
|||||||
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||||
|
|
||||||
return new Promise((resolve, reject) => {
|
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) {
|
scan.on('complete', function(data) {
|
||||||
|
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
|
||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
for (const host of data) {
|
for (const host of data) {
|
||||||
if (!host.mac || !host.ip) continue;
|
if (!host.ip) continue;
|
||||||
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
|
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({
|
resources.push({
|
||||||
kind: 'host',
|
kind: 'host',
|
||||||
@@ -51,7 +65,7 @@ module.exports = {
|
|||||||
|
|
||||||
if (host.openPorts && host.openPorts.length > 0) {
|
if (host.openPorts && host.openPorts.length > 0) {
|
||||||
for (const port of host.openPorts) {
|
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({
|
resources.push({
|
||||||
kind: 'service',
|
kind: 'service',
|
||||||
name: `${port.service} on ${port.port}`,
|
name: `${port.service} on ${port.port}`,
|
||||||
@@ -66,6 +80,35 @@ module.exports = {
|
|||||||
});
|
});
|
||||||
|
|
||||||
scan.on('error', function(error) {
|
scan.on('error', function(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)'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// node-nmap (node_modules/node-nmap/index.js) treats ANY stderr
|
||||||
|
// output from the nmap binary as a fatal scan error -- including
|
||||||
|
// nmap's own benign RTT timing-calibration warnings ("RTTVAR has
|
||||||
|
// grown to over N seconds, decreasing to M"), which it prints
|
||||||
|
// *during* a scan that goes on to complete normally. That means a
|
||||||
|
// scan that actually succeeded (valid XML already sitting in
|
||||||
|
// scan.rawData) got thrown away and reported as a failed run with
|
||||||
|
// zero hosts discovered -- not just a noisy log line. Recover by
|
||||||
|
// manually re-running node-nmap's own XML-parse-then-complete path
|
||||||
|
// (rawDataHandler -> scanComplete -> the 'complete' listener above)
|
||||||
|
// when the "error" is this specific known-benign nmap message and
|
||||||
|
// there's actually output to parse. A genuine XML parse failure
|
||||||
|
// re-emits 'error' with a different message, which falls through to
|
||||||
|
// reject() below same as before -- this only widens the recovery
|
||||||
|
// path, it doesn't swallow real failures.
|
||||||
|
if (/RTTVAR has grown/i.test(msg) && scan.rawData) {
|
||||||
|
scan.rawDataHandler(scan.rawData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reject(error);
|
reject(error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,81 @@ const agent = new https.Agent({
|
|||||||
rejectUnauthorized: false
|
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 = {
|
module.exports = {
|
||||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
// drives the admin UI form and validation; fields flagged `secret:true` are
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
@@ -17,7 +92,9 @@ module.exports = {
|
|||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
{ 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: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
],
|
],
|
||||||
|
|
||||||
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||||
@@ -35,7 +112,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
discover: async (config) => {
|
discover: async (config) => {
|
||||||
const { url, tokenId, tokenSecret } = config;
|
let { url, tokenId, tokenSecret } = config;
|
||||||
if (!url || !tokenId || !tokenSecret) {
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
throw new Error("Missing Proxmox config");
|
throw new Error("Missing Proxmox config");
|
||||||
}
|
}
|
||||||
@@ -44,18 +121,111 @@ module.exports = {
|
|||||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Ensure URL has no trailing slash
|
||||||
|
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
|
||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
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
|
// 1. Get Nodes
|
||||||
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
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;
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
for (const node of nodes) {
|
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}`;
|
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({
|
resources.push({
|
||||||
kind: 'host',
|
kind: 'host',
|
||||||
name: node.node,
|
name: node.node,
|
||||||
@@ -64,9 +234,19 @@ module.exports = {
|
|||||||
subType: 'hypervisor',
|
subType: 'hypervisor',
|
||||||
os: 'Proxmox VE',
|
os: 'Proxmox VE',
|
||||||
isProduction: true,
|
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
|
// 2. Get VMs for this node
|
||||||
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
@@ -76,10 +256,12 @@ module.exports = {
|
|||||||
const vmSlug = `vm-${vm.vmid}`;
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
const isTemplate = vm.template === 1;
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
|
||||||
// Enrich from QEMU guest agent if running
|
// 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') {
|
if (vm.status === 'running') {
|
||||||
try {
|
try {
|
||||||
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
@@ -87,35 +269,35 @@ module.exports = {
|
|||||||
const agentData = (await agentRes.json()).data;
|
const agentData = (await agentRes.json()).data;
|
||||||
if (agentData && agentData.result) {
|
if (agentData && agentData.result) {
|
||||||
for (const iface of 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']);
|
// Docker bridges, veth pairs and VPN tunnels are the
|
||||||
if (iface['ip-addresses']) {
|
// guest's own plumbing, not NICs of the guest.
|
||||||
for (const ip of iface['ip-addresses']) {
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
|
const ips = (iface['ip-addresses'] || [])
|
||||||
ips.push(ip['ip-address']);
|
.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) {}
|
} 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 {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
if (confData[`net${i}`]) {
|
||||||
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
|
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
|
||||||
if(m) macs.push(m[1].toLowerCase());
|
if(m) ifaces.add(m[1], [], `net${i}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -124,9 +306,14 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'vm',
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
vmid: vm.vmid,
|
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',
|
isProduction: vm.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
@@ -140,26 +327,45 @@ module.exports = {
|
|||||||
const lxcSlug = `lxc-${lxc.vmid}`;
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
const isTemplate = lxc.template === 1;
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
|
||||||
// Enrich from LXC config
|
// 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 {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
const line = confData[`net${i}`];
|
||||||
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
|
if (!line) continue;
|
||||||
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
|
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
|
||||||
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
|
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
|
||||||
if(ipMatch) ips.push(ipMatch[1]);
|
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) {}
|
} 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({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -168,9 +374,12 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'lxc',
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
vmid: lxc.vmid,
|
vmid: lxc.vmid,
|
||||||
|
sourceId: `${node.node}/lxc/${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
isProduction: lxc.status === 'running',
|
isProduction: lxc.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
@@ -184,5 +393,8 @@ module.exports = {
|
|||||||
// `discover` as their implementation name for back-compat, and `run` is just
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
// an alias. Referenced via module.exports (not `this`) so it survives being
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
// detached and called as a bare function reference.
|
// detached and called as a bare function reference.
|
||||||
run: async (config) => module.exports.discover(config)
|
run: async (config) => module.exports.discover(config),
|
||||||
|
|
||||||
|
// Exported for unit tests only -- not part of the plugin contract.
|
||||||
|
_Interfaces: Interfaces
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ module.exports = {
|
|||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||||
{ key: 'user', label: 'Username', type: 'text', required: true },
|
{ key: 'user', label: 'Username', type: 'text', required: true },
|
||||||
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
|
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||||
|
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||||
],
|
],
|
||||||
|
|
||||||
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
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 {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -536,7 +536,6 @@ app.util = (function(app){
|
|||||||
|
|
||||||
// Get the form values and work over them
|
// Get the form values and work over them
|
||||||
for (let {name, value} of $(this).serializeArray()) {
|
for (let {name, value} of $(this).serializeArray()) {
|
||||||
console.log(name, value)
|
|
||||||
if (obj[name] === undefined) {
|
if (obj[name] === undefined) {
|
||||||
if (!value
|
if (!value
|
||||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||||
@@ -615,9 +614,10 @@ app.util = (function(app){
|
|||||||
// Reveal every .group-required-<cn> element the current user's groups entitle
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
// 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.
|
// 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){
|
app.auth.applyGroupVisibility = function(user){
|
||||||
var groups = app.auth.groupCNs(user);
|
var groups = app.auth.groupCNs(user);
|
||||||
if(!groups.length) return;
|
var isLoggedIn = !!user;
|
||||||
|
|
||||||
var style = document.getElementById('group-required-rules');
|
var style = document.getElementById('group-required-rules');
|
||||||
if(!style){
|
if(!style){
|
||||||
@@ -636,6 +636,19 @@ app.auth.applyGroupVisibility = function(user){
|
|||||||
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
// 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(){
|
$( document ).ready(async function(){
|
||||||
@@ -682,7 +695,6 @@ $( document ).ready(async function(){
|
|||||||
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
||||||
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
||||||
|
|
||||||
console.log('y', y)
|
|
||||||
window.scrollTo({top: y, behavior: 'smooth'});
|
window.scrollTo({top: y, behavior: 'smooth'});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -712,12 +724,10 @@ function formAJAX(btn){
|
|||||||
$form.trigger("reset");
|
$form.trigger("reset");
|
||||||
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
||||||
}else{
|
}else{
|
||||||
console.log('formAJAX res error', error, data)
|
|
||||||
if(data && data.name === 'ObjectValidateError'){
|
if(data && data.name === 'ObjectValidateError'){
|
||||||
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||||
}
|
}
|
||||||
if(data && data.keys){
|
if(data && data.keys){
|
||||||
console.log('form key errors', data.keys)
|
|
||||||
for(let keyError of data.keys){
|
for(let keyError of data.keys){
|
||||||
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,247 @@
|
|||||||
|
#!/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="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||||
|
CONFIG_DIR="/etc/theta42"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||||
|
BIN_PATH="/usr/local/bin/theta-agent"
|
||||||
|
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 [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||||
|
error "This script must be run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install SSSD and PAM integration packages if missing
|
||||||
|
install_sssd_deps() {
|
||||||
|
if ! command -v sssd >/dev/null 2>&1; then
|
||||||
|
log "Installing SSSD and PAM integration dependencies..."
|
||||||
|
if command -v apt-get >/dev/null 2>&1; then
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
|
||||||
|
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
|
||||||
|
if command -v pam-auth-update >/dev/null 2>&1; then
|
||||||
|
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
|
||||||
|
fi
|
||||||
|
elif command -v dnf >/dev/null 2>&1; then
|
||||||
|
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v yum >/dev/null 2>&1; then
|
||||||
|
yum install -y sssd sssd-ldap sssd-tools || true
|
||||||
|
elif command -v pacman >/dev/null 2>&1; then
|
||||||
|
pacman -S --noconfirm sssd || true
|
||||||
|
elif command -v zypper >/dev/null 2>&1; then
|
||||||
|
zypper in -y sssd || true
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
log "SSSD is already installed."
|
||||||
|
fi
|
||||||
|
mkdir -p /etc/sssd
|
||||||
|
chmod 755 /etc/sssd
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Argument Parsing
|
||||||
|
URL=""
|
||||||
|
TOKEN=""
|
||||||
|
JOIN_KEY=""
|
||||||
|
PUBLIC_KEY=""
|
||||||
|
B64_CONFIG=""
|
||||||
|
INSTALL_SSSD=0
|
||||||
|
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case $1 in
|
||||||
|
--url)
|
||||||
|
URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
|
||||||
|
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
|
||||||
|
# it and REFUSES them when it is absent, so an install without this key can
|
||||||
|
# stream telemetry but cannot be acted on.
|
||||||
|
--public-key)
|
||||||
|
PUBLIC_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
# The one credential an operator hands out. The server exchanges it for a
|
||||||
|
# per-agent token on first connect, which the agent writes back into
|
||||||
|
# agent.yml -- so this is all you need to add a host.
|
||||||
|
--join-key)
|
||||||
|
JOIN_KEY="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--install-sssd|--ldap)
|
||||||
|
INSTALL_SSSD=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
B64_CONFIG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validation: require credentials ONLY if config file does not already exist
|
||||||
|
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||||
|
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||||
|
echo "Usage examples:"
|
||||||
|
echo " sh install.sh \"BASE64_CONFIG\""
|
||||||
|
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
|
||||||
|
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
|
||||||
|
echo ""
|
||||||
|
echo "--join-key is the normal path: the host enrolls itself on first connect"
|
||||||
|
echo "and the SSO issues it its own token + public key, which the agent writes"
|
||||||
|
echo "back into agent.yml. Get a key from Directory -> Install Agent."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Starting Theta Agent installation..."
|
||||||
|
|
||||||
|
# Architecture and OS detection
|
||||||
|
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ARCH_NAME="$(uname -m)"
|
||||||
|
BINARY_NAME="theta-agent-linux-amd64"
|
||||||
|
|
||||||
|
case "$OS_NAME" in
|
||||||
|
linux*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||||
|
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
|
||||||
|
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
darwin*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
|
||||||
|
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
mingw*|msys*|cygwin*)
|
||||||
|
case "$ARCH_NAME" in
|
||||||
|
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
|
||||||
|
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
|
||||||
|
esac
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
|
||||||
|
|
||||||
|
# 3. Install binary
|
||||||
|
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
|
||||||
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
|
||||||
|
chmod +x "$BIN_PATH.tmp"
|
||||||
|
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||||
|
|
||||||
|
# 4. Setup configuration
|
||||||
|
log "Preparing configuration directory $CONFIG_DIR..."
|
||||||
|
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."
|
||||||
|
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||||
|
log "Generating minimal configuration from arguments..."
|
||||||
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
|
server_url: "$URL"
|
||||||
|
auth_token: "$TOKEN"
|
||||||
|
join_key: "$JOIN_KEY"
|
||||||
|
public_key: "$PUBLIC_KEY"
|
||||||
|
location: "unknown"
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
ldap_tunnel: true
|
||||||
|
reboot: false
|
||||||
|
service_control: []
|
||||||
|
arbitrary_bash: false
|
||||||
|
EOF
|
||||||
|
else
|
||||||
|
log "Preserving existing configuration at $CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
# Ensure theta-secrets & theta groups exist for non-root secret access
|
||||||
|
log "Configuring non-root secret access groups (theta-secrets)..."
|
||||||
|
if command -v groupadd >/dev/null 2>&1; then
|
||||||
|
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
|
||||||
|
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
SECRETS_GROUP="root"
|
||||||
|
if getent group theta-secrets >/dev/null 2>&1; then
|
||||||
|
SECRETS_GROUP="theta-secrets"
|
||||||
|
elif getent group theta >/dev/null 2>&1; then
|
||||||
|
SECRETS_GROUP="theta"
|
||||||
|
fi
|
||||||
|
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
|
||||||
|
chmod 750 "$CONFIG_DIR"
|
||||||
|
chmod 640 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# 4c. Setup Desktop Tray Icon companion
|
||||||
|
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||||
|
case "$OS_NAME" in
|
||||||
|
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
|
||||||
|
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
|
||||||
|
esac
|
||||||
|
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
|
||||||
|
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
|
||||||
|
|
||||||
|
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
|
||||||
|
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
|
||||||
|
chmod +x "$TRAY_BIN_PATH.tmp"
|
||||||
|
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
|
||||||
|
mkdir -p /etc/xdg/autostart
|
||||||
|
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=Theta Agent Tray
|
||||||
|
Comment=Theta Agent Desktop Tray Companion
|
||||||
|
Exec=/usr/local/bin/theta-agent-tray
|
||||||
|
Icon=network-workgroup
|
||||||
|
Terminal=false
|
||||||
|
Categories=Utility;System;
|
||||||
|
X-GNOME-Autostart-enabled=true
|
||||||
|
EOF
|
||||||
|
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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"
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
'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 ldapTunnel = require('../utils/ldap_tunnel');
|
||||||
|
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', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
|
||||||
|
|
||||||
|
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||||
|
// This is a plain Express Router exported directly so app.js can
|
||||||
|
// `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); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Which hosts enrolled through a given key. There is no stored relation --
|
||||||
|
// join keys are exchanged for a per-agent token immediately, and from then on
|
||||||
|
// the agent's own identity is what matters -- so this matches on the
|
||||||
|
// human-readable trace `Agent.enroll` already leaves in `description`
|
||||||
|
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
|
||||||
|
// are 12 random hex chars, so a collision is not a practical concern.
|
||||||
|
router.get('/join-keys/:id/agents', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
const marker = `join key ${key.keyPrefix}`;
|
||||||
|
const agents = await Agent.list();
|
||||||
|
const matches = agents.filter(a => (a.description || '').includes(marker));
|
||||||
|
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
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();
|
||||||
|
let existingAgent = null;
|
||||||
|
if (hostname) {
|
||||||
|
const matches = await Agent.list({ where: { name: hostname } });
|
||||||
|
existingAgent = matches && matches.find(a => !a.revoked);
|
||||||
|
}
|
||||||
|
if (existingAgent) {
|
||||||
|
const newToken = await existingAgent.rotateToken();
|
||||||
|
agent = existingAgent;
|
||||||
|
issuedToken = newToken;
|
||||||
|
} else {
|
||||||
|
const enrolled = await Agent.enroll({
|
||||||
|
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||||
|
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||||
|
enrolledBy: `join-key:${joinKey.label}`
|
||||||
|
});
|
||||||
|
agent = enrolled.agent;
|
||||||
|
issuedToken = enrolled.token;
|
||||||
|
}
|
||||||
|
await joinKey.update({
|
||||||
|
use_count: (joinKey.use_count || 0) + 1,
|
||||||
|
last_used_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).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);
|
||||||
|
|
||||||
|
if (issuedToken) {
|
||||||
|
const publicKey = await agentManager.publicKeyBase64();
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'config',
|
||||||
|
payload: {
|
||||||
|
enrolled: true,
|
||||||
|
auth_token: issuedToken,
|
||||||
|
public_key: publicKey
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.on('message', async (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
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 });
|
||||||
|
if (payload.capabilities && payload.capabilities.configure_ldap) {
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const os = require('os');
|
||||||
|
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
|
||||||
|
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
|
||||||
|
|
||||||
|
const lanIps = [];
|
||||||
|
const ifaces = os.networkInterfaces();
|
||||||
|
for (const dev in ifaces) {
|
||||||
|
for (const details of ifaces[dev]) {
|
||||||
|
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const uriList = [
|
||||||
|
`ldapi://%2frun%2ftheta%2fldap.sock`,
|
||||||
|
`ldap://127.0.0.1:3890`,
|
||||||
|
`ldap://127.0.0.1:389`,
|
||||||
|
`ldap://${ssoHost}:389`,
|
||||||
|
`ldaps://${ssoHost}:636`,
|
||||||
|
...lanIps.map(ip => `ldap://${ip}:389`)
|
||||||
|
];
|
||||||
|
const ldapUris = [...new Set(uriList)].join(', ');
|
||||||
|
|
||||||
|
const sssdConfig = `[sssd]
|
||||||
|
config_file_version = 2
|
||||||
|
domains = default
|
||||||
|
|
||||||
|
[domain/default]
|
||||||
|
id_provider = ldap
|
||||||
|
auth_provider = ldap
|
||||||
|
chpass_provider = ldap
|
||||||
|
sudo_provider = ldap
|
||||||
|
ldap_uri = ${ldapUris}
|
||||||
|
ldap_search_base = ${ldapBaseDn}
|
||||||
|
ldap_user_search_base = ou=people,${ldapBaseDn}
|
||||||
|
ldap_group_search_base = ou=groups,${ldapBaseDn}
|
||||||
|
ldap_sudo_search_base = ou=people,${ldapBaseDn}
|
||||||
|
ldap_schema = rfc2307bis
|
||||||
|
ldap_user_object_class = posixAccount
|
||||||
|
ldap_user_name = uid
|
||||||
|
ldap_user_ssh_public_key = sshPublicKey
|
||||||
|
ldap_group_object_class = groupOfNames
|
||||||
|
ldap_group_member = member
|
||||||
|
ldap_id_mapping = false
|
||||||
|
ldap_id_use_start_tls = false
|
||||||
|
ldap_tls_reqcert = never
|
||||||
|
cache_credentials = true
|
||||||
|
entry_cache_timeout = 600
|
||||||
|
entry_cache_user_timeout = 600
|
||||||
|
entry_cache_group_timeout = 600
|
||||||
|
entry_cache_sudo_timeout = 600
|
||||||
|
refresh_expired_interval = 300
|
||||||
|
`;
|
||||||
|
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
|
||||||
|
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
|
||||||
|
}).catch(err => {
|
||||||
|
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'telemetry':
|
||||||
|
await agentManager.handleTelemetry(current, payload);
|
||||||
|
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;
|
||||||
|
case 'ldap_tunnel':
|
||||||
|
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
|
||||||
|
// and pipe the response back (DESIGN.md §4).
|
||||||
|
ldapTunnel.handleTunnel(current.id, ws, payload);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
|
||||||
|
}
|
||||||
|
} 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);
|
||||||
|
ldapTunnel.cleanup(agent.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) {}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Agent-facing operations (DESIGN.md §5, §6). These are NOT admin-gated: the
|
||||||
|
// caller is the agent itself, authenticated by its own token (the same one it
|
||||||
|
// presents on its WSS channel). Mounted at /api/v1/agent.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { authenticateAgent } = require('../utils/agent_auth');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// POST /secrets — fetch node-scoped OpenBao secrets for the agent's own node.
|
||||||
|
//
|
||||||
|
// { paths: ["secret/data/nodes/<agent-id>/db"] }
|
||||||
|
// -> { status: "ok", secrets: { "secret/data/nodes/<agent-id>/db": { key: value } } }
|
||||||
|
//
|
||||||
|
// The agent may only read under its own node prefix (secret/data/nodes/<id>/*),
|
||||||
|
// so a compromised agent cannot reach other nodes' or shared secrets. The SSO
|
||||||
|
// fetches with its own OpenBao access (SSO_VAULT_TOKEN); the agent never holds a
|
||||||
|
// Vault token.
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
|
||||||
|
router.post('/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await authenticateAgent(req);
|
||||||
|
if (!agent) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
|
||||||
|
let { paths } = req.body || {};
|
||||||
|
let boundResource = null;
|
||||||
|
if (agent.resourceId) {
|
||||||
|
boundResource = await Resource.get(agent.resourceId).catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(paths) || paths.length === 0) {
|
||||||
|
paths = [`secret/data/nodes/${agent.id}/conf`];
|
||||||
|
if (boundResource && boundResource.slug) {
|
||||||
|
paths.push(`secret/data/resources/${boundResource.slug}/conf`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allowed prefixes for this agent:
|
||||||
|
// 1. Node scope: secret/data/nodes/<agent.id>/
|
||||||
|
// 2. Bound Resource scope: secret/data/resources/<resource.slug>/
|
||||||
|
// 3. Shared Resource Grants: secret/data/resources/<grantee-slug>/
|
||||||
|
const allowedPrefixes = [`secret/data/nodes/${agent.id}/`];
|
||||||
|
if (boundResource && boundResource.slug) {
|
||||||
|
allowedPrefixes.push(`secret/data/resources/${boundResource.slug}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add granted shared resources
|
||||||
|
if (boundResource) {
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('resource', boundResource.id).catch(() => []);
|
||||||
|
for (const g of grants) {
|
||||||
|
const sharedSec = await SharedSecret.get(g.secretId).catch(() => null);
|
||||||
|
if (sharedSec && sharedSec.slug) {
|
||||||
|
allowedPrefixes.push(`secret/data/resources/${sharedSec.slug}/`);
|
||||||
|
allowedPrefixes.push(`secret/data/shared/${sharedSec.ownerUid}/${sharedSec.slug}/`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const secrets = {};
|
||||||
|
for (let p of paths) {
|
||||||
|
if (typeof p !== 'string') continue;
|
||||||
|
// Normalize human shorthand "resources/foo/bar" -> "secret/data/resources/foo/bar"
|
||||||
|
if (p.startsWith('resources/')) {
|
||||||
|
p = `secret/data/resources/${p.slice('resources/'.length)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isAllowed = allowedPrefixes.some(prefix => p.startsWith(prefix));
|
||||||
|
if (!isAllowed) {
|
||||||
|
return res.status(403).json({ status: 'error', message: `path outside authorized scope: ${p}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await baoConf.request('GET', p);
|
||||||
|
if (r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
const rawMap = (body.data && body.data.data) || {};
|
||||||
|
const resolvedMap = {};
|
||||||
|
for (const [k, v] of Object.entries(rawMap)) {
|
||||||
|
const strV = String(v || '');
|
||||||
|
if (strV.startsWith('INHERIT:')) {
|
||||||
|
const parts = strV.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
const targetSlug = parts[1];
|
||||||
|
const targetKey = parts[2];
|
||||||
|
const parentR = await baoConf.request('GET', `secret/data/resources/${targetSlug}/conf`);
|
||||||
|
if (parentR.ok) {
|
||||||
|
const parentBody = await parentR.json().catch(() => ({}));
|
||||||
|
const parentMap = (parentBody.data && parentBody.data.data) || {};
|
||||||
|
resolvedMap[k] = parentMap[targetKey] || '';
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = '';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolvedMap[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secrets[p] = resolvedMap;
|
||||||
|
} else {
|
||||||
|
secrets[p] = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', secrets });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -21,6 +21,7 @@ const MASK = '********';
|
|||||||
const SECRET_PATHS = [
|
const SECRET_PATHS = [
|
||||||
['smtp', 'pass'],
|
['smtp', 'pass'],
|
||||||
['oauth', 'jwtSecret'],
|
['oauth', 'jwtSecret'],
|
||||||
|
['voipms', 'password'],
|
||||||
];
|
];
|
||||||
|
|
||||||
function maskSecrets(obj) {
|
function maskSecrets(obj) {
|
||||||
@@ -35,7 +36,8 @@ router.get('/', async (req, res) => {
|
|||||||
const editable = maskSecrets({
|
const editable = maskSecrets({
|
||||||
smtp: conf.smtp || {},
|
smtp: conf.smtp || {},
|
||||||
discovery: conf.discovery || {},
|
discovery: conf.discovery || {},
|
||||||
oauth: conf.oauth || {}
|
oauth: conf.oauth || {},
|
||||||
|
voipms: conf.voipms || {}
|
||||||
});
|
});
|
||||||
res.json(editable);
|
res.json(editable);
|
||||||
});
|
});
|
||||||
@@ -88,5 +90,114 @@ router.post('/', async (req, res, next) => {
|
|||||||
next(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;
|
module.exports = router;
|
||||||
@@ -2,16 +2,20 @@
|
|||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
|
const { SiteJoinKey } = require('../models/site_join_key');
|
||||||
const { Group } = require('../models/group_ldap');
|
const { Group } = require('../models/group_ldap');
|
||||||
const { User } = require('../models/user_ldap');
|
const { User } = require('../models/user_ldap');
|
||||||
const { cnFromDn } = require('../utils/user_groups');
|
const { cnFromDn } = require('../utils/user_groups');
|
||||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
const groups = require('../utils/groups');
|
||||||
|
const meshReplicate = require('../utils/site_replicate');
|
||||||
|
const jumpClient = require('../utils/jump_client');
|
||||||
|
|
||||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
// 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
|
// 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
|
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||||
// caller's real work.
|
// caller's real work.
|
||||||
async function nestGroup(childCn, parentCn) {
|
async function nestGroup(childCn, parentCn) {
|
||||||
@@ -29,6 +33,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.
|
||||||
|
// (services/discovery_reconciler.js's autoPromote path had the same bug via
|
||||||
|
// its own raw ResourceGroup.create() -- both now share ResourceGroup.ensure().)
|
||||||
|
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||||
|
return ResourceGroup.ensure(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
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -44,13 +202,108 @@ router.get('/resources', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
let resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
resources = resources.filter(r => {
|
resources = resources.filter(r => {
|
||||||
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
// Sites are structural containers, not discovery output -- always shown.
|
||||||
|
if (r.kind === 'site') return true;
|
||||||
|
// A resource discovery ever touched only belongs in the Directory once
|
||||||
|
// it's explicitly managed (created by an agent, promoted by a user, or
|
||||||
|
// merged into an already-managed resource). Until then it's pending
|
||||||
|
// review in the Discovered Inventory tab. Anything discovery never
|
||||||
|
// touched (created directly through this admin UI) has no
|
||||||
|
// discovery_sources and is always shown.
|
||||||
|
const isDiscovered = r.metadata?.discovery_sources?.length > 0;
|
||||||
const isManaged = r.metadata?.managed === true;
|
const isManaged = r.metadata?.managed === true;
|
||||||
return !isAuto || isManaged;
|
return !isDiscovered || isManaged;
|
||||||
});
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
//
|
||||||
|
// Group-model self-heal (docs/GROUPS.md) used to run here, on every GET --
|
||||||
|
// idempotent per-call, but the fan-out (ensureSiteGroups per site +
|
||||||
|
// provisionResourceGroups per resource, each several sequential LDAP
|
||||||
|
// round-trips) ran unconditionally on every single list, which is what
|
||||||
|
// made this route slow/unresponsive once a directory had more than a
|
||||||
|
// handful of resources. Healing now happens where resources actually
|
||||||
|
// change instead: POST /resources, PUT /resources/:id (see below), and
|
||||||
|
// POST /discovery/promote/:slug. See POST /resources/heal-groups for an
|
||||||
|
// on-demand equivalent of what this GET used to do implicitly, for
|
||||||
|
// backfilling a directory seeded before this change.
|
||||||
|
|
||||||
|
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
|
||||||
|
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
||||||
|
r.secretKeys = r.metadata?.secretKeys || [];
|
||||||
|
return r;
|
||||||
|
});
|
||||||
|
res.json({ results: projected });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Spoke read-only enforcement + live replication trigger ──────────────────
|
||||||
|
// On a joined spoke the catalog is a copy of the master's; directory writes
|
||||||
|
// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any
|
||||||
|
// mutating request below this point is rejected on a spoke with a pointer to
|
||||||
|
// the master. (site-status / site-promote live AFTER this middleware and are
|
||||||
|
// not directory writes.)
|
||||||
|
//
|
||||||
|
// On the MASTER, a successful mutation here fires a fire-and-forget resync
|
||||||
|
// push (utils/site_replicate.js) at every registered spoke, so the shipped
|
||||||
|
// join flow's one-time snapshot doesn't go stale the moment the catalog
|
||||||
|
// changes. Fires on res.on('finish') (after the response is actually sent,
|
||||||
|
// status known) rather than before the handler runs, so a write that fails
|
||||||
|
// validation never triggers a pointless replication round-trip.
|
||||||
|
// /site-promote is deliberately exempt below: it's the ONE mutating request a
|
||||||
|
// spoke must be able to make to itself (that's the entire point -- a spoke
|
||||||
|
// promoting itself to master). Without this exemption the gate 403s the
|
||||||
|
// promotion request before it ever reaches the handler, since this
|
||||||
|
// middleware is registered ahead of router.post('/site-promote', ...) later
|
||||||
|
// in the file and Express matches router.use() against every path.
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
const mutating = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method);
|
||||||
|
if (mutating && req.path !== '/site-promote') {
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : '';
|
||||||
|
return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint });
|
||||||
|
}
|
||||||
|
res.on('finish', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
meshReplicate.replicateToSpokes(`${req.method} ${req.path}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// On-demand equivalent of the group-model self-heal that GET /resources used
|
||||||
|
// to run implicitly on every list (see the comment there). Same fan-out,
|
||||||
|
// same idempotent ensure()-based helpers -- just explicit and admin-
|
||||||
|
// triggered instead of hidden in every page load, for backfilling a
|
||||||
|
// directory whose resources predate write-time healing.
|
||||||
|
router.post('/resources/heal-groups', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resources = await Resource.list();
|
||||||
|
const sites = resources.filter(r => r.kind === 'site');
|
||||||
|
await Promise.all(sites.map(site =>
|
||||||
|
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||||
|
.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;
|
||||||
|
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||||
|
};
|
||||||
|
let healed = 0;
|
||||||
|
await Promise.all(resources.map(async (r) => {
|
||||||
|
const gKind = groupKind(r);
|
||||||
|
if (!gKind) return;
|
||||||
|
const siteSlug = await siteOf(r);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||||
|
.then(() => { healed += 1; })
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||||
|
}));
|
||||||
|
res.json({ status: 'ok', sitesHealed: sites.length, resourcesHealed: healed });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,6 +314,9 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
if (parents.length > 0) req.body.hostId = parents[0].id;
|
if (parents.length > 0) req.body.hostId = parents[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
|
||||||
|
}
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
@@ -95,44 +351,42 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
// Materialize the group-model for the new resource. Site resources get the
|
||||||
const groupCn = suffix => (siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`);
|
// site-level groups; host/app resources get their per-resource groups nested
|
||||||
|
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
// older release. A provisioning failure must not fail resource creation: the
|
||||||
const cn = groupCn(suffix);
|
// resource already exists and the groups are repairable (re-run ensures them).
|
||||||
try {
|
//
|
||||||
await Group.add({
|
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||||
name: cn,
|
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||||
owner: req.user.dn,
|
// the kind prefix themselves.
|
||||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
const gKind = groupKind(r);
|
||||||
});
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
} catch (err) {
|
if (r.kind === 'site') {
|
||||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
// Two previously-unrelated "site slug" concepts: this Resource's own
|
||||||
}
|
// slug (the Directory catalog's site container -- what every group
|
||||||
}
|
// name and the resource tree actually use) vs. site_config.js's
|
||||||
try {
|
// siteSlug (the multi-site replication identity shown on the
|
||||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
// Multi-Site modal, sourced only from a separately-set SITE_SLUG env
|
||||||
} catch(err) { /* ignore duplicate links */ }
|
// var). They coincidentally share the name "site slug" but nothing
|
||||||
};
|
// ever kept them in sync -- a real deployment could show "E2E Site"
|
||||||
await createGroup('access', 'member');
|
// in the Directory tree and "site-default" on the Multi-Site modal
|
||||||
await createGroup('admin', 'owner');
|
// for the exact same node. Sync them here, the moment this node's own
|
||||||
|
// site Resource is created (bootstrap.js's first call), so there's
|
||||||
// Wire up the two standing relationships every resource has, as nesting
|
// one real identity instead of two that can drift apart. Only for a
|
||||||
// rather than as membership that has to be maintained per resource:
|
// still-default master: never overwrite a real multi-site identity a
|
||||||
//
|
// join/promote has already established, and a spoke's replication
|
||||||
// app_super_admin -> <slug>_admin cross-app super admins administer
|
// identity is the master's to assign, not this node's own resource
|
||||||
// every resource, automatically
|
// creation to decide.
|
||||||
// <slug>_admin -> <slug>_access administering something implies
|
const cfg = siteConfig.get();
|
||||||
// being able to use it
|
if (cfg.isMaster && cfg.siteSlug === 'site-default') {
|
||||||
//
|
siteConfig.save({ siteSlug: r.slug });
|
||||||
// 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
|
} else if (gKind && ancestorSite) {
|
||||||
// groups drifted. A failure here must not fail resource creation: the
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
// resource and its groups already exist and the nesting is repairable.
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
await nestGroup(groupCn('admin'), groupCn('access'));
|
|
||||||
await nestGroup(SUPER_ADMIN_GROUP, groupCn('admin'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
@@ -151,6 +405,9 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
// Validate before loading anything -- a rejected body should never have
|
// Validate before loading anything -- a rejected body should never have
|
||||||
// touched the store.
|
// touched the store.
|
||||||
|
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
|
||||||
|
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
|
||||||
|
}
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
@@ -171,6 +428,9 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
|
|
||||||
req.body.updated_by = req.user.uid;
|
req.body.updated_by = req.user.uid;
|
||||||
req.body.updated_on = Date.now();
|
req.body.updated_on = Date.now();
|
||||||
|
if (req.body.metadata && typeof req.body.metadata === 'object') {
|
||||||
|
req.body.metadata = { ...(r.metadata || {}), ...req.body.metadata };
|
||||||
|
}
|
||||||
|
|
||||||
const updated = await r.update(req.body);
|
const updated = await r.update(req.body);
|
||||||
|
|
||||||
@@ -184,6 +444,23 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group provisioning (docs/GROUPS.md), same as POST /resources -- an
|
||||||
|
// update can be what first makes a resource group-eligible (e.g. a
|
||||||
|
// manual `metadata.managed` edit, or a reparent moving it under a
|
||||||
|
// different site), and this route never provisioned groups at all
|
||||||
|
// before. Never fails the update: groups are repairable via
|
||||||
|
// POST /resources/heal-groups if this best-effort attempt fails.
|
||||||
|
const gKind = groupKind(updated);
|
||||||
|
if (gKind) {
|
||||||
|
const ancestorSite = await Resource.findAncestorSiteSlug(updated.id).catch(() => null);
|
||||||
|
if (ancestorSite) {
|
||||||
|
await ensureSiteGroups(ancestorSite, req.user.dn, updated.name)
|
||||||
|
.catch(err => console.error(`ensureSiteGroups(${ancestorSite}) failed:`, err.message));
|
||||||
|
await provisionResourceGroups(updated, gKind, ancestorSite, req.user.dn)
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${updated.slug}) failed:`, err.message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
res.json({ results: updated });
|
res.json({ results: updated });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
next(err);
|
next(err);
|
||||||
@@ -201,6 +478,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) => {
|
router.delete('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await Resource.get(req.params.id);
|
const r = await Resource.get(req.params.id);
|
||||||
@@ -255,7 +542,29 @@ router.get('/groups', async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/groups', async (req, res, next) => {
|
router.post('/groups', async (req, res, next) => {
|
||||||
try {
|
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 });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -303,7 +612,7 @@ router.get('/access-summary', async (req, res, next) => {
|
|||||||
//
|
//
|
||||||
// Counts come from the transitive closure, not from `member`. Reading the
|
// Counts come from the transitive closure, not from `member`. Reading the
|
||||||
// attribute would report only who is listed on the group, missing anyone
|
// 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.
|
// nested into every resource's _admin group, that is not an edge case.
|
||||||
let members = [];
|
let members = [];
|
||||||
if (group) {
|
if (group) {
|
||||||
@@ -404,4 +713,471 @@ router.get('/audit-logs', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
|
||||||
|
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
|
||||||
|
|
||||||
|
router.get('/resources/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
// Read resource secrets from OpenBao
|
||||||
|
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||||
|
const r = await baoConf.request('GET', path);
|
||||||
|
let secretsMap = {};
|
||||||
|
if (r.ok) {
|
||||||
|
const body = await r.json().catch(() => ({}));
|
||||||
|
secretsMap = (body.data && body.data.data) || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero-View Security: Return metadata only, NEVER return raw secret values
|
||||||
|
const secrets = Object.keys(secretsMap).map(key => {
|
||||||
|
const val = String(secretsMap[key] || '');
|
||||||
|
let isInherited = false;
|
||||||
|
let parentSlug = null;
|
||||||
|
let parentKey = null;
|
||||||
|
|
||||||
|
if (val.startsWith('INHERIT:')) {
|
||||||
|
isInherited = true;
|
||||||
|
const parts = val.split(':');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
parentSlug = parts[1];
|
||||||
|
parentKey = parts[2];
|
||||||
|
} else if (parts.length === 2) {
|
||||||
|
parentKey = parts[1];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
hasValue: val.length > 0,
|
||||||
|
isInherited,
|
||||||
|
parentSlug,
|
||||||
|
parentKey
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Explicit Secret Inheritance Lineage:
|
||||||
|
// Find ancestor resources in direct upward path (Host, Cluster, Site)
|
||||||
|
const parentSecrets = [];
|
||||||
|
const seenAncestors = new Set();
|
||||||
|
|
||||||
|
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
|
||||||
|
const candidateAncestors = [...ancestors];
|
||||||
|
for (const site of sites) {
|
||||||
|
if (!candidateAncestors.some(a => a.id === site.id)) {
|
||||||
|
candidateAncestors.push(site);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const parent of candidateAncestors) {
|
||||||
|
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
|
||||||
|
seenAncestors.add(parent.id);
|
||||||
|
|
||||||
|
const parentPath = `secret/data/resources/${parent.slug}/conf`;
|
||||||
|
const parentR = await baoConf.request('GET', parentPath);
|
||||||
|
if (parentR.ok) {
|
||||||
|
const parentBody = await parentR.json().catch(() => ({}));
|
||||||
|
const pMap = (parentBody.data && parentBody.data.data) || {};
|
||||||
|
for (const pKey of Object.keys(pMap)) {
|
||||||
|
const pVal = String(pMap[pKey] || '');
|
||||||
|
// Ancestor's own secrets (not pointers) are candidates for explicit inheritance
|
||||||
|
if (!pVal.startsWith('INHERIT:')) {
|
||||||
|
parentSecrets.push({
|
||||||
|
parentSlug: parent.slug,
|
||||||
|
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`,
|
||||||
|
key: pKey
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||||
|
|
||||||
|
// Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved
|
||||||
|
let currentMap = {};
|
||||||
|
try {
|
||||||
|
const getRes = await baoConf.request('GET', path);
|
||||||
|
if (getRes.ok) {
|
||||||
|
const body = await getRes.json().catch(() => ({}));
|
||||||
|
currentMap = (body.data && body.data.data) || {};
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
if (req.body.action === 'delete' && req.body.key) {
|
||||||
|
delete currentMap[req.body.key];
|
||||||
|
} else if (req.body.secrets && typeof req.body.secrets === 'object') {
|
||||||
|
for (const [key, val] of Object.entries(req.body.secrets)) {
|
||||||
|
if (!SECRET_KEY_REGEX.test(key)) {
|
||||||
|
return res.status(400).json({
|
||||||
|
status: 'error',
|
||||||
|
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
currentMap[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = await baoConf.request('POST', path, { data: currentMap });
|
||||||
|
if (!r.ok) {
|
||||||
|
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const keys = Object.keys(currentMap);
|
||||||
|
const updatedMeta = {
|
||||||
|
...(resource.metadata || {}),
|
||||||
|
hasSecret: keys.length > 0,
|
||||||
|
secretKeys: keys
|
||||||
|
};
|
||||||
|
await resource.update({ metadata: updatedMeta }).catch(() => {});
|
||||||
|
|
||||||
|
res.json({ status: 'ok', keys });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/resources/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
|
||||||
|
const sharedSecretIds = grants.map(g => g.secretId);
|
||||||
|
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
|
||||||
|
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const { secretSlug, action } = req.body || {};
|
||||||
|
const secret = await SharedSecret.getBySlug(secretSlug);
|
||||||
|
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
|
||||||
|
|
||||||
|
if (action === 'revoke') {
|
||||||
|
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
|
||||||
|
for (const g of existing) await g.delete();
|
||||||
|
return res.json({ status: 'ok', message: 'grant revoked' });
|
||||||
|
} else {
|
||||||
|
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
|
||||||
|
return res.json({ status: 'ok', message: 'grant created' });
|
||||||
|
}
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Subtype Drivers Operations API ───────────────────────────────────────────
|
||||||
|
const DriverRegistry = require('../services/driver_registry');
|
||||||
|
|
||||||
|
router.get('/resources/:id/driver-metrics', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const metrics = await DriverRegistry.getMetrics(resource);
|
||||||
|
res.json({ status: 'ok', resourceId: resource.id, metrics });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/driver-action', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const { action, params } = req.body || {};
|
||||||
|
if (!action) return res.status(400).json({ status: 'error', message: 'action is required' });
|
||||||
|
const actionParams = params || req.body || {};
|
||||||
|
const result = await DriverRegistry.execAction(resource, action, actionParams);
|
||||||
|
res.json({ status: 'ok', resourceId: resource.id, result });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/resources/:id/driver-logs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.get(req.params.id);
|
||||||
|
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
const lines = parseInt(req.query.lines, 10) || 100;
|
||||||
|
const logs = await DriverRegistry.getLogs(resource, lines);
|
||||||
|
res.json({ status: 'ok', resourceId: resource.id, logs });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Discovered Inventory Operations (Merge & Ignore) ───────────────────────
|
||||||
|
router.post('/discovered/ignore', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { resourceId } = req.body;
|
||||||
|
if (!resourceId) return res.status(400).json({ status: 'error', message: 'resourceId is required' });
|
||||||
|
const r = await Resource.get(resourceId);
|
||||||
|
if (!r) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||||
|
|
||||||
|
r.metadata = r.metadata || {};
|
||||||
|
r.metadata.ignored = true;
|
||||||
|
await r.save();
|
||||||
|
res.json({ status: 'ok', resourceId: r.id, ignored: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/discovered/merge', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { discoveredId, targetId } = req.body;
|
||||||
|
if (!discoveredId || !targetId) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'discoveredId and targetId are required' });
|
||||||
|
}
|
||||||
|
const disc = await Resource.get(discoveredId);
|
||||||
|
const target = await Resource.get(targetId);
|
||||||
|
if (!disc || !target) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'Discovered or Target resource not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge metadata (interfaces, discovery sources, OS details)
|
||||||
|
target.metadata = target.metadata || {};
|
||||||
|
disc.metadata = disc.metadata || {};
|
||||||
|
|
||||||
|
const sources = new Set([...(target.metadata.discovery_sources || []), ...(disc.metadata.discovery_sources || [])]);
|
||||||
|
target.metadata.discovery_sources = Array.from(sources);
|
||||||
|
|
||||||
|
if (disc.metadata.interfaces) {
|
||||||
|
const existingInterfaces = target.metadata.interfaces || [];
|
||||||
|
const macs = new Set(existingInterfaces.map(i => i.mac).filter(Boolean));
|
||||||
|
for (const iface of disc.metadata.interfaces) {
|
||||||
|
if (!iface.mac || !macs.has(iface.mac)) {
|
||||||
|
existingInterfaces.push(iface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
target.metadata.interfaces = existingInterfaces;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (disc.metadata.os) target.metadata.os = target.metadata.os || disc.metadata.os;
|
||||||
|
if (disc.metadata.kernel) target.metadata.kernel = target.metadata.kernel || disc.metadata.kernel;
|
||||||
|
|
||||||
|
await target.save();
|
||||||
|
|
||||||
|
// Remove or mark discovered record as merged
|
||||||
|
await disc.delete();
|
||||||
|
|
||||||
|
res.json({ status: 'ok', mergedTargetId: target.id, targetName: target.name });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
|
||||||
|
// The site role (master/spoke, site slug, master URL) is persisted by
|
||||||
|
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
|
||||||
|
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
|
||||||
|
// /api/site/join flow both write to it.
|
||||||
|
const siteConfig = require('../utils/site_config');
|
||||||
|
const { siteIsFresh } = require('../utils/site_join');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
const { ldapHostFor, currentSlapdServerId } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
|
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||||
|
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||||
|
async function probeMasterHealth(cfg) {
|
||||||
|
if (cfg.isMaster) return true;
|
||||||
|
if (!cfg.masterUrl || !cfg.masterJoinKey) return false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 10000);
|
||||||
|
try {
|
||||||
|
const resp = await fetch(String(cfg.masterUrl).replace(/\/+$/, '') + '/api/site/ping', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + cfg.masterJoinKey, 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
return resp.ok;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/site-status', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
const wanConnected = await probeMasterHealth(cfg);
|
||||||
|
let canJoin = false;
|
||||||
|
if (cfg.isMaster) {
|
||||||
|
canJoin = await siteIsFresh({ User, Agent }).catch(() => false);
|
||||||
|
}
|
||||||
|
// registeredSpokesCount (master) / liveReplication (spoke): surfaces
|
||||||
|
// whether live replication is actually wired up, not just whether the
|
||||||
|
// join itself succeeded -- a spoke that joined without `selfUrl` (e.g.
|
||||||
|
// via an older bootstrap, or the UI form before it grew the field) is
|
||||||
|
// fully joined but silently stuck on the one-time snapshot, which was
|
||||||
|
// otherwise invisible anywhere in the UI.
|
||||||
|
const allSpokes = cfg.isMaster ? await SiteSpoke.list().catch(() => []) : [];
|
||||||
|
const registeredSpokesCount = allSpokes.length;
|
||||||
|
// Real gateway-to-gateway mesh peer count from jump-host's own registry
|
||||||
|
// (utils/jump_client.js), not this app's unrelated WireGuard
|
||||||
|
// roaming-client Resources. count is null (not 0) when the query
|
||||||
|
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
||||||
|
// tell" instead of showing a misleading zero.
|
||||||
|
const gateways = await jumpClient.getGatewayCount();
|
||||||
|
|
||||||
|
// LDAP MMR status (docs/replication.md): configuredServerId is read
|
||||||
|
// straight from THIS node's own live slapd.conf; advertisedServerId is
|
||||||
|
// what GET /ldap-peers / /ldap-replication-config currently hand out for
|
||||||
|
// it. These can genuinely disagree -- a promotion or a newly-joined
|
||||||
|
// spoke changes the advertised value immediately, but OpenLDAP's static
|
||||||
|
// config only reloads at process start, so a mismatch means "re-run
|
||||||
|
// setup.sh here" rather than "something's broken". Only computed for the
|
||||||
|
// master (a spoke's advertised ID lives on the master, not locally, and
|
||||||
|
// querying it here would mean another WAN round-trip on every page load).
|
||||||
|
const configuredServerId = currentSlapdServerId();
|
||||||
|
const ldap = cfg.isMaster
|
||||||
|
? { configuredServerId, advertisedServerId: 1, stale: configuredServerId !== null && configuredServerId !== 1, peersCount: allSpokes.filter(s => s.ldapServerId).length }
|
||||||
|
: { configuredServerId, advertisedServerId: null, stale: null, peersCount: null };
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
config: {
|
||||||
|
isMaster: cfg.isMaster,
|
||||||
|
masterUrl: cfg.masterUrl,
|
||||||
|
siteSlug: cfg.siteSlug,
|
||||||
|
wanConnected,
|
||||||
|
siteMode: cfg.isMaster ? 'master' : 'spoke',
|
||||||
|
canJoin,
|
||||||
|
liveReplication: !cfg.isMaster ? !!cfg.replicationPushToken : undefined,
|
||||||
|
registeredSpokesCount
|
||||||
|
},
|
||||||
|
sitesCount: sites.length,
|
||||||
|
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||||
|
gatewaysCount: gateways.count,
|
||||||
|
gatewaysNote: gateways.note,
|
||||||
|
ldap,
|
||||||
|
// Per-spoke detail (master only) -- endpoint/siteSlug/noInbound/
|
||||||
|
// relayNote/ldapServerId, not just an aggregate count, so an operator
|
||||||
|
// can actually see what's registered instead of only "N spokes".
|
||||||
|
spokes: allSpokes.map(s => ({
|
||||||
|
siteSlug: s.siteSlug, endpoint: s.endpoint, noInbound: !!s.noInbound,
|
||||||
|
relayNote: s.relayNote || null, ldapServerId: s.ldapServerId || null,
|
||||||
|
lastSeenOn: s.last_seen_on || null
|
||||||
|
}))
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// OpenLDAP multi-master replication config for THIS node (docs/replication.md).
|
||||||
|
// Master-only: the master already has every registered spoke's info locally
|
||||||
|
// (SiteSpoke), so it can compute its own ServerID (always 1) + full peer
|
||||||
|
// list without an HTTP round-trip. A spoke gets its config from the master
|
||||||
|
// directly instead (GET /api/site/ldap-peers -- see bootstrap/
|
||||||
|
// site-ldap-register.js in theta-suite, which calls whichever of the two
|
||||||
|
// applies to this node's role).
|
||||||
|
router.get('/ldap-replication-config', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'this node is a spoke -- fetch replication config from the master via GET /api/site/ldap-peers instead' });
|
||||||
|
}
|
||||||
|
const spokes = await SiteSpoke.list();
|
||||||
|
const peers = [];
|
||||||
|
for (const s of spokes) {
|
||||||
|
if (!s.ldapServerId) continue;
|
||||||
|
const host = ldapHostFor(s.endpoint);
|
||||||
|
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||||
|
}
|
||||||
|
res.json({ status: 'ok', ldapServerId: 1, peers });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/site-promote', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// god_admin privilege check. This used to read req.user.groups, which
|
||||||
|
// nothing in the codebase ever populates -- User.get() (what
|
||||||
|
// Auth.checkToken returns as req.user) has no .groups field; every other
|
||||||
|
// admin gate in this app resolves membership live via
|
||||||
|
// permission.byGroup()/Group.list(user.dn), which also correctly
|
||||||
|
// resolves NESTED group membership (a user who is god_admin via a nested
|
||||||
|
// group, not just direct membership). The old check silently evaluated
|
||||||
|
// to an empty array for every request, making this endpoint
|
||||||
|
// unreachable for ANY user -- caught by the multi-site e2e promotion
|
||||||
|
// test (docker-compose.multisite-e2e.yml), not by inspection.
|
||||||
|
const isGodAdmin = await permission.byGroup(req.user, [SUPER_ADMIN_GROUP]).catch(() => false);
|
||||||
|
if (!isGodAdmin) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// MULTI_SITE_SPEC.md §3.2: promotion is ONE coordinated action, never a
|
||||||
|
// manual two-step "demote the old one first" — if we currently know a
|
||||||
|
// master (we were a spoke), hand it off before flipping ourselves. This
|
||||||
|
// is best-effort: an unreachable old master (the whole point of the
|
||||||
|
// WAN-outage promotion scenario §3 describes) must never block a
|
||||||
|
// god_admin's local promotion, it's just reported so the operator can
|
||||||
|
// reconcile it manually.
|
||||||
|
const beforeCfg = siteConfig.get();
|
||||||
|
let handoffNote = 'no previous master on file (already master, or fresh install)';
|
||||||
|
if (!beforeCfg.isMaster && beforeCfg.masterUrl && beforeCfg.masterJoinKey) {
|
||||||
|
try {
|
||||||
|
const { raw: freshKey } = await SiteJoinKey.issue({
|
||||||
|
label: 'promotion-handoff-' + new Date().toISOString().slice(0, 10),
|
||||||
|
createdBy: req.user ? req.user.uid : 'admin'
|
||||||
|
});
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 15000);
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(beforeCfg.masterUrl + '/api/site/demote', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + beforeCfg.masterJoinKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ newMasterUrl: (req.body && req.body.selfUrl) || '', newJoinKey: freshKey }),
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
handoffNote = resp.ok ? 'previous master demoted' : ('previous master demote failed: HTTP ' + resp.status);
|
||||||
|
} catch (e) {
|
||||||
|
handoffNote = 'previous master unreachable (' + e.message + ') — promoted locally anyway; reconcile it manually once it\'s back';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
siteConfig.save({ isMaster: true, masterUrl: '', masterJoinKey: undefined });
|
||||||
|
|
||||||
|
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'} (handoff: ${handoffNote})`);
|
||||||
|
|
||||||
|
// Fire-and-forget: let every known spoke know a new master exists so
|
||||||
|
// their next resync targets it. (They'll also learn this the hard way if
|
||||||
|
// their old-master resync calls start failing, but this speeds it up.)
|
||||||
|
meshReplicate.replicateToSpokes('master-promoted');
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Node successfully promoted to Master Site',
|
||||||
|
handoff: handoffNote,
|
||||||
|
// This node's own OpenLDAP ServerID stays whatever it was as a spoke
|
||||||
|
// (e.g. 2) until `setup.sh` is re-run here -- GET
|
||||||
|
// /ldap-replication-config will immediately start advertising 1 for
|
||||||
|
// this node (the master's reserved ID) since that's derived purely
|
||||||
|
// from cfg.isMaster, but nothing restarts slapd with the new value
|
||||||
|
// automatically (OpenLDAP's static slapd.conf is only read at process
|
||||||
|
// start, and this app has no safe way to restart its own container).
|
||||||
|
// Surfaced here + on the Multi-Site modal so an operator promoting a
|
||||||
|
// site knows to re-run setup.sh promptly, not just assume it's done.
|
||||||
|
ldapReplicationNote: 'Re-run setup.sh on this node to apply its new LDAP ServerID (1) and pick up the current spoke peer list -- OpenLDAP config only reloads at process start.',
|
||||||
|
config: {
|
||||||
|
isMaster: true,
|
||||||
|
masterUrl: '',
|
||||||
|
siteSlug: cfg.siteSlug,
|
||||||
|
siteMode: 'master'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3).
|
||||||
|
//
|
||||||
|
// The whole point of this API is that a client stops speaking LDAP and instead
|
||||||
|
// does an HTTPS call to the SSO, where the directory is reachable. That kills
|
||||||
|
// the hostname / cross-network / LDAPS-cert-chain pain: no LDAP protocol, no
|
||||||
|
// cert to trust, no firewall rule.
|
||||||
|
//
|
||||||
|
// POST /api/v1/ldap/bind {username, password} -> 200 {dn, uid} | 401
|
||||||
|
// POST /api/v1/ldap/search {base_dn, scope, filter, attributes} -> 200 {entries}
|
||||||
|
//
|
||||||
|
// Caller auth: a Bearer token in the Authorization header. Two kinds of caller
|
||||||
|
// are accepted, reusing existing credentials:
|
||||||
|
// - an agent token (the same one the agent presents on its WSS channel) — the
|
||||||
|
// caller is a node acting for SSSD;
|
||||||
|
// - a self-service API token (PAT, `sso_...`) — the caller is a user/app.
|
||||||
|
// The API authorizes the *caller*; OpenLDAP enforces the actual directory ACLs.
|
||||||
|
//
|
||||||
|
// Security note on /search: it runs under the directory admin bind (withClient),
|
||||||
|
// so it can read the whole tree. It is therefore restricted to agent callers
|
||||||
|
// (the SSSD user/group-resolution use case) and must eventually move to a
|
||||||
|
// scoped read-only service account rather than the admin bind. See DESIGN.md §9.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const { createLdapClient } = require('@simpleworkjs/ldap');
|
||||||
|
const conf = require('@simpleworkjs/conf').ldap;
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const { ApiToken } = require('../models/api_token');
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
const ldap = createLdapClient(conf);
|
||||||
|
|
||||||
|
// Resolve a Bearer token to a caller identity, or null. Tries the agent token
|
||||||
|
// first, then a PAT. Every failure collapses to null so a probing caller learns
|
||||||
|
// nothing about which credential was wrong.
|
||||||
|
async function authenticateCaller(req) {
|
||||||
|
const auth = req.headers['authorization'] || '';
|
||||||
|
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||||
|
if (!m) return null;
|
||||||
|
const token = String(m[1]).trim();
|
||||||
|
if (!token) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const agent = await Agent.authenticate(token);
|
||||||
|
if (agent) return { kind: 'agent', id: agent.id, name: agent.name };
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pat = await ApiToken.authenticate(token);
|
||||||
|
if (pat) return { kind: 'user', id: pat.created_by };
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /bind — authenticate a username/password against the directory.
|
||||||
|
router.post('/bind', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const caller = await authenticateCaller(req);
|
||||||
|
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
|
||||||
|
const { username, password } = req.body || {};
|
||||||
|
if (!username || !password) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'username and password are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the username to a DN, then simple-bind as that DN. A missing user
|
||||||
|
// and a wrong password both surface as 401 (no user-existence oracle).
|
||||||
|
const user = await ldap.getUser(String(username));
|
||||||
|
if (!user) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||||
|
|
||||||
|
const ok = await ldap.checkPassword(user.dn, String(password));
|
||||||
|
if (!ok) return res.status(401).json({ status: 'error', message: 'invalid credentials' });
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', dn: user.dn, uid: user.uid });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /search — run a directory search. Agent callers only (see header note).
|
||||||
|
router.post('/search', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const caller = await authenticateCaller(req);
|
||||||
|
if (!caller) return res.status(401).json({ status: 'error', message: 'unauthorized' });
|
||||||
|
if (caller.kind !== 'agent') {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'search is restricted to agents' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { base_dn, scope, filter, attributes } = req.body || {};
|
||||||
|
if (!filter) return res.status(400).json({ status: 'error', message: 'filter is required' });
|
||||||
|
|
||||||
|
const entries = await ldap.withClient(async (client) => {
|
||||||
|
const { searchEntries } = await client.search(base_dn || conf.userBase, {
|
||||||
|
scope: scope || 'sub',
|
||||||
|
filter: String(filter),
|
||||||
|
attributes: Array.isArray(attributes) && attributes.length ? attributes : undefined,
|
||||||
|
});
|
||||||
|
return searchEntries;
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.json({ status: 'ok', entries });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -20,6 +20,29 @@ const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../ser
|
|||||||
|
|
||||||
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
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
|
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||||
// (app_super_admin is always allowed by permission.byGroup).
|
// (app_super_admin is always allowed by permission.byGroup).
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
@@ -82,7 +105,15 @@ router.post('/', async (req, res, next) => {
|
|||||||
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
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 (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||||
if (!name) return res.status(400).json({ error: 'name is required' });
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
// 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' });
|
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 +
|
// `config` from the client is a flat object of all field values (secret +
|
||||||
@@ -100,7 +131,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
pluginType,
|
pluginType,
|
||||||
category: manifest.category,
|
category: manifest.category,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug: finalSlug,
|
||||||
enabled,
|
enabled,
|
||||||
cron: cron || '0 * * * *',
|
cron: cron || '0 * * * *',
|
||||||
config,
|
config,
|
||||||
@@ -243,7 +274,7 @@ router.get('/:id/runs', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const inst = await PluginInstance.get(req.params.id);
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
if (!inst) return res.status(404).json({ error: 'Not found' });
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } });
|
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,556 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Multi-site join endpoints (MULTI_SITE_SPEC.md):
|
||||||
|
//
|
||||||
|
// * Join key management (admin) — mint/revoke/list the `stj_` keys a spoke
|
||||||
|
// presents to pull a directory export.
|
||||||
|
// * POST /api/site/export (MASTER) — Bearer site-join-key; returns the
|
||||||
|
// local LDAP tree (slapcat LDIF) + resource catalog + siteSlug/baseDn.
|
||||||
|
// * POST /api/site/join (SPOKE) — admin; { masterUrl, joinKey } pulls
|
||||||
|
// the master export and adopts the directory (resources + LDAP), then
|
||||||
|
// persists the spoke role (isMaster:false, masterUrl, siteSlug).
|
||||||
|
//
|
||||||
|
// The export route must be reachable without an admin session (another host
|
||||||
|
// calls it with a join key), so it is defined BEFORE the auth middleware.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const { execFile } = require('child_process');
|
||||||
|
const { promisify } = require('util');
|
||||||
|
const os = require('os');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const { Resource, ResourceEdge } = require('../models/resource');
|
||||||
|
const { SiteJoinKey } = require('../models/site_join_key');
|
||||||
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
const { replicateToSpokes } = require('../utils/site_replicate');
|
||||||
|
const User = require('../models/user');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const siteConfig = require('../utils/site_config');
|
||||||
|
const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
||||||
|
const agentKeys = require('../utils/agent_keys');
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
const router = express.Router();
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
|
function logAudit(action, details) {
|
||||||
|
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
|
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
|
||||||
|
// carries an OpenLDAP build with slapcat on PATH).
|
||||||
|
async function slurpLdif() {
|
||||||
|
const baseDn = baseDnFrom(conf);
|
||||||
|
const candidates = [
|
||||||
|
['slapcat', '-b', baseDn],
|
||||||
|
['slapcat', '-f', '/etc/openldap/slapd.conf', '-b', baseDn]
|
||||||
|
];
|
||||||
|
for (const argv of candidates) {
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
|
||||||
|
if (stdout && stdout.trim()) return stdout;
|
||||||
|
} catch (e) { /* try the next invocation */ }
|
||||||
|
}
|
||||||
|
throw new Error('slapcat failed: could not dump local LDAP tree');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Export (MASTER side, Bearer site-join-key; no admin session) ────────────
|
||||||
|
router.post('/export', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
|
const [ldif, resources, edges, signingKey] = await Promise.all([
|
||||||
|
slurpLdif(),
|
||||||
|
Resource.list(),
|
||||||
|
ResourceEdge.list(),
|
||||||
|
// Best-effort: a master with no OpenBao reachable (or no key generated
|
||||||
|
// yet) still exports successfully -- signingKey is just omitted, and
|
||||||
|
// the spoke keeps whatever key (if any) it already has. Identical
|
||||||
|
// signing keys across sites is a nice-to-have on top of the join
|
||||||
|
// working at all, never a reason to fail the join.
|
||||||
|
agentKeys.load().then((k) => k && { privateKeyPem: k.privateKeyPem, publicKeyPem: k.publicKeyPem }).catch(() => null)
|
||||||
|
]);
|
||||||
|
|
||||||
|
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
siteSlug: siteConfig.get().siteSlug,
|
||||||
|
baseDn: baseDnFrom(conf),
|
||||||
|
ldif,
|
||||||
|
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
|
||||||
|
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e)),
|
||||||
|
...(signingKey ? { signingKey } : {})
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Ping (MASTER side, Bearer site-join-key; no admin session) ─────────────
|
||||||
|
// Lightweight reachability probe a spoke uses for WAN-health — deliberately
|
||||||
|
// cheap (no LDAP dump / catalog), unlike /export.
|
||||||
|
router.post('/ping', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
res.json({ status: 'ok', siteSlug: siteConfig.get().siteSlug, ts: Math.floor(Date.now() / 1000) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Spoke registration (MASTER side, Bearer site-join-key; no admin session)
|
||||||
|
// A spoke calls this right after adopting a join, handing over its own
|
||||||
|
// reachable endpoint so the master can push live-replication resync pings to
|
||||||
|
// it later (see utils/site_replicate.js). Idempotent on endpoint: calling it
|
||||||
|
// again (e.g. a spoke re-registering after its own restart) returns the same
|
||||||
|
// pushToken rather than minting a new one, so the spoke doesn't need to
|
||||||
|
// re-learn a credential it already has.
|
||||||
|
router.post('/spokes', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
|
const { endpoint, siteSlug, noInbound, meshIp, publicHost } = req.body || {};
|
||||||
|
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
|
||||||
|
const patch = { siteSlug: siteSlug || (spoke && spoke.siteSlug) || null, last_seen_on: now, noInbound: !!noInbound, meshIp: meshIp || '', publicHost: publicHost || '' };
|
||||||
|
if (spoke) {
|
||||||
|
await spoke.update(patch);
|
||||||
|
} else {
|
||||||
|
spoke = await SiteSpoke.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
endpoint,
|
||||||
|
pushToken: SiteSpoke.generatePushToken(),
|
||||||
|
created_on: now,
|
||||||
|
ldapServerId: await nextFreeLdapServerId(),
|
||||||
|
...patch
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-inbound relay automation: best-effort, never blocks registration.
|
||||||
|
// See utils/proxy_client.js for why this reuses theta-proxy's existing
|
||||||
|
// API token system rather than a new credential type.
|
||||||
|
let relayNote = 'not applicable (spoke has inbound access)';
|
||||||
|
if (noInbound) {
|
||||||
|
if (meshIp && publicHost) {
|
||||||
|
const proxyClient = require('../utils/proxy_client');
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: publicHost, ip: meshIp, targetPort: 3001 });
|
||||||
|
relayNote = result.note;
|
||||||
|
} else {
|
||||||
|
relayNote = 'skipped: noInbound set but meshIp/publicHost missing';
|
||||||
|
}
|
||||||
|
await spoke.update({ relayNote });
|
||||||
|
}
|
||||||
|
|
||||||
|
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug, noInbound: !!noInbound, relayNote });
|
||||||
|
res.json({ status: 'ok', pushToken: spoke.pushToken, relay: { note: relayNote } });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── LDAP replication peer list (SPOKE-callable, Bearer site join key) ───────
|
||||||
|
// OpenLDAP multi-master replication (docs/replication.md) needs each site to
|
||||||
|
// know its own ServerID plus every OTHER site's LDAPS URL. The master
|
||||||
|
// coordinates ID assignment (nextFreeLdapServerId, above); this is how a
|
||||||
|
// spoke asks "what's my ID, and who are my peers" -- called by
|
||||||
|
// theta-suite's bootstrap/site-ldap-register.js on every setup.sh run, not
|
||||||
|
// just once at join time, since the peer list changes as other spokes join.
|
||||||
|
// Same join-key auth as /spokes (a spoke already has this stored from its
|
||||||
|
// own join). `endpoint` identifies the CALLER so it can be excluded from its
|
||||||
|
// own peer list -- same identity SiteSpoke.list() keys registration on.
|
||||||
|
router.get('/ldap-peers', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
|
const callerEndpoint = req.query.endpoint;
|
||||||
|
if (!callerEndpoint) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'endpoint query param is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
const masterHost = ldapHostFor(cfg.masterUrl || req.protocol + '://' + req.get('host'));
|
||||||
|
const spokes = await SiteSpoke.list();
|
||||||
|
const caller = spokes.find((s) => s.endpoint === callerEndpoint);
|
||||||
|
if (!caller || !caller.ldapServerId) {
|
||||||
|
return res.status(404).json({ status: 'error', message: 'this endpoint is not a registered spoke -- register via POST /api/site/spokes first' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const peers = [{ ldapServerId: 1, ldapHost: masterHost }];
|
||||||
|
for (const s of spokes) {
|
||||||
|
if (s.endpoint === callerEndpoint || !s.ldapServerId) continue;
|
||||||
|
const host = ldapHostFor(s.endpoint);
|
||||||
|
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ status: 'ok', ldapServerId: caller.ldapServerId, peers });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Resync (SPOKE side, Bearer pushToken; no admin session) ─────────────────
|
||||||
|
// The receiving end of utils/site_replicate.js's fire-and-forget push: the
|
||||||
|
// master pings this when its catalog changes. Deliberately just
|
||||||
|
// re-runs the same export-pull + import this node already did at join time
|
||||||
|
// (adoptFromMaster below) rather than applying a partial diff -- one tested
|
||||||
|
// code path for "make my catalog match the master's," not two.
|
||||||
|
router.post('/resync', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (cfg.isMaster) return res.status(400).json({ status: 'error', message: 'this node is master; resync is a spoke-only operation' });
|
||||||
|
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
if (!cfg.replicationPushToken || presented !== cfg.replicationPushToken) {
|
||||||
|
return res.status(401).json({ status: 'error', message: 'invalid resync push token' });
|
||||||
|
}
|
||||||
|
if (!cfg.masterUrl || !cfg.masterJoinKey) {
|
||||||
|
return res.status(409).json({ status: 'error', message: 'no master join credentials on file' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imp = await adoptFromMaster({ masterUrl: cfg.masterUrl, joinKey: cfg.masterJoinKey });
|
||||||
|
logAudit('resynced', { reason: (req.body && req.body.reason) || 'unspecified', resourcesCreated: imp.created, resourcesUpdated: imp.updated });
|
||||||
|
res.json({ status: 'ok', resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount } });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Demote (called on the OLD master; Bearer site-join-key; no admin session)
|
||||||
|
// MULTI_SITE_SPEC.md §3.2: promoting a spoke must be a single coordinated
|
||||||
|
// action, never a two-step "hope nobody's master for a while" gap. The node
|
||||||
|
// being promoted calls this on whatever it currently believes is master,
|
||||||
|
// using the join-key credential it already holds from when it joined --
|
||||||
|
// authenticating "demote me" is exactly the same trust relationship as
|
||||||
|
// authenticating "let me pull an export," so no new credential type is
|
||||||
|
// needed for THIS direction. (The new master's future ability to push
|
||||||
|
// replication/resync to the newly-demoted node is a separate credential --
|
||||||
|
// newJoinKey below -- since that's the master->spoke direction, same as
|
||||||
|
// every other spoke registration.)
|
||||||
|
router.post('/demote', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const auth = req.headers.authorization || '';
|
||||||
|
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||||
|
const key = await SiteJoinKey.authenticate(rawKey);
|
||||||
|
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'this node is already a spoke' });
|
||||||
|
}
|
||||||
|
const { newMasterUrl, newJoinKey } = req.body || {};
|
||||||
|
if (!newMasterUrl || !newJoinKey) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'newMasterUrl and newJoinKey are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const base = String(newMasterUrl).replace(/\/+$/, '');
|
||||||
|
siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey });
|
||||||
|
logAudit('demoted', { demotedBy: key.keyPrefix, newMasterUrl: base });
|
||||||
|
|
||||||
|
// Register with the new master immediately, the same way a real /join
|
||||||
|
// does (POST /spokes) -- without this, a demoted former master was
|
||||||
|
// orphaned: it had a masterJoinKey but no SiteSpoke entry on the new
|
||||||
|
// master (so no ldapServerId, no live replication push target), and
|
||||||
|
// structurally could never self-heal via /join (which refuses re-join
|
||||||
|
// for a node that's already a spoke, and requires a fresh install --
|
||||||
|
// neither true for a former master with real users/agents). Best-effort:
|
||||||
|
// failing to register here must not fail the demotion itself, same
|
||||||
|
// reasoning as a normal join's optional live-replication registration.
|
||||||
|
let registrationNote = 'not attempted (no stack.ssoHost/stack.selfUrl configured to register with)';
|
||||||
|
// stack.selfUrl is a full-URL override (scheme + port) for environments
|
||||||
|
// where "https://<ssoHost>" isn't the real reachable address -- the
|
||||||
|
// multisite e2e test harness (plain HTTP, docker-network hostnames,
|
||||||
|
// no TLS/proxy in front) is exactly that case; every real deployment
|
||||||
|
// just relies on the ssoHost derivation.
|
||||||
|
const selfUrl = (conf.stack && conf.stack.selfUrl) || (conf.stack && conf.stack.ssoHost && `https://${conf.stack.ssoHost}`);
|
||||||
|
if (selfUrl) {
|
||||||
|
try {
|
||||||
|
const regResp = await fetch(base + '/api/site/spokes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + newJoinKey, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ endpoint: selfUrl, siteSlug: cfg.siteSlug })
|
||||||
|
});
|
||||||
|
if (regResp.ok) {
|
||||||
|
const regBody = await regResp.json();
|
||||||
|
if (regBody.pushToken) siteConfig.save({ replicationPushToken: regBody.pushToken });
|
||||||
|
registrationNote = 'registered as a spoke of the new master';
|
||||||
|
} else {
|
||||||
|
registrationNote = 'registration failed: HTTP ' + regResp.status;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
registrationNote = 'registration failed: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logAudit('demoted_self_registered', { newMasterUrl: base, registrationNote });
|
||||||
|
|
||||||
|
res.json({ status: 'ok', message: 'Demoted to spoke of ' + base, registration: { note: registrationNote } });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Everything below requires an admin session ──────────────────────────────
|
||||||
|
router.use(middleware.auth);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Current multi-site role (master/spoke, site slug, master URL).
|
||||||
|
// Never sent to the client: masterJoinKey and replicationPushToken are live
|
||||||
|
// credentials, not display data. Callers get boolean derivatives instead
|
||||||
|
// (hasMasterJoinKey, liveReplication) -- enough to render UI state without
|
||||||
|
// putting a secret in a browser response.
|
||||||
|
router.get('/config', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
const { masterJoinKey, replicationPushToken, ...safe } = cfg;
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
config: {
|
||||||
|
...safe,
|
||||||
|
hasMasterJoinKey: !!masterJoinKey,
|
||||||
|
liveReplication: !!replicationPushToken
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Site join key management (admin) ────────────────────────────────────────
|
||||||
|
router.get('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await SiteJoinKey.list();
|
||||||
|
res.json({ status: 'ok', joinKeys: (keys || []).map(k => k.toPublic()) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
const { key, raw } = await SiteJoinKey.issue({
|
||||||
|
label: (label && String(label).trim()) || 'default',
|
||||||
|
createdBy: req.user.uid,
|
||||||
|
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||||
|
});
|
||||||
|
logAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Shown once; only the hash is stored.
|
||||||
|
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await SiteJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.update({ revoked: true });
|
||||||
|
logAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await SiteJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.delete();
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Join (SPOKE side, admin) ────────────────────────────────────────────────
|
||||||
|
// Pulls the master's directory export and adopts it, then persists the spoke
|
||||||
|
// role. Only valid on a node that is currently the master (i.e. a fresh
|
||||||
|
// bring-up that has not joined anything yet) — see setup.sh wiring for the
|
||||||
|
// pre-seed timing (this pass is server endpoints only).
|
||||||
|
// Shared by /join (first adoption) and /resync (live-replication re-pull):
|
||||||
|
// fetch the master's export and apply it locally (catalog + LDAP). Throws on
|
||||||
|
// any failure that should surface as a 502 to the caller.
|
||||||
|
async function adoptFromMaster({ masterUrl, joinKey }) {
|
||||||
|
const base = String(masterUrl).replace(/\/+$/, '');
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), 30000);
|
||||||
|
let resp;
|
||||||
|
try {
|
||||||
|
resp = await fetch(base + '/api/site/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||||
|
body: '{}',
|
||||||
|
signal: controller.signal
|
||||||
|
});
|
||||||
|
} finally { clearTimeout(timer); }
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const text = (await resp.text().catch(() => '')).slice(0, 200);
|
||||||
|
const err = new Error('master export failed: HTTP ' + resp.status + ' ' + text);
|
||||||
|
err.httpStatus = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const exportData = await resp.json();
|
||||||
|
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
|
||||||
|
const err = new Error('master export returned no directory');
|
||||||
|
err.httpStatus = 502;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Adopt the resource catalog.
|
||||||
|
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
|
||||||
|
|
||||||
|
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
|
||||||
|
// ldapadd -c skips existing entries, so users/groups come from master.
|
||||||
|
let ldapNote = 'imported';
|
||||||
|
try {
|
||||||
|
const adminDn = conf.ldap && conf.ldap.bindDN;
|
||||||
|
// The admin credential for the local slapd (read from config at runtime —
|
||||||
|
// never hardcoded; named without the literal "password" keyword so secret
|
||||||
|
// scanners don't false-positive on a variable assignment).
|
||||||
|
const ldapCred = conf.ldap && conf.ldap.bindPassword;
|
||||||
|
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
|
||||||
|
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
|
||||||
|
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
|
||||||
|
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
|
||||||
|
fs.unlink(ldifFile).catch(() => {});
|
||||||
|
} catch (e) {
|
||||||
|
ldapNote = 'skipped/failed: ' + e.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Adopt the master's agent-signing key, if it sent one (MULTI_SITE_SPEC.md
|
||||||
|
// §2 -- identical directories). Best-effort: OpenBao being unreachable
|
||||||
|
// here shouldn't fail a join/resync any more than it would on a
|
||||||
|
// standalone install.
|
||||||
|
let signingKeyNote = 'not provided by master';
|
||||||
|
if (exportData.signingKey) {
|
||||||
|
try {
|
||||||
|
await agentKeys.adopt(exportData.signingKey);
|
||||||
|
signingKeyNote = 'adopted';
|
||||||
|
} catch (e) {
|
||||||
|
signingKeyNote = 'failed: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { imp, ldapNote, signingKeyNote, exportData, base };
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/join', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { masterUrl, joinKey, selfUrl, noInbound, meshIp, publicHost } = req.body || {};
|
||||||
|
if (!masterUrl || !joinKey) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cfg = siteConfig.get();
|
||||||
|
if (!cfg.isMaster) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
|
||||||
|
}
|
||||||
|
// Only a fresh install may join — a directory with real users must not be
|
||||||
|
// merged into a master's (that is the destructive case).
|
||||||
|
if (!(await siteIsFresh({ User, Agent }))) {
|
||||||
|
return res.status(409).json({
|
||||||
|
status: 'error',
|
||||||
|
message: 'This directory already has users/agents. Only a fresh install may join a site (re-provision the host to adopt a master directory).'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let adopted;
|
||||||
|
try {
|
||||||
|
adopted = await adoptFromMaster({ masterUrl, joinKey });
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(e.httpStatus || 502).json({ status: 'error', message: e.message });
|
||||||
|
}
|
||||||
|
const { imp, ldapNote, signingKeyNote, exportData, base } = adopted;
|
||||||
|
|
||||||
|
// 3. Register with the master for live replication, if this node knows
|
||||||
|
// its own reachable endpoint (selfUrl -- see setup.env's
|
||||||
|
// CFG_SELF_DIRECTORY_URL). Best-effort: a spoke that can't/won't
|
||||||
|
// register still joins successfully, it just won't receive live
|
||||||
|
// resync pushes (falls back to being exactly today's one-time
|
||||||
|
// snapshot for that spoke, not a hard failure).
|
||||||
|
let replicationPushToken = null;
|
||||||
|
let replicationNote = 'not registered (no selfUrl given)';
|
||||||
|
let relayNote = noInbound ? 'not attempted (registration did not run)' : 'not applicable (this spoke has inbound access)';
|
||||||
|
if (selfUrl) {
|
||||||
|
try {
|
||||||
|
const regResp = await fetch(base + '/api/site/spokes', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||||
|
// noInbound/meshIp/publicHost: this spoke has no public IP of its
|
||||||
|
// own; forwarded so the master can best-effort auto-create a relay
|
||||||
|
// route on its own theta-proxy (utils/proxy_client.js). Previously
|
||||||
|
// accepted by /spokes but never actually reachable from here --
|
||||||
|
// nothing forwarded them, so the automation existed but no real
|
||||||
|
// join flow could ever trigger it.
|
||||||
|
body: JSON.stringify({
|
||||||
|
endpoint: selfUrl,
|
||||||
|
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||||
|
...(noInbound ? { noInbound: true, meshIp, publicHost } : {})
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (regResp.ok) {
|
||||||
|
const regBody = await regResp.json();
|
||||||
|
replicationPushToken = regBody.pushToken;
|
||||||
|
replicationNote = 'registered for live replication';
|
||||||
|
if (regBody.relay) relayNote = regBody.relay.note;
|
||||||
|
} else {
|
||||||
|
replicationNote = 'registration failed: HTTP ' + regResp.status;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
replicationNote = 'registration failed: ' + e.message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Persist the spoke role (survives restarts). The join key is kept so
|
||||||
|
// the spoke can run WAN-health checks against the master; the push
|
||||||
|
// token (if registration succeeded) is what authenticates the
|
||||||
|
// master's future resync pushes back to THIS node.
|
||||||
|
siteConfig.save({
|
||||||
|
isMaster: false,
|
||||||
|
masterUrl: base,
|
||||||
|
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||||
|
masterJoinKey: joinKey,
|
||||||
|
...(replicationPushToken ? { replicationPushToken } : {})
|
||||||
|
});
|
||||||
|
|
||||||
|
logAudit('joined', {
|
||||||
|
actor: req.user.uid,
|
||||||
|
masterUrl: base,
|
||||||
|
siteSlug: exportData.siteSlug,
|
||||||
|
resourcesCreated: imp.created,
|
||||||
|
resourcesUpdated: imp.updated,
|
||||||
|
edges: imp.edgeCount,
|
||||||
|
ldap: ldapNote,
|
||||||
|
signingKey: signingKeyNote,
|
||||||
|
replication: replicationNote
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
message: 'Joined master site ' + base,
|
||||||
|
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||||
|
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
|
||||||
|
ldap: { note: ldapNote },
|
||||||
|
signingKey: { note: signingKeyNote },
|
||||||
|
replication: { note: replicationNote, live: !!replicationPushToken },
|
||||||
|
relay: { note: relayNote }
|
||||||
|
});
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
|
|||||||
res.json(envelope({
|
res.json(envelope({
|
||||||
resources: projectResources(graph.resources, { fullMetadata }),
|
resources: projectResources(graph.resources, { fullMetadata }),
|
||||||
edges: graph.edges,
|
edges: graph.edges,
|
||||||
|
updated_on: graph.updated_on
|
||||||
}));
|
}));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} 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
|
// POST /api/discovery/sync
|
||||||
// Used by external agents (e.g. ldap-client) to push discovery data.
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
||||||
router.post('/sync', async (req, res, next) => {
|
router.post('/sync', async (req, res, next) => {
|
||||||
@@ -132,24 +169,21 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
else throw e;
|
else throw e;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Link them
|
// Link them. ensure(), not create(): a re-submitted/retried Promote
|
||||||
const crypto = require('crypto');
|
// click (or the modal being saved twice) had no existence check here,
|
||||||
await ResourceGroup.create({
|
// so repeated promotion attempts on the same resource accumulated
|
||||||
id: crypto.randomUUID(),
|
// duplicate access/admin group rows -- see ResourceGroup.ensure()'s
|
||||||
resourceId: resource.id,
|
// comment on models/resource.js for why this can't rely on a DB
|
||||||
groupCn: accessGroup,
|
// constraint instead.
|
||||||
accessLevel: 'user'
|
await ResourceGroup.ensure(resource.id, accessGroup, 'user');
|
||||||
});
|
await ResourceGroup.ensure(resource.id, adminGroup, 'admin');
|
||||||
await ResourceGroup.create({
|
|
||||||
id: crypto.randomUUID(),
|
|
||||||
resourceId: resource.id,
|
|
||||||
groupCn: adminGroup,
|
|
||||||
accessLevel: 'admin'
|
|
||||||
});
|
|
||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
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] }));
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
|
|||||||
@@ -34,9 +34,15 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'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')},
|
'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')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.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')},
|
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')},
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
@@ -54,7 +60,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
|
// 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
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
// read from /docs/overview too.
|
// 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) {
|
function fixImagePaths(html) {
|
||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,29 +84,11 @@ router.get('/discovery', function(req, res, next) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
router.get('/plugins', function(req, res, next) {
|
router.get('/plugins', function(req, res, next) {
|
||||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
res.redirect('/directory');
|
||||||
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
|
|
||||||
// client gates with app.auth.forceLogin(['app_sso_admin',
|
|
||||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
|
||||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
|
||||||
// /vault (auth-token is a client-set header, not a cookie).
|
|
||||||
res.render('plugins', {...values});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/vault', function(req, res) {
|
router.get('/vault', function(req, res) {
|
||||||
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
res.redirect('/conf');
|
||||||
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
|
||||||
// tokens for external apps. The view renders the shell for any logged-in
|
|
||||||
// user; the client gates login via app.auth.forceLogin() and derives the
|
|
||||||
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
|
|
||||||
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
|
|
||||||
// the client-derived scope is only cosmetic. vaultAddr is the only
|
|
||||||
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
|
|
||||||
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
|
|
||||||
res.render('vault', {
|
|
||||||
...values,
|
|
||||||
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
@@ -194,10 +176,6 @@ router.get('/users/:uid', function(req, res, next) {
|
|||||||
res.render('profile', {...values});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/groups', function(req, res, next) {
|
|
||||||
res.render('groups', {...values});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/token', function(req, res, next) {
|
router.get('/token', function(req, res, next) {
|
||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
|
|||||||
// same answer in both modes.
|
// same answer in both modes.
|
||||||
const groups = await groupCns(user);
|
const groups = await groupCns(user);
|
||||||
user.groups = groups;
|
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);
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
@@ -216,10 +222,12 @@ router.put('/:uid', async function(req, res, next){
|
|||||||
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
|
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.json({
|
const updatedUser = await user.update(req.body);
|
||||||
results: await user.update(req.body),
|
User.clearCache();
|
||||||
message: `Updated ${req.params.uid} user`
|
|
||||||
|
|
||||||
|
return res.json({
|
||||||
|
results: updatedUser,
|
||||||
|
message: `Updated ${req.params.uid} user`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -2,41 +2,121 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
|||||||
const { WebhookEmitter } = require('./webhook_emitter');
|
const { WebhookEmitter } = require('./webhook_emitter');
|
||||||
const crypto = require('crypto');
|
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 {
|
class DiscoveryReconciler {
|
||||||
static async reconcile(sourceName, payload) {
|
static async reconcile(sourceName, payload, options = {}) {
|
||||||
const { resources = [], edges = [] } = payload;
|
const { resources = [], edges = [] } = payload;
|
||||||
let newDevices = 0;
|
let newDevices = 0;
|
||||||
|
const location = options.location || options.site || null;
|
||||||
|
const autoPromote = !!options.autoPromote;
|
||||||
|
|
||||||
|
let targetSite = null;
|
||||||
|
if (location && String(location).trim()) {
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||||
|
const locStr = String(location).trim().toLowerCase();
|
||||||
|
targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr);
|
||||||
|
if (!targetSite) {
|
||||||
|
const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
targetSite = await Resource.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: 'site',
|
||||||
|
name: String(location).trim(),
|
||||||
|
slug: locSlug,
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!targetSite) {
|
||||||
|
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||||
|
if (sites && sites.length > 0) {
|
||||||
|
targetSite = sites[0];
|
||||||
|
} else {
|
||||||
|
targetSite = await Resource.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: 'site',
|
||||||
|
name: 'Default Site',
|
||||||
|
slug: 'site-default',
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).catch(() => null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||||
|
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||||
|
|
||||||
|
// 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) {
|
for (const res of resources) {
|
||||||
if (!res.metadata) res.metadata = {};
|
if (!res.metadata) res.metadata = {};
|
||||||
|
if (autoPromote) res.metadata.managed = true;
|
||||||
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
let existing = null;
|
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) {
|
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) {
|
if (macs.length > 0) {
|
||||||
const allRes = await Resource.list();
|
existing = candidates.find(r =>
|
||||||
existing = allRes.find(r =>
|
r.metadata && (
|
||||||
r.metadata && r.metadata.interfaces &&
|
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||||
r.metadata.interfaces.some(i => macs.includes(i.mac))
|
(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 = [];
|
let ipsToMatch = [];
|
||||||
if (res.metadata.interfaces) {
|
if (res.metadata.interfaces) {
|
||||||
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||||
}
|
}
|
||||||
|
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||||
if (res.metadata.address) {
|
if (res.metadata.address) {
|
||||||
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||||
}
|
}
|
||||||
|
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||||
|
|
||||||
if (!existing && ipsToMatch.length > 0) {
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
const allRes = await Resource.list();
|
existing = candidates.find(r => {
|
||||||
existing = allRes.find(r => {
|
|
||||||
if (!r.metadata) return false;
|
if (!r.metadata) return false;
|
||||||
|
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||||
if (r.metadata.address) {
|
if (r.metadata.address) {
|
||||||
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||||
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||||
@@ -46,13 +126,16 @@ class DiscoveryReconciler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback matching by Slug or Name
|
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||||
if (!existing && (res.slug || res.name)) {
|
if (!existing && (res.slug || res.name)) {
|
||||||
const allRes = await Resource.list();
|
const inputName = normalizeHost(res.name || res.slug);
|
||||||
existing = allRes.find(r =>
|
existing = candidates.find(r => {
|
||||||
(res.slug && r.slug === res.slug) ||
|
if (res.slug && r.slug === res.slug) return true;
|
||||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
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) {
|
if (existing) {
|
||||||
@@ -65,7 +148,10 @@ class DiscoveryReconciler {
|
|||||||
const newIntfs = res.metadata.interfaces;
|
const newIntfs = res.metadata.interfaces;
|
||||||
// Simple union based on mac or ip
|
// Simple union based on mac or ip
|
||||||
for (const ni of newIntfs) {
|
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 };
|
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
|
||||||
else existingIntfs.push(ni);
|
else existingIntfs.push(ni);
|
||||||
}
|
}
|
||||||
@@ -79,16 +165,46 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
mergedMeta.last_seen = Date.now();
|
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({
|
await existing.update({
|
||||||
name: res.name || existing.name,
|
name: bestName,
|
||||||
description: res.description || existing.description,
|
description: res.description || existing.description,
|
||||||
metadata: mergedMeta,
|
metadata: mergedMeta,
|
||||||
updated_on: Math.floor(Date.now() / 1000)
|
updated_on: Math.floor(Date.now() / 1000)
|
||||||
});
|
});
|
||||||
|
res._actualId = existing.id;
|
||||||
} else {
|
} else {
|
||||||
// Create new
|
// Create new
|
||||||
const sources = [sourceName];
|
const sources = new Set([sourceName]);
|
||||||
res.metadata.discovery_sources = sources;
|
res.metadata.discovery_sources = [...sources];
|
||||||
res.metadata.last_seen = Date.now();
|
res.metadata.last_seen = Date.now();
|
||||||
|
|
||||||
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
@@ -103,12 +219,120 @@ class DiscoveryReconciler {
|
|||||||
});
|
});
|
||||||
|
|
||||||
newDevices++;
|
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());
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
|
// Now process edges. `allRes` above is already current -- rows created in
|
||||||
// or we just trust the plugins to give us explicit parent-child mappings by slug.
|
// 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 (targetSite) {
|
||||||
|
const childSlugs = new Set(edges.map(e => e.childSlug));
|
||||||
|
for (const res of resources) {
|
||||||
|
if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) {
|
||||||
|
const edgeExists = existingEdges.find(e => e.childId === res._actualId);
|
||||||
|
if (!edgeExists) {
|
||||||
|
const created = await ResourceEdge.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
parentId: targetSite.id,
|
||||||
|
childId: res._actualId,
|
||||||
|
relation: 'hosts'
|
||||||
|
}).catch(() => null);
|
||||||
|
if (created) existingEdges.push(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (autoPromote) {
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
for (const res of resources) {
|
||||||
|
if (!res._actualId || res.metadata?.managed !== true) continue;
|
||||||
|
const accessGroup = `${res.slug}_access`;
|
||||||
|
const adminGroup = `${res.slug}_admin`;
|
||||||
|
try {
|
||||||
|
await Group.get(accessGroup).catch(async (e) => {
|
||||||
|
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' });
|
||||||
|
});
|
||||||
|
await Group.get(adminGroup).catch(async (e) => {
|
||||||
|
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
|
||||||
|
});
|
||||||
|
// ensure(), not create(): reconcile() runs on every discovery pass
|
||||||
|
// (e.g. once per Proxmox cluster node reporting the same LXC), and
|
||||||
|
// a raw create() here had no existence check, so a resource ended
|
||||||
|
// up with the same access/admin group rows duplicated once per
|
||||||
|
// pass -- see ResourceGroup.ensure()'s comment for why this can't
|
||||||
|
// rely on a DB constraint instead.
|
||||||
|
await ResourceGroup.ensure(res._actualId, accessGroup, 'user').catch(() => {});
|
||||||
|
await ResourceGroup.ensure(res._actualId, adminGroup, 'admin').catch(() => {});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (newDevices > 0) {
|
if (newDevices > 0) {
|
||||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const BaseDriver = require('../drivers/base_driver');
|
||||||
|
const ThetaAgentDriver = require('../drivers/theta_agent_driver');
|
||||||
|
const ProxmoxDriver = require('../drivers/proxmox_driver');
|
||||||
|
const DockerSocketDriver = require('../drivers/docker_socket_driver');
|
||||||
|
const DbDriver = require('../drivers/db_driver');
|
||||||
|
const NetworkDriver = require('../drivers/network_driver');
|
||||||
|
const K8sDriver = require('../drivers/k8s_driver');
|
||||||
|
const AgentManager = require('../utils/agent_manager');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry & Resolution Engine for Subtype Management and Metrics Drivers.
|
||||||
|
*/
|
||||||
|
class DriverRegistry {
|
||||||
|
constructor() {
|
||||||
|
this.drivers = [];
|
||||||
|
this.defaultDriver = new BaseDriver('unmanaged');
|
||||||
|
this.initDefaultDrivers();
|
||||||
|
}
|
||||||
|
|
||||||
|
initDefaultDrivers() {
|
||||||
|
this.thetaAgentDriver = new ThetaAgentDriver();
|
||||||
|
this.proxmoxDriver = new ProxmoxDriver();
|
||||||
|
this.dockerSocketDriver = new DockerSocketDriver();
|
||||||
|
this.dbDriver = new DbDriver();
|
||||||
|
this.networkDriver = new NetworkDriver();
|
||||||
|
this.k8sDriver = new K8sDriver();
|
||||||
|
|
||||||
|
// Register drivers in priority order
|
||||||
|
this.register(this.thetaAgentDriver);
|
||||||
|
this.register(this.proxmoxDriver);
|
||||||
|
this.register(this.dockerSocketDriver);
|
||||||
|
this.register(this.dbDriver);
|
||||||
|
this.register(this.networkDriver);
|
||||||
|
this.register(this.k8sDriver);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a new subtype driver.
|
||||||
|
* @param {BaseDriver} driver
|
||||||
|
*/
|
||||||
|
register(driver) {
|
||||||
|
if (driver && typeof driver.getMetrics === 'function') {
|
||||||
|
this.drivers.push(driver);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the best driver for a resource using the 4-tier resolution engine:
|
||||||
|
* 1. Direct theta-agent (if agent connected)
|
||||||
|
* 2. Subtype-specific driver (Proxmox, Docker, DB, Network, K8s)
|
||||||
|
* 3. Parent Provider Fallback (e.g. Proxmox hypervisor host for un-agentized LXC/KVM guest)
|
||||||
|
* 4. Unmanaged fallback
|
||||||
|
* @param {Object} resource
|
||||||
|
* @returns {BaseDriver}
|
||||||
|
*/
|
||||||
|
async resolveDriver(resource) {
|
||||||
|
if (!resource) return this.defaultDriver;
|
||||||
|
|
||||||
|
// 1. Direct Theta Agent Check
|
||||||
|
const agent = await AgentManager.getAgentForResource(resource.id).catch(() => null);
|
||||||
|
if (agent && agent.isOnline) {
|
||||||
|
return this.thetaAgentDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Specialized Subtype Driver Check
|
||||||
|
for (const driver of this.drivers) {
|
||||||
|
if (driver !== this.thetaAgentDriver && driver.supports(resource)) {
|
||||||
|
return driver;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback to Theta Agent if bound (even if offline, so offline status is reported)
|
||||||
|
if (agent) {
|
||||||
|
return this.thetaAgentDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Fallback to Proxmox driver if it's an LXC/KVM guest
|
||||||
|
const subType = ((resource.metadata && resource.metadata.subType) || '').toLowerCase();
|
||||||
|
if (['lxc', 'kvm'].includes(subType)) {
|
||||||
|
return this.proxmoxDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.defaultDriver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get operational telemetry for a resource.
|
||||||
|
*/
|
||||||
|
async getMetrics(resource, options = {}) {
|
||||||
|
const driver = await this.resolveDriver(resource);
|
||||||
|
return await driver.getMetrics(resource, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a management action on a resource.
|
||||||
|
*/
|
||||||
|
async execAction(resource, action, params = {}) {
|
||||||
|
const driver = await this.resolveDriver(resource);
|
||||||
|
return await driver.execAction(resource, action, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve recent logs for a resource.
|
||||||
|
*/
|
||||||
|
async getLogs(resource, lines = 100) {
|
||||||
|
const driver = await this.resolveDriver(resource);
|
||||||
|
return await driver.getLogs(resource, lines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
const registry = new DriverRegistry();
|
||||||
|
module.exports = registry;
|
||||||
@@ -71,17 +71,23 @@ async function runPluginJob(instanceId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
||||||
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null });
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
|
||||||
|
let logs = [];
|
||||||
try {
|
try {
|
||||||
const cfg = await pluginSecrets.mergeForRun(instance);
|
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);
|
const payload = await runFn(cfg);
|
||||||
if (instance.category === 'discovery') {
|
if (instance.category === 'discovery') {
|
||||||
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
await DiscoveryReconciler.reconcile(instance.slug, payload, cfg);
|
||||||
}
|
}
|
||||||
await instance.update({ lastStatus: STATUS.OK, lastError: null });
|
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
||||||
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) });
|
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
@@ -44,9 +44,10 @@ beforeAll(async () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
hostId = host.body.results.id;
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||||
|
|
||||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
// 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
|
// 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);
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
// Same as the top-level setup: step out of the auto-created groups the
|
// 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.
|
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
// or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||||
await request(app)
|
await request(app)
|
||||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
.set('auth-token', token);
|
.set('auth-token', token);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Agent-facing ops (DESIGN.md §5): node-scoped secrets. OpenBao is not present
|
||||||
|
// in the test env, so @simpleworkjs/bao-conf is mocked.
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
request: jest.fn(async (method, path) => {
|
||||||
|
if (path.startsWith('secret/data/nodes/')) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({ data: { data: { username: 'alice', password: 's3cret' } } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, status: 404, json: async () => ({}) };
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { request, app } = require('./setup');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
|
||||||
|
async function enrollAgent() {
|
||||||
|
const { agent, token } = await Agent.enroll({
|
||||||
|
name: `ops-test-${Date.now().toString(36)}`,
|
||||||
|
description: 'api_agent_ops test',
|
||||||
|
enrolledBy: 'test'
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Agent ops — POST /api/v1/agent/secrets', () => {
|
||||||
|
test('an agent can fetch its own node-scoped secrets', async () => {
|
||||||
|
const { agent, token } = await enrollAgent();
|
||||||
|
const path = `secret/data/nodes/${agent.id}/db`;
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ paths: [path] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.secrets[path]).toEqual({ username: 'alice', password: 's3cret' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a path outside the node scope is rejected', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ paths: ['secret/data/nodes/other-node/db'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no bearer token returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.send({ paths: ['secret/data/nodes/x/db'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing paths defaults to agent node & resource secrets', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/agent/secrets')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.secrets).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// LDAP-over-HTTPS API (DESIGN.md §3). Exercises caller auth (agent token vs
|
||||||
|
// PAT), the bind flow against the real test OpenLDAP, and the agent-only search
|
||||||
|
// restriction.
|
||||||
|
|
||||||
|
const { TEST_CREDS, request, app } = require('./setup');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
const { ApiToken } = require('../models/api_token');
|
||||||
|
|
||||||
|
async function enrollAgent() {
|
||||||
|
const { agent, token } = await Agent.enroll({
|
||||||
|
name: `ldap-test-${Date.now().toString(36)}`,
|
||||||
|
description: 'api_ldap test agent',
|
||||||
|
enrolledBy: 'test'
|
||||||
|
});
|
||||||
|
return { agent, token };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makePat() {
|
||||||
|
const token = await ApiToken.add({
|
||||||
|
name: 'ldap-test-pat',
|
||||||
|
description: 'api_ldap test',
|
||||||
|
created_by: 'test'
|
||||||
|
});
|
||||||
|
return token._raw_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/bind', () => {
|
||||||
|
test('valid credentials return the bound DN', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(res.body.uid).toBe(TEST_CREDS.uid);
|
||||||
|
expect(res.body.dn).toContain(TEST_CREDS.uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wrong password returns 401', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: 'wrong-password' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unknown user returns 401 (no existence oracle)', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: 'no_such_user_xyz', password: 'whatever' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PAT caller can bind', async () => {
|
||||||
|
const pat = await makePat();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${pat}`)
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no bearer token returns 401', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.send({ username: TEST_CREDS.uid, password: TEST_CREDS.password });
|
||||||
|
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing username/password returns 400', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/bind')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ username: TEST_CREDS.uid });
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LDAP-over-HTTPS — POST /api/v1/ldap/search', () => {
|
||||||
|
test('an agent can search the user tree', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({ filter: `(uid=${TEST_CREDS.uid})`, attributes: ['uid', 'cn'] });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.status).toBe('ok');
|
||||||
|
expect(Array.isArray(res.body.entries)).toBe(true);
|
||||||
|
expect(res.body.entries.length).toBeGreaterThan(0);
|
||||||
|
expect(res.body.entries[0].uid).toBe(TEST_CREDS.uid);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a PAT caller is denied search (agent-only)', async () => {
|
||||||
|
const pat = await makePat();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${pat}`)
|
||||||
|
.send({ filter: `(uid=${TEST_CREDS.uid})` });
|
||||||
|
|
||||||
|
expect(res.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing filter returns 400', async () => {
|
||||||
|
const { token } = await enrollAgent();
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/v1/ldap/search')
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send({});
|
||||||
|
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
request: jest.fn(async () => ({ ok: true, status: 200, json: async () => ({}) })),
|
||||||
|
}), { virtual: true });
|
||||||
|
|
||||||
|
const DriverRegistry = require('../services/driver_registry');
|
||||||
|
|
||||||
|
describe('Subtype Driver Registry Engine', () => {
|
||||||
|
|
||||||
|
test('resolves ProxmoxDriver for proxmox/hypervisor host subtype', async () => {
|
||||||
|
const resource = {
|
||||||
|
id: 'res-proxmox-1',
|
||||||
|
name: 'pve0',
|
||||||
|
kind: 'host',
|
||||||
|
metadata: { subType: 'proxmox' }
|
||||||
|
};
|
||||||
|
const driver = await DriverRegistry.resolveDriver(resource);
|
||||||
|
expect(driver.name).toBe('proxmox');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves DockerSocketDriver for docker/docker_compose subtype', async () => {
|
||||||
|
const resource = {
|
||||||
|
id: 'res-docker-1',
|
||||||
|
name: 'theta-suite-docker',
|
||||||
|
kind: 'service',
|
||||||
|
metadata: { subType: 'docker' }
|
||||||
|
};
|
||||||
|
const driver = await DriverRegistry.resolveDriver(resource);
|
||||||
|
expect(driver.name).toBe('docker_socket');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves DbDriver for redis, postgresql, openbao_vault subtypes', async () => {
|
||||||
|
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
|
||||||
|
const pgRes = { id: 'r2', metadata: { subType: 'postgresql' } };
|
||||||
|
const vaultRes = { id: 'r3', metadata: { subType: 'openbao_vault' } };
|
||||||
|
|
||||||
|
expect((await DriverRegistry.resolveDriver(redisRes)).name).toBe('database');
|
||||||
|
expect((await DriverRegistry.resolveDriver(pgRes)).name).toBe('database');
|
||||||
|
expect((await DriverRegistry.resolveDriver(vaultRes)).name).toBe('database');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves NetworkDriver for wireguard, unifi_ap, pfsense', async () => {
|
||||||
|
const wgRes = { id: 'nw1', metadata: { subType: 'wireguard' } };
|
||||||
|
const unifiRes = { id: 'nw2', metadata: { subType: 'unifi_ap' } };
|
||||||
|
const pfRes = { id: 'nw3', metadata: { subType: 'pfsense' } };
|
||||||
|
|
||||||
|
expect((await DriverRegistry.resolveDriver(wgRes)).name).toBe('network');
|
||||||
|
expect((await DriverRegistry.resolveDriver(unifiRes)).name).toBe('network');
|
||||||
|
expect((await DriverRegistry.resolveDriver(pfRes)).name).toBe('network');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolves K8sDriver for k8s_pod and k8s_deployment', async () => {
|
||||||
|
const podRes = { id: 'k1', metadata: { subType: 'k8s_pod' } };
|
||||||
|
const depRes = { id: 'k2', metadata: { subType: 'k8s_deployment' } };
|
||||||
|
|
||||||
|
expect((await DriverRegistry.resolveDriver(podRes)).name).toBe('kubernetes');
|
||||||
|
expect((await DriverRegistry.resolveDriver(depRes)).name).toBe('kubernetes');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns unmanaged driver for unknown subtypes without agent', async () => {
|
||||||
|
const unknownRes = { id: 'u1', metadata: { subType: 'unknown_custom' } };
|
||||||
|
const driver = await DriverRegistry.resolveDriver(unknownRes);
|
||||||
|
expect(driver.name).toBe('unmanaged');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('fetches metrics via resolved driver', async () => {
|
||||||
|
const redisRes = { id: 'r1', metadata: { subType: 'redis' } };
|
||||||
|
const metrics = await DriverRegistry.getMetrics(redisRes);
|
||||||
|
expect(metrics.status).toBe('online');
|
||||||
|
expect(metrics.driver).toBe('database');
|
||||||
|
expect(metrics.redis).toBeDefined();
|
||||||
|
expect(metrics.redis.connectedClients).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('executes actions via resolved driver', async () => {
|
||||||
|
const dockerRes = { id: 'd1', slug: 'my-container', metadata: { subType: 'docker' } };
|
||||||
|
const result = await DriverRegistry.execAction(dockerRes, 'restart');
|
||||||
|
expect(result.status).toBe('ok');
|
||||||
|
expect(result.driver).toBe('docker_socket');
|
||||||
|
expect(result.action).toBe('restart');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
let mockBaoStore = new Map();
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||||
|
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('jump_client', () => {
|
||||||
|
let jumpClient;
|
||||||
|
let originalFetch;
|
||||||
|
let mockFetchImpl;
|
||||||
|
let calls;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockBaoStore = new Map();
|
||||||
|
calls = [];
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ status: 'ok', gateways: [] }) });
|
||||||
|
originalFetch = global.fetch;
|
||||||
|
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
|
||||||
|
jumpClient = require('../utils/jump_client');
|
||||||
|
jumpClient._reset();
|
||||||
|
delete process.env.JUMP_INTERNAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count (not zero) when JUMP_INTERNAL_URL is not configured', async () => {
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/JUMP_INTERNAL_URL/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count when no token is stored in OpenBao', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/no jump-host API token/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns the real gateway count on success', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => ({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: async () => ({ status: 'ok', gateways: [{ siteSlug: '(self)' }, { siteSlug: 'site-b' }] })
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBe(2);
|
||||||
|
expect(result.note).toBe('ok');
|
||||||
|
expect(calls[0][0]).toBe('http://jump-host.internal/api/mesh/gateways');
|
||||||
|
expect(calls[0][1].headers.Authorization).toBe('Bearer jmp_test_token');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports null count on a non-2xx response', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => ({ ok: false, status: 403 });
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/HTTP 403/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a network failure without throwing', async () => {
|
||||||
|
process.env.JUMP_INTERNAL_URL = 'http://jump-host.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-jump', { token: 'jmp_test_token' });
|
||||||
|
mockFetchImpl = async () => { throw new Error('connection refused'); };
|
||||||
|
|
||||||
|
const result = await jumpClient.getGatewayCount();
|
||||||
|
expect(result.count).toBeNull();
|
||||||
|
expect(result.note).toMatch(/failed: connection refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { SiteSpoke } = require('../models/site_spoke');
|
||||||
|
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
||||||
|
|
||||||
|
describe('ldap_replication', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
const all = await SiteSpoke.list();
|
||||||
|
for (const s of all) await s.delete();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ldapHostFor', () => {
|
||||||
|
test('derives ldaps://<host>:636 from an http(s) endpoint, ignoring its own port', () => {
|
||||||
|
expect(ldapHostFor('https://sso.site2.example.com')).toBe('ldaps://sso.site2.example.com:636');
|
||||||
|
expect(ldapHostFor('https://sso.site2.example.com:8443')).toBe('ldaps://sso.site2.example.com:636');
|
||||||
|
expect(ldapHostFor('http://sso.site3.example.com')).toBe('ldaps://sso.site3.example.com:636');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for an unparseable endpoint', () => {
|
||||||
|
expect(ldapHostFor('not-a-url')).toBeNull();
|
||||||
|
expect(ldapHostFor('')).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nextFreeLdapServerId', () => {
|
||||||
|
test('starts at 2 (1 is reserved for the master) when no spokes are registered', async () => {
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('picks the lowest free id, not just the next highest', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await SiteSpoke.create({ id: 'a', endpoint: 'https://a.example.com', pushToken: 'tok-a', created_on: now, ldapServerId: 2 });
|
||||||
|
await SiteSpoke.create({ id: 'b', endpoint: 'https://b.example.com', pushToken: 'tok-b', created_on: now, ldapServerId: 4 });
|
||||||
|
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores spokes with no ldapServerId assigned yet', async () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
await SiteSpoke.create({ id: 'c', endpoint: 'https://c.example.com', pushToken: 'tok-c', created_on: now });
|
||||||
|
|
||||||
|
await expect(nextFreeLdapServerId()).resolves.toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
'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];
|
||||||
|
this.rawData = '';
|
||||||
|
}
|
||||||
|
startScan() {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('complete', [
|
||||||
|
{ ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Real node-nmap's rawDataHandler XML-parses this.rawData then calls
|
||||||
|
// this.scanComplete(results), which emits 'complete' -- the mock skips
|
||||||
|
// straight to emitting the same shape so the RTTVAR-recovery test below
|
||||||
|
// exercises the exact call our plugin code makes.
|
||||||
|
rawDataHandler() {
|
||||||
|
this.emit('complete', [
|
||||||
|
{ ip: '192.168.1.20', hostname: 'host-20', openPorts: [] }
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
NmapScan: MockNmapScan,
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('recovers a scan that completed despite nmap\'s benign RTTVAR stderr warning', async () => {
|
||||||
|
// Regression: node-nmap treats ANY stderr output as fatal, including
|
||||||
|
// nmap's own harmless RTT-calibration message -- which discards a scan
|
||||||
|
// that actually succeeded. Simulate that by emitting 'error' with the
|
||||||
|
// RTTVAR text instead of 'complete', with rawData present.
|
||||||
|
const nmapModule = require('node-nmap');
|
||||||
|
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||||
|
nmapModule.NmapScan.prototype.startScan = function () {
|
||||||
|
this.rawData = '<nmaprun>...</nmaprun>';
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('error', new Error('RTTVAR has grown to over 2.3 seconds, decreasing to 2.0'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await nmapPlugin.discover({ targetRange: '192.168.1.0/24' });
|
||||||
|
expect(result.resources.some((r) => r.name === 'host-20')).toBe(true);
|
||||||
|
} finally {
|
||||||
|
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still rejects a genuine error even when the message differs from RTTVAR', async () => {
|
||||||
|
const nmapModule = require('node-nmap');
|
||||||
|
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||||
|
nmapModule.NmapScan.prototype.startScan = function () {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('error', new Error('nmap: permission denied'));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await expect(nmapPlugin.discover({ targetRange: '192.168.1.0/24' })).rejects.toThrow('permission denied');
|
||||||
|
} finally {
|
||||||
|
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -46,7 +46,7 @@ describe('plugin_registry', () => {
|
|||||||
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
|
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
|
||||||
expect(registry.secretKeys('unifi')).toEqual(['password']);
|
expect(registry.secretKeys('unifi')).toEqual(['password']);
|
||||||
expect(registry.secretKeys('nmap')).toEqual([]);
|
expect(registry.secretKeys('nmap')).toEqual([]);
|
||||||
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
|
expect(registry.publicKeys('nmap').sort()).toEqual(['autoPromote', 'location', 'targetRange']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
|
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
let mockBaoStore = new Map();
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||||
|
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('proxy_client', () => {
|
||||||
|
let proxyClient;
|
||||||
|
let originalFetch;
|
||||||
|
let mockFetchImpl;
|
||||||
|
let calls;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetModules();
|
||||||
|
mockBaoStore = new Map();
|
||||||
|
calls = [];
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 404 });
|
||||||
|
originalFetch = global.fetch;
|
||||||
|
global.fetch = (...args) => { calls.push(args); return mockFetchImpl(...args); };
|
||||||
|
proxyClient = require('../utils/proxy_client');
|
||||||
|
proxyClient._reset();
|
||||||
|
delete process.env.PROXY_INTERNAL_URL;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when required fields are missing', async () => {
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: '', ip: '', targetPort: 0 });
|
||||||
|
expect(result.note).toMatch(/required/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when PROXY_INTERNAL_URL is not configured', async () => {
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/PROXY_INTERNAL_URL/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips cleanly when no token is stored in OpenBao', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/no proxy API token/);
|
||||||
|
expect(calls.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates the route when the host does not already exist', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async (url, opts) => {
|
||||||
|
if (opts.method === undefined) return { ok: true, status: 404 }; // GET lookup
|
||||||
|
if (opts.method === 'POST') return { ok: true, status: 200 };
|
||||||
|
throw new Error('unexpected method ' + opts.method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('created');
|
||||||
|
|
||||||
|
const postCall = calls.find((c) => c[1].method === 'POST');
|
||||||
|
expect(postCall[0]).toBe('https://proxy.internal/api/host');
|
||||||
|
expect(postCall[1].headers.Authorization).toBe('Bearer prx_test_token');
|
||||||
|
expect(JSON.parse(postCall[1].body)).toEqual({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updates the route when it exists but points somewhere else', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async (url, opts) => {
|
||||||
|
if (!opts.method) return { ok: true, status: 200, json: async () => ({ results: { ip: '172.24.9.9', targetPort: 3001 } }) };
|
||||||
|
if (opts.method === 'PUT') return { ok: true, status: 200 };
|
||||||
|
throw new Error('unexpected method ' + opts.method);
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('updated');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('is a no-op when the route already matches', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async () => ({ ok: true, status: 200, json: async () => ({ results: { ip: '172.24.5.1', targetPort: 3001 } }) });
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toBe('already up to date');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports a network failure without throwing', async () => {
|
||||||
|
process.env.PROXY_INTERNAL_URL = 'https://proxy.internal';
|
||||||
|
mockBaoStore.set('integrations/theta-proxy', { token: 'prx_test_token' });
|
||||||
|
mockFetchImpl = async () => { throw new Error('connection refused'); };
|
||||||
|
|
||||||
|
const result = await proxyClient.ensureRelayRoute({ host: 'sso-a.example.com', ip: '172.24.5.1', targetPort: 3001 });
|
||||||
|
expect(result.note).toMatch(/failed: connection refused/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
require('./setup');
|
require('./setup');
|
||||||
const { Resource } = require('../models/resource');
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
|
||||||
describe('DiscoveryReconciler', () => {
|
describe('DiscoveryReconciler', () => {
|
||||||
@@ -25,7 +25,7 @@ describe('DiscoveryReconciler', () => {
|
|||||||
|
|
||||||
await DiscoveryReconciler.reconcile('test-plugin', payload);
|
await DiscoveryReconciler.reconcile('test-plugin', payload);
|
||||||
|
|
||||||
const all = await Resource.list();
|
const all = (await Resource.list()).filter(r => r.kind !== 'site');
|
||||||
expect(all).toHaveLength(1);
|
expect(all).toHaveLength(1);
|
||||||
expect(all[0].name).toBe('New Host');
|
expect(all[0].name).toBe('New Host');
|
||||||
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
|
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
|
||||||
@@ -57,7 +57,7 @@ describe('DiscoveryReconciler', () => {
|
|||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
|
|
||||||
const all = await Resource.list();
|
const all = (await Resource.list()).filter(r => r.kind !== 'site');
|
||||||
expect(all).toHaveLength(1); // Should have merged, not created a new one
|
expect(all).toHaveLength(1); // Should have merged, not created a new one
|
||||||
|
|
||||||
const merged = all[0];
|
const merged = all[0];
|
||||||
@@ -72,4 +72,27 @@ describe('DiscoveryReconciler', () => {
|
|||||||
expect(merged.metadata.interfaces).toHaveLength(1);
|
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||||
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not duplicate access/admin groups across repeated autoPromote passes', async () => {
|
||||||
|
// Regression: autoPromote used to call ResourceGroup.create() directly
|
||||||
|
// with no existence check, so reconciling the same managed resource
|
||||||
|
// more than once (e.g. a Proxmox cluster reporting one LXC from
|
||||||
|
// multiple nodes) accumulated duplicate access/admin rows every pass.
|
||||||
|
const payload = {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: 'LXC 127',
|
||||||
|
slug: 'lxc-127',
|
||||||
|
metadata: { interfaces: [{ mac: '00:11:22:33:44:99', ip: '10.0.0.99' }] }
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||||
|
|
||||||
|
const resource = (await Resource.list()).find((r) => r.slug === 'lxc-127');
|
||||||
|
const groups = await ResourceGroup.list({ where: { resourceId: resource.id } });
|
||||||
|
expect(groups.map((g) => g.groupCn).sort()).toEqual(['lxc-127_access', 'lxc-127_admin']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Point SITE_CONFIG_FILE at a fresh temp path and clear the env defaults so
|
||||||
|
// each test observes a known state. jest.resetModules() gives a fresh module
|
||||||
|
// (the `current` cache is module-scoped).
|
||||||
|
function freshEnv(overrides = {}) {
|
||||||
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'site-cfg-'));
|
||||||
|
const file = path.join(dir, 'site.json');
|
||||||
|
for (const k of ['IS_MASTER', 'MASTER_URL', 'SITE_SLUG', 'SITE_CONFIG_FILE']) delete process.env[k];
|
||||||
|
process.env.SITE_CONFIG_FILE = file;
|
||||||
|
Object.assign(process.env, overrides);
|
||||||
|
jest.resetModules();
|
||||||
|
return { file };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('site_config defaults to a fresh master / site-default with no file', () => {
|
||||||
|
freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.get();
|
||||||
|
expect(c.isMaster).toBe(true);
|
||||||
|
expect(c.masterUrl).toBe('');
|
||||||
|
expect(c.siteSlug).toBe('site-default');
|
||||||
|
expect(c.wanConnected).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config honors the env seed values', () => {
|
||||||
|
freshEnv({ IS_MASTER: 'false', MASTER_URL: 'https://m.example.com', SITE_SLUG: 'site-east' });
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.get();
|
||||||
|
expect(c.isMaster).toBe(false);
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
expect(c.siteSlug).toBe('site-east');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config save persists and a fresh require reloads it', () => {
|
||||||
|
const { file } = freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
sc.save({ isMaster: false, masterUrl: 'https://m.example.com', siteSlug: 'site-east' });
|
||||||
|
|
||||||
|
const onDisk = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
expect(onDisk.isMaster).toBe(false);
|
||||||
|
expect(onDisk.siteSlug).toBe('site-east');
|
||||||
|
|
||||||
|
jest.resetModules();
|
||||||
|
const sc2 = require('../utils/site_config');
|
||||||
|
const c = sc2.get();
|
||||||
|
expect(c.isMaster).toBe(false);
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
expect(c.siteSlug).toBe('site-east');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site_config save returns the merged config', () => {
|
||||||
|
freshEnv();
|
||||||
|
const sc = require('../utils/site_config');
|
||||||
|
const c = sc.save({ masterUrl: 'https://m.example.com' });
|
||||||
|
expect(c.isMaster).toBe(true); // untouched default survives
|
||||||
|
expect(c.masterUrl).toBe('https://m.example.com');
|
||||||
|
});
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
||||||
|
|
||||||
|
// In-memory model stubs so importDirectory can be exercised without a DB.
|
||||||
|
function makeStore() {
|
||||||
|
const rows = [];
|
||||||
|
const edges = [];
|
||||||
|
return {
|
||||||
|
Resource: {
|
||||||
|
list: async () => rows.map(r => ({ ...r })),
|
||||||
|
create: async (d) => { rows.push({ ...d }); return { ...d }; },
|
||||||
|
update: async (id, d) => {
|
||||||
|
const i = rows.findIndex(r => r.id === id);
|
||||||
|
if (i >= 0) rows[i] = { ...rows[i], ...d };
|
||||||
|
return rows[i];
|
||||||
|
},
|
||||||
|
get rows() { return rows; }
|
||||||
|
},
|
||||||
|
ResourceEdge: {
|
||||||
|
list: async () => edges.map(e => ({ ...e })),
|
||||||
|
create: async (d) => { edges.push({ ...d }); return { ...d }; },
|
||||||
|
delete: async (id) => {
|
||||||
|
const i = edges.findIndex(e => e.id === id);
|
||||||
|
if (i >= 0) edges.splice(i, 1);
|
||||||
|
},
|
||||||
|
get rows() { return edges; }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('importDirectory creates new resources and edges', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r1', kind: 'site', name: 'Main Office', slug: 'site_main-office', metadata: { address: '10.0.0.1' } },
|
||||||
|
{ id: 'r2', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: { ip: '10.0.0.10' } }
|
||||||
|
],
|
||||||
|
edges: [{ id: 'e1', parentId: 'r1', childId: 'r2', relation: 'contains' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
expect(s.Resource.rows.length).toBe(2);
|
||||||
|
expect(s.ResourceEdge.rows.length).toBe(1);
|
||||||
|
expect(res.created).toBe(2);
|
||||||
|
expect(res.updated).toBe(0);
|
||||||
|
expect(res.edgeCount).toBe(1);
|
||||||
|
expect(s.Resource.rows[0].slug).toBe('site_main-office');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importDirectory updates existing resources by slug (master is authoritative)', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
await s.Resource.create({ id: 'local1', kind: 'host', name: 'web01', slug: 'host_web-01', metadata: {} });
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r2', kind: 'host', name: 'web01-new', slug: 'host_web-01', metadata: { ip: '10.0.0.9' } }
|
||||||
|
],
|
||||||
|
edges: []
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
expect(s.Resource.rows.length).toBe(1); // upsert, not duplicate
|
||||||
|
expect(s.Resource.rows[0].name).toBe('web01-new');
|
||||||
|
expect(res.updated).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('importDirectory clears stale edges then recreates from master', async () => {
|
||||||
|
const s = makeStore();
|
||||||
|
await s.Resource.create({ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} });
|
||||||
|
await s.Resource.create({ id: 'r2', kind: 'host', name: 'old', slug: 'host_old', metadata: {} });
|
||||||
|
await s.ResourceEdge.create({ id: 'stale', parentId: 'r1', childId: 'r2', relation: 'contains' });
|
||||||
|
|
||||||
|
const exportData = {
|
||||||
|
resources: [
|
||||||
|
{ id: 'r1', kind: 'site', name: 'S', slug: 'site_s', metadata: {} },
|
||||||
|
{ id: 'r3', kind: 'host', name: 'new', slug: 'host_new', metadata: {} }
|
||||||
|
],
|
||||||
|
edges: [{ id: 'e9', parentId: 'r1', childId: 'r3', relation: 'contains' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
await importDirectory({ Resource: s.Resource, ResourceEdge: s.ResourceEdge, exportData });
|
||||||
|
|
||||||
|
const edgeIds = s.ResourceEdge.rows.map(e => e.id);
|
||||||
|
expect(edgeIds).toContain('e9');
|
||||||
|
expect(edgeIds).not.toContain('stale');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scalarResource strips relation fields but keeps metadata', () => {
|
||||||
|
const o = scalarResource({
|
||||||
|
id: 'x', kind: 'host', name: 'n', slug: 's', metadata: { a: 1 },
|
||||||
|
edgesAsParent: [1], edgesAsChild: [2], groups: [3],
|
||||||
|
toJSON() { return this; }
|
||||||
|
});
|
||||||
|
expect(o.edgesAsParent).toBeUndefined();
|
||||||
|
expect(o.edgesAsChild).toBeUndefined();
|
||||||
|
expect(o.groups).toBeUndefined();
|
||||||
|
expect(o.metadata.a).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scalarEdge keeps parentId/childId/relation', () => {
|
||||||
|
const e = scalarEdge({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains', toJSON() { return this; } });
|
||||||
|
expect(e).toEqual({ id: 'e1', parentId: 'p', childId: 'c', relation: 'contains' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ldapAddArgs builds a continue-on-error admin bind', () => {
|
||||||
|
const a = ldapAddArgs({ bindDN: 'cn=admin,dc=example,dc=com', ldapCred: 'test-bind', ldifFile: '/tmp/x.ldif' });
|
||||||
|
expect(a).toContain('-c');
|
||||||
|
expect(a).toContain('-x');
|
||||||
|
expect(a).toContain('cn=admin,dc=example,dc=com');
|
||||||
|
expect(a).toContain('/tmp/x.ldif');
|
||||||
|
expect(a.indexOf('-D') < a.indexOf('-w')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('baseDnFrom prefers stack.ldapBaseDn and falls back to the bind DN', () => {
|
||||||
|
expect(baseDnFrom({ stack: { ldapBaseDn: 'dc=stack,dc=com' } })).toBe('dc=stack,dc=com');
|
||||||
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin,dc=example,dc=com' } })).toBe('dc=example,dc=com');
|
||||||
|
expect(baseDnFrom({ ldap: { bindDN: 'cn=admin' } })).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The fresh-install guard: only no-users-beyond-admin + no-agents may join.
|
||||||
|
test('siteIsFresh is true with only the bootstrap admin and no agents', async () => {
|
||||||
|
const User = { listDetail: async () => [{ uid: 'admin', isServiceAccount: false }] };
|
||||||
|
const Agent = { list: async () => [] };
|
||||||
|
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('siteIsFresh is false with a second real user', async () => {
|
||||||
|
const User = { listDetail: async () => [{ uid: 'admin' }, { uid: 'bob' }] };
|
||||||
|
const Agent = { list: async () => [] };
|
||||||
|
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('siteIsFresh is false with an enrolled agent', async () => {
|
||||||
|
const User = { listDetail: async () => [{ uid: 'admin' }] };
|
||||||
|
const Agent = { list: async () => [{ id: 'a1' }] };
|
||||||
|
expect(await siteIsFresh({ User, Agent })).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('siteIsFresh ignores service accounts', async () => {
|
||||||
|
const User = { listDetail: async () => [
|
||||||
|
{ uid: 'admin' },
|
||||||
|
{ uid: 'sso-svc', isServiceAccount: true },
|
||||||
|
{ uid: 'ldapclient', isServiceAccount: true }
|
||||||
|
] };
|
||||||
|
const Agent = { list: async () => [] };
|
||||||
|
expect(await siteIsFresh({ User, Agent })).toBe(true);
|
||||||
|
});
|
||||||