Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6e748bfa66 | |||
| a78db906e8 | |||
| e7e3eeb6cd | |||
| f178f1a972 | |||
| 03605267bc | |||
| 7e9a271090 | |||
| b28a18064a | |||
| 87339da1b2 | |||
| 49100c9b68 | |||
| e8d04203c3 |
@@ -1,3 +1,125 @@
|
|||||||
|
# v1.30.2
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Outbound mail (test email, invites, password resets, OTP-by-email, notifications) could be rejected by the SMTP relay with `554 5.7.1 ... Sender is not same as SMTP authenticate username`.** Many authenticated relays require the `From` address to match the authenticated account or they refuse the send outright. `models/email.js` fell back to a hardcoded `noreply@theta42.com` when `smtp.from` wasn't set, which no relay ever authorized this account to send as. It now falls back to `smtp.user` first — the address the account can actually prove it owns — before the hardcoded placeholder.
|
||||||
|
- **Catalog page card titles read icon-then-name.** Swapped to name-then-icon so the resource name leads.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- `docs/configuration.md` didn't mention that OpenBao + the live Configuration UI sit above the four file/env config layers and win the merge — added.
|
||||||
|
- `docs/plugins.md` listed 3 of 4 discovery plugin types (missing `docker`) and didn't mention the `messaging` plugin category (`twilio`, `webhook`) at all — added both.
|
||||||
|
- `docs/vault.md` had no navigation (no frontmatter, no back-link, unreachable from the docs index) and described OpenBao as running in dev mode with API access via the root token — both wrong for a real deployment. Fixed navigation and corrected to describe the actual production setup (unsealed OpenBao, server-side scoped-token injection, personal API tokens for programmatic access).
|
||||||
|
- `docs/discovery.md` was unreachable from the docs index and missing its back-link — both fixed.
|
||||||
|
- `README.md`'s required-groups list was missing `app_sso_directory_admin` (gates Directory/Plugins/Agent admin).
|
||||||
|
|
||||||
|
# v1.30.1
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Test Email always failed with `Email.send is not a function`.** `models/email.js` exports `{Mail}`; the handler required the module and called `.send` on it directly. Every other caller destructures it. The button could never have worked.
|
||||||
|
- **Test SMS failed with `Unexpected token '<', "<!DOCTYPE "...`.** It POSTed to `https://api.voip.ms/v1.0/sms/send` with Basic auth — an endpoint that does not exist. VoIP.ms's REST API is a GET against `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and `method=sendSMS`, so the fabricated URL returned an HTML page and `response.json()` threw. It could never have sent anything.
|
||||||
|
- **All SMS delivery was broken, not just the test button.** `models/sms.js` called `PluginInstance.find({…})`, but @simpleworkjs/orm has no `find` — the query method is `list({where})`. It threw "is not a function" on every send, before it could even fall back to the direct VoIP.ms path, so OTP-by-SMS and notifications were dead too.
|
||||||
|
- Both test endpoints now send through the **same senders every real message uses** (`Mail.send`, `SMS.send`). A test that reimplements delivery proves nothing about whether real delivery works — which is exactly how two broken paths went unnoticed.
|
||||||
|
- The SMS credential check no longer demands `conf.voipms` when a messaging plugin is loaded; the plugin supplies its own credentials, and requiring both blocked a working setup from testing itself.
|
||||||
|
- Both endpoints report a failure as a `400` with the underlying reason (`VoIP.ms error: invalid_credentials`, `connect ECONNREFUSED …:587`) instead of an opaque `500`. A misconfiguration is the operator's to fix and the UI should be able to show it.
|
||||||
|
- test: a guard suite that fails the build on any call to a non-existent ORM static (`find`/`findOne`/`findAll`/`where`), on requiring `models/email` without destructuring `{Mail}`, and on any reference to the bogus `api.voip.ms` host.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Install Agent offers the join-key flow.** The modal now leads with "Join key" — mint one, copy a single install command, and the host enrolls itself. Pre-registering a specific host moved to a second tab. v1.30.0 shipped join keys in the API and documented the modal as the place to get one, but the modal itself still only did the pre-register flow.
|
||||||
|
|
||||||
|
# v1.30.0
|
||||||
|
|
||||||
|
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||||
|
|
||||||
|
### theta-agent — enrollment without pre-registering
|
||||||
|
|
||||||
|
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
|
||||||
|
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
|
||||||
|
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
|
||||||
|
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
|
||||||
|
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
|
||||||
|
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
|
||||||
|
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
|
||||||
|
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
|
||||||
|
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
|
||||||
|
|
||||||
|
# v1.29.0
|
||||||
|
|
||||||
|
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
||||||
|
|
||||||
|
### Security — theta-agent channel
|
||||||
|
|
||||||
|
- **sec: `/api/agent/ws` accepted any token.** There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token it guessed. Tokens were generated in the *browser* (`generateRandomHexToken`) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with `4001` and audited.
|
||||||
|
- **sec: the command signing key was ephemeral.** `AgentManager` generated an Ed25519 pair in its constructor, so it changed on every process start and the `public_key` an agent pinned in `agent.yml` stopped matching immediately. The key now lives in OpenBao at `secret/agent/signing-key` and survives restarts. If it cannot be loaded the SSO **refuses** to send high-risk commands rather than signing with a key no agent has seen (`signingAvailable: false` on `GET /api/agent/nodes`).
|
||||||
|
- **sec: commands are addressed by agent id, not token.** A credential has no business in a URL, an access log or browser history.
|
||||||
|
- **sec: agent actions are audited.** Enroll, update, rotate, revoke, delete, every command (with `signed`), and every rejected connection are emitted as structured `"component":"agent"` log records carrying the acting user.
|
||||||
|
|
||||||
|
### theta-agent — enrollment & resource binding
|
||||||
|
|
||||||
|
- feat: `POST /api/agent/enroll` mints the token server-side and returns it **once**; only its SHA-256 is stored. Plus `PUT /nodes/:id` (rename/rebind), `POST /nodes/:id/rotate`, `POST /nodes/:id/revoke`, `DELETE /nodes/:id`. Rotate, revoke and delete drop the live socket immediately (`4004`/`4003`) instead of waiting for a reconnect.
|
||||||
|
- feat: an agent binds to a **host resource** (`resourceId`). The Directory reads that link instead of guessing by hostname — the old `agentsByHost[name]` match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one.
|
||||||
|
- feat: **agent discovery reaches the Directory.** A bound agent's facts (`os`, `kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`) are written onto its host resource, tagged `discovery_sources: ["theta-agent"]` with an `agentId` back-reference. An unbound agent goes through the normal reconciler. Previously `handleDiscovery` wrote to an in-memory record and updated nothing — the one source actually running *on* the host contributed nothing to the directory.
|
||||||
|
- feat: agent state is persisted, so an agent that is installed but **offline** is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
|
||||||
|
- feat: the Install Agent modal enrolls first and builds the install command from the result, including `--public-key`. `public_key` was never emitted into the generated `agent.yml` before, so no installed agent could verify anything.
|
||||||
|
- fix: `registerAgent` is synchronous. Awaiting a database write before attaching the WebSocket `message` listener lost every agent's first `discovery` frame, which it sends the instant the socket opens (`ws` drops events emitted with no listener attached).
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- feat: **the resource tree is collapsible.** Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
|
||||||
|
- fix: **the Proxmox plugin mismatched MAC addresses to IPs.** It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
|
||||||
|
- feat: Proxmox discovery emits an **endpoint resource** (named from `/cluster/status`) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent.
|
||||||
|
- feat: discovered guests carry `sourceId` (`<node>/qemu/<vmid>`), `node`, `vmid` and `macAddress`, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources and `sourceId` to 54/54.
|
||||||
|
- fix: Proxmox interfaces belonging to something running *inside* a guest (`docker0`, `veth*`, `br-*`, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches.
|
||||||
|
- fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox **nodes** report their own IP/MAC (recovered from `enx<mac>` predictable names, since `/nodes/*/network` carries no `hwaddr`). Offline nodes are recorded with `status` instead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week.
|
||||||
|
- fix: **the reconciler could make a resource its own parent.** Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
|
||||||
|
- fix: **hosts were named after their MAC address.** `bestName` preferred the *longer* name, so UniFi's `ac:16:2d:b3:da:80` (17 chars) beat Proxmox's real hostname `dl380-0` (7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank.
|
||||||
|
- fix: `isIp` never matched anything — `\\.` inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered.
|
||||||
|
- fix: a discovered device can only merge into a resource of the same kind. A VM named `gitea-runner` could match a hand-created *service* of the same name on the name rule and overwrite it.
|
||||||
|
- perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
|
||||||
|
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read `metadata.ip` while any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and shows `vmid`, slug, `sourceId` and per-interface MAC/name.
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's `.container`.
|
||||||
|
|
||||||
|
### Build & docs
|
||||||
|
|
||||||
|
- fix: `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29.
|
||||||
|
- docs: `docs/agents.md` rewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a corrected `public_key` example (the documented `MCowBQYDK2VwAyEA...` was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32).
|
||||||
|
- docs: `docs/directory.md` covers the collapsible tree and the corrected seed hierarchy; `docs/plugins.md` documents what the Proxmox plugin produces and why the endpoint has no IP.
|
||||||
|
|
||||||
|
# v1.28.0
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
|
||||||
|
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
|
||||||
|
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
|
||||||
|
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
|
||||||
|
|
||||||
|
# v1.27.0
|
||||||
|
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
|
||||||
|
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
|
||||||
|
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
|
||||||
|
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
|
||||||
|
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
|
||||||
|
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
|
||||||
|
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
|
||||||
|
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
|
||||||
|
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
|
||||||
|
|
||||||
# v1.26.1
|
# 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.
|
- 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.
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ COPY nodejs/conf ./conf
|
|||||||
COPY nodejs/controller ./controller
|
COPY nodejs/controller ./controller
|
||||||
COPY nodejs/middleware ./middleware
|
COPY nodejs/middleware ./middleware
|
||||||
COPY nodejs/models ./models
|
COPY nodejs/models ./models
|
||||||
|
# Without this the discovery/plugin suites cannot even load their subject and
|
||||||
|
# fail as "Cannot find module ../plugins/discovery/..." -- plugin code was
|
||||||
|
# effectively untested in CI.
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
COPY nodejs/routes ./routes
|
COPY nodejs/routes ./routes
|
||||||
COPY nodejs/services ./services
|
COPY nodejs/services ./services
|
||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
|
|||||||
@@ -200,7 +200,8 @@ If you are pointing the app at your own existing LDAP server, see
|
|||||||
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
`pw-sha2`, `ppolicy`, `memberof`, and `refint` modules plus a small custom
|
||||||
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
schema. The bundled Docker image and `install.sh` set all of that up for you.
|
||||||
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
Required groups: `app_sso_admin` (full admin), `app_sso_oauth_admin` (manage
|
||||||
OAuth clients only), `app_sso_invite` (invitation management) — see
|
OAuth clients only), `app_sso_invite` (invitation management),
|
||||||
|
`app_sso_directory_admin` (Directory/Plugins/Agent admin) — see
|
||||||
DEPLOYMENT.md for the full setup.
|
DEPLOYMENT.md for the full setup.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -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
|
## Core Functionality
|
||||||
|
|
||||||
### 1. Host Discovery & Inventory
|
### 1. Host Discovery & Inventory
|
||||||
@@ -41,12 +128,31 @@ Directory shows a status dot in the row:
|
|||||||
| :--- | :--- |
|
| :--- | :--- |
|
||||||
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||||
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
| **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
|
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).
|
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**:
|
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.
|
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||||
3. The Base64 signature is appended to the message payload.
|
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.
|
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
|
## 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:
|
Run the following command as `root` on the target Linux host:
|
||||||
|
|
||||||
```bash
|
```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
|
### 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:
|
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
|
```yaml
|
||||||
# /etc/theta42/agent.yml
|
# /etc/theta42/agent.yml
|
||||||
server_url: "wss://sso.example.com"
|
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"
|
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:
|
capabilities:
|
||||||
telemetry: true
|
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`)
|
## 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
|
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
|
||||||
|
|||||||
@@ -16,13 +16,27 @@ deep-merges, in order (later wins):
|
|||||||
`localhost`, `SSO Manager`).
|
`localhost`, `SSO Manager`).
|
||||||
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
2. `conf/<NODE_ENV>.js` — optional, environment-specific.
|
||||||
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
3. `conf/secrets.js` — gitignored; secrets + per-deployment values.
|
||||||
4. **`app_*` environment variables** — the highest-precedence layer.
|
4. **`app_*` environment variables** — the highest-precedence layer among these
|
||||||
|
four.
|
||||||
|
|
||||||
Any env var whose name starts with `app_` overrides the merged config. The rest
|
Any env var whose name starts with `app_` overrides the merged config. The rest
|
||||||
of the name splits on **double-underscore** (`__`) into a nested path. Values are
|
of the name splits on **double-underscore** (`__`) into a nested path. Values are
|
||||||
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
`JSON.parse`-coerced when possible (numbers, booleans, null, JSON) and kept as
|
||||||
raw strings otherwise.
|
raw strings otherwise.
|
||||||
|
|
||||||
|
### A fifth, higher-precedence layer: OpenBao + the Configuration UI
|
||||||
|
|
||||||
|
In a theta-suite deployment, `@simpleworkjs/bao-conf`'s `init()` deep-merges
|
||||||
|
`secret/sso-manager/conf` (from OpenBao) over the four layers above at boot —
|
||||||
|
this is the layer `setup.sh`/theta-suite actually manages, and it wins over
|
||||||
|
everything else here. On top of that, the admin **Configuration** page in the
|
||||||
|
UI writes straight to `secret/sso-manager/conf` (via `routes/api_conf.js`)
|
||||||
|
and applies the change to the live `conf` object immediately
|
||||||
|
(`applyToLiveConf`) — no restart, and it bypasses `conf/secrets.js` entirely.
|
||||||
|
If a value isn't behaving the way `conf/secrets.js` says it should, check the
|
||||||
|
Configuration UI / OpenBao before assuming a file edit didn't take — it's
|
||||||
|
almost certainly OpenBao (or a live UI edit) winning the merge.
|
||||||
|
|
||||||
## Examples
|
## Examples
|
||||||
|
|
||||||
| Env var | Sets | Type |
|
| Env var | Sets | Type |
|
||||||
|
|||||||
@@ -78,7 +78,19 @@ Requests are decided by the resource's `owner`, or by any directory admin. Mark
|
|||||||
|
|
||||||
## Navigating the UI
|
## Navigating the UI
|
||||||
|
|
||||||
The Directory Management interface provides a **Tree View** toggle that visually nests your resources, making it easy to comprehend your network topography at a glance. You can also filter, search, and sort your entire infrastructure inventory. From the tree view, you can click the green `+` icon next to any resource to instantly add a child resource beneath it.
|
The Directory Management interface nests your resources as a tree, making it easy
|
||||||
|
to comprehend your network topography at a glance. You can filter, search, and
|
||||||
|
sort your entire infrastructure inventory. Click the green `+` icon next to any
|
||||||
|
resource to add a child resource beneath it.
|
||||||
|
|
||||||
|
**Collapsing the tree.** Any resource with children carries a caret; click it to
|
||||||
|
fold that subtree away. The toolbar's double-chevron buttons expand or collapse
|
||||||
|
everything at once. Collapsed state is remembered per browser, so the shape you
|
||||||
|
arrange survives a refresh (and the self-heal reload that follows most edits).
|
||||||
|
|
||||||
|
While a search filter is active every match is shown regardless of collapsed
|
||||||
|
ancestors — otherwise searching for something inside a folded subtree would
|
||||||
|
silently return nothing. Clearing the box restores your saved shape.
|
||||||
|
|
||||||
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
<a href="images/directory.png" target="_blank"><img src="images/directory.png" alt="Directory & inventory list view" width="80%"></a>
|
||||||
|
|
||||||
@@ -102,9 +114,17 @@ You don't have to build the graph by hand — the theta42 tooling registers itse
|
|||||||
|
|
||||||
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
- a **site** (name from `CFG_SITE_NAME` in `setup.env`, default `local` → slug `site_local`) marked as the current site
|
||||||
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
- the **host** the stack runs on (`host_<hostname>`), with IP, MAC address, OS, and kernel collected from the machine
|
||||||
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), and OpenResty Edge (the 80/443 data plane) — each with its address, internal port, and git repo
|
- the **hosts** for the proxy and jump host (`host_theta-proxy`, `host_theta-jump`)
|
||||||
|
- the **services** it composes — SSO Manager, Proxy (management UI), OpenLDAP Directory (the LDAPS endpoint Linux hosts and LDAP-native apps bind to), OpenResty Edge (the 80/443 data plane), and the SSH Jump Host — each with its address, internal port, and git repo
|
||||||
- the proxy's auto-registered **OAuth client**, linked under its service
|
- the proxy's auto-registered **OAuth client**, linked under its service
|
||||||
|
|
||||||
|
Services are parented to the host that actually runs them: Proxy and OpenResty
|
||||||
|
Edge under `host_theta-proxy`, the SSH Jump Host under `host_theta-jump`, and the
|
||||||
|
rest under the stack host. Installs seeded before this was fixed had all of them
|
||||||
|
under the stack host, leaving the two purpose-made host resources childless; the
|
||||||
|
seed re-parents those on its next run, and only when the current parent is the
|
||||||
|
one the old code set, so a layout you arranged deliberately is left alone.
|
||||||
|
|
||||||
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
The seed is idempotent and non-destructive: a resource whose slug already exists is considered operator-owned — the seed only fills in metadata fields you haven't set, and never overwrites your values.
|
||||||
|
|
||||||
### Linux hosts (ldap-client)
|
### Linux hosts (ldap-client)
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery & Inventory
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery & Inventory
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
|
The Directory holds two different kinds of thing, and the distinction matters
|
||||||
|
for every consumer of the directory:
|
||||||
|
|
||||||
|
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||||
|
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||||
|
groups, appear in the Catalog, and are the only hosts the
|
||||||
|
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||||
|
- **Discovered resources** — what the network reports. Produced by
|
||||||
|
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||||
|
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||||
|
infrastructure you have committed to.
|
||||||
|
|
||||||
|
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||||
|
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||||
|
which point it becomes catalog content like any other resource.
|
||||||
|
|
||||||
|
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||||
|
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||||
|
> guest is not a jump target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where discovered data comes from
|
||||||
|
|
||||||
|
| Source | What it reports |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||||
|
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||||
|
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||||
|
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||||
|
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||||
|
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||||
|
|
||||||
|
An agent is the most authoritative of these: it runs *on* the machine it
|
||||||
|
describes. A network scan is the least — it only knows what answered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How results are matched to existing resources
|
||||||
|
|
||||||
|
Every source runs through one reconciler, so two sources seeing the same
|
||||||
|
machine converge on one resource instead of creating duplicates. Matching is
|
||||||
|
tried in order of precision:
|
||||||
|
|
||||||
|
1. **MAC address** — the strongest signal, compared across every interface.
|
||||||
|
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||||
|
3. **Slug, name, or base hostname** — last resort.
|
||||||
|
|
||||||
|
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||||
|
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||||
|
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||||
|
template is the same machine.)
|
||||||
|
|
||||||
|
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||||
|
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||||
|
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||||
|
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||||
|
tie-break within a rank. This is why a device UniFi knows only as
|
||||||
|
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||||
|
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||||
|
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||||
|
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoting a discovered resource
|
||||||
|
|
||||||
|
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||||
|
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||||
|
correct it before committing. Saving marks it managed and provisions its
|
||||||
|
[LDAP groups](groups.html).
|
||||||
|
|
||||||
|
Each row shows what the directory knows about the device: its source(s), its
|
||||||
|
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||||
|
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||||
|
looks wrong, that detail is where to start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale results
|
||||||
|
|
||||||
|
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||||
|
stops reporting one for long enough it is marked
|
||||||
|
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||||
|
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||||
|
|
||||||
|
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||||
|
downtime does not look like decommissioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the stack discovers about itself
|
||||||
|
|
||||||
|
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||||
|
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||||
|
discovery plugin then finds the containers backing them. Containers belonging to
|
||||||
|
the theta-suite compose project are recognised and attached to the service they
|
||||||
|
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||||
|
empty Discovered Inventory rather than five things demanding attention.
|
||||||
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 332 KiB |
|
Before Width: | Height: | Size: 392 KiB After Width: | Height: | Size: 503 KiB |
|
Before Width: | Height: | Size: 430 KiB After Width: | Height: | Size: 119 KiB |
|
Before Width: | Height: | Size: 313 KiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 221 KiB After Width: | Height: | Size: 320 KiB |
@@ -60,7 +60,10 @@ backend, that's the niche.
|
|||||||
run the pieces separately via `app_*` env config.
|
run the pieces separately via `app_*` env config.
|
||||||
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
|
||||||
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
|
||||||
|
- **[Discovery](discovery.html)** — the catalog-vs-discovered distinction, how scanned assets are matched/merged into existing resources, and how a discovery gets promoted into the catalog (and becomes reachable through the jump host).
|
||||||
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
|
||||||
|
- **[Vault secrets](vault.html)** — an OpenBao-backed key-value store built into the UI, for stashing passwords/API keys/credentials with encryption and access control.
|
||||||
|
- **[API tokens](concepts-api-tokens.html)** — self-service personal access tokens for calling the management API from scripts/CI without a browser session.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,63 @@ needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
|||||||
|
|
||||||
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
filename basename (without `.js`) is the `type`; the parent directory is the
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
`category`. The built-ins ship under `plugins/discovery/`:
|
`category`. Two built-in categories ship today:
|
||||||
|
|
||||||
|
**`discovery`** — scheduled scans that sync external assets into the
|
||||||
|
directory catalog:
|
||||||
|
|
||||||
- `proxmox` — Proxmox VE (URL + API token)
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
- `unifi` — UniFi Network controller (URL + username/password)
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
- `docker` — Docker daemon discovery (containers as directory resources)
|
||||||
|
|
||||||
|
**`messaging`** — on-demand delivery for alerts, 2FA codes, and
|
||||||
|
notifications:
|
||||||
|
|
||||||
|
- `twilio` — Twilio SMS
|
||||||
|
- `webhook` — universal REST webhook (custom JSON payload to Slack, Teams,
|
||||||
|
Discord, or any HTTP endpoint)
|
||||||
|
|
||||||
|
If no messaging plugin instance is enabled, the system falls back to the
|
||||||
|
legacy `voipms` integration configured directly in the SSO secrets.
|
||||||
|
|
||||||
|
### What the Proxmox plugin produces
|
||||||
|
|
||||||
|
One endpoint becomes one subtree:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox endpoint (cluster name, or the endpoint hostname)
|
||||||
|
└── node (hypervisor)
|
||||||
|
├── VM / template
|
||||||
|
└── LXC / template
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint resource stands for the cluster, not a machine, so it carries the
|
||||||
|
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
||||||
|
reached at made the reconciler merge it with the node answering on that address,
|
||||||
|
which produced a resource that was its own parent.
|
||||||
|
|
||||||
|
Every guest carries:
|
||||||
|
|
||||||
|
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
||||||
|
The MAC and the address on it are read from the same source, so they cannot be
|
||||||
|
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
||||||
|
zipped them by index, which attributed addresses to the wrong NIC on any
|
||||||
|
multi-NIC guest).
|
||||||
|
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
||||||
|
has an address.
|
||||||
|
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
||||||
|
a directory row traces back to the exact guest on the exact node.
|
||||||
|
|
||||||
|
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
||||||
|
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
||||||
|
172.x addresses would otherwise give the reconciler spurious matches.
|
||||||
|
|
||||||
|
A stopped VM still reports its MAC (read from the VM config rather than the
|
||||||
|
guest agent), and a DHCP-configured LXC gets its address from the running
|
||||||
|
container's interface list. Offline nodes are recorded with `status` rather than
|
||||||
|
skipped, so a hypervisor that is down does not look decommissioned and get
|
||||||
|
garbage-collected after a week.
|
||||||
|
|
||||||
A module exports a **manifest**:
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Vault Secrets
|
||||||
|
description: OpenBao-backed personal, shared, and external-app secret storage built into the SSO Manager UI.
|
||||||
|
---
|
||||||
|
|
||||||
# Vault Secrets Management
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
[← Back to Home](index.html)
|
||||||
|
|
||||||
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -26,13 +34,53 @@ You can access the Vault UI from the application's top navigation bar.
|
|||||||
|
|
||||||
### OpenBao Integration
|
### OpenBao Integration
|
||||||
|
|
||||||
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
The secrets are stored in a real, initialized-and-unsealed OpenBao backend
|
||||||
|
(`setup.sh` handles init/unseal on first run) — not OpenBao's ephemeral dev
|
||||||
|
mode, which auto-unseals with an in-memory store and loses everything on
|
||||||
|
restart. The default KV (Key-Value) version 2 engine is mounted at `secret/`.
|
||||||
|
The built-in UI proxies through `/api/vault/secret/…`, authenticated the same
|
||||||
|
way as the rest of the app (session cookie or a personal API token) — the
|
||||||
|
server resolves your OpenBao access itself and injects the right scoped
|
||||||
|
token; you never see or handle a raw OpenBao token as a UI user.
|
||||||
|
|
||||||
|
## Apps tab (admin)
|
||||||
|
|
||||||
|
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||||
|
|
||||||
|
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||||
|
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||||
|
|
||||||
|
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
|
|
||||||
|
## 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
|
## API Access
|
||||||
|
|
||||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
To read your own secrets programmatically, call the `/api/vault` proxy with
|
||||||
|
a [personal API token](concepts-api-tokens.html) — **not** a raw OpenBao
|
||||||
|
token. The server authenticates the request, resolves your own scoped
|
||||||
|
OpenBao access, and injects the real `X-Vault-Token` itself:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Example: Read a secret via the API
|
# Example: Read a secret via the API (KV-v2, so the path includes /data/)
|
||||||
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
curl -H "Authorization: Bearer sso_<id>_<secret>" \
|
||||||
|
https://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
An **external app** reading its own config uses the scoped token minted for
|
||||||
|
it on the **Apps** tab instead of a personal token — see *Apps tab (admin)*
|
||||||
|
above for how that token is minted and what it's confined to.
|
||||||
|
|
||||||
|
Using the OpenBao **root token** directly (bypassing the SSO entirely) is
|
||||||
|
never the intended path for day-to-day secret access — it's an
|
||||||
|
operator/maintenance credential (seeding, disaster recovery), kept in
|
||||||
|
`setup.env` and never passed to a service container. See
|
||||||
|
[theta-env's Secrets doc](https://theta42.github.io/theta-env/secrets.html)
|
||||||
|
for the full token/policy model.
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ app.onListen.push(function(){
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Initialize Theta Agent WebSockets
|
// Initialize Theta Agent WebSockets. The REST router is already mounted
|
||||||
require('./routes/api_agent')(app);
|
// synchronously above (see the /api/agent mount); this hook only wires the WS.
|
||||||
});
|
require('./routes/api_agent').initAgentWebSockets(app);
|
||||||
|
});
|
||||||
|
|
||||||
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||||
// uncompressed vendor JS/CSS files on every full page navigation (a
|
// 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.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
|
// theta-agent REST API. Mounted SYNCHRONOUSLY (before the 404 catch-all below),
|
||||||
|
// not from an onListen hook — a router registered post-listen would sit behind
|
||||||
|
// the terminal 404 handler and make every /api/agent/* request 404. The agent
|
||||||
|
// WebSocket handler (routes/api_agent.initAgentWebSockets) still runs on onListen.
|
||||||
|
app.use('/api/agent', require('./routes/api_agent'));
|
||||||
|
|
||||||
// OAuth 2.0 / OpenID Connect
|
// OAuth 2.0 / OpenID Connect
|
||||||
app.use('/oauth', oauthRouter);
|
app.use('/oauth', oauthRouter);
|
||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -33,8 +33,15 @@ Mail.send = function(to, subject, message, from){
|
|||||||
|
|
||||||
var transporter = nodemailer.createTransport(transportOpts);
|
var transporter = nodemailer.createTransport(transportOpts);
|
||||||
|
|
||||||
|
// Most authenticated SMTP relays (and this bit the field: "554 5.7.1
|
||||||
|
// ...: Sender is not same as SMTP authenticate username") require the
|
||||||
|
// envelope/header From to equal the authenticated user, or reject the
|
||||||
|
// send outright. If the operator hasn't set an explicit smtp.from,
|
||||||
|
// defaulting to the SMTP username is far more likely to actually send
|
||||||
|
// than a made-up noreply@theta42.com address that no relay authorized
|
||||||
|
// this account to send as.
|
||||||
var mailOpts = {
|
var mailOpts = {
|
||||||
from: from || conf.smtp.from || `${conf.name} Accounts <noreply@theta42.com>`,
|
from: from || conf.smtp.from || conf.smtp.user || `${conf.name} Accounts <noreply@theta42.com>`,
|
||||||
to: to,
|
to: to,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
html: message
|
html: message
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const { PluginInstance } = require('./plugin_instance');
|
|||||||
const { SharedSecret } = require('./shared_secret');
|
const { SharedSecret } = require('./shared_secret');
|
||||||
const { SharedSecretGrant } = require('./shared_secret_grant');
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
const { VaultAppToken } = require('./vault_app_token');
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -34,7 +35,7 @@ async function initORM() {
|
|||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
SharedSecret, SharedSecretGrant, VaultAppToken,
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ async function send(to, message) {
|
|||||||
const registry = require('../services/plugin_registry');
|
const registry = require('../services/plugin_registry');
|
||||||
const pluginSecrets = require('../utils/plugin_secrets');
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
|
||||||
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
// @simpleworkjs/orm has no `find` -- the query method is `list({where})`.
|
||||||
|
// `PluginInstance.find(...)` threw "is not a function" on EVERY call into
|
||||||
|
// this sender, so SMS delivery never worked at all: not the test button, not
|
||||||
|
// OTP-by-SMS, not notifications. It failed before it could even fall back to
|
||||||
|
// the direct VoIP.ms path below.
|
||||||
|
const instances = await PluginInstance.list({ where: { category: 'messaging', enabled: true } });
|
||||||
if (instances.length > 0) {
|
if (instances.length > 0) {
|
||||||
const inst = instances[0];
|
const inst = instances[0];
|
||||||
const manifest = registry.getManifest(inst.pluginType);
|
const manifest = registry.getManifest(inst.pluginType);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.30.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.30.2",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.26.1",
|
"version": "1.30.2",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,15 @@ module.exports = {
|
|||||||
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||||
configSchema: [
|
configSchema: [
|
||||||
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
{ 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) => {
|
validate: async (config) => {
|
||||||
@@ -48,23 +56,57 @@ module.exports = {
|
|||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
const stackProject = (config.stackProject || '').trim();
|
||||||
|
const hostSlug = (config.hostSlug || '').trim();
|
||||||
|
|
||||||
for (const c of containers) {
|
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 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 ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||||
|
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'container',
|
kind: 'container',
|
||||||
name: name,
|
name: composeService || name,
|
||||||
slug: slug,
|
slug: slug,
|
||||||
metadata: {
|
metadata: {
|
||||||
image: c.Image,
|
image: c.Image,
|
||||||
state: c.State,
|
state: c.State,
|
||||||
status: c.Status,
|
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 });
|
resolve({ resources, edges });
|
||||||
|
|||||||
@@ -6,6 +6,81 @@ const agent = new https.Agent({
|
|||||||
rejectUnauthorized: false
|
rejectUnauthorized: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
|
||||||
|
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
|
||||||
|
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
|
||||||
|
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
|
||||||
|
// the pairing honest -- the previous code collected MACs and IPs into two flat
|
||||||
|
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
|
||||||
|
class Interfaces {
|
||||||
|
constructor() { this.byMac = new Map(); this.anonymous = []; }
|
||||||
|
|
||||||
|
// Interfaces that belong to something running INSIDE the guest -- container
|
||||||
|
// engines, overlay networks, VPNs -- rather than to the guest itself. A
|
||||||
|
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
|
||||||
|
// alongside its one real NIC, which is noise in the directory and, worse,
|
||||||
|
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
|
||||||
|
// Only applied to guests; a hypervisor's own bridges are how you reach it.
|
||||||
|
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
|
||||||
|
|
||||||
|
static isVirtualName(name) {
|
||||||
|
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
|
||||||
|
// the only place the Proxmox node network API exposes a physical NIC's MAC
|
||||||
|
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
|
||||||
|
// rather than leaving every hypervisor MAC-less.
|
||||||
|
static macFromIfaceName(name) {
|
||||||
|
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
|
||||||
|
if (!m) return null;
|
||||||
|
return m[1].toLowerCase().match(/.{2}/g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalizeMac(mac) {
|
||||||
|
const m = (mac || '').toLowerCase().trim();
|
||||||
|
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
|
||||||
|
if (m === '00:00:00:00:00:00') return null;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ips` are the addresses observed on this one NIC (may be empty for a
|
||||||
|
// stopped guest, where only the MAC is known).
|
||||||
|
add(mac, ips, name) {
|
||||||
|
const key = Interfaces.normalizeMac(mac);
|
||||||
|
const addrs = (ips || []).filter(Boolean);
|
||||||
|
if (!key) {
|
||||||
|
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
|
||||||
|
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.byMac.get(key);
|
||||||
|
if (existing) {
|
||||||
|
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
|
||||||
|
existing.ip = existing.ips[0] || null;
|
||||||
|
if (!existing.name && name) existing.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
|
||||||
|
|
||||||
|
// The address/MAC the directory shows in its single-value columns, and what
|
||||||
|
// the reconciler matches on. Prefer a NIC that actually has an address.
|
||||||
|
primaryIp() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip);
|
||||||
|
return withIp ? withIp.ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryMac() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip && i.mac);
|
||||||
|
if (withIp) return withIp.mac;
|
||||||
|
const first = this.toArray().find(i => i.mac);
|
||||||
|
return first ? first.mac : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
// drives the admin UI form and validation; fields flagged `secret:true` are
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
@@ -50,6 +125,45 @@ module.exports = {
|
|||||||
const resources = [];
|
const resources = [];
|
||||||
const edges = [];
|
const edges = [];
|
||||||
|
|
||||||
|
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
|
||||||
|
// several unrelated roots in the Directory tree and nothing says where any
|
||||||
|
// of them came from. Every node discovered below is parented to this, so
|
||||||
|
// one endpoint == one subtree.
|
||||||
|
const endpointHost = (() => {
|
||||||
|
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
|
||||||
|
})();
|
||||||
|
const clusterName = await (async () => {
|
||||||
|
// /cluster/status names the cluster when one exists; a standalone node
|
||||||
|
// has no cluster entry, in which case the endpoint hostname is the name.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
|
||||||
|
return entry ? entry.name : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: clusterName || `Proxmox (${endpointHost})`,
|
||||||
|
slug: endpointSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'proxmox',
|
||||||
|
address: url,
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
sourceId: url,
|
||||||
|
// Deliberately NO `ip`/`interfaces`: this resource stands for the
|
||||||
|
// cluster (the API endpoint), not for a machine. Giving it the address
|
||||||
|
// it is reached at made the reconciler match it to the very node that
|
||||||
|
// answers on that address -- the endpoint and the node collapsed into
|
||||||
|
// one row, which then became its own parent. The cluster is identified
|
||||||
|
// by slug + sourceId instead, which nothing else can collide with.
|
||||||
|
interfaces: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// 1. Get Nodes
|
// 1. Get Nodes
|
||||||
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
if(!resNodes.ok) {
|
if(!resNodes.ok) {
|
||||||
@@ -59,9 +173,57 @@ module.exports = {
|
|||||||
const nodes = (await resNodes.json()).data;
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
for (const node of nodes) {
|
for (const node of nodes) {
|
||||||
if (node.status !== 'online') continue;
|
// An offline node is still a real hypervisor that belongs in the
|
||||||
|
// directory -- skipping it entirely used to make it look decommissioned
|
||||||
|
// and let the reconciler's garbage collector archive it after a week of
|
||||||
|
// downtime. Record it, mark it down, and skip only the guest enumeration
|
||||||
|
// (which needs the node to answer).
|
||||||
|
const online = node.status === 'online';
|
||||||
|
|
||||||
const nodeSlug = `pve-node-${node.node}`;
|
const nodeSlug = `pve-node-${node.node}`;
|
||||||
|
|
||||||
|
// A hypervisor with no address is not actionable. Read its bridges/NICs
|
||||||
|
// so the node lands in the directory reachable and MAC-identified like
|
||||||
|
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
|
||||||
|
// normally the address you actually reach the hypervisor on.
|
||||||
|
const nodeIfaces = new Interfaces();
|
||||||
|
try {
|
||||||
|
const netRes = online
|
||||||
|
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
|
||||||
|
: { ok: false };
|
||||||
|
if (netRes.ok) {
|
||||||
|
const ifaceList = (await netRes.json()).data || [];
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
if (iface.iface === 'lo') continue;
|
||||||
|
const ip = iface.address || iface.cidr;
|
||||||
|
// This endpoint has no hwaddr field, so the MAC has to be recovered
|
||||||
|
// from a predictable interface name -- either this interface's own
|
||||||
|
// or, for a bridge, one of the physical ports beneath it.
|
||||||
|
let mac = Interfaces.macFromIfaceName(iface.iface);
|
||||||
|
if (!mac) {
|
||||||
|
for (const alt of (iface.altnames || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mac && iface.bridge_ports) {
|
||||||
|
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
|
||||||
|
mac = Interfaces.macFromIfaceName(port);
|
||||||
|
if (mac) break;
|
||||||
|
// The port may itself only carry the MAC in an altname.
|
||||||
|
const portDef = ifaceList.find(i => i.iface === port);
|
||||||
|
for (const alt of ((portDef && portDef.altnames) || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: 'host',
|
kind: 'host',
|
||||||
name: node.node,
|
name: node.node,
|
||||||
@@ -70,9 +232,19 @@ module.exports = {
|
|||||||
subType: 'hypervisor',
|
subType: 'hypervisor',
|
||||||
os: 'Proxmox VE',
|
os: 'Proxmox VE',
|
||||||
isProduction: true,
|
isProduction: true,
|
||||||
interfaces: []
|
status: node.status,
|
||||||
|
sourceId: `${node.node}`,
|
||||||
|
node: node.node,
|
||||||
|
interfaces: nodeIfaces.toArray(),
|
||||||
|
macAddress: nodeIfaces.primaryMac(),
|
||||||
|
ip: nodeIfaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
|
||||||
|
|
||||||
|
// Everything below asks the node itself; an offline node answers none of
|
||||||
|
// it, and its guests are already recorded from previous runs.
|
||||||
|
if (!online) continue;
|
||||||
|
|
||||||
// 2. Get VMs for this node
|
// 2. Get VMs for this node
|
||||||
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
@@ -82,10 +254,12 @@ module.exports = {
|
|||||||
const vmSlug = `vm-${vm.vmid}`;
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
const isTemplate = vm.template === 1;
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
// Enrich from QEMU guest agent if running. The agent is the only source
|
||||||
// Enrich from QEMU guest agent if running
|
// that knows which IP sits on which NIC, so pair them here rather than
|
||||||
|
// accumulating two flat lists (zipping those by index attributed IPs to
|
||||||
|
// the wrong MAC on any guest with more than one NIC).
|
||||||
if (vm.status === 'running') {
|
if (vm.status === 'running') {
|
||||||
try {
|
try {
|
||||||
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
@@ -93,35 +267,35 @@ module.exports = {
|
|||||||
const agentData = (await agentRes.json()).data;
|
const agentData = (await agentRes.json()).data;
|
||||||
if (agentData && agentData.result) {
|
if (agentData && agentData.result) {
|
||||||
for (const iface of agentData.result) {
|
for (const iface of agentData.result) {
|
||||||
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
|
// Docker bridges, veth pairs and VPN tunnels are the
|
||||||
if (iface['ip-addresses']) {
|
// guest's own plumbing, not NICs of the guest.
|
||||||
for (const ip of iface['ip-addresses']) {
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
|
const ips = (iface['ip-addresses'] || [])
|
||||||
ips.push(ip['ip-address']);
|
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
|
||||||
}
|
.map(ip => ip['ip-address']);
|
||||||
}
|
ifaces.add(iface['hardware-address'], ips, iface.name);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich from VM config to at least get MAC if agent failed/stopped
|
// Enrich from VM config: the MAC is declared there whether or not the
|
||||||
|
// guest agent answered, so a stopped VM still gets a stable identity.
|
||||||
try {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
if (confData[`net${i}`]) {
|
||||||
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
|
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
|
||||||
if(m) macs.push(m[1].toLowerCase());
|
if(m) ifaces.add(m[1], [], `net${i}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -130,9 +304,14 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'vm',
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
vmid: vm.vmid,
|
vmid: vm.vmid,
|
||||||
|
// The Proxmox-side identity, so a resource can be traced back to the
|
||||||
|
// exact guest on the exact node it was discovered from.
|
||||||
|
sourceId: `${node.node}/qemu/${vm.vmid}`,
|
||||||
|
node: node.node,
|
||||||
isProduction: vm.status === 'running',
|
isProduction: vm.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
@@ -146,26 +325,45 @@ module.exports = {
|
|||||||
const lxcSlug = `lxc-${lxc.vmid}`;
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
const isTemplate = lxc.template === 1;
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
let ips = [];
|
const ifaces = new Interfaces();
|
||||||
let macs = [];
|
|
||||||
|
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
|
||||||
// Enrich from LXC config
|
// so read them off the same line instead of into parallel lists.
|
||||||
try {
|
try {
|
||||||
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
if (configRes.ok) {
|
if (configRes.ok) {
|
||||||
const confData = (await configRes.json()).data;
|
const confData = (await configRes.json()).data;
|
||||||
for (let i = 0; i < 10; i++) {
|
for (let i = 0; i < 10; i++) {
|
||||||
if (confData[`net${i}`]) {
|
const line = confData[`net${i}`];
|
||||||
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
|
if (!line) continue;
|
||||||
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
|
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
|
||||||
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
|
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
|
||||||
if(ipMatch) ips.push(ipMatch[1]);
|
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
const nameMatch = line.match(/\bname=([^,]+)/);
|
||||||
|
if (hwMatch || ipMatch) {
|
||||||
|
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch(e) {}
|
} catch(e) {}
|
||||||
|
|
||||||
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
|
// A DHCP-configured container has no IP in its config. Ask the running
|
||||||
|
// container's interface list so it lands in the directory addressable
|
||||||
|
// instead of as an IP-less row.
|
||||||
|
if (lxc.status === 'running' && !ifaces.primaryIp()) {
|
||||||
|
try {
|
||||||
|
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
|
||||||
|
if (ifRes.ok) {
|
||||||
|
for (const iface of ((await ifRes.json()).data || [])) {
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ip = (iface.inet || '').split('/')[0];
|
||||||
|
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
resources.push({
|
resources.push({
|
||||||
kind: isTemplate ? 'template' : 'host',
|
kind: isTemplate ? 'template' : 'host',
|
||||||
@@ -174,9 +372,12 @@ module.exports = {
|
|||||||
metadata: {
|
metadata: {
|
||||||
subType: isTemplate ? 'template' : 'lxc',
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
vmid: lxc.vmid,
|
vmid: lxc.vmid,
|
||||||
|
sourceId: `${node.node}/lxc/${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
isProduction: lxc.status === 'running',
|
isProduction: lxc.status === 'running',
|
||||||
interfaces,
|
interfaces,
|
||||||
ip: ips[0] || null
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
@@ -190,5 +391,8 @@ module.exports = {
|
|||||||
// `discover` as their implementation name for back-compat, and `run` is just
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
// an alias. Referenced via module.exports (not `this`) so it survives being
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
// detached and called as a bare function reference.
|
// detached and called as a bare function reference.
|
||||||
run: async (config) => module.exports.discover(config)
|
run: async (config) => module.exports.discover(config),
|
||||||
|
|
||||||
|
// Exported for unit tests only -- not part of the plugin contract.
|
||||||
|
_Interfaces: Interfaces
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,121 +4,391 @@ const express = require('express');
|
|||||||
const middleware = require('../middleware/auth');
|
const middleware = require('../middleware/auth');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const agentManager = require('../utils/agent_manager');
|
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'];
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
module.exports = function initAgentWebSockets(app) {
|
// Commands that can change or run code on the host. They are signed with the
|
||||||
if (!app.wss) {
|
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||||
console.warn("WebSocket server for agents is not initialized.");
|
// its agent.yml.
|
||||||
return;
|
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 url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||||
|
const remoteAddr = req.socket.remoteAddress;
|
||||||
|
|
||||||
if (!token) {
|
// Authenticate BEFORE doing anything else: no registration, no welcome
|
||||||
ws.close(4001, 'Unauthorized: Missing token');
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteAddr = req.socket.remoteAddress;
|
if (!agent) {
|
||||||
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
|
// 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 {
|
try {
|
||||||
const data = JSON.parse(message);
|
const data = JSON.parse(message);
|
||||||
if (!data || typeof data.type !== 'string') return;
|
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 || {};
|
const payload = data.payload || {};
|
||||||
|
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case 'discovery':
|
case 'discovery':
|
||||||
agentManager.handleDiscovery(token, payload);
|
await agentManager.handleDiscovery(current, payload);
|
||||||
if (app.io) app.io.emit('agent.discovery', { token, payload });
|
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
|
||||||
break;
|
break;
|
||||||
case 'telemetry':
|
case 'telemetry':
|
||||||
agentManager.handleTelemetry(token, payload);
|
await agentManager.handleTelemetry(current, payload);
|
||||||
if (app.io) app.io.emit('agent.telemetry', { token, payload });
|
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
|
||||||
break;
|
break;
|
||||||
case 'heartbeat':
|
case 'heartbeat':
|
||||||
agentManager.handleHeartbeat(token, payload, ws);
|
await agentManager.handleHeartbeat(current, payload, ws);
|
||||||
break;
|
break;
|
||||||
case 'response':
|
case 'response':
|
||||||
agentManager.handleResponse(token, payload);
|
await agentManager.handleResponse(current, payload);
|
||||||
if (app.io) app.io.emit('agent.response', { token, payload });
|
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
|
||||||
break;
|
break;
|
||||||
default:
|
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) {
|
} catch (err) {
|
||||||
console.error("[Theta Agent] Error parsing message:", err);
|
console.error('[Theta Agent] Error handling message:', err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ws.on('close', () => {
|
ws.on('close', () => {
|
||||||
console.log(`[Theta Agent] Agent disconnected (${token})`);
|
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
|
||||||
agentManager.unregisterAgent(token, ws);
|
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 {
|
try {
|
||||||
ws.send(JSON.stringify({
|
const payload = {
|
||||||
type: 'config',
|
message: 'Connected to SSO Manager C2',
|
||||||
payload: {
|
protocol_version: '1.2.0',
|
||||||
message: 'Connected to SSO Manager C2',
|
agent_id: agent.id
|
||||||
protocol_version: '1.1.0'
|
};
|
||||||
}
|
if (issuedToken) {
|
||||||
}));
|
payload.enrolled = true;
|
||||||
|
payload.auth_token = issuedToken;
|
||||||
|
payload.public_key = await agentManager.publicKeyBase64();
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'config', payload }));
|
||||||
} catch (e) {}
|
} 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);
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -136,15 +136,25 @@ router.post('/test-email', async (req, res, next) => {
|
|||||||
return res.status(400).json({ error: 'Recipient email address is required' });
|
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the email model to send the test message
|
// Send through the SAME sender every other feature uses (password reset,
|
||||||
const Email = require('../models/email');
|
// invites, OTP-by-email, notifications). A "test" that reimplements
|
||||||
|
// delivery proves nothing about whether real mail works.
|
||||||
|
//
|
||||||
|
// models/email.js exports `{Mail}`; requiring the module and calling
|
||||||
|
// `.send` on it directly -- as this did -- always threw
|
||||||
|
// "Email.send is not a function", so the button could never succeed.
|
||||||
|
const { Mail } = require('../models/email');
|
||||||
const testSubject = subject || 'SSO Manager Test Email';
|
const testSubject = subject || 'SSO Manager Test Email';
|
||||||
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||||
|
|
||||||
await Email.send(to, testSubject, testBody);
|
await Mail.send(to, testSubject, testBody);
|
||||||
res.json({ success: true, message: `Test email sent to ${to}` });
|
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
next(err);
|
// A failed test is almost always a misconfiguration (wrong host, refused
|
||||||
|
// connection, bad credentials) -- the operator's to fix, and something the
|
||||||
|
// UI should be able to show them. Surfacing it as a 400 with the reason
|
||||||
|
// beats an opaque 500 carrying a raw stack-trace name.
|
||||||
|
return res.status(400).json({ error: err.message || 'Failed to send test email' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,38 +166,37 @@ router.post('/test-sms', async (req, res, next) => {
|
|||||||
return res.status(400).json({ error: 'Recipient phone number is required' });
|
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Send through models/sms.js -- the same path every real SMS takes. It
|
||||||
|
// prefers a configured messaging plugin and falls back to VoIP.ms, and it
|
||||||
|
// normalizes the destination to E.164 digits.
|
||||||
|
//
|
||||||
|
// This used to POST to `https://api.voip.ms/v1.0/sms/send` with Basic auth.
|
||||||
|
// No such endpoint exists: VoIP.ms's REST API is a GET against
|
||||||
|
// `https://voip.ms/api/v1/rest.php` with `api_username`/`api_password` and
|
||||||
|
// `method=sendSMS`. The fabricated URL returned an HTML page, so
|
||||||
|
// `response.json()` threw `Unexpected token '<', "<!DOCTYPE "...` and the
|
||||||
|
// button reported that as the failure. It could never have sent anything.
|
||||||
|
const { SMS } = require('../models/sms');
|
||||||
|
const { PluginInstance } = require('../models/plugin_instance');
|
||||||
|
|
||||||
|
// A messaging plugin, when present, supplies its own credentials -- so
|
||||||
|
// requiring conf.voipms unconditionally would block a perfectly working
|
||||||
|
// setup from testing itself.
|
||||||
|
const messagingPlugins = await PluginInstance.list({ where: { category: 'messaging', enabled: true } }).catch(() => []);
|
||||||
const voipmsConf = conf.voipms || {};
|
const voipmsConf = conf.voipms || {};
|
||||||
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
|
if (!messagingPlugins.length && (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did)) {
|
||||||
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
|
return res.status(400).json({ error: 'No messaging plugin is loaded and VoIP.ms credentials are not configured. Set username, DID and password in the SMS tab, or load a messaging plugin.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your VoIP.ms configuration is working correctly.`;
|
const testMessage = message || `SSO Manager Test SMS: This is a test message from ${conf.name}. If you received this, your SMS configuration is working correctly.`;
|
||||||
|
|
||||||
// VoIP.ms SMS API endpoint
|
await SMS.send(to, testMessage);
|
||||||
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
|
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||||
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
|
|
||||||
|
|
||||||
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
|
||||||
'Authorization': `Basic ${authHeader}`,
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded'
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
did: voipmsConf.did,
|
|
||||||
to: to,
|
|
||||||
message: testMessage
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await response.json();
|
|
||||||
if (result.status === 'success') {
|
|
||||||
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
|
||||||
} else {
|
|
||||||
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
|
|
||||||
}
|
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
next(err);
|
// The sender rejects with a useful reason (`VoIP.ms error: <status>`, or a
|
||||||
|
// plugin's own error). Surface it as a 400 the UI can display rather than
|
||||||
|
// an opaque 500 -- a misconfiguration is the operator's to fix, not a bug.
|
||||||
|
return res.status(400).json({ error: err.message || 'Failed to send test SMS' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
// 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
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
// older release gets its groups without a rebuild:
|
// older release gets its groups without a rebuild:
|
||||||
@@ -79,19 +90,20 @@ async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
|||||||
// groups as member.
|
// groups as member.
|
||||||
const link = async (cn, isAdmin) => {
|
const link = async (cn, isAdmin) => {
|
||||||
if (!siteResourceId) return;
|
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);
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
await link(sAdmin, true);
|
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']) {
|
for (const kind of ['host', 'app']) {
|
||||||
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
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.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
await link(groups.siteEveryoneCns(siteSlug), false);
|
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
|
// 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).
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
// The specific group name uses the resource's slug verbatim
|
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
|
||||||
// (`{site}_{slug}_{level}` -- the kind is carried in the slug, e.g. `host_theta-env`);
|
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
|
||||||
// `kind` (host/app) selects which aggregate the group nests into:
|
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
|
||||||
//
|
//
|
||||||
// {site}_{slug}_admin -> {site}_{slug}_access
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
|
||||||
// {site}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
// {site}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
// god_admin -> {site}_{slug}_admin (global super admin)
|
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
|
||||||
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
const accessCn = groups.resourceGroupCns(siteSlug, resource.slug, 'access');
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
const adminCn = groups.resourceGroupCns(siteSlug, resource.slug, 'admin');
|
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(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
await ensureGroup(adminCn, ownerDn, `Admin 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.
|
// 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 ensureResourceGroup(resource.id, accessCn, 'member');
|
||||||
await ResourceGroup.create({ resourceId: resource.id, groupCn: adminCn, accessLevel: 'owner' }).catch(() => {});
|
await ensureResourceGroup(resource.id, adminCn, 'owner');
|
||||||
|
|
||||||
await nestGroup(adminCn, accessCn); // administering implies using
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
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(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
|
// 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.
|
// capability groups following the same shapes.
|
||||||
function validGroupCnsForResource(resource, siteSlug) {
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
const valid = new Set();
|
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') {
|
if (resource.kind === 'site') {
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
valid.add(groups.siteEveryoneCns(siteSlug));
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
for (const k of ['host', 'app']) {
|
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
|
||||||
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-]+$`) };
|
|
||||||
}
|
}
|
||||||
const kind = groupKind(resource); // 'host'|'app'|null
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
if (kind) {
|
if (kind) {
|
||||||
const slug = resource.slug; // verbatim (kind is carried in the slug)
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
valid.add(groups.resourceGroupCns(siteSlug, slug, 'admin'));
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
|
||||||
valid.add(groups.resourceGroupCns(siteSlug, slug, 'access'));
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
valid.add(groups.siteEveryoneCns(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.
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
valid.add(groups.siteSuperAdminCns(siteSlug));
|
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 });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,10 @@ const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
|||||||
const vaultBroker = require('../utils/vault_broker');
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
|
||||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
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();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -77,7 +80,9 @@ router.get('/', async (req, res, next) => {
|
|||||||
if (byId.has(g.id)) continue; // already owner
|
if (byId.has(g.id)) continue; // already owner
|
||||||
byId.set(g.id, { role: 'grantee', ...g });
|
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); }
|
} catch (e) { next(e); }
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,7 +91,7 @@ router.post('/', async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
const uid = req.user.uid;
|
const uid = req.user.uid;
|
||||||
const slug = String(req.body.slug || '').trim().toLowerCase();
|
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 description = String(req.body.description || '').trim();
|
||||||
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||||
|
|
||||||
|
|||||||
@@ -186,8 +186,11 @@ router.post('/promote/:slug', async (req, res, next) => {
|
|||||||
|
|
||||||
const meta = resource.metadata || {};
|
const meta = resource.metadata || {};
|
||||||
meta.managed = true;
|
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] }));
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,8 +34,12 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
// `agents` pointed at plugins.md, so docs/agents.md -- the theta-agent
|
||||||
|
// guide the Directory links to -- was unreachable in the app.
|
||||||
|
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
|
||||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||||
|
// The Discovery tab's help icon links here; without an entry it 404'd.
|
||||||
|
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||||
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,64 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
|||||||
const { WebhookEmitter } = require('./webhook_emitter');
|
const { WebhookEmitter } = require('./webhook_emitter');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
|
||||||
|
// edge that would close a loop. Carries its own visited set so it terminates
|
||||||
|
// even if the stored graph already contains a cycle from an older release.
|
||||||
|
function isDescendant(candidateId, rootId, edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (id === candidateId) return true;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
class DiscoveryReconciler {
|
class DiscoveryReconciler {
|
||||||
static async reconcile(sourceName, payload) {
|
static async reconcile(sourceName, payload) {
|
||||||
const { resources = [], edges = [] } = payload;
|
const { resources = [], edges = [] } = payload;
|
||||||
let newDevices = 0;
|
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) {
|
for (const res of resources) {
|
||||||
if (!res.metadata) res.metadata = {};
|
if (!res.metadata) res.metadata = {};
|
||||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
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)
|
// 1. Attempt matching by MAC (highest precision)
|
||||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||||
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||||
if (macs.length > 0) {
|
if (macs.length > 0) {
|
||||||
existing = allRes.find(r =>
|
existing = candidates.find(r =>
|
||||||
r.metadata && (
|
r.metadata && (
|
||||||
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
(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))];
|
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||||
|
|
||||||
if (!existing && ipsToMatch.length > 0) {
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
existing = allRes.find(r => {
|
existing = candidates.find(r => {
|
||||||
if (!r.metadata) return false;
|
if (!r.metadata) return false;
|
||||||
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||||
if (r.metadata.address) {
|
if (r.metadata.address) {
|
||||||
@@ -57,7 +95,7 @@ class DiscoveryReconciler {
|
|||||||
// 3. Fallback matching by Slug, Name, or Base Hostname
|
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||||
if (!existing && (res.slug || res.name)) {
|
if (!existing && (res.slug || res.name)) {
|
||||||
const inputName = normalizeHost(res.name || res.slug);
|
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.slug && r.slug === res.slug) return true;
|
||||||
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
|
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
|
||||||
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
|
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
|
||||||
@@ -93,10 +131,33 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
mergedMeta.last_seen = Date.now();
|
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;
|
let bestName = existing.name;
|
||||||
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
|
if (res.name) {
|
||||||
bestName = res.name;
|
const incoming = nameRank(res.name);
|
||||||
|
const current = nameRank(bestName);
|
||||||
|
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
|
||||||
|
bestName = res.name;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await existing.update({
|
await existing.update({
|
||||||
@@ -125,12 +186,17 @@ class DiscoveryReconciler {
|
|||||||
|
|
||||||
newDevices++;
|
newDevices++;
|
||||||
res._actualId = created.id; // Map original slug to actual ID
|
res._actualId = created.id; // Map original slug to actual ID
|
||||||
|
// Make it visible to the rest of THIS payload: a Proxmox run reports
|
||||||
|
// the endpoint, then its nodes, then their guests, and two of them can
|
||||||
|
// legitimately share a MAC/IP. Without this the same device could be
|
||||||
|
// created twice in a single run.
|
||||||
|
allRes.push(created);
|
||||||
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now process edges
|
// Now process edges. `allRes` above is already current -- rows created in
|
||||||
const allRes = await Resource.list();
|
// the loop were pushed onto it -- so no second full read is needed.
|
||||||
const existingEdges = await ResourceEdge.list();
|
const existingEdges = await ResourceEdge.list();
|
||||||
|
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
@@ -154,15 +220,37 @@ class DiscoveryReconciler {
|
|||||||
if (childResInDb) childId = childResInDb.id;
|
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) {
|
if (parentId && childId) {
|
||||||
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||||
if (!edgeExists) {
|
if (!edgeExists) {
|
||||||
await ResourceEdge.create({
|
const created = await ResourceEdge.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
parentId,
|
parentId,
|
||||||
childId,
|
childId,
|
||||||
relation: edge.relation
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ beforeAll(async () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
hostId = host.body.results.id;
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
// Creating a host auto-provisions <site>_<slug>_access / _admin.
|
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||||
accessGroupCn = `${siteSlug}_${hostSlug}_access`;
|
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||||
const adminGroupCn = `${siteSlug}_${hostSlug}_admin`;
|
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||||
|
|
||||||
// The creator is seeded into both groups -- groupOfNames requires at least
|
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||||
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||||
@@ -213,8 +214,9 @@ describe('Access requests — withdrawal', () => {
|
|||||||
expect(host.status).toBe(200);
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
// Same as the top-level setup: step out of the auto-created groups the
|
// Same as the top-level setup: step out of the auto-created groups the
|
||||||
// creator is seeded into, or this is a request for access already held.
|
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||||
for (const cn of [`${siteSlug}_${slug}_admin`, `${siteSlug}_${slug}_access`]) {
|
// or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||||
await request(app)
|
await request(app)
|
||||||
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
.set('auth-token', token);
|
.set('auth-token', token);
|
||||||
|
|||||||
@@ -1,11 +1,45 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
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 mockWs;
|
||||||
let sentMessages;
|
let sentMessages;
|
||||||
|
let agent;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
sentMessages = [];
|
sentMessages = [];
|
||||||
@@ -14,23 +48,30 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
|
|||||||
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
|
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
|
||||||
close: jest.fn()
|
close: jest.fn()
|
||||||
};
|
};
|
||||||
|
agent = stubAgent();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('registers agent and tracks initial connection state', () => {
|
test('registers an agent and reports it as connected', () => {
|
||||||
const record = agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
expect(record.token).toBe('test-token-123');
|
const state = agentManager.liveState(agent.id);
|
||||||
expect(record.ipAddress).toBe('192.168.1.100');
|
expect(state.connected).toBe(true);
|
||||||
|
expect(state.ipAddress).toBe('192.168.1.100');
|
||||||
const agents = agentManager.getConnectedAgents();
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
const found = agents.find(a => a.token === 'test-token-123');
|
|
||||||
expect(found).toBeDefined();
|
|
||||||
expect(found.isOnline).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processes discovery payload per PROTOCOL.md v1.1.0 Section 3.1', () => {
|
// registerAgent must not be async: the WS `message` listener is attached in
|
||||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
// 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',
|
hostname: 'node-01.local',
|
||||||
ip_addresses: ['192.168.1.100', '10.0.0.5'],
|
ip_addresses: ['192.168.1.100', '10.0.0.5'],
|
||||||
os: 'Ubuntu 24.04 LTS',
|
os: 'Ubuntu 24.04 LTS',
|
||||||
@@ -39,41 +80,34 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
|
|||||||
ram_total_gb: 32.0,
|
ram_total_gb: 32.0,
|
||||||
disk_total_gb: 500.0,
|
disk_total_gb: 500.0,
|
||||||
location: 'dc-chicago-rack-4'
|
location: 'dc-chicago-rack-4'
|
||||||
};
|
});
|
||||||
|
|
||||||
agentManager.handleDiscovery('test-token-123', discoveryPayload);
|
const saved = agent.persisted.lastDiscovery;
|
||||||
|
expect(saved.hostname).toBe('node-01.local');
|
||||||
const agents = agentManager.getConnectedAgents();
|
expect(saved.os).toBe('Ubuntu 24.04 LTS');
|
||||||
const agent = agents.find(a => a.token === 'test-token-123');
|
expect(saved.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
|
||||||
expect(agent.hostname).toBe('node-01.local');
|
// Durable, not just in memory: an agent that goes offline keeps its facts.
|
||||||
expect(agent.discovery.os).toBe('Ubuntu 24.04 LTS');
|
expect(agent.persisted.last_seen).toEqual(expect.any(Number));
|
||||||
expect(agent.discovery.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('processes telemetry payload per PROTOCOL.md v1.1.0 Section 3.2', () => {
|
test('persists telemetry to the agent row (Section 3.2)', async () => {
|
||||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleTelemetry(agent, {
|
||||||
const telemetryPayload = {
|
|
||||||
cpu_usage_percent: 14.5,
|
cpu_usage_percent: 14.5,
|
||||||
ram_usage_percent: 42.1,
|
ram_usage_percent: 42.1,
|
||||||
disk_usage_percent: 68.0,
|
disk_usage_percent: 68.0,
|
||||||
zfs_health: 'ONLINE',
|
zfs_health: 'ONLINE',
|
||||||
gpu_usage_percent: -1.0,
|
gpu_usage_percent: -1.0,
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
});
|
||||||
|
|
||||||
agentManager.handleTelemetry('test-token-123', telemetryPayload);
|
expect(agent.persisted.lastTelemetry.cpu_usage_percent).toBe(14.5);
|
||||||
|
expect(agent.persisted.lastTelemetry.zfs_health).toBe('ONLINE');
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('responds to heartbeat with heartbeat_ack per Section 3.3', () => {
|
test('responds to heartbeat with heartbeat_ack (Section 3.3)', async () => {
|
||||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleHeartbeat(agent, { timestamp: new Date().toISOString() }, mockWs);
|
||||||
agentManager.handleHeartbeat('test-token-123', { timestamp: new Date().toISOString() }, mockWs);
|
|
||||||
|
|
||||||
expect(mockWs.send).toHaveBeenCalled();
|
expect(mockWs.send).toHaveBeenCalled();
|
||||||
const lastMsg = sentMessages[sentMessages.length - 1];
|
const lastMsg = sentMessages[sentMessages.length - 1];
|
||||||
@@ -81,20 +115,92 @@ describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
|
|||||||
expect(lastMsg.payload.timestamp).toBeDefined();
|
expect(lastMsg.payload.timestamp).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('canonicalizes payload and signs high-risk commands using Ed25519 per Section 5', () => {
|
test('canonicalizes and signs high-risk commands with Ed25519 (Section 5)', async () => {
|
||||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
|
||||||
const rawPayload = { script: 'uptime', location: 'datacenter' };
|
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.type).toBe('arbitrary_bash');
|
||||||
expect(msg.payload.signature).toBeDefined();
|
|
||||||
expect(typeof msg.payload.signature).toBe('string');
|
expect(typeof msg.payload.signature).toBe('string');
|
||||||
|
|
||||||
// Verify signature with public key
|
const keys = await agentKeys.load();
|
||||||
const signatureBuffer = Buffer.from(msg.payload.signature, 'base64');
|
const isValid = crypto.verify(
|
||||||
const canonicalStr = agentManager.canonicalize(rawPayload);
|
null,
|
||||||
const isValid = crypto.verify(null, Buffer.from(canonicalStr, 'utf8'), agentManager.publicKeyPem, signatureBuffer);
|
Buffer.from(agentManager.canonicalize(rawPayload), 'utf8'),
|
||||||
|
crypto.createPublicKey(keys.publicKeyPem),
|
||||||
|
Buffer.from(msg.payload.signature, 'base64')
|
||||||
|
);
|
||||||
expect(isValid).toBe(true);
|
expect(isValid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The canonical form has to match the Go agent's byte for byte. Go's
|
||||||
|
// encoding/json escapes <, > and & by default and JSON.stringify does not, so
|
||||||
|
// the agent uses SetEscapeHTML(false); this pins the server's half of that
|
||||||
|
// contract. See theta-agent TestCanonicalizeMatchesServerForm.
|
||||||
|
test('canonical form is sorted, unescaped, and omits the signature', () => {
|
||||||
|
const canonical = agentManager.canonicalize({
|
||||||
|
script: 'echo a > b && c',
|
||||||
|
comment: 'x&y',
|
||||||
|
signature: 'should-not-appear'
|
||||||
|
});
|
||||||
|
expect(canonical).toBe('{"comment":"x&y","script":"echo a > b && c"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to send to an agent that is not connected', async () => {
|
||||||
|
await expect(agentManager.sendCommand(agent, 'reload_config', {}, false))
|
||||||
|
.rejects.toThrow(/not connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revocation that only applies on the next reconnect is not revocation.
|
||||||
|
test('disconnect drops the live socket immediately', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
|
||||||
|
const dropped = agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
|
||||||
|
expect(dropped).toBe(true);
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4003, 'Enrollment revoked');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second connection for the same agent supersedes the first', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
const secondWs = { readyState: 1, send: jest.fn(), close: jest.fn() };
|
||||||
|
agentManager.registerAgent(agent, secondWs, '192.168.1.101');
|
||||||
|
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4002, 'Superseded by new connection');
|
||||||
|
expect(agentManager.liveState(agent.id).ipAddress).toBe('192.168.1.101');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown agent id is simply not connected', () => {
|
||||||
|
expect(agentManager.isConnected('no-such-agent')).toBe(false);
|
||||||
|
expect(agentManager.liveState('no-such-agent')).toEqual({ connected: false, lastResponse: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agent signing key', () => {
|
||||||
|
// The old manager generated a key pair in its constructor, so it changed on
|
||||||
|
// every restart and the public_key pinned in agent.yml stopped matching.
|
||||||
|
test('the same key is returned across repeated loads', async () => {
|
||||||
|
const first = await agentKeys.load();
|
||||||
|
const second = await agentKeys.load();
|
||||||
|
expect(first.publicKeyBase64).toBe(second.publicKeyBase64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the exported public key is the raw 32 bytes agents pin', async () => {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
expect(Buffer.from(keys.publicKeyBase64, 'base64')).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rawPublicKeyBase64 strips the SPKI wrapper', () => {
|
||||||
|
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
||||||
|
});
|
||||||
|
const raw = Buffer.from(agentKeys.rawPublicKeyBase64(publicKey), 'base64');
|
||||||
|
expect(raw).toHaveLength(32);
|
||||||
|
// and it is the tail of the DER encoding
|
||||||
|
const der = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
|
||||||
|
expect(raw.equals(der.subarray(der.length - 32))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,11 +12,13 @@ const {
|
|||||||
GOD_ADMIN,
|
GOD_ADMIN,
|
||||||
} = require('../utils/groups');
|
} = require('../utils/groups');
|
||||||
|
|
||||||
// Resource fixtures mirror the directory's real slugs: hosts carry a `host_`
|
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
|
||||||
// prefix, services/apps are stored bare. The group-model builders use these
|
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
|
||||||
// verbatim (no re-slugifying, no kind insertion) -- see groups.js.
|
// 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 HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
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' };
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
@@ -30,9 +32,13 @@ describe('slugify', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('group cn builders', () => {
|
describe('group cn builders', () => {
|
||||||
test('per-resource uses the resource slug verbatim (kind is carried in the slug)', () => {
|
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', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
expect(resourceGroupCns('main-office', 'emby', 'access')).toBe('main-office_emby_access');
|
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', () => {
|
test('aggregate uses the plural kind', () => {
|
||||||
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
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(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
});
|
});
|
||||||
test('a directory site slug with a kind prefix is kept verbatim, not re-slugified', () => {
|
test('a directory site slug with a kind prefix is kept verbatim', () => {
|
||||||
// Resource slugs are `site_local` / `host_theta-env` -- re-slugifying the
|
|
||||||
// site (`site_local` -> `site-local`) would corrupt the delimiter.
|
|
||||||
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
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();
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,32 +93,37 @@ describe('hasPermission — inheritance', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('specific host group grants only that host', () => {
|
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], HOST, 'admin')).toBe(true);
|
||||||
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('admin implies access; access does not imply admin', () => {
|
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', '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', 'access')], HOST, 'admin')).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('capabilities are exact — admin does not grant a capability', () => {
|
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', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
expect(hasPermission([resourceGroupCns('main-office', 'host_web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
// aggregate capability
|
|
||||||
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('hosts and apps are orthogonal namespaces', () => {
|
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);
|
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);
|
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', () => {
|
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([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// @simpleworkjs/orm models expose `list`/`get`/`count`/`create` -- there is no
|
||||||
|
// `find`, `findOne`, `findAll` or `where`. Calling one is not a syntax error and
|
||||||
|
// nothing catches it until the line actually runs, so it can sit in a rarely
|
||||||
|
// exercised path indefinitely.
|
||||||
|
//
|
||||||
|
// It did: `models/sms.js` called `PluginInstance.find({...})`, which threw
|
||||||
|
// "is not a function" on EVERY SMS send -- the test button, OTP-by-SMS and
|
||||||
|
// notifications alike -- before it could even reach the VoIP.ms fallback. SMS
|
||||||
|
// delivery had simply never worked.
|
||||||
|
const ORM_MODELS = [
|
||||||
|
'Resource', 'ResourceEdge', 'ResourceGroup', 'AccessRequest', 'Webhook',
|
||||||
|
'PluginInstance', 'SharedSecret', 'SharedSecretGrant', 'VaultAppToken',
|
||||||
|
'Agent', 'AgentJoinKey',
|
||||||
|
];
|
||||||
|
const MISSING_STATICS = ['find', 'findOne', 'findAll', 'findAndCountAll', 'where'];
|
||||||
|
|
||||||
|
const ROOT = path.join(__dirname, '..');
|
||||||
|
const SCAN_DIRS = ['models', 'routes', 'services', 'utils', 'plugins', 'controller', 'middleware'];
|
||||||
|
|
||||||
|
function walk(dir, out = []) {
|
||||||
|
let entries;
|
||||||
|
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch (e) { return out; }
|
||||||
|
for (const entry of entries) {
|
||||||
|
const full = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === 'node_modules') continue;
|
||||||
|
walk(full, out);
|
||||||
|
} else if (entry.name.endsWith('.js')) {
|
||||||
|
out.push(full);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip comments so a line *describing* the bug (like the one in models/sms.js)
|
||||||
|
// isn't reported as the bug.
|
||||||
|
function stripComments(src) {
|
||||||
|
return src
|
||||||
|
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||||
|
.replace(/(^|[^:])\/\/.*$/gm, '$1');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('no source file calls an ORM static that does not exist', () => {
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`\\b(${ORM_MODELS.join('|')})\\s*\\.\\s*(${MISSING_STATICS.join('|')})\\s*\\(`,
|
||||||
|
'g'
|
||||||
|
);
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
const m = line.match(pattern);
|
||||||
|
if (m) offenders.push(`${path.relative(ROOT, file)}:${i + 1} — ${m.join(', ')}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// models/email.js exports `{Mail}`, not a bare sender. Requiring the module and
|
||||||
|
// calling `.send` on it -- as routes/api_conf.js's test-email did -- always
|
||||||
|
// threw "Email.send is not a function", so the Test Email button could never
|
||||||
|
// have worked.
|
||||||
|
test('the email module exports Mail.send and callers destructure it', () => {
|
||||||
|
const mod = require('../models/email');
|
||||||
|
expect(typeof mod.Mail).toBe('object');
|
||||||
|
expect(typeof mod.Mail.send).toBe('function');
|
||||||
|
// The bare module has no send() -- this is exactly the mistake to catch.
|
||||||
|
expect(mod.send).toBeUndefined();
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
// `X = require('...email')` followed by `X.send(` where X was not
|
||||||
|
// destructured.
|
||||||
|
const assigned = [...src.matchAll(/(?:const|let|var)\s+(\w+)\s*=\s*require\([^)]*models\/email[^)]*\)/g)]
|
||||||
|
.map(m => m[1]);
|
||||||
|
for (const name of assigned) {
|
||||||
|
if (new RegExp(`\\b${name}\\s*\\.\\s*send\\s*\\(`).test(src)) {
|
||||||
|
offenders.push(`${path.relative(ROOT, file)} — ${name}.send(), but the module exports {Mail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The VoIP.ms REST API is a GET against voip.ms/api/v1/rest.php with
|
||||||
|
// api_username/api_password and method=sendSMS. `api.voip.ms/v1.0/sms/send`
|
||||||
|
// (which test-sms used to POST to with Basic auth) does not exist -- it
|
||||||
|
// returned an HTML page, so response.json() threw
|
||||||
|
// `Unexpected token '<', "<!DOCTYPE "...` and the button reported that.
|
||||||
|
test('nothing targets the non-existent api.voip.ms host', () => {
|
||||||
|
const offenders = [];
|
||||||
|
for (const dir of SCAN_DIRS) {
|
||||||
|
for (const file of walk(path.join(ROOT, dir))) {
|
||||||
|
// Comments stripped: the note in routes/api_conf.js explaining this
|
||||||
|
// very bug names the bad host, and describing a mistake is not
|
||||||
|
// making it.
|
||||||
|
const src = stripComments(fs.readFileSync(file, 'utf8'));
|
||||||
|
src.split('\n').forEach((line, i) => {
|
||||||
|
if (line.includes('api.voip.ms')) {
|
||||||
|
offenders.push(`${path.relative(ROOT, file)}:${i + 1}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(offenders).toEqual([]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { _Interfaces: Interfaces } = require('../plugins/discovery/proxmox');
|
||||||
|
|
||||||
|
// Regression coverage for the MAC/IP mismatch: the plugin used to collect MACs
|
||||||
|
// and IPs into two flat lists and zip them by index, so on a multi-NIC guest
|
||||||
|
// -- or any guest where one NIC had no address -- the directory recorded an IP
|
||||||
|
// against the wrong MAC. Interfaces keys by MAC so a pairing can only come from
|
||||||
|
// the source that observed both together.
|
||||||
|
describe('proxmox Interfaces', () => {
|
||||||
|
test('keeps each IP on the NIC it was observed on', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('AA:BB:CC:00:00:01', ['10.0.0.5'], 'eth0');
|
||||||
|
i.add('AA:BB:CC:00:00:02', ['192.168.9.7'], 'eth1');
|
||||||
|
|
||||||
|
expect(i.toArray()).toEqual([
|
||||||
|
{ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5', ips: ['10.0.0.5'], name: 'eth0' },
|
||||||
|
{ mac: 'aa:bb:cc:00:00:02', ip: '192.168.9.7', ips: ['192.168.9.7'], name: 'eth1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a NIC with no address does not steal the next NIC\'s IP', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('AA:BB:CC:00:00:01', [], 'eth0'); // stopped/unconfigured
|
||||||
|
i.add('AA:BB:CC:00:00:02', ['10.0.0.9'], 'eth1');
|
||||||
|
|
||||||
|
const byMac = Object.fromEntries(i.toArray().map(x => [x.mac, x.ip]));
|
||||||
|
expect(byMac['aa:bb:cc:00:00:01']).toBeNull();
|
||||||
|
expect(byMac['aa:bb:cc:00:00:02']).toBe('10.0.0.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('merges the config MAC with the agent-reported address for the same NIC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', ['10.0.0.5'], 'eth0'); // guest agent
|
||||||
|
i.add('AA:BB:CC:00:00:01', [], 'net0'); // VM config, same NIC
|
||||||
|
expect(i.toArray()).toHaveLength(1);
|
||||||
|
expect(i.toArray()[0]).toMatchObject({ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collects multiple addresses on one NIC without inventing a second NIC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', ['10.0.0.5', '10.0.0.6'], 'eth0');
|
||||||
|
expect(i.toArray()).toHaveLength(1);
|
||||||
|
expect(i.toArray()[0].ips).toEqual(['10.0.0.5', '10.0.0.6']);
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ignores placeholder and malformed MACs', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('00:00:00:00:00:00', [], 'eth0');
|
||||||
|
i.add('not-a-mac', [], 'eth1');
|
||||||
|
i.add('', [], 'eth2');
|
||||||
|
expect(i.toArray()).toEqual([]);
|
||||||
|
expect(i.primaryMac()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('keeps an address that arrived without a usable MAC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add(null, ['10.0.0.5'], 'eth0');
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.5');
|
||||||
|
expect(i.primaryMac()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('primary values prefer a NIC that actually has an address', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', [], 'eth0');
|
||||||
|
i.add('aa:bb:cc:00:00:02', ['10.0.0.9'], 'eth1');
|
||||||
|
expect(i.primaryIp()).toBe('10.0.0.9');
|
||||||
|
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a fully unaddressed guest still reports its MAC', () => {
|
||||||
|
const i = new Interfaces();
|
||||||
|
i.add('aa:bb:cc:00:00:01', [], 'net0');
|
||||||
|
expect(i.primaryIp()).toBeNull();
|
||||||
|
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:01');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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 };
|
||||||
@@ -1,26 +1,19 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const crypto = require('crypto');
|
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 {
|
class AgentManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.agents = new Map(); // token -> agentRecord
|
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
|
||||||
this.privateKeyPem = null;
|
this.live = new Map();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,54 +30,89 @@ class AgentManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sign payload using Ed25519 private key.
|
* Sign payload using the persisted Ed25519 private key. Throws when no key is
|
||||||
* Returns base64 encoded signature.
|
* 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) {
|
async signPayload(payload) {
|
||||||
if (!this.privateKeyPem) {
|
const keys = await agentKeys.load();
|
||||||
throw new Error('Ed25519 private key is not initialized');
|
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 canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
|
||||||
const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem);
|
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
|
||||||
return signature.toString('base64');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
registerAgent(token, ws, remoteAddress) {
|
async publicKeyBase64() {
|
||||||
const existing = this.agents.get(token);
|
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) {
|
if (existing && existing.ws && existing.ws !== ws) {
|
||||||
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const agentRecord = {
|
this.live.set(agent.id, {
|
||||||
token,
|
|
||||||
ws,
|
ws,
|
||||||
ipAddress: remoteAddress,
|
ipAddress: remoteAddress,
|
||||||
hostname: 'unknown',
|
|
||||||
connectedAt: new Date().toISOString(),
|
connectedAt: new Date().toISOString(),
|
||||||
lastSeen: new Date().toISOString(),
|
lastResponse: null
|
||||||
discovery: {},
|
});
|
||||||
telemetry: {},
|
|
||||||
pendingResponses: new Map()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.agents.set(token, agentRecord);
|
agent.update({
|
||||||
return agentRecord;
|
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) {
|
unregisterAgent(agentId, ws) {
|
||||||
const record = this.agents.get(token);
|
const state = this.live.get(agentId);
|
||||||
if (record && record.ws === ws) {
|
if (state && state.ws === ws) this.live.delete(agentId);
|
||||||
this.agents.delete(token);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleDiscovery(token, payload) {
|
// Drop an agent's live socket now. Revocation that only takes effect on the
|
||||||
const agent = this.agents.get(token);
|
// next reconnect is not revocation -- a connected agent would keep receiving
|
||||||
if (!agent) return;
|
// 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();
|
isConnected(agentId) {
|
||||||
agent.hostname = payload.hostname || agent.hostname;
|
const state = this.live.get(agentId);
|
||||||
agent.discovery = {
|
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 || '',
|
hostname: payload.hostname || '',
|
||||||
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
|
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
|
||||||
os: payload.os || '',
|
os: payload.os || '',
|
||||||
@@ -94,28 +122,79 @@ class AgentManager {
|
|||||||
disk_total_gb: payload.disk_total_gb || 0,
|
disk_total_gb: payload.disk_total_gb || 0,
|
||||||
location: payload.location || 'default'
|
location: payload.location || 'default'
|
||||||
};
|
};
|
||||||
|
await this.touch(agent, { lastDiscovery: discovery });
|
||||||
|
await this.applyDiscoveryToDirectory(agent, discovery);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTelemetry(token, payload) {
|
// An agent runs ON the host it describes, which makes it the most
|
||||||
const agent = this.agents.get(token);
|
// authoritative source the directory has -- more so than a hypervisor API or
|
||||||
if (!agent) return;
|
// 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();
|
if (agent.resourceId) {
|
||||||
agent.telemetry = {
|
const resource = await Resource.get(agent.resourceId);
|
||||||
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
if (!resource) return;
|
||||||
ram_usage_percent: payload.ram_usage_percent || 0,
|
const merged = { ...(resource.metadata || {}), ...metadata };
|
||||||
disk_usage_percent: payload.disk_usage_percent || 0,
|
const sources = new Set(merged.discovery_sources || []);
|
||||||
zfs_health: payload.zfs_health || 'N/A',
|
sources.add('theta-agent');
|
||||||
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
merged.discovery_sources = [...sources];
|
||||||
timestamp: payload.timestamp || new Date().toISOString()
|
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
|
||||||
};
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleHeartbeat(token, payload, ws) {
|
if (!discovery.hostname) return;
|
||||||
const agent = this.agents.get(token);
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
if (agent) {
|
await DiscoveryReconciler.reconcile('theta-agent', {
|
||||||
agent.lastSeen = new Date().toISOString();
|
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 {
|
try {
|
||||||
ws.send(JSON.stringify({
|
ws.send(JSON.stringify({
|
||||||
type: 'heartbeat_ack',
|
type: 'heartbeat_ack',
|
||||||
@@ -124,57 +203,51 @@ class AgentManager {
|
|||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleResponse(token, payload) {
|
async handleResponse(agent, payload) {
|
||||||
const agent = this.agents.get(token);
|
const state = this.live.get(agent.id);
|
||||||
if (agent) {
|
if (state) {
|
||||||
agent.lastSeen = new Date().toISOString();
|
state.lastResponse = {
|
||||||
agent.lastResponse = {
|
|
||||||
status: payload.status || 'ok',
|
status: payload.status || 'ok',
|
||||||
message: payload.message || '',
|
message: payload.message || '',
|
||||||
output: payload.output || '',
|
output: payload.output || '',
|
||||||
timestamp: new Date().toISOString()
|
timestamp: new Date().toISOString()
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
await this.touch(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
sendCommand(token, commandType, payload = {}, isHighRisk = false) {
|
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
|
||||||
const agent = this.agents.get(token);
|
const state = this.live.get(agent.id);
|
||||||
if (!agent || !agent.ws || agent.ws.readyState !== 1) {
|
if (!state || !state.ws || state.ws.readyState !== 1) {
|
||||||
throw new Error(`Agent with token "${token}" is not connected`);
|
throw new Error(`Agent "${agent.name}" is not connected`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const finalPayload = { ...payload };
|
const finalPayload = { ...payload };
|
||||||
if (isHighRisk) {
|
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
|
||||||
finalPayload.signature = this.signPayload(finalPayload);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = {
|
const message = { type: commandType, payload: finalPayload };
|
||||||
type: commandType,
|
state.ws.send(JSON.stringify(message));
|
||||||
payload: finalPayload
|
|
||||||
};
|
|
||||||
|
|
||||||
agent.ws.send(JSON.stringify(message));
|
|
||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
getConnectedAgents() {
|
// Live view for one agent, for merging into its row.
|
||||||
const list = [];
|
liveState(agentId) {
|
||||||
const now = new Date();
|
const state = this.live.get(agentId);
|
||||||
for (const [token, agent] of this.agents.entries()) {
|
if (!state) return { connected: false, lastResponse: null };
|
||||||
list.push({
|
return {
|
||||||
token,
|
connected: !!(state.ws && state.ws.readyState === 1),
|
||||||
hostname: agent.hostname,
|
ipAddress: state.ipAddress,
|
||||||
ipAddress: agent.ipAddress,
|
connectedAt: state.connectedAt,
|
||||||
connectedAt: agent.connectedAt,
|
lastResponse: state.lastResponse || null
|
||||||
lastSeen: agent.lastSeen,
|
};
|
||||||
discovery: agent.discovery,
|
}
|
||||||
telemetry: agent.telemetry,
|
|
||||||
lastResponse: agent.lastResponse || null,
|
// Every enrolled agent, connected or not.
|
||||||
isOnline: (now - new Date(agent.lastSeen)) < 90000
|
async listAgents() {
|
||||||
});
|
const rows = await Agent.list();
|
||||||
}
|
return rows.map(a => a.toPublic(this.liveState(a.id)));
|
||||||
return list;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new AgentManager();
|
module.exports = new AgentManager();
|
||||||
|
module.exports.AgentManager = AgentManager;
|
||||||
|
|||||||
@@ -41,17 +41,23 @@ function assertKind(kind) {
|
|||||||
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
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.
|
// 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
|
||||||
// Both `site` and `slug` are the resource slugs verbatim (e.g. `site_local`,
|
// (`sso-manager`), so this is a no-op for them.
|
||||||
// `host_theta-env`), NOT slugified or kind-inserted: directory resource slugs
|
function resourceNameSlug(slug) {
|
||||||
// carry their kind as a prefix (`host_theta-env`), so `site_local` + `host_theta-env`
|
return String(slug || '').replace(/^(site|host|app)_/, '');
|
||||||
// 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
|
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
|
||||||
// all share -- re-slugifying or inserting a kind would double the delimiter.
|
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
|
||||||
function resourceGroupCns(site, slug, level) {
|
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
|
||||||
return `${site}_${slug}_${level}`;
|
// 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).
|
// {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
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
// the user's membership only.
|
// the user's membership only.
|
||||||
function hasPermission(memberOf, resource, level) {
|
function hasPermission(memberOf, resource, level) {
|
||||||
// `site` and `slug` are used verbatim (resource slugs may carry a kind prefix,
|
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
|
||||||
// e.g. `site_local` / `host_theta-env`) -- see resourceGroupCns.
|
// 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 site = resource && resource.site;
|
||||||
const kind = resource && resource.kind;
|
const rawKind = resource && resource.kind;
|
||||||
const slug = resource && resource.slug;
|
const kind = rawKind === 'service' ? 'app' : rawKind;
|
||||||
|
const nameSlug = resourceNameSlug(resource && resource.slug);
|
||||||
const set = new Set(memberOf || []);
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
if (set.has(GOD_ADMIN)) return true;
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
@@ -107,13 +115,13 @@ function hasPermission(memberOf, resource, level) {
|
|||||||
if (isKnownLevel(level)) {
|
if (isKnownLevel(level)) {
|
||||||
// admin / access
|
// admin / access
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
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;
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
// Opaque capability — exact aggregate or specific grant only.
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +130,7 @@ module.exports = {
|
|||||||
KNOWN_LEVELS,
|
KNOWN_LEVELS,
|
||||||
KINDS,
|
KINDS,
|
||||||
slugify,
|
slugify,
|
||||||
|
resourceNameSlug,
|
||||||
resourceGroupCns,
|
resourceGroupCns,
|
||||||
aggregateGroupCns,
|
aggregateGroupCns,
|
||||||
siteSuperAdminCns,
|
siteSuperAdminCns,
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
getOrCreateUserToken,
|
getOrCreateUserToken,
|
||||||
getOrCreateAdminToken,
|
getOrCreateAdminToken,
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
<%- include('top') %>
|
<%- 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="container mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<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>
|
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
<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;">
|
<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;">
|
<select id="sort-by" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||||
<option value="name">Name (A-Z)</option>
|
<option value="name">Name (A-Z)</option>
|
||||||
@@ -70,9 +85,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="resources-list" jq-repeat="resources">
|
<tbody id="resources-list" jq-repeat="resources">
|
||||||
<tr id="resource-row-{{id}}">
|
<tr id="resource-row-{{id}}" data-depth="{{depth}}">
|
||||||
<td class="ps-3">
|
<td class="ps-3">
|
||||||
{{{indentHtml}}}
|
{{{indentHtml}}}
|
||||||
|
{{{caretHtml}}}
|
||||||
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
|
{{#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>
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
@@ -138,6 +154,15 @@
|
|||||||
<div class="text-muted small">
|
<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}}
|
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
</div>
|
</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>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge bg-secondary me-1">{{kind}}</span>
|
<span class="badge bg-secondary me-1">{{kind}}</span>
|
||||||
@@ -149,15 +174,23 @@
|
|||||||
{{/metadata.subType}}
|
{{/metadata.subType}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
{{#displayIp}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{displayIp}}</div>{{/displayIp}}
|
||||||
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
{{^displayIp}}<span class="text-muted small fst-italic">Unknown IP</span>{{/displayIp}}
|
||||||
{{#metadata.interfaces.length}}
|
{{#metadata.interfaces.length}}
|
||||||
<div class="mt-1 small text-muted">
|
<div class="mt-1 small text-muted">
|
||||||
{{#metadata.interfaces}}
|
{{#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}}
|
{{/metadata.interfaces}}
|
||||||
</div>
|
</div>
|
||||||
{{/metadata.interfaces.length}}
|
{{/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>
|
||||||
<td>
|
<td>
|
||||||
{{#metadata.managed}}
|
{{#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>
|
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||||
</div>
|
</div>
|
||||||
</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 id="discovery-plugins-list" class="mt-3"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -515,6 +555,9 @@
|
|||||||
var rawResources = [];
|
var rawResources = [];
|
||||||
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
// resourceId -> { groups: [{cn, accessLevel, exists, memberCount}], memberCount }
|
||||||
var accessSummary = {};
|
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() {
|
$(document).ready(async function() {
|
||||||
await loadResources();
|
await loadResources();
|
||||||
@@ -529,7 +572,8 @@
|
|||||||
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
|
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
|
||||||
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
||||||
var agentsByHost = {};
|
var agentsByHost = {};
|
||||||
var agentsByToken = {};
|
var agentsByResource = {};
|
||||||
|
var agentsById = {};
|
||||||
// True when the agent/nodes endpoint itself was unreachable (network, or an
|
// 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
|
// 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
|
// 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) {
|
function indexAgents(agents) {
|
||||||
agentsByHost = {};
|
agentsByHost = {};
|
||||||
agentsByToken = {};
|
agentsByResource = {};
|
||||||
|
agentsById = {};
|
||||||
for (const a of agents || []) {
|
for (const a of agents || []) {
|
||||||
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
|
agentsById[a.id] = a;
|
||||||
if (hn) agentsByHost[hn] = a;
|
if (a.resourceId) agentsByResource[a.resourceId] = a;
|
||||||
if (a.token) agentsByToken[a.token] = 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.
|
// stores the agent on resourcesById so the Metrics tab can find it.
|
||||||
function attachAgentStatus(n) {
|
function attachAgentStatus(n) {
|
||||||
n.isHost = true;
|
n.isHost = true;
|
||||||
|
// Bound agent first; hostname match only for agents with no binding yet.
|
||||||
const name = (n.name || '').toLowerCase();
|
const name = (n.name || '').toLowerCase();
|
||||||
const slug = (n.slug || '').replace(/^host_/, '').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;
|
n.agent = a || null;
|
||||||
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||||
if (!a) {
|
if (!a) {
|
||||||
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
|
// Endpoint unreachable: we genuinely don't know -- neutral grey, not a
|
||||||
// false red alarm across every host.
|
// false red alarm across every host.
|
||||||
if (agentsUnavailable) { n.agentColor = '#adb5bd'; n.agentStatusTitle = 'Agent service unreachable'; return; }
|
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; }
|
if (a.revoked) { n.agentColor = '#6c757d'; n.agentStatusTitle = 'Agent enrollment revoked'; return; }
|
||||||
const t = a.telemetry || {};
|
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);
|
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||||
n.agentColor = high ? '#ffc107' : '#198754';
|
n.agentColor = high ? '#ffc107' : '#198754';
|
||||||
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||||
@@ -637,8 +700,8 @@
|
|||||||
if (!agent) {
|
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>';
|
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 d = agent.lastDiscovery || {};
|
||||||
const t = agent.telemetry || {};
|
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 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 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';
|
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) => {
|
const flatten = (nodes, depth) => {
|
||||||
nodes.forEach(n => {
|
nodes.forEach(n => {
|
||||||
let indentHtml = '';
|
let indentHtml = '';
|
||||||
@@ -783,6 +850,16 @@
|
|||||||
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
indentHtml += '<i class="fa-solid fa-turn-up fa-rotate-90 text-muted me-2"></i>';
|
||||||
}
|
}
|
||||||
n.indentHtml = indentHtml;
|
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);
|
n.accessHtml = accessCellHtml(n.id);
|
||||||
if (n.kind === 'host') attachAgentStatus(n);
|
if (n.kind === 'host') attachAgentStatus(n);
|
||||||
finalRenderList.push(n);
|
finalRenderList.push(n);
|
||||||
@@ -798,8 +875,110 @@
|
|||||||
for (const r of finalRenderList) {
|
for (const r of finalRenderList) {
|
||||||
$.scope.resources.push(r);
|
$.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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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() {
|
function toggleFormFields() {
|
||||||
const kind = $('#res-kind').val();
|
const kind = $('#res-kind').val();
|
||||||
if (kind === 'host') {
|
if (kind === 'host') {
|
||||||
@@ -1179,6 +1358,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function saveResource() {
|
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 id = $('#res-id').val();
|
||||||
const data = {
|
const data = {
|
||||||
name: $('#res-name').val(),
|
name: $('#res-name').val(),
|
||||||
@@ -1277,6 +1479,7 @@
|
|||||||
allGroups.push(res.results);
|
allGroups.push(res.results);
|
||||||
refreshGroupsUI(resourceId);
|
refreshGroupsUI(resourceId);
|
||||||
$('#new-group-cn').val('');
|
$('#new-group-cn').val('');
|
||||||
|
await loadResources(); // keep the Access column in sync
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
app.messages.action('Failed to add group', app.modal.body(), 'danger');
|
||||||
@@ -1288,6 +1491,7 @@
|
|||||||
await app.api.delete('directory-admin/groups/' + id);
|
await app.api.delete('directory-admin/groups/' + id);
|
||||||
allGroups = allGroups.filter(g => g.id !== id);
|
allGroups = allGroups.filter(g => g.id !== id);
|
||||||
refreshGroupsUI($('#res-id').val());
|
refreshGroupsUI($('#res-id').val());
|
||||||
|
await loadResources(); // keep the Access column in sync
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
app.messages.action('Failed to remove group', app.modal.body(), 'danger');
|
||||||
@@ -1316,7 +1520,7 @@
|
|||||||
allEdges.push(res.results);
|
allEdges.push(res.results);
|
||||||
refreshEdgesUI(resourceId);
|
refreshEdgesUI(resourceId);
|
||||||
$('#new-edge-target').val('');
|
$('#new-edge-target').val('');
|
||||||
await loadData();
|
await loadResources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||||
@@ -1328,7 +1532,7 @@
|
|||||||
await app.api.delete('directory-admin/edges/' + id);
|
await app.api.delete('directory-admin/edges/' + id);
|
||||||
allEdges = allEdges.filter(e => e.id !== id);
|
allEdges = allEdges.filter(e => e.id !== id);
|
||||||
refreshEdgesUI($('#res-id').val());
|
refreshEdgesUI($('#res-id').val());
|
||||||
await loadData();
|
await loadResources();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||||
@@ -1377,6 +1581,14 @@
|
|||||||
|
|
||||||
$.scope.discoveryResources.empty();
|
$.scope.discoveryResources.empty();
|
||||||
for(const r of filtered) {
|
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);
|
$.scope.discoveryResources.push(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1389,42 +1601,63 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function promoteResource(slug) {
|
||||||
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
const r = allDiscoveryResources.find(x => x.slug === slug);
|
||||||
if(err) {
|
if (!r) { app.messages.toast('Discovered resource not found', 'danger'); return; }
|
||||||
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
promoteSlug = slug;
|
||||||
return;
|
openResourceModal('Promote Resource', null); // add-mode: groups/children tabs hidden
|
||||||
}
|
const m = r.metadata || {};
|
||||||
const resource = allDiscoveryResources.find(r => r.slug === slug);
|
$('#res-name').val(r.name || '');
|
||||||
if(resource) {
|
$('#res-slug').val(r.slug || '');
|
||||||
resource.metadata = resource.metadata || {};
|
$('#res-kind').val(r.kind || 'host');
|
||||||
resource.metadata.managed = true;
|
$('#res-description').val(r.description || '');
|
||||||
}
|
$('#res-ip').val(m.ip || '');
|
||||||
$('.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();
|
$('#res-address').val(m.address || '');
|
||||||
renderDiscoveryTable();
|
$('#res-subtype').val(m.subType || '');
|
||||||
loadResources(); // Also update directory tab
|
$('#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 ---
|
// --- THETA AGENT INSTALL MODAL & WIZARD ---
|
||||||
function generateRandomHexToken(byteLen) {
|
// Agent tokens are no longer generated here. The browser minting a token the
|
||||||
const arr = new Uint8Array(byteLen || 16);
|
// server had never heard of is exactly what made /api/agent/ws unauthenticated:
|
||||||
(window.crypto || window.msCrypto).getRandomValues(arr);
|
// there was nothing to validate against. Tokens now come from
|
||||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
// POST /api/agent/enroll (see enrollAgent).
|
||||||
}
|
|
||||||
|
|
||||||
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();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAgentCommands() {
|
function updateAgentCommands() {
|
||||||
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||||
const quickToken = $('#agent-quick-token').val() || '';
|
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.
|
||||||
|
// Join-key command. Only a key we just minted can appear here -- the list
|
||||||
|
// endpoint deliberately never returns key values.
|
||||||
|
const joinUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||||
|
const selectedKeyId = $('#agent-join-key-select').val();
|
||||||
|
let joinCmd;
|
||||||
|
if (mintedJoinKey) {
|
||||||
|
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${mintedJoinKey}"`;
|
||||||
|
} else if (selectedKeyId) {
|
||||||
|
const k = agentJoinKeys.find(x => x.id === selectedKeyId);
|
||||||
|
joinCmd = `curl -fsSL ${joinUrl}/resources/theta-agent/install.sh | sh -s -- --url "${joinUrl}" --join-key "${k ? k.keyPrefix : ''}…"\n\n# Paste the full value of this key -- it was only shown when created.\n# If you no longer have it, create a new key above.`;
|
||||||
|
} else {
|
||||||
|
joinCmd = '# Create a join key above, or select one you already have the value for.';
|
||||||
|
}
|
||||||
|
$('#agent-join-command').text(joinCmd);
|
||||||
|
|
||||||
|
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);
|
$('#agent-quick-command').text(quickCmd);
|
||||||
|
|
||||||
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
|
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||||
@@ -1445,6 +1678,7 @@
|
|||||||
const yamlStr = [
|
const yamlStr = [
|
||||||
`server_url: "${customUrl}"`,
|
`server_url: "${customUrl}"`,
|
||||||
`auth_token: "${customToken}"`,
|
`auth_token: "${customToken}"`,
|
||||||
|
`public_key: "${pubKey}"`,
|
||||||
`location: "${customLocation}"`,
|
`location: "${customLocation}"`,
|
||||||
`capabilities:`,
|
`capabilities:`,
|
||||||
` telemetry: ${telemetry}`,
|
` telemetry: ${telemetry}`,
|
||||||
@@ -1480,9 +1714,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() {
|
function openAgentInstallModal() {
|
||||||
const currentOrigin = window.location.origin;
|
const currentOrigin = window.location.origin;
|
||||||
const initialToken = generateRandomHexToken(16);
|
pendingEnrollment = null;
|
||||||
|
|
||||||
const bodyHtml = `
|
const bodyHtml = `
|
||||||
<div class="mb-3 p-3 bg-light rounded border">
|
<div class="mb-3 p-3 bg-light rounded border">
|
||||||
@@ -1495,6 +1734,89 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<ul class="nav nav-tabs mb-3" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="agent-mode-join-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-join" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-key me-1"></i> Join key <span class="badge bg-success ms-1">easiest</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="agent-mode-pre-btn" data-bs-toggle="tab" data-bs-target="#agent-mode-pre" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-id-badge me-1"></i> Pre-register this host
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content mb-3">
|
||||||
|
<!-- ── Join key: one credential, host enrolls itself ────────────── -->
|
||||||
|
<div class="tab-pane fade show active" id="agent-mode-join" role="tabpanel">
|
||||||
|
<div class="card border-success">
|
||||||
|
<div class="card-header py-2 fw-bold small bg-success-subtle">
|
||||||
|
<i class="fa-solid fa-key me-1"></i> Install with a join key
|
||||||
|
</div>
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<p class="small text-muted mb-3">
|
||||||
|
Run this on any host and it enrolls itself. The SSO issues that host its own
|
||||||
|
token and public key on first connect, and the agent writes both into its
|
||||||
|
<code>agent.yml</code> — nothing to copy back and forth. One key works for as
|
||||||
|
many hosts as you like; each still gets its own revocable identity.
|
||||||
|
</p>
|
||||||
|
<div class="d-flex gap-2 align-items-end mb-3">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label class="form-label small fw-bold mb-1">Existing join keys</label>
|
||||||
|
<select id="agent-join-key-select" class="form-select form-select-sm" onchange="updateAgentCommands()"></select>
|
||||||
|
<div class="form-text small">A key's value is shown only when it is created — mint a new one if you don't have it saved.</div>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-sm btn-success" onclick="mintAgentJoinKey()">
|
||||||
|
<i class="fa-solid fa-plus me-1"></i> New join key
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="agent-join-key-result" style="display:none"></div>
|
||||||
|
|
||||||
|
<label class="form-label small fw-bold mb-1">Run on the target host (as root):</label>
|
||||||
|
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-2 text-wrap text-break" id="agent-join-command" style="user-select: all;"></pre>
|
||||||
|
<div class="d-flex justify-content-end">
|
||||||
|
<button class="btn btn-sm btn-success" id="btn-copy-join" onclick="copyAgentCommand('agent-join-command', 'btn-copy-join')">
|
||||||
|
<i class="fa-solid fa-copy me-1"></i> Copy install command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Pre-register: bind to a host resource up front ───────────── -->
|
||||||
|
<div class="tab-pane fade" id="agent-mode-pre" role="tabpanel">
|
||||||
|
|
||||||
|
<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">
|
||||||
|
Use this when you want the agent bound to a specific Directory host from the start.
|
||||||
|
The SSO issues the token here and you copy it onto the machine yourself.
|
||||||
|
</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 & 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">
|
<ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
|
||||||
<li class="nav-item" role="presentation">
|
<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">
|
<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 +1839,9 @@
|
|||||||
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<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">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
|
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-quick-token')" title="Regenerate Token">
|
|
||||||
<i class="fa-solid fa-rotate"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1546,12 +1865,9 @@
|
|||||||
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<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">
|
<div class="input-group input-group-sm">
|
||||||
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
|
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="" readonly title="Issued by the SSO at enrollment">
|
||||||
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-custom-token')" title="Regenerate Token">
|
|
||||||
<i class="fa-solid fa-rotate"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
@@ -1621,6 +1937,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><!-- /pre-register pane -->
|
||||||
|
</div><!-- /tab-content -->
|
||||||
`;
|
`;
|
||||||
|
|
||||||
app.modal.open({
|
app.modal.open({
|
||||||
@@ -1629,9 +1948,129 @@
|
|||||||
size: 'lg'
|
size: 'lg'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
loadAgentJoinKeys();
|
||||||
|
|
||||||
|
// 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();
|
updateAgentCommands();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Join keys the operator can reuse. Values are never returned by the list
|
||||||
|
// endpoint -- only a prefix -- so the dropdown identifies a key without being
|
||||||
|
// able to rebuild an install command from it. Minting is the only way to see
|
||||||
|
// a key's value, and only once.
|
||||||
|
var agentJoinKeys = [];
|
||||||
|
var mintedJoinKey = null; // in-memory, for the command shown right now
|
||||||
|
|
||||||
|
function loadAgentJoinKeys() {
|
||||||
|
app.api.get('agent/join-keys', function(err, res) {
|
||||||
|
agentJoinKeys = (res && res.joinKeys ? res.joinKeys : []).filter(k => !k.revoked);
|
||||||
|
const $sel = $('#agent-join-key-select').empty();
|
||||||
|
if (!agentJoinKeys.length) {
|
||||||
|
$sel.append('<option value="">No join keys yet — create one</option>');
|
||||||
|
} else {
|
||||||
|
$sel.append('<option value="">Select a key…</option>');
|
||||||
|
agentJoinKeys.forEach(k => {
|
||||||
|
const used = k.use_count ? `${k.use_count} host${k.use_count === 1 ? '' : 's'}` : 'unused';
|
||||||
|
$sel.append($('<option>').val(k.id).text(`${k.label} (${k.keyPrefix}…, ${used})`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
updateAgentCommands();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function mintAgentJoinKey() {
|
||||||
|
try {
|
||||||
|
const res = await app.api.post('agent/join-keys', { label: 'ui' });
|
||||||
|
const body = (res && (res.results || res)) || {};
|
||||||
|
if (!body.key) throw new Error(body.message || 'no key returned');
|
||||||
|
mintedJoinKey = body.key;
|
||||||
|
$('#agent-join-key-result').show().html(
|
||||||
|
'<div class="alert alert-success py-2 small mb-3">'
|
||||||
|
+ '<i class="fa-solid fa-circle-check me-1"></i><strong>Join key created.</strong> '
|
||||||
|
+ 'It is shown <strong>once</strong> — only its hash is stored. It is already in the command below.'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<label class="form-label small fw-bold mb-1">Join key</label>'
|
||||||
|
+ '<div class="input-group input-group-sm mb-3">'
|
||||||
|
+ '<input type="text" class="form-control font-monospace" readonly value="' + esc(body.key) + '">'
|
||||||
|
+ '<button class="btn btn-outline-secondary" type="button" id="btn-copy-jk" onclick="copyAgentCommand(\'agent-jk-copy\', \'btn-copy-jk\')"><i class="fa-solid fa-copy"></i></button>'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<span id="agent-jk-copy" class="d-none">' + esc(body.key) + '</span>'
|
||||||
|
);
|
||||||
|
loadAgentJoinKeys();
|
||||||
|
updateAgentCommands();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast('Could not create a join key: ' + (err.message || err), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 & issue token');
|
||||||
|
app.messages.toast('Enrollment failed: ' + (err.message || err), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var discoveryPlugins = [];
|
var discoveryPlugins = [];
|
||||||
|
|
||||||
function loadDiscoveryPlugins() {
|
function loadDiscoveryPlugins() {
|
||||||
@@ -1651,15 +2090,29 @@
|
|||||||
discoveryPlugins.forEach(p => {
|
discoveryPlugins.forEach(p => {
|
||||||
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
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 = `
|
const card = `
|
||||||
<div class="card mb-3 border shadow-sm">
|
<div class="card mb-3 border shadow-sm">
|
||||||
<div class="card-body d-flex align-items-center justify-content-between">
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
<div>
|
<div>
|
||||||
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
<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 text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
<div class="small">Last run: ${runState}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
<div class="d-flex align-items-center gap-2">
|
||||||
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
<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-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-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>
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
@@ -1671,6 +2124,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) {
|
async function toggleDiscoveryPlugin(id, state) {
|
||||||
const endpoint = state ? 'load' : 'unload';
|
const endpoint = state ? 'load' : 'unload';
|
||||||
try {
|
try {
|
||||||
@@ -1739,18 +2216,27 @@
|
|||||||
var raw = document.getElementById(prefix + 'cron');
|
var raw = document.getElementById(prefix + 'cron');
|
||||||
return (raw && raw.value.trim()) || '0 * * * *';
|
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 t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||||
var schema = t && t.configSchema;
|
var schema = t && t.configSchema;
|
||||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||||
|
values = values || {};
|
||||||
var html = '';
|
var html = '';
|
||||||
schema.forEach(function(f) {
|
schema.forEach(function(f) {
|
||||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||||
var req = f.required ? ' required' : '';
|
var req = (f.required && !f.secret) ? ' required' : '';
|
||||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
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>' : '');
|
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>' +
|
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;
|
return html;
|
||||||
}
|
}
|
||||||
@@ -1810,6 +2296,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() {
|
async function saveNewDiscoveryPlugin() {
|
||||||
const type = $('#new-plugin-type').val();
|
const type = $('#new-plugin-type').val();
|
||||||
const name = $('#new-plugin-name').val().trim();
|
const name = $('#new-plugin-name').val().trim();
|
||||||
@@ -1846,12 +2428,12 @@
|
|||||||
try {
|
try {
|
||||||
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
|
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||||
dirAgentSocket.on('agent.telemetry', function(msg){
|
dirAgentSocket.on('agent.telemetry', function(msg){
|
||||||
const a = msg && agentsByToken[msg.token];
|
const a = msg && agentsById[msg.agentId];
|
||||||
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
|
if (a) { a.lastTelemetry = msg.payload; a.isOnline = true; renderTable(); }
|
||||||
});
|
});
|
||||||
dirAgentSocket.on('agent.discovery', function(msg){
|
dirAgentSocket.on('agent.discovery', function(msg){
|
||||||
const a = msg && agentsByToken[msg.token];
|
const a = msg && agentsById[msg.agentId];
|
||||||
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
|
if (a) { a.lastDiscovery = msg.payload; a.isOnline = true; renderTable(); }
|
||||||
});
|
});
|
||||||
} catch (e) { /* socket is optional; periodic refresh still runs */ }
|
} catch (e) { /* socket is optional; periodic refresh still runs */ }
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -206,8 +206,8 @@
|
|||||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||||
+ '<div class="card-body">'
|
+ '<div class="card-body">'
|
||||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||||
+ iconHtml
|
|
||||||
+ '<span>' + esc(r.name) + '</span>'
|
+ '<span>' + esc(r.name) + '</span>'
|
||||||
|
+ iconHtml
|
||||||
+ '</h5>'
|
+ '</h5>'
|
||||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||||
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||||
|
|||||||
@@ -656,7 +656,10 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</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="row mt-3">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow-lg">
|
<div class="card shadow-lg">
|
||||||
|
|||||||
@@ -4,12 +4,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow">
|
<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">
|
<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"><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" 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>
|
<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>
|
</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>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="tab-content">
|
<div class="tab-content">
|
||||||
@@ -17,7 +18,10 @@
|
|||||||
<div class="tab-pane fade show active" id="tab-secrets">
|
<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">
|
<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>
|
<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 & 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>
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="row">
|
<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>
|
||||||
</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/<name>/*</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>
|
||||||
</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-token').textContent = result.token;
|
||||||
document.getElementById('app-name-display').textContent = name;
|
document.getElementById('app-name-display').textContent = name;
|
||||||
document.getElementById('app-result-card').classList.remove('d-none');
|
document.getElementById('app-result-card').classList.remove('d-none');
|
||||||
|
loadApps();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
errorEl.textContent = err.message;
|
errorEl.textContent = err.message;
|
||||||
errorEl.classList.remove('d-none');
|
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) {
|
function copyText(text) {
|
||||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
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>';
|
'<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-label').textContent = 'Secret path (under secret/)';
|
||||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||||
|
loadApps();
|
||||||
}
|
}
|
||||||
loadSecrets();
|
loadSecrets();
|
||||||
loadShared();
|
loadShared();
|
||||||
|
|||||||