Compare commits
52 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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:
|
||||
|
||||
+129
@@ -1,3 +1,132 @@
|
||||
# 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
|
||||
|
||||
+17
-60
@@ -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
|
||||
@@ -49,6 +50,8 @@ 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,30 +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),
|
||||
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — 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,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
|
||||
+19
-6
@@ -6,12 +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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`):
|
||||
|
||||
@@ -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.
|
||||
+9
-37
@@ -10,48 +10,20 @@ description: OpenBao-backed personal, shared, and external-app secret storage bu
|
||||
|
||||
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 a real, initialized-and-unsealed OpenBao backend
|
||||
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
|
||||
mode, which auto-unseals with an in-memory store and loses everything on
|
||||
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
|
||||
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
|
||||
way as the rest of the app (session cookie or a personal API token) — the
|
||||
server resolves your OpenBao access itself and injects the right scoped
|
||||
token; you never see or handle a raw OpenBao token as a UI user.
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -4,4 +4,7 @@ module.exports = {
|
||||
redis: {
|
||||
prefix: 'sso_manager_test_'
|
||||
},
|
||||
oauth: {
|
||||
jwtSecret: 'test-jwt-secret-for-automated-tests-only'
|
||||
}
|
||||
};
|
||||
|
||||
Binary file not shown.
@@ -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.
|
||||
|
||||
@@ -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
|
||||
]
|
||||
});
|
||||
|
||||
@@ -195,11 +195,12 @@ class Resource extends Model {
|
||||
// Walk all parent ResourceEdges upwards recursively to find all ancestor
|
||||
// resources (Host, Cluster, Site, etc.).
|
||||
static async findAllAncestors(resourceId, visited = new Set()) {
|
||||
if (visited.has(resourceId)) return [];
|
||||
if (!resourceId || visited.has(resourceId)) return [];
|
||||
visited.add(resourceId);
|
||||
|
||||
const ancestors = [];
|
||||
const parentEdges = await ResourceEdge.list({ where: { childId: resourceId } }).catch(() => []);
|
||||
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;
|
||||
|
||||
@@ -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,52 @@
|
||||
'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' }
|
||||
};
|
||||
|
||||
toPublic() {
|
||||
const data = this.toJSON ? this.toJSON() : { ...this };
|
||||
delete data.pushToken;
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { SiteSpoke };
|
||||
Generated
+5
-5
@@ -1,18 +1,18 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.2",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.0.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.30.2",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.0.4",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.1",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.31.0",
|
||||
"name": "t42-theta-directory",
|
||||
"version": "2.5.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 --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) => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
#!/bin/sh
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
# --- Configuration ---
|
||||
BINARY_URL="${BINARY_URL:-}"
|
||||
# In a real environment, these would be derived from the script's download URL
|
||||
# or passed as additional arguments. For now, we use the most recent release.
|
||||
BINARY_URL="https://github.com/theta42/theta-agent/releases/latest/download/theta-agent-linux-amd64"
|
||||
CONFIG_DIR="/etc/theta42"
|
||||
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||
BIN_PATH="/usr/local/bin/theta-agent"
|
||||
@@ -13,8 +15,8 @@ RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
log() { echo "${GREEN}[+]${NC} $1"; }
|
||||
error() { echo "${RED}[!]${NC} $1"; exit 1; }
|
||||
log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||
|
||||
# 1. Root check
|
||||
if [ "$(id -u 2>/dev/null || echo 1)" -ne 0 ]; then
|
||||
@@ -66,10 +68,17 @@ 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
|
||||
@@ -88,27 +97,53 @@ done
|
||||
# Validation: require credentials ONLY if config file does not already exist
|
||||
if [ ! -f "$CONFIG_FILE" ] && [ -z "$B64_CONFIG" ] && { [ -z "$URL" ] || { [ -z "$TOKEN" ] && [ -z "$JOIN_KEY" ]; }; }; then
|
||||
error "Missing required configuration. Provide a base64 encoded config, or --url with either --join-key or --token."
|
||||
echo "Usage examples:"
|
||||
echo " sh install.sh \"BASE64_CONFIG\""
|
||||
echo " sh install.sh --url \"https://sso.local\" --join-key \"tjk_...\" --install-sssd"
|
||||
echo " sh install.sh --url \"https://sso.local\" --token \"ISSUED_TOKEN\" --public-key \"BASE64_KEY\""
|
||||
echo ""
|
||||
echo "--join-key is the normal path: the host enrolls itself on first connect"
|
||||
echo "and the SSO issues it its own token + public key, which the agent writes"
|
||||
echo "back into agent.yml. Get a key from Directory -> Install Agent."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 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.tmp" || error "Failed to download binary."
|
||||
# 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"
|
||||
|
||||
@@ -139,11 +174,48 @@ EOF
|
||||
else
|
||||
log "Preserving existing configuration at $CONFIG_FILE"
|
||||
fi
|
||||
chmod 600 "$CONFIG_FILE"
|
||||
# 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"
|
||||
|
||||
# 4b. Ensure SSSD dependencies are installed if configure_ldap is enabled
|
||||
if [ "$INSTALL_SSSD" -eq 1 ] || grep -qE -i 'configure_ldap:[[:space:]]*true' "$CONFIG_FILE" 2>/dev/null; then
|
||||
install_sssd_deps
|
||||
# 4c. Setup Desktop Tray Icon companion
|
||||
TRAY_BINARY_NAME="theta-agent-tray-${OS_NAME}-${ARCH_NAME}"
|
||||
case "$OS_NAME" in
|
||||
linux*) TRAY_BINARY_NAME="theta-agent-tray-linux-amd64" ;;
|
||||
windows*) TRAY_BINARY_NAME="theta-agent-tray-windows-amd64.exe" ;;
|
||||
esac
|
||||
TRAY_BIN_PATH="/usr/local/bin/theta-agent-tray"
|
||||
TRAY_URL="https://github.com/theta42/theta-agent/releases/latest/download/${TRAY_BINARY_NAME}"
|
||||
|
||||
log "Attempting to install desktop tray companion ($TRAY_BINARY_NAME)..."
|
||||
if curl -fsSL "$TRAY_URL" -o "$TRAY_BIN_PATH.tmp" 2>/dev/null; then
|
||||
chmod +x "$TRAY_BIN_PATH.tmp"
|
||||
mv -f "$TRAY_BIN_PATH.tmp" "$TRAY_BIN_PATH"
|
||||
mkdir -p /etc/xdg/autostart
|
||||
cat <<EOF > /etc/xdg/autostart/theta-agent-tray.desktop
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Theta Agent Tray
|
||||
Comment=Theta Agent Desktop Tray Companion
|
||||
Exec=/usr/local/bin/theta-agent-tray
|
||||
Icon=network-workgroup
|
||||
Terminal=false
|
||||
Categories=Utility;System;
|
||||
X-GNOME-Autostart-enabled=true
|
||||
EOF
|
||||
log "Desktop tray companion installed at $TRAY_BIN_PATH with autostart."
|
||||
fi
|
||||
|
||||
# 5. Setup systemd service
|
||||
|
||||
Binary file not shown.
@@ -13,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', 'render_secrets', 'iam_apply'];
|
||||
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
|
||||
|
||||
@@ -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,7 @@ 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');
|
||||
|
||||
// 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
|
||||
@@ -199,10 +201,17 @@ router.get('/resources', async (req, res, next) => {
|
||||
try {
|
||||
let resources = await Resource.list();
|
||||
resources = resources.filter(r => {
|
||||
if (r.kind === 'host' || r.kind === 'site') return true;
|
||||
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.
|
||||
@@ -236,10 +245,51 @@ router.get('/resources', async (req, res, next) => {
|
||||
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||
}));
|
||||
|
||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||
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();
|
||||
});
|
||||
|
||||
router.post('/resources', async (req, res, next) => {
|
||||
try {
|
||||
if (!req.body.hostId && req.body.parentSlug) {
|
||||
@@ -247,6 +297,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' });
|
||||
}
|
||||
@@ -316,6 +369,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' });
|
||||
}
|
||||
@@ -336,6 +392,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);
|
||||
|
||||
@@ -646,15 +705,21 @@ router.get('/resources/:id/secrets', async (req, res, next) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Find all ancestor resources across any depth (Host, Site, etc.) + Global Sites
|
||||
// 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 allAncestors = [...ancestors, ...sites];
|
||||
const candidateAncestors = [...ancestors];
|
||||
for (const site of sites) {
|
||||
if (!candidateAncestors.some(a => a.id === site.id)) {
|
||||
candidateAncestors.push(site);
|
||||
}
|
||||
}
|
||||
|
||||
for (const parent of allAncestors) {
|
||||
for (const parent of candidateAncestors) {
|
||||
if (!parent || parent.id === resource.id || seenAncestors.has(parent.id)) continue;
|
||||
seenAncestors.add(parent.id);
|
||||
|
||||
@@ -664,11 +729,15 @@ router.get('/resources/:id/secrets', async (req, res, next) => {
|
||||
const parentBody = await parentR.json().catch(() => ({}));
|
||||
const pMap = (parentBody.data && parentBody.data.data) || {};
|
||||
for (const pKey of Object.keys(pMap)) {
|
||||
parentSecrets.push({
|
||||
parentSlug: parent.slug,
|
||||
parentName: `${parent.name} (${parent.kind ? parent.kind.toUpperCase() : 'PARENT'})`,
|
||||
key: pKey
|
||||
});
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,25 +750,47 @@ 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 secrets = (req.body.secrets && typeof req.body.secrets === 'object') ? req.body.secrets : {};
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||
|
||||
// Validate key names (Standard Env Var format: A-Z, 0-9, underscores)
|
||||
for (const key of Object.keys(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)`
|
||||
});
|
||||
// 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 baoConf = require('@simpleworkjs/bao-conf');
|
||||
const path = `secret/data/resources/${resource.slug}/conf`;
|
||||
const r = await baoConf.request('POST', path, { data: secrets });
|
||||
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' });
|
||||
}
|
||||
res.json({ status: 'ok' });
|
||||
|
||||
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); }
|
||||
});
|
||||
|
||||
@@ -737,4 +828,238 @@ router.post('/resources/:id/grants', async (req, res, next) => {
|
||||
} 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');
|
||||
|
||||
// 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 allResources = await Resource.list();
|
||||
const gateResources = allResources.filter(r => r.metadata && r.metadata.subType === 'wireguard');
|
||||
|
||||
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;
|
||||
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: gateResources.length
|
||||
});
|
||||
} 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,475 @@
|
||||
'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 }));
|
||||
}
|
||||
|
||||
// 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,
|
||||
...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); }
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
@@ -42,6 +42,7 @@ const DOCS = {
|
||||
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||
'site-join': {title: 'Multi-Site: Joining a Spoke', file: path.join(__dirname, '../../docs/site-join.md')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
+1
-13
@@ -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,45 @@ 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' });
|
||||
});
|
||||
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: accessGroup, accessLevel: 'user' }).catch(() => {});
|
||||
await ResourceGroup.create({ id: crypto.randomUUID(), resourceId: res._actualId, groupCn: adminGroup, accessLevel: '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) {
|
||||
|
||||
@@ -60,13 +60,15 @@ describe('Agent ops — POST /api/v1/agent/secrets', () => {
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
test('missing paths returns 400', async () => {
|
||||
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(400);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('ok');
|
||||
expect(res.body.secrets).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
|
||||
+109
-69
@@ -118,22 +118,130 @@ 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,
|
||||
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);
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleHeartbeat(agent, payload, ws) {
|
||||
await this.touch(agent);
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'heartbeat_ack',
|
||||
payload: { timestamp: new Date().toISOString() }
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async handleResponse(agent, payload) {
|
||||
const state = this.live.get(agent.id);
|
||||
if (state) {
|
||||
state.lastResponse = {
|
||||
status: payload.status || 'ok',
|
||||
message: payload.message || '',
|
||||
output: payload.output || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
await this.touch(agent);
|
||||
}
|
||||
|
||||
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
|
||||
const state = this.live.get(agent.id);
|
||||
if (!state || !state.ws || state.ws.readyState !== 1) {
|
||||
throw new Error(`Agent "${agent.name}" is not connected`);
|
||||
}
|
||||
|
||||
const finalPayload = { ...payload };
|
||||
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
|
||||
|
||||
const message = { type: commandType, payload: finalPayload };
|
||||
state.ws.send(JSON.stringify(message));
|
||||
return message;
|
||||
}
|
||||
|
||||
// Live view for one agent, for merging into its row.
|
||||
liveState(agentId) {
|
||||
const state = this.live.get(agentId);
|
||||
if (!state) return { connected: false, lastResponse: null };
|
||||
return {
|
||||
connected: !!(state.ws && state.ws.readyState === 1),
|
||||
ipAddress: state.ipAddress,
|
||||
connectedAt: state.connectedAt,
|
||||
lastResponse: state.lastResponse || null
|
||||
};
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -227,74 +335,6 @@ class AgentManager {
|
||||
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async handleTelemetry(agent, payload) {
|
||||
await this.touch(agent, {
|
||||
lastTelemetry: {
|
||||
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
||||
ram_usage_percent: payload.ram_usage_percent || 0,
|
||||
disk_usage_percent: payload.disk_usage_percent || 0,
|
||||
zfs_health: payload.zfs_health || 'N/A',
|
||||
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
||||
timestamp: payload.timestamp || new Date().toISOString()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async handleHeartbeat(agent, payload, ws) {
|
||||
await this.touch(agent);
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'heartbeat_ack',
|
||||
payload: { timestamp: new Date().toISOString() }
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
async handleResponse(agent, payload) {
|
||||
const state = this.live.get(agent.id);
|
||||
if (state) {
|
||||
state.lastResponse = {
|
||||
status: payload.status || 'ok',
|
||||
message: payload.message || '',
|
||||
output: payload.output || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
await this.touch(agent);
|
||||
}
|
||||
|
||||
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
|
||||
const state = this.live.get(agent.id);
|
||||
if (!state || !state.ws || state.ws.readyState !== 1) {
|
||||
throw new Error(`Agent "${agent.name}" is not connected`);
|
||||
}
|
||||
|
||||
const finalPayload = { ...payload };
|
||||
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
|
||||
|
||||
const message = { type: commandType, payload: finalPayload };
|
||||
state.ws.send(JSON.stringify(message));
|
||||
return message;
|
||||
}
|
||||
|
||||
// Live view for one agent, for merging into its row.
|
||||
liveState(agentId) {
|
||||
const state = this.live.get(agentId);
|
||||
if (!state) return { connected: false, lastResponse: null };
|
||||
return {
|
||||
connected: !!(state.ws && state.ws.readyState === 1),
|
||||
ipAddress: state.ipAddress,
|
||||
connectedAt: state.connectedAt,
|
||||
lastResponse: state.lastResponse || null
|
||||
};
|
||||
}
|
||||
|
||||
// Every enrolled agent, connected or not.
|
||||
async listAgents() {
|
||||
const rows = await Agent.list();
|
||||
return rows.map(a => a.toPublic(this.liveState(a.id)));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AgentManager();
|
||||
|
||||
@@ -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">
|
||||
|
||||
+775
-113
File diff suppressed because it is too large
Load Diff
+37
-10
@@ -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,297 @@
|
||||
'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 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);
|
||||
});
|
||||
Reference in New Issue
Block a user