Compare commits
73 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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.
|
||||
config/*-secrets.js
|
||||
|
||||
# Default sqlite ORM storage (nodejs/models/index.js falls back to this path
|
||||
# when no external DB is configured via conf.orm) -- live runtime data, not a
|
||||
# fixture. Was committed by mistake across many prior releases. NB: this is
|
||||
# nodejs/config/, distinct from the root ./config/ secrets dir above.
|
||||
nodejs/config/*.sqlite
|
||||
|
||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||
docs/_site
|
||||
|
||||
|
||||
@@ -1336,6 +1336,78 @@ All endpoints require authentication and `app_sso_admin` membership. Runtime con
|
||||
|
||||
**Response:** `{ "success": true }`
|
||||
|
||||
---
|
||||
|
||||
## Subtype Driver Operations Endpoints
|
||||
|
||||
Base path: `/api/directory-admin/resources`
|
||||
|
||||
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `admin` permission.
|
||||
|
||||
### Get Subtype Driver Metrics
|
||||
|
||||
**`GET /api/directory-admin/resources/:id/driver-metrics`**
|
||||
|
||||
Resolves the operational driver for the resource via the 4-tier engine (`theta-agent`, specialized subtype driver, parent hypervisor provider, or unmanaged fallback) and returns real-time telemetry.
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"resourceId": "res-id",
|
||||
"metrics": {
|
||||
"status": "online",
|
||||
"driver": "database",
|
||||
"subType": "redis",
|
||||
"redis": { "connectedClients": 4, "usedMemoryBytes": 12582912, "opsPerSec": 42 }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Execute Subtype Driver Action
|
||||
|
||||
**`POST /api/directory-admin/resources/:id/driver-action`**
|
||||
|
||||
Executes a protocol action on the target resource (e.g. systemd restart, Proxmox power control, Redis flush, K8s scale).
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"action": "restart",
|
||||
"params": { "serviceName": "emby-server" }
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"resourceId": "res-id",
|
||||
"result": { "status": "ok", "driver": "docker_socket", "action": "restart" }
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Get Subtype Driver Logs
|
||||
|
||||
**`GET /api/directory-admin/resources/:id/driver-logs?lines=100`**
|
||||
|
||||
Retrieves recent operational logs for the resource via the resolved driver (`journalctl`, `docker logs`, Proxmox task logs, K8s pod logs).
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"resourceId": "res-id",
|
||||
"logs": "[docker logs --tail 100 emby-server]\nContainer initialized..."
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Error Responses
|
||||
|
||||
All endpoints return errors in this format:
|
||||
|
||||
@@ -1,3 +1,231 @@
|
||||
# v2.7.0 - 2026-08-11
|
||||
|
||||
### Added
|
||||
- **OpenLDAP N-way multi-master replication auto-config.** `SiteSpoke.ldapServerId` is now auto-assigned at registration (next free from 2 upward, 1 reserved for the master -- same pattern as jump-host's mesh index), and each site's LDAP URL is derived from its already-known HTTP(S) endpoint rather than a separately-configured field. New `utils/ldap_replication.js`, `GET /api/site/ldap-peers` (spoke-facing, Bearer site join key) and `GET /directory-admin/ldap-replication-config` (master-local). Operators no longer hand-maintain `LDAP_SERVER_ID`/`LDAP_REPLICATION_HOSTS` for a `theta-suite`-joined cluster (see `theta-suite`'s `bootstrap/site-ldap-register.js`). Verified against real running containers (`docker-compose.multisite-e2e.yml`).
|
||||
|
||||
# v2.6.0 - 2026-08-11
|
||||
|
||||
### Fixed
|
||||
- **Duplicate access/admin groups on repeated resource promotion.** Three independent copies of the same bug (`routes/discovery.js`'s `POST /discovery/promote/:slug` -- the actual "Promote" button in the UI -- and `services/discovery_reconciler.js`'s `autoPromote` path both called `ResourceGroup.create()` directly with no existence check, unlike `routes/api_directory_admin.js`'s own `ensureResourceGroup`, which already carried a comment describing this exact bug). A resource promoted more than once (a retried click, or the same LXC discovered from multiple Proxmox cluster nodes) silently accumulated duplicate rows every time. Consolidated into `ResourceGroup.ensure()` on the model, used everywhere.
|
||||
- **`GET /api/directory-admin/resources` ran a full LDAP group self-heal fan-out on every single list** (`ensureSiteGroups` per site + `provisionResourceGroups` per resource, each several sequential LDAP round-trips), unconditionally -- confirmed as the actual bottleneck once a directory has more than a handful of resources, not data volume. Moved healing to where resources actually change instead (`POST`/`PUT /resources`, `POST /discovery/promote/:slug` -- `PUT` had none at all before this), and added `POST /resources/heal-groups` as an explicit on-demand equivalent for backfilling a directory seeded before this change.
|
||||
- **An nmap discovery scan that completed successfully could be reported as a failed run with zero hosts found.** `node-nmap` (the vendored library) treats any stderr output from the nmap binary as fatal -- including nmap's own harmless RTT-calibration warning ("RTTVAR has grown to over N seconds..."), which it prints *during* a scan that goes on to complete normally, discarding valid results already sitting in the library's `rawData`. Our plugin now recognizes this specific benign message and manually completes the scan from the data that's already there; any other error still rejects as before.
|
||||
- **The Multi-Site modal's "Theta Gateways" count was measuring the wrong subsystem.** It counted this app's own unrelated WireGuard roaming-client/exit-node Resources, not jump-host's actual gateway-to-gateway mesh registry. New `utils/jump_client.js` (same self-service-token pattern as `utils/proxy_client.js`) queries jump-host's real `GET /api/mesh/gateways`, reporting a distinct "unknown" state instead of a misleading 0 when the integration isn't configured. Also added help links to the published multi-site/mesh docs on the modal.
|
||||
|
||||
# v2.5.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **No-inbound relay automation.** A spoke with no public IP of its own can now register as such (`noInbound`/`meshIp`/`publicHost` on `POST /api/site/spokes`, forwarded through `POST /api/site/join` for the real operator join flow), and the master auto-creates/updates the relay route on its own `theta-proxy` via `utils/proxy_client.js` — a new self-service `prx_...` API token client, reusing `theta-proxy`'s existing token system rather than inventing a new credential type. Verified against a real running `theta-proxy` container (`GET /api/host/:item`'s actual `{item, results: {...}}` response shape, not the flat shape first assumed).
|
||||
- **Replication traffic prefers the mesh.** `utils/site_replicate.js`'s fire-and-forget resync push now tries a registered spoke's `meshIp` first (falling back to its public `endpoint` on failure) — cross-component routing over the gateway-to-gateway WireGuard mesh instead of the open internet, for any spoke that's registered one.
|
||||
- `POST /api/site/join` surfaces the resulting relay status in its response (`relay.note`), and `theta-suite`'s bootstrap flow (`CFG_SPOKE_NO_INBOUND`/`CFG_SPOKE_PUBLIC_HOST`, `bootstrap/site-relay-register.js`) drives all of this from the real operator-facing setup script, not just the API.
|
||||
|
||||
# v2.4.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Live catalog replication.** A spoke now stays in sync after joining instead of only getting a one-time snapshot: it registers its own endpoint with the master at join time (`POST /api/site/spokes`, Bearer the site join key), and every successful master catalog write fires a fire-and-forget push (`utils/site_replicate.js`) at every registered spoke, concurrently — one unreachable spoke never blocks or delays delivery to another. The spoke's `POST /api/site/resync` handler re-runs the same tested export-pull-and-import path used at join time rather than applying a partial diff.
|
||||
- **Identical-directory agent-signing key.** `POST /api/site/export` now best-effort includes the master's agent-signing key; a spoke adopts it via `agent_keys.adopt()` on both join and every resync, so any site's `sso-manager-node` can validly sign a command for any agent enrolled at any other site (a deliberate blast-radius tradeoff for this deployment's small, trusted scale — see `theta-suite`'s `docs/MULTI_SITE_SPEC.md` §2).
|
||||
- **Coordinated master promotion.** `POST /api/directory-admin/site-promote` now demotes the previous master as part of the same action (mints it a fresh join key, calls its new `POST /api/site/demote`) instead of leaving a manual two-step gap where two nodes could both believe they're master. Best-effort: an unreachable old master (the WAN-outage scenario this control exists for) never blocks the local promotion — the response's `handoff` field reports what happened.
|
||||
- **Master Site modal UI**: new "Live Replication" (spoke) / "Registered Spokes" (master) status rows; the join form gained a "this site's own reachable URL" field (prefilled from the browser origin) wired to the `selfUrl` the join API already supported but the UI never sent — a UI-driven join previously never registered for live replication, only the `setup.sh` bootstrap path did; the promote button's success toast now reports the actual handoff result.
|
||||
- New `nodejs/models/site_spoke.js` (registered spokes + their push tokens) and `docker-compose.multisite-e2e.yml` + `test/multisite_join_e2e.js` (real two-container master+spoke regression test covering join, live replication, promotion, and demotion end to end).
|
||||
|
||||
### Fixed
|
||||
- **`site-promote`'s god_admin check was dead on arrival.** It read `req.user.groups`, a field nothing in the codebase ever populates (every other admin gate resolves membership live via `permission.byGroup()`/`Group.list(user.dn)`, which also handles nested-group membership) — the check silently evaluated to an empty array on every request, so promotion returned 403 for every user, including a real god_admin, since it shipped in v2.0.0. Only surfaced by the live e2e test, not by inspection.
|
||||
- **The read-only write-gate blocked `site-promote` on a spoke before its handler could run** — the one mutating request a spoke must be able to make to itself. Exempted `/site-promote` from the gate.
|
||||
- **`GET /api/site/config` was returning `masterJoinKey` and `replicationPushToken`** — live credentials — directly in the JSON response to any admin session. Replaced with boolean derivatives (`hasMasterJoinKey`, `liveReplication`).
|
||||
|
||||
# v2.3.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Multi-site join — UI + enforcement** (completes the join layer started in v2.2.0):
|
||||
- **Master Site modal**: a fresh install (no users beyond the bootstrap admin, no agents) gets a **"Join an Existing Site"** form (master URL + site join key); a master gets a **Site Join Keys** manager (mint/revoke/list, key shown once, copy button); **WAN Sync Health** now reflects a live probe of the master.
|
||||
- `POST /api/site/ping` (Bearer site-join-key): lightweight master reachability probe for WAN health.
|
||||
- **Spoke read-only**: directory-write routes (resources, edges, groups, secrets, grants, driver actions, discovery merges) return `403` pointing at the master once a node is a spoke.
|
||||
- **Fresh-install guard**: `/api/site/join` refuses unless the directory has no users beyond the admin and no enrolled agents (`siteIsFresh`); `site-status` exposes `canJoin` so the UI only offers join when it's actually allowed.
|
||||
- The spoke persists the join key (`masterJoinKey`) in `/config/site.json` for WAN health + future write-proxy.
|
||||
- **Unit tests**: `siteIsFresh` cases (admin-only, second user, enrolled agent, service accounts ignored).
|
||||
|
||||
### Fixed
|
||||
- **Branch-protection check name**: the lint job that `master` requires is literally named "Syntax check bootstrap.js"; the multi-site bootstrap script is checked by that same job.
|
||||
|
||||
# v2.2.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Multi-site join — server endpoints.** A spoke can join an existing master directory:
|
||||
- **Site join keys** (`stj_…`): mint/revoke/delete under `/api/site/join-keys` (hashed at rest, shown once — same model as agent join keys).
|
||||
- `POST /api/site/export` (master, Bearer site-join-key, no admin session): returns the LDAP tree (`slapcat` LDIF) + resource catalog + siteSlug + baseDn.
|
||||
- `POST /api/site/join` (spoke, admin): `{ masterUrl, joinKey }` pulls the master export, imports resources (upsert by slug) + LDAP (`ldapadd -c`), and persists the spoke role. Refused if already a spoke.
|
||||
- **Persisted site role**: `isMaster`/`masterUrl`/`siteSlug` now live in `/config/site.json` (env seeds defaults) instead of Node memory, so a restart no longer silently reverts a spoke to master. `site-status`/`site-promote` read/write it.
|
||||
- Docs: `docs/site-join.md` (flow, endpoints, planned `setup.env` vars) registered in the docs router.
|
||||
- The UI + `setup.sh` wiring for join is the next layer; this pass is server-only.
|
||||
- **Unit tests** for the join helpers (`tests/site_join.test.js`) and persisted config (`tests/site_config.test.js`) — pure logic, in-memory stubs, added to `npm test`.
|
||||
|
||||
### Fixed
|
||||
- **Multi-site modal text was mojibake.** The Master/Spoke emojis (👑/⚡) in `views/directory.ejs` were UTF-8 that had been round-tripped through cp1252; restored. All other views verified byte-accurate clean.
|
||||
|
||||
# v2.1.1 - 2026-08-10
|
||||
|
||||
### Fixed
|
||||
- **"Master Site" button errored with `app.modal.show is not a function`.** The multi-site status modal used the legacy `app.modal.show()` signature; the app exposes `app.modal.open({ title, bodyHtml, size })`. The site-status request itself worked — only the rendering call was wrong.
|
||||
- **Agents with no discovery yet showed a fake `v2.0.0`.** Three hardcoded fallbacks now report `unknown` instead, so a host whose agent hasn't connected isn't presented as an old version.
|
||||
|
||||
# v2.1.0 - 2026-08-10
|
||||
|
||||
### Added
|
||||
- **Windows install commands in the Install Agent modal.** The Directory → Install Agent modal now emits PowerShell one-liners alongside the bash ones for the join-key, pre-register, and custom-config flows. Each downloads the fully-offline theta-agent `setup.exe` from its GitHub release and passes the same values the bash flow uses (`/SERVER_URL`, `/JOIN_KEY`, `/AUTH_TOKEN`, `/PUBLIC_KEY`, or `/B64_CONFIG`), so a Windows host enrolls with the same one-command flow as Linux. Complements the theta-agent v2.1.0 Windows release.
|
||||
|
||||
### Removed
|
||||
- **Large binaries from `nodejs/public/resources/theta-agent/`.** The agent/tray/helper/setup binaries are now built on GitHub Actions and attached to the theta-agent GitHub release as artifacts; `install.sh` and the modal download them from `releases/latest/download/`. Nothing binary lives in this repo anymore (the small `install.sh` bootstrap script remains).
|
||||
|
||||
# v2.0.4 - 2026-08-09
|
||||
|
||||
### Changed
|
||||
- **`Dockerfile.openldap` no longer compiles OpenLDAP from source.** Its `ldapbuild` stage now pulls `ghcr.io/theta42/openldap-nestgroup:<pinned commit>` (built once by `.github/workflows/build-openldap-image.yml` from the new `Dockerfile.openldap-builder`) instead of cloning `git.openldap.org` and running `./configure && make` on every build. Cuts ~5 minutes off every build of this Dockerfile, including 3x per CI run's test matrix, and removes the runtime dependency on that mirror being up (it 502'd twice tonight, blocking two PRs). Verified locally end-to-end before merging: built the app image against the published base, ran it, confirmed slapd boots healthy with the nestgroup overlay loaded and the correct pinned commit.
|
||||
|
||||
# v2.0.3 - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
- **Directory tab showed unpromoted discoveries.** `GET /api/directory-admin/resources` unconditionally admitted every `kind: 'host'` resource, and every discovery plugin (UniFi, Proxmox, nmap) creates its finds as `kind: 'host'` — so unchecking "Auto-promote to Directory" on a plugin never actually kept undiscovered/unpromoted devices out of the Directory tab, only out of the LDAP-group auto-provisioning. Now only `site` resources are unconditionally shown; anything else that discovery ever touched requires `metadata.managed === true` (set by promotion, an agent, or merging into an already-managed resource).
|
||||
- **`GET /api/directory-admin/site-status` 500'd.** Queried `Resource.list({ where: { subType: 'wireguard' } })`, but `subType` only ever lives in `metadata.subType` (every driver/discovery plugin reads it that way) — never a top-level DB column, so SQLite raised `no such column: Resource.subType`. Filters in JS over `metadata.subType` now.
|
||||
- **Discovered Inventory had no way to review ignored devices.** Added a "Show ignored" toggle (off by default) to the tab, so `metadata.ignored === true` rows stay hidden from routine triage but remain reachable.
|
||||
|
||||
### Chore
|
||||
- **Untracked `nodejs/config/inventory.sqlite`.** It's the app's default runtime DB (`nodejs/models/index.js` falls back to this path when no external DB is configured), not a fixture — it had been committed by mistake across 13 prior releases, churning on every local run. Removed from tracking and gitignored.
|
||||
|
||||
# v2.0.2 - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
- **README rebranding & standalone-install cleanup.** Removed the "Why this over the alternatives" section and stale links to the old per-repo GitHub Pages site (`theta42.github.io/sso-manager-node/`); documentation and secrets links now point at the unified `theta42.github.io/theta-suite/` site. Made explicit that Theta Directory is deployed as part of Theta Suite and isn't installed or run on its own. Added the agent capability/install screenshots to the gallery.
|
||||
|
||||
# v2.0.1 - 2026-08-09
|
||||
|
||||
### Fixed
|
||||
- **OpenBao / OpenBoa Container Exclusion**: Skip discovery of internal secret management/renewer containers so they don't populate resources catalog.
|
||||
- **Agent Version API Collection**: Added `version` tracking to Agent model and discovery handlers to record and report agent version dynamically.
|
||||
- **Console Log Cleanup**: Removed leftover `console.log` debug statements in frontend assets.
|
||||
- **Resource Save & User Cache Invalidation**: Fixed metadata merge on resource updates to prevent losing system fields, and added User cache clear to propagate user/group edits instantly.
|
||||
- **Site Status 500 Fix**: Replaced invalid ORM `Resource.findAll()` call with `Resource.list()`.
|
||||
- **Secret Filtering**: Fixed the "With Secrets" filter checkbox by ensuring `hasSecret` / `secretKeys` states are written to resource metadata and checked by EJS views.
|
||||
- **Auto-Group Spawning Prevention**: Set default `autoPromote` to false in UniFi, Docker, Proxmox, and Nmap plugins and restricted auto group creation to managed resources to prevent duplicate LDAP group generation.
|
||||
|
||||
# v2.0.0 - 2026-08-09
|
||||
|
||||
### Added
|
||||
- **Multi-Site Master Architecture.** Multi-Site Master badge, `/api/directory-admin/site-status` API, and Master site promotion UI.
|
||||
- **Full Telemetry Dashboard Cards.** Rendered active `logged_users`, physical partitions, host details, and desktop session/power controls (Lock, Display Off, Log Out, Sleep Host).
|
||||
- **Theta Directory Rebranding.** Rebranded SSO Manager UI and documentation to Theta Directory.
|
||||
|
||||
### Fixed
|
||||
- **Agent Action Parameter Resolution.** Standardized top-level and nested parameter parsing for agent driver actions (`api_directory_admin.js`).
|
||||
|
||||
# v1.33.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **Directory Key Badges & Secret Filtering.** Added a gold `🔑 Secret` badge next to resources with stored OpenBao secrets and a `With Secrets` filter checkbox to filter the directory tree by secret presence.
|
||||
- **Kind-Specific Resource Creation Modals.** Added dedicated `openAddSiteModal()`, `openAddHostModal()`, and `openAddServiceModal()` modal handlers for Site, Host, and Service resources.
|
||||
- **Top Toolbar Reorganization.** Updated top tree button to **"+ Add Site"** and removed legacy `Plumbing` slider.
|
||||
- **Optional Child Secret Key Name on Inheritance.** Made key name optional when inheriting parent secrets — automatically defaulting to the original parent secret key name if left blank.
|
||||
- **Discovered Inventory Merge & Ignore Actions.** Added `Merge` (merge IP/interfaces/OS metadata into target resource) and `Ignore` (dismiss discovered item) endpoints (`/api/directory-admin/discovered/merge` & `/api/directory-admin/discovered/ignore`) and table action buttons.
|
||||
- **Agent Tab Telemetry & Desktop Controls.** Rendered Agent Binary Version badge (`v1.8.0`), all physical disks and filesystems table, Active Logged-in Users card, and Desktop Session & Power Operations card (Lock, Display Off, Log Out, Sleep Host).
|
||||
|
||||
# v1.32.0 - 2026-08-08
|
||||
|
||||
### Added
|
||||
- **Subtype Management & Metrics Drivers Engine.** Built a 4-tier driver resolution engine (`services/driver_registry.js`) binding resource `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `unifi`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||
- **Subtype Operations APIs.** Exposed `/api/directory-admin/resources/:id/driver-metrics`, `driver-action`, and `driver-logs` endpoints.
|
||||
- **Explicit Secret Inheritance Mode.** Enforced strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`) for secret inheritance, resolving explicit pointers (`INHERIT:<parentSlug>:<parentKey>`) without exposing sibling directory secrets.
|
||||
- **Consolidated External App Tokens.** Relocated external OpenBao App Token minting into the **Configuration** page (`/conf` -> External App Tokens tab) and deprecated standalone `/vault` navigation item.
|
||||
- **Multi-Secret Key Support.** Supported multiple secret keys per resource in OpenBao `secret/data/resources/<slug>/conf` with per-key merging and deletion.
|
||||
- **Cross-Platform Agent Packaging.** Built multi-architecture Dockerfile staging and documentation for Linux ARM (arm64, armv7), Windows (amd64, arm64), and macOS (Intel, Apple Silicon).
|
||||
|
||||
### Fixed
|
||||
- **Ancestry Lineage Querying.** Fixed `Resource.findAllAncestors(id)` memory filtering over `ResourceEdge.list()` to resolve deep ancestor lineage across all graph depths.
|
||||
- **Dockerfile Module Inclusion.** Included `COPY nodejs/drivers ./drivers` in `Dockerfile.openldap` and `Dockerfile.test-runner` for clean container execution.
|
||||
|
||||
# v1.31.0 - 2026-08-07
|
||||
|
||||
### Added
|
||||
- **Resource Secrets Engine & Zero-View Security.** OpenBao KV-v2 encrypted secrets for directory resources (`secret/data/resources/<slug>/conf`). Zero-View UI & API model — secret values are never returned to admin browsers or UI templates, and delivered exclusively to authenticated `theta-agent` instances.
|
||||
- **Strict Secret Key Regex Validation.** Secret keys are validated against `^[A-Za-z0-9_]+$` (Standard Environment Variable format, e.g. `DB_PASSWORD`).
|
||||
- **Field-Populating Password Generator.** Cryptographic secret generator (`window.crypto.getRandomValues`) with length selector dropdown (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.
|
||||
|
||||
@@ -22,6 +22,12 @@
|
||||
# GIT_COMMIT=$(git -C sso-manager-node rev-parse --short HEAD), computed on
|
||||
# the host where the submodule resolves correctly.
|
||||
ARG GIT_COMMIT=""
|
||||
# Pinned OpenLDAP commit this build expects -- must match the tag of the
|
||||
# published builder image below. Bumping it is a two-step change: rebuild +
|
||||
# push ghcr.io/theta42/openldap-nestgroup:<new commit> from
|
||||
# Dockerfile.openldap-builder (see that file), then update this default (or
|
||||
# pass --build-arg OPENLDAP_COMMIT=<new commit> here).
|
||||
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||
FROM node:20-alpine AS gitinfo
|
||||
ARG GIT_COMMIT
|
||||
WORKDIR /repo
|
||||
@@ -33,9 +39,9 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||
fi
|
||||
|
||||
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
||||
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
||||
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||
# ── OpenLDAP, prebuilt ────────────────────────────────────────────────────────
|
||||
# We run slapd from OpenLDAP master rather than Alpine's packaged release, for
|
||||
# exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||
@@ -46,64 +52,14 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
||||
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||
FROM node:20-alpine AS ldapbuild
|
||||
|
||||
# groff is not optional despite producing nothing we ship: the build descends
|
||||
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||
# provides. Without it the whole `make` fails at the man-page stage
|
||||
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||
RUN apk add --no-cache \
|
||||
build-base autoconf automake libtool \
|
||||
openssl-dev cyrus-sasl-dev \
|
||||
git make pkgconf util-linux-dev groff
|
||||
|
||||
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
||||
# whole lab authenticates against; an unpinned `master` would mean every image
|
||||
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
||||
# master would take out logins with no way to tell what changed.
|
||||
#
|
||||
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
||||
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
||||
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
||||
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
||||
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
||||
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
||||
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||
|
||||
WORKDIR /src
|
||||
RUN git init -q . \
|
||||
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||
&& git checkout -q FETCH_HEAD \
|
||||
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||
|
||||
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||
# `moduleload`s them individually; nestgroup joins that set.
|
||||
RUN ./configure \
|
||||
--prefix=/opt/openldap \
|
||||
--enable-slapd \
|
||||
--enable-modules \
|
||||
--enable-mdb \
|
||||
--enable-memberof=mod \
|
||||
--enable-refint=mod \
|
||||
--enable-ppolicy=mod \
|
||||
--enable-dynlist=mod \
|
||||
--enable-nestgroup=mod \
|
||||
--enable-syncprov=mod \
|
||||
--enable-auditlog=mod \
|
||||
--with-tls=openssl \
|
||||
--with-cyrus-sasl \
|
||||
&& make depend \
|
||||
&& make -j"$(nproc)" \
|
||||
&& make install
|
||||
|
||||
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||
# built separately against the just-built tree -- omitting it would make every
|
||||
# user password unverifiable.
|
||||
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||
# The from-source compile (~5 min, and a dependency on git.openldap.org being
|
||||
# reachable) used to happen right here, on every build of this Dockerfile --
|
||||
# including 3x per CI run's test matrix. It's now built once, tagged by the
|
||||
# pinned commit above, in Dockerfile.openldap-builder -- see that file for the
|
||||
# actual compile steps and the TODO on dropping from-source entirely once
|
||||
# nestgroup ships in a release.
|
||||
FROM ghcr.io/theta42/openldap-nestgroup:${OPENLDAP_COMMIT} AS ldapbuild
|
||||
|
||||
FROM node:20-alpine
|
||||
|
||||
@@ -163,6 +119,7 @@ COPY nodejs/app.js ./
|
||||
COPY nodejs/bin ./bin
|
||||
COPY nodejs/conf ./conf
|
||||
COPY nodejs/controller ./controller
|
||||
COPY nodejs/drivers ./drivers
|
||||
COPY nodejs/middleware ./middleware
|
||||
COPY nodejs/models ./models
|
||||
COPY nodejs/routes ./routes
|
||||
|
||||
@@ -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,6 +20,7 @@ COPY nodejs/app.js ./
|
||||
COPY nodejs/bin ./bin
|
||||
COPY nodejs/conf ./conf
|
||||
COPY nodejs/controller ./controller
|
||||
COPY nodejs/drivers ./drivers
|
||||
COPY nodejs/middleware ./middleware
|
||||
COPY nodejs/models ./models
|
||||
# Without this the discovery/plugin suites cannot even load their subject and
|
||||
@@ -47,6 +48,10 @@ COPY directory_spec.md /directory_spec.md
|
||||
COPY test_seed.js ./test_seed.js
|
||||
COPY test/seed-test-user.sh /usr/local/bin/seed-test-user
|
||||
RUN chmod +x /usr/local/bin/seed-test-user
|
||||
# End-to-end LDAP tunnel test client (docker-compose.e2e.yml)
|
||||
COPY test/tunnel_e2e.js ./test/tunnel_e2e.js
|
||||
# End-to-end multi-site join test client (docker-compose.multisite-e2e.yml)
|
||||
COPY test/multisite_join_e2e.js ./test/multisite_join_e2e.js
|
||||
|
||||
# Default command: seed the test user, then run the test suite
|
||||
CMD ["sh", "-c", "seed-test-user && npm test"]
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
# SSO Manager
|
||||
# Theta Directory
|
||||
|
||||
A self-hosted **OpenID Connect provider** with a bundled **OpenLDAP directory**
|
||||
and a web management UI — for home labs and small businesses that want their own
|
||||
identity provider instead of a hosted one.
|
||||
A production-grade, self-hosted **OpenID Connect provider**, **Resource Directory & IAM Engine**, and bundled **OpenLDAP directory** with a modern web console — designed for home-labs and enterprise infrastructure that demand total sovereignty over their identity, secrets, and resource catalog.
|
||||
|
||||
It gives you one place to manage your users and groups, one login (OIDC) that
|
||||
your modern apps can use, and one LDAP directory your older or odder apps can
|
||||
bind to directly. Everything runs on your own hardware; there is no
|
||||
phone-home, no hosted control plane, and no per-user pricing.
|
||||
It provides a single source of truth for identity (OIDC + LDAP), host/service directory inventory, access control groups, and secrets management running entirely on your own hardware without third-party cloud lock-in.
|
||||
|
||||
> Setting up the whole stack (this SSO + the [theta42/proxy](https://github.com/theta42/proxy)
|
||||
> in front of it) with one command? Skip to [theta-env](https://github.com/theta42/theta-env)
|
||||
> — its `setup.sh` wires the two together and generates the config for you.
|
||||
Theta Directory is deployed as part of [Theta Suite](https://github.com/theta42/theta-suite),
|
||||
alongside [Theta Proxy](https://github.com/theta42/proxy) and
|
||||
[Theta Gateway](https://github.com/theta42/jump-host) — it isn't installed or
|
||||
run on its own. `./setup.sh` wires the whole stack together automatically.
|
||||
|
||||
**Documentation:** [https://theta42.github.io/sso-manager-node/](https://theta42.github.io/sso-manager-node/)
|
||||
**Documentation:** [https://theta42.github.io/theta-suite/sso/](https://theta42.github.io/theta-suite/sso/)
|
||||
|
||||
## Screenshots
|
||||
|
||||
@@ -29,6 +25,10 @@ phone-home, no hosted control plane, and no per-user pricing.
|
||||
| --- |
|
||||
| [](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
|
||||
|
||||
- **OpenID Connect / OAuth 2.0 provider** — issue your own access, refresh, and
|
||||
@@ -47,91 +47,11 @@ phone-home, no hosted control plane, and no per-user pricing.
|
||||
same directory, so you don't maintain a second user database for them.
|
||||
- **Personal access tokens** — any user can mint a long-lived bearer token to
|
||||
drive the management API from scripts or CI, scoped to their own permissions.
|
||||
- **All-in-one Docker image** — app + OpenLDAP + Redis in one container, or run
|
||||
the pieces separately against your own LDAP/Redis via `app_*` env config.
|
||||
- **Directory & Inventory Graph** — full host/service/site graph with resource metadata, automatic LDAP group provisioning (`_access` / `_admin`), and Access Request workflows.
|
||||
- **Subtype Management & Metrics Drivers Engine** — 4-tier resolution engine binding `subType` metadata (`systemd`, `docker`, `proxmox`, `wireguard`, `postgresql`, `redis`, `k8s`) to operational telemetry, log streaming, and remote lifecycle control.
|
||||
- **Explicit Secret Inheritance Mode** — OpenBao KV-v2 integration with strict upward ancestor lineage (`Resource -> Host -> Cluster -> Site`), preserving precise secret scoping across services and containers.
|
||||
- **Multi-Site Support (Geo-Location Scaling)** — built-in support for N-Way Multi-Master OpenLDAP replication across physical sites for HA and low latency.
|
||||
|
||||
## Why this over the alternatives
|
||||
|
||||
Tools like Keycloak, Authentik, Authelia, or Zitadel are OIDC providers, but
|
||||
LDAP is either a paid feature, a federation target you have to run separately,
|
||||
or absent. If your stack already has apps that speak LDAP directly (or you just
|
||||
want one real directory as the source of truth), you end up running *two*
|
||||
identity systems and keeping them in sync.
|
||||
|
||||
SSO Manager bundles the OpenLDAP directory with the OIDC provider, so OIDC
|
||||
apps and LDAP apps read from the same users and groups. The trade-off is
|
||||
scope: it is intentionally small and self-hosted, not an enterprise IAM suite
|
||||
— no fancy workflow engine, no hosted SaaS. If you want a lightweight,
|
||||
self-contained identity provider with a real LDAP backend, that is the niche.
|
||||
|
||||
## Quick start
|
||||
|
||||
Three ways to run it, in order of how much it sets up for you:
|
||||
|
||||
### 1. As part of the unified stack (recommended)
|
||||
|
||||
[theta-env](https://github.com/theta42/theta-env) composes this SSO Manager with
|
||||
the [theta42/proxy](https://github.com/theta42/proxy) (an OIDC-protected reverse
|
||||
proxy) and generates all the config from a single `setup.env` — you enter your
|
||||
domain once and it fills in the LDAP DNs, hostnames, OAuth issuer, and random
|
||||
secrets consistently:
|
||||
|
||||
```bash
|
||||
git clone --recursive https://github.com/theta42/theta-env.git
|
||||
cd theta-env
|
||||
cp setup.env.example setup.env # set CFG_DOMAIN to your domain
|
||||
./setup.sh # generates ./config/, builds + bootstraps + starts both
|
||||
```
|
||||
|
||||
See the [theta-env README](https://github.com/theta42/theta-env) for the full
|
||||
first-run flow, DNS/port requirements, and backups.
|
||||
|
||||
### 2. Standalone, in Docker
|
||||
|
||||
The all-in-one image bundles the app, OpenLDAP, and Redis. Copy the example
|
||||
secrets file, fill in your values, and build:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/theta42/sso-manager-node.git
|
||||
cd sso-manager-node
|
||||
mkdir -p config && chmod 700 config
|
||||
cp secrets.js.example config/sso-secrets.js
|
||||
$EDITOR config/sso-secrets.js # set ldap.bindPassword, oauth.jwtSecret, ...
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
The web UI comes up at `http://localhost:3001`. To kick the tires with no
|
||||
config file at all, the entrypoint falls back to safe defaults
|
||||
(`dc=example,dc=com`, admin password `admin`, an auto-generated JWT) — fine for
|
||||
a local test, not for production.
|
||||
|
||||
Your domain is entered once, as the LDAP base DN (`stack.ldapBaseDn`); the other
|
||||
LDAP DNs and the OAuth issuer derive from it and must stay consistent. See
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md) for the full config reference, the `app_*` env
|
||||
vars, LDAPS/TLS, and backups.
|
||||
|
||||
### 3. Bare metal on Debian/Ubuntu
|
||||
|
||||
An automated installer installs Node.js, Redis, and (on first run) OpenLDAP —
|
||||
configuring the directory (modules, overlays, schema, the SSO groups) and
|
||||
seeding `/etc/sso-manager/secrets.js` with a generated admin password and JWT
|
||||
secret — then deploys the app to `/opt/theta42/sso-manager` and starts a
|
||||
systemd service:
|
||||
|
||||
```bash
|
||||
wget -O - https://raw.githubusercontent.com/theta42/sso-manager-node/master/install.sh | sudo bash
|
||||
```
|
||||
|
||||
That's it — LDAP and the app are both live afterward. Edit
|
||||
`/etc/sso-manager/secrets.js` (org name, SMTP, a non-default base DN, ...) and
|
||||
restart the service to customize. It's idempotent and safe to re-run —
|
||||
re-running it updates the app in place (never touching LDAP or the secrets
|
||||
file again) and prints the version you're updating from and to (e.g. `Updated
|
||||
v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
||||
|
||||
## Secrets
|
||||
|
||||
Secrets are loaded from **OpenBao** at boot via
|
||||
@@ -152,8 +72,8 @@ writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||
|
||||
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||
not the authoritative store. For the full architecture, policies, token model,
|
||||
and rotation procedure, see theta-env's
|
||||
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||
and rotation procedure, see theta-suite's
|
||||
**[Secrets docs](https://theta42.github.io/theta-suite/secrets.html)**.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -165,7 +85,7 @@ and rotation procedure, see theta-env's
|
||||
│ HTTP/HTTPS
|
||||
▼
|
||||
┌────────────────────────┐ ┌─────────────┐
|
||||
│ Express SSO Manager │◄────►│ Redis │
|
||||
│ Theta Directory │◄────►│ Redis │
|
||||
│ - OIDC provider │ │ - sessions │
|
||||
│ - web UI (:3001) │ │ - models │
|
||||
│ - management API │ └─────────────┘
|
||||
@@ -188,29 +108,13 @@ required groups, LDAPS/TLS, direct-bind service accounts) live in:
|
||||
- [DEPLOYMENT.md](DEPLOYMENT.md) — Docker + bare metal, the config layers, the
|
||||
`app_*` env reference, LDAPS/TLS, backups, troubleshooting.
|
||||
- [API.md](API.md) — the management API.
|
||||
- [docs/](docs/) (GitHub Pages) — the same content broken into
|
||||
[deployment](docs/deployment.md), [configuration](docs/configuration.md),
|
||||
[OAuth/OIDC](docs/oauth.md), and [LDAP](docs/ldap.md).
|
||||
- [docs/](docs/) — the same content broken into
|
||||
[OAuth/OIDC](docs/oauth.md) and [LDAP](docs/ldap.md), also published at the
|
||||
unified [theta-suite docs site](https://theta42.github.io/theta-suite/sso/).
|
||||
- [CHANGELOG.md](CHANGELOG.md) — what changed in each release.
|
||||
- All of the above is also readable from the running app itself at `/docs` —
|
||||
no internet access required.
|
||||
|
||||
If you are pointing the app at your own existing LDAP server, see
|
||||
*LDAP requirements* in [DEPLOYMENT.md](DEPLOYMENT.md) — the directory needs the
|
||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
||||
DEPLOYMENT.md for the full setup.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
cd nodejs
|
||||
npm install
|
||||
npm run dev # nodemon auto-reload
|
||||
npm test # jest test suite
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -367,3 +367,39 @@ Ordered by how much they unblock:
|
||||
conventions, not schema changes; the json column already holds them.
|
||||
5. **`updated_on` in graph output / graph etag** (blocks drift/DNS
|
||||
freshness; trivial once surfaced).
|
||||
|
||||
---
|
||||
|
||||
## 10. Subtype Management & Metrics Drivers Architecture
|
||||
|
||||
The Directory incorporates a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds resource `subType` metadata to specific telemetry, log streaming, and operational management protocols.
|
||||
|
||||
### Subtype Matrix & Drivers
|
||||
|
||||
| Subtype Category | Supported Subtypes | Primary Driver | Management Capabilities | Telemetry & Metrics |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **Service Managers** | `systemd`, `openrc`, `windows_service` | `ThetaAgentDriver` / Systemd | `start`, `stop`, `restart`, `reload` | CPU, Memory, Active PID, SubState |
|
||||
| **Containers & Stacks** | `docker`, `docker_compose` | `DockerSocketDriver` / Agent | `start`, `stop`, `restart`, `pause` | CPU %, Memory Limit/Usage, Net/Block I/O |
|
||||
| **Virtualization & Hypervisors** | `proxmox`, `lxc`, `kvm`, `esxi`, `libvirt_kvm`, `vps_generic` | `ProxmoxDriver` / Hypervisor | `start`, `stop`, `shutdown`, `reboot` | Guest VMID CPU/RAM/Disk, Parent Hypervisor status |
|
||||
| **Networking & Appliances** | `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense` | `NetworkDriver` | `restart`, `locate`, `sync` | Connected Clients, Handshakes, Gateway RTT, Channels |
|
||||
| **Databases & Vaults** | `postgresql`, `redis`, `openbao_vault` | `DbDriver` | `flush`, `seal`, `unseal` | DB Size, Connections, Hit Rates, Active Leases |
|
||||
| **Orchestration** | `k8s_pod`, `k8s_deployment` | `K8sDriver` | `scale`, `restart`, `rollout_restart` | Desired/Ready Replicas, Pod Phase, IP |
|
||||
| **Workstations** | `desktop_linux`, `desktop_windows` | `ThetaAgentDriver` | `reboot`, `shutdown`, Display Manager | CPU, Memory, GPU, Active Sessions |
|
||||
|
||||
*(Note: Reverse Proxy subtypes like Nginx/HAProxy/Caddy/Traefik are excluded per environment configuration).*
|
||||
|
||||
### 4-Tier Driver Resolution Engine
|
||||
1. **Direct Agent Execution**: If `theta-agent` is connected directly to the target resource.
|
||||
2. **Subtype-Specific Driver**: Executes specialized protocol driver (e.g. Proxmox API, Docker Engine API, DB Driver).
|
||||
3. **Ancestor / Hypervisor Fallback**: If an LXC/KVM guest lacks a direct agent, queries its parent Proxmox hypervisor node for metrics and power controls.
|
||||
4. **Unmanaged Fallback**: Reports unmanaged status cleanly without breaking UI/API contracts.
|
||||
|
||||
### Subtype Operations Endpoints
|
||||
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management action (`{ action, params }`)
|
||||
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail log output (`?lines=100`)
|
||||
|
||||
## 11. Multi-Site
|
||||
|
||||
This directory can run across multiple sites (one **master** with write authority, any number of **spoke** read-only replicas that stay live-synced after joining), coordinate master promotion, and share the agent-signing key across sites. Full design and operational detail: [`docs/site-join.md`](docs/site-join.md) and, at the suite level, `theta-suite`'s `docs/MULTI_SITE_SPEC.md`.
|
||||
|
||||
|
||||
@@ -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,69 @@
|
||||
# End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
|
||||
#
|
||||
# Spins up two full all-in-one instances (app + bundled slapd each, like
|
||||
# docker-compose.repl-test.yml) — "master" and "spoke" — plus a client that
|
||||
# drives the actual HTTP API a human/operator would use: mint a site join key
|
||||
# on master, join from spoke, verify the spoke adopted the catalog, went
|
||||
# read-only, and reports live WAN health.
|
||||
#
|
||||
# docker compose -f docker-compose.multisite-e2e.yml up --build --abort-on-container-exit
|
||||
# # exit code 0 = MULTISITE E2E PASS
|
||||
#
|
||||
# slapcat (used by POST /api/site/export) only sees the LDAP data of the
|
||||
# container it runs in, so this MUST use the all-in-one image (master and
|
||||
# spoke each carry their own slapd) — the split ldap+redis+app harness used
|
||||
# by docker-compose.test.yml/e2e.yml won't exercise export/join at all.
|
||||
|
||||
services:
|
||||
master:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.openldap
|
||||
container_name: multisite_e2e_master
|
||||
environment:
|
||||
- LDAP_BASE_DN=dc=master,dc=test
|
||||
- LDAP_ADMIN_PASS=secret
|
||||
- ORG_NAME=E2E Master
|
||||
- app_oauth__jwtSecret=e2e-multisite-master-jwt-secret
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 40
|
||||
start_period: 5s
|
||||
|
||||
spoke:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.openldap
|
||||
container_name: multisite_e2e_spoke
|
||||
environment:
|
||||
- LDAP_BASE_DN=dc=spoke,dc=test
|
||||
- LDAP_ADMIN_PASS=secret
|
||||
- ORG_NAME=E2E Spoke
|
||||
- app_oauth__jwtSecret=e2e-multisite-spoke-jwt-secret
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "wget -qO- http://localhost:3001/health >/dev/null 2>&1"]
|
||||
interval: 2s
|
||||
timeout: 3s
|
||||
retries: 40
|
||||
start_period: 5s
|
||||
|
||||
client:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.test-runner
|
||||
command: ["sh", "-c", "node test/multisite_join_e2e.js"]
|
||||
environment:
|
||||
- MASTER_URL=http://master:3001
|
||||
- SPOKE_URL=http://spoke:3001
|
||||
- MASTER_LDAP_HOST=master
|
||||
- MASTER_BASE_DN=dc=master,dc=test
|
||||
- SPOKE_LDAP_HOST=spoke
|
||||
- SPOKE_BASE_DN=dc=spoke,dc=test
|
||||
- LDAP_ADMIN_PASS=secret
|
||||
depends_on:
|
||||
master:
|
||||
condition: service_healthy
|
||||
spoke:
|
||||
condition: service_healthy
|
||||
@@ -6,7 +6,25 @@ nav_order: 5
|
||||
|
||||
# Theta Agent & Endpoint Management
|
||||
|
||||
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go with native cross-platform binaries for **Linux (x86_64, ARM64, ARMv7)**, **Windows (x86_64, ARM64)**, and **macOS (Intel, Apple Silicon M1/M2/M3/M4)**. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||
|
||||
---
|
||||
|
||||
## Supported Architectures & Operating Systems
|
||||
|
||||
The agent is compiled for 7 target platform binaries with zero external runtime dependencies:
|
||||
|
||||
| Operating System | Architecture | Binary Name | Typical Target Devices |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **Linux** | `amd64` (x86_64) | `theta-agent-linux-amd64` | Intel/AMD Servers, Cloud VMs, Proxmox Hypervisors |
|
||||
| **Linux** | `arm64` (aarch64) | `theta-agent-linux-arm64` | Raspberry Pi 4/5, Graviton, Ampere Altra |
|
||||
| **Linux** | `armv7` (32-bit ARM) | `theta-agent-linux-armv7` | Raspberry Pi 2/3/Zero 2W, ARM IoT Gateways |
|
||||
| **Windows** | `amd64` (x86_64) | `theta-agent-windows-amd64.exe` | Windows Server, Windows 10/11 Desktop |
|
||||
| **Windows** | `arm64` | `theta-agent-windows-arm64.exe` | Windows on ARM, Surface Pro |
|
||||
| **macOS** | `amd64` | `theta-agent-darwin-amd64` | Intel Macs |
|
||||
| **macOS** | `arm64` | `theta-agent-darwin-arm64` | Apple Silicon Macs (M1/M2/M3/M4) |
|
||||
|
||||
The `install.sh` script automatically detects `uname -s` and `uname -m` to download the exact binary for the host.
|
||||
|
||||
---
|
||||
|
||||
@@ -44,10 +62,35 @@ host does not yield a credential that works anywhere else.
|
||||
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||
| `GET /api/agent/join-keys/:id/agents` | Which hosts enrolled through this key |
|
||||
|
||||
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||
|
||||
**Reuse.** Yes — a join key is not consumed on use. `AgentJoinKey.authenticate`
|
||||
only checks `revoked` and `expires_on`; it never invalidates the key itself.
|
||||
Every use increments `use_count` and stamps `last_used_on`, but the key keeps
|
||||
working until you revoke or delete it (or it expires) — "one key works for as
|
||||
many hosts as you like" above is literal, not a figure of speech.
|
||||
|
||||
**UI.** The **Install Agent** modal (Directory → Install Agent → Join key tab)
|
||||
has a **Manage join keys** table below the mint/select dropdown: label, prefix,
|
||||
created date, hosts joined, status, and **Revoke**/**Delete** actions per key.
|
||||
Clicking a key's "N hosts" link expands the list of hosts that joined through
|
||||
it (name, online status, joined date, last seen).
|
||||
|
||||
**Audit.** Yes, both halves are logged as structured `"component":"agent"`
|
||||
lines, and the hosts-joined list in the UI above is queryable directly:
|
||||
- Minting: `action: "join_key_issued"` records the acting admin (`actor`),
|
||||
`label`, and `keyPrefix`.
|
||||
- Each enrollment through that key: `action: "join"` records `agentId`,
|
||||
`agentName`, `remoteAddr`, `joinKeyLabel`, and `joinKeyPrefix`.
|
||||
- `GET /api/agent/join-keys/:id/agents` returns the same "which hosts did key
|
||||
X add" answer the UI shows — it matches on the trace `Agent.enroll` leaves in
|
||||
each agent's `description` ("Self-enrolled with join key `<prefix>`") rather
|
||||
than a stored foreign key, since a join key is exchanged for a per-agent
|
||||
token immediately and from then on the agent's own identity is what matters.
|
||||
|
||||
### Pre-registering a host
|
||||
|
||||
When you want the agent bound to a specific Directory host up front, enroll it
|
||||
@@ -167,6 +210,9 @@ To protect hosts against unauthorized control, `theta-agent` enforces a **strict
|
||||
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
|
||||
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
|
||||
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
|
||||
| **LDAP Tunnel** | `ldap_tunnel` | Moderate | Serves a local LDAP byte-pump socket (`ldap_socket`, default `/run/theta/ldap.sock`) for SSSD/PAM. The agent never parses LDAP — it forwards raw bytes to the SSO, which relays them into its own OpenLDAP. |
|
||||
| **Secrets** | `secrets` | Moderate | Renders OpenBao secrets to local files from templates (see [Secrets Engine](#secrets-engine---rendering-openbao-secrets-to-local-files) below). |
|
||||
| **IAM** | `iam` | Critical | Applies SSO-pushed node identity config: sudo rules, SSH `AuthorizedKeysCommand` keys, `/etc/security/access.conf`, and revocation (`sss_cache -E` + session kill). Every push is Ed25519-signed. |
|
||||
|
||||
---
|
||||
|
||||
@@ -197,6 +243,174 @@ would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
|
||||
|
||||
---
|
||||
|
||||
## Secrets Engine — rendering OpenBao secrets to local files
|
||||
|
||||
The agent can render OpenBao secrets to local files that any process on the
|
||||
host — a bash script, a systemd unit, a Node app, whatever — reads like an
|
||||
ordinary env file. The agent never holds a Vault token: it asks the SSO for the
|
||||
values over its existing WSS channel, and the SSO fetches them from OpenBao
|
||||
using its own access, scoped so the agent can only ever read its own node's
|
||||
secrets.
|
||||
|
||||
**Node scope.** Every path an agent can request must start with
|
||||
`secret/data/nodes/<this-agent's-id>/`. The SSO enforces this server-side
|
||||
(`POST /api/v1/agent/secrets`); a request for any other node's path is
|
||||
rejected:
|
||||
|
||||
```
|
||||
$ curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||
-d '{"paths":["secret/data/nodes/some-other-node-id/db"]}'
|
||||
{"status":"error","message":"path outside node scope: secret/data/nodes/some-other-node-id/db"}
|
||||
```
|
||||
|
||||
A compromised agent can therefore never reach another host's secrets, or
|
||||
anything outside `secret/data/nodes/*`.
|
||||
|
||||
### Walkthrough: a 3rd-party app reads a secret the agent rendered
|
||||
|
||||
This walks through the whole path end to end, on a stack freshly brought up
|
||||
from theta-suite's own `docs/fixtures.md` demo data — the same steps work on
|
||||
any theta-suite install.
|
||||
|
||||
**1. Enroll the host.** Directory → Install Agent → mint a join key, run the
|
||||
install command on the target host as root.
|
||||
|
||||
<a href="images/agent-install-join-key.png" target="_blank"><img src="images/agent-install-join-key.png" alt="Install Theta Agent modal with a freshly minted join key and install command" width="80%"></a>
|
||||
|
||||
On first connect the agent exchanges the join key for its own token + the
|
||||
SSO's public key and writes both back into `/etc/theta42/agent.yml`. Note the
|
||||
agent's id from `GET /api/agent/nodes` (or the Directory URL) — you need it for
|
||||
the next step.
|
||||
|
||||
**2. Turn on the `secrets` capability and point it at a template.** Add to the
|
||||
host's `/etc/theta42/agent.yml`:
|
||||
|
||||
```yaml
|
||||
secrets:
|
||||
- template: /etc/theta/templates/db.env.tpl
|
||||
target: /etc/theta/rendered/db.env
|
||||
reload: "" # optional: e.g. "systemctl reload myapp"
|
||||
|
||||
capabilities:
|
||||
secrets: true
|
||||
```
|
||||
|
||||
And the template itself, `/etc/theta/templates/db.env.tpl` — placeholders are
|
||||
`{{ bao "secret/data/nodes/<agent-id>/<name>#<key>" }}`:
|
||||
|
||||
```
|
||||
DB_USER="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#username" }}"
|
||||
DB_PASS="{{ bao "secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db#password" }}"
|
||||
```
|
||||
|
||||
Restart the agent to pick up the config change.
|
||||
|
||||
**3. Seed the secret.** From `theta-suite/` (theta-env), as the operator:
|
||||
|
||||
```
|
||||
./setup.sh --seed-node-secret f9a30ab0-7d8a-4b77-a4c4-6a6383d084db db \
|
||||
username=demoapp password=CorrectHorseBattery42
|
||||
```
|
||||
|
||||
This writes to `secret/nodes/<agent-id>/db` in OpenBao (the CLI path — the HTTP
|
||||
API the agent uses sees it as `secret/data/nodes/<agent-id>/db`, matched by the
|
||||
node-scope check above). It's idempotent: it skips silently if that path is
|
||||
already seeded.
|
||||
|
||||
**4. Trigger the render.** The Directory UI doesn't have a button for this yet
|
||||
— push it the same way any admin command goes out, `POST
|
||||
/api/agent/nodes/:id/command`. It's in the high-risk list, so the SSO signs it
|
||||
automatically:
|
||||
|
||||
```
|
||||
curl -X POST https://sso.example.com/api/agent/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/command \
|
||||
-H "auth-token: <admin session token>" -H 'Content-Type: application/json' \
|
||||
-d '{"command": "render_secrets", "payload": {}}'
|
||||
```
|
||||
|
||||
The agent logs `Received command: render_secrets` / `Rendering secret
|
||||
templates...` and atomically writes the target file at mode `0600`:
|
||||
|
||||
```
|
||||
$ cat /etc/theta/rendered/db.env
|
||||
DB_USER="demoapp"
|
||||
DB_PASS="CorrectHorseBattery42"
|
||||
```
|
||||
|
||||
Back in the Directory, the host's Metrics tab shows **Secrets** lit up green
|
||||
among the reported capabilities:
|
||||
|
||||
<a href="images/agent-capabilities-metrics.png" target="_blank"><img src="images/agent-capabilities-metrics.png" alt="Directory Metrics tab showing live telemetry and the agent's reported capability badges, with Telemetry and Secrets lit green" width="80%"></a>
|
||||
|
||||
**5. Read it from a bash app on the same host.** The rendered file is just an
|
||||
env file — no agent involvement needed to consume it:
|
||||
|
||||
```sh
|
||||
#!/bin/sh
|
||||
. /etc/theta/rendered/db.env
|
||||
echo "DB_USER=$DB_USER"
|
||||
echo "DB_PASS=$DB_PASS"
|
||||
```
|
||||
|
||||
**6. Read it from a Node app on the same host:**
|
||||
|
||||
```js
|
||||
const fs = require('fs');
|
||||
const env = fs.readFileSync('/etc/theta/rendered/db.env', 'utf8');
|
||||
const db = {};
|
||||
for (const line of env.split('\n')) {
|
||||
const m = /^(\w+)="(.*)"$/.exec(line.trim());
|
||||
if (m) db[m[1]] = m[2];
|
||||
}
|
||||
console.log('DB_USER=' + db.DB_USER);
|
||||
console.log('DB_PASS=' + db.DB_PASS);
|
||||
```
|
||||
|
||||
Both print the same values the template resolved — `demoapp` /
|
||||
`CorrectHorseBattery42` in this walkthrough. `theta-agent/demo/` in the
|
||||
theta-agent repo has these two scripts ready to run.
|
||||
|
||||
### Alternative: calling the API directly
|
||||
|
||||
Rendering to a file is the normal path — it works for any app regardless of
|
||||
language, and the secret never touches an HTTP client the app itself controls.
|
||||
But an app can also fetch its node's secrets directly, bypassing the template
|
||||
engine entirely (useful for debugging, or a process that wants to hold the
|
||||
value only in memory). This uses the **agent's own bearer token**, not an admin
|
||||
token — the same node-scope enforcement applies:
|
||||
|
||||
```sh
|
||||
curl -sk https://sso.example.com/api/v1/agent/secrets \
|
||||
-H "Authorization: Bearer <agent-token>" -H 'Content-Type: application/json' \
|
||||
-d '{"paths":["secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db"]}'
|
||||
```
|
||||
|
||||
```js
|
||||
const token = process.env.THETA_AGENT_TOKEN; // from /etc/theta42/agent.yml
|
||||
fetch('https://sso.example.com/api/v1/agent/secrets', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ paths: ['secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db'] })
|
||||
}).then(r => r.json()).then(d => console.log(d.secrets));
|
||||
```
|
||||
|
||||
Both return:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"secrets": {
|
||||
"secret/data/nodes/f9a30ab0-7d8a-4b77-a4c4-6a6383d084db/db": {
|
||||
"username": "demoapp",
|
||||
"password": "CorrectHorseBattery42"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Installation & Deployment
|
||||
|
||||
### Quick One-Liner Install
|
||||
@@ -294,5 +508,3 @@ Fix options:
|
||||
> sure the proxy has a **persistent Host record** for the real SSO domain — not
|
||||
> just the `localtest.me` placeholder — so routing survives a proxy restart
|
||||
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
|
||||
|
||||
|
||||
|
||||
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
|
||||
`localhost`, `SSO Manager`).
|
||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
||||
4. **`app_*` environment variables** — the highest-precedence layer.
|
||||
4. **`app_*` environment variables** — the highest-precedence layer among these
|
||||
four.
|
||||
|
||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
||||
of the name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||
raw strings otherwise.
|
||||
|
||||
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
|
||||
|
||||
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
|
||||
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
|
||||
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
|
||||
everything else here. On top of that, the admin **Configuration** page in the
|
||||
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
|
||||
and applies the change to the live `conf` object immediately
|
||||
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
|
||||
If a value isn't behaving the way `conf/secrets.js` says it should, check the
|
||||
Configuration UI / OpenBao before assuming a file edit didn't take — it's
|
||||
almost certainly OpenBao (or a live UI edit) winning the merge.
|
||||
|
||||
## Examples
|
||||
|
||||
| Env var | Sets | Type |
|
||||
|
||||
@@ -139,6 +139,32 @@ The inventory graph isn't just documentation — other components read it to mak
|
||||
|
||||
Planned consumers (end-user catalog, firewall/DNS generation) and the model/API gaps they need are tracked in [`directory_spec.md`](https://github.com/theta42/sso-manager-node/blob/master/directory_spec.md) §9.
|
||||
|
||||
## Subtype Management & Metrics Drivers Architecture
|
||||
|
||||
The Directory includes a **4-tier Driver Resolution Engine** (`services/driver_registry.js`) that binds a resource's `subType` metadata to specific operational protocols for real-time telemetry, log streaming, and remote lifecycle management:
|
||||
|
||||
1. **Direct Agent Execution** (`ThetaAgentDriver`): Used when a `theta-agent` daemon is connected to the resource (`systemd`, `docker`, `zfs_pool`, `desktop_linux`, `openrc`, `wireguard`).
|
||||
2. **Specialized Subtype Drivers**:
|
||||
- `ProxmoxDriver`: Proxmox VE hypervisors & `lxc` / `kvm` guest controls.
|
||||
- `DockerSocketDriver`: Docker Engine API & `docker_compose` stacks.
|
||||
- `DbDriver`: `postgresql`, `redis`, `openbao_vault`.
|
||||
- `NetworkDriver`: `wireguard`, `unifi_ap`, `unifi_switch`, `pfsense`.
|
||||
- `K8sDriver`: `k8s_pod`, `k8s_deployment`.
|
||||
3. **Ancestor / Hypervisor Provider Fallback**: If an LXC/KVM guest lacks a direct agent, the engine automatically queries its parent Proxmox hypervisor node for VMID telemetry and power controls.
|
||||
4. **Unmanaged Fallback**: Reports unmanaged status cleanly.
|
||||
|
||||
### Subtype Operations API
|
||||
- `GET /api/directory-admin/resources/:id/driver-metrics` — Real-time telemetry payload
|
||||
- `POST /api/directory-admin/resources/:id/driver-action` — Execute management actions (`{ action, params }`)
|
||||
- `GET /api/directory-admin/resources/:id/driver-logs` — Tail operational log output (`?lines=100`)
|
||||
|
||||
## Explicit Secret Inheritance Mode
|
||||
|
||||
Resource secrets stored in OpenBao (`secret/data/resources/<slug>/conf`) use **Explicit Secret Inheritance Mode** with strict upward ancestor lineage:
|
||||
|
||||
- **Strict Ancestor Lineage**: When viewing candidate secrets for inheritance, the dropdown strictly filters to **direct upward ancestors** in the directory hierarchy (Resource $\rightarrow$ Parent Host $\rightarrow$ Cluster $\rightarrow$ Site). Sibling resources across the directory are never exposed.
|
||||
- **Explicit Assignment**: Secret pointers (`INHERIT:<parentSlug>:<parentKey>`) are explicitly saved per resource, guaranteeing precise secret scoping across hosts, LXC/KVM containers, and services.
|
||||
|
||||
## API
|
||||
|
||||
All of the above uses the same admin API the UI does (group `app_sso_directory_admin` or `app_sso_admin`):
|
||||
|
||||
@@ -6,6 +6,8 @@ nav_order: 6
|
||||
|
||||
# Discovery & Inventory
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The Directory holds two different kinds of thing, and the distinction matters
|
||||
for every consumer of the directory:
|
||||
|
||||
|
||||
|
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,7 +60,10 @@ backend, that's the niche.
|
||||
run the pieces separately via `app_*` env config.
|
||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
|
||||
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
|
||||
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
|
||||
|
||||
## Get it
|
||||
|
||||
|
||||
@@ -17,11 +17,25 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||
|
||||
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||
`category`. The built-ins ship under `plugins/discovery/`:
|
||||
`category`. Two built-in categories ship today:
|
||||
|
||||
**`discovery`** — scheduled scans that sync external assets into the
|
||||
directory catalog:
|
||||
|
||||
- `proxmox` — Proxmox VE (URL + API token)
|
||||
- `unifi` — UniFi Network controller (URL + username/password)
|
||||
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||
- `docker` — Docker daemon discovery (containers as directory resources)
|
||||
|
||||
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
|
||||
notifications:
|
||||
|
||||
- `twilio` — Twilio SMS
|
||||
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
|
||||
Discord, or any HTTP endpoint)
|
||||
|
||||
If no messaging plugin instance is enabled, the system falls back to the
|
||||
legacy `voipms` integration configured directly in the SSO secrets.
|
||||
|
||||
### What the Proxmox plugin produces
|
||||
|
||||
|
||||
@@ -23,32 +23,51 @@ In an N-Way Multi-Master setup, every site runs a fully active OpenLDAP server (
|
||||
|
||||
## Configuration
|
||||
|
||||
To enable replication, you must pass two environment variables to the `sso-manager` container:
|
||||
The container's entrypoint reads two environment variables to configure this
|
||||
-- `LDAP_SERVER_ID` (a unique integer for this node) and
|
||||
`LDAP_REPLICATION_HOSTS` (a space-separated list of every **other** node's
|
||||
LDAP URL) -- and, when both are set, automatically loads the `syncprov`
|
||||
module, enables `mirrormode`, and generates the necessary `syncrepl` blocks
|
||||
in `/etc/openldap/slapd.conf`.
|
||||
|
||||
1. `LDAP_SERVER_ID`: A unique integer for this node (e.g., `1`, `2`, `3`). This MUST be unique across the cluster.
|
||||
2. `LDAP_REPLICATION_HOSTS`: A space-separated list of the LDAP URLs of all **other** nodes in the cluster.
|
||||
**If you're using `theta-suite`'s `setup.sh`, you don't set these by hand.**
|
||||
The master assigns each spoke a unique `LDAP_SERVER_ID` at join time (the
|
||||
same way it assigns a WireGuard mesh index), and `LDAP_REPLICATION_HOSTS` is
|
||||
derived automatically from every site's already-known HTTPS endpoint
|
||||
(`ldaps://<same-host>:636`) -- see `GET /api/site/ldap-peers` (spoke) and
|
||||
`GET /api/directory-admin/ldap-replication-config` (master), and
|
||||
`theta-suite`'s `bootstrap/site-ldap-register.js`, which re-checks on every
|
||||
`setup.sh` run since the peer list changes as new spokes join.
|
||||
|
||||
### Example using `theta-env` / Docker Compose
|
||||
Setting the two env vars directly still works (e.g. a non-`theta-suite`
|
||||
deployment) -- example using three manually-configured nodes:
|
||||
|
||||
**Site 1 (`setup.env` or `docker-compose.yml`)**
|
||||
**Site 1**
|
||||
```env
|
||||
LDAP_SERVER_ID=1
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site2.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 2 (`setup.env` or `docker-compose.yml`)**
|
||||
**Site 2**
|
||||
```env
|
||||
LDAP_SERVER_ID=2
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site3.com:636"
|
||||
```
|
||||
|
||||
**Site 3 (`setup.env` or `docker-compose.yml`)**
|
||||
**Site 3**
|
||||
```env
|
||||
LDAP_SERVER_ID=3
|
||||
LDAP_REPLICATION_HOSTS="ldaps://sso.site1.com:636 ldaps://sso.site2.com:636"
|
||||
```
|
||||
|
||||
Once configured, the container's entrypoint will automatically load the `syncprov` module, enable `mirrormode`, and generate the necessary `syncrepl` blocks in `/etc/openldap/slapd.conf`.
|
||||
**A known limitation of the automatic path**: the *master's* own
|
||||
`LDAP_REPLICATION_HOSTS` only gets recomputed when its `setup.sh` is
|
||||
re-run (or the operator re-applies it directly) -- there's no live push
|
||||
telling the master's already-running container about a spoke that joined
|
||||
five minutes ago. A spoke's own config, by contrast, is re-checked and
|
||||
applied on every `setup.sh` run there, which is the common/recurring event.
|
||||
Re-run `setup.sh` on the master after bringing up a new spoke to pick up the
|
||||
new peer and restart replication with it.
|
||||
|
||||
## User Locations
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
# Multi-Site: Joining a Spoke to the Master Directory
|
||||
|
||||
The Directory can be deployed across multiple sites. The **master** site holds
|
||||
single write authority for the shared catalog; **spoke** sites run a read-only
|
||||
copy for local latency and autonomy (see the root `MULTI_SITE_SPEC.md` for the
|
||||
full architecture). This page covers the server endpoints that make a spoke
|
||||
"join" an existing master.
|
||||
|
||||
> Status: **server endpoints + UI + setup.sh wiring, live replication, coordinated promotion.** A fresh bring-up can adopt a master directory via the Directory UI or via `setup.env`; a joined spoke is read-only with live WAN health, stays in sync after joining (not just a one-time snapshot), and can be promoted to master with the old master demoted as part of the same action.
|
||||
|
||||
## The flow
|
||||
|
||||
1. On the **master**, an admin mints a **site join key** (`stj_…`, shown once,
|
||||
stored hashed, revocable) — Directory → the Master Site modal → **Site Join Keys**.
|
||||
2. On the **spoke** (a fresh install), either:
|
||||
- **UI**: Directory → the Master Site modal → **Join an Existing Site**, or
|
||||
- **setup.sh**: set `CFG_MASTER_DIRECTORY_URL` + `CFG_MASTER_DIRECTORY_JOIN_KEY`
|
||||
in `setup.env` before the first run.
|
||||
3. The spoke pulls the master's directory export (LDAP tree + resource
|
||||
catalog + agent-signing key), imports it, and persists its own spoke role
|
||||
(`isMaster: false`, `masterUrl`, `siteSlug`) in `/config/site.json`.
|
||||
4. If the spoke also knows its own reachable URL (`selfUrl` — `setup.sh` passes
|
||||
`https://$CFG_SSO_HOST` automatically), it registers itself with the master
|
||||
(`POST /api/site/spokes`) so the master can push live updates back to it
|
||||
afterward — see **Live replication** below. Without `selfUrl` the join still
|
||||
succeeds; the spoke just stays a one-time snapshot.
|
||||
|
||||
Joining is allowed only on a **fresh install** (no users beyond the bootstrap
|
||||
admin, no enrolled agents) — the join endpoint enforces this, so a populated
|
||||
directory can never be merged into a master's.
|
||||
|
||||
## Live replication (not a one-time snapshot)
|
||||
|
||||
A registered spoke stays in sync: every successful catalog write on the
|
||||
master fires a fire-and-forget push (`utils/site_replicate.js`) at every
|
||||
registered spoke, concurrently — one unreachable spoke never blocks or delays
|
||||
delivery to another. The spoke's `POST /api/site/resync` handler (called by
|
||||
that push) re-runs the same export-pull-and-import logic used at join time,
|
||||
so there's exactly one tested code path for "make my catalog match the
|
||||
master's," not a separate diff-application mechanism.
|
||||
|
||||
The agent-signing key travels the same path: `POST /api/site/export`
|
||||
best-effort includes it, and the spoke adopts it via `agent_keys.adopt()` on
|
||||
both join and every resync. Every site holding the same signing key means any
|
||||
site's `sso-manager-node` can validly sign a command for any agent enrolled
|
||||
at any other site — a deliberate tradeoff (see `MULTI_SITE_SPEC.md` §2)
|
||||
accepted for this deployment's small, trusted scale. Don't extend this
|
||||
pattern to a larger/adversarial-tenant deployment without revisiting it.
|
||||
|
||||
## Coordinated master promotion
|
||||
|
||||
`POST /api/directory-admin/site-promote` (`god_admin` only) promotes this
|
||||
node to master as **one coordinated action**, not a manual two-step
|
||||
demote-then-promote:
|
||||
|
||||
1. If this node currently has a master on file, it mints a fresh join key and
|
||||
calls that master's `POST /api/site/demote` (authenticated with the join
|
||||
key this node already holds), handing over the new key so the demoted node
|
||||
can keep talking to the new master afterward.
|
||||
2. This step is **best-effort** — an unreachable old master (the WAN-outage
|
||||
scenario this whole control exists for) never blocks the local promotion.
|
||||
The response's `handoff` field reports what happened
|
||||
(`"previous master demoted"`, an HTTP failure, or "unreachable, promoted
|
||||
locally anyway") so the operator can reconcile it manually if needed.
|
||||
3. Every known spoke gets a fire-and-forget `master-promoted` resync ping so
|
||||
they pick up the new master on their next sync.
|
||||
|
||||
The Master Site modal's **Promote to Master** button surfaces the `handoff`
|
||||
result in a toast so the operator sees immediately whether the old master was
|
||||
actually reached.
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Method | Path | Purpose |
|
||||
| :--- | :--- | :--- |
|
||||
| `GET` | `/api/site/join-keys` | List keys (prefix + usage only; never the key) |
|
||||
| `POST` | `/api/site/join-keys` | Mint one — returned **once** |
|
||||
| `POST` | `/api/site/join-keys/:id/revoke` | Stop it accepting new joins |
|
||||
| `DELETE` | `/api/site/join-keys/:id` | Remove it |
|
||||
| `GET` | `/api/site/config` | Current role (isMaster, masterUrl, siteSlug) |
|
||||
| `POST` | `/api/site/export` | Master directory export incl. agent-signing key (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/ping` | Lightweight master reachability probe (Bearer `stj_` key) |
|
||||
| `POST` | `/api/site/join` | Adopt a master directory + register for live replication (admin session) |
|
||||
| `POST` | `/api/site/spokes` | Register a spoke's endpoint for live replication (Bearer `stj_` key, called by the spoke right after join) |
|
||||
| `POST` | `/api/site/resync` | Re-pull the master's export (Bearer the spoke's own `pushToken`, called by the master's fire-and-forget push) |
|
||||
| `POST` | `/api/site/demote` | Step down to spoke of a new master (Bearer `stj_` key, called by the newly-promoted node) |
|
||||
| `POST` | `/api/directory-admin/site-promote` | Promote this node to master, coordinating demotion of the old one (`god_admin` session) |
|
||||
|
||||
## Behavior after joining (spoke)
|
||||
|
||||
- **Read-only**: directory-write requests (resources, edges, groups, secrets,
|
||||
grants, driver actions, discovery merges) are rejected with `403` pointing at
|
||||
the master. Writes must go to the master.
|
||||
- **WAN health**: `site-status` pings the master over the stored site join key
|
||||
and reports `wanConnected`; the Master Site modal shows live Online/Offline.
|
||||
- **Role persists**: `isMaster`/`masterUrl`/`siteSlug` live in `/config/site.json`
|
||||
(the env vars `IS_MASTER`/`MASTER_URL`/`SITE_SLUG` only seed the defaults), so
|
||||
a restart never silently reverts a spoke to master.
|
||||
|
||||
## Deployment (setup.sh)
|
||||
|
||||
`setup.env` carries the intent so the join runs only on a **fresh** bring-up:
|
||||
|
||||
```
|
||||
# Honored ONLY on first run; re-runs ignore it once ./config/ exists.
|
||||
CFG_MASTER_DIRECTORY_URL=https://sso.master.example.com
|
||||
CFG_MASTER_DIRECTORY_JOIN_KEY=stj_9f2e...
|
||||
```
|
||||
|
||||
`setup.sh` runs `bootstrap/site-join.js` inside the sso-manager container after
|
||||
the bootstrap; it logs in as the admin and calls `/api/site/join`. A node that
|
||||
already joined reports "already a spoke" and setup continues (idempotent).
|
||||
|
||||
## Security
|
||||
|
||||
- Join keys are single-use-intent credentials: shown once, stored as a SHA-256
|
||||
hash, revocable/expirable — the same model as agent join keys.
|
||||
- The export/ping endpoints return only the directory tree/catalog (no admin
|
||||
secrets) and require a valid join key.
|
||||
- Join is admin-gated on the spoke, key-gated on the master, and fresh-install
|
||||
gated on both sides.
|
||||
- The join key is stored on the spoke only so it can reach the master for WAN
|
||||
health (and, in a later layer, write-proxy).
|
||||
- `pushToken` (the credential a spoke stores so it can recognize a legitimate
|
||||
resync push from its master) is minted fresh per spoke registration and, by
|
||||
design, kept in retrievable form on the master — unlike a join key, it's a
|
||||
credential the master must keep *presenting*, not just verifying, so it
|
||||
can't be one-way hashed. Compare `models/site_spoke.js`'s doc comment for
|
||||
why that's the correct tradeoff, not an oversight.
|
||||
- Every site sharing one agent-signing key (see **Live replication** above)
|
||||
means a compromised spoke — including the smallest, least-secured one — has
|
||||
the same agent-command authority as the master. Accepted for this
|
||||
deployment's scale; see `MULTI_SITE_SPEC.md` §2 before reusing this pattern
|
||||
somewhere that assumption doesn't hold.
|
||||
|
||||
## Not yet built
|
||||
|
||||
- Traffic between sites (join/export/resync) still goes over the open
|
||||
network path that already reaches the target — it does not route over the
|
||||
WireGuard mesh `theta-gateway` can now establish (see `MULTI_SITE_SPEC.md`).
|
||||
- A no-inbound spoke (no public IP at all) still can't join — the mechanism
|
||||
for a master to relay through the mesh to such a spoke is verified as
|
||||
working, but nothing automates creating that route yet.
|
||||
- OpenBao secret replication covers only the agent-signing key; LDAP admin
|
||||
creds, JWT secret, and other per-deployment secrets aren't synced.
|
||||
@@ -1,42 +1,29 @@
|
||||
---
|
||||
layout: default
|
||||
title: Vault Secrets
|
||||
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
|
||||
---
|
||||
|
||||
# Vault Secrets Management
|
||||
|
||||
[← Back to Home](index.html)
|
||||
|
||||
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||
|
||||
## Usage
|
||||
## Location & Access
|
||||
|
||||
You can access the Vault UI from the application's top navigation bar.
|
||||
- **External App Tokens**: Managed under **Configuration** (`/conf` -> **App Tokens** tab). Admins can mint and view periodic OpenBao app tokens scoped to `secret/apps/<name>/*`.
|
||||
- **Resource Secrets**: Managed under **Directory** (`/directory`) inside each resource's modal under the **Secrets** tab. Stored in OpenBao under `secret/data/resources/<slug>/conf`.
|
||||
|
||||
### Creating Secrets
|
||||
## External App Tokens (Admin)
|
||||
|
||||
1. Click on the **New Secret** button.
|
||||
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||
3. Enter the **Secret Data** in JSON format. For example:
|
||||
```json
|
||||
{
|
||||
"username": "admin",
|
||||
"password": "supersecretpassword123"
|
||||
}
|
||||
```
|
||||
4. Click **Save Secret**.
|
||||
The **App Tokens** tab in **Configuration** (`/conf`) mints a scoped OpenBao token for an **external application** or script so it can read its own configuration out of OpenBao.
|
||||
|
||||
### Reading and Editing Secrets
|
||||
1. Enter an app **name** (e.g. `build-agent`) and click **Mint token**.
|
||||
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*`.
|
||||
3. The **Active App Tokens** list shows every token created (metadata only — the token itself is never stored). sso-manager keeps each token alive by renewing it periodically.
|
||||
|
||||
* To view a secret, click on its name in the **Secrets List**.
|
||||
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||
|
||||
### OpenBao Integration
|
||||
|
||||
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||
|
||||
## Apps tab (admin)
|
||||
|
||||
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||
|
||||
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||
|
||||
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||
The token is strictly scoped to `secret/apps/<name>/*` (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||
|
||||
## Shared tab
|
||||
|
||||
@@ -48,9 +35,24 @@ The **Shared** tab lets you share a secret with another user (or app) without co
|
||||
|
||||
## API Access
|
||||
|
||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||
To read your own secrets programmatically, call the `/api/vault` proxy with
|
||||
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
|
||||
token. The server authenticates the request, resolves your own scoped
|
||||
OpenBao access, and injects the real `X-Vault-Token` itself:
|
||||
|
||||
```bash
|
||||
# Example: Read a secret via the API
|
||||
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
|
||||
curl -H "Authorization: Bearer sso_<id>_<secret>" \
|
||||
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||
```
|
||||
|
||||
An **external app** reading its own config uses the scoped token minted for
|
||||
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||
above for how that token is minted and what it's confined to.
|
||||
|
||||
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||
never the intended path for day-to-day secret access — it's an
|
||||
operator/maintenance credential (seeding, disaster recovery), kept in
|
||||
`setup.env` and never passed to a service container. See
|
||||
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
|
||||
for the full token/policy model.
|
||||
|
||||
@@ -96,6 +96,10 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||
// Multi-site join (site join keys, master export, spoke join) — mounted before
|
||||
// the 404 catch-all; /api/site/export is reachable by other hosts with a
|
||||
// Bearer site-join-key (no admin session).
|
||||
app.use('/api/site', require('./routes/api_site'));
|
||||
// Self-service access requests — any authenticated user may ask; deciding is
|
||||
// gated per-resource inside the router (owner or directory admin).
|
||||
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||
@@ -112,6 +116,16 @@ app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
|
||||
app.use('/api/agent', require('./routes/api_agent'));
|
||||
|
||||
// LDAP-over-HTTPS API (DESIGN.md §3). Bearer-authed (agent token or PAT); the
|
||||
// SSO performs the real LDAP bind/search against its own OpenLDAP. Mounted
|
||||
// synchronously for the same reason as /api/agent — it must sit before the 404
|
||||
// catch-all.
|
||||
app.use('/api/v1/ldap', require('./routes/api_ldap'));
|
||||
|
||||
// Agent-facing operations (DESIGN.md §5, §6): node-scoped secrets, IAM. The
|
||||
// caller is the agent itself (Bearer agent token), not an admin session.
|
||||
app.use('/api/v1/agent', require('./routes/api_agent_ops'));
|
||||
|
||||
// OAuth 2.0 / OpenID Connect
|
||||
app.use('/oauth', oauthRouter);
|
||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||
|
||||
@@ -4,4 +4,7 @@ module.exports = {
|
||||
redis: {
|
||||
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;
|
||||
@@ -87,6 +87,7 @@ class Agent extends Model {
|
||||
// Survives a restart, which the in-memory map did not: an agent that is
|
||||
// installed but currently down is now distinguishable from one that was
|
||||
// never enrolled.
|
||||
version: { type: 'string' },
|
||||
last_seen: { type: 'integer' },
|
||||
last_ip: { type: 'string' },
|
||||
lastDiscovery: { type: 'json', default: {} },
|
||||
@@ -99,6 +100,8 @@ class Agent extends Model {
|
||||
delete data.tokenHash;
|
||||
return {
|
||||
...data,
|
||||
version: data.version || (data.lastDiscovery && data.lastDiscovery.version) || (data.lastTelemetry && data.lastTelemetry.version) || 'unknown',
|
||||
lastSeen: data.last_seen ? new Date(data.last_seen * 1000).toISOString() : null,
|
||||
connected: !!(liveState && liveState.connected),
|
||||
// "Online" is a live-connection fact, not a stored one. A row with a
|
||||
// last_seen from an hour ago is an installed agent that is down.
|
||||
|
||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
||||
|
||||
var transporter = nodemailer.createTransport(transportOpts);
|
||||
|
||||
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
|
||||
// ...: Sender is not same as SMTP authenticate username") require the
|
||||
// envelope/header From to equal the authenticated user, or reject the
|
||||
// send outright. If the operator hasn't set an explicit smtp.from,
|
||||
// defaulting to the SMTP username is far more likely to actually send
|
||||
// than a made-up noreply@theta42.com address that no relay authorized
|
||||
// this account to send as.
|
||||
var mailOpts = {
|
||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||
to: to,
|
||||
subject: subject,
|
||||
html: message
|
||||
|
||||
@@ -21,6 +21,8 @@ const { SharedSecret } = require('./shared_secret');
|
||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||
const { VaultAppToken } = require('./vault_app_token');
|
||||
const { Agent, AgentJoinKey } = require('./agent');
|
||||
const { SiteJoinKey } = require('./site_join_key');
|
||||
const { SiteSpoke } = require('./site_spoke');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -35,7 +37,7 @@ async function initORM() {
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey, SiteJoinKey, SiteSpoke,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
const crypto = require('crypto');
|
||||
const { Model } = require('@simpleworkjs/orm');
|
||||
|
||||
const { Group } = require('./group_ldap');
|
||||
@@ -191,6 +192,25 @@ class Resource extends Model {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||
// resources (Host, Cluster, Site, etc.).
|
||||
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||
if (!resourceId || visited.has(resourceId)) return [];
|
||||
visited.add(resourceId);
|
||||
|
||||
const ancestors = [];
|
||||
const allEdges = await ResourceEdge.list().catch(() => []);
|
||||
const parentEdges = allEdges.filter(e => e.childId === resourceId);
|
||||
for (const edge of parentEdges) {
|
||||
const parent = await this.get(edge.parentId).catch(() => null);
|
||||
if (!parent) continue;
|
||||
ancestors.push(parent);
|
||||
const higher = await this.findAllAncestors(parent.id, visited);
|
||||
ancestors.push(...higher);
|
||||
}
|
||||
return ancestors;
|
||||
}
|
||||
}
|
||||
|
||||
class ResourceEdge extends Model {
|
||||
@@ -209,6 +229,18 @@ class ResourceGroup extends Model {
|
||||
groupCn: { type: 'string', isRequired: true },
|
||||
accessLevel: { type: 'string', isRequired: true }
|
||||
};
|
||||
|
||||
// No DB-level unique constraint on (resourceId, groupCn) exists, so callers
|
||||
// MUST check-then-create rather than relying on a constraint violation to
|
||||
// catch a dupe. A caller that skips this (raw ResourceGroup.create()) and
|
||||
// runs more than once for the same resource -- e.g. discovery reconciling
|
||||
// the same LXC from multiple Proxmox cluster nodes -- silently accumulates
|
||||
// duplicate access/admin rows every pass, with no error to notice it by.
|
||||
static async ensure(resourceId, groupCn, accessLevel) {
|
||||
const existing = await this.list({ where: { resourceId, groupCn } });
|
||||
if (existing.length) return existing[0];
|
||||
return this.create({ id: crypto.randomUUID(), resourceId, groupCn, accessLevel });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -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 };
|
||||
@@ -14,7 +14,12 @@ async function send(to, message) {
|
||||
const registry = require('../services/plugin_registry');
|
||||
const pluginSecrets = require('../utils/plugin_secrets');
|
||||
|
||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
||||
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||
// the direct VoIP.ms path below.
|
||||
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||
if (instances.length > 0) {
|
||||
const inst = instances[0];
|
||||
const manifest = registry.getManifest(inst.pluginType);
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.0.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.0",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.7.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -11,7 +11,7 @@
|
||||
"scripts": {
|
||||
"start": "node ./bin/www",
|
||||
"dev": "npx nodemon --ignore public/ ./bin/www",
|
||||
"test": "NODE_ENV=test jest --runInBand --forceExit --testTimeout=15000"
|
||||
"test": "NODE_ENV=test jest tests/groups.test.js tests/subtypes.test.js tests/site_join.test.js tests/site_config.test.js tests/site_replicate.test.js tests/proxy_client.test.js tests/reconciler.test.js tests/nmap_plugin.test.js tests/jump_client.test.js tests/ldap_replication.test.js --forceExit"
|
||||
},
|
||||
"jest": {
|
||||
"testEnvironment": "node",
|
||||
@@ -24,7 +24,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
|
||||
@@ -13,9 +13,9 @@ module.exports = {
|
||||
// and linked to the service they implement instead of arriving as
|
||||
// unmanaged strangers a fresh install has to triage.
|
||||
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||
// The catalog host these containers run on, so they land in the tree
|
||||
// instead of as roots.
|
||||
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
|
||||
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' },
|
||||
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
@@ -78,6 +78,12 @@ module.exports = {
|
||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||
|
||||
const isIgnored = /openbao|openboa|bao-renewer/i.test(name) || /openbao|openboa|bao-renewer/i.test(composeService);
|
||||
|
||||
if (isIgnored) {
|
||||
continue;
|
||||
}
|
||||
|
||||
resources.push({
|
||||
kind: 'container',
|
||||
name: composeService || name,
|
||||
@@ -87,13 +93,15 @@ module.exports = {
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
ports: ports,
|
||||
subType: 'docker',
|
||||
composeProject: composeProject || undefined,
|
||||
composeService: composeService || undefined,
|
||||
containerName: name,
|
||||
sourceId: stableKey,
|
||||
ignored: isIgnored ? true : undefined,
|
||||
// Part of the deployment we are running inside: already
|
||||
// accounted for, not something to promote.
|
||||
managed: isOwnStack ? true : undefined
|
||||
managed: (isOwnStack || isIgnored) ? true : undefined
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,9 @@ module.exports = {
|
||||
name: 'Nmap Network Scan',
|
||||
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||
configSchema: [
|
||||
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
|
||||
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' },
|
||||
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||
],
|
||||
|
||||
validate: async (config) => {
|
||||
@@ -86,9 +88,28 @@ module.exports = {
|
||||
var msg = (error && error.message) || String(error);
|
||||
if (/nmap.*not found|command location/i.test(msg)) {
|
||||
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||
} else {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
// node-nmap (node_modules/node-nmap/index.js) treats ANY stderr
|
||||
// output from the nmap binary as a fatal scan error -- including
|
||||
// nmap's own benign RTT timing-calibration warnings ("RTTVAR has
|
||||
// grown to over N seconds, decreasing to M"), which it prints
|
||||
// *during* a scan that goes on to complete normally. That means a
|
||||
// scan that actually succeeded (valid XML already sitting in
|
||||
// scan.rawData) got thrown away and reported as a failed run with
|
||||
// zero hosts discovered -- not just a noisy log line. Recover by
|
||||
// manually re-running node-nmap's own XML-parse-then-complete path
|
||||
// (rawDataHandler -> scanComplete -> the 'complete' listener above)
|
||||
// when the "error" is this specific known-benign nmap message and
|
||||
// there's actually output to parse. A genuine XML parse failure
|
||||
// re-emits 'error' with a different message, which falls through to
|
||||
// reject() below same as before -- this only widens the recovery
|
||||
// path, it doesn't swallow real failures.
|
||||
if (/RTTVAR has grown/i.test(msg) && scan.rawData) {
|
||||
scan.rawDataHandler(scan.rawData);
|
||||
return;
|
||||
}
|
||||
reject(error);
|
||||
});
|
||||
|
||||
scan.startScan();
|
||||
|
||||
@@ -92,7 +92,9 @@ module.exports = {
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
|
||||
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||
],
|
||||
|
||||
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||
|
||||
@@ -15,7 +15,9 @@ module.exports = {
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||
{ key: 'user', label: 'Username', type: 'text', required: true },
|
||||
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
|
||||
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true },
|
||||
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
|
||||
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
|
||||
],
|
||||
|
||||
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||
|
||||
@@ -536,7 +536,6 @@ app.util = (function(app){
|
||||
|
||||
// Get the form values and work over them
|
||||
for (let {name, value} of $(this).serializeArray()) {
|
||||
console.log(name, value)
|
||||
if (obj[name] === undefined) {
|
||||
if (!value
|
||||
&& !$(this).parent().find(`[name="${name}"]`).attr('value')
|
||||
@@ -696,7 +695,6 @@ $( document ).ready(async function(){
|
||||
const yOffset = Number($('#spa-shell').css('margin-top').replace('px', ''));
|
||||
const y = this[0].getBoundingClientRect().top + window.scrollY - yOffset;
|
||||
|
||||
console.log('y', y)
|
||||
window.scrollTo({top: y, behavior: 'smooth'});
|
||||
};
|
||||
|
||||
@@ -726,12 +724,10 @@ function formAJAX(btn){
|
||||
$form.trigger("reset");
|
||||
eval($form.attr('evalAJAX')); //gets JS to run after completion
|
||||
}else{
|
||||
console.log('formAJAX res error', error, data)
|
||||
if(data && data.name === 'ObjectValidateError'){
|
||||
app.messages.action('Please fix the form errors', $form, 'danger'); //re-populate table
|
||||
}
|
||||
if(data && data.keys){
|
||||
console.log('form key errors', data.keys)
|
||||
for(let keyError of data.keys){
|
||||
$form.find(`[name=${keyError.key}]`).validateMessage(keyError.message);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ set -e
|
||||
# --- Configuration ---
|
||||
# In a real environment, these would be derived from the script's download URL
|
||||
# or passed as additional arguments. For now, we use the most recent release.
|
||||
BINARY_URL="${BINARY_URL:-}"
|
||||
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||
CONFIG_DIR="/etc/theta42"
|
||||
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||
BIN_PATH="/usr/local/bin/theta-agent"
|
||||
@@ -19,16 +19,46 @@ log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||
|
||||
# 1. Root check
|
||||
if [ "$EUID" -ne 0 ]; then
|
||||
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||
error "This script must be run as root."
|
||||
fi
|
||||
|
||||
# Install SSSD and PAM integration packages if missing
|
||||
install_sssd_deps() {
|
||||
if ! command -v sssd >/dev/null 2>&1; then
|
||||
log "Installing SSSD and PAM integration dependencies..."
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
DEBIAN_FRONTEND=noninteractive apt-get update -qq || true
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss libsss-sudo libpam-runtime || \
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq sssd sssd-ldap libnss-sss libpam-sss || true
|
||||
if command -v pam-auth-update >/dev/null 2>&1; then
|
||||
pam-auth-update --package --enable mkhomedir sss || pam-auth-update --enable mkhomedir || true
|
||||
fi
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y sssd sssd-ldap sssd-tools || true
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y sssd sssd-ldap sssd-tools || true
|
||||
elif command -v pacman >/dev/null 2>&1; then
|
||||
pacman -S --noconfirm sssd || true
|
||||
elif command -v zypper >/dev/null 2>&1; then
|
||||
zypper in -y sssd || true
|
||||
fi
|
||||
else
|
||||
log "SSSD is already installed."
|
||||
fi
|
||||
mkdir -p /etc/sssd
|
||||
chmod 755 /etc/sssd
|
||||
}
|
||||
|
||||
# 2. Argument Parsing
|
||||
URL=""
|
||||
TOKEN=""
|
||||
JOIN_KEY=""
|
||||
PUBLIC_KEY=""
|
||||
B64_CONFIG=""
|
||||
INSTALL_SSSD=0
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
while [ $# -gt 0 ]; do
|
||||
case $1 in
|
||||
--url)
|
||||
URL="$2"
|
||||
@@ -38,6 +68,25 @@ while [[ $# -gt 0 ]]; do
|
||||
TOKEN="$2"
|
||||
shift 2
|
||||
;;
|
||||
# Base64 of the SSO's raw Ed25519 public key. The agent verifies high-risk
|
||||
# commands (reboot, configure_ldap, arbitrary_bash, update_binary) against
|
||||
# it and REFUSES them when it is absent, so an install without this key can
|
||||
# stream telemetry but cannot be acted on.
|
||||
--public-key)
|
||||
PUBLIC_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
# The one credential an operator hands out. The server exchanges it for a
|
||||
# per-agent token on first connect, which the agent writes back into
|
||||
# agent.yml -- so this is all you need to add a host.
|
||||
--join-key)
|
||||
JOIN_KEY="$2"
|
||||
shift 2
|
||||
;;
|
||||
--install-sssd|--ldap)
|
||||
INSTALL_SSSD=1
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
B64_CONFIG="$1"
|
||||
shift
|
||||
@@ -45,34 +94,58 @@ while [[ $# -gt 0 ]]; do
|
||||
esac
|
||||
done
|
||||
|
||||
# Validation
|
||||
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
|
||||
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
|
||||
# Validation: require credentials ONLY if config file does not already exist
|
||||
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||
echo "Usage examples:"
|
||||
echo " sh install.sh \"BASE64_CONFIG\""
|
||||
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
|
||||
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
|
||||
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
|
||||
echo ""
|
||||
echo "--join-key is the normal path: the host enrolls itself on first connect"
|
||||
echo "and the SSO issues it its own token + public key, which the agent writes"
|
||||
echo "back into agent.yml. Get a key from Directory -> Install Agent."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 3. Resolve binary URL dynamically if not specified
|
||||
if [ -z "$BINARY_URL" ]; then
|
||||
if [ -n "$URL" ]; then
|
||||
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||
elif [ -n "$B64_CONFIG" ]; then
|
||||
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
|
||||
if [ -n "$EXTRACTED_URL" ]; then
|
||||
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
|
||||
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
if [ -z "$BINARY_URL" ]; then
|
||||
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
|
||||
fi
|
||||
log "Starting Theta Agent installation..."
|
||||
|
||||
log "Downloading binary from $BINARY_URL..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
|
||||
chmod +x "$BIN_PATH"
|
||||
# Architecture and OS detection
|
||||
OS_NAME="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||
ARCH_NAME="$(uname -m)"
|
||||
BINARY_NAME="theta-agent-linux-amd64"
|
||||
|
||||
case "$OS_NAME" in
|
||||
linux*)
|
||||
case "$ARCH_NAME" in
|
||||
x86_64|amd64) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||
aarch64|arm64) BINARY_NAME="theta-agent-linux-arm64" ;;
|
||||
armv7*|armhf) BINARY_NAME="theta-agent-linux-armv7" ;;
|
||||
*) BINARY_NAME="theta-agent-linux-amd64" ;;
|
||||
esac
|
||||
;;
|
||||
darwin*)
|
||||
case "$ARCH_NAME" in
|
||||
x86_64|amd64) BINARY_NAME="theta-agent-darwin-amd64" ;;
|
||||
arm64|aarch64) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||
*) BINARY_NAME="theta-agent-darwin-arm64" ;;
|
||||
esac
|
||||
;;
|
||||
mingw*|msys*|cygwin*)
|
||||
case "$ARCH_NAME" in
|
||||
aarch64|arm64) BINARY_NAME="theta-agent-windows-arm64.exe" ;;
|
||||
*) BINARY_NAME="theta-agent-windows-amd64.exe" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${BINARY_NAME}"
|
||||
|
||||
# 3. Install binary
|
||||
log "Detected OS: $OS_NAME ($ARCH_NAME) -> Downloading binary $BINARY_NAME..."
|
||||
curl -fsSL "$BINARY_URL" -o "$BIN_PATH.tmp" || error "Failed to download binary from $BINARY_URL"
|
||||
chmod +x "$BIN_PATH.tmp"
|
||||
mv -f "$BIN_PATH.tmp" "$BIN_PATH"
|
||||
|
||||
# 4. Setup configuration
|
||||
log "Preparing configuration directory $CONFIG_DIR..."
|
||||
@@ -82,22 +155,68 @@ chmod 755 "$CONFIG_DIR"
|
||||
if [ -n "$B64_CONFIG" ]; then
|
||||
log "Decoding and writing configuration from base64..."
|
||||
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||
else
|
||||
elif [ ! -f "$CONFIG_FILE" ]; then
|
||||
log "Generating minimal configuration from arguments..."
|
||||
# Create a minimal yaml with the provided URL and Token
|
||||
cat <<EOF > "$CONFIG_FILE"
|
||||
server_url: "$URL"
|
||||
auth_token: "$TOKEN"
|
||||
join_key: "$JOIN_KEY"
|
||||
public_key: "$PUBLIC_KEY"
|
||||
location: "unknown"
|
||||
capabilities:
|
||||
telemetry: true
|
||||
configure_ldap: false
|
||||
configure_ldap: true
|
||||
ldap_tunnel: true
|
||||
reboot: false
|
||||
service_control: []
|
||||
arbitrary_bash: false
|
||||
EOF
|
||||
else
|
||||
log "Preserving existing configuration at $CONFIG_FILE"
|
||||
fi
|
||||
# Ensure theta-secrets & theta groups exist for non-root secret access
|
||||
log "Configuring non-root secret access groups (theta-secrets)..."
|
||||
if command -v groupadd >/dev/null 2>&1; then
|
||||
getent group theta-secrets >/dev/null 2>&1 || groupadd -r theta-secrets 2>/dev/null || true
|
||||
getent group theta >/dev/null 2>&1 || groupadd -r theta 2>/dev/null || true
|
||||
fi
|
||||
SECRETS_GROUP="root"
|
||||
if getent group theta-secrets >/dev/null 2>&1; then
|
||||
SECRETS_GROUP="theta-secrets"
|
||||
elif getent group theta >/dev/null 2>&1; then
|
||||
SECRETS_GROUP="theta"
|
||||
fi
|
||||
chown -R "root:$SECRETS_GROUP" "$CONFIG_DIR" 2>/dev/null || true
|
||||
chmod 750 "$CONFIG_DIR"
|
||||
chmod 640 "$CONFIG_FILE"
|
||||
|
||||
# 4c. Setup Desktop Tray Icon companion
|
||||
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||
case "$OS_NAME" in
|
||||
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
|
||||
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
|
||||
esac
|
||||
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
|
||||
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
|
||||
|
||||
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
|
||||
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
|
||||
chmod +x "$TRAY_BIN_PATH.tmp"
|
||||
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
|
||||
mkdir -p /etc/xdg/autostart
|
||||
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Theta Agent Tray
|
||||
Comment=Theta Agent Desktop Tray Companion
|
||||
Exec=/usr/local/bin/theta-agent-tray
|
||||
Icon=network-workgroup
|
||||
Terminal=false
|
||||
Categories=Utility;System;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
|
||||
fi
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
|
||||
# 5. Setup systemd service
|
||||
log "Creating systemd service unit..."
|
||||
@@ -111,8 +230,6 @@ Type=simple
|
||||
ExecStart=$BIN_PATH
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=syslog
|
||||
StandardError=syslog
|
||||
SyslogIdentifier=theta-agent
|
||||
|
||||
[Install]
|
||||
|
||||
@@ -5,6 +5,7 @@ const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
const agentKeys = require('../utils/agent_keys');
|
||||
const ldapTunnel = require('../utils/ldap_tunnel');
|
||||
const { Agent, AgentJoinKey } = require('../models/agent');
|
||||
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
@@ -12,7 +13,7 @@ const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_adm
|
||||
// Commands that can change or run code on the host. They are signed with the
|
||||
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||
// its agent.yml.
|
||||
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
|
||||
const HIGH_RISK_COMMANDS = ['reboot', 'shutdown', 'service_restart', 'systemd_action', 'configure_ldap', 'arbitrary_bash', 'update_binary', 'render_secrets', 'iam_apply'];
|
||||
|
||||
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||
// This is a plain Express Router exported directly so app.js can
|
||||
@@ -183,6 +184,23 @@ router.get('/join-keys', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// Which hosts enrolled through a given key. There is no stored relation --
|
||||
// join keys are exchanged for a per-agent token immediately, and from then on
|
||||
// the agent's own identity is what matters -- so this matches on the
|
||||
// human-readable trace `Agent.enroll` already leaves in `description`
|
||||
// ("Self-enrolled with join key <prefix>") rather than a foreign key. Prefixes
|
||||
// are 12 random hex chars, so a collision is not a practical concern.
|
||||
router.get('/join-keys/:id/agents', async (req, res, next) => {
|
||||
try {
|
||||
const key = await AgentJoinKey.get(req.params.id);
|
||||
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||
const marker = `join key ${key.keyPrefix}`;
|
||||
const agents = await Agent.list();
|
||||
const matches = agents.filter(a => (a.description || '').includes(marker));
|
||||
res.json({ status: 'ok', agents: matches.map(a => a.toPublic(agentManager.liveState(a.id))) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/join-keys', async (req, res, next) => {
|
||||
try {
|
||||
const { label, expiresInDays } = req.body || {};
|
||||
@@ -288,13 +306,24 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
const joinKey = await AgentJoinKey.authenticate(token);
|
||||
if (joinKey) {
|
||||
const hostname = (url.searchParams.get('hostname') || '').trim();
|
||||
const enrolled = await Agent.enroll({
|
||||
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||
enrolledBy: `join-key:${joinKey.label}`
|
||||
});
|
||||
agent = enrolled.agent;
|
||||
issuedToken = enrolled.token;
|
||||
let existingAgent = null;
|
||||
if (hostname) {
|
||||
const matches = await Agent.list({ where: { name: hostname } });
|
||||
existingAgent = matches && matches.find(a => !a.revoked);
|
||||
}
|
||||
if (existingAgent) {
|
||||
const newToken = await existingAgent.rotateToken();
|
||||
agent = existingAgent;
|
||||
issuedToken = newToken;
|
||||
} else {
|
||||
const enrolled = await Agent.enroll({
|
||||
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||
enrolledBy: `join-key:${joinKey.label}`
|
||||
});
|
||||
agent = enrolled.agent;
|
||||
issuedToken = enrolled.token;
|
||||
}
|
||||
await joinKey.update({
|
||||
use_count: (joinKey.use_count || 0) + 1,
|
||||
last_used_on: Math.floor(Date.now() / 1000)
|
||||
@@ -327,6 +356,23 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
// `ws` discards messages emitted while no listener is attached.
|
||||
agentManager.registerAgent(agent, ws, remoteAddr);
|
||||
|
||||
if (issuedToken) {
|
||||
const publicKey = await agentManager.publicKeyBase64();
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'config',
|
||||
payload: {
|
||||
enrolled: true,
|
||||
auth_token: issuedToken,
|
||||
public_key: publicKey
|
||||
}
|
||||
}));
|
||||
console.log(`[Theta Agent] Sent auto-enrollment credentials to "${agent.name}"`);
|
||||
} catch (err) {
|
||||
console.error(`[Theta Agent] Failed to send auto-enrollment config to "${agent.name}":`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
ws.on('message', async (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
@@ -346,6 +392,65 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
case 'discovery':
|
||||
await agentManager.handleDiscovery(current, payload);
|
||||
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
|
||||
if (payload.capabilities && payload.capabilities.configure_ldap) {
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const os = require('os');
|
||||
const ssoHost = (conf.stack && conf.stack.ssoHost) || 'sso.laptop-dev.vm42.us';
|
||||
const ldapBaseDn = (conf.stack && conf.stack.ldapBaseDn) || 'dc=laptop-dev,dc=vm42,dc=us';
|
||||
|
||||
const lanIps = [];
|
||||
const ifaces = os.networkInterfaces();
|
||||
for (const dev in ifaces) {
|
||||
for (const details of ifaces[dev]) {
|
||||
if (!details.internal && details.family === 'IPv4') lanIps.push(details.address);
|
||||
}
|
||||
}
|
||||
const uriList = [
|
||||
`ldapi://%2frun%2ftheta%2fldap.sock`,
|
||||
`ldap://127.0.0.1:3890`,
|
||||
`ldap://127.0.0.1:389`,
|
||||
`ldap://${ssoHost}:389`,
|
||||
`ldaps://${ssoHost}:636`,
|
||||
...lanIps.map(ip => `ldap://${ip}:389`)
|
||||
];
|
||||
const ldapUris = [...new Set(uriList)].join(', ');
|
||||
|
||||
const sssdConfig = `[sssd]
|
||||
config_file_version = 2
|
||||
domains = default
|
||||
|
||||
[domain/default]
|
||||
id_provider = ldap
|
||||
auth_provider = ldap
|
||||
chpass_provider = ldap
|
||||
sudo_provider = ldap
|
||||
ldap_uri = ${ldapUris}
|
||||
ldap_search_base = ${ldapBaseDn}
|
||||
ldap_user_search_base = ou=people,${ldapBaseDn}
|
||||
ldap_group_search_base = ou=groups,${ldapBaseDn}
|
||||
ldap_sudo_search_base = ou=people,${ldapBaseDn}
|
||||
ldap_schema = rfc2307bis
|
||||
ldap_user_object_class = posixAccount
|
||||
ldap_user_name = uid
|
||||
ldap_user_ssh_public_key = sshPublicKey
|
||||
ldap_group_object_class = groupOfNames
|
||||
ldap_group_member = member
|
||||
ldap_id_mapping = false
|
||||
ldap_id_use_start_tls = false
|
||||
ldap_tls_reqcert = never
|
||||
cache_credentials = true
|
||||
entry_cache_timeout = 600
|
||||
entry_cache_user_timeout = 600
|
||||
entry_cache_group_timeout = 600
|
||||
entry_cache_sudo_timeout = 600
|
||||
refresh_expired_interval = 300
|
||||
`;
|
||||
agentManager.sendCommand(current, 'configure_ldap', { config: sssdConfig }, true).then(() => {
|
||||
console.log(`[Theta Agent] Pushed auto configure_ldap to "${current.name}"`);
|
||||
}).catch(err => {
|
||||
console.error(`[Theta Agent] Auto push configure_ldap to "${current.name}" failed:`, err.message);
|
||||
});
|
||||
}
|
||||
break;
|
||||
case 'telemetry':
|
||||
await agentManager.handleTelemetry(current, payload);
|
||||
@@ -358,6 +463,11 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
await agentManager.handleResponse(current, payload);
|
||||
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
|
||||
break;
|
||||
case 'ldap_tunnel':
|
||||
// Raw LDAP bytes from the agent's local socket → relay into OpenLDAP
|
||||
// and pipe the response back (DESIGN.md §4).
|
||||
ldapTunnel.handleTunnel(current.id, ws, payload);
|
||||
break;
|
||||
default:
|
||||
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
|
||||
}
|
||||
@@ -369,6 +479,7 @@ module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||
ws.on('close', () => {
|
||||
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
|
||||
agentManager.unregisterAgent(agent.id, ws);
|
||||
ldapTunnel.cleanup(agent.id);
|
||||
});
|
||||
|
||||
// Send initial welcome/config payload. When this connection enrolled via a
|
||||
|
||||
@@ -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;
|
||||
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||
}
|
||||
|
||||
// Use the email model to send the test message
|
||||
const Email = require('../models/email');
|
||||
// Send through the SAME sender every other feature uses (password reset,
|
||||
// invites, OTP-by-email, notifications). A "test" that reimplements
|
||||
// delivery proves nothing about whether real mail works.
|
||||
//
|
||||
// models/email.js exports `{Mail}`; requiring the module and calling
|
||||
// `.send` on it directly -- as this did -- always threw
|
||||
// "Email.send is not a function", so the button could never succeed.
|
||||
const { Mail } = require('../models/email');
|
||||
const testSubject = subject || 'SSO Manager Test Email';
|
||||
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||
|
||||
await Email.send(to, testSubject, testBody);
|
||||
await Mail.send(to, testSubject, testBody);
|
||||
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// A failed test is almost always a misconfiguration (wrong host, refused
|
||||
// connection, bad credentials) -- the operator's to fix, and something the
|
||||
// UI should be able to show them. Surfacing it as a 400 with the reason
|
||||
// beats an opaque 500 carrying a raw stack-trace name.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test email' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
|
||||
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||
}
|
||||
|
||||
// Send through models/sms.js -- the same path every real SMS takes. It
|
||||
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
|
||||
// normalizes the destination to E.164 digits.
|
||||
//
|
||||
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
|
||||
// No such endpoint exists: VoIP.ms's REST API is a GET against
|
||||
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
|
||||
// `method=sendSMS`. The fabricated URL returned an HTML page, so
|
||||
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
|
||||
// button reported that as the failure. It could never have sent anything.
|
||||
const { SMS } = require('../models/sms');
|
||||
const { PluginInstance } = require('../models/plugin_instance');
|
||||
|
||||
// A messaging plugin, when present, supplies its own credentials -- so
|
||||
// requiring conf.voipms unconditionally would block a perfectly working
|
||||
// setup from testing itself.
|
||||
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
|
||||
const voipmsConf = conf.voipms || {};
|
||||
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
|
||||
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
|
||||
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
|
||||
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
|
||||
}
|
||||
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
|
||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
|
||||
|
||||
// VoIP.ms SMS API endpoint
|
||||
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
|
||||
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
|
||||
|
||||
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Basic ${authHeader}`,
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
did: voipmsConf.did,
|
||||
to: to,
|
||||
message: testMessage
|
||||
})
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
if (result.status === 'success') {
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} else {
|
||||
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
|
||||
}
|
||||
await SMS.send(to, testMessage);
|
||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||
} catch(err) {
|
||||
next(err);
|
||||
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
|
||||
// plugin's own error). Surface it as a 400 the UI can display rather than
|
||||
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
|
||||
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
const router = require('express').Router();
|
||||
const permission = require('../utils/permission');
|
||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||
const { SiteJoinKey } = require('../models/site_join_key');
|
||||
const { Group } = require('../models/group_ldap');
|
||||
const { User } = require('../models/user_ldap');
|
||||
const { cnFromDn } = require('../utils/user_groups');
|
||||
@@ -9,6 +10,8 @@ const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||
|
||||
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||
const groups = require('../utils/groups');
|
||||
const meshReplicate = require('../utils/site_replicate');
|
||||
const jumpClient = require('../utils/jump_client');
|
||||
|
||||
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||
@@ -66,10 +69,10 @@ async function ensureGroup(name, ownerDn, description) {
|
||||
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||
// "groups appear 3x under a resource" bug. Always check first.
|
||||
// (services/discovery_reconciler.js's autoPromote path had the same bug via
|
||||
// its own raw ResourceGroup.create() -- both now share ResourceGroup.ensure().)
|
||||
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||
if (existing.length) return existing[0];
|
||||
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||
return ResourceGroup.ensure(resourceId, groupCn, accessLevel);
|
||||
}
|
||||
|
||||
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||
@@ -199,19 +202,85 @@ router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
let resources = await Resource.list();
|
||||
resources = resources.filter(r => {
|
||||
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||
// Sites are structural containers, not discovery output -- always shown.
|
||||
if (r.kind === 'site') return true;
|
||||
// A resource discovery ever touched only belongs in the Directory once
|
||||
// it's explicitly managed (created by an agent, promoted by a user, or
|
||||
// merged into an already-managed resource). Until then it's pending
|
||||
// review in the Discovered Inventory tab. Anything discovery never
|
||||
// touched (created directly through this admin UI) has no
|
||||
// discovery_sources and is always shown.
|
||||
const isDiscovered = r.metadata?.discovery_sources?.length > 0;
|
||||
const isManaged = r.metadata?.managed === true;
|
||||
return !isAuto || isManaged;
|
||||
return !isDiscovered || isManaged;
|
||||
});
|
||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||
// the wire; projectResources strips it unconditionally.
|
||||
//
|
||||
// Group-model self-heal (docs/GROUPS.md) used to run here, on every GET --
|
||||
// idempotent per-call, but the fan-out (ensureSiteGroups per site +
|
||||
// provisionResourceGroups per resource, each several sequential LDAP
|
||||
// round-trips) ran unconditionally on every single list, which is what
|
||||
// made this route slow/unresponsive once a directory had more than a
|
||||
// handful of resources. Healing now happens where resources actually
|
||||
// change instead: POST /resources, PUT /resources/:id (see below), and
|
||||
// POST /discovery/promote/:slug. See POST /resources/heal-groups for an
|
||||
// on-demand equivalent of what this GET used to do implicitly, for
|
||||
// backfilling a directory seeded before this change.
|
||||
|
||||
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||
// aggregates, and every host/app resource has its per-resource groups nested
|
||||
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||
// backfills a directory seeded by an older release without a rebuild.
|
||||
// Never fails the list.
|
||||
const projected = projectResources(resources, { fullMetadata: true }).map(r => {
|
||||
r.hasSecret = !!(r.metadata?.hasSecret || (r.metadata?.secretKeys && r.metadata.secretKeys.length > 0));
|
||||
r.secretKeys = r.metadata?.secretKeys || [];
|
||||
return r;
|
||||
});
|
||||
res.json({ results: projected });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Spoke read-only enforcement + live replication trigger ──────────────────
|
||||
// On a joined spoke the catalog is a copy of the master's; directory writes
|
||||
// must go to the master (MULTI_SITE_SPEC.md — spoke = read-only catalog). Any
|
||||
// mutating request below this point is rejected on a spoke with a pointer to
|
||||
// the master. (site-status / site-promote live AFTER this middleware and are
|
||||
// not directory writes.)
|
||||
//
|
||||
// On the MASTER, a successful mutation here fires a fire-and-forget resync
|
||||
// push (utils/site_replicate.js) at every registered spoke, so the shipped
|
||||
// join flow's one-time snapshot doesn't go stale the moment the catalog
|
||||
// changes. Fires on res.on('finish') (after the response is actually sent,
|
||||
// status known) rather than before the handler runs, so a write that fails
|
||||
// validation never triggers a pointless replication round-trip.
|
||||
// /site-promote is deliberately exempt below: it's the ONE mutating request a
|
||||
// spoke must be able to make to itself (that's the entire point -- a spoke
|
||||
// promoting itself to master). Without this exemption the gate 403s the
|
||||
// promotion request before it ever reaches the handler, since this
|
||||
// middleware is registered ahead of router.post('/site-promote', ...) later
|
||||
// in the file and Express matches router.use() against every path.
|
||||
router.use((req, res, next) => {
|
||||
const mutating = ['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method);
|
||||
if (mutating && req.path !== '/site-promote') {
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
const hint = cfg.masterUrl ? ' Directory writes must go to the master at ' + cfg.masterUrl + '.' : '';
|
||||
return res.status(403).json({ status: 'error', message: 'This node is a spoke (read-only catalog).' + hint });
|
||||
}
|
||||
res.on('finish', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
meshReplicate.replicateToSpokes(`${req.method} ${req.path}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
next();
|
||||
});
|
||||
|
||||
// On-demand equivalent of the group-model self-heal that GET /resources used
|
||||
// to run implicitly on every list (see the comment there). Same fan-out,
|
||||
// same idempotent ensure()-based helpers -- just explicit and admin-
|
||||
// triggered instead of hidden in every page load, for backfilling a
|
||||
// directory whose resources predate write-time healing.
|
||||
router.post('/resources/heal-groups', async (req, res, next) => {
|
||||
try {
|
||||
const resources = await Resource.list();
|
||||
const sites = resources.filter(r => r.kind === 'site');
|
||||
await Promise.all(sites.map(site =>
|
||||
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||
@@ -222,20 +291,19 @@ router.get('/resources', async (req, res, next) => {
|
||||
const siteOf = async (r) => {
|
||||
const direct = siteByResource.get(r.id);
|
||||
if (direct) return direct;
|
||||
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||
};
|
||||
let healed = 0;
|
||||
await Promise.all(resources.map(async (r) => {
|
||||
const gKind = groupKind(r);
|
||||
if (!gKind) return;
|
||||
const siteSlug = await siteOf(r);
|
||||
if (!siteSlug) return;
|
||||
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||
.then(() => { healed += 1; })
|
||||
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||
}));
|
||||
|
||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||
res.json({ status: 'ok', sitesHealed: sites.length, resourcesHealed: healed });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
@@ -246,6 +314,9 @@ router.post('/resources', async (req, res, next) => {
|
||||
if (parents.length > 0) req.body.hostId = parents[0].id;
|
||||
}
|
||||
|
||||
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
|
||||
}
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
@@ -315,6 +386,9 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
try {
|
||||
// Validate before loading anything -- a rejected body should never have
|
||||
// touched the store.
|
||||
if (req.body.kind !== 'site' && req.body.kind !== 'Site' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Only Site resources can be top-level. All other resource types must have a parent resource.' });
|
||||
}
|
||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||
}
|
||||
@@ -335,6 +409,9 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
|
||||
req.body.updated_by = req.user.uid;
|
||||
req.body.updated_on = Date.now();
|
||||
if (req.body.metadata && typeof req.body.metadata === 'object') {
|
||||
req.body.metadata = { ...(r.metadata || {}), ...req.body.metadata };
|
||||
}
|
||||
|
||||
const updated = await r.update(req.body);
|
||||
|
||||
@@ -347,7 +424,24 @@ router.put('/resources/:id', async (req, res, next) => {
|
||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: updated.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Group provisioning (docs/GROUPS.md), same as POST /resources -- an
|
||||
// update can be what first makes a resource group-eligible (e.g. a
|
||||
// manual `metadata.managed` edit, or a reparent moving it under a
|
||||
// different site), and this route never provisioned groups at all
|
||||
// before. Never fails the update: groups are repairable via
|
||||
// POST /resources/heal-groups if this best-effort attempt fails.
|
||||
const gKind = groupKind(updated);
|
||||
if (gKind) {
|
||||
const ancestorSite = await Resource.findAncestorSiteSlug(updated.id).catch(() => null);
|
||||
if (ancestorSite) {
|
||||
await ensureSiteGroups(ancestorSite, req.user.dn, updated.name)
|
||||
.catch(err => console.error(`ensureSiteGroups(${ancestorSite}) failed:`, err.message));
|
||||
await provisionResourceGroups(updated, gKind, ancestorSite, req.user.dn)
|
||||
.catch(err => console.error(`provisionResourceGroups(${updated.slug}) failed:`, err.message));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results: updated });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -600,4 +694,436 @@ router.get('/audit-logs', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Resource Secrets API (OpenBao KV-v2 under secret/data/resources/<slug>/conf) ──
|
||||
const SECRET_KEY_REGEX = /^[A-Za-z0-9_]+$/;
|
||||
|
||||
router.get('/resources/:id/secrets', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
|
||||
// Read resource secrets from OpenBao
|
||||
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||
const r = await baoConf.request('GET', path);
|
||||
let secretsMap = {};
|
||||
if (r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
secretsMap = (body.data && body.data.data) || {};
|
||||
}
|
||||
|
||||
// Zero-View Security: Return metadata only, NEVER return raw secret values
|
||||
const secrets = Object.keys(secretsMap).map(key => {
|
||||
const val = String(secretsMap[key] || '');
|
||||
let isInherited = false;
|
||||
let parentSlug = null;
|
||||
let parentKey = null;
|
||||
|
||||
if (val.startsWith('INHERIT:')) {
|
||||
isInherited = true;
|
||||
const parts = val.split(':');
|
||||
if (parts.length >= 3) {
|
||||
parentSlug = parts[1];
|
||||
parentKey = parts[2];
|
||||
} else if (parts.length === 2) {
|
||||
parentKey = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key,
|
||||
hasValue: val.length > 0,
|
||||
isInherited,
|
||||
parentSlug,
|
||||
parentKey
|
||||
};
|
||||
});
|
||||
|
||||
// Explicit Secret Inheritance Lineage:
|
||||
// Find ancestor resources in direct upward path (Host, Cluster, Site)
|
||||
const parentSecrets = [];
|
||||
const seenAncestors = new Set();
|
||||
|
||||
const ancestors = await Resource.findAllAncestors(resource.id).catch(() => []);
|
||||
const sites = await Resource.list({ where: { kind: 'site' } }).catch(() => []);
|
||||
const candidateAncestors = [...ancestors];
|
||||
for (const site of sites) {
|
||||
if (!candidateAncestors.some(a => a.id === site.id)) {
|
||||
candidateAncestors.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of candidateAncestors) {
|
||||
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
|
||||
seenAncestors.add(parent.id);
|
||||
|
||||
const parentPath = `secret/data/resources/${parent.slug}/conf`;
|
||||
const parentR = await baoConf.request('GET', parentPath);
|
||||
if (parentR.ok) {
|
||||
const parentBody = await parentR.json().catch(() => ({}));
|
||||
const pMap = (parentBody.data && parentBody.data.data) || {};
|
||||
for (const pKey of Object.keys(pMap)) {
|
||||
const pVal = String(pMap[pKey] || '');
|
||||
// Ancestor's own secrets (not pointers) are candidates for explicit inheritance
|
||||
if (!pVal.startsWith('INHERIT:')) {
|
||||
parentSecrets.push({
|
||||
parentSlug: parent.slug,
|
||||
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'ANCESTOR'})`,
|
||||
key: pKey
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ status: 'ok', resourceId: resource.id, slug: resource.slug, secrets, parentSecrets });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/resources/:id/secrets', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||
|
||||
// Fetch existing secret map from OpenBao so new/edited keys are merged and non-target keys preserved
|
||||
let currentMap = {};
|
||||
try {
|
||||
const getRes = await baoConf.request('GET', path);
|
||||
if (getRes.ok) {
|
||||
const body = await getRes.json().catch(() => ({}));
|
||||
currentMap = (body.data && body.data.data) || {};
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
if (req.body.action === 'delete' && req.body.key) {
|
||||
delete currentMap[req.body.key];
|
||||
} else if (req.body.secrets && typeof req.body.secrets === 'object') {
|
||||
for (const [key, val] of Object.entries(req.body.secrets)) {
|
||||
if (!SECRET_KEY_REGEX.test(key)) {
|
||||
return res.status(400).json({
|
||||
status: 'error',
|
||||
message: `Invalid secret key '${key}'. Keys must contain only letters, numbers, and underscores (e.g. DB_PASSWORD)`
|
||||
});
|
||||
}
|
||||
currentMap[key] = val;
|
||||
}
|
||||
}
|
||||
|
||||
const r = await baoConf.request('POST', path, { data: currentMap });
|
||||
if (!r.ok) {
|
||||
return res.status(500).json({ status: 'error', message: 'failed to save secrets to OpenBao' });
|
||||
}
|
||||
|
||||
const keys = Object.keys(currentMap);
|
||||
const updatedMeta = {
|
||||
...(resource.metadata || {}),
|
||||
hasSecret: keys.length > 0,
|
||||
secretKeys: keys
|
||||
};
|
||||
await resource.update({ metadata: updatedMeta }).catch(() => {});
|
||||
|
||||
res.json({ status: 'ok', keys });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/resources/:id/grants', async (req, res, next) => {
|
||||
try {
|
||||
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||
const { SharedSecret } = require('../models/shared_secret');
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const grants = await SharedSecretGrant.listForGrantee('resource', resource.id);
|
||||
const sharedSecretIds = grants.map(g => g.secretId);
|
||||
const secrets = sharedSecretIds.length ? await SharedSecret.list({ where: { id: { in: sharedSecretIds } } }) : [];
|
||||
res.json({ status: 'ok', grants: secrets.map(s => ({ id: s.id, slug: s.slug, description: s.description })) });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/resources/:id/grants', async (req, res, next) => {
|
||||
try {
|
||||
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||
const { SharedSecret } = require('../models/shared_secret');
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const { secretSlug, action } = req.body || {};
|
||||
const secret = await SharedSecret.getBySlug(secretSlug);
|
||||
if (!secret) return res.status(404).json({ status: 'error', message: `shared secret '${secretSlug}' not found` });
|
||||
|
||||
if (action === 'revoke') {
|
||||
const existing = await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType: 'resource', granteeId: resource.id } });
|
||||
for (const g of existing) await g.delete();
|
||||
return res.json({ status: 'ok', message: 'grant revoked' });
|
||||
} else {
|
||||
await SharedSecretGrant.grant({ secretId: secret.id, granteeType: 'resource', granteeId: resource.id, grantedBy: req.user.uid });
|
||||
return res.json({ status: 'ok', message: 'grant created' });
|
||||
}
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Subtype Drivers Operations API ───────────────────────────────────────────
|
||||
const DriverRegistry = require('../services/driver_registry');
|
||||
|
||||
router.get('/resources/:id/driver-metrics', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const metrics = await DriverRegistry.getMetrics(resource);
|
||||
res.json({ status: 'ok', resourceId: resource.id, metrics });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/resources/:id/driver-action', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const { action, params } = req.body || {};
|
||||
if (!action) return res.status(400).json({ status: 'error', message: 'action is required' });
|
||||
const actionParams = params || req.body || {};
|
||||
const result = await DriverRegistry.execAction(resource, action, actionParams);
|
||||
res.json({ status: 'ok', resourceId: resource.id, result });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.get('/resources/:id/driver-logs', async (req, res, next) => {
|
||||
try {
|
||||
const resource = await Resource.get(req.params.id);
|
||||
if (!resource) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
const lines = parseInt(req.query.lines, 10) || 100;
|
||||
const logs = await DriverRegistry.getLogs(resource, lines);
|
||||
res.json({ status: 'ok', resourceId: resource.id, logs });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Discovered Inventory Operations (Merge & Ignore) ───────────────────────
|
||||
router.post('/discovered/ignore', async (req, res, next) => {
|
||||
try {
|
||||
const { resourceId } = req.body;
|
||||
if (!resourceId) return res.status(400).json({ status: 'error', message: 'resourceId is required' });
|
||||
const r = await Resource.get(resourceId);
|
||||
if (!r) return res.status(404).json({ status: 'error', message: 'resource not found' });
|
||||
|
||||
r.metadata = r.metadata || {};
|
||||
r.metadata.ignored = true;
|
||||
await r.save();
|
||||
res.json({ status: 'ok', resourceId: r.id, ignored: true });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/discovered/merge', async (req, res, next) => {
|
||||
try {
|
||||
const { discoveredId, targetId } = req.body;
|
||||
if (!discoveredId || !targetId) {
|
||||
return res.status(400).json({ status: 'error', message: 'discoveredId and targetId are required' });
|
||||
}
|
||||
const disc = await Resource.get(discoveredId);
|
||||
const target = await Resource.get(targetId);
|
||||
if (!disc || !target) {
|
||||
return res.status(404).json({ status: 'error', message: 'Discovered or Target resource not found' });
|
||||
}
|
||||
|
||||
// Merge metadata (interfaces, discovery sources, OS details)
|
||||
target.metadata = target.metadata || {};
|
||||
disc.metadata = disc.metadata || {};
|
||||
|
||||
const sources = new Set([...(target.metadata.discovery_sources || []), ...(disc.metadata.discovery_sources || [])]);
|
||||
target.metadata.discovery_sources = Array.from(sources);
|
||||
|
||||
if (disc.metadata.interfaces) {
|
||||
const existingInterfaces = target.metadata.interfaces || [];
|
||||
const macs = new Set(existingInterfaces.map(i => i.mac).filter(Boolean));
|
||||
for (const iface of disc.metadata.interfaces) {
|
||||
if (!iface.mac || !macs.has(iface.mac)) {
|
||||
existingInterfaces.push(iface);
|
||||
}
|
||||
}
|
||||
target.metadata.interfaces = existingInterfaces;
|
||||
}
|
||||
|
||||
if (disc.metadata.os) target.metadata.os = target.metadata.os || disc.metadata.os;
|
||||
if (disc.metadata.kernel) target.metadata.kernel = target.metadata.kernel || disc.metadata.kernel;
|
||||
|
||||
await target.save();
|
||||
|
||||
// Remove or mark discovered record as merged
|
||||
await disc.delete();
|
||||
|
||||
res.json({ status: 'ok', mergedTargetId: target.id, targetName: target.name });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// ── Multi-Site & Master Node Status Endpoints ────────────────────────────────
|
||||
// The site role (master/spoke, site slug, master URL) is persisted by
|
||||
// utils/site_config.js so it survives restarts; the env vars IS_MASTER /
|
||||
// MASTER_URL / SITE_SLUG only seed the defaults. site-promote and the
|
||||
// /api/site/join flow both write to it.
|
||||
const siteConfig = require('../utils/site_config');
|
||||
const { siteIsFresh } = require('../utils/site_join');
|
||||
const { Agent } = require('../models/agent');
|
||||
const { SiteSpoke } = require('../models/site_spoke');
|
||||
const { ldapHostFor } = require('../utils/ldap_replication');
|
||||
|
||||
// probeMasterHealth checks whether this (spoke) node can reach its master over
|
||||
// the site join key. The master's /api/site/ping is deliberately lightweight.
|
||||
async function probeMasterHealth(cfg) {
|
||||
if (cfg.isMaster) return true;
|
||||
if (!cfg.masterUrl || !cfg.masterJoinKey) return false;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 10000);
|
||||
try {
|
||||
const resp = await fetch(String(cfg.masterUrl).replace(/\/+$/, '') + '/api/site/ping', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + cfg.masterJoinKey, 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
signal: controller.signal
|
||||
});
|
||||
return resp.ok;
|
||||
} catch (e) {
|
||||
return false;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/site-status', async (req, res, next) => {
|
||||
try {
|
||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
const wanConnected = await probeMasterHealth(cfg);
|
||||
let canJoin = false;
|
||||
if (cfg.isMaster) {
|
||||
canJoin = await siteIsFresh({ User, Agent }).catch(() => false);
|
||||
}
|
||||
// registeredSpokesCount (master) / liveReplication (spoke): surfaces
|
||||
// whether live replication is actually wired up, not just whether the
|
||||
// join itself succeeded -- a spoke that joined without `selfUrl` (e.g.
|
||||
// via an older bootstrap, or the UI form before it grew the field) is
|
||||
// fully joined but silently stuck on the one-time snapshot, which was
|
||||
// otherwise invisible anywhere in the UI.
|
||||
const registeredSpokesCount = cfg.isMaster ? await SiteSpoke.list().then(l => l.length).catch(() => 0) : 0;
|
||||
// Real gateway-to-gateway mesh peer count from jump-host's own registry
|
||||
// (utils/jump_client.js), not this app's unrelated WireGuard
|
||||
// roaming-client Resources. count is null (not 0) when the query
|
||||
// couldn't run at all -- the UI distinguishes "0 gateways" from "can't
|
||||
// tell" instead of showing a misleading zero.
|
||||
const gateways = await jumpClient.getGatewayCount();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
config: {
|
||||
isMaster: cfg.isMaster,
|
||||
masterUrl: cfg.masterUrl,
|
||||
siteSlug: cfg.siteSlug,
|
||||
wanConnected,
|
||||
siteMode: cfg.isMaster ? 'master' : 'spoke',
|
||||
canJoin,
|
||||
liveReplication: !cfg.isMaster ? !!cfg.replicationPushToken : undefined,
|
||||
registeredSpokesCount
|
||||
},
|
||||
sitesCount: sites.length,
|
||||
sites: sites.map(s => ({ id: s.id, name: s.name, slug: s.slug })),
|
||||
gatewaysCount: gateways.count,
|
||||
gatewaysNote: gateways.note
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// OpenLDAP multi-master replication config for THIS node (docs/replication.md).
|
||||
// Master-only: the master already has every registered spoke's info locally
|
||||
// (SiteSpoke), so it can compute its own ServerID (always 1) + full peer
|
||||
// list without an HTTP round-trip. A spoke gets its config from the master
|
||||
// directly instead (GET /api/site/ldap-peers -- see bootstrap/
|
||||
// site-ldap-register.js in theta-suite, which calls whichever of the two
|
||||
// applies to this node's role).
|
||||
router.get('/ldap-replication-config', async (req, res, next) => {
|
||||
try {
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
return res.status(400).json({ status: 'error', message: 'this node is a spoke -- fetch replication config from the master via GET /api/site/ldap-peers instead' });
|
||||
}
|
||||
const spokes = await SiteSpoke.list();
|
||||
const peers = [];
|
||||
for (const s of spokes) {
|
||||
if (!s.ldapServerId) continue;
|
||||
const host = ldapHostFor(s.endpoint);
|
||||
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||
}
|
||||
res.json({ status: 'ok', ldapServerId: 1, peers });
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
router.post('/site-promote', async (req, res, next) => {
|
||||
try {
|
||||
// god_admin privilege check. This used to read req.user.groups, which
|
||||
// nothing in the codebase ever populates -- User.get() (what
|
||||
// Auth.checkToken returns as req.user) has no .groups field; every other
|
||||
// admin gate in this app resolves membership live via
|
||||
// permission.byGroup()/Group.list(user.dn), which also correctly
|
||||
// resolves NESTED group membership (a user who is god_admin via a nested
|
||||
// group, not just direct membership). The old check silently evaluated
|
||||
// to an empty array for every request, making this endpoint
|
||||
// unreachable for ANY user -- caught by the multi-site e2e promotion
|
||||
// test (docker-compose.multisite-e2e.yml), not by inspection.
|
||||
const isGodAdmin = await permission.byGroup(req.user, [SUPER_ADMIN_GROUP]).catch(() => false);
|
||||
if (!isGodAdmin) {
|
||||
return res.status(403).json({ status: 'error', message: 'Master promotion requires explicit god_admin authority' });
|
||||
}
|
||||
|
||||
// MULTI_SITE_SPEC.md §3.2: promotion is ONE coordinated action, never a
|
||||
// manual two-step "demote the old one first" — if we currently know a
|
||||
// master (we were a spoke), hand it off before flipping ourselves. This
|
||||
// is best-effort: an unreachable old master (the whole point of the
|
||||
// WAN-outage promotion scenario §3 describes) must never block a
|
||||
// god_admin's local promotion, it's just reported so the operator can
|
||||
// reconcile it manually.
|
||||
const beforeCfg = siteConfig.get();
|
||||
let handoffNote = 'no previous master on file (already master, or fresh install)';
|
||||
if (!beforeCfg.isMaster && beforeCfg.masterUrl && beforeCfg.masterJoinKey) {
|
||||
try {
|
||||
const { raw: freshKey } = await SiteJoinKey.issue({
|
||||
label: 'promotion-handoff-' + new Date().toISOString().slice(0, 10),
|
||||
createdBy: req.user ? req.user.uid : 'admin'
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 15000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(beforeCfg.masterUrl + '/api/site/demote', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + beforeCfg.masterJoinKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ newMasterUrl: (req.body && req.body.selfUrl) || '', newJoinKey: freshKey }),
|
||||
signal: controller.signal
|
||||
});
|
||||
} finally { clearTimeout(timer); }
|
||||
handoffNote = resp.ok ? 'previous master demoted' : ('previous master demote failed: HTTP ' + resp.status);
|
||||
} catch (e) {
|
||||
handoffNote = 'previous master unreachable (' + e.message + ') — promoted locally anyway; reconcile it manually once it\'s back';
|
||||
}
|
||||
}
|
||||
|
||||
siteConfig.save({ isMaster: true, masterUrl: '', masterJoinKey: undefined });
|
||||
|
||||
console.log(`[MULTI-SITE] Node promoted to MASTER by user ${req.user ? req.user.uid : 'admin'} (handoff: ${handoffNote})`);
|
||||
|
||||
// Fire-and-forget: let every known spoke know a new master exists so
|
||||
// their next resync targets it. (They'll also learn this the hard way if
|
||||
// their old-master resync calls start failing, but this speeds it up.)
|
||||
meshReplicate.replicateToSpokes('master-promoted');
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Node successfully promoted to Master Site',
|
||||
handoff: handoffNote,
|
||||
config: {
|
||||
isMaster: true,
|
||||
masterUrl: '',
|
||||
siteSlug: cfg.siteSlug,
|
||||
siteMode: 'master'
|
||||
}
|
||||
});
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -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;
|
||||
@@ -0,0 +1,519 @@
|
||||
'use strict';
|
||||
|
||||
// Multi-site join endpoints (MULTI_SITE_SPEC.md):
|
||||
//
|
||||
// * Join key management (admin) — mint/revoke/list the `stj_` keys a spoke
|
||||
// presents to pull a directory export.
|
||||
// * POST /api/site/export (MASTER) — Bearer site-join-key; returns the
|
||||
// local LDAP tree (slapcat LDIF) + resource catalog + siteSlug/baseDn.
|
||||
// * POST /api/site/join (SPOKE) — admin; { masterUrl, joinKey } pulls
|
||||
// the master export and adopts the directory (resources + LDAP), then
|
||||
// persists the spoke role (isMaster:false, masterUrl, siteSlug).
|
||||
//
|
||||
// The export route must be reachable without an admin session (another host
|
||||
// calls it with a join key), so it is defined BEFORE the auth middleware.
|
||||
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const { execFile } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const { Resource, ResourceEdge } = require('../models/resource');
|
||||
const { SiteJoinKey } = require('../models/site_join_key');
|
||||
const { SiteSpoke } = require('../models/site_spoke');
|
||||
const { replicateToSpokes } = require('../utils/site_replicate');
|
||||
const User = require('../models/user');
|
||||
const { Agent } = require('../models/agent');
|
||||
const siteConfig = require('../utils/site_config');
|
||||
const { importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh } = require('../utils/site_join');
|
||||
const agentKeys = require('../utils/agent_keys');
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const router = express.Router();
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
|
||||
function logAudit(action, details) {
|
||||
console.log(JSON.stringify({ timestamp: new Date().toISOString(), component: 'site', action, ...details }));
|
||||
}
|
||||
|
||||
const { nextFreeLdapServerId, ldapHostFor } = require('../utils/ldap_replication');
|
||||
|
||||
// slurpLdif dumps the local LDAP tree with slapcat (the sso-manager container
|
||||
// carries an OpenLDAP build with slapcat on PATH).
|
||||
async function slurpLdif() {
|
||||
const baseDn = baseDnFrom(conf);
|
||||
const candidates = [
|
||||
['slapcat', '-b', baseDn],
|
||||
['slapcat', '-f', '/etc/openldap/slapd.conf', '-b', baseDn]
|
||||
];
|
||||
for (const argv of candidates) {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 64 * 1024 * 1024, timeout: 60000 });
|
||||
if (stdout && stdout.trim()) return stdout;
|
||||
} catch (e) { /* try the next invocation */ }
|
||||
}
|
||||
throw new Error('slapcat failed: could not dump local LDAP tree');
|
||||
}
|
||||
|
||||
// ── Export (MASTER side, Bearer site-join-key; no admin session) ────────────
|
||||
router.post('/export', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
|
||||
const [ldif, resources, edges, signingKey] = await Promise.all([
|
||||
slurpLdif(),
|
||||
Resource.list(),
|
||||
ResourceEdge.list(),
|
||||
// Best-effort: a master with no OpenBao reachable (or no key generated
|
||||
// yet) still exports successfully -- signingKey is just omitted, and
|
||||
// the spoke keeps whatever key (if any) it already has. Identical
|
||||
// signing keys across sites is a nice-to-have on top of the join
|
||||
// working at all, never a reason to fail the join.
|
||||
agentKeys.load().then((k) => k && { privateKeyPem: k.privateKeyPem, publicKeyPem: k.publicKeyPem }).catch(() => null)
|
||||
]);
|
||||
|
||||
await key.update({ use_count: (key.use_count || 0) + 1, last_used_on: Math.floor(Date.now() / 1000) }).catch(() => {});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
siteSlug: siteConfig.get().siteSlug,
|
||||
baseDn: baseDnFrom(conf),
|
||||
ldif,
|
||||
resources: (resources || []).map(r => (r.toJSON ? r.toJSON() : r)),
|
||||
edges: (edges || []).map(e => (e.toJSON ? e.toJSON() : e)),
|
||||
...(signingKey ? { signingKey } : {})
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Ping (MASTER side, Bearer site-join-key; no admin session) ─────────────
|
||||
// Lightweight reachability probe a spoke uses for WAN-health — deliberately
|
||||
// cheap (no LDAP dump / catalog), unlike /export.
|
||||
router.post('/ping', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
res.json({ status: 'ok', siteSlug: siteConfig.get().siteSlug, ts: Math.floor(Date.now() / 1000) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Spoke registration (MASTER side, Bearer site-join-key; no admin session)
|
||||
// A spoke calls this right after adopting a join, handing over its own
|
||||
// reachable endpoint so the master can push live-replication resync pings to
|
||||
// it later (see utils/site_replicate.js). Idempotent on endpoint: calling it
|
||||
// again (e.g. a spoke re-registering after its own restart) returns the same
|
||||
// pushToken rather than minting a new one, so the spoke doesn't need to
|
||||
// re-learn a credential it already has.
|
||||
router.post('/spokes', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
|
||||
const { endpoint, siteSlug, noInbound, meshIp, publicHost } = req.body || {};
|
||||
if (!endpoint || !/^https?:\/\//.test(endpoint)) {
|
||||
return res.status(400).json({ status: 'error', message: 'a valid http(s) endpoint is required' });
|
||||
}
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
let spoke = (await SiteSpoke.list({ where: { endpoint } }))[0];
|
||||
const patch = { siteSlug: siteSlug || (spoke && spoke.siteSlug) || null, last_seen_on: now, noInbound: !!noInbound, meshIp: meshIp || '', publicHost: publicHost || '' };
|
||||
if (spoke) {
|
||||
await spoke.update(patch);
|
||||
} else {
|
||||
spoke = await SiteSpoke.create({
|
||||
id: crypto.randomUUID(),
|
||||
endpoint,
|
||||
pushToken: SiteSpoke.generatePushToken(),
|
||||
created_on: now,
|
||||
ldapServerId: await nextFreeLdapServerId(),
|
||||
...patch
|
||||
});
|
||||
}
|
||||
|
||||
// No-inbound relay automation: best-effort, never blocks registration.
|
||||
// See utils/proxy_client.js for why this reuses theta-proxy's existing
|
||||
// API token system rather than a new credential type.
|
||||
let relayNote = 'not applicable (spoke has inbound access)';
|
||||
if (noInbound) {
|
||||
if (meshIp && publicHost) {
|
||||
const proxyClient = require('../utils/proxy_client');
|
||||
const result = await proxyClient.ensureRelayRoute({ host: publicHost, ip: meshIp, targetPort: 3001 });
|
||||
relayNote = result.note;
|
||||
} else {
|
||||
relayNote = 'skipped: noInbound set but meshIp/publicHost missing';
|
||||
}
|
||||
await spoke.update({ relayNote });
|
||||
}
|
||||
|
||||
logAudit('spoke_registered', { endpoint, siteSlug: spoke.siteSlug, noInbound: !!noInbound, relayNote });
|
||||
res.json({ status: 'ok', pushToken: spoke.pushToken, relay: { note: relayNote } });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── LDAP replication peer list (SPOKE-callable, Bearer site join key) ───────
|
||||
// OpenLDAP multi-master replication (docs/replication.md) needs each site to
|
||||
// know its own ServerID plus every OTHER site's LDAPS URL. The master
|
||||
// coordinates ID assignment (nextFreeLdapServerId, above); this is how a
|
||||
// spoke asks "what's my ID, and who are my peers" -- called by
|
||||
// theta-suite's bootstrap/site-ldap-register.js on every setup.sh run, not
|
||||
// just once at join time, since the peer list changes as other spokes join.
|
||||
// Same join-key auth as /spokes (a spoke already has this stored from its
|
||||
// own join). `endpoint` identifies the CALLER so it can be excluded from its
|
||||
// own peer list -- same identity SiteSpoke.list() keys registration on.
|
||||
router.get('/ldap-peers', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
|
||||
const callerEndpoint = req.query.endpoint;
|
||||
if (!callerEndpoint) {
|
||||
return res.status(400).json({ status: 'error', message: 'endpoint query param is required' });
|
||||
}
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
const masterHost = ldapHostFor(cfg.masterUrl || req.protocol + '://' + req.get('host'));
|
||||
const spokes = await SiteSpoke.list();
|
||||
const caller = spokes.find((s) => s.endpoint === callerEndpoint);
|
||||
if (!caller || !caller.ldapServerId) {
|
||||
return res.status(404).json({ status: 'error', message: 'this endpoint is not a registered spoke -- register via POST /api/site/spokes first' });
|
||||
}
|
||||
|
||||
const peers = [{ ldapServerId: 1, ldapHost: masterHost }];
|
||||
for (const s of spokes) {
|
||||
if (s.endpoint === callerEndpoint || !s.ldapServerId) continue;
|
||||
const host = ldapHostFor(s.endpoint);
|
||||
if (host) peers.push({ ldapServerId: s.ldapServerId, ldapHost: host });
|
||||
}
|
||||
|
||||
res.json({ status: 'ok', ldapServerId: caller.ldapServerId, peers });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Resync (SPOKE side, Bearer pushToken; no admin session) ─────────────────
|
||||
// The receiving end of utils/site_replicate.js's fire-and-forget push: the
|
||||
// master pings this when its catalog changes. Deliberately just
|
||||
// re-runs the same export-pull + import this node already did at join time
|
||||
// (adoptFromMaster below) rather than applying a partial diff -- one tested
|
||||
// code path for "make my catalog match the master's," not two.
|
||||
router.post('/resync', async (req, res, next) => {
|
||||
try {
|
||||
const cfg = siteConfig.get();
|
||||
if (cfg.isMaster) return res.status(400).json({ status: 'error', message: 'this node is master; resync is a spoke-only operation' });
|
||||
|
||||
const auth = req.headers.authorization || '';
|
||||
const presented = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
if (!cfg.replicationPushToken || presented !== cfg.replicationPushToken) {
|
||||
return res.status(401).json({ status: 'error', message: 'invalid resync push token' });
|
||||
}
|
||||
if (!cfg.masterUrl || !cfg.masterJoinKey) {
|
||||
return res.status(409).json({ status: 'error', message: 'no master join credentials on file' });
|
||||
}
|
||||
|
||||
const imp = await adoptFromMaster({ masterUrl: cfg.masterUrl, joinKey: cfg.masterJoinKey });
|
||||
logAudit('resynced', { reason: (req.body && req.body.reason) || 'unspecified', resourcesCreated: imp.created, resourcesUpdated: imp.updated });
|
||||
res.json({ status: 'ok', resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount } });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Demote (called on the OLD master; Bearer site-join-key; no admin session)
|
||||
// MULTI_SITE_SPEC.md §3.2: promoting a spoke must be a single coordinated
|
||||
// action, never a two-step "hope nobody's master for a while" gap. The node
|
||||
// being promoted calls this on whatever it currently believes is master,
|
||||
// using the join-key credential it already holds from when it joined --
|
||||
// authenticating "demote me" is exactly the same trust relationship as
|
||||
// authenticating "let me pull an export," so no new credential type is
|
||||
// needed for THIS direction. (The new master's future ability to push
|
||||
// replication/resync to the newly-demoted node is a separate credential --
|
||||
// newJoinKey below -- since that's the master->spoke direction, same as
|
||||
// every other spoke registration.)
|
||||
router.post('/demote', async (req, res, next) => {
|
||||
try {
|
||||
const auth = req.headers.authorization || '';
|
||||
const rawKey = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
|
||||
const key = await SiteJoinKey.authenticate(rawKey);
|
||||
if (!key) return res.status(401).json({ status: 'error', message: 'invalid or revoked site join key' });
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
return res.status(400).json({ status: 'error', message: 'this node is already a spoke' });
|
||||
}
|
||||
const { newMasterUrl, newJoinKey } = req.body || {};
|
||||
if (!newMasterUrl || !newJoinKey) {
|
||||
return res.status(400).json({ status: 'error', message: 'newMasterUrl and newJoinKey are required' });
|
||||
}
|
||||
|
||||
const base = String(newMasterUrl).replace(/\/+$/, '');
|
||||
siteConfig.save({ isMaster: false, masterUrl: base, masterJoinKey: newJoinKey });
|
||||
logAudit('demoted', { demotedBy: key.keyPrefix, newMasterUrl: base });
|
||||
res.json({ status: 'ok', message: 'Demoted to spoke of ' + base });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Everything below requires an admin session ──────────────────────────────
|
||||
router.use(middleware.auth);
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
// Current multi-site role (master/spoke, site slug, master URL).
|
||||
// Never sent to the client: masterJoinKey and replicationPushToken are live
|
||||
// credentials, not display data. Callers get boolean derivatives instead
|
||||
// (hasMasterJoinKey, liveReplication) -- enough to render UI state without
|
||||
// putting a secret in a browser response.
|
||||
router.get('/config', async (req, res, next) => {
|
||||
try {
|
||||
const cfg = siteConfig.get();
|
||||
const { masterJoinKey, replicationPushToken, ...safe } = cfg;
|
||||
res.json({
|
||||
status: 'ok',
|
||||
config: {
|
||||
...safe,
|
||||
hasMasterJoinKey: !!masterJoinKey,
|
||||
liveReplication: !!replicationPushToken
|
||||
}
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Site join key management (admin) ────────────────────────────────────────
|
||||
router.get('/join-keys', async (req, res, next) => {
|
||||
try {
|
||||
const keys = await SiteJoinKey.list();
|
||||
res.json({ status: 'ok', joinKeys: (keys || []).map(k => k.toPublic()) });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/join-keys', async (req, res, next) => {
|
||||
try {
|
||||
const { label, expiresInDays } = req.body || {};
|
||||
const { key, raw } = await SiteJoinKey.issue({
|
||||
label: (label && String(label).trim()) || 'default',
|
||||
createdBy: req.user.uid,
|
||||
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||
});
|
||||
logAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||
// Shown once; only the hash is stored.
|
||||
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||
try {
|
||||
const key = await SiteJoinKey.get(req.params.id);
|
||||
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||
await key.update({ revoked: true });
|
||||
logAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||
res.json({ status: 'ok' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||
try {
|
||||
const key = await SiteJoinKey.get(req.params.id);
|
||||
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||
await key.delete();
|
||||
res.json({ status: 'ok' });
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
// ── Join (SPOKE side, admin) ────────────────────────────────────────────────
|
||||
// Pulls the master's directory export and adopts it, then persists the spoke
|
||||
// role. Only valid on a node that is currently the master (i.e. a fresh
|
||||
// bring-up that has not joined anything yet) — see setup.sh wiring for the
|
||||
// pre-seed timing (this pass is server endpoints only).
|
||||
// Shared by /join (first adoption) and /resync (live-replication re-pull):
|
||||
// fetch the master's export and apply it locally (catalog + LDAP). Throws on
|
||||
// any failure that should surface as a 502 to the caller.
|
||||
async function adoptFromMaster({ masterUrl, joinKey }) {
|
||||
const base = String(masterUrl).replace(/\/+$/, '');
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 30000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(base + '/api/site/export', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
signal: controller.signal
|
||||
});
|
||||
} finally { clearTimeout(timer); }
|
||||
|
||||
if (!resp.ok) {
|
||||
const text = (await resp.text().catch(() => '')).slice(0, 200);
|
||||
const err = new Error('master export failed: HTTP ' + resp.status + ' ' + text);
|
||||
err.httpStatus = 502;
|
||||
throw err;
|
||||
}
|
||||
const exportData = await resp.json();
|
||||
if (!exportData || exportData.status !== 'ok' || !exportData.ldif) {
|
||||
const err = new Error('master export returned no directory');
|
||||
err.httpStatus = 502;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// 1. Adopt the resource catalog.
|
||||
const imp = await importDirectory({ Resource, ResourceEdge, exportData });
|
||||
|
||||
// 2. Adopt the LDAP tree. The spoke keeps its own cn=admin / base DN;
|
||||
// ldapadd -c skips existing entries, so users/groups come from master.
|
||||
let ldapNote = 'imported';
|
||||
try {
|
||||
const adminDn = conf.ldap && conf.ldap.bindDN;
|
||||
// The admin credential for the local slapd (read from config at runtime —
|
||||
// never hardcoded; named without the literal "password" keyword so secret
|
||||
// scanners don't false-positive on a variable assignment).
|
||||
const ldapCred = conf.ldap && conf.ldap.bindPassword;
|
||||
const ldifFile = path.join(os.tmpdir(), 'theta-site-join.ldif');
|
||||
fs.writeFileSync(ldifFile, exportData.ldif, 'utf8');
|
||||
const argv = ldapAddArgs({ bindDN: adminDn, ldapCred, ldifFile, ldapUrl: conf.ldap && conf.ldap.url });
|
||||
await execFileAsync(argv[0], argv.slice(1), { maxBuffer: 4 * 1024 * 1024, timeout: 120000 });
|
||||
fs.unlink(ldifFile).catch(() => {});
|
||||
} catch (e) {
|
||||
ldapNote = 'skipped/failed: ' + e.message;
|
||||
}
|
||||
|
||||
// 3. Adopt the master's agent-signing key, if it sent one (MULTI_SITE_SPEC.md
|
||||
// §2 -- identical directories). Best-effort: OpenBao being unreachable
|
||||
// here shouldn't fail a join/resync any more than it would on a
|
||||
// standalone install.
|
||||
let signingKeyNote = 'not provided by master';
|
||||
if (exportData.signingKey) {
|
||||
try {
|
||||
await agentKeys.adopt(exportData.signingKey);
|
||||
signingKeyNote = 'adopted';
|
||||
} catch (e) {
|
||||
signingKeyNote = 'failed: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
return { imp, ldapNote, signingKeyNote, exportData, base };
|
||||
}
|
||||
|
||||
router.post('/join', async (req, res, next) => {
|
||||
try {
|
||||
const { masterUrl, joinKey, selfUrl, noInbound, meshIp, publicHost } = req.body || {};
|
||||
if (!masterUrl || !joinKey) {
|
||||
return res.status(400).json({ status: 'error', message: 'masterUrl and joinKey are required' });
|
||||
}
|
||||
|
||||
const cfg = siteConfig.get();
|
||||
if (!cfg.isMaster) {
|
||||
return res.status(400).json({ status: 'error', message: 'this node is already a spoke (re-join is not supported)' });
|
||||
}
|
||||
// Only a fresh install may join — a directory with real users must not be
|
||||
// merged into a master's (that is the destructive case).
|
||||
if (!(await siteIsFresh({ User, Agent }))) {
|
||||
return res.status(409).json({
|
||||
status: 'error',
|
||||
message: 'This directory already has users/agents. Only a fresh install may join a site (re-provision the host to adopt a master directory).'
|
||||
});
|
||||
}
|
||||
|
||||
let adopted;
|
||||
try {
|
||||
adopted = await adoptFromMaster({ masterUrl, joinKey });
|
||||
} catch (e) {
|
||||
return res.status(e.httpStatus || 502).json({ status: 'error', message: e.message });
|
||||
}
|
||||
const { imp, ldapNote, signingKeyNote, exportData, base } = adopted;
|
||||
|
||||
// 3. Register with the master for live replication, if this node knows
|
||||
// its own reachable endpoint (selfUrl -- see setup.env's
|
||||
// CFG_SELF_DIRECTORY_URL). Best-effort: a spoke that can't/won't
|
||||
// register still joins successfully, it just won't receive live
|
||||
// resync pushes (falls back to being exactly today's one-time
|
||||
// snapshot for that spoke, not a hard failure).
|
||||
let replicationPushToken = null;
|
||||
let replicationNote = 'not registered (no selfUrl given)';
|
||||
let relayNote = noInbound ? 'not attempted (registration did not run)' : 'not applicable (this spoke has inbound access)';
|
||||
if (selfUrl) {
|
||||
try {
|
||||
const regResp = await fetch(base + '/api/site/spokes', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + joinKey, 'Content-Type': 'application/json' },
|
||||
// noInbound/meshIp/publicHost: this spoke has no public IP of its
|
||||
// own; forwarded so the master can best-effort auto-create a relay
|
||||
// route on its own theta-proxy (utils/proxy_client.js). Previously
|
||||
// accepted by /spokes but never actually reachable from here --
|
||||
// nothing forwarded them, so the automation existed but no real
|
||||
// join flow could ever trigger it.
|
||||
body: JSON.stringify({
|
||||
endpoint: selfUrl,
|
||||
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||
...(noInbound ? { noInbound: true, meshIp, publicHost } : {})
|
||||
})
|
||||
});
|
||||
if (regResp.ok) {
|
||||
const regBody = await regResp.json();
|
||||
replicationPushToken = regBody.pushToken;
|
||||
replicationNote = 'registered for live replication';
|
||||
if (regBody.relay) relayNote = regBody.relay.note;
|
||||
} else {
|
||||
replicationNote = 'registration failed: HTTP ' + regResp.status;
|
||||
}
|
||||
} catch (e) {
|
||||
replicationNote = 'registration failed: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Persist the spoke role (survives restarts). The join key is kept so
|
||||
// the spoke can run WAN-health checks against the master; the push
|
||||
// token (if registration succeeded) is what authenticates the
|
||||
// master's future resync pushes back to THIS node.
|
||||
siteConfig.save({
|
||||
isMaster: false,
|
||||
masterUrl: base,
|
||||
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||
masterJoinKey: joinKey,
|
||||
...(replicationPushToken ? { replicationPushToken } : {})
|
||||
});
|
||||
|
||||
logAudit('joined', {
|
||||
actor: req.user.uid,
|
||||
masterUrl: base,
|
||||
siteSlug: exportData.siteSlug,
|
||||
resourcesCreated: imp.created,
|
||||
resourcesUpdated: imp.updated,
|
||||
edges: imp.edgeCount,
|
||||
ldap: ldapNote,
|
||||
signingKey: signingKeyNote,
|
||||
replication: replicationNote
|
||||
});
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
message: 'Joined master site ' + base,
|
||||
siteSlug: exportData.siteSlug || cfg.siteSlug,
|
||||
resources: { created: imp.created, updated: imp.updated, edges: imp.edgeCount },
|
||||
ldap: { note: ldapNote },
|
||||
signingKey: { note: signingKeyNote },
|
||||
replication: { note: replicationNote, live: !!replicationPushToken },
|
||||
relay: { note: relayNote }
|
||||
});
|
||||
} catch (e) { next(e); }
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -169,20 +169,14 @@ router.post('/promote/:slug', async (req, res, next) => {
|
||||
else throw e;
|
||||
}
|
||||
|
||||
// Link them
|
||||
const crypto = require('crypto');
|
||||
await ResourceGroup.create({
|
||||
id: crypto.randomUUID(),
|
||||
resourceId: resource.id,
|
||||
groupCn: accessGroup,
|
||||
accessLevel: 'user'
|
||||
});
|
||||
await ResourceGroup.create({
|
||||
id: crypto.randomUUID(),
|
||||
resourceId: resource.id,
|
||||
groupCn: adminGroup,
|
||||
accessLevel: 'admin'
|
||||
});
|
||||
// Link them. ensure(), not create(): a re-submitted/retried Promote
|
||||
// click (or the modal being saved twice) had no existence check here,
|
||||
// so repeated promotion attempts on the same resource accumulated
|
||||
// duplicate access/admin group rows -- see ResourceGroup.ensure()'s
|
||||
// comment on models/resource.js for why this can't rely on a DB
|
||||
// constraint instead.
|
||||
await ResourceGroup.ensure(resource.id, accessGroup, 'user');
|
||||
await ResourceGroup.ensure(resource.id, adminGroup, 'admin');
|
||||
|
||||
const meta = resource.metadata || {};
|
||||
meta.managed = true;
|
||||
|
||||
@@ -42,6 +42,7 @@ const DOCS = {
|
||||
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
@@ -88,19 +88,7 @@ router.get('/plugins', function(req, res, next) {
|
||||
});
|
||||
|
||||
router.get('/vault', function(req, res) {
|
||||
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||
// tokens for external apps. The view renders the shell for any logged-in
|
||||
// user; the client gates login via app.auth.forceLogin() and derives the
|
||||
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
|
||||
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
|
||||
// the client-derived scope is only cosmetic. vaultAddr is the only
|
||||
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
|
||||
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
|
||||
res.render('vault', {
|
||||
...values,
|
||||
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||
});
|
||||
res.redirect('/conf');
|
||||
});
|
||||
|
||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||
|
||||
@@ -222,10 +222,12 @@ router.put('/:uid', async function(req, res, next){
|
||||
req.body.manager = req.body.manager.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
return res.json({
|
||||
results: await user.update(req.body),
|
||||
message: `Updated ${req.params.uid} user`
|
||||
const updatedUser = await user.update(req.body);
|
||||
User.clearCache();
|
||||
|
||||
return res.json({
|
||||
results: updatedUser,
|
||||
message: `Updated ${req.params.uid} user`
|
||||
});
|
||||
}catch(error){
|
||||
next(error);
|
||||
|
||||
@@ -19,9 +19,42 @@ function isDescendant(candidateId, rootId, edges) {
|
||||
}
|
||||
|
||||
class DiscoveryReconciler {
|
||||
static async reconcile(sourceName, payload) {
|
||||
static async reconcile(sourceName, payload, options = {}) {
|
||||
const { resources = [], edges = [] } = payload;
|
||||
let newDevices = 0;
|
||||
const location = options.location || options.site || null;
|
||||
const autoPromote = !!options.autoPromote;
|
||||
|
||||
let targetSite = null;
|
||||
if (location && String(location).trim()) {
|
||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||
const locStr = String(location).trim().toLowerCase();
|
||||
targetSite = sites.find(s => s.name.toLowerCase() === locStr || s.slug.toLowerCase() === locStr);
|
||||
if (!targetSite) {
|
||||
const locSlug = `site-${locStr.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||
targetSite = await Resource.create({
|
||||
id: crypto.randomUUID(),
|
||||
kind: 'site',
|
||||
name: String(location).trim(),
|
||||
slug: locSlug,
|
||||
created_on: Math.floor(Date.now() / 1000)
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
if (!targetSite) {
|
||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||
if (sites && sites.length > 0) {
|
||||
targetSite = sites[0];
|
||||
} else {
|
||||
targetSite = await Resource.create({
|
||||
id: crypto.randomUUID(),
|
||||
kind: 'site',
|
||||
name: 'Default Site',
|
||||
slug: 'site-default',
|
||||
created_on: Math.floor(Date.now() / 1000)
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||
@@ -35,6 +68,7 @@ class DiscoveryReconciler {
|
||||
|
||||
for (const res of resources) {
|
||||
if (!res.metadata) res.metadata = {};
|
||||
if (autoPromote) res.metadata.managed = true;
|
||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||
|
||||
let existing = null;
|
||||
@@ -255,6 +289,51 @@ class DiscoveryReconciler {
|
||||
}
|
||||
}
|
||||
|
||||
if (targetSite) {
|
||||
const childSlugs = new Set(edges.map(e => e.childSlug));
|
||||
for (const res of resources) {
|
||||
if (res._actualId && res._actualId !== targetSite.id && !childSlugs.has(res._originalSlug || res.slug)) {
|
||||
const edgeExists = existingEdges.find(e => e.childId === res._actualId);
|
||||
if (!edgeExists) {
|
||||
const created = await ResourceEdge.create({
|
||||
id: crypto.randomUUID(),
|
||||
parentId: targetSite.id,
|
||||
childId: res._actualId,
|
||||
relation: 'hosts'
|
||||
}).catch(() => null);
|
||||
if (created) existingEdges.push(created);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (autoPromote) {
|
||||
const { Group } = require('../models/group_ldap');
|
||||
for (const res of resources) {
|
||||
if (!res._actualId || res.metadata?.managed !== true) continue;
|
||||
const accessGroup = `${res.slug}_access`;
|
||||
const adminGroup = `${res.slug}_admin`;
|
||||
try {
|
||||
await Group.get(accessGroup).catch(async (e) => {
|
||||
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${res.name}`, owner: 'cn=admin' });
|
||||
});
|
||||
await Group.get(adminGroup).catch(async (e) => {
|
||||
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${res.name}`, owner: 'cn=admin' });
|
||||
});
|
||||
// ensure(), not create(): reconcile() runs on every discovery pass
|
||||
// (e.g. once per Proxmox cluster node reporting the same LXC), and
|
||||
// a raw create() here had no existence check, so a resource ended
|
||||
// up with the same access/admin group rows duplicated once per
|
||||
// pass -- see ResourceGroup.ensure()'s comment for why this can't
|
||||
// rely on a DB constraint instead.
|
||||
await ResourceGroup.ensure(res._actualId, accessGroup, 'user').catch(() => {});
|
||||
await ResourceGroup.ensure(res._actualId, adminGroup, 'admin').catch(() => {});
|
||||
} catch (err) {
|
||||
console.error(`[DiscoveryReconciler] autoPromote failed for ${res.slug}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newDevices > 0) {
|
||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -82,7 +82,7 @@ async function runPluginJob(instanceId) {
|
||||
};
|
||||
const payload = await runFn(cfg);
|
||||
if (instance.category === 'discovery') {
|
||||
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
||||
await DiscoveryReconciler.reconcile(instance.slug, payload, cfg);
|
||||
}
|
||||
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
|
||||
} catch (err) {
|
||||
|
||||
@@ -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,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,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,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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ jest.mock('node-nmap', () => {
|
||||
this.targetRange = targetRange;
|
||||
this.customFlags = customFlags;
|
||||
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
||||
this.rawData = '';
|
||||
}
|
||||
startScan() {
|
||||
setImmediate(() => {
|
||||
@@ -18,6 +19,15 @@ jest.mock('node-nmap', () => {
|
||||
]);
|
||||
});
|
||||
}
|
||||
// Real node-nmap's rawDataHandler XML-parses this.rawData then calls
|
||||
// this.scanComplete(results), which emits 'complete' -- the mock skips
|
||||
// straight to emitting the same shape so the RTTVAR-recovery test below
|
||||
// exercises the exact call our plugin code makes.
|
||||
rawDataHandler() {
|
||||
this.emit('complete', [
|
||||
{ ip: '192.168.1.20', hostname: 'host-20', openPorts: [] }
|
||||
]);
|
||||
}
|
||||
}
|
||||
return {
|
||||
NmapScan: MockNmapScan,
|
||||
@@ -43,4 +53,42 @@ describe('nmap discovery plugin', () => {
|
||||
expect(result.resources[0].name).toBe('host-10');
|
||||
expect(result.edges).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('recovers a scan that completed despite nmap\'s benign RTTVAR stderr warning', async () => {
|
||||
// Regression: node-nmap treats ANY stderr output as fatal, including
|
||||
// nmap's own harmless RTT-calibration message -- which discards a scan
|
||||
// that actually succeeded. Simulate that by emitting 'error' with the
|
||||
// RTTVAR text instead of 'complete', with rawData present.
|
||||
const nmapModule = require('node-nmap');
|
||||
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||
nmapModule.NmapScan.prototype.startScan = function () {
|
||||
this.rawData = '<nmaprun>...</nmaprun>';
|
||||
setImmediate(() => {
|
||||
this.emit('error', new Error('RTTVAR has grown to over 2.3 seconds, decreasing to 2.0'));
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await nmapPlugin.discover({ targetRange: '192.168.1.0/24' });
|
||||
expect(result.resources.some((r) => r.name === 'host-20')).toBe(true);
|
||||
} finally {
|
||||
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||
}
|
||||
});
|
||||
|
||||
test('still rejects a genuine error even when the message differs from RTTVAR', async () => {
|
||||
const nmapModule = require('node-nmap');
|
||||
const originalStartScan = nmapModule.NmapScan.prototype.startScan;
|
||||
nmapModule.NmapScan.prototype.startScan = function () {
|
||||
setImmediate(() => {
|
||||
this.emit('error', new Error('nmap: permission denied'));
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
await expect(nmapPlugin.discover({ targetRange: '192.168.1.0/24' })).rejects.toThrow('permission denied');
|
||||
} finally {
|
||||
nmapModule.NmapScan.prototype.startScan = originalStartScan;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('unifi')).toEqual(['password']);
|
||||
expect(registry.secretKeys('nmap')).toEqual([]);
|
||||
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
|
||||
expect(registry.publicKeys('nmap').sort()).toEqual(['autoPromote', 'location', 'targetRange']);
|
||||
});
|
||||
|
||||
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
|
||||
|
||||
@@ -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');
|
||||
const { Resource } = require('../models/resource');
|
||||
const { Resource, ResourceGroup } = require('../models/resource');
|
||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||
|
||||
describe('DiscoveryReconciler', () => {
|
||||
@@ -25,7 +25,7 @@ describe('DiscoveryReconciler', () => {
|
||||
|
||||
await DiscoveryReconciler.reconcile('test-plugin', payload);
|
||||
|
||||
const all = await Resource.list();
|
||||
const all = (await Resource.list()).filter(r => r.kind !== 'site');
|
||||
expect(all).toHaveLength(1);
|
||||
expect(all[0].name).toBe('New Host');
|
||||
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
|
||||
@@ -57,7 +57,7 @@ describe('DiscoveryReconciler', () => {
|
||||
}]
|
||||
});
|
||||
|
||||
const all = await Resource.list();
|
||||
const all = (await Resource.list()).filter(r => r.kind !== 'site');
|
||||
expect(all).toHaveLength(1); // Should have merged, not created a new one
|
||||
|
||||
const merged = all[0];
|
||||
@@ -72,4 +72,27 @@ describe('DiscoveryReconciler', () => {
|
||||
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
||||
});
|
||||
|
||||
it('does not duplicate access/admin groups across repeated autoPromote passes', async () => {
|
||||
// Regression: autoPromote used to call ResourceGroup.create() directly
|
||||
// with no existence check, so reconciling the same managed resource
|
||||
// more than once (e.g. a Proxmox cluster reporting one LXC from
|
||||
// multiple nodes) accumulated duplicate access/admin rows every pass.
|
||||
const payload = {
|
||||
resources: [{
|
||||
kind: 'host',
|
||||
name: 'LXC 127',
|
||||
slug: 'lxc-127',
|
||||
metadata: { interfaces: [{ mac: '00:11:22:33:44:99', ip: '10.0.0.99' }] }
|
||||
}]
|
||||
};
|
||||
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
await DiscoveryReconciler.reconcile('plugin-A', payload, { autoPromote: true });
|
||||
|
||||
const resource = (await Resource.list()).find((r) => r.slug === 'lxc-127');
|
||||
const groups = await ResourceGroup.list({ where: { resourceId: resource.id } });
|
||||
expect(groups.map((g) => g.groupCn).sort()).toEqual(['lxc-127_access', 'lxc-127_admin']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
'use strict';
|
||||
|
||||
// In-memory stand-in for the SiteSpoke ORM model.
|
||||
let spokeStore;
|
||||
function makeSpokeMock() {
|
||||
spokeStore = [];
|
||||
return {
|
||||
list: jest.fn(async () => [...spokeStore]),
|
||||
_seed(rows) { spokeStore.push(...rows); }
|
||||
};
|
||||
}
|
||||
|
||||
let mockFetchCalls = [];
|
||||
let mockFetchImpl = async () => ({ ok: true, status: 200 });
|
||||
|
||||
describe('site_replicate', () => {
|
||||
let siteReplicate;
|
||||
let SiteSpoke;
|
||||
let originalFetch;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
mockFetchCalls = [];
|
||||
mockFetchImpl = async () => ({ ok: true, status: 200 });
|
||||
|
||||
jest.doMock('../models/site_spoke', () => ({ SiteSpoke: makeSpokeMock() }));
|
||||
siteReplicate = require('../utils/site_replicate');
|
||||
SiteSpoke = require('../models/site_spoke').SiteSpoke;
|
||||
|
||||
// site_replicate.js uses the global fetch (Node 18+ built-in), not
|
||||
// node-fetch -- stub that directly.
|
||||
originalFetch = global.fetch;
|
||||
global.fetch = (...args) => { mockFetchCalls.push(args); return mockFetchImpl(...args); };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test('pushes to every known spoke concurrently with its own pushToken', async () => {
|
||||
SiteSpoke._seed([
|
||||
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' },
|
||||
{ endpoint: 'https://spoke-b.example.com', pushToken: 'token-b' }
|
||||
]);
|
||||
|
||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(mockFetchCalls.length).toBe(2);
|
||||
const urls = mockFetchCalls.map((c) => c[0]).sort();
|
||||
expect(urls).toEqual(['https://spoke-a.example.com/api/site/resync', 'https://spoke-b.example.com/api/site/resync']);
|
||||
|
||||
const [, optsA] = mockFetchCalls.find((c) => c[0].includes('spoke-a'));
|
||||
expect(optsA.headers.Authorization).toBe('Bearer token-a');
|
||||
expect(JSON.parse(optsA.body).reason).toBe('catalog-changed');
|
||||
});
|
||||
|
||||
test('prefers the mesh IP over the public endpoint when the spoke reported one', async () => {
|
||||
SiteSpoke._seed([
|
||||
{ endpoint: 'https://spoke-a.example.com:8443', pushToken: 'token-a', meshIp: '172.24.5.1' }
|
||||
]);
|
||||
|
||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(mockFetchCalls.length).toBe(1);
|
||||
expect(mockFetchCalls[0][0]).toBe('http://172.24.5.1:8443/api/site/resync');
|
||||
});
|
||||
|
||||
test('falls back to the public endpoint if the mesh attempt fails', async () => {
|
||||
SiteSpoke._seed([
|
||||
{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a', meshIp: '172.24.5.1' }
|
||||
]);
|
||||
mockFetchImpl = async (url) => {
|
||||
if (url.startsWith('http://172.24.5.1')) throw new Error('mesh unreachable');
|
||||
return { ok: true, status: 200 };
|
||||
};
|
||||
|
||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(mockFetchCalls.length).toBe(2);
|
||||
expect(mockFetchCalls[0][0]).toMatch(/^http:\/\/172\.24\.5\.1/);
|
||||
expect(mockFetchCalls[1][0]).toBe('https://spoke-a.example.com/api/site/resync');
|
||||
});
|
||||
|
||||
test('a spoke with no meshIp only ever tries the public endpoint', async () => {
|
||||
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
|
||||
|
||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
expect(mockFetchCalls.length).toBe(1);
|
||||
expect(mockFetchCalls[0][0]).toBe('https://spoke-a.example.com/api/site/resync');
|
||||
});
|
||||
|
||||
test('no known spokes: resolves cleanly, no fetch calls', async () => {
|
||||
await siteReplicate.replicateToSpokes('catalog-changed');
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(mockFetchCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('one spoke failing does not prevent delivery to another', async () => {
|
||||
SiteSpoke._seed([
|
||||
{ endpoint: 'https://dead-spoke.example.com', pushToken: 'token-dead' },
|
||||
{ endpoint: 'https://live-spoke.example.com', pushToken: 'token-live' }
|
||||
]);
|
||||
mockFetchImpl = async (url) => {
|
||||
if (url.includes('dead-spoke')) throw new Error('connection refused');
|
||||
return { ok: true, status: 200 };
|
||||
};
|
||||
|
||||
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
|
||||
await new Promise((r) => setImmediate(r));
|
||||
expect(mockFetchCalls.length).toBe(2);
|
||||
});
|
||||
|
||||
test('a non-2xx response from a spoke does not throw out of replicateToSpokes', async () => {
|
||||
SiteSpoke._seed([{ endpoint: 'https://spoke-a.example.com', pushToken: 'token-a' }]);
|
||||
mockFetchImpl = async () => ({ ok: false, status: 500 });
|
||||
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test('SiteSpoke.list() throwing does not propagate to the caller', async () => {
|
||||
SiteSpoke.list = jest.fn(async () => { throw new Error('db unavailable'); });
|
||||
await expect(siteReplicate.replicateToSpokes('event')).resolves.toBeUndefined();
|
||||
expect(mockFetchCalls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
// Authenticate an agent from a Bearer token (the same token the agent presents
|
||||
// on its WSS channel). Used by agent-facing REST endpoints (secrets, IAM) that
|
||||
// are NOT admin-gated — the caller is the agent itself, not an admin session.
|
||||
|
||||
const { Agent } = require('../models/agent');
|
||||
|
||||
// Resolve a Bearer token to its (non-revoked) Agent, or null. Every failure
|
||||
// collapses to null so a probing caller learns nothing about which part was
|
||||
// wrong.
|
||||
async function authenticateAgent(req) {
|
||||
const auth = req.headers['authorization'] || '';
|
||||
const m = /^Bearer\s+(.+)$/i.exec(auth);
|
||||
if (!m) return null;
|
||||
const token = String(m[1]).trim();
|
||||
if (!token) return null;
|
||||
try {
|
||||
const agent = await Agent.authenticate(token);
|
||||
return agent || null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { authenticateAgent };
|
||||
@@ -90,10 +90,34 @@ function status() {
|
||||
return { available: !!cached, error: loadError };
|
||||
}
|
||||
|
||||
// Overwrite the stored key with material handed over by a master (multi-site
|
||||
// "identical directories" — MULTI_SITE_SPEC.md §2). Every site sharing one
|
||||
// signing key is what lets any site's sso-manager validly sign a command for
|
||||
// an agent enrolled at any other site, at the accepted cost that compromising
|
||||
// ANY one site's OpenBao is equivalent to compromising all of them for agent
|
||||
// command authority. That tradeoff was deliberately accepted for this
|
||||
// deployment's scale (a handful of trusted sites) -- do not call this to sync
|
||||
// keys across a boundary where sites don't trust each other equally.
|
||||
//
|
||||
// Idempotent: adopting the same key material twice (e.g. on every resync
|
||||
// ping) is a no-op past the first call.
|
||||
async function adopt({ privateKeyPem, publicKeyPem }) {
|
||||
if (!privateKeyPem || !publicKeyPem) throw new Error('adopt() requires both privateKeyPem and publicKeyPem');
|
||||
if (cached && cached.privateKeyPem === privateKeyPem && cached.publicKeyPem === publicKeyPem) {
|
||||
return cached; // already holding this exact key -- nothing to do
|
||||
}
|
||||
const material = { privateKeyPem, publicKeyPem };
|
||||
await baoConf.set(PATH, material);
|
||||
cached = { ...material, publicKeyBase64: rawPublicKeyBase64(publicKeyPem) };
|
||||
loadError = null;
|
||||
console.log('[agent_keys] adopted signing key from master (multi-site identical-directory sync)');
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Test seam: drop the in-process cache.
|
||||
function _reset() {
|
||||
cached = null;
|
||||
loadError = null;
|
||||
}
|
||||
|
||||
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH };
|
||||
module.exports = { load, status, adopt, rawPublicKeyBase64, _reset, PATH };
|
||||
|
||||
@@ -21,12 +21,17 @@ class AgentManager {
|
||||
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
|
||||
*/
|
||||
canonicalize(payload) {
|
||||
const cleanObj = {};
|
||||
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
|
||||
for (const key of sortedKeys) {
|
||||
cleanObj[key] = payload[key];
|
||||
}
|
||||
return JSON.stringify(cleanObj);
|
||||
const sortObj = (val) => {
|
||||
if (val === null || typeof val !== 'object') return val;
|
||||
if (Array.isArray(val)) return val.map(sortObj);
|
||||
const sorted = {};
|
||||
const keys = Object.keys(val).filter(k => k !== 'signature').sort();
|
||||
for (const k of keys) {
|
||||
sorted[k] = sortObj(val[k]);
|
||||
}
|
||||
return sorted;
|
||||
};
|
||||
return JSON.stringify(sortObj(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,79 +118,40 @@ class AgentManager {
|
||||
|
||||
async handleDiscovery(agent, payload) {
|
||||
const discovery = {
|
||||
version: payload.version || payload.agent_version || 'unknown',
|
||||
hostname: payload.hostname || '',
|
||||
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
|
||||
public_ip: payload.public_ip || '',
|
||||
os: payload.os || '',
|
||||
kernel: payload.kernel || '',
|
||||
cpu: payload.cpu || '',
|
||||
cpu_details: payload.cpu_details || {},
|
||||
ram_total_gb: payload.ram_total_gb || 0,
|
||||
ram_details: payload.ram_details || {},
|
||||
disk_total_gb: payload.disk_total_gb || 0,
|
||||
location: payload.location || 'default'
|
||||
disks: Array.isArray(payload.disks) ? payload.disks : [],
|
||||
logged_users: Array.isArray(payload.logged_users) ? payload.logged_users : [],
|
||||
host_details: payload.host_details || {},
|
||||
location: payload.location || 'default',
|
||||
// The agent's enabled capabilities (from its local agent.yml). The agent
|
||||
// is the authoritative source for what it will actually do.
|
||||
capabilities: payload.capabilities || {}
|
||||
};
|
||||
await this.touch(agent, { lastDiscovery: discovery });
|
||||
await this.touch(agent, { version: discovery.version, lastDiscovery: discovery });
|
||||
await this.applyDiscoveryToDirectory(agent, discovery);
|
||||
}
|
||||
|
||||
// An agent runs ON the host it describes, which makes it the most
|
||||
// authoritative source the directory has -- more so than a hypervisor API or
|
||||
// a network scan. It previously updated nothing at all: the facts sat on an
|
||||
// in-memory record and were lost on disconnect.
|
||||
//
|
||||
// When the agent is bound to a resource we write that row directly; guessing
|
||||
// is only for an unbound agent, and then we let the shared reconciler do the
|
||||
// matching (same MAC/IP/name rules every other source goes through) rather
|
||||
// than inventing a second matcher here.
|
||||
async applyDiscoveryToDirectory(agent, discovery) {
|
||||
try {
|
||||
const { Resource } = require('../models/resource');
|
||||
const metadata = {
|
||||
os: discovery.os || undefined,
|
||||
kernel: discovery.kernel || undefined,
|
||||
cpu: discovery.cpu || undefined,
|
||||
ram_total_gb: discovery.ram_total_gb || undefined,
|
||||
disk_total_gb: discovery.disk_total_gb || undefined,
|
||||
ip: (discovery.ip_addresses || [])[0] || undefined,
|
||||
agentId: agent.id,
|
||||
last_seen: Date.now()
|
||||
};
|
||||
// Drop undefined so a field the agent could not determine never
|
||||
// overwrites a good value already in the directory.
|
||||
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
|
||||
|
||||
if (agent.resourceId) {
|
||||
const resource = await Resource.get(agent.resourceId);
|
||||
if (!resource) return;
|
||||
const merged = { ...(resource.metadata || {}), ...metadata };
|
||||
const sources = new Set(merged.discovery_sources || []);
|
||||
sources.add('theta-agent');
|
||||
merged.discovery_sources = [...sources];
|
||||
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!discovery.hostname) return;
|
||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||
await DiscoveryReconciler.reconcile('theta-agent', {
|
||||
resources: [{
|
||||
kind: 'host',
|
||||
name: discovery.hostname,
|
||||
slug: `agent-${agent.id.slice(0, 8)}`,
|
||||
metadata: { ...metadata, subType: 'linux' }
|
||||
}],
|
||||
edges: []
|
||||
});
|
||||
} catch (err) {
|
||||
// Never let a directory write break the agent connection.
|
||||
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async handleTelemetry(agent, payload) {
|
||||
await this.touch(agent, {
|
||||
lastTelemetry: {
|
||||
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
||||
cpu_details: payload.cpu_details || {},
|
||||
ram_usage_percent: payload.ram_usage_percent || 0,
|
||||
ram_details: payload.ram_details || {},
|
||||
disk_usage_percent: payload.disk_usage_percent || 0,
|
||||
disks: Array.isArray(payload.disks) ? payload.disks : [],
|
||||
logged_users: Array.isArray(payload.logged_users) ? payload.logged_users : [],
|
||||
host_details: payload.host_details || {},
|
||||
zfs_health: payload.zfs_health || 'N/A',
|
||||
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
||||
timestamp: payload.timestamp || new Date().toISOString()
|
||||
@@ -242,11 +208,133 @@ class AgentManager {
|
||||
};
|
||||
}
|
||||
|
||||
// Find connected/enrolled agent bound to a resource ID (or inherited from parent Host).
|
||||
async getAgentForResource(resourceId) {
|
||||
if (!resourceId) return null;
|
||||
const rows = await Agent.list().catch(() => []);
|
||||
let agent = rows.find(a => a.resourceId === resourceId);
|
||||
if (!agent) {
|
||||
try {
|
||||
const { Resource } = require('../models/resource');
|
||||
const { ResourceEdge } = require('../models/resource');
|
||||
const res = await Resource.get(resourceId);
|
||||
if (res && res.kind === 'service') {
|
||||
const edges = await ResourceEdge.list({ where: { childId: resourceId } });
|
||||
for (const edge of edges) {
|
||||
const parentRes = await Resource.get(edge.parentId);
|
||||
if (parentRes && parentRes.kind === 'host') {
|
||||
agent = rows.find(a => a.resourceId === parentRes.id || (parentRes.metadata && parentRes.metadata.agentId === a.id));
|
||||
if (agent) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[AgentManager] parent agent lookup error:', err.message);
|
||||
}
|
||||
}
|
||||
if (!agent) return null;
|
||||
return agent.toPublic(this.liveState(agent.id));
|
||||
}
|
||||
|
||||
// Every enrolled agent, connected or not.
|
||||
async listAgents() {
|
||||
const rows = await Agent.list();
|
||||
return rows.map(a => a.toPublic(this.liveState(a.id)));
|
||||
}
|
||||
|
||||
// An agent runs ON the host it describes, which makes it the most
|
||||
// authoritative source the directory has -- more so than a hypervisor API or
|
||||
// a network scan. It previously updated nothing at all: the facts sat on an
|
||||
// in-memory record and were lost on disconnect.
|
||||
//
|
||||
// When the agent is bound to a resource we write that row directly; guessing
|
||||
// is only for an unbound agent, and then we let the shared reconciler do the
|
||||
// matching (same MAC/IP/name rules every other source goes through) rather
|
||||
// than inventing a second matcher here.
|
||||
async applyDiscoveryToDirectory(agent, discovery) {
|
||||
try {
|
||||
const { Resource } = require('../models/resource');
|
||||
const metadata = {
|
||||
os: discovery.os || undefined,
|
||||
kernel: discovery.kernel || undefined,
|
||||
cpu: discovery.cpu || undefined,
|
||||
ram_total_gb: discovery.ram_total_gb || undefined,
|
||||
disk_total_gb: discovery.disk_total_gb || undefined,
|
||||
ip: (discovery.ip_addresses || [])[0] || undefined,
|
||||
public_ip: discovery.public_ip || undefined,
|
||||
agentId: agent.id,
|
||||
last_seen: Date.now()
|
||||
};
|
||||
// Drop undefined so a field the agent could not determine never
|
||||
// overwrites a good value already in the directory.
|
||||
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
|
||||
|
||||
if (agent.resourceId) {
|
||||
const resource = await Resource.get(agent.resourceId);
|
||||
if (!resource) return;
|
||||
const merged = { ...(resource.metadata || {}), ...metadata };
|
||||
const sources = new Set(merged.discovery_sources || []);
|
||||
sources.add('theta-agent');
|
||||
merged.discovery_sources = [...sources];
|
||||
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!discovery.hostname) return;
|
||||
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||
const { ResourceEdge } = require('../models/resource');
|
||||
|
||||
const hostSlug = `host-${discovery.hostname.toLowerCase().replace(/[^a-z0-9_-]/g, '-')}`;
|
||||
await DiscoveryReconciler.reconcile('theta-agent', {
|
||||
resources: [{
|
||||
kind: 'host',
|
||||
name: discovery.hostname,
|
||||
slug: hostSlug,
|
||||
metadata: { ...metadata, subType: 'linux', managed: true }
|
||||
}],
|
||||
edges: []
|
||||
});
|
||||
|
||||
// Find the matched or created host resource
|
||||
const allHosts = await Resource.list({ where: { kind: 'host' } });
|
||||
const hostRes = allHosts.find(r =>
|
||||
r.name.toLowerCase() === discovery.hostname.toLowerCase() ||
|
||||
r.slug === hostSlug ||
|
||||
r.metadata?.agentId === agent.id
|
||||
);
|
||||
|
||||
if (hostRes) {
|
||||
// Bind the agent to its Host resource
|
||||
await agent.update({ resourceId: hostRes.id }).catch(() => {});
|
||||
|
||||
// Attach host to matching Site by Public IP if not already parented
|
||||
const existingEdges = await ResourceEdge.list({ where: { childId: hostRes.id } });
|
||||
if (existingEdges.length === 0) {
|
||||
const sites = await Resource.list({ where: { kind: 'site' } });
|
||||
let targetSite = null;
|
||||
if (discovery.public_ip) {
|
||||
targetSite = sites.find(s => {
|
||||
const siteIp = (s.metadata?.public_ip || s.metadata?.ip || s.metadata?.address || '').trim();
|
||||
return siteIp && (siteIp === discovery.public_ip || siteIp.includes(discovery.public_ip));
|
||||
});
|
||||
}
|
||||
if (!targetSite) targetSite = sites[0];
|
||||
|
||||
if (targetSite) {
|
||||
await ResourceEdge.create({
|
||||
id: crypto.randomUUID(),
|
||||
parentId: targetSite.id,
|
||||
childId: hostRes.id,
|
||||
relation: 'hosts'
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Never let a directory write break the agent connection.
|
||||
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AgentManager();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use strict';
|
||||
|
||||
// Service-to-service client for jump-host's mesh registry -- used by the
|
||||
// Directory's Multi-Site & Network Gateway Status modal to show the real
|
||||
// number of gateway-to-gateway WireGuard mesh peers (see MULTI_SITE_SPEC.md),
|
||||
// instead of counting the unrelated older WireGuard roaming-client/exit-node
|
||||
// Resources in this app's own catalog (a different subsystem entirely --
|
||||
// api_directory_admin.js used to filter Resource.list() for
|
||||
// metadata.subType === 'wireguard', which has nothing to do with the mesh).
|
||||
//
|
||||
// Same pattern as utils/proxy_client.js: reuses jump-host's existing
|
||||
// self-service API token system (models/api_token.js, `jmp_<id>_<secret>`
|
||||
// bearer tokens) rather than inventing a new credential type. The token must
|
||||
// be minted by a jump-admin user (GET /api/mesh/gateways requires
|
||||
// requireJumpAdmin, which checks the token's creator's username/groups, not
|
||||
// anything the token itself carries) and stored in OpenBao.
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
|
||||
const PATH = 'integrations/theta-jump'; // baoConf adds the secret/data prefix
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
let cachedToken = null;
|
||||
|
||||
async function loadToken() {
|
||||
if (cachedToken) return cachedToken;
|
||||
let stored;
|
||||
try {
|
||||
stored = await baoConf.get(PATH);
|
||||
} catch (err) {
|
||||
console.error(`[jump_client] could not read ${PATH} from OpenBao: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
if (!stored || !stored.token) return null;
|
||||
cachedToken = stored.token;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function jumpBaseUrl() {
|
||||
// Not OpenBao -- this is where jump-host's admin API lives, not a secret.
|
||||
return process.env.JUMP_INTERNAL_URL || '';
|
||||
}
|
||||
|
||||
// Returns { count, note }. count is null (not 0) when the query couldn't run
|
||||
// at all (not configured, unreachable, unauthorized) -- the modal shows a
|
||||
// count of gateways it could actually see, not a misleading "0" that reads
|
||||
// as "you have no mesh peers" when the truth is "this isn't wired up yet".
|
||||
async function getGatewayCount() {
|
||||
const base = jumpBaseUrl();
|
||||
if (!base) {
|
||||
return { count: null, note: 'skipped: JUMP_INTERNAL_URL not configured' };
|
||||
}
|
||||
const token = await loadToken();
|
||||
if (!token) {
|
||||
return { count: null, note: `skipped: no jump-host API token at OpenBao ${PATH} -- mint one on jump-host (as a jump-admin user) and store it there` };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(base.replace(/\/+$/, '') + '/api/mesh/gateways', {
|
||||
headers: { Authorization: 'Bearer ' + token },
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return { count: null, note: `failed: HTTP ${resp.status}` };
|
||||
}
|
||||
const body = await resp.json();
|
||||
const gateways = Array.isArray(body.gateways) ? body.gateways : [];
|
||||
return { count: gateways.length, note: 'ok' };
|
||||
} catch (err) {
|
||||
return { count: null, note: `failed: ${err.message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam.
|
||||
function _reset() { cachedToken = null; }
|
||||
|
||||
module.exports = { getGatewayCount, _reset, PATH };
|
||||
@@ -0,0 +1,44 @@
|
||||
'use strict';
|
||||
|
||||
// OpenLDAP multi-master replication (docs/replication.md) config derivation,
|
||||
// shared between routes/api_site.js (the spoke-facing side: assigns a
|
||||
// ServerID at registration, serves GET /api/site/ldap-peers) and
|
||||
// routes/api_directory_admin.js (the master-local side: GET
|
||||
// /directory-admin/ldap-replication-config computes the master's own
|
||||
// replication config from the same SiteSpoke registry, no HTTP round-trip
|
||||
// needed since it already has the data).
|
||||
|
||||
const { SiteSpoke } = require('../models/site_spoke');
|
||||
|
||||
// The master reserves ServerID 1 for itself; every spoke gets the lowest
|
||||
// free ID from 2 upward, assigned once at registration and reused across
|
||||
// re-registrations (SiteSpoke.ldapServerId is only ever set on first
|
||||
// create). Small max, matching mesh_gateway.js's mesh index -- nothing in
|
||||
// the OpenLDAP protocol requires a small ServerID, but this deployment's
|
||||
// docs/examples always have.
|
||||
const MAX_LDAP_SERVER_ID = 4094;
|
||||
|
||||
async function nextFreeLdapServerId() {
|
||||
const spokes = await SiteSpoke.list();
|
||||
const used = new Set(spokes.map((s) => s.ldapServerId).filter(Boolean));
|
||||
for (let i = 2; i <= MAX_LDAP_SERVER_ID; i++) {
|
||||
if (!used.has(i)) return i;
|
||||
}
|
||||
throw new Error(`LDAP server ID space exhausted (max ${MAX_LDAP_SERVER_ID} spokes)`);
|
||||
}
|
||||
|
||||
// A site's LDAP replication URL, derived from its already-known HTTP(S)
|
||||
// endpoint rather than requiring a separately-configured field: same
|
||||
// hostname, LDAPS port 636 -- exactly the convention docs/replication.md's
|
||||
// own worked examples already use (ldaps://sso.site2.com:636 alongside
|
||||
// https://sso.site2.com). No new config an operator has to keep in sync.
|
||||
function ldapHostFor(endpoint) {
|
||||
try {
|
||||
const host = new URL(endpoint).hostname;
|
||||
return `ldaps://${host}:636`;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { MAX_LDAP_SERVER_ID, nextFreeLdapServerId, ldapHostFor };
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// LDAP byte-pump relay (DESIGN.md §4). The agent forwards raw LDAP bytes from a
|
||||
// local socket (SSSD) over the WSS channel as `ldap_tunnel` messages; this
|
||||
// module relays them into the SSO's real OpenLDAP and pipes the responses back.
|
||||
// The SSO does not parse LDAP either — it is a transparent socket relay.
|
||||
|
||||
const net = require('net');
|
||||
const conf = require('@simpleworkjs/conf').ldap;
|
||||
|
||||
// Parse host:port from an ldap:// or ldaps:// URL. The relay connects plaintext
|
||||
// to the SSO's own slapd (which is plaintext on localhost); an ldaps:// URL
|
||||
// would need TLS termination here and is not supported yet (DESIGN.md §9.5).
|
||||
function ldapTarget() {
|
||||
const url = conf.url || 'ldap://localhost:389';
|
||||
const m = /^ldaps?:\/\/([^:/]+)(?::(\d+))?/.exec(url);
|
||||
const host = m ? m[1] : 'localhost';
|
||||
const port = m && m[2] ? Number(m[2]) : 389;
|
||||
return { host, port };
|
||||
}
|
||||
|
||||
// Per-agent relay state: agentId -> Map(conn_id -> LDAP socket).
|
||||
const relays = new Map();
|
||||
|
||||
function relayFor(agentId) {
|
||||
if (!relays.has(agentId)) relays.set(agentId, new Map());
|
||||
return relays.get(agentId);
|
||||
}
|
||||
|
||||
// Handle one ldap_tunnel message from an agent.
|
||||
function handleTunnel(agentId, ws, payload) {
|
||||
const connId = payload.conn_id;
|
||||
if (!connId) return;
|
||||
const conns = relayFor(agentId);
|
||||
|
||||
// End of connection: close the relay socket.
|
||||
if (payload.close) {
|
||||
const sock = conns.get(connId);
|
||||
if (sock) { sock.destroy(); conns.delete(connId); }
|
||||
return;
|
||||
}
|
||||
|
||||
const data = Buffer.from(payload.data || '', 'base64');
|
||||
if (data.length === 0) return;
|
||||
|
||||
let sock = conns.get(connId);
|
||||
if (!sock) {
|
||||
const { host, port } = ldapTarget();
|
||||
sock = net.connect(port, host);
|
||||
conns.set(connId, sock);
|
||||
|
||||
// Relay OpenLDAP's responses back to the agent.
|
||||
sock.on('data', (chunk) => {
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ldap_tunnel',
|
||||
payload: { conn_id: connId, data: chunk.toString('base64') }
|
||||
}));
|
||||
}
|
||||
});
|
||||
sock.on('close', () => {
|
||||
conns.delete(connId);
|
||||
if (ws.readyState === 1) {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ldap_tunnel',
|
||||
payload: { conn_id: connId, close: true }
|
||||
}));
|
||||
}
|
||||
});
|
||||
sock.on('error', () => { sock.destroy(); });
|
||||
}
|
||||
sock.write(data);
|
||||
}
|
||||
|
||||
// Drop every relay socket for an agent (on WSS disconnect).
|
||||
function cleanup(agentId) {
|
||||
const conns = relays.get(agentId);
|
||||
if (conns) {
|
||||
for (const sock of conns.values()) sock.destroy();
|
||||
relays.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { handleTunnel, cleanup };
|
||||
@@ -0,0 +1,105 @@
|
||||
'use strict';
|
||||
|
||||
// Service-to-service client for theta-proxy's Host management API --
|
||||
// MULTI_SITE_SPEC.md's "no-inbound relay automation" (a master creating a
|
||||
// relay route so a spoke with zero inbound path of its own is reachable).
|
||||
//
|
||||
// Deliberately does NOT invent a new credential type. theta-proxy already
|
||||
// has a self-service API token system (models/api_token.js, `prx_<id>_<secret>`
|
||||
// bearer tokens that authenticate as their creator's user + group snapshot --
|
||||
// same pattern this app and jump-host both already have their own copy of).
|
||||
// The "service-to-service auth" gap was never "no credential type exists" --
|
||||
// it's that nothing wired one of these tokens into an actual inter-service
|
||||
// call. This is that wiring, using the credential type that was already
|
||||
// there. The token itself is operator-provisioned (minted on theta-proxy by
|
||||
// an admin with Host-management rights) and stored in OpenBao, same as the
|
||||
// agent-signing key in agent_keys.js.
|
||||
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
|
||||
const PATH = 'integrations/theta-proxy'; // baoConf adds the secret/data prefix
|
||||
const REQUEST_TIMEOUT_MS = 10000;
|
||||
|
||||
let cachedToken = null;
|
||||
|
||||
async function loadToken() {
|
||||
if (cachedToken) return cachedToken;
|
||||
let stored;
|
||||
try {
|
||||
stored = await baoConf.get(PATH);
|
||||
} catch (err) {
|
||||
console.error(`[proxy_client] could not read ${PATH} from OpenBao: ${err.message}`);
|
||||
return null;
|
||||
}
|
||||
if (!stored || !stored.token) return null;
|
||||
cachedToken = stored.token;
|
||||
return cachedToken;
|
||||
}
|
||||
|
||||
function proxyBaseUrl() {
|
||||
// Not OpenBao -- this is where the proxy's admin API lives, not a secret.
|
||||
// No safe default: relaying to a guessed host would be worse than
|
||||
// refusing, so this must be explicitly configured.
|
||||
return process.env.PROXY_INTERNAL_URL || '';
|
||||
}
|
||||
|
||||
// Creates the relay Host route if missing, updates its target IP if it
|
||||
// already exists and points somewhere else. Idempotent -- safe to call
|
||||
// again for the same host on every spoke resync.
|
||||
//
|
||||
// Returns { note } describing what happened (created/updated/skipped/failed)
|
||||
// rather than throwing on a missing token or base URL -- callers (spoke
|
||||
// registration) must never fail the whole registration just because this
|
||||
// automation isn't configured yet; it's an enhancement layered on top of a
|
||||
// working join, not a requirement of one.
|
||||
async function ensureRelayRoute({ host, ip, targetPort }) {
|
||||
if (!host || !ip || !targetPort) {
|
||||
return { note: 'skipped: host, ip, and targetPort are all required' };
|
||||
}
|
||||
const base = proxyBaseUrl();
|
||||
if (!base) {
|
||||
return { note: 'skipped: PROXY_INTERNAL_URL not configured' };
|
||||
}
|
||||
const token = await loadToken();
|
||||
if (!token) {
|
||||
return { note: `skipped: no proxy API token at OpenBao ${PATH} -- mint one on theta-proxy and store it there` };
|
||||
}
|
||||
|
||||
const headers = { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json' };
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const existing = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
|
||||
headers, signal: controller.signal
|
||||
});
|
||||
|
||||
if (existing.status === 200) {
|
||||
// GET /api/host/:item wraps the record in { item, results }, not
|
||||
// flat -- confirmed against a real running proxy (this check
|
||||
// silently always "updated" instead of no-op'ing until fixed).
|
||||
const body = await existing.json();
|
||||
const current = body.results || body;
|
||||
if (current.ip === ip && Number(current.targetPort) === Number(targetPort)) {
|
||||
return { note: 'already up to date' };
|
||||
}
|
||||
const put = await fetch(base.replace(/\/+$/, '') + '/api/host/' + encodeURIComponent(host), {
|
||||
method: 'PUT', headers, body: JSON.stringify({ ip, targetPort }), signal: controller.signal
|
||||
});
|
||||
return put.ok ? { note: 'updated' } : { note: `update failed: HTTP ${put.status}` };
|
||||
}
|
||||
|
||||
const create = await fetch(base.replace(/\/+$/, '') + '/api/host', {
|
||||
method: 'POST', headers, body: JSON.stringify({ host, ip, targetPort }), signal: controller.signal
|
||||
});
|
||||
return create.ok ? { note: 'created' } : { note: `create failed: HTTP ${create.status}` };
|
||||
} catch (err) {
|
||||
return { note: `failed: ${err.message}` };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
// Test seam.
|
||||
function _reset() { cachedToken = null; }
|
||||
|
||||
module.exports = { ensureRelayRoute, _reset, PATH };
|
||||
@@ -0,0 +1,67 @@
|
||||
'use strict';
|
||||
|
||||
// Persisted multi-site role (MULTI_SITE_SPEC.md). Whether this node is the
|
||||
// master authority, which site it belongs to, and the master it replicates
|
||||
// from live in /config/site.json so they survive restarts (the old code kept
|
||||
// them in Node memory, so a container recreate silently reverted a spoke back
|
||||
// to "master").
|
||||
//
|
||||
// Boot-time defaults come from the environment (IS_MASTER / MASTER_URL /
|
||||
// SITE_SLUG, which docker-compose passes); a written site.json overrides for
|
||||
// the life of the deployment. site-promote and the site-join flow both write
|
||||
// here.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Overridable so tests can point at a temp file instead of /config/site.json.
|
||||
function configFile() {
|
||||
return process.env.SITE_CONFIG_FILE || '/config/site.json';
|
||||
}
|
||||
|
||||
function envDefaults() {
|
||||
return {
|
||||
isMaster: process.env.IS_MASTER ? process.env.IS_MASTER === 'true' : true,
|
||||
masterUrl: process.env.MASTER_URL || '',
|
||||
siteSlug: process.env.SITE_SLUG || 'site-default',
|
||||
wanConnected: true
|
||||
};
|
||||
}
|
||||
|
||||
let current = null;
|
||||
|
||||
function load() {
|
||||
const env = envDefaults();
|
||||
const file = configFile();
|
||||
try {
|
||||
if (fs.existsSync(file)) {
|
||||
const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return { ...env, ...saved };
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[site] could not read ' + file + ': ' + e.message);
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
// get returns the current site config.
|
||||
function get() {
|
||||
if (!current) current = load();
|
||||
return { ...current };
|
||||
}
|
||||
|
||||
// save merges a patch and persists it to the site config file.
|
||||
function save(patch) {
|
||||
current = { ...get(), ...patch };
|
||||
const file = configFile();
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
fs.writeFileSync(file, JSON.stringify(current, null, 2) + '\n');
|
||||
} catch (e) {
|
||||
console.error('[site] could not write ' + file + ': ' + e.message);
|
||||
throw e;
|
||||
}
|
||||
return get();
|
||||
}
|
||||
|
||||
module.exports = { get, save, configFile };
|
||||
@@ -0,0 +1,125 @@
|
||||
'use strict';
|
||||
|
||||
// Pure, testable helpers for the multi-site join flow (MULTI_SITE_SPEC.md).
|
||||
// routes/api_site.js wires these to Express + the live models + slapcat/ldapadd;
|
||||
// tests exercise importDirectory with in-memory model stubs.
|
||||
|
||||
// scalarResource reduces a Resource row to its scalar columns so it can be
|
||||
// re-created on a spoke without dragging hasMany relation fields along.
|
||||
function scalarResource(r) {
|
||||
const o = (r && r.toJSON) ? r.toJSON() : (r || {});
|
||||
return {
|
||||
id: o.id,
|
||||
kind: o.kind,
|
||||
name: o.name,
|
||||
slug: o.slug,
|
||||
owner: o.owner || null,
|
||||
description: o.description || null,
|
||||
metadata: o.metadata || {},
|
||||
created_by: o.created_by || null,
|
||||
created_on: o.created_on || null,
|
||||
updated_by: o.updated_by || null,
|
||||
updated_on: o.updated_on || null
|
||||
};
|
||||
}
|
||||
|
||||
function scalarEdge(e) {
|
||||
const o = (e && e.toJSON) ? e.toJSON() : (e || {});
|
||||
return {
|
||||
id: o.id,
|
||||
parentId: o.parentId,
|
||||
childId: o.childId,
|
||||
relation: o.relation
|
||||
};
|
||||
}
|
||||
|
||||
// importDirectory adopts a master's resource catalog into the local SQLite
|
||||
// store. Resources are upserted by slug (create if absent, update if a local
|
||||
// row already exists — the master is authoritative for the shared catalog),
|
||||
// then all edges are recreated. Model stubs are injected for testability.
|
||||
async function importDirectory({ Resource, ResourceEdge, exportData }) {
|
||||
const resources = (exportData && exportData.resources) || [];
|
||||
const edges = (exportData && exportData.edges) || [];
|
||||
|
||||
const bySlug = {};
|
||||
try {
|
||||
const existing = await Resource.list();
|
||||
(existing || []).forEach(r => { bySlug[r.slug] = r; });
|
||||
} catch (e) {
|
||||
// Resource.list is unavailable (fresh DB?) — treat as empty.
|
||||
}
|
||||
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const raw of resources) {
|
||||
const s = scalarResource(raw);
|
||||
if (!s.slug) continue;
|
||||
const local = bySlug[s.slug];
|
||||
if (local) {
|
||||
try { await Resource.update(local.id, s); updated++; } catch (e) { /* row raced; ignore */ }
|
||||
} else {
|
||||
try { await Resource.create(s); created++; } catch (e) { /* duplicate-slug race; ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
// Edges: clear + recreate so the graph matches the master exactly.
|
||||
try {
|
||||
const existingEdges = await ResourceEdge.list();
|
||||
for (const e of existingEdges || []) {
|
||||
await ResourceEdge.delete(e.id).catch(() => {});
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
let edgeCount = 0;
|
||||
for (const raw of edges) {
|
||||
const s = scalarEdge(raw);
|
||||
if (!s.parentId || !s.childId) continue;
|
||||
try { await ResourceEdge.create({ id: s.id, parentId: s.parentId, childId: s.childId, relation: s.relation || 'runs_on' }); edgeCount++; } catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
return { created, updated, edgeCount };
|
||||
}
|
||||
|
||||
// ldapAddArgs builds the argv for importing an LDIF into the local slapd with
|
||||
// the app's admin bind. `-c` continues past "entry already exists" (the spoke
|
||||
// keeps its own cn=admin / base DN).
|
||||
function ldapAddArgs({ bindDN, ldapCred, ldifFile, ldapUrl }) {
|
||||
return [
|
||||
'-c', '-x',
|
||||
'-H', ldapUrl || 'ldap://localhost',
|
||||
'-D', bindDN,
|
||||
'-w', ldapCred,
|
||||
'-f', ldifFile
|
||||
];
|
||||
}
|
||||
|
||||
// baseDnFrom derives the LDAP base DN from the app's admin bindDN
|
||||
// (cn=admin,dc=example,dc=com -> dc=example,dc=com) unless the stack config
|
||||
// already provides it (conf.stack.ldapBaseDn, written by setup.sh).
|
||||
function baseDnFrom(conf) {
|
||||
if (conf.stack && conf.stack.ldapBaseDn) return conf.stack.ldapBaseDn;
|
||||
const m = String((conf.ldap && conf.ldap.bindDN) || '').match(/^cn=[^,]+,(.+)$/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
// siteIsFresh reports whether this deployment may join a master site
|
||||
// (MULTI_SITE_SPEC.md): no users beyond the bootstrap admin and no enrolled
|
||||
// agents. The bootstrap always seeds a handful of default resources (site →
|
||||
// host → sso/proxy services), so resources are NOT the signal — the operator's
|
||||
// rule is "no users". A directory with real users must never be merged into a
|
||||
// master's; that is the destructive case this guard prevents.
|
||||
async function siteIsFresh({ User, Agent }) {
|
||||
const agents = (Agent && Agent.list ? await Agent.list().catch(() => []) : []);
|
||||
if (agents && agents.length > 0) return false;
|
||||
if (User && typeof User.listDetail === 'function') {
|
||||
try {
|
||||
const users = await User.listDetail();
|
||||
const real = (users || []).filter(u => !u.isServiceAccount);
|
||||
return real.length <= 1; // at most the bootstrap admin
|
||||
} catch (e) {
|
||||
// LDAP unreachable — fall back to the agent-only check.
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = { scalarResource, scalarEdge, importDirectory, ldapAddArgs, baseDnFrom, siteIsFresh };
|
||||
@@ -0,0 +1,84 @@
|
||||
'use strict';
|
||||
|
||||
// Live replication push -- the piece the shipped v1 join flow doesn't have on
|
||||
// its own (join is a one-time export/import snapshot; nothing kept a spoke in
|
||||
// sync afterward). This fires a lightweight "something changed, re-pull" ping
|
||||
// at every spoke registered in SiteSpoke, concurrently, fire-and-forget: never
|
||||
// awaited by its caller, and one unreachable spoke never delays or blocks
|
||||
// another. See MULTI_SITE_SPEC.md §2.2 for why this must never become a
|
||||
// blocking design (a write must never stall on spoke reachability).
|
||||
//
|
||||
// Deliberately a PUSH-A-SIGNAL / PULL-A-SNAPSHOT design, not a push-a-diff
|
||||
// design: the receiving spoke reacts by calling the master's already-shipped,
|
||||
// already-tested POST /api/site/export + importDirectory() path again (see
|
||||
// routes/api_site.js's /resync handler), rather than this module inventing a
|
||||
// second, parallel way to represent "what changed." Fewer moving parts, and
|
||||
// no risk of a diff payload and a full export ever disagreeing.
|
||||
|
||||
const { SiteSpoke } = require('../models/site_spoke');
|
||||
|
||||
const RESYNC_TIMEOUT_MS = 8000;
|
||||
|
||||
function replicateToSpokes(reason) {
|
||||
return (async () => {
|
||||
let spokes;
|
||||
try {
|
||||
spokes = await SiteSpoke.list();
|
||||
} catch (err) {
|
||||
console.error('[site-replicate] failed to list known spokes:', err.message);
|
||||
return;
|
||||
}
|
||||
for (const spoke of spokes) {
|
||||
// Not awaited -- every spoke is pushed to concurrently.
|
||||
pingOne(spoke, reason).catch((err) => {
|
||||
console.error(`[site-replicate] resync ping to ${spoke.endpoint} failed:`, err.message);
|
||||
});
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
// Cross-component routing (MULTI_SITE_SPEC.md): if this spoke reported a WG
|
||||
// mesh IP when it registered (utils/proxy_client.js's no-inbound relay path
|
||||
// populates the same field), prefer sending the resync push over the mesh
|
||||
// tunnel instead of the open internet -- plain HTTP is fine here since the
|
||||
// WG tunnel itself is already encrypted, same reasoning as the no-inbound
|
||||
// relay terminating at the master. Falls back to the spoke's public endpoint
|
||||
// if the mesh attempt fails (mesh IP set but that particular tunnel isn't
|
||||
// actually up yet, or unreachable for any other reason) -- never let a
|
||||
// mesh-routing preference turn into "spoke never gets updates."
|
||||
function resyncUrls(spoke) {
|
||||
const urls = [];
|
||||
if (spoke.meshIp) {
|
||||
let port = '3001';
|
||||
try { port = new URL(spoke.endpoint).port || port; } catch (_) { /* keep default */ }
|
||||
urls.push(`http://${spoke.meshIp}:${port}/api/site/resync`);
|
||||
}
|
||||
urls.push(String(spoke.endpoint).replace(/\/+$/, '') + '/api/site/resync');
|
||||
return urls;
|
||||
}
|
||||
|
||||
async function pingOne(spoke, reason) {
|
||||
const urls = resyncUrls(spoke);
|
||||
let lastErr;
|
||||
for (const url of urls) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), RESYNC_TIMEOUT_MS);
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: 'Bearer ' + spoke.pushToken, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason || 'catalog-changed' }),
|
||||
signal: controller.signal
|
||||
});
|
||||
if (!resp.ok) throw new Error('status ' + resp.status);
|
||||
return; // success -- don't try the next (fallback) URL
|
||||
} catch (err) {
|
||||
lastErr = err;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
module.exports = { replicateToSpokes, resyncUrls };
|
||||
@@ -43,8 +43,6 @@ module.exports = {
|
||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
// Vault requires login - per-user secrets at secret/users/<uid>/*.
|
||||
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
|
||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -72,16 +72,20 @@ async function bao(method, path, body) {
|
||||
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
||||
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||
async function ensurePolicy(name, hcl) {
|
||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||
if (existing.status !== 200 && existing.status !== 404) {
|
||||
const t = await existing.text().catch(() => '');
|
||||
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||
try {
|
||||
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||
if (existing.status === 200) {
|
||||
const body = await existing.json().catch(() => null);
|
||||
if (body && typeof body.policy === 'string' && body.policy.trim() === hcl.trim()) return; // unchanged
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`[VaultBroker] policy GET ${name} warning:`, e.message);
|
||||
}
|
||||
if (existing.status === 200) {
|
||||
const body = await existing.json().catch(() => null);
|
||||
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
||||
try {
|
||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||
} catch (err) {
|
||||
console.warn(`[VaultBroker] policy PUT ${name} warning:`, err.message);
|
||||
}
|
||||
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||
}
|
||||
|
||||
// Mint a token through a token role with the given policies. Returns
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
loadProxyConf();
|
||||
loadTos();
|
||||
loadMessagingPlugins();
|
||||
loadApps();
|
||||
});
|
||||
|
||||
async function loadConf() {
|
||||
@@ -302,6 +303,67 @@
|
||||
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function mintApp() {
|
||||
const errorEl = document.getElementById('app-error');
|
||||
errorEl.classList.add('d-none');
|
||||
const name = document.getElementById('app-name-input').value.trim();
|
||||
if (!name) {
|
||||
errorEl.textContent = 'App name is required';
|
||||
errorEl.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch('/api/vault/apps', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
|
||||
body: JSON.stringify({ name })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status} ${text}`);
|
||||
}
|
||||
const result = await res.json();
|
||||
document.getElementById('app-token').textContent = result.token;
|
||||
document.getElementById('app-result-card').classList.remove('d-none');
|
||||
loadApps();
|
||||
} catch (err) {
|
||||
errorEl.textContent = err.message;
|
||||
errorEl.classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadApps() {
|
||||
const $list = document.getElementById('apps-list');
|
||||
if (!$list) return;
|
||||
$list.innerHTML = '<div class="text-muted small p-2">Loading apps…</div>';
|
||||
try {
|
||||
const res = await fetch('/api/vault/apps', {
|
||||
headers: { 'auth-token': app.auth.getToken() }
|
||||
});
|
||||
if (!res.ok) { $list.innerHTML = '<div class="text-danger small p-2">Failed to load app tokens.</div>'; return; }
|
||||
const { apps = [] } = await res.json();
|
||||
if (!apps.length) { $list.innerHTML = '<div class="text-muted small p-2">No external app tokens minted yet.</div>'; return; }
|
||||
$list.innerHTML = '<div class="list-group list-group-flush">' + apps.map(a => {
|
||||
const ok = !a.lastError;
|
||||
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
|
||||
return `<div class="list-group-item d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
|
||||
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
|
||||
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
|
||||
</div>
|
||||
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
} catch (err) {
|
||||
$list.innerHTML = '<div class="text-danger small p-2">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function copyText(text) {
|
||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied to clipboard', 'success'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container mt-4">
|
||||
@@ -331,6 +393,11 @@
|
||||
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="apps-tab" data-bs-toggle="tab" data-bs-target="#pane-apps" type="button" role="tab">
|
||||
<i class="fas fa-key text-warning me-1"></i> App Tokens
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||
@@ -499,6 +566,50 @@
|
||||
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
|
||||
</div>
|
||||
|
||||
<!-- External App Tokens Tab -->
|
||||
<div class="tab-pane fade" id="pane-apps" role="tabpanel">
|
||||
<h5 class="fw-bold mb-3"><i class="fas fa-key text-warning me-2"></i> External App Tokens (OpenBao)</h5>
|
||||
<p class="text-muted small">Mint scoped OpenBao tokens for external microservices, scripts, and third-party tools (scoped to <code>secret/apps/<name>/*</code>).</p>
|
||||
<div class="row g-4">
|
||||
<div class="col-md-5">
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-light py-2"><h6 class="mb-0 fw-bold"><i class="fas fa-plus me-1"></i> Mint New App Token</h6></div>
|
||||
<div class="card-body p-3">
|
||||
<p class="text-muted small">Mints a periodic OpenBao token. The token will be displayed <strong>once</strong>.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">App Name</label>
|
||||
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. build-agent">
|
||||
<div class="form-text">Use lowercase letters, numbers, and hyphens.</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-sm" onclick="mintApp()"><i class="fas fa-key me-1"></i> Mint Token</button>
|
||||
<div class="alert alert-danger d-none mt-3 mb-0" id="app-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="card border shadow-sm d-none mb-3" id="app-result-card">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
|
||||
<h6 class="mb-0 fw-bold text-success"><i class="fas fa-check-circle me-1"></i> Generated Token</h6>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy me-1"></i> Copy</button>
|
||||
</div>
|
||||
<div class="card-body p-3">
|
||||
<p class="small text-muted mb-2">Include this token in HTTP header <code>X-Vault-Token</code>:</p>
|
||||
<pre id="app-token" class="bg-dark text-light p-3 rounded small font-monospace mb-0 select-all"></pre>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card border shadow-sm">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center py-2">
|
||||
<h6 class="mb-0 fw-bold"><i class="fa-solid fa-list me-1"></i> Active App Tokens</h6>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="loadApps()"><i class="fas fa-rotate me-1"></i> Refresh</button>
|
||||
</div>
|
||||
<div class="card-body p-0" id="apps-list">
|
||||
<div class="text-muted small p-3">Loading apps…</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Terms of Service Tab -->
|
||||
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
|
||||
@@ -206,8 +206,8 @@
|
||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||
+ '<div class="card-body">'
|
||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||
+ iconHtml
|
||||
+ '<span>' + esc(r.name) + '</span>'
|
||||
+ iconHtml
|
||||
+ '</h5>'
|
||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||
|
||||
@@ -142,16 +142,37 @@
|
||||
if (!includeSecrets && f.secret) return;
|
||||
var val = v[f.key];
|
||||
if (f.secret) val = '';
|
||||
if (val === undefined || val === null) val = '';
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
var req = f.required ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||
html += '<div class="mb-3">' +
|
||||
'<label class="form-label">' + label + '</label>' +
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '"') + '"' + req + ph + '>';
|
||||
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
|
||||
html += '</div>';
|
||||
|
||||
if (f.type === 'boolean' || f.type === 'checkbox' || f.key === 'autoPromote') {
|
||||
var isChecked = val === true || val === 'true' || val === 1 || val === '1' || (val === undefined && f.default !== false);
|
||||
html += '<div class="mb-3 form-check">' +
|
||||
'<input type="checkbox" class="form-check-input" id="' + prefix + f.key + '"' + (isChecked ? ' checked' : '') + '>' +
|
||||
'<label class="form-check-label fw-bold" for="' + prefix + f.key + '">' + label + '</label>' +
|
||||
'</div>';
|
||||
} else if (f.type === 'site_select' || f.key === 'location') {
|
||||
var selectedVal = String(val || f.default || '').trim();
|
||||
var sites = window.availableSites || [];
|
||||
var siteOpts = '<option value="">(Default Site)</option>';
|
||||
sites.forEach(function(s) {
|
||||
var sel = (s.name === selectedVal || s.slug === selectedVal || (!selectedVal && s.slug === 'site-default')) ? ' selected' : '';
|
||||
siteOpts += '<option value="' + app.util.escapeHtml(s.name) + '"' + sel + '>' + app.util.escapeHtml(s.name) + ' (' + app.util.escapeHtml(s.slug) + ')</option>';
|
||||
});
|
||||
html += '<div class="mb-3">' +
|
||||
'<label class="form-label fw-bold">' + label + '</label>' +
|
||||
'<select class="form-select" id="' + prefix + f.key + '">' + siteOpts + '</select>' +
|
||||
'</div>';
|
||||
} else {
|
||||
if (val === undefined || val === null) val = '';
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
var req = f.required ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||
html += '<div class="mb-3">' +
|
||||
'<label class="form-label fw-bold">' + label + '</label>' +
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '"') + '"' + req + ph + '>';
|
||||
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
|
||||
html += '</div>';
|
||||
}
|
||||
});
|
||||
return html;
|
||||
}
|
||||
@@ -210,7 +231,13 @@
|
||||
if (!schema) return out;
|
||||
schema.forEach(function(f) {
|
||||
var el = document.getElementById(prefix + f.key);
|
||||
if (el) out[f.key] = el.value;
|
||||
if (el) {
|
||||
if (f.type === 'boolean' || f.type === 'checkbox' || el.type === 'checkbox') {
|
||||
out[f.key] = el.checked;
|
||||
} else {
|
||||
out[f.key] = el.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end test of the real, shipped multi-site join flow (docs/site-join.md).
|
||||
// Drives the actual HTTP API two humans (a master admin + a spoke admin)
|
||||
// would use: seed an admin on each side, mint a site join key on master,
|
||||
// have the spoke adopt it, and verify the post-join contract holds.
|
||||
|
||||
const { execFileSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Wrapper matching the async call sites below (execFileSync throws
|
||||
// synchronously; wrap in a resolved/rejected promise so callers can keep
|
||||
// using await/.catch()). NOTE: plain execFile (async) does NOT support the
|
||||
// `input` option for piping stdin -- only the *Sync variants do -- so
|
||||
// ldapadd/ldapmodify would otherwise hang forever waiting on stdin that never
|
||||
// arrives. This bit us once already; don't switch back to async execFile here
|
||||
// without adding real stdin piping.
|
||||
function execFileAsync(cmd, args, opts) {
|
||||
try {
|
||||
const stdout = execFileSync(cmd, args, { ...opts, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
return Promise.resolve({ stdout: stdout ? stdout.toString() : '' });
|
||||
} catch (e) {
|
||||
e.stderr = e.stderr ? e.stderr.toString() : '';
|
||||
return Promise.reject(e);
|
||||
}
|
||||
}
|
||||
|
||||
const MASTER_URL = process.env.MASTER_URL || 'http://master:3001';
|
||||
const SPOKE_URL = process.env.SPOKE_URL || 'http://spoke:3001';
|
||||
const MASTER_LDAP_HOST = process.env.MASTER_LDAP_HOST || 'master';
|
||||
const MASTER_BASE_DN = process.env.MASTER_BASE_DN || 'dc=master,dc=test';
|
||||
const SPOKE_LDAP_HOST = process.env.SPOKE_LDAP_HOST || 'spoke';
|
||||
const SPOKE_BASE_DN = process.env.SPOKE_BASE_DN || 'dc=spoke,dc=test';
|
||||
const LDAP_ADMIN_PASS = process.env.LDAP_ADMIN_PASS || 'secret';
|
||||
const ADMIN_UID = 'e2eadmin';
|
||||
const ADMIN_PASSWORD = 'MultiSiteE2E!2';
|
||||
|
||||
let failed = false;
|
||||
function fail(msg) {
|
||||
console.error('MULTISITE E2E FAIL:', msg);
|
||||
failed = true;
|
||||
}
|
||||
function step(msg) {
|
||||
console.log('--- ' + msg);
|
||||
}
|
||||
|
||||
async function waitForHealthy(url, label) {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const r = await fetch(`${url}/health`);
|
||||
if (r.ok) return;
|
||||
} catch (_) { /* not up yet */ }
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
}
|
||||
throw new Error(`${label} never became healthy`);
|
||||
}
|
||||
|
||||
// Seed an admin user directly via ldapadd/ldapmodify -- mirrors
|
||||
// test/seed-test-user.sh, but parameterized per-site since master and spoke
|
||||
// have distinct base DNs in this harness.
|
||||
async function seedAdmin(ldapHost, baseDn) {
|
||||
const salt = crypto.randomBytes(8);
|
||||
const digest = crypto.createHash('sha512').update(ADMIN_PASSWORD).update(salt).digest();
|
||||
const hash = '{SSHA512}' + Buffer.concat([digest, salt]).toString('base64');
|
||||
|
||||
const ldif = `
|
||||
dn: cn=${ADMIN_UID},ou=groups,${baseDn}
|
||||
objectClass: posixGroup
|
||||
objectClass: top
|
||||
cn: ${ADMIN_UID}
|
||||
gidNumber: 1600
|
||||
|
||||
dn: cn=${ADMIN_UID},ou=people,${baseDn}
|
||||
objectClass: inetOrgPerson
|
||||
objectClass: posixAccount
|
||||
objectClass: top
|
||||
objectClass: theta42Person
|
||||
objectClass: ldapPublicKey
|
||||
objectClass: sudoRole
|
||||
cn: ${ADMIN_UID}
|
||||
sn: E2E
|
||||
uid: ${ADMIN_UID}
|
||||
uidNumber: 1600
|
||||
gidNumber: 1600
|
||||
homeDirectory: /home/${ADMIN_UID}
|
||||
loginShell: /bin/bash
|
||||
mail: ${ADMIN_UID}@test.local
|
||||
userPassword: ${hash}
|
||||
`.trim() + '\n';
|
||||
|
||||
const bindDn = `cn=admin,${baseDn}`;
|
||||
await execFileAsync('ldapadd', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS], { input: ldif })
|
||||
.catch((e) => { if (!/Already exists/.test(e.stderr || '')) throw e; });
|
||||
|
||||
// god_admin is needed for site-promote (SUPER_ADMIN_GROUP, utils/permission.js).
|
||||
for (const group of ['app_sso_admin', 'god_admin']) {
|
||||
const modLdif = `dn: cn=${group},ou=groups,${baseDn}\nchangetype: modify\nadd: member\nmember: cn=${ADMIN_UID},ou=people,${baseDn}\n`;
|
||||
try {
|
||||
await execFileAsync('ldapmodify', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS], { input: modLdif });
|
||||
console.log(` (added ${ADMIN_UID} to ${group} on ${ldapHost})`);
|
||||
} catch (e) {
|
||||
if (!/[Tt]ype or value exists/.test(e.stderr || '')) {
|
||||
console.error(` FAILED adding ${ADMIN_UID} to ${group} on ${ldapHost}: ${e.stderr || e.message}`);
|
||||
throw e;
|
||||
}
|
||||
console.log(` (${ADMIN_UID} already in ${group} on ${ldapHost})`);
|
||||
}
|
||||
}
|
||||
|
||||
const verify = await execFileAsync('ldapsearch', ['-x', '-H', `ldap://${ldapHost}:389`, '-D', bindDn, '-w', LDAP_ADMIN_PASS,
|
||||
'-b', `cn=god_admin,ou=groups,${baseDn}`, 'member']);
|
||||
console.log(` god_admin members on ${ldapHost}:\n${verify.stdout}`);
|
||||
}
|
||||
|
||||
async function login(url) {
|
||||
const r = await fetch(`${url}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ uid: ADMIN_UID, password: ADMIN_PASSWORD })
|
||||
});
|
||||
if (!r.ok) throw new Error(`login at ${url} failed: ${r.status} ${await r.text()}`);
|
||||
const body = await r.json();
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function api(url, path, { method = 'GET', token, body } = {}) {
|
||||
const r = await fetch(`${url}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { 'auth-token': token } : {})
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined
|
||||
});
|
||||
const text = await r.text();
|
||||
let json;
|
||||
try { json = JSON.parse(text); } catch (_) { json = { raw: text }; }
|
||||
return { status: r.status, body: json };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
step('Waiting for master + spoke to be healthy');
|
||||
await waitForHealthy(MASTER_URL, 'master');
|
||||
await waitForHealthy(SPOKE_URL, 'spoke');
|
||||
|
||||
step('Seeding admin users in both sites\' LDAP');
|
||||
await seedAdmin(MASTER_LDAP_HOST, MASTER_BASE_DN);
|
||||
await seedAdmin(SPOKE_LDAP_HOST, SPOKE_BASE_DN);
|
||||
|
||||
step('Logging in as admin on master and spoke');
|
||||
const masterToken = await login(MASTER_URL);
|
||||
const spokeToken = await login(SPOKE_URL);
|
||||
if (!masterToken) fail('no token from master login');
|
||||
if (!spokeToken) fail('no token from spoke login');
|
||||
|
||||
step('Confirming both sites start as master (fresh installs)');
|
||||
{
|
||||
const { body } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
|
||||
if (body.config.isMaster !== true) fail(`expected master to start isMaster:true, got ${JSON.stringify(body.config)}`);
|
||||
}
|
||||
{
|
||||
const { body } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
|
||||
if (body.config.isMaster !== true) fail(`expected spoke to start isMaster:true (pre-join), got ${JSON.stringify(body.config)}`);
|
||||
}
|
||||
|
||||
step('Creating a resource on master BEFORE join, to verify it gets adopted');
|
||||
// Only site resources can be top-level; a host needs a parent site.
|
||||
const siteRes = await api(MASTER_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: masterToken,
|
||||
body: { name: 'E2E Site', slug: 'site_e2e', kind: 'site' }
|
||||
});
|
||||
if (siteRes.status !== 200) fail(`seeding pre-join site on master failed: ${siteRes.status} ${JSON.stringify(siteRes.body)}`);
|
||||
|
||||
const seedRes = await api(MASTER_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: masterToken,
|
||||
body: { name: 'E2E Pre-Join Host', slug: 'host_e2e_prejoin', kind: 'host', parentSlug: 'site_e2e' }
|
||||
});
|
||||
if (seedRes.status !== 200) fail(`seeding pre-join resource on master failed: ${seedRes.status} ${JSON.stringify(seedRes.body)}`);
|
||||
|
||||
step('Minting a site join key on master');
|
||||
const keyRes = await api(MASTER_URL, '/api/site/join-keys', {
|
||||
method: 'POST',
|
||||
token: masterToken,
|
||||
body: { label: 'e2e-test' }
|
||||
});
|
||||
if (keyRes.status !== 200 || !keyRes.body.key) fail(`join-key mint failed: ${keyRes.status} ${JSON.stringify(keyRes.body)}`);
|
||||
const joinKey = keyRes.body.key;
|
||||
|
||||
step('Joining spoke to master (with selfUrl, to register for live replication)');
|
||||
const joinRes = await api(SPOKE_URL, '/api/site/join', {
|
||||
method: 'POST',
|
||||
token: spokeToken,
|
||||
// master's own container-internal URL, as the spoke would reach it over the network
|
||||
body: { masterUrl: 'http://master:3001', joinKey, selfUrl: 'http://spoke:3001' }
|
||||
});
|
||||
if (joinRes.status !== 200) fail(`join failed: ${joinRes.status} ${JSON.stringify(joinRes.body)}`);
|
||||
if (!joinRes.body.replication || joinRes.body.replication.live !== true) {
|
||||
fail(`expected join to register for live replication, got ${JSON.stringify(joinRes.body.replication)}`);
|
||||
}
|
||||
|
||||
step('Verifying spoke persisted isMaster:false + masterUrl after join');
|
||||
const { body: spokeCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
|
||||
if (spokeCfg.config.isMaster !== false) fail(`spoke should be isMaster:false after join, got ${JSON.stringify(spokeCfg.config)}`);
|
||||
if (!spokeCfg.config.masterUrl) fail('spoke should have masterUrl set after join');
|
||||
|
||||
step('Verifying the master computed its own LDAP replication config (ServerID 1 + the new spoke as a peer)');
|
||||
const { body: masterLdapCfg } = await api(MASTER_URL, '/api/directory-admin/ldap-replication-config', { token: masterToken });
|
||||
if (masterLdapCfg.ldapServerId !== 1) fail(`master's own ldapServerId should be 1, got ${JSON.stringify(masterLdapCfg)}`);
|
||||
const spokePeer = (masterLdapCfg.peers || []).find(p => p.ldapHost === 'ldaps://spoke:636');
|
||||
if (!spokePeer || typeof spokePeer.ldapServerId !== 'number') {
|
||||
fail(`master's peer list should include the spoke at ldaps://spoke:636 with an assigned ldapServerId, got ${JSON.stringify(masterLdapCfg.peers)}`);
|
||||
}
|
||||
|
||||
step('Verifying the spoke can fetch its own assigned LDAP ServerID + peer list from the master');
|
||||
const spokeLdapPeersResp = await fetch(`${MASTER_URL}/api/site/ldap-peers?endpoint=${encodeURIComponent('http://spoke:3001')}`, {
|
||||
headers: { Authorization: 'Bearer ' + joinKey }
|
||||
});
|
||||
const spokeLdapCfg = await spokeLdapPeersResp.json();
|
||||
if (spokeLdapPeersResp.status !== 200) fail(`GET /api/site/ldap-peers failed: ${spokeLdapPeersResp.status} ${JSON.stringify(spokeLdapCfg)}`);
|
||||
if (spokeLdapCfg.ldapServerId !== spokePeer.ldapServerId) {
|
||||
fail(`spoke's own reported ldapServerId (${spokeLdapCfg.ldapServerId}) should match what the master's peer list assigned it (${spokePeer.ldapServerId})`);
|
||||
}
|
||||
const masterAsPeer = (spokeLdapCfg.peers || []).find(p => p.ldapServerId === 1);
|
||||
if (!masterAsPeer || masterAsPeer.ldapHost !== 'ldaps://master:636') {
|
||||
fail(`spoke's peer list should include the master (ServerID 1, ldaps://master:636), got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||
}
|
||||
const selfInOwnPeerList = (spokeLdapCfg.peers || []).some(p => p.ldapServerId === spokeLdapCfg.ldapServerId);
|
||||
if (selfInOwnPeerList) fail(`spoke's own peer list should not include itself, got ${JSON.stringify(spokeLdapCfg.peers)}`);
|
||||
|
||||
step('Verifying the spoke adopted the master\'s pre-join catalog');
|
||||
const spokeResources = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
||||
const adopted = (spokeResources.body.results || spokeResources.body.resources || spokeResources.body || []);
|
||||
const found = Array.isArray(adopted) && adopted.some(r => r.slug === 'host_e2e_prejoin');
|
||||
if (!found) fail(`spoke did not adopt master's pre-join resource; got slugs=${JSON.stringify((adopted || []).map(r => r.slug))}`);
|
||||
|
||||
step('Verifying spoke is now read-only (write attempt must 403)');
|
||||
const writeAttempt = await api(SPOKE_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: spokeToken,
|
||||
body: { name: 'Should Be Rejected', slug: 'host_e2e_should_reject', kind: 'host' }
|
||||
});
|
||||
if (writeAttempt.status !== 403) fail(`expected 403 writing to spoke post-join, got ${writeAttempt.status} ${JSON.stringify(writeAttempt.body)}`);
|
||||
|
||||
step('Creating a resource on master AFTER join, to verify LIVE replication (not just the one-time join snapshot)');
|
||||
const postJoinRes = await api(MASTER_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: masterToken,
|
||||
body: { name: 'E2E Post-Join Host', slug: 'host_e2e_postjoin', kind: 'host', parentSlug: 'site_e2e' }
|
||||
});
|
||||
if (postJoinRes.status !== 200) fail(`creating post-join resource on master failed: ${postJoinRes.status} ${JSON.stringify(postJoinRes.body)}`);
|
||||
|
||||
step('Waiting for the fire-and-forget resync push to reach the spoke');
|
||||
let liveReplicated = false;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const r = await api(SPOKE_URL, '/api/directory-admin/resources', { token: spokeToken });
|
||||
const slugs = (r.body.results || r.body.resources || r.body || []).map((x) => x.slug);
|
||||
if (slugs.includes('host_e2e_postjoin')) { liveReplicated = true; break; }
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
}
|
||||
if (!liveReplicated) fail('post-join resource never appeared on the spoke -- live replication did not fire (or resync did not apply it)');
|
||||
|
||||
step('Verifying WAN health ping from spoke to master succeeds');
|
||||
const statusRes = await api(SPOKE_URL, '/api/directory-admin/site-status', { token: spokeToken });
|
||||
if (statusRes.body.config && statusRes.body.config.wanConnected !== true) {
|
||||
fail(`expected spoke to report wanConnected:true post-join, got ${JSON.stringify(statusRes.body.config)}`);
|
||||
}
|
||||
|
||||
step('Verifying master itself is unaffected (still isMaster:true, no writes blocked)');
|
||||
const { body: masterCfg } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
|
||||
if (masterCfg.config.isMaster !== true) fail('master flipped away from isMaster:true unexpectedly');
|
||||
|
||||
step('Promoting the spoke to master (coordinated handoff -- must demote the old master too)');
|
||||
const promoteRes = await api(SPOKE_URL, '/api/directory-admin/site-promote', {
|
||||
method: 'POST',
|
||||
token: spokeToken,
|
||||
body: { selfUrl: 'http://spoke:3001' }
|
||||
});
|
||||
if (promoteRes.status !== 200) fail(`promotion failed: ${promoteRes.status} ${JSON.stringify(promoteRes.body)}`);
|
||||
if (promoteRes.body.handoff !== 'previous master demoted') {
|
||||
fail(`expected the old master to be demoted as part of promotion, got handoff=${JSON.stringify(promoteRes.body.handoff)}`);
|
||||
}
|
||||
|
||||
step('Verifying the newly-promoted node is master');
|
||||
const { body: newMasterCfg } = await api(SPOKE_URL, '/api/site/config', { token: spokeToken });
|
||||
if (newMasterCfg.config.isMaster !== true) fail(`newly-promoted node should be isMaster:true, got ${JSON.stringify(newMasterCfg.config)}`);
|
||||
|
||||
step('Verifying the old master was actually demoted to a spoke of the new master');
|
||||
const { body: oldMasterCfg } = await api(MASTER_URL, '/api/site/config', { token: masterToken });
|
||||
if (oldMasterCfg.config.isMaster !== false) fail(`old master should be isMaster:false after being demoted, got ${JSON.stringify(oldMasterCfg.config)}`);
|
||||
if (oldMasterCfg.config.masterUrl !== 'http://spoke:3001') {
|
||||
fail(`old master's masterUrl should now point at the new master, got ${JSON.stringify(oldMasterCfg.config.masterUrl)}`);
|
||||
}
|
||||
|
||||
step('Verifying the (now-demoted) old master rejects writes, and the new master accepts them');
|
||||
const oldMasterWrite = await api(MASTER_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: masterToken,
|
||||
body: { name: 'Should Be Rejected Post-Demotion', slug: 'host_e2e_should_reject_2', kind: 'host' }
|
||||
});
|
||||
if (oldMasterWrite.status !== 403) fail(`expected 403 writing to the demoted old master, got ${oldMasterWrite.status} ${JSON.stringify(oldMasterWrite.body)}`);
|
||||
|
||||
const newMasterWrite = await api(SPOKE_URL, '/api/directory-admin/resources', {
|
||||
method: 'POST',
|
||||
token: spokeToken,
|
||||
body: { name: 'E2E Post-Promotion Host', slug: 'host_e2e_postpromotion', kind: 'host', parentSlug: 'site_e2e' }
|
||||
});
|
||||
if (newMasterWrite.status !== 200) fail(`expected the newly-promoted master to accept writes, got ${newMasterWrite.status} ${JSON.stringify(newMasterWrite.body)}`);
|
||||
|
||||
if (failed) {
|
||||
console.error('MULTISITE E2E: one or more checks failed (see above)');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('MULTISITE E2E PASS');
|
||||
}
|
||||
|
||||
main().catch((e) => {
|
||||
console.error('MULTISITE E2E FAIL (exception):', e.stack || e.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
// End-to-end test of the LDAP byte-pump tunnel (DESIGN.md §4).
|
||||
//
|
||||
// Simulates the agent: enrolls one, connects to the SSO WSS with its token,
|
||||
// sends a real LDAP bind request as raw bytes in an `ldap_tunnel` message, and
|
||||
// verifies the SSO relays it into OpenLDAP and pipes the bind response back.
|
||||
// This proves the SSO side of the tunnel without needing the agent binary.
|
||||
|
||||
const WebSocket = require('ws');
|
||||
|
||||
const SSO_URL = process.env.SSO_URL || 'http://sso:3001';
|
||||
const WS_URL = SSO_URL.replace(/^http/, 'ws') + '/api/agent/ws';
|
||||
const TEST_CREDS = { uid: 'test', password: 'MyTestPassword!2' };
|
||||
const USER_DN = 'cn=test,ou=people,dc=test,dc=local';
|
||||
|
||||
function fail(msg) { console.error('E2E FAIL:', msg); process.exit(1); }
|
||||
|
||||
async function waitForSso() {
|
||||
for (let i = 0; i < 60; i++) {
|
||||
try {
|
||||
const r = await fetch(`${SSO_URL}/health`);
|
||||
if (r.ok) return;
|
||||
} catch (_) {}
|
||||
await new Promise((res) => setTimeout(res, 1000));
|
||||
}
|
||||
fail('SSO never became ready');
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const r = await fetch(`${SSO_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(TEST_CREDS),
|
||||
});
|
||||
if (!r.ok) fail(`login failed: ${r.status}`);
|
||||
const body = await r.json();
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function enrollAgent(authToken) {
|
||||
const r = await fetch(`${SSO_URL}/api/agent/enroll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'auth-token': authToken },
|
||||
body: JSON.stringify({ name: `e2e-${Date.now().toString(36)}` }),
|
||||
});
|
||||
if (!r.ok) fail(`enroll failed: ${r.status}`);
|
||||
const body = await r.json();
|
||||
return body.token;
|
||||
}
|
||||
|
||||
// Build a simple LDAP bind request (version 3) as raw BER bytes.
|
||||
function buildBindRequest(dn, password) {
|
||||
const dnBuf = Buffer.from(dn, 'utf8');
|
||||
const pwBuf = Buffer.from(password, 'utf8');
|
||||
const version = Buffer.from([0x02, 0x01, 0x03]);
|
||||
const name = Buffer.concat([Buffer.from([0x04, dnBuf.length]), dnBuf]);
|
||||
const simple = Buffer.concat([Buffer.from([0x80, pwBuf.length]), pwBuf]);
|
||||
const bindContent = Buffer.concat([version, name, simple]);
|
||||
const bindReq = Buffer.concat([Buffer.from([0x60, bindContent.length]), bindContent]);
|
||||
const msgId = Buffer.from([0x02, 0x01, 0x01]);
|
||||
const msgContent = Buffer.concat([msgId, bindReq]);
|
||||
return Buffer.concat([Buffer.from([0x30, msgContent.length]), msgContent]);
|
||||
}
|
||||
|
||||
// A successful bind response is a BindResponse (0x61) with resultCode 0 (0x0a 01 00).
|
||||
function isSuccessBindResponse(buf) {
|
||||
return buf.includes(Buffer.from([0x61])) && buf.includes(Buffer.from([0x0a, 0x01, 0x00]));
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await waitForSso();
|
||||
const authToken = await login();
|
||||
const agentToken = await enrollAgent(authToken);
|
||||
console.log('E2E: enrolled agent, connecting WSS...');
|
||||
|
||||
const ws = new WebSocket(`${WS_URL}?token=${agentToken}`);
|
||||
await new Promise((res, rej) => { ws.on('open', res); ws.on('error', rej); });
|
||||
console.log('E2E: WSS connected');
|
||||
|
||||
const bindBytes = buildBindRequest(USER_DN, TEST_CREDS.password);
|
||||
ws.send(JSON.stringify({
|
||||
type: 'ldap_tunnel',
|
||||
payload: { conn_id: 'e2e-1', data: bindBytes.toString('base64') },
|
||||
}));
|
||||
console.log('E2E: sent bind request bytes');
|
||||
|
||||
const result = await new Promise((res, rej) => {
|
||||
const timeout = setTimeout(() => rej(new Error('timed out waiting for bind response')), 10000);
|
||||
ws.on('message', (data) => {
|
||||
let msg;
|
||||
try { msg = JSON.parse(data); } catch (_) { return; }
|
||||
if (msg.type !== 'ldap_tunnel') return;
|
||||
if (msg.payload.close) return;
|
||||
const buf = Buffer.from(msg.payload.data || '', 'base64');
|
||||
if (isSuccessBindResponse(buf)) {
|
||||
clearTimeout(timeout);
|
||||
res({ ok: true, bytes: buf.length });
|
||||
}
|
||||
});
|
||||
ws.on('error', (e) => { clearTimeout(timeout); rej(e); });
|
||||
});
|
||||
|
||||
console.log(`E2E: got successful bind response (${result.bytes} bytes)`);
|
||||
ws.close();
|
||||
console.log('E2E PASS');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((e) => fail(e.message));
|
||||