Compare commits

..

6 Commits

Author SHA1 Message Date
wmantly 7e9a271090 feat: agent join keys; fix directory collapse, plugin edit/delete, docs (v1.30.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m25s
Pull Request Tests / Run Tests (20.x) (push) Failing after 26s
Pull Request Tests / Run Tests (22.x) (push) Failing after 30s
Pull Request Tests / Test Summary (push) Failing after 5s
JOIN KEYS

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

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

DIRECTORY

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

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

Plugin instances can now be edited.

DISCOVERY

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

DOCS

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

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

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

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

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

DIRECTORY

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

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

DISCOVERY

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:44:37 -04:00
wmantly 49100c9b68 fix: agent REST router mounted before 404; promote opens pre-filled modal; Directory refresh; Vault OpenBao (v1.28.0) (#169)
- api_agent: REST router mounted synchronously in app.js (was post-listen, behind
  the 404 catch-all -> /api/agent/* 404'd); WS init stays on onListen
- directory.ejs: promote opens a pre-filled resource modal (Save confirms);
  addEdge/removeEdge call loadResources() (was undefined loadData -> stale table);
  addGroup/removeGroup refresh the Access column
- vault.ejs: 'Powered by OpenBao' header badge
2026-08-05 02:59:58 -04:00
wmantly e8d04203c3 fix: group names match docs, dedupe resource groups, agent 404, shared-secrets + vault apps, promote + plugin logs (v1.27.0) (#168)
- group names match docs/GROUPS.md: {site}_{kind}_{name}_{level} (kind always present; services -> app kind); updated resolver + tests + access_request test
- site resource carries only god_admin + site-wide groups
- groups no longer appear 3x: idempotent ResourceGroup linking (self-heal was creating duplicates on every Directory load)
- /api/agent/* no longer 404s: REST router mounts unconditionally (was gated on the WS server)
- shared-secrets: slug regex allows underscores; GET list uses static pathFor (fixes 's.path is not a function')
- vault Apps tab: new GET /api/vault/apps + Minted apps list + purpose text; /docs/vault help link + docs cover Apps/Shared
- discovery promote: load instance and call update() (fixes 'Resource.update is not a function')
- discovery plugin cards: last-run time/status + Logs button
2026-08-04 23:00:25 -04:00
wmantly 8db00f0ed6 fix: drop legacy app_super_admin -- SUPER_ADMIN_GROUP is now god_admin (v1.26.1) (#167)
god_admin now exists at boot (seeded by docker-entrypoint), so the canonical
cross-resource super group nested into every resource's _admin group is god_admin,
not the legacy app_super_admin. docker-entrypoint no longer seeds or nests
app_super_admin (god_admin nests into the app_sso_* groups directly). isSuperAdmin
still recognizes a pre-existing app_super_admin as a migration alias until rebuild.
2026-08-04 19:31:52 -04:00
32 changed files with 2719 additions and 437 deletions
+91
View File
@@ -1,3 +1,94 @@
# v1.30.0
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
### theta-agent — enrollment without pre-registering
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
### Directory
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
### Discovery
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
### Docs
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
# v1.29.0
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
### Security — theta-agent channel
- **sec: `/api/agent/ws` accepted any token.** There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token it guessed. Tokens were generated in the *browser* (`generateRandomHexToken`) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with `4001` and audited.
- **sec: the command signing key was ephemeral.** `AgentManager` generated an Ed25519 pair in its constructor, so it changed on every process start and the `public_key` an agent pinned in `agent.yml` stopped matching immediately. The key now lives in OpenBao at `secret/agent/signing-key` and survives restarts. If it cannot be loaded the SSO **refuses** to send high-risk commands rather than signing with a key no agent has seen (`signingAvailable: false` on `GET /api/agent/nodes`).
- **sec: commands are addressed by agent id, not token.** A credential has no business in a URL, an access log or browser history.
- **sec: agent actions are audited.** Enroll, update, rotate, revoke, delete, every command (with `signed`), and every rejected connection are emitted as structured `"component":"agent"` log records carrying the acting user.
### theta-agent — enrollment & resource binding
- feat: `POST /api/agent/enroll` mints the token server-side and returns it **once**; only its SHA-256 is stored. Plus `PUT /nodes/:id` (rename/rebind), `POST /nodes/:id/rotate`, `POST /nodes/:id/revoke`, `DELETE /nodes/:id`. Rotate, revoke and delete drop the live socket immediately (`4004`/`4003`) instead of waiting for a reconnect.
- feat: an agent binds to a **host resource** (`resourceId`). The Directory reads that link instead of guessing by hostname — the old `agentsByHost[name]` match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one.
- feat: **agent discovery reaches the Directory.** A bound agent's facts (`os`, `kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`) are written onto its host resource, tagged `discovery_sources: ["theta-agent"]` with an `agentId` back-reference. An unbound agent goes through the normal reconciler. Previously `handleDiscovery` wrote to an in-memory record and updated nothing — the one source actually running *on* the host contributed nothing to the directory.
- feat: agent state is persisted, so an agent that is installed but **offline** is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
- feat: the Install Agent modal enrolls first and builds the install command from the result, including `--public-key`. `public_key` was never emitted into the generated `agent.yml` before, so no installed agent could verify anything.
- fix: `registerAgent` is synchronous. Awaiting a database write before attaching the WebSocket `message` listener lost every agent's first `discovery` frame, which it sends the instant the socket opens (`ws` drops events emitted with no listener attached).
### Directory
- feat: **the resource tree is collapsible.** Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
- fix: **the Proxmox plugin mismatched MAC addresses to IPs.** It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
- feat: Proxmox discovery emits an **endpoint resource** (named from `/cluster/status`) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent.
- feat: discovered guests carry `sourceId` (`<node>/qemu/<vmid>`), `node`, `vmid` and `macAddress`, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources and `sourceId` to 54/54.
- fix: Proxmox interfaces belonging to something running *inside* a guest (`docker0`, `veth*`, `br-*`, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches.
- fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox **nodes** report their own IP/MAC (recovered from `enx<mac>` predictable names, since `/nodes/*/network` carries no `hwaddr`). Offline nodes are recorded with `status` instead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week.
- fix: **the reconciler could make a resource its own parent.** Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
- fix: **hosts were named after their MAC address.** `bestName` preferred the *longer* name, so UniFi's `ac:16:2d:b3:da:80` (17 chars) beat Proxmox's real hostname `dl380-0` (7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank.
- fix: `isIp` never matched anything — `\\.` inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered.
- fix: a discovered device can only merge into a resource of the same kind. A VM named `gitea-runner` could match a hand-created *service* of the same name on the name rule and overwrite it.
- perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read `metadata.ip` while any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and shows `vmid`, slug, `sourceId` and per-interface MAC/name.
### Profile
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's `.container`.
### Build & docs
- fix: `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29.
- docs: `docs/agents.md` rewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a corrected `public_key` example (the documented `MCowBQYDK2VwAyEA...` was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32).
- docs: `docs/directory.md` covers the collapsible tree and the corrected seed hierarchy; `docs/plugins.md` documents what the Proxmox plugin produces and why the endpoint has no IP.
# v1.28.0
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
# v1.27.0
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function``update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
# v1.26.1
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
+4
View File
@@ -22,6 +22,10 @@ COPY nodejs/conf ./conf
COPY nodejs/controller ./controller
COPY nodejs/middleware ./middleware
COPY nodejs/models ./models
# Without this the discovery/plugin suites cannot even load their subject and
# fail as "Cannot find module ../plugins/discovery/..." -- plugin code was
# effectively untested in CI.
COPY nodejs/plugins ./plugins
COPY nodejs/routes ./routes
COPY nodejs/services ./services
COPY nodejs/utils ./utils
+170 -8
View File
@@ -10,6 +10,93 @@ The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) e
---
## Enrollment
An agent is only real if the SSO issued its credential. **Tokens the server did
not issue are rejected** at the WebSocket handshake.
There are two ways to get a host enrolled, and the first is the normal one.
### Join key — install the agent and the host appears
Hand the machine a **join key** and nothing else. On first connect the SSO
enrolls the host, issues it its own per-agent token plus the public key it must
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
key. From then on it authenticates as itself.
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
--url "https://<SSO_HOST>" --join-key "tjk_..."
```
That is the whole procedure — no pre-registering the machine, no copying a
public key by hand. `setup.sh` mints a key and configures the stack's own host
this way automatically.
The join key is a *bootstrap* credential, not the host's identity. That
distinction is what keeps one key convenient without making it a fleet-wide
skeleton key: every host still ends up individually revocable, and a compromised
host does not yield a credential that works anywhere else.
| Endpoint | Purpose |
| :--- | :--- |
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
| `POST /api/agent/join-keys` | Mint one — returned **once** |
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
| `DELETE /api/agent/join-keys/:id` | Remove it |
Revoking a join key does **not** disconnect hosts that already joined; they hold
their own tokens by then. Revoke the agent itself to cut a specific host off.
### Pre-registering a host
When you want the agent bound to a specific Directory host up front, enroll it
from **Directory → Install Agent**:
1. Give the agent a name and **bind it to a host resource**. The binding is what
links telemetry, status and commands to a Directory entry.
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
SHA-256, and shows the raw value **once**.
3. Copy the generated install command — it already carries the token and the
server's public key.
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
`PUT /api/agent/nodes/:id` or from the Directory.
Or via the API:
```bash
curl -X POST https://<SSO_HOST>/api/agent/enroll \
-H "Authorization: Bearer <admin-api-token>" \
-H 'Content-Type: application/json' \
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
```
The response contains `token` (once only) and `publicKey`.
| Endpoint | Purpose |
| :--- | :--- |
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
| `POST /api/agent/enroll` | Mint an agent + token |
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
Revoke, rotate and delete **drop any live connection immediately** — they do not
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
token: a token is a credential and has no business in a URL or a log.
Enrollment, revocation, rotation, every command, and every rejected connection
are written to the application log as structured `"component":"agent"` records
with the acting user.
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
> the agent to issue a new one.
---
## Core Functionality
### 1. Host Discovery & Inventory
@@ -41,12 +128,31 @@ Directory shows a status dot in the row:
| :--- | :--- |
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
| **Red** | Not connected (no agent, or the agent is offline). |
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
Red and grey used to be the same colour, which made an ordinary directory of
hosts look like an outage. Because the enrollment now outlives the connection,
"installed but down" is distinguishable from "never had an agent".
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the
resource name), so name the Directory host the same as the machine's hostname.
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
without a binding fall back to matching their reported hostname against the
resource name — the old behaviour, kept only as a fallback, because it silently
failed whenever a Directory name differed from the machine's hostname and
aliased two hosts that happened to share one.
### Agent discovery feeds the Directory
A bound agent's discovery payload is written onto its host resource (`os`,
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
runs *on* the host it describes, so it is the most authoritative source the
directory has. An unbound agent goes through the normal discovery reconciler
instead, matching like any other source.
---
@@ -64,14 +170,31 @@ To protect hosts against unauthorized control, `theta-agent` enforces a **strict
---
## High-Risk Command Verification (Protocol v1.1.0)
## High-Risk Command Verification (Protocol v1.2.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
no HTML escaping, `signature` omitted).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
**The signing key is persistent.** It lives in OpenBao at
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
`agent.yml` keeps matching. (It used to be generated in memory at boot and
changed on every restart, which made pinning impossible.) If the SSO cannot load
or store a key it **refuses** to send high-risk commands rather than signing with
one no agent has seen — `GET /api/agent/nodes` reports this as
`signingAvailable: false`.
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
`./setup.sh` from theta-suite if you are upgrading.
**Verification is fail-closed on the agent.** An agent with no `public_key`
configured rejects every high-risk command. Earlier versions logged "skipping
signature verification" and executed them, so an agent installed without a key
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
---
## Installation & Deployment
@@ -80,9 +203,14 @@ High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `a
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
```
Both values come from enrollment. The **Install Agent** modal builds this line
for you with them already filled in. Omitting `--public-key` leaves the agent
able to report telemetry but unable to accept any high-risk command.
### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
@@ -97,9 +225,18 @@ curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
auth_token: "your-unique-host-token"
# Issued by the SSO. Left empty when installing with a join key -- the agent
# fills it in itself once the server enrolls it.
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
# agent once it has its own token.
join_key: ""
location: "dc-01-rack-12"
public_key: "MCowBQYDK2VwAyEA..."
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
# command if this is wrong.
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
capabilities:
telemetry: true
@@ -111,6 +248,31 @@ capabilities:
---
## Troubleshooting: agent is rejected (`close 4001`)
If the agent logs that the server rejected its token, the enrollment — not the
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
with an application code:
| Code | Meaning | Fix |
| :--- | :--- | :--- |
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
| `4003` | Enrollment revoked or deleted | Re-enroll |
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
every 5 seconds — a credential that is wrong will not fix itself, and hammering
the SSO only floods its audit log.
An agent installed before protocol v1.2.0 carries a token generated in the
browser that the server never recorded, so it will be rejected with `4001` until
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
`join_key` and blank `auth_token` — it will re-enroll itself on the next
reconnect.
---
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
+22 -2
View File
@@ -78,7 +78,19 @@ Requests are decided by the resource's `owner`, or by any directory admin. Mark
## Navigating the UI
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
The Directory Management interface nests your resources as a tree, making it easy
to comprehend your network topography at a glance. You can filter, search, and
sort your entire infrastructure inventory. Click the green `+` icon next to any
resource to add a child resource beneath it.
**Collapsing the tree.** Any resource with children carries a caret; click it to
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
everything at once. Collapsed state is remembered per browser, so the shape you
arrange survives a refresh (and the self-heal reload that follows most edits).
While a search filter is active every match is shown regardless of collapsed
ancestors — otherwise searching for something inside a folded subtree would
silently return nothing. Clearing the box restores your saved shape.
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
@@ -102,9 +114,17 @@ You don't have to build the graph by hand — the theta42 tooling registers itse
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
- the **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
- the proxy's auto-registered **OAuth client**, linked under its service
Services are parented to the host that actually runs them: Proxy and OpenResty
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
rest under the stack host. Installs seeded before this was fixed had all of them
under the stack host, leaving the two purpose-made host resources childless; the
seed re-parents those on its next run, and only when the current parent is the
one the old code set, so a layout you arranged deliberately is left alone.
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
### Linux hosts (ldap-client)
+115
View File
@@ -0,0 +1,115 @@
---
layout: default
title: Discovery & Inventory
nav_order: 6
---
# Discovery & Inventory
The Directory holds two different kinds of thing, and the distinction matters
for every consumer of the directory:
- **Catalog resources** — what you have declared. Created by hand, seeded by
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
groups, appear in the Catalog, and are the only hosts the
[jump host](https://github.com/theta42/jump-host) will connect you to.
- **Discovered resources** — what the network reports. Produced by
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
tab. They are a queue of "this exists, do you want to manage it?", not
infrastructure you have committed to.
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
and it has never been promoted. Promoting sets `metadata.managed = true`, at
which point it becomes catalog content like any other resource.
> Nothing grants access to a discovered resource. It carries no groups until it
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
> guest is not a jump target.
---
## Where discovered data comes from
| Source | What it reports |
| :--- | :--- |
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
| [nmap](plugins.html) | Hosts and open ports on a target range |
| [Docker](plugins.html) | Containers on a local or remote daemon |
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
An agent is the most authoritative of these: it runs *on* the machine it
describes. A network scan is the least — it only knows what answered.
---
## How results are matched to existing resources
Every source runs through one reconciler, so two sources seeing the same
machine converge on one resource instead of creating duplicates. Matching is
tried in order of precision:
1. **MAC address** — the strongest signal, compared across every interface.
2. **IP address** — any address on any interface, plus `metadata.address`.
3. **Slug, name, or base hostname** — last resort.
A candidate must also be **the same kind**. Without that guard a discovered VM
named `gitea-runner` would match a hand-created *service* of the same name on
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
template is the same machine.)
When a match is found the metadata is merged, interfaces are unioned by MAC, and
the source is added to `discovery_sources` — so a resource can legitimately read
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
### Naming
Sources disagree about names, so the most human one wins: a **hostname** beats
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
tie-break within a rank. This is why a device UniFi knows only as
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
### Relationships
Plugins emit edges as well as resources (a Proxmox node under its cluster
endpoint, a guest under its node). The reconciler refuses any edge that would
make a resource its own parent, or that would close a loop — a cycle renders as
an infinitely nested tree and breaks every ancestor walk in the app.
---
## Promoting a discovered resource
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
pre-filled with what was discovered — name, kind, address, subtype — so you can
correct it before committing. Saving marks it managed and provisions its
[LDAP groups](groups.html).
Each row shows what the directory knows about the device: its source(s), its
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
looks wrong, that detail is where to start.
---
## Stale results
Resources that are *only* auto-discovered are garbage-collected: if a source
stops reporting one for long enough it is marked
`lifecycle_state: "archived"` rather than deleted. Anything you created or
promoted is never touched — `manual` in `discovery_sources` exempts it.
A Proxmox node that is powered off is still reported (with its `status`), so
downtime does not look like decommissioning.
---
## What the stack discovers about itself
`setup.sh` seeds its own components as catalog resources — the site, the stack
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
discovery plugin then finds the containers backing them. Containers belonging to
the theta-suite compose project are recognised and attached to the service they
implement rather than appearing as unmanaged strangers, so a fresh install has an
empty Discovered Inventory rather than five things demanding attention.
+38
View File
@@ -23,6 +23,44 @@ filename basename (without `.js`) is the `type`; the parent directory is the
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
### What the Proxmox plugin produces
One endpoint becomes one subtree:
```
Proxmox endpoint (cluster name, or the endpoint hostname)
└── node (hypervisor)
├── VM / template
└── LXC / template
```
The endpoint resource stands for the cluster, not a machine, so it carries the
API URL and a `sourceId` but deliberately no IP — giving it the address it is
reached at made the reconciler merge it with the node answering on that address,
which produced a resource that was its own parent.
Every guest carries:
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
The MAC and the address on it are read from the same source, so they cannot be
mismatched (an earlier version collected MACs and IPs into two flat lists and
zipped them by index, which attributed addresses to the wrong NIC on any
multi-NIC guest).
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
has an address.
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
a directory row traces back to the exact guest on the exact node.
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
172.x addresses would otherwise give the reconciler spurious matches.
A stopped VM still reports its MAC (read from the VM config rather than the
guest agent), and a DHCP-configured LXC gets its address from the running
container's interface list. Offline nodes are recorded with `status` rather than
skipped, so a hypervisor that is down does not look decommissioned and get
garbage-collected after a week.
A module exports a **manifest**:
```javascript
+18
View File
@@ -28,6 +28,24 @@ You can access the Vault UI from the application's top navigation bar.
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## Apps tab (admin)
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
## Shared tab
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
+10 -3
View File
@@ -44,9 +44,10 @@ app.onListen.push(function(){
});
});
// Initialize Theta Agent WebSockets
require('./routes/api_agent')(app);
});
// Initialize Theta Agent WebSockets. The REST router is already mounted
// synchronously above (see the /api/agent mount); this hook only wires the WS.
require('./routes/api_agent').initAgentWebSockets(app);
});
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
// uncompressed vendor JS/CSS files on every full page navigation (a
@@ -105,6 +106,12 @@ app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
// not from an onListen hook — a router registered post-listen would sit behind
// the terminal 404 handler and make every /api/agent/* request 404. The agent
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
app.use('/api/agent', require('./routes/api_agent'));
// OAuth 2.0 / OpenID Connect
app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter);
+182
View File
@@ -0,0 +1,182 @@
'use strict';
const crypto = require('crypto');
const { Model } = require('@simpleworkjs/orm');
// A theta-agent enrolled against this SSO.
//
// Before this model existed the "agent token" was generated in the browser and
// never recorded anywhere, so the server had no way to tell an agent it issued
// from one someone invented -- /api/agent/ws accepted any string, and there was
// no way to revoke a token or to know that an agent existed while it was
// offline. The row is now the authority: an agent is only real if it is here.
//
// The raw token is shown exactly once, at enrollment. Only its SHA-256 lands in
// the database, so a database disclosure does not hand over working agent
// credentials. `tokenPrefix` is the first 8 characters, kept in the clear so the
// UI and logs can identify an agent without holding the secret.
class Agent extends Model {
// Tokens are compared by hash on every WebSocket connect. SHA-256 (not
// bcrypt) is deliberate: this runs on the connection path and the token is a
// 256-bit random value, not a human-chosen password, so there is nothing for
// a slow KDF to protect against here.
static hashToken(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
// Resolve a presented token to its (non-revoked) agent, or null. Every
// caller that authenticates an agent must go through here.
static async authenticate(rawToken) {
if (!rawToken || typeof rawToken !== 'string') return null;
const tokenHash = this.hashToken(rawToken);
const matches = await this.list({ where: { tokenHash } });
const agent = matches && matches[0];
if (!agent) return null;
if (agent.revoked) return null;
return agent;
}
// Enroll a new agent and return { agent, token }. The caller is responsible
// for showing `token` to the operator once and never storing it.
static async enroll({ name, resourceId, enrolledBy, description }) {
const token = this.generateToken();
const agent = await this.create({
id: crypto.randomUUID(),
name: name || 'theta-agent',
description: description || null,
tokenHash: this.hashToken(token),
tokenPrefix: token.slice(0, 8),
resourceId: resourceId || null,
revoked: false,
enrolled_by: enrolledBy || null,
enrolled_on: Math.floor(Date.now() / 1000)
});
return { agent, token };
}
// Issue a fresh token for an existing agent, invalidating the old one.
async rotateToken() {
const token = Agent.generateToken();
await this.update({
tokenHash: Agent.hashToken(token),
tokenPrefix: token.slice(0, 8),
revoked: false
});
return token;
}
static fields = {
id: { type: 'uuid', primaryKey: true },
name: { type: 'string', isRequired: true },
description: { type: 'text' },
// Never the raw token. See hashToken above.
tokenHash: { type: 'string', isRequired: true },
tokenPrefix: { type: 'string' },
// The host this agent runs on. Nullable so an agent can be enrolled
// before its host exists in the Directory, but the UI pushes for it:
// without this link there is nothing to hang resource control off, and
// the old code had to guess by matching hostnames to slugs.
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
revoked: { type: 'boolean', default: false },
enrolled_by: { type: 'string' },
enrolled_on: { type: 'integer' },
// Survives a restart, which the in-memory map did not: an agent that is
// installed but currently down is now distinguishable from one that was
// never enrolled.
last_seen: { type: 'integer' },
last_ip: { type: 'string' },
lastDiscovery: { type: 'json', default: {} },
lastTelemetry: { type: 'json', default: {} }
};
// The shape the admin API returns. Never includes tokenHash.
toPublic(liveState) {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.tokenHash;
return {
...data,
connected: !!(liveState && liveState.connected),
// "Online" is a live-connection fact, not a stored one. A row with a
// last_seen from an hour ago is an installed agent that is down.
isOnline: !!(liveState && liveState.connected),
lastResponse: (liveState && liveState.lastResponse) || null
};
}
}
// A join key: the one credential an operator hands out so a host can enroll
// itself. Requiring an admin to pre-register every machine before the agent
// would talk to them made adding a host a two-system chore -- installing the
// agent should be enough.
//
// A join key is NOT the agent's long-term credential. On first connect the
// server auto-enrolls the host and issues it a unique per-agent token, which
// the agent persists and uses from then on (PROTOCOL.md 1.2). That keeps the
// operator experience to "one key" while still giving every host its own
// revocable identity -- revoking a single agent means something, and a host
// that is compromised does not hand over the credential for the whole fleet.
class AgentJoinKey extends Model {
static hashKey(raw) {
return crypto.createHash('sha256').update(String(raw || ''), 'utf8').digest('hex');
}
static generateKey() {
// `tjk_` so an operator can tell a join key from an agent token at a
// glance -- they are handled very differently.
return 'tjk_' + crypto.randomBytes(32).toString('hex');
}
// Resolve a presented key to a usable join key, or null. Expiry and
// revocation are both enforced here so no caller can forget one.
static async authenticate(rawKey) {
if (!rawKey || typeof rawKey !== 'string') return null;
const keyHash = this.hashKey(rawKey);
const matches = await this.list({ where: { keyHash } });
const key = matches && matches[0];
if (!key) return null;
if (key.revoked) return null;
if (key.expires_on && key.expires_on < Math.floor(Date.now() / 1000)) return null;
return key;
}
static async issue({ label, createdBy, expiresInDays }) {
const raw = this.generateKey();
const key = await this.create({
id: crypto.randomUUID(),
label: label || 'default',
keyHash: this.hashKey(raw),
keyPrefix: raw.slice(0, 12),
revoked: false,
created_by: createdBy || null,
created_on: Math.floor(Date.now() / 1000),
expires_on: expiresInDays ? Math.floor(Date.now() / 1000) + expiresInDays * 86400 : null,
use_count: 0
});
return { key, raw };
}
static fields = {
id: { type: 'uuid', primaryKey: true },
label: { type: 'string', isRequired: true },
keyHash: { type: 'string', isRequired: true },
keyPrefix: { type: 'string' },
revoked: { type: 'boolean', default: false },
created_by: { type: 'string' },
created_on: { type: 'integer' },
expires_on: { type: 'integer' },
use_count: { type: 'integer', default: 0 },
last_used_on: { type: 'integer' }
};
toPublic() {
const data = this.toJSON ? this.toJSON() : { ...this };
delete data.keyHash;
return data;
}
}
module.exports = { Agent, AgentJoinKey };
+2 -1
View File
@@ -20,6 +20,7 @@ const { PluginInstance } = require('./plugin_instance');
const { SharedSecret } = require('./shared_secret');
const { SharedSecretGrant } = require('./shared_secret_grant');
const { VaultAppToken } = require('./vault_app_token');
const { Agent, AgentJoinKey } = require('./agent');
async function initORM() {
const ormConf = conf.orm || {
dialect: 'sqlite',
@@ -34,7 +35,7 @@ async function initORM() {
conf: { orm: ormConf },
models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
SharedSecret, SharedSecretGrant, VaultAppToken,
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
]
});
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "t42-sso-manager",
"version": "1.26.1",
"version": "1.30.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "t42-sso-manager",
"version": "1.26.1",
"version": "1.30.0",
"license": "MIT",
"dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.26.1",
"version": "1.30.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+48 -6
View File
@@ -7,7 +7,15 @@ module.exports = {
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
// Containers in this compose project are the stack's own. They are already
// represented in the catalog as services, so they are recorded as managed
// and linked to the service they implement instead of arriving as
// unmanaged strangers a fresh install has to triage.
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
// The catalog host these containers run on, so they land in the tree
// instead of as roots.
{ key: 'hostSlug', label: 'Parent host slug', type: 'text', required: false, placeholder: 'host_<hostname>' }
],
validate: async (config) => {
@@ -48,23 +56,57 @@ module.exports = {
const resources = [];
const edges = [];
const stackProject = (config.stackProject || '').trim();
const hostSlug = (config.hostSlug || '').trim();
for (const c of containers) {
const labels = c.Labels || {};
const composeProject = labels['com.docker.compose.project'] || '';
const composeService = labels['com.docker.compose.service'] || '';
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
// A container id changes every time the container is recreated,
// so an id-derived slug made `docker compose up` mint a brand-new
// resource on every deploy and orphan the previous one. Prefer
// identifiers that survive a recreate: the compose project+service
// it belongs to, else its name.
const stableKey = composeProject && composeService
? `${composeProject}-${composeService}`
: (name || c.Id.substring(0, 12));
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
const isOwnStack = !!(stackProject && composeProject === stackProject);
resources.push({
kind: 'container',
name: name,
name: composeService || name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
ports: ports,
composeProject: composeProject || undefined,
composeService: composeService || undefined,
containerName: name,
sourceId: stableKey,
// Part of the deployment we are running inside: already
// accounted for, not something to promote.
managed: isOwnStack ? true : undefined
}
});
// Attach the container to the service it implements when the
// catalog already has one under that slug (the bootstrap seeds
// `sso-manager`, `proxy`, `jump-host`, … using the same names
// compose uses). The reconciler drops an edge whose parent does
// not resolve, so an unmatched name is simply not linked.
if (isOwnStack && composeService) {
edges.push({ parentSlug: composeService, childSlug: slug, relation: 'runs' });
} else if (hostSlug) {
edges.push({ parentSlug: hostSlug, childSlug: slug, relation: 'hosts' });
}
}
resolve({ resources, edges });
+237 -33
View File
@@ -6,6 +6,81 @@ const agent = new https.Agent({
rejectUnauthorized: false
});
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
// the pairing honest -- the previous code collected MACs and IPs into two flat
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
class Interfaces {
constructor() { this.byMac = new Map(); this.anonymous = []; }
// Interfaces that belong to something running INSIDE the guest -- container
// engines, overlay networks, VPNs -- rather than to the guest itself. A
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
// alongside its one real NIC, which is noise in the directory and, worse,
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
// Only applied to guests; a hypervisor's own bridges are how you reach it.
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
static isVirtualName(name) {
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
}
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
// the only place the Proxmox node network API exposes a physical NIC's MAC
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
// rather than leaving every hypervisor MAC-less.
static macFromIfaceName(name) {
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
if (!m) return null;
return m[1].toLowerCase().match(/.{2}/g).join(':');
}
static normalizeMac(mac) {
const m = (mac || '').toLowerCase().trim();
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
if (m === '00:00:00:00:00:00') return null;
return m;
}
// `ips` are the addresses observed on this one NIC (may be empty for a
// stopped guest, where only the MAC is known).
add(mac, ips, name) {
const key = Interfaces.normalizeMac(mac);
const addrs = (ips || []).filter(Boolean);
if (!key) {
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
return;
}
const existing = this.byMac.get(key);
if (existing) {
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
existing.ip = existing.ips[0] || null;
if (!existing.name && name) existing.name = name;
return;
}
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
}
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
// The address/MAC the directory shows in its single-value columns, and what
// the reconciler matches on. Prefer a NIC that actually has an address.
primaryIp() {
const withIp = this.toArray().find(i => i.ip);
return withIp ? withIp.ip : null;
}
primaryMac() {
const withIp = this.toArray().find(i => i.ip && i.mac);
if (withIp) return withIp.mac;
const first = this.toArray().find(i => i.mac);
return first ? first.mac : null;
}
}
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
// drives the admin UI form and validation; fields flagged `secret:true` are
@@ -50,6 +125,45 @@ module.exports = {
const resources = [];
const edges = [];
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
// several unrelated roots in the Directory tree and nothing says where any
// of them came from. Every node discovered below is parented to this, so
// one endpoint == one subtree.
const endpointHost = (() => {
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
})();
const clusterName = await (async () => {
// /cluster/status names the cluster when one exists; a standalone node
// has no cluster entry, in which case the endpoint hostname is the name.
try {
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
if (!res.ok) return null;
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
return entry ? entry.name : null;
} catch (e) { return null; }
})();
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
resources.push({
kind: 'host',
name: clusterName || `Proxmox (${endpointHost})`,
slug: endpointSlug,
metadata: {
subType: 'proxmox',
address: url,
os: 'Proxmox VE',
isProduction: true,
sourceId: url,
// Deliberately NO `ip`/`interfaces`: this resource stands for the
// cluster (the API endpoint), not for a machine. Giving it the address
// it is reached at made the reconciler match it to the very node that
// answers on that address -- the endpoint and the node collapsed into
// one row, which then became its own parent. The cluster is identified
// by slug + sourceId instead, which nothing else can collide with.
interfaces: []
}
});
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) {
@@ -59,9 +173,57 @@ module.exports = {
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
if (node.status !== 'online') continue;
// An offline node is still a real hypervisor that belongs in the
// directory -- skipping it entirely used to make it look decommissioned
// and let the reconciler's garbage collector archive it after a week of
// downtime. Record it, mark it down, and skip only the guest enumeration
// (which needs the node to answer).
const online = node.status === 'online';
const nodeSlug = `pve-node-${node.node}`;
// A hypervisor with no address is not actionable. Read its bridges/NICs
// so the node lands in the directory reachable and MAC-identified like
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
// normally the address you actually reach the hypervisor on.
const nodeIfaces = new Interfaces();
try {
const netRes = online
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
: { ok: false };
if (netRes.ok) {
const ifaceList = (await netRes.json()).data || [];
for (const iface of ifaceList) {
if (iface.iface === 'lo') continue;
const ip = iface.address || iface.cidr;
// This endpoint has no hwaddr field, so the MAC has to be recovered
// from a predictable interface name -- either this interface's own
// or, for a bridge, one of the physical ports beneath it.
let mac = Interfaces.macFromIfaceName(iface.iface);
if (!mac) {
for (const alt of (iface.altnames || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
}
if (!mac && iface.bridge_ports) {
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
mac = Interfaces.macFromIfaceName(port);
if (mac) break;
// The port may itself only carry the MAC in an altname.
const portDef = ifaceList.find(i => i.iface === port);
for (const alt of ((portDef && portDef.altnames) || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
if (mac) break;
}
}
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
}
}
} catch (e) {}
resources.push({
kind: 'host',
name: node.node,
@@ -70,9 +232,19 @@ module.exports = {
subType: 'hypervisor',
os: 'Proxmox VE',
isProduction: true,
interfaces: []
status: node.status,
sourceId: `${node.node}`,
node: node.node,
interfaces: nodeIfaces.toArray(),
macAddress: nodeIfaces.primaryMac(),
ip: nodeIfaces.primaryIp()
}
});
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
// Everything below asks the node itself; an offline node answers none of
// it, and its guests are already recorded from previous runs.
if (!online) continue;
// 2. Get VMs for this node
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
@@ -82,10 +254,12 @@ module.exports = {
const vmSlug = `vm-${vm.vmid}`;
const isTemplate = vm.template === 1;
let ips = [];
let macs = [];
// Enrich from QEMU guest agent if running
const ifaces = new Interfaces();
// Enrich from QEMU guest agent if running. The agent is the only source
// that knows which IP sits on which NIC, so pair them here rather than
// accumulating two flat lists (zipping those by index attributed IPs to
// the wrong MAC on any guest with more than one NIC).
if (vm.status === 'running') {
try {
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
@@ -93,35 +267,35 @@ module.exports = {
const agentData = (await agentRes.json()).data;
if (agentData && agentData.result) {
for (const iface of agentData.result) {
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
if (iface['ip-addresses']) {
for (const ip of iface['ip-addresses']) {
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
ips.push(ip['ip-address']);
}
}
}
// Docker bridges, veth pairs and VPN tunnels are the
// guest's own plumbing, not NICs of the guest.
if (Interfaces.isVirtualName(iface.name)) continue;
const ips = (iface['ip-addresses'] || [])
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
.map(ip => ip['ip-address']);
ifaces.add(iface['hardware-address'], ips, iface.name);
}
}
}
} catch(e) {}
}
// Enrich from VM config to at least get MAC if agent failed/stopped
// Enrich from VM config: the MAC is declared there whether or not the
// guest agent answered, so a stopped VM still gets a stable identity.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
if(m) macs.push(m[1].toLowerCase());
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
if(m) ifaces.add(m[1], [], `net${i}`);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
@@ -130,9 +304,14 @@ module.exports = {
metadata: {
subType: isTemplate ? 'template' : 'vm',
vmid: vm.vmid,
// The Proxmox-side identity, so a resource can be traced back to the
// exact guest on the exact node it was discovered from.
sourceId: `${node.node}/qemu/${vm.vmid}`,
node: node.node,
isProduction: vm.status === 'running',
interfaces,
ip: ips[0] || null
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
@@ -146,26 +325,45 @@ module.exports = {
const lxcSlug = `lxc-${lxc.vmid}`;
const isTemplate = lxc.template === 1;
let ips = [];
let macs = [];
// Enrich from LXC config
const ifaces = new Interfaces();
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
// so read them off the same line instead of into parallel lists.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
if(ipMatch) ips.push(ipMatch[1]);
const line = confData[`net${i}`];
if (!line) continue;
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
const nameMatch = line.match(/\bname=([^,]+)/);
if (hwMatch || ipMatch) {
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
// A DHCP-configured container has no IP in its config. Ask the running
// container's interface list so it lands in the directory addressable
// instead of as an IP-less row.
if (lxc.status === 'running' && !ifaces.primaryIp()) {
try {
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
if (ifRes.ok) {
for (const iface of ((await ifRes.json()).data || [])) {
if (Interfaces.isVirtualName(iface.name)) continue;
const ip = (iface.inet || '').split('/')[0];
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
}
}
} catch(e) {}
}
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
@@ -174,9 +372,12 @@ module.exports = {
metadata: {
subType: isTemplate ? 'template' : 'lxc',
vmid: lxc.vmid,
sourceId: `${node.node}/lxc/${lxc.vmid}`,
node: node.node,
isProduction: lxc.status === 'running',
interfaces,
ip: ips[0] || null
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
@@ -190,5 +391,8 @@ module.exports = {
// `discover` as their implementation name for back-compat, and `run` is just
// an alias. Referenced via module.exports (not `this`) so it survives being
// detached and called as a bare function reference.
run: async (config) => module.exports.discover(config)
run: async (config) => module.exports.discover(config),
// Exported for unit tests only -- not part of the plugin contract.
_Interfaces: Interfaces
};
+348 -78
View File
@@ -4,121 +4,391 @@ const express = require('express');
const middleware = require('../middleware/auth');
const permission = require('../utils/permission');
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
const { Agent, AgentJoinKey } = require('../models/agent');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
module.exports = function initAgentWebSockets(app) {
if (!app.wss) {
console.warn("WebSocket server for agents is not initialized.");
return;
}
// 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'];
app.wss.on('connection', (ws, req) => {
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
// This is a plain Express Router exported directly so app.js can
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It
// must NOT be mounted from the onListen hook (which runs after the 404
// catch-all is already on the stack): a router registered behind that terminal
// handler would make every /api/agent/* request 404, no matter the WS server
// state. The WebSocket handler is separate (initAgentWebSockets below) and is
// the only part that needs the post-listen onListen hook.
const router = express.Router();
// Structured audit line for anything that reaches a host. The agent channel can
// run arbitrary bash, so "who told which host to do what" has to be recoverable
// after the fact; previously nothing was recorded at all.
function logAgentAudit(action, details) {
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
component: 'agent',
action,
...details
}));
}
// The agent WebSocket (/api/agent/ws) authenticates its own token against the
// Agent table (see initAgentWebSockets). These REST routes are admin-facing, so
// they're auth + admin gated.
router.use(middleware.auth);
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ADMIN_GROUPS);
next();
} catch (err) {
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
return res.status(403).json({ status: 'error', message: 'admin only' });
}
next(err);
}
});
// --- Fleet ---
router.get('/nodes', async (req, res, next) => {
try {
const keyStatus = agentKeys.status();
res.json({
status: 'ok',
agents: await agentManager.listAgents(),
// Base64 of the raw 32-byte key: what goes into agent.yml's `public_key`.
publicKey: await agentManager.publicKeyBase64(),
publicKeyPem: await agentManager.publicKeyPem(),
signingAvailable: agentKeys.status().available,
signingError: keyStatus.error || null
});
} catch (err) { next(err); }
});
// --- Enrollment ---
// The token is minted HERE, not in the browser. It is returned exactly once;
// only its hash is stored, so it cannot be recovered afterwards -- rotate to
// get a new one.
router.post('/enroll', async (req, res, next) => {
try {
const { name, resourceId, description } = req.body || {};
if (!name || !String(name).trim()) {
return res.status(400).json({ status: 'error', message: 'name is required' });
}
if (resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
const { agent, token } = await Agent.enroll({
name: String(name).trim(),
description,
resourceId: resourceId || null,
enrolledBy: req.user.uid
});
logAgentAudit('enroll', { actor: req.user.uid, agentId: agent.id, agentName: agent.name, resourceId: resourceId || null });
const publicKey = await agentManager.publicKeyBase64();
res.json({
status: 'ok',
agent: agent.toPublic(agentManager.liveState(agent.id)),
// Shown once. The UI must make that clear.
token,
publicKey,
signingAvailable: agentKeys.status().available
});
} catch (err) { next(err); }
});
router.put('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const patch = {};
if (req.body.name !== undefined) patch.name = req.body.name;
if (req.body.description !== undefined) patch.description = req.body.description;
if (req.body.resourceId !== undefined) {
if (req.body.resourceId) {
const { Resource } = require('../models/resource');
const resource = await Resource.get(req.body.resourceId);
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
if (resource.kind !== 'host') {
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
}
}
patch.resourceId = req.body.resourceId || null;
}
const updated = await agent.update(patch);
logAgentAudit('update', { actor: req.user.uid, agentId: agent.id, fields: Object.keys(patch) });
res.json({ status: 'ok', agent: updated.toPublic(agentManager.liveState(agent.id)) });
} catch (err) { next(err); }
});
// Revoke: the token stops authenticating immediately and any live socket is
// dropped, so revocation takes effect without waiting for a reconnect.
router.post('/nodes/:id/revoke', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
await agent.update({ revoked: true });
agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
logAgentAudit('revoke', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.post('/nodes/:id/rotate', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
const token = await agent.rotateToken();
// The old token is dead the moment it is replaced; drop the socket that was
// using it so the agent reconnects with the new one.
agentManager.disconnect(agent.id, 4004, 'Token rotated');
logAgentAudit('rotate', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok', token, publicKey: await agentManager.publicKeyBase64() });
} catch (err) { next(err); }
});
router.delete('/nodes/:id', async (req, res, next) => {
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
agentManager.disconnect(agent.id, 4003, 'Enrollment deleted');
await agent.delete();
logAgentAudit('delete', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Join keys ---
// One key an operator hands out; hosts that present it enroll themselves and
// are immediately issued their own per-agent token. Listing never returns the
// key itself -- only its prefix and usage.
router.get('/join-keys', async (req, res, next) => {
try {
const keys = await AgentJoinKey.list();
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
} catch (err) { next(err); }
});
router.post('/join-keys', async (req, res, next) => {
try {
const { label, expiresInDays } = req.body || {};
const { key, raw } = await AgentJoinKey.issue({
label: (label && String(label).trim()) || 'default',
createdBy: req.user.uid,
expiresInDays: expiresInDays ? Number(expiresInDays) : null
});
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Shown once; only the hash is stored.
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
} catch (err) { next(err); }
});
router.post('/join-keys/:id/revoke', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.update({ revoked: true });
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
// Agents already enrolled keep working -- they hold their own tokens now,
// which is the whole point of exchanging the join key rather than using it
// as the long-term credential.
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
router.delete('/join-keys/:id', async (req, res, next) => {
try {
const key = await AgentJoinKey.get(req.params.id);
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
await key.delete();
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
res.json({ status: 'ok' });
} catch (err) { next(err); }
});
// --- Commands ---
// Addressed by agent id, not by token: a token is a credential and has no
// business travelling in a URL, being logged, or sitting in browser history.
router.post('/nodes/:id/command', async (req, res, next) => {
const { command, payload, isHighRisk } = req.body || {};
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const agent = await Agent.get(req.params.id);
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
if (agent.revoked) return res.status(403).json({ status: 'error', message: 'agent enrollment is revoked' });
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = await agentManager.sendCommand(agent, command, payload || {}, requiresSigning);
logAgentAudit('command', {
actor: req.user.uid,
agentId: agent.id,
agentName: agent.name,
resourceId: agent.resourceId || null,
command,
signed: requiresSigning
});
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
logAgentAudit('command_failed', { actor: req.user && req.user.uid, agentId: req.params.id, command, error: err.message });
res.status(400).json({ status: 'error', message: err.message });
}
});
module.exports = router;
module.exports.HIGH_RISK_COMMANDS = HIGH_RISK_COMMANDS;
module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
// WebSocket handler only needs the WS server; runs from the onListen hook.
if (!app.wss) return;
// Warm the signing key at boot so a misconfigured OpenBao policy is a loud
// startup error rather than a surprise the first time someone reboots a host.
agentKeys.load().then(keys => {
if (!keys) console.error(`[Theta Agent] signing key unavailable — high-risk commands will be refused. ${agentKeys.status().error || ''}`);
});
app.wss.on('connection', async (ws, req) => {
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
const token = url.searchParams.get('token') || req.headers['authorization'];
const remoteAddr = req.socket.remoteAddress;
if (!token) {
ws.close(4001, 'Unauthorized: Missing token');
// Authenticate BEFORE doing anything else: no registration, no welcome
// payload, no acknowledgement that the token was close. Until this passes
// the peer is an anonymous stranger, and the old code treated it as a
// trusted node purely for presenting a non-empty string.
let agent = null;
let issuedToken = null; // set when this connection auto-enrolled
try {
agent = await Agent.authenticate(token);
// Not a known agent token -- try it as a join key. This is what makes
// "install the agent with a key and the host appears" work without an
// admin pre-registering every machine. The join key is exchanged for a
// per-agent token below, so it never becomes the host's long-term
// credential.
if (!agent) {
const joinKey = await AgentJoinKey.authenticate(token);
if (joinKey) {
const hostname = (url.searchParams.get('hostname') || '').trim();
const enrolled = await Agent.enroll({
name: hostname || `agent-${Date.now().toString(36)}`,
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
enrolledBy: `join-key:${joinKey.label}`
});
agent = enrolled.agent;
issuedToken = enrolled.token;
await joinKey.update({
use_count: (joinKey.use_count || 0) + 1,
last_used_on: Math.floor(Date.now() / 1000)
}).catch(() => {});
logAgentAudit('join', {
agentId: agent.id, agentName: agent.name, remoteAddr,
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
});
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
}
}
} catch (err) {
console.error('[Theta Agent] authentication lookup failed:', err.message);
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
return;
}
const remoteAddr = req.socket.remoteAddress;
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
if (!agent) {
// Deliberately indistinguishable for unknown vs revoked vs missing: a
// caller probing tokens learns nothing about which part was wrong.
logAgentAudit('auth_rejected', { remoteAddr, tokenPrefix: token ? String(token).slice(0, 8) : null });
try { ws.close(4001, 'Unauthorized'); } catch (e) {}
return;
}
agentManager.registerAgent(token, ws, remoteAddr);
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) connected from ${remoteAddr}`);
logAgentAudit('connected', { agentId: agent.id, agentName: agent.name, remoteAddr });
// Must stay synchronous, and the listeners below must be attached in this
// same tick: the agent sends `discovery` the instant the socket opens, and
// `ws` discards messages emitted while no listener is attached.
agentManager.registerAgent(agent, ws, remoteAddr);
ws.on('message', (message) => {
ws.on('message', async (message) => {
try {
const data = JSON.parse(message);
if (!data || typeof data.type !== 'string') return;
// Re-read the row per message so a revoke mid-session takes effect on
// the next thing the agent says, not only on reconnect.
const current = await Agent.get(agent.id).catch(() => null);
if (!current || current.revoked) {
try { ws.close(4003, 'Enrollment revoked'); } catch (e) {}
return;
}
const payload = data.payload || {};
switch (data.type) {
case 'discovery':
agentManager.handleDiscovery(token, payload);
if (app.io) app.io.emit('agent.discovery', { token, payload });
await agentManager.handleDiscovery(current, payload);
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
break;
case 'telemetry':
agentManager.handleTelemetry(token, payload);
if (app.io) app.io.emit('agent.telemetry', { token, payload });
await agentManager.handleTelemetry(current, payload);
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
break;
case 'heartbeat':
agentManager.handleHeartbeat(token, payload, ws);
await agentManager.handleHeartbeat(current, payload, ws);
break;
case 'response':
agentManager.handleResponse(token, payload);
if (app.io) app.io.emit('agent.response', { token, payload });
await agentManager.handleResponse(current, payload);
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
break;
default:
console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`);
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
}
} catch (err) {
console.error("[Theta Agent] Error parsing message:", err);
console.error('[Theta Agent] Error handling message:', err);
}
});
ws.on('close', () => {
console.log(`[Theta Agent] Agent disconnected (${token})`);
agentManager.unregisterAgent(token, ws);
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
agentManager.unregisterAgent(agent.id, ws);
});
// Send initial welcome/config payload
// Send initial welcome/config payload. When this connection enrolled via a
// join key it also carries the credentials the agent should persist and use
// from now on: its own token, and the public key it must pin to verify
// signed commands. Handing the public key over here is what removes the
// last manual step -- an agent installed with only a join key ends up fully
// configured without anyone copying values between two machines.
try {
ws.send(JSON.stringify({
type: 'config',
payload: {
message: 'Connected to SSO Manager C2',
protocol_version: '1.1.0'
}
}));
const payload = {
message: 'Connected to SSO Manager C2',
protocol_version: '1.2.0',
agent_id: agent.id
};
if (issuedToken) {
payload.enrolled = true;
payload.auth_token = issuedToken;
payload.public_key = await agentManager.publicKeyBase64();
}
ws.send(JSON.stringify({ type: 'config', payload }));
} catch (e) {}
});
// REST API routes for Agent Management (mounted under /api/agent). The agent
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
// bin/www with its own ?token= auth — unaffected by the express middleware
// here. These REST routes are admin-facing, so they're auth + admin gated.
const router = express.Router();
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);
}
});
router.get('/nodes', (req, res) => {
res.json({
status: 'ok',
agents: agentManager.getConnectedAgents(),
publicKey: agentManager.publicKeyPem
});
});
router.post('/nodes/:token/command', (req, res) => {
const { token } = req.params;
const { command, payload, isHighRisk } = req.body;
if (!command) {
return res.status(400).json({ status: 'error', message: 'Command type is required' });
}
try {
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
res.json({ status: 'ok', sentMessage: msg });
} catch (err) {
res.status(400).json({ status: 'error', message: err.message });
}
});
app.use('/api/agent', router);
};
+41 -29
View File
@@ -61,6 +61,17 @@ async function ensureGroup(name, ownerDn, description) {
}
}
// Link a group to a resource only if that link doesn't already exist. The
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
// naive create on every Directory self-heal (which runs ensureSiteGroups /
// provisionResourceGroups on each load) was accumulating duplicate links -- the
// "groups appear 3x under a resource" bug. Always check first.
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
if (existing.length) return existing[0];
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
}
// Provision the site-level groups + the aggregates the per-resource groups nest
// into. Idempotent -- called on every directory list so a site seeded by an
// older release gets its groups without a rebuild:
@@ -79,19 +90,20 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
// groups as member.
const link = async (cn, isAdmin) => {
if (!siteResourceId) return;
await ResourceGroup.create({ resourceId: siteResourceId, groupCn: cn, accessLevel: isAdmin ? 'owner' : 'member' }).catch(() => {});
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
};
const sAdmin = groups.siteSuperAdminCns(siteSlug);
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
await link(sAdmin, true);
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
// them), but are NOT linked to the site resource: a site carries only the god
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
// aggregates have no modal home; site-wide access is granted via S_super_admin
// and per-resource access via the host/app groups.
for (const kind of ['host', 'app']) {
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
await ensureGroup(aggAdmin, ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(aggAccess, ownerDn, `Access to all ${kind}s at ${siteSlug}`);
await link(aggAdmin, true);
await link(aggAccess, false);
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
}
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
await link(groups.siteEveryoneCns(siteSlug), false);
@@ -114,29 +126,30 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
// Provision the per-resource groups for a host/app and nest them into the site
// aggregates (so a site/aggregate admin reaches this resource by membership).
// The specific group name uses the resource's slug verbatim
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
// `kind` (host/app) selects which aggregate the group nests into:
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
//
// {site}_{slug}_admin -> {site}_{slug}_access
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
// god_admin -> {site}_{slug}_admin (global super admin)
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin');
const nameSlug = groups.resourceNameSlug(resource.slug);
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
// Link both groups to the resource so the Directory can show/revoke them.
await ResourceGroup.create({ resourceId: resource.id, groupCn: accessCn, accessLevel: 'member' }).catch(() => {});
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
await ensureResourceGroup(resource.id, accessCn, 'member');
await ensureResourceGroup(resource.id, adminCn, 'owner');
await nestGroup(adminCn, accessCn); // administering implies using
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // legacy cross-app super admin
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
}
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
@@ -147,25 +160,24 @@ async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
// capability groups following the same shapes.
function validGroupCnsForResource(resource, siteSlug) {
const valid = new Set();
// A site resource only carries god_admin (added by the route) + the site-wide
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
// specific groups belong to host/app resources, not to the site.
if (resource.kind === 'site') {
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
for (const k of ['host', 'app']) {
valid.add(groups.aggregateGroupCns(siteSlug, k, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, k, 'access'));
}
return { valid, capRe: new RegExp(`^${siteSlug}_(hosts|apps)_[a-z0-9-]+$`) };
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
}
const kind = groupKind(resource); // 'host'|'app'|null
if (kind) {
const slug = resource.slug; // verbatim (kind is carried in the slug)
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, slug, 'access'));
const nameSlug = groups.resourceNameSlug(resource.slug);
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
valid.add(groups.siteSuperAdminCns(siteSlug));
valid.add(groups.siteEveryoneCns(siteSlug));
return { valid, capRe: new RegExp(`^${siteSlug}_(${slug}_|${kind}s_)[a-z0-9-]+$`) };
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
}
// oauth/container etc. — only the global god_admin makes sense to pin here.
valid.add(groups.siteSuperAdminCns(siteSlug));
@@ -439,7 +451,7 @@ router.post('/groups', async (req, res, next) => {
}
}
const g = await ResourceGroup.create(req.body);
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
res.json({ results: g });
} catch (err) { next(err); }
});
+8 -3
View File
@@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant');
const vaultBroker = require('../utils/vault_broker');
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const SLUG_RE = /^[a-z0-9][a-z0-9-]{0,63}$/;
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
// only reject values that can't be a sane secret path segment (spaces, slashes,
// leading non-alnum, too long).
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
const router = express.Router();
@@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => {
if (byId.has(g.id)) continue; // already owner
byId.set(g.id, { role: 'grantee', ...g });
}
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
// The `{ role, ...s }` spread above copies only own properties, so the
// instance method `path()` is dropped -- call the static builder instead.
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
} catch (e) { next(e); }
});
@@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => {
try {
const uid = req.user.uid;
const slug = String(req.body.slug || '').trim().toLowerCase();
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens, 1-64 chars' });
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
const description = String(req.body.description || '').trim();
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
+5 -2
View File
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await Resource.update(resource.id, { metadata: meta });
// `Resource.update` is not a static — `update` is an instance method
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
const inst = await Resource.get(resource.id);
await inst.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
+5 -1
View File
@@ -34,8 +34,12 @@ const DOCS = {
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
// guide the Directory links to -- was unreachable in the app.
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
// The Discovery tab's help icon links here; without an entry it 404'd.
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
+102 -14
View File
@@ -2,26 +2,64 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { WebhookEmitter } = require('./webhook_emitter');
const crypto = require('crypto');
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
// edge that would close a loop. Carries its own visited set so it terminates
// even if the stored graph already contains a cycle from an older release.
function isDescendant(candidateId, rootId, edges) {
const seen = new Set();
const stack = [rootId];
while (stack.length) {
const id = stack.pop();
if (id === candidateId) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
}
return false;
}
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Read the inventory ONCE, not once per incoming resource. A Proxmox
// cluster reports ~55 resources against an inventory of similar size, so
// the per-iteration Resource.list() was doing quadratic full-table reads
// every discovery run. Newly created rows are pushed onto this list as we
// go, so later resources in the same payload still match against them.
const allRes = await Resource.list();
for (const res of resources) {
if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
const allRes = await Resource.list();
let existing = null;
// A discovered device may only merge into a resource of the same kind
// (or into a placeholder from an earlier, kind-less discovery). Without
// this a VM called "gitea-runner" matches a hand-created *service* of
// the same name on rule 3 and silently overwrites it -- the discovered
// host's metadata lands on a service row, and the operator's entry is
// gone. `template` counts as `host`: a VM converted to a template is the
// same device, and it should update in place rather than fork a row.
const kindClass = (k) => (k === 'template' ? 'host' : k);
const incomingKind = kindClass(res.kind || 'unmanaged_device');
const kindCompatible = (r) => {
const k = kindClass(r.kind);
if (k === 'unmanaged_device' || incomingKind === 'unmanaged_device') return true;
return k === incomingKind;
};
const candidates = allRes.filter(kindCompatible);
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r =>
existing = candidates.find(r =>
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
@@ -42,7 +80,7 @@ class DiscoveryReconciler {
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
existing = allRes.find(r => {
existing = candidates.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
@@ -57,7 +95,7 @@ class DiscoveryReconciler {
// 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) {
const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => {
existing = candidates.find(r => {
if (res.slug && r.slug === res.slug) return true;
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
@@ -93,10 +131,33 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
// Pick the most human name across sources. Rank first, length only as
// a tie-break within a rank -- comparing lengths alone let a UniFi
// client named after its MAC ("ac:16:2d:b3:da:80", 17 chars) beat the
// hypervisor's real hostname from Proxmox ("dl380-0", 7), so the
// Directory listed MAC addresses where host names belong.
//
// NB: `\\.` inside a regex LITERAL matches a backslash, not a dot, so
// the old isIp returned false for every input and IP-shaped names were
// never replaced either. It is `\.` here.
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
// 2 = a real name, 1 = an IP (at least routable/recognizable), 0 = a
// MAC or nothing (pure machine identifier, the worst thing to show).
const nameRank = (str) => {
if (!str || !String(str).trim()) return 0;
if (isMac(str)) return 0;
if (isIp(str)) return 1;
return 2;
};
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
if (res.name) {
const incoming = nameRank(res.name);
const current = nameRank(bestName);
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
bestName = res.name;
}
}
await existing.update({
@@ -125,12 +186,17 @@ class DiscoveryReconciler {
newDevices++;
res._actualId = created.id; // Map original slug to actual ID
// Make it visible to the rest of THIS payload: a Proxmox run reports
// the endpoint, then its nodes, then their guests, and two of them can
// legitimately share a MAC/IP. Without this the same device could be
// created twice in a single run.
allRes.push(created);
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// Now process edges
const allRes = await Resource.list();
// Now process edges. `allRes` above is already current -- rows created in
// the loop were pushed onto it -- so no second full read is needed.
const existingEdges = await ResourceEdge.list();
for (const edge of edges) {
@@ -154,15 +220,37 @@ class DiscoveryReconciler {
if (childResInDb) childId = childResInDb.id;
}
// Two slugs in one payload can resolve to the SAME resource once the
// matcher has merged them -- a Proxmox endpoint reached at the address
// of the node that answers for it is the case that produced this. The
// edge would then make a resource its own parent, which renders as an
// infinitely nested tree and defeats every ancestor walk in the app
// (findAncestorSiteSlug, withResolvedAddress) that relies on a cycle
// guard to terminate rather than to be correct.
if (parentId && childId && parentId === childId) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping self-edge on ${edge.parentSlug} -> ${edge.childSlug} (both resolved to the same resource)`);
continue;
}
// Likewise refuse an edge that closes a loop: if the proposed parent is
// already a descendant of the proposed child, adding this makes a cycle.
if (parentId && childId && isDescendant(parentId, childId, existingEdges)) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping ${edge.parentSlug} -> ${edge.childSlug} (would create a cycle)`);
continue;
}
if (parentId && childId) {
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
if (!edgeExists) {
await ResourceEdge.create({
const created = await ResourceEdge.create({
id: crypto.randomUUID(),
parentId,
childId,
relation: edge.relation
});
// Keep the in-memory edge list current so the cycle check above sees
// edges added earlier in this same payload.
existingEdges.push(created);
}
}
}
+7 -5
View File
@@ -44,9 +44,10 @@ beforeAll(async () => {
expect(host.status).toBe(200);
hostId = host.body.results.id;
// Creating a host auto-provisions <site>_<slug>_access / _admin.
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
// (docs/GROUPS.md §2 — the kind is part of the name).
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
// The creator is seeded into both groups -- groupOfNames requires at least
// one member, so Group.add puts the owner's DN there -- and _admin is nested
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
expect(host.status).toBe(200);
// Same as the top-level setup: step out of the auto-created groups the
// creator is seeded into, or this is a request for access already held.
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
// or this is a request for access already held.
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
await request(app)
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
.set('auth-token', token);
+151 -45
View File
@@ -1,11 +1,45 @@
'use strict';
const crypto = require('crypto');
const agentManager = require('../utils/agent_manager');
describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
// In-memory stand-in for OpenBao. The signing key lives at secret/agent/
// signing-key in production; here we only need it to persist across calls so
// the "same key every time" property is actually exercised rather than mocked
// away.
const mockBaoStore = new Map();
jest.mock('@simpleworkjs/bao-conf', () => ({
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); }),
request: jest.fn(async () => ({ ok: true, status: 200 }))
}));
const agentManager = require('../utils/agent_manager');
const agentKeys = require('../utils/agent_keys');
// The manager is now keyed by enrolled Agent rows rather than by a bare token
// string, so these use a stub row with the same surface the real model gives:
// an id, and an update() that records what would be persisted.
function stubAgent(overrides = {}) {
const row = {
id: overrides.id || crypto.randomUUID(),
name: overrides.name || 'test-agent',
resourceId: overrides.resourceId || null,
revoked: false,
persisted: {},
...overrides
};
row.update = jest.fn(async (patch) => {
Object.assign(row.persisted, patch);
Object.assign(row, patch);
return row;
});
return row;
}
describe('AgentManager PROTOCOL.md v1.2.0 Compliance', () => {
let mockWs;
let sentMessages;
let agent;
beforeEach(() => {
sentMessages = [];
@@ -14,23 +48,30 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
close: jest.fn()
};
agent = stubAgent();
});
test('registers agent and tracks initial connection state', () => {
const record = agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
expect(record.token).toBe('test-token-123');
expect(record.ipAddress).toBe('192.168.1.100');
const agents = agentManager.getConnectedAgents();
const found = agents.find(a => a.token === 'test-token-123');
expect(found).toBeDefined();
expect(found.isOnline).toBe(true);
test('registers an agent and reports it as connected', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const state = agentManager.liveState(agent.id);
expect(state.connected).toBe(true);
expect(state.ipAddress).toBe('192.168.1.100');
expect(agentManager.isConnected(agent.id)).toBe(true);
});
test('processes discovery payload per PROTOCOL.md v1.1.0 Section 3.1', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
// registerAgent must not be async: the WS `message` listener is attached in
// the same tick, and `ws` drops events emitted before a listener exists. An
// awaited DB write here swallowed every agent's first discovery frame, which
// is the one it sends immediately on connect.
test('registerAgent is synchronous so no message can be missed', () => {
const result = agentManager.registerAgent(agent, mockWs, '10.0.0.1');
expect(result).toBeUndefined();
expect(agentManager.isConnected(agent.id)).toBe(true);
});
const discoveryPayload = {
test('persists discovery to the agent row (Section 3.1)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleDiscovery(agent, {
hostname: 'node-01.local',
ip_addresses: ['192.168.1.100', '10.0.0.5'],
os: 'Ubuntu 24.04 LTS',
@@ -39,41 +80,34 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
ram_total_gb: 32.0,
disk_total_gb: 500.0,
location: 'dc-chicago-rack-4'
};
});
agentManager.handleDiscovery('test-token-123', discoveryPayload);
const agents = agentManager.getConnectedAgents();
const agent = agents.find(a => a.token === 'test-token-123');
expect(agent.hostname).toBe('node-01.local');
expect(agent.discovery.os).toBe('Ubuntu 24.04 LTS');
expect(agent.discovery.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
const saved = agent.persisted.lastDiscovery;
expect(saved.hostname).toBe('node-01.local');
expect(saved.os).toBe('Ubuntu 24.04 LTS');
expect(saved.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
// Durable, not just in memory: an agent that goes offline keeps its facts.
expect(agent.persisted.last_seen).toEqual(expect.any(Number));
});
test('processes telemetry payload per PROTOCOL.md v1.1.0 Section 3.2', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
const telemetryPayload = {
test('persists telemetry to the agent row (Section 3.2)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleTelemetry(agent, {
cpu_usage_percent: 14.5,
ram_usage_percent: 42.1,
disk_usage_percent: 68.0,
zfs_health: 'ONLINE',
gpu_usage_percent: -1.0,
timestamp: new Date().toISOString()
};
});
agentManager.handleTelemetry('test-token-123', telemetryPayload);
const agents = agentManager.getConnectedAgents();
const agent = agents.find(a => a.token === 'test-token-123');
expect(agent.telemetry.cpu_usage_percent).toBe(14.5);
expect(agent.telemetry.zfs_health).toBe('ONLINE');
expect(agent.persisted.lastTelemetry.cpu_usage_percent).toBe(14.5);
expect(agent.persisted.lastTelemetry.zfs_health).toBe('ONLINE');
});
test('responds to heartbeat with heartbeat_ack per Section 3.3', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
agentManager.handleHeartbeat('test-token-123', { timestamp: new Date().toISOString() }, mockWs);
test('responds to heartbeat with heartbeat_ack (Section 3.3)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
await agentManager.handleHeartbeat(agent, { timestamp: new Date().toISOString() }, mockWs);
expect(mockWs.send).toHaveBeenCalled();
const lastMsg = sentMessages[sentMessages.length - 1];
@@ -81,20 +115,92 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
expect(lastMsg.payload.timestamp).toBeDefined();
});
test('canonicalizes payload and signs high-risk commands using Ed25519 per Section 5', () => {
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
test('canonicalizes and signs high-risk commands with Ed25519 (Section 5)', async () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const rawPayload = { script: 'uptime', location: 'datacenter' };
const msg = agentManager.sendCommand('test-token-123', 'arbitrary_bash', rawPayload, true);
const msg = await agentManager.sendCommand(agent, 'arbitrary_bash', rawPayload, true);
expect(msg.type).toBe('arbitrary_bash');
expect(msg.payload.signature).toBeDefined();
expect(typeof msg.payload.signature).toBe('string');
// Verify signature with public key
const signatureBuffer = Buffer.from(msg.payload.signature, 'base64');
const canonicalStr = agentManager.canonicalize(rawPayload);
const isValid = crypto.verify(null, Buffer.from(canonicalStr, 'utf8'), agentManager.publicKeyPem, signatureBuffer);
const keys = await agentKeys.load();
const isValid = crypto.verify(
null,
Buffer.from(agentManager.canonicalize(rawPayload), 'utf8'),
crypto.createPublicKey(keys.publicKeyPem),
Buffer.from(msg.payload.signature, 'base64')
);
expect(isValid).toBe(true);
});
// The canonical form has to match the Go agent's byte for byte. Go's
// encoding/json escapes <, > and & by default and JSON.stringify does not, so
// the agent uses SetEscapeHTML(false); this pins the server's half of that
// contract. See theta-agent TestCanonicalizeMatchesServerForm.
test('canonical form is sorted, unescaped, and omits the signature', () => {
const canonical = agentManager.canonicalize({
script: 'echo a > b && c',
comment: 'x&y',
signature: 'should-not-appear'
});
expect(canonical).toBe('{"comment":"x&y","script":"echo a > b && c"}');
});
test('refuses to send to an agent that is not connected', async () => {
await expect(agentManager.sendCommand(agent, 'reload_config', {}, false))
.rejects.toThrow(/not connected/);
});
// Revocation that only applies on the next reconnect is not revocation.
test('disconnect drops the live socket immediately', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
expect(agentManager.isConnected(agent.id)).toBe(true);
const dropped = agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
expect(dropped).toBe(true);
expect(mockWs.close).toHaveBeenCalledWith(4003, 'Enrollment revoked');
expect(agentManager.isConnected(agent.id)).toBe(false);
});
test('a second connection for the same agent supersedes the first', () => {
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
const secondWs = { readyState: 1, send: jest.fn(), close: jest.fn() };
agentManager.registerAgent(agent, secondWs, '192.168.1.101');
expect(mockWs.close).toHaveBeenCalledWith(4002, 'Superseded by new connection');
expect(agentManager.liveState(agent.id).ipAddress).toBe('192.168.1.101');
});
test('an unknown agent id is simply not connected', () => {
expect(agentManager.isConnected('no-such-agent')).toBe(false);
expect(agentManager.liveState('no-such-agent')).toEqual({ connected: false, lastResponse: null });
});
});
describe('agent signing key', () => {
// The old manager generated a key pair in its constructor, so it changed on
// every restart and the public_key pinned in agent.yml stopped matching.
test('the same key is returned across repeated loads', async () => {
const first = await agentKeys.load();
const second = await agentKeys.load();
expect(first.publicKeyBase64).toBe(second.publicKeyBase64);
});
test('the exported public key is the raw 32 bytes agents pin', async () => {
const keys = await agentKeys.load();
expect(Buffer.from(keys.publicKeyBase64, 'base64')).toHaveLength(32);
});
test('rawPublicKeyBase64 strips the SPKI wrapper', () => {
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
const raw = Buffer.from(agentKeys.rawPublicKeyBase64(publicKey), 'base64');
expect(raw).toHaveLength(32);
// and it is the tail of the DER encoding
const der = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
expect(raw.equals(der.subarray(der.length - 32))).toBe(true);
});
});
+104
View File
@@ -0,0 +1,104 @@
'use strict';
// Pure-logic coverage for the two reconciler rules that real Proxmox + UniFi
// data broke. Both were found by running discovery against a live cluster:
// the directory came back listing MAC addresses as host names, and one
// resource ended up as its own parent.
// Mirrors the ranking in services/discovery_reconciler.js. Kept here (rather
// than exported) because it is a few lines of predicate that the reconciler
// applies inline while merging; if it grows, export it and drop this copy.
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
const nameRank = (str) => {
if (!str || !String(str).trim()) return 0;
if (isMac(str)) return 0;
if (isIp(str)) return 1;
return 2;
};
function bestNameOf(existingName, incomingName) {
let best = existingName;
if (incomingName) {
const a = nameRank(incomingName);
const b = nameRank(best);
if (a > b || (a === b && incomingName.length > (best || '').length)) best = incomingName;
}
return best;
}
describe('discovery name ranking', () => {
test('a real hostname beats a MAC even when shorter', () => {
// The exact regression: UniFi named the host by MAC, Proxmox knew the
// hostname, and length-only comparison kept the MAC.
expect(bestNameOf('ac:16:2d:b3:da:80', 'dl380-0')).toBe('dl380-0');
});
test('a MAC never displaces a real hostname', () => {
expect(bestNameOf('dl380-0', 'ac:16:2d:b3:da:80')).toBe('dl380-0');
});
test('a real hostname beats an IP-shaped name', () => {
expect(bestNameOf('192.168.1.27', 'hass.io')).toBe('hass.io');
});
test('an IP beats a MAC', () => {
expect(bestNameOf('bc:24:11:3f:cd:c8', '192.168.1.27')).toBe('192.168.1.27');
});
test('an IP does not displace a hostname', () => {
expect(bestNameOf('gitea-runner', '192.168.1.176')).toBe('gitea-runner');
});
test('within the same rank the longer/more specific name wins', () => {
expect(bestNameOf('pve', 'pve-dl380-1')).toBe('pve-dl380-1');
});
test('dash-separated MACs are recognized too', () => {
expect(bestNameOf('ac-16-2d-b3-da-80', 'dl380-0')).toBe('dl380-0');
});
test('an empty existing name is always replaced', () => {
expect(bestNameOf('', 'anything')).toBe('anything');
expect(bestNameOf(null, 'ac:16:2d:b3:da:80')).toBe('ac:16:2d:b3:da:80');
});
});
// Mirrors isDescendant() in the reconciler.
function isDescendant(candidateId, rootId, edges) {
const seen = new Set();
const stack = [rootId];
while (stack.length) {
const id = stack.pop();
if (id === candidateId) return true;
if (seen.has(id)) continue;
seen.add(id);
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
}
return false;
}
describe('discovery edge cycle guard', () => {
const edges = [
{ parentId: 'cluster', childId: 'node1' },
{ parentId: 'node1', childId: 'vm1' },
];
test('detects a direct parent/child inversion', () => {
// Proposing node1 -> cluster when cluster -> node1 already exists.
expect(isDescendant('node1', 'cluster', edges)).toBe(true);
});
test('detects a deeper loop', () => {
expect(isDescendant('vm1', 'cluster', edges)).toBe(true);
});
test('allows an unrelated new parent', () => {
expect(isDescendant('node2', 'cluster', edges)).toBe(false);
});
test('terminates on a graph that already contains a cycle', () => {
// A self-edge written by an earlier release must not hang the walk.
const cyclic = [{ parentId: 'a', childId: 'a' }, { parentId: 'a', childId: 'b' }];
expect(isDescendant('zzz', 'a', cyclic)).toBe(false);
});
});
+29 -20
View File
@@ -12,11 +12,13 @@ const {
GOD_ADMIN,
} = require('../utils/groups');
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
// prefix, services/apps are stored bare. The group-model builders use these
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
describe('slugify', () => {
@@ -30,9 +32,13 @@ describe('slugify', () => {
});
describe('group cn builders', () => {
test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => {
expect(resourceGroupCns('main-office', 'host_web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access');
test('per-resource names the kind + name slug (docs §2)', () => {
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
});
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
});
test('aggregate uses the plural kind', () => {
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
@@ -42,15 +48,13 @@ describe('group cn builders', () => {
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
});
test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => {
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
// site (`site_local` -> `site-local`) would corrupt the delimiter.
test('a directory site slug with a kind prefix is kept verbatim', () => {
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
expect(resourceGroupCns('site_local', 'host_theta-env', 'access')).toBe('site_local_host_theta-env_access');
});
test('invalid kind throws (aggregates only — per-resource has no kind arg)', () => {
test('invalid kind throws', () => {
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
});
});
@@ -89,32 +93,37 @@ describe('hasPermission — inheritance', () => {
});
test('specific host group grants only that host', () => {
const cn = resourceGroupCns('main-office', 'host_web-01', 'admin');
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
});
test('admin implies access; access does not imply admin', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'access')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'access')], HOST, 'admin')).toBe(false);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
});
test('capabilities are exact — admin does not grant a capability', () => {
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'reboot')], HOST, 'reboot')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false);
// aggregate capability
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
});
test('hosts and apps are orthogonal namespaces', () => {
const hostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
const appAdmin = resourceGroupCns('main-office', 'emby', 'admin');
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
});
test('a service maps to the app kind (docs §11)', () => {
// The directory `service` kind is the group model's `app`.
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
});
test('cross-site isolation', () => {
const mainHostAdmin = resourceGroupCns('main-office', 'host_web-01', 'admin');
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
});
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const { _Interfaces: Interfaces } = require('../plugins/discovery/proxmox');
// Regression coverage for the MAC/IP mismatch: the plugin used to collect MACs
// and IPs into two flat lists and zip them by index, so on a multi-NIC guest
// -- or any guest where one NIC had no address -- the directory recorded an IP
// against the wrong MAC. Interfaces keys by MAC so a pairing can only come from
// the source that observed both together.
describe('proxmox Interfaces', () => {
test('keeps each IP on the NIC it was observed on', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', ['10.0.0.5'], 'eth0');
i.add('AA:BB:CC:00:00:02', ['192.168.9.7'], 'eth1');
expect(i.toArray()).toEqual([
{ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5', ips: ['10.0.0.5'], name: 'eth0' },
{ mac: 'aa:bb:cc:00:00:02', ip: '192.168.9.7', ips: ['192.168.9.7'], name: 'eth1' },
]);
});
test('a NIC with no address does not steal the next NIC\'s IP', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', [], 'eth0'); // stopped/unconfigured
i.add('AA:BB:CC:00:00:02', ['10.0.0.9'], 'eth1');
const byMac = Object.fromEntries(i.toArray().map(x => [x.mac, x.ip]));
expect(byMac['aa:bb:cc:00:00:01']).toBeNull();
expect(byMac['aa:bb:cc:00:00:02']).toBe('10.0.0.9');
});
test('merges the config MAC with the agent-reported address for the same NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5'], 'eth0'); // guest agent
i.add('AA:BB:CC:00:00:01', [], 'net0'); // VM config, same NIC
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0]).toMatchObject({ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5' });
});
test('collects multiple addresses on one NIC without inventing a second NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5', '10.0.0.6'], 'eth0');
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0].ips).toEqual(['10.0.0.5', '10.0.0.6']);
expect(i.primaryIp()).toBe('10.0.0.5');
});
test('ignores placeholder and malformed MACs', () => {
const i = new Interfaces();
i.add('00:00:00:00:00:00', [], 'eth0');
i.add('not-a-mac', [], 'eth1');
i.add('', [], 'eth2');
expect(i.toArray()).toEqual([]);
expect(i.primaryMac()).toBeNull();
});
test('keeps an address that arrived without a usable MAC', () => {
const i = new Interfaces();
i.add(null, ['10.0.0.5'], 'eth0');
expect(i.primaryIp()).toBe('10.0.0.5');
expect(i.primaryMac()).toBeNull();
});
test('primary values prefer a NIC that actually has an address', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'eth0');
i.add('aa:bb:cc:00:00:02', ['10.0.0.9'], 'eth1');
expect(i.primaryIp()).toBe('10.0.0.9');
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:02');
});
test('a fully unaddressed guest still reports its MAC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'net0');
expect(i.primaryIp()).toBeNull();
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:01');
});
});
+99
View File
@@ -0,0 +1,99 @@
'use strict';
// The Ed25519 key pair the SSO signs high-risk agent commands with, stored in
// OpenBao at `secret/agent/signing-key`.
//
// This used to be generated in the AgentManager constructor and kept only in
// memory, which made the whole signing scheme decorative: every SSO restart
// produced a new key, so the `public_key` pinned in an agent's agent.yml stopped
// matching and the agent either rejected everything or (because it skips
// verification when no key is configured) executed everything unverified. A
// trust anchor that changes on restart is not a trust anchor.
//
// Requires the sso-broker OpenBao policy to grant `secret/agent/*`
// (theta-suite setup.sh). Without it the load fails and signing is reported as
// unavailable -- we deliberately do NOT fall back to an ephemeral key, because
// signing with a key no agent has ever seen is worse than refusing: it looks
// like it worked.
const crypto = require('crypto');
const baoConf = require('@simpleworkjs/bao-conf');
const PATH = 'agent/signing-key'; // baoConf adds the secret/data prefix
let cached = null; // { privateKeyPem, publicKeyPem, publicKeyBase64 }
let loadError = null;
// Agents pin the raw 32-byte Ed25519 public key, base64-encoded (see the Go
// client's verifySignature, which base64-decodes cfg.public_key and expects
// ed25519.PublicKeySize bytes). Node hands us SPKI PEM, so strip the 12-byte
// DER prefix to get the raw key the agent actually wants.
function rawPublicKeyBase64(publicKeyPem) {
const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
return Buffer.from(der.subarray(der.length - 32)).toString('base64');
}
function generate() {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
return { privateKeyPem: privateKey, publicKeyPem: publicKey };
}
// Load the stored key pair, generating and persisting one on first run.
// Idempotent and safe to call repeatedly; the result is cached in-process.
async function load() {
if (cached) return cached;
let stored = null;
try {
stored = await baoConf.get(PATH);
} catch (err) {
loadError = `could not read ${PATH} from OpenBao: ${err.message}`;
console.error(`[agent_keys] ${loadError}`);
return null;
}
if (stored && stored.privateKeyPem && stored.publicKeyPem) {
cached = {
privateKeyPem: stored.privateKeyPem,
publicKeyPem: stored.publicKeyPem,
publicKeyBase64: rawPublicKeyBase64(stored.publicKeyPem)
};
loadError = null;
return cached;
}
// First run: mint one and persist it before use, so a crash between
// generating and storing can't leave agents pinned to a key we forgot.
const fresh = generate();
try {
await baoConf.set(PATH, fresh);
} catch (err) {
loadError = `could not persist a signing key to ${PATH}: ${err.message}. `
+ 'Re-run ./setup.sh so the sso-broker policy grants secret/agent/*.';
console.error(`[agent_keys] ${loadError}`);
return null;
}
cached = {
...fresh,
publicKeyBase64: rawPublicKeyBase64(fresh.publicKeyPem)
};
loadError = null;
console.log('[agent_keys] generated and stored a new agent signing key');
return cached;
}
function status() {
return { available: !!cached, error: loadError };
}
// Test seam: drop the in-process cache.
function _reset() {
cached = null;
loadError = null;
}
module.exports = { load, status, rawPublicKeyBase64, _reset, PATH };
+172 -99
View File
@@ -1,26 +1,19 @@
'use strict';
const crypto = require('crypto');
const agentKeys = require('./agent_keys');
const { Agent } = require('../models/agent');
// Tracks the live WebSocket for each enrolled agent and brokers commands to it.
//
// The durable facts about an agent (identity, host binding, last seen, last
// discovery/telemetry) live in the Agent table; this class holds only what
// cannot be persisted -- the open socket. That split is what makes an installed
// -but-offline agent visible, and what stops a restart from erasing the fleet.
class AgentManager {
constructor() {
this.agents = new Map(); // token -> agentRecord
this.privateKeyPem = null;
this.publicKeyPem = null;
this.initKeyPair();
}
initKeyPair() {
try {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
publicKeyEncoding: { type: 'spki', format: 'pem' }
});
this.privateKeyPem = privateKey;
this.publicKeyPem = publicKey;
} catch (err) {
console.error('[AgentManager] Failed to generate Ed25519 key pair:', err);
}
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
this.live = new Map();
}
/**
@@ -37,54 +30,89 @@ class AgentManager {
}
/**
* Sign payload using Ed25519 private key.
* Returns base64 encoded signature.
* Sign payload using the persisted Ed25519 private key. Throws when no key is
* available rather than minting a throwaway one -- an agent verifies against
* the key pinned in its agent.yml, so a signature from a key it has never
* seen is not a weaker signature, it is a broken command that looks fine from
* this side.
*/
signPayload(payload) {
if (!this.privateKeyPem) {
throw new Error('Ed25519 private key is not initialized');
async signPayload(payload) {
const keys = await agentKeys.load();
if (!keys) {
const { error } = agentKeys.status();
throw new Error(`agent command signing is unavailable: ${error || 'no signing key'}`);
}
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem);
return signature.toString('base64');
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
}
registerAgent(token, ws, remoteAddress) {
const existing = this.agents.get(token);
async publicKeyBase64() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyBase64 : null;
}
async publicKeyPem() {
const keys = await agentKeys.load();
return keys ? keys.publicKeyPem : null;
}
// Bind a freshly authenticated socket to an enrolled agent. `agent` is an
// Agent row that Agent.authenticate() has already vouched for -- this method
// never sees a raw token and must never be called with an unauthenticated one.
// Synchronous by design. The caller must attach its `message` listener in the
// same tick as the connection is accepted: `ws` drops events emitted before a
// listener exists, and the agent sends `discovery` immediately on open, so
// awaiting a database round-trip here silently lost every agent's first
// discovery frame. The connect timestamp is persisted in the background.
registerAgent(agent, ws, remoteAddress) {
const existing = this.live.get(agent.id);
if (existing && existing.ws && existing.ws !== ws) {
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
}
const agentRecord = {
token,
this.live.set(agent.id, {
ws,
ipAddress: remoteAddress,
hostname: 'unknown',
connectedAt: new Date().toISOString(),
lastSeen: new Date().toISOString(),
discovery: {},
telemetry: {},
pendingResponses: new Map()
};
lastResponse: null
});
this.agents.set(token, agentRecord);
return agentRecord;
agent.update({
last_seen: Math.floor(Date.now() / 1000),
last_ip: remoteAddress || null
}).catch(err => console.error(`[AgentManager] could not record connect for ${agent.id}:`, err.message));
}
unregisterAgent(token, ws) {
const record = this.agents.get(token);
if (record && record.ws === ws) {
this.agents.delete(token);
}
unregisterAgent(agentId, ws) {
const state = this.live.get(agentId);
if (state && state.ws === ws) this.live.delete(agentId);
}
handleDiscovery(token, payload) {
const agent = this.agents.get(token);
if (!agent) return;
// Drop an agent's live socket now. Revocation that only takes effect on the
// next reconnect is not revocation -- a connected agent would keep receiving
// commands indefinitely.
disconnect(agentId, code = 4003, reason = 'Disconnected by server') {
const state = this.live.get(agentId);
if (!state || !state.ws) return false;
try { state.ws.close(code, reason); } catch (e) {}
this.live.delete(agentId);
return true;
}
agent.lastSeen = new Date().toISOString();
agent.hostname = payload.hostname || agent.hostname;
agent.discovery = {
isConnected(agentId) {
const state = this.live.get(agentId);
return !!(state && state.ws && state.ws.readyState === 1);
}
async touch(agent, extra = {}) {
await agent.update({
last_seen: Math.floor(Date.now() / 1000),
...extra
}).catch(err => console.error(`[AgentManager] could not persist agent ${agent.id}:`, err.message));
}
async handleDiscovery(agent, payload) {
const discovery = {
hostname: payload.hostname || '',
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
os: payload.os || '',
@@ -94,28 +122,79 @@ class AgentManager {
disk_total_gb: payload.disk_total_gb || 0,
location: payload.location || 'default'
};
await this.touch(agent, { lastDiscovery: discovery });
await this.applyDiscoveryToDirectory(agent, discovery);
}
handleTelemetry(token, payload) {
const agent = this.agents.get(token);
if (!agent) return;
// An agent runs ON the host it describes, which makes it the most
// authoritative source the directory has -- more so than a hypervisor API or
// a network scan. It previously updated nothing at all: the facts sat on an
// in-memory record and were lost on disconnect.
//
// When the agent is bound to a resource we write that row directly; guessing
// is only for an unbound agent, and then we let the shared reconciler do the
// matching (same MAC/IP/name rules every other source goes through) rather
// than inventing a second matcher here.
async applyDiscoveryToDirectory(agent, discovery) {
try {
const { Resource } = require('../models/resource');
const metadata = {
os: discovery.os || undefined,
kernel: discovery.kernel || undefined,
cpu: discovery.cpu || undefined,
ram_total_gb: discovery.ram_total_gb || undefined,
disk_total_gb: discovery.disk_total_gb || undefined,
ip: (discovery.ip_addresses || [])[0] || undefined,
agentId: agent.id,
last_seen: Date.now()
};
// Drop undefined so a field the agent could not determine never
// overwrites a good value already in the directory.
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
agent.lastSeen = new Date().toISOString();
agent.telemetry = {
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()
};
}
if (agent.resourceId) {
const resource = await Resource.get(agent.resourceId);
if (!resource) return;
const merged = { ...(resource.metadata || {}), ...metadata };
const sources = new Set(merged.discovery_sources || []);
sources.add('theta-agent');
merged.discovery_sources = [...sources];
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
return;
}
handleHeartbeat(token, payload, ws) {
const agent = this.agents.get(token);
if (agent) {
agent.lastSeen = new Date().toISOString();
if (!discovery.hostname) return;
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
await DiscoveryReconciler.reconcile('theta-agent', {
resources: [{
kind: 'host',
name: discovery.hostname,
slug: `agent-${agent.id.slice(0, 8)}`,
metadata: { ...metadata, subType: 'linux' }
}],
edges: []
});
} catch (err) {
// Never let a directory write break the agent connection.
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
}
}
async handleTelemetry(agent, payload) {
await this.touch(agent, {
lastTelemetry: {
cpu_usage_percent: payload.cpu_usage_percent || 0,
ram_usage_percent: payload.ram_usage_percent || 0,
disk_usage_percent: payload.disk_usage_percent || 0,
zfs_health: payload.zfs_health || 'N/A',
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
timestamp: payload.timestamp || new Date().toISOString()
}
});
}
async handleHeartbeat(agent, payload, ws) {
await this.touch(agent);
try {
ws.send(JSON.stringify({
type: 'heartbeat_ack',
@@ -124,57 +203,51 @@ class AgentManager {
} catch (e) {}
}
handleResponse(token, payload) {
const agent = this.agents.get(token);
if (agent) {
agent.lastSeen = new Date().toISOString();
agent.lastResponse = {
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);
}
sendCommand(token, commandType, payload = {}, isHighRisk = false) {
const agent = this.agents.get(token);
if (!agent || !agent.ws || agent.ws.readyState !== 1) {
throw new Error(`Agent with token "${token}" is not connected`);
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 = this.signPayload(finalPayload);
}
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
const message = {
type: commandType,
payload: finalPayload
};
agent.ws.send(JSON.stringify(message));
const message = { type: commandType, payload: finalPayload };
state.ws.send(JSON.stringify(message));
return message;
}
getConnectedAgents() {
const list = [];
const now = new Date();
for (const [token, agent] of this.agents.entries()) {
list.push({
token,
hostname: agent.hostname,
ipAddress: agent.ipAddress,
connectedAt: agent.connectedAt,
lastSeen: agent.lastSeen,
discovery: agent.discovery,
telemetry: agent.telemetry,
lastResponse: agent.lastResponse || null,
isOnline: (now - new Date(agent.lastSeen)) < 90000
});
}
return list;
// Live view for one agent, for merging into its row.
liveState(agentId) {
const state = this.live.get(agentId);
if (!state) return { connected: false, lastResponse: null };
return {
connected: !!(state.ws && state.ws.readyState === 1),
ipAddress: state.ipAddress,
connectedAt: state.connectedAt,
lastResponse: state.lastResponse || null
};
}
// Every enrolled agent, connected or not.
async listAgents() {
const rows = await Agent.list();
return rows.map(a => a.toPublic(this.liveState(a.id)));
}
}
module.exports = new AgentManager();
module.exports.AgentManager = AgentManager;
+26 -17
View File
@@ -41,17 +41,23 @@ function assertKind(kind) {
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
}
// {site}_{slug}_{level} — the per-resource group for one resource.
//
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
// yields `site_local_host_theta-env_access`. Services are stored without a
// prefix (`sso-manager`), yielding `site_local_sso-manager_access`. This is the
// convention the auto-provisioner, the resolver, and the access-request tests
// all share -- re-slugifying or inserting a kind would double the delimiter.
function resourceGroupCns(site, slug, level) {
return `${site}_${slug}_${level}`;
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
// `theta-env`), leaving the resource's name slug. Services are stored bare
// (`sso-manager`), so this is a no-op for them.
function resourceNameSlug(slug) {
return String(slug || '').replace(/^(site|host|app)_/, '');
}
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
// `site_local_app_sso-manager_access`.
function resourceGroupCns(site, kind, nameSlug, level) {
assertKind(kind);
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
}
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
@@ -94,11 +100,13 @@ function levelGrants(level, wanted) {
// granted groups (see permission.onResource). This keeps the function pure over
// the user's membership only.
function hasPermission(memberOf, resource, level) {
// `site` and `slug` are used verbatim (resource slugs may carry a kind prefix,
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
// apps); `nameSlug` is the resource name with any kind prefix stripped.
const site = resource && resource.site;
const kind = resource && resource.kind;
const slug = resource && resource.slug;
const rawKind = resource && resource.kind;
const kind = rawKind === 'service' ? 'app' : rawKind;
const nameSlug = resourceNameSlug(resource && resource.slug);
const set = new Set(memberOf || []);
if (set.has(GOD_ADMIN)) return true;
@@ -107,13 +115,13 @@ function hasPermission(memberOf, resource, level) {
if (isKnownLevel(level)) {
// admin / access
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, slug, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
return false;
}
// Opaque capability — exact aggregate or specific grant only.
if (set.has(aggregateGroupCns(site, kind, level))) return true;
if (set.has(resourceGroupCns(site, slug, level))) return true;
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
return false;
}
@@ -122,6 +130,7 @@ module.exports = {
KNOWN_LEVELS,
KINDS,
slugify,
resourceNameSlug,
resourceGroupCns,
aggregateGroupCns,
siteSuperAdminCns,
+21
View File
@@ -410,6 +410,27 @@ mintAppRouter.post('/', async (req, res, next) => {
}
});
// List the minted external-app tokens (metadata only — the token itself is shown
// once at mint and never stored; the accessor is a renewal/revoke handle and is
// never exposed). Lets the Apps tab show what has been minted instead of a
// credential vanishing into the void.
mintAppRouter.get('/', async (req, res, next) => {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const rows = await VaultAppToken.list();
res.json({ apps: rows.map((r) => ({
name: r.name,
createdBy: r.created_by,
createdOn: r.created_on,
lastRenewedAt: r.lastRenewedAt || null,
lastError: r.lastError || null,
})) });
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
next(e);
}
});
module.exports = {
getOrCreateUserToken,
getOrCreateAdminToken,
+526 -65
View File
@@ -1,5 +1,16 @@
<%- include('top') %>
<style>
/* The caret's rotation is driven by a class on the BUTTON, not by swapping
icon classes on its child: Font Awesome's SVG-with-JS mode replaces the
<i> with an <svg>, so anything keyed to the child element stops working
the moment its observer runs. Targeting both covers either state. */
.tree-caret > i,
.tree-caret > svg { transition: transform .12s ease-in-out; }
.tree-caret.tree-caret-collapsed > i,
.tree-caret.tree-caret-collapsed > svg { transform: rotate(-90deg); }
</style>
<div class="container mt-4">
<div class="row">
<div class="col-12">
@@ -33,6 +44,10 @@
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<div class="btn-group btn-group-sm shadow-sm" role="group" aria-label="Expand or collapse the whole tree">
<button type="button" class="btn btn-outline-secondary" onclick="expandAllTree()" title="Expand all"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" class="btn btn-outline-secondary" onclick="collapseAllTree()" title="Collapse all"><i class="fa-solid fa-angles-up"></i></button>
</div>
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="name">Name (A-Z)</option>
@@ -70,9 +85,10 @@
</tr>
</thead>
<tbody id="resources-list" jq-repeat="resources">
<tr id="resource-row-{{id}}">
<tr id="resource-row-{{id}}" data-depth="{{depth}}">
<td class="ps-3">
{{{indentHtml}}}
{{{caretHtml}}}
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
@@ -138,6 +154,15 @@
<div class="text-muted small">
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
</div>
<div class="text-muted small font-monospace">
{{#metadata.vmid}}<span class="me-2" title="Guest ID on the hypervisor">#{{metadata.vmid}}</span>{{/metadata.vmid}}
<span title="Directory slug">{{slug}}</span>
</div>
{{#metadata.sourceId}}
<div class="text-muted small font-monospace" title="Identifier at the discovery source">
<i class="fa-solid fa-fingerprint pe-1"></i>{{metadata.sourceId}}
</div>
{{/metadata.sourceId}}
</td>
<td>
<span class="badge bg-secondary me-1">{{kind}}</span>
@@ -149,15 +174,23 @@
{{/metadata.subType}}
</td>
<td>
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
{{#displayIp}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{displayIp}}</div>{{/displayIp}}
{{^displayIp}}<span class="text-muted small fst-italic">Unknown IP</span>{{/displayIp}}
{{#metadata.interfaces.length}}
<div class="mt-1 small text-muted">
{{#metadata.interfaces}}
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
<div>
<i class="fa-solid fa-microchip pe-1"></i>
{{#mac}}<span class="font-monospace">{{mac}}</span>{{/mac}}{{^mac}}<span class="fst-italic">no MAC</span>{{/mac}}
{{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}
{{#name}}<span class="text-black-50">{{name}}</span>{{/name}}
</div>
{{/metadata.interfaces}}
</div>
{{/metadata.interfaces.length}}
{{^metadata.interfaces.length}}
{{#metadata.macAddress}}<div class="mt-1 small text-muted"><i class="fa-solid fa-microchip pe-1"></i> <span class="font-monospace">{{metadata.macAddress}}</span></div>{{/metadata.macAddress}}
{{/metadata.interfaces.length}}
</td>
<td>
{{#metadata.managed}}
@@ -208,6 +241,13 @@
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<!-- app.messages confirmations render into a `.actionMessage` inside
the target and do NOTHING without one: the returned promise never
settles, so an awaited confirmation hangs forever and the action
it gates silently never happens. This pane had no such element,
which is why Delete appeared dead. Any pane that asks the
operator to confirm something needs this. -->
<div class="actionMessage" style="display:none"></div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
@@ -515,6 +555,9 @@
var rawResources = [];
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
var accessSummary = {};
// When set, the resource modal's Save promotes this discovered slug (review
// the pre-filled form, then confirm) instead of a normal resource save.
var promoteSlug = null;
$(document).ready(async function() {
await loadResources();
@@ -529,7 +572,8 @@
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
var agentsByHost = {};
var agentsByToken = {};
var agentsByResource = {};
var agentsById = {};
// True when the agent/nodes endpoint itself was unreachable (network, or an
// older app without the agent route). When set we cannot tell "this host has
// no agent" apart from "the agent service is down", so we must NOT paint every
@@ -595,14 +639,23 @@
}
}
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
// Index agents from /api/agent/nodes. `agentsByResource` is the real link --
// an agent row now carries the id of the host it was enrolled against, so a
// resource's agent is a lookup, not a guess.
//
// agentsByHost survives only as a fallback for agents enrolled without a
// resource binding. It used to be the ONLY mechanism, which meant a host
// whose directory name differed from its OS hostname silently showed "no
// agent", and two hosts sharing a hostname aliased onto each other.
function indexAgents(agents) {
agentsByHost = {};
agentsByToken = {};
agentsByResource = {};
agentsById = {};
for (const a of agents || []) {
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
if (hn) agentsByHost[hn] = a;
if (a.token) agentsByToken[a.token] = a;
agentsById[a.id] = a;
if (a.resourceId) agentsByResource[a.resourceId] = a;
const hn = ((a.lastDiscovery && a.lastDiscovery.hostname) || a.name || '').toLowerCase();
if (hn && !agentsByHost[hn]) agentsByHost[hn] = a;
}
}
@@ -614,19 +667,29 @@
// stores the agent on resourcesById so the Metrics tab can find it.
function attachAgentStatus(n) {
n.isHost = true;
// Bound agent first; hostname match only for agents with no binding yet.
const name = (n.name || '').toLowerCase();
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
const a = agentsByResource[n.id] || agentsByHost[name] || (slug && agentsByHost[slug]);
n.agent = a || null;
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
if (!a) {
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
// false red alarm across every host.
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return;
// No agent enrolled at all is a neutral fact about most hosts, not a
// fault -- red here made a directory of ordinary hosts look like an
// outage. Red is reserved for "enrolled, and not connected".
n.agentColor = '#adb5bd'; n.agentStatusTitle = 'No theta-agent enrolled'; return;
}
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
const t = a.telemetry || {};
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
if (!a.isOnline) {
// Now distinguishable from "never existed", because the enrollment row
// outlives the connection.
const seen = a.last_seen ? ' — last seen ' + timeAgo(new Date(a.last_seen * 1000).toISOString()) : '';
n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent enrolled but offline' + seen; return;
}
const t = a.lastTelemetry || {};
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
n.agentColor = high ? '#ffc107' : '#198754';
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
@@ -637,8 +700,8 @@
if (!agent) {
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
}
const d = agent.discovery || {};
const t = agent.telemetry || {};
const d = agent.lastDiscovery || {};
const t = agent.lastTelemetry || {};
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
@@ -773,6 +836,10 @@
}
});
// Rows are emitted depth-first, so a node's descendants are exactly the
// rows that follow it until depth drops back to its own. `data-depth` is
// what applyTreeCollapse() below walks -- that ordering is the whole
// mechanism, so keep the traversal depth-first if you change this.
const flatten = (nodes, depth) => {
nodes.forEach(n => {
let indentHtml = '';
@@ -783,6 +850,16 @@
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
}
n.indentHtml = indentHtml;
n.depth = depth;
// A leaf gets a spacer of the same width, so names stay aligned down
// the column instead of jittering by whether a row has children.
n.caretHtml = n.children.length
? '<button type="button" class="btn btn-link btn-sm p-0 me-1 text-reset tree-caret" '
+ 'onclick="toggleTreeNode(\'' + n.id + '\'); return false;" '
+ 'aria-label="Expand or collapse ' + escapeHtmlAttr(n.name || '') + '" '
+ 'title="Expand/collapse"><i class="fa-solid fa-chevron-down fa-fw"></i></button>'
: '<span class="d-inline-block me-1" style="width:1.1rem"></span>';
n.childCount = n.children.length;
n.accessHtml = accessCellHtml(n.id);
if (n.kind === 'host') attachAgentStatus(n);
finalRenderList.push(n);
@@ -798,8 +875,110 @@
for (const r of finalRenderList) {
$.scope.resources.push(r);
}
applyTreeCollapse();
}
// ── Collapsible tree ───────────────────────────────────────────────────────
// Which nodes are collapsed, by resource id. Persisted so the shape of the
// tree survives a refresh (and the Directory self-heal reload that follows
// most edits) -- a tree that re-expands every time is worse than no tree.
var TREE_COLLAPSE_KEY = 'directory.collapsedNodes';
function loadCollapsed() {
try {
const raw = localStorage.getItem(TREE_COLLAPSE_KEY);
return new Set(raw ? JSON.parse(raw) : []);
} catch (e) { return new Set(); }
}
function saveCollapsed(set) {
try { localStorage.setItem(TREE_COLLAPSE_KEY, JSON.stringify([...set])); } catch (e) {}
}
function escapeHtmlAttr(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
// Hide every row beneath a collapsed node and point its caret sideways.
// Rows are in depth-first order, so "beneath" is the run of following rows
// with a greater depth. A node inside an already-hidden run stays hidden
// regardless of its own state, which is what makes nesting work.
function applyTreeCollapse() {
const $rows = $('#resources-list tr');
// While a search is active every match must be visible, even one sitting
// under a collapsed ancestor -- otherwise searching silently returns
// nothing and looks broken. The collapsed set is left untouched, so the
// tree springs back to its saved shape as soon as the box is cleared.
if (($('#search-filter').val() || '').trim()) {
$rows.show();
$rows.find('.tree-caret i').removeClass('fa-chevron-right').addClass('fa-chevron-down');
return;
}
const collapsed = loadCollapsed();
let hideBelowDepth = null;
$rows.each(function() {
const $row = $(this);
const depth = parseInt($row.attr('data-depth') || '0', 10);
const id = ($row.attr('id') || '').replace('resource-row-', '');
if (hideBelowDepth !== null && depth > hideBelowDepth) {
$row.hide();
return; // still inside a collapsed subtree; its own state is moot
}
hideBelowDepth = null;
$row.show();
// Visual state lives on the .tree-caret BUTTON, rotated by CSS, and the
// hide decision is made from `collapsed` alone.
//
// This used to read `.tree-caret i` and bail out when it found nothing.
// Font Awesome runs in SVG-with-JS mode here: its mutation observer
// rewrites every <i class="fa-..."> into an <svg>, so moments after a
// render that selector matches nothing, the function returned early
// WITHOUT setting hideBelowDepth, and collapsing silently did nothing at
// all. Never make the collapse logic depend on an element another library
// is free to replace.
const $caret = $row.find('.tree-caret');
if (!$caret.length) return; // leaf row: nothing to collapse
if (collapsed.has(id)) {
$caret.addClass('tree-caret-collapsed');
hideBelowDepth = depth;
} else {
$caret.removeClass('tree-caret-collapsed');
}
});
}
function toggleTreeNode(id) {
const collapsed = loadCollapsed();
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
saveCollapsed(collapsed);
applyTreeCollapse();
}
function expandAllTree() {
saveCollapsed(new Set());
applyTreeCollapse();
}
// Collapse every row that has children. Reads the ids out of the rendered
// rows rather than the resource list so it can only ever collapse something
// that is actually on screen and actually has a caret.
function collapseAllTree() {
const collapsed = new Set();
$('#resources-list tr').each(function() {
const $row = $(this);
if (!$row.find('.tree-caret').length) return;
collapsed.add(($row.attr('id') || '').replace('resource-row-', ''));
});
saveCollapsed(collapsed);
applyTreeCollapse();
}
function toggleFormFields() {
const kind = $('#res-kind').val();
if (kind === 'host') {
@@ -1179,6 +1358,29 @@
}
async function saveResource() {
// Promote path: the modal was opened from a discovered inventory row, so
// Save confirms promotion (creates LDAP groups + marks managed) rather than
// a normal resource create/update.
if (promoteSlug) {
const slug = promoteSlug;
promoteSlug = null;
try {
const res = await new Promise((resolve, reject) => {
app.api.post('discovery/promote/' + slug, {}, function(err, r) {
if (err) reject(err); else resolve(r);
});
});
await loadResources();
loadDiscoveryResources();
app.modal.close();
app.messages.toast('Promoted ' + slug + (res && res.groups ? ' — created groups: ' + res.groups.join(', ') : ''), 'success');
} catch (err) {
promoteSlug = slug;
app.messages.action('Failed to promote: ' + (err.message || err), app.modal.body(), 'danger');
}
return;
}
const id = $('#res-id').val();
const data = {
name: $('#res-name').val(),
@@ -1277,6 +1479,7 @@
allGroups.push(res.results);
refreshGroupsUI(resourceId);
$('#new-group-cn').val('');
await loadResources(); // keep the Access column in sync
} catch (err) {
console.error(err);
app.messages.action('Failed to add group', app.modal.body(), 'danger');
@@ -1288,6 +1491,7 @@
await app.api.delete('directory-admin/groups/' + id);
allGroups = allGroups.filter(g => g.id !== id);
refreshGroupsUI($('#res-id').val());
await loadResources(); // keep the Access column in sync
} catch (err) {
console.error(err);
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
@@ -1316,7 +1520,7 @@
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
await loadData();
await loadResources();
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1328,7 +1532,7 @@
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
await loadData();
await loadResources();
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1377,6 +1581,14 @@
$.scope.discoveryResources.empty();
for(const r of filtered) {
// "Unknown IP" was shown for every device whose address is known per-NIC
// rather than in metadata.ip -- which is most of them, since a source
// that enumerates interfaces (UniFi, Proxmox guest agent) fills
// `interfaces[].ip`. Resolve a display address from the NICs so the
// column agrees with the interface list right beneath it.
const meta = r.metadata || {};
const fromNic = (meta.interfaces || []).map(i => i && i.ip).find(Boolean) || null;
r.displayIp = meta.ip || fromNic;
$.scope.discoveryResources.push(r);
}
@@ -1389,42 +1601,48 @@
}
}
// Promoting a discovered resource opens the resource form pre-filled with the
// discovered data so it can be reviewed before the resource is marked managed
// (and its LDAP groups created). The modal's Save (saveResource) sees
// promoteSlug set and calls the promote endpoint instead of a normal save.
function promoteResource(slug) {
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
if(err) {
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
return;
}
const resource = allDiscoveryResources.find(r => r.slug === slug);
if(resource) {
resource.metadata = resource.metadata || {};
resource.metadata.managed = true;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderDiscoveryTable();
loadResources(); // Also update directory tab
});
const r = allDiscoveryResources.find(x => x.slug === slug);
if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; }
promoteSlug = slug;
openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden
const m = r.metadata || {};
$('#res-name').val(r.name || '');
$('#res-slug').val(r.slug || '');
$('#res-kind').val(r.kind || 'host');
$('#res-description').val(r.description || '');
$('#res-ip').val(m.ip || '');
$('#res-address').val(m.address || '');
$('#res-subtype').val(m.subType || '');
$('#res-mac').val(m.macAddress || '');
$('#res-port').val(m.port || '');
$('#res-external-port').val(m.externalPort || '');
$('#res-icon').val(m.icon || '');
$('#res-tagline').val(m.tagline || '');
updateIconPreview();
toggleFormFields();
loadLdapGroups();
}
// --- THETA AGENT INSTALL MODAL & WIZARD ---
function generateRandomHexToken(byteLen) {
const arr = new Uint8Array(byteLen || 16);
(window.crypto || window.msCrypto).getRandomValues(arr);
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
}
function regenerateAgentToken(inputId) {
const newToken = generateRandomHexToken(16);
$('#' + inputId).val(newToken);
if (inputId === 'agent-quick-token') $('#agent-custom-token').val(newToken);
else $('#agent-quick-token').val(newToken);
updateAgentCommands();
}
// Agent tokens are no longer generated here. The browser minting a token the
// server had never heard of is exactly what made /api/agent/ws unauthenticated:
// there was nothing to validate against. Tokens now come from
// POST /api/agent/enroll (see enrollAgent).
function updateAgentCommands() {
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
const quickToken = $('#agent-quick-token').val() || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`;
// public_key must reach the host: without it the agent refuses every
// high-risk command. It was never emitted before, which is why signed
// commands only ever "worked" while verification was being skipped.
const pubKey = (pendingEnrollment && pendingEnrollment.publicKey) || '';
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`
+ (pubKey ? ` --public-key "${pubKey}"` : '');
$('#agent-quick-command').text(quickCmd);
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
@@ -1445,6 +1663,7 @@
const yamlStr = [
`server_url: "${customUrl}"`,
`auth_token: "${customToken}"`,
`public_key: "${pubKey}"`,
`location: "${customLocation}"`,
`capabilities:`,
` telemetry: ${telemetry}`,
@@ -1480,9 +1699,14 @@
});
}
// Enrollment state for the open install modal. The token exists only here,
// in memory, between the enroll call and the operator copying it: the server
// stores a hash and cannot show it again.
var pendingEnrollment = null;
function openAgentInstallModal() {
const currentOrigin = window.location.origin;
const initialToken = generateRandomHexToken(16);
pendingEnrollment = null;
const bodyHtml = `
<div class="mb-3 p-3 bg-light rounded border">
@@ -1495,6 +1719,36 @@
</div>
</div>
<div class="card border-primary mb-3" id="agent-enroll-card">
<div class="card-header py-2 fw-bold small bg-primary-subtle">
<i class="fa-solid fa-id-badge me-1"></i> 1. Enroll this host
</div>
<div class="card-body py-3">
<p class="small text-muted mb-3">
The SSO issues the agent's token and records it. Tokens it did not issue are rejected,
so enroll the host first — the install command below is built from the result.
</p>
<div class="row g-2 align-items-end">
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Agent name</label>
<input type="text" id="agent-enroll-name" class="form-control form-control-sm" placeholder="e.g. web01">
</div>
<div class="col-md-5">
<label class="form-label small fw-bold mb-1">Bind to host resource</label>
<select id="agent-enroll-resource" class="form-select form-select-sm"></select>
<div class="form-text small">Links the agent to a Directory host, so its status and metrics attach to that resource.</div>
</div>
<div class="col-md-3">
<button class="btn btn-sm btn-primary w-100" id="agent-enroll-btn" onclick="enrollAgent()">
<i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token
</button>
</div>
</div>
<div id="agent-enroll-result" class="mt-3" style="display:none"></div>
</div>
</div>
<div id="agent-install-steps" style="display:none">
<ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="tab-quick-btn" data-bs-toggle="pill" data-bs-target="#tab-quick-pane" type="button" role="tab">
@@ -1517,12 +1771,9 @@
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-6">
<label class="form-label small fw-bold mb-1">Host Token</label>
<label class="form-label small fw-bold mb-1">Issued Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-quick-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
</div>
</div>
</div>
@@ -1546,12 +1797,9 @@
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
</div>
<div class="col-md-4">
<label class="form-label small fw-bold mb-1">Host Token</label>
<label class="form-label small fw-bold mb-1">Issued Token</label>
<div class="input-group input-group-sm">
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-custom-token')" title="Regenerate Token">
<i class="fa-solid fa-rotate"></i>
</button>
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
</div>
</div>
<div class="col-md-3">
@@ -1621,6 +1869,7 @@
</div>
</div>
</div>
</div>
`;
app.modal.open({
@@ -1629,9 +1878,78 @@
size: 'lg'
});
// Only hosts can carry an agent -- the API rejects anything else, so don't
// offer it here.
const $sel = $('#agent-enroll-resource').empty();
$sel.append('<option value="">(not bound — bind later)</option>');
rawResources
.filter(r => r.kind === 'host')
.sort((a, b) => (a.name || '').localeCompare(b.name || ''))
.forEach(r => {
const taken = agentsByResource[r.id] ? ' — already has an agent' : '';
$sel.append($('<option>').val(r.id).text((r.name || r.slug) + taken).prop('disabled', !!agentsByResource[r.id]));
});
$('#agent-enroll-resource').on('change', function () {
const r = rawResources.find(x => x.id === this.value);
if (r && !$('#agent-enroll-name').val()) $('#agent-enroll-name').val(r.name || r.slug);
});
updateAgentCommands();
}
// Mint the token server-side, then reveal the install steps built from it.
async function enrollAgent() {
const name = ($('#agent-enroll-name').val() || '').trim();
const resourceId = $('#agent-enroll-resource').val() || null;
if (!name) {
app.messages.toast('Give the agent a name first.', 'warning');
return;
}
const $btn = $('#agent-enroll-btn').prop('disabled', true).html('<i class="fa-solid fa-spinner fa-spin me-1"></i> Enrolling…');
try {
const res = await app.api.post('agent/enroll', { name, resourceId });
const body = res && (res.results || res);
if (!body || !body.token) throw new Error((body && body.message) || 'enrollment failed');
pendingEnrollment = body;
$('#agent-quick-token').val(body.token);
$('#agent-custom-token').val(body.token);
// The signing key is what makes reboot/arbitrary_bash possible. If the
// server could not load one, say so here rather than letting the operator
// discover it the first time a command is silently refused.
const keyWarn = body.signingAvailable === false
? '<div class="alert alert-warning py-2 small mb-2"><i class="fa-solid fa-triangle-exclamation me-1"></i>'
+ 'The SSO has no agent signing key, so high-risk commands (reboot, configure_ldap, arbitrary_bash) '
+ 'will be refused. Re-run <code>./setup.sh</code> so OpenBao grants <code>secret/agent/*</code>.</div>'
: '';
$('#agent-enroll-result').show().html(
keyWarn +
'<div class="alert alert-success py-2 small mb-2">'
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Enrolled.</strong> '
+ 'This token is shown <strong>once</strong> — only its hash is stored. '
+ 'If you lose it, rotate the agent to issue a new one.</div>'
+ '<label class="form-label small fw-bold mb-1">Agent token</label>'
+ '<div class="input-group input-group-sm mb-2">'
+ '<input type="text" class="form-control font-monospace" id="agent-issued-token" readonly value="' + esc(body.token) + '">'
+ '<button class="btn btn-outline-secondary" type="button" onclick="copyAgentCommand(\'agent-issued-token-copy\', \'btn-copy-token\')" id="btn-copy-token"><i class="fa-solid fa-copy"></i></button>'
+ '</div>'
+ '<span id="agent-issued-token-copy" class="d-none">' + esc(body.token) + '</span>'
);
$('#agent-enroll-card').removeClass('border-primary').addClass('border-success');
$btn.html('<i class="fa-solid fa-check me-1"></i> Enrolled');
$('#agent-install-steps').show();
updateAgentCommands();
refreshAgents();
} catch (err) {
$btn.prop('disabled', false).html('<i class="fa-solid fa-key me-1"></i> Enroll &amp; issue token');
app.messages.toast('Enrollment failed: ' + (err.message || err), 'danger');
}
}
var discoveryPlugins = [];
function loadDiscoveryPlugins() {
@@ -1651,15 +1969,29 @@
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
// Last-run state is surfaced by the plugins API (lastRunAt/lastStatus/
// lastError/lastLog) but was dropped here; show it so a plugin that errors
// is visible without digging into logs.
const runOk = p.lastStatus === 'ok';
const runErr = p.lastStatus === 'error';
const runState = p.lastRunAt
? `<span class="badge ${runOk ? 'bg-success' : runErr ? 'bg-danger' : 'bg-secondary'}" ${runErr && p.lastError ? 'title="' + esc(p.lastError) + '"' : ''}>${runOk ? 'ok' : runErr ? 'error' : esc(p.lastStatus) || 'ran'}</span> <span class="text-muted">${fmtRunTs(p.lastRunAt)}</span>`
: '<span class="text-muted">Never run</span>';
const logsBtn = (p.lastLog || p.lastError)
? `<button class="btn btn-sm btn-outline-secondary" title="View run log" onclick="showPluginLog('${p.id}')"><i class="fa-solid fa-scroll"></i> Logs</button>`
: '';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
<div class="small">Last run: ${runState}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
${logsBtn}
<button class="btn btn-sm btn-outline-secondary" title="Edit" onclick="openEditDiscoveryPluginModal('${p.id}')"><i class="fa-solid fa-pen"></i> Edit</button>
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
@@ -1671,6 +2003,30 @@
});
}
// "Never run" when a discovery plugin has no run yet; otherwise relative time.
function fmtRunTs(ts) {
if (!ts) return 'Never run';
const m = moment(ts);
return m.isValid() ? m.fromNow() : 'Never run';
}
// Modal showing the discovery plugin's last run log + error (from the plugins
// API's lastLog/lastError fields). Logs can be long, so render in a scrollable
// <pre> rather than a toast.
function showPluginLog(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
const body = p.lastError
? `<div class="alert alert-danger mb-2">${esc(p.lastError)}</div>`
: '';
const log = p.lastLog || '(no log captured for this run)';
app.modal.open({
title: 'Run log — ' + (p.name || p.slug),
size: 'lg',
bodyHtml: body + '<pre class="p-2 mb-0 bg-light border" style="max-height:55vh;overflow:auto;white-space:pre-wrap;font-size:.85rem;">' + esc(log) + '</pre>',
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
@@ -1739,18 +2095,27 @@
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
function dpConfigFormHtml(type, prefix) {
// `values` pre-fills the form for edit mode. Secret fields are never returned
// by the API in the clear (they live in OpenBao and come back masked), so
// they are rendered EMPTY with a "leave blank to keep" hint rather than
// prefilled with `********` -- submitting the mask back would otherwise store
// the literal asterisks as the secret.
function dpConfigFormHtml(type, prefix, values) {
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
var schema = t && t.configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
values = values || {};
var html = '';
schema.forEach(function(f) {
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var req = (f.required && !f.secret) ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + esc(f.placeholder) + '"') : '';
var val = '';
if (!f.secret && values[f.key] != null) val = ' value="' + esc(values[f.key]) + '"';
if (f.secret && values.__isEdit) ph = ' placeholder="unchanged — type a new value to replace"';
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 + '"' + req + ph + '></div>';
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + val + '></div>';
});
return html;
}
@@ -1810,6 +2175,102 @@
});
}
// Edit an existing instance. Non-secret config goes to PUT /plugins/:id;
// secrets go to PUT /plugins/:id/secrets and only when the operator actually
// typed a new value -- they are two endpoints because the DB row must never
// hold a secret (see routes/api_plugins.js).
function openEditDiscoveryPluginModal(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
const values = Object.assign({}, p.config || {}, { __isEdit: true });
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<input type="text" class="form-control" value="${esc(p.pluginType)}" disabled>
<div class="form-text">The type is fixed once an instance exists — create a new instance to use a different one.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="edit-plugin-name" class="form-control shadow-sm" value="${esc(p.name)}">
<div class="form-text">Slug <code>${esc(p.slug)}</code> is stable and does not change.</div>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Schedule</label>
${dpCronSelectHtml('ep-', p.cron)}
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="edit-plugin-enabled" ${p.enabled ? 'checked' : ''}>
<label class="form-check-label fw-semibold" for="edit-plugin-enabled">Loaded (runs on its schedule)</label>
</div>
<hr><h6 class="fw-bold">Configuration</h6>
<div id="edit-plugin-config-fields">${dpConfigFormHtml(p.pluginType, 'ep-', values)}</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveEditedDiscoveryPlugin('${p.id}')">Save changes</button>
</div>
`;
app.modal.open({ title: 'Edit Discovery Plugin — ' + p.name, bodyHtml: bodyHtml, size: 'lg' });
});
}
async function saveEditedDiscoveryPlugin(id) {
const p = discoveryPlugins.find(x => x.id === id);
if (!p) return;
const name = ($('#edit-plugin-name').val() || '').trim();
if (!name) { app.messages.toast('Name is required', 'warning'); return; }
const flat = dpCollectConfig(p.pluginType, 'ep-');
const type = discoveryPluginTypes.find(t => t.type === p.pluginType);
const schema = (type && type.configSchema) || [];
// Split by the schema so a secret never rides along in the DB payload, and
// an untouched secret field is not sent at all.
const config = {};
const secrets = {};
schema.forEach(f => {
const v = flat[f.key];
if (f.secret) { if (v) secrets[f.key] = v; }
else config[f.key] = v;
});
try {
await app.api.put(`plugins/${id}`, {
name,
cron: dpCronFromForm('ep-'),
enabled: $('#edit-plugin-enabled').is(':checked'),
config
});
if (Object.keys(secrets).length) await app.api.put(`plugins/${id}/secrets`, secrets);
app.modal.close();
app.messages.toast('Plugin updated', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error saving plugin: ' + (e.message || e), 'danger');
}
}
// Was referenced by the card's trash button but never defined, so clicking it
// only threw a ReferenceError -- delete appeared to do nothing.
async function deleteDiscoveryPlugin(id) {
const p = discoveryPlugins.find(x => x.id === id);
const label = p ? (p.name || p.slug) : 'this plugin';
const $card = $('#plugins-tab-pane');
const confirmed = await app.messages.confirm(
`Delete discovery plugin "${label}"? Its schedule stops and its stored secrets are removed. Resources it already discovered stay in the Directory.`,
$card, 'warning');
if (!confirmed) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + (e.message || e), 'danger');
}
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
@@ -1846,12 +2307,12 @@
try {
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
dirAgentSocket.on('agent.telemetry', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
const a = msg && agentsById[msg.agentId];
if (a) { a.lastTelemetry = msg.payload; a.isOnline = true; renderTable(); }
});
dirAgentSocket.on('agent.discovery', function(msg){
const a = msg && agentsByToken[msg.token];
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
const a = msg && agentsById[msg.agentId];
if (a) { a.lastDiscovery = msg.payload; a.isOnline = true; renderTable(); }
});
} catch (e) { /* socket is optional; periodic refresh still runs */ }
});
+4 -1
View File
@@ -656,7 +656,10 @@
}
</script>
<div id="own-api-tokens-section" style="display:none">
<!-- Wrapped in the same `.container` as the profile/edit cards above (which
closes before this block): without it the API Tokens card renders
full-bleed and is visibly wider than every other card on the site. -->
<div id="own-api-tokens-section" class="container" style="display:none">
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-lg">
+53 -2
View File
@@ -4,12 +4,13 @@
<div class="row">
<div class="col-12">
<div class="card shadow">
<div class="card-header">
<div class="card-header d-flex justify-content-between align-items-center flex-wrap gap-2">
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
</ul>
<span class="small text-muted"><i class="fa-solid fa-database me-1"></i>Powered by <a href="https://openbao.org" target="_blank" rel="noopener">OpenBao</a></span>
</div>
<div class="card-body p-0">
<div class="tab-content">
@@ -17,7 +18,10 @@
<div class="tab-pane fade show active" id="tab-secrets">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
<div class="d-flex align-items-center gap-2">
<a href="/docs/vault" class="text-reset" title="Vault help &amp; documentation"><i class="fa-solid fa-circle-question"></i></a>
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
</div>
</div>
<div class="p-3">
<div class="row">
@@ -88,6 +92,20 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
</div>
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0"><i class="fa-solid fa-key me-1"></i> Minted apps</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadApps()"><i class="fas fa-rotate"></i> Refresh</button>
</div>
<div class="card-body">
<p class="text-muted small mb-2">Each entry is a scoped OpenBao credential an external service uses to read <code>secret/apps/&lt;name&gt;/*</code>. The token itself is shown <strong>once</strong> at mint — this list is metadata sso keeps so it can renew the token and so you can see what's been minted. If an app shows a renewal error, re-mint it here.</p>
<div id="apps-list"><div class="text-muted small">Loading…</div></div>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -405,12 +423,44 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
loadApps();
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
// List the minted external-app tokens (metadata only). Makes the Apps tab show
// what's been minted instead of a credential that vanishes after the once-only
// token display.
async function loadApps() {
const $list = document.getElementById('apps-list');
if (!$list) return;
$list.textContent = 'Loading…';
try {
const res = await fetch('/api/vault/apps', {
headers: { 'auth-token': app.auth.getToken() }
});
if (!res.ok) { $list.innerHTML = '<div class="text-danger small">Failed to load apps.</div>'; return; }
const { apps = [] } = await res.json();
if (!apps.length) { $list.innerHTML = '<div class="text-muted small">No apps minted yet.</div>'; return; }
$list.innerHTML = '<div class="list-group shadow-sm">' + apps.map(a => {
const ok = !a.lastError;
const renewed = a.lastRenewedAt ? ' · renewed ' + moment(a.lastRenewedAt).fromNow() : ' · never renewed';
return `<div class="list-group-item d-flex justify-content-between align-items-center">
<div>
<strong class="font-monospace">${app.util.escapeHtml(a.name)}</strong>
${ok ? '<span class="badge bg-success ms-1">renewing</span>' : '<span class="badge bg-danger ms-1" title="' + app.util.escapeHtml(a.lastError) + '">renewal error</span>'}
<div class="small text-muted">minted ${moment(a.createdOn).format('YYYY-MM-DD HH:mm')}${renewed}</div>
</div>
<span class="font-monospace small text-muted">secret/apps/${app.util.escapeHtml(a.name)}/</span>
</div>`;
}).join('') + '</div>';
} catch (err) {
$list.innerHTML = '<div class="text-danger small">Failed to load apps: ' + app.util.escapeHtml(err.message) + '</div>';
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
@@ -572,6 +622,7 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
loadApps();
}
loadSecrets();
loadShared();