Compare commits
94 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7e9a271090 | |||
| b28a18064a | |||
| 87339da1b2 | |||
| 49100c9b68 | |||
| e8d04203c3 | |||
| 8db00f0ed6 | |||
| 8a9de94d24 | |||
| 512a28d1f5 | |||
| 398b64f5e3 | |||
| 8d6c7dffd0 | |||
| 50d093f28b | |||
| f00d311029 | |||
| 88b2255d5a | |||
| b0819e81e6 | |||
| d41915f955 | |||
| be8ccf66e9 | |||
| 58597ac8fd | |||
| d02ba32925 | |||
| 6d9c2f05ba | |||
| bbcc235b68 | |||
| 93c47751db | |||
| 9a438bd30e | |||
| c618e75a22 | |||
| 69434d06ec | |||
| dd24257640 | |||
| b06aeca363 | |||
| ccf3122668 | |||
| 5aad6c13bf | |||
| b46b3bed80 | |||
| 948fef4adc | |||
| 2612b0e3ab | |||
| bf471c2e19 | |||
| d802c399a3 | |||
| d8242b1d53 | |||
| 0c5159c49b | |||
| 70b76c6ed5 | |||
| 8143ef8ca8 | |||
| 2b8b7a96e0 | |||
| 7a364bfb8e | |||
| b7aac2d2ba | |||
| 4945dec9c2 | |||
| 59d68c0269 | |||
| ef2207ed72 | |||
| 7782cf8973 | |||
| 80317d1b7e | |||
| ded6a1b0d5 | |||
| a6c24850d4 | |||
| 7da5050ce3 | |||
| 1cb693a1eb | |||
| 5c3a8cefe1 | |||
| 15b3a424bc | |||
| 6cb309b6d9 | |||
| f0ceb750a8 | |||
| 6e95defcf5 | |||
| 92c2e8a03b | |||
| 230e5be2fd | |||
| 0331cb976a | |||
| 90cf65e920 | |||
| 2d202b4979 | |||
| 8f04c20cd7 | |||
| df330c6c0f | |||
| 522093e898 | |||
| 7b84a10420 | |||
| c461723ec7 | |||
| b948cd8625 | |||
| 36aa114d7c | |||
| fbce59b1be | |||
| 554a0999ab | |||
| 3ca221d64d | |||
| 75b133f610 | |||
| f1d52601de | |||
| ecd21c4984 | |||
| 5ba2ace835 | |||
| 25b0d57a97 | |||
| 320e7594e4 | |||
| cec0d92c25 | |||
| 21a56dce50 | |||
| ebb5b2c2a7 | |||
| c8c4cad46d | |||
| 59d4b65195 | |||
| 74746e409b | |||
| 70aed035a5 | |||
| 0264a62b22 | |||
| c212537163 | |||
| 391ad12afc | |||
| 622317b6da | |||
| aa17981c15 | |||
| 99fc0d2819 | |||
| 276629a587 | |||
| a26d54ec6f | |||
| 011d4b2975 | |||
| ecc9b62842 | |||
| e74c5cf11d | |||
| 4a592f9795 |
@@ -19,6 +19,9 @@
|
|||||||
!API.md
|
!API.md
|
||||||
!directory_spec.md
|
!directory_spec.md
|
||||||
!docs/**/*.md
|
!docs/**/*.md
|
||||||
|
# The screenshots the README (served at /docs/overview) links. `COPY docs /docs`
|
||||||
|
# in Dockerfile.openldap needs these present in the build context.
|
||||||
|
!docs/images/**
|
||||||
|
|
||||||
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
# Tests (excluded from production builds; test-runner Dockerfile copies them explicitly)
|
||||||
# nodejs/tests/
|
# nodejs/tests/
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
|||||||
secrets.json
|
secrets.json
|
||||||
secrets.js
|
secrets.js
|
||||||
|
|
||||||
|
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
|
||||||
|
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
|
||||||
|
# they must never be committed. The empty *.example templates ARE tracked.
|
||||||
|
config/*-secrets.js
|
||||||
|
|
||||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
docs/_site
|
||||||
|
|
||||||
|
|||||||
@@ -703,6 +703,10 @@ The authenticated user is automatically set as the group owner.
|
|||||||
{ "results": true, "message": "Added user uid to group group." }
|
{ "results": true, "message": "Added user uid to group group." }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Returns `409` if the user is already a member — common in practice, since
|
||||||
|
`groupOfNames` requires at least one member and so seeds whoever created the
|
||||||
|
group into it.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Remove User from Group
|
### Remove User from Group
|
||||||
@@ -716,6 +720,66 @@ The authenticated user is automatically set as the group owner.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### Nest a Group Inside Another
|
||||||
|
|
||||||
|
**`PUT /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
Makes `:child` a member of `:group`, so everyone in `:child` is a member of
|
||||||
|
`:group` at any depth.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Nested child inside group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `400` | `:group` and `:child` are the same group |
|
||||||
|
| `409` | already nested, or the nesting would create a loop (`:child` already contains `:group`, directly or transitively) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Un-nest a Group
|
||||||
|
|
||||||
|
**`DELETE /api/group/:group/nested/:child`** — `app_sso_admin` or group owner
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{ "results": { "cn": "group", "member": ["..."] }, "message": "Removed child from group." }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Errors:**
|
||||||
|
|
||||||
|
| Status | When |
|
||||||
|
|--------|------|
|
||||||
|
| `409` | `:child` is the only member — `groupOfNames` requires at least one |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Effective Membership
|
||||||
|
|
||||||
|
**`GET /api/group/:group/effective`** — Any authenticated user
|
||||||
|
|
||||||
|
Who a group actually grants. `direct` is users listed on the group itself
|
||||||
|
(never groups); `nestedGroups` is what is nested into it; `effective` is every
|
||||||
|
user reachable through the whole chain.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": {
|
||||||
|
"cn": "app_gitea_access",
|
||||||
|
"direct": ["cn=alice,ou=people,dc=example,dc=com"],
|
||||||
|
"nestedGroups": [{ "cn": "developers", "dn": "cn=developers,ou=groups,dc=example,dc=com" }],
|
||||||
|
"effective": ["cn=alice,ou=people,dc=example,dc=com", "cn=bob,ou=people,dc=example,dc=com"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
### Delete Group
|
### Delete Group
|
||||||
|
|
||||||
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
**`DELETE /api/group/:group`** — `app_sso_admin` or group owner
|
||||||
@@ -1135,6 +1199,143 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Plugin Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/plugins`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
|
||||||
|
|
||||||
|
### List Plugin Types
|
||||||
|
|
||||||
|
**`GET /api/plugins/types`**
|
||||||
|
|
||||||
|
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"type": "proxmox",
|
||||||
|
"category": "discovery",
|
||||||
|
"name": "Proxmox VE",
|
||||||
|
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
|
||||||
|
"configSchema": [
|
||||||
|
{ "key": "url", "label": "API URL", "type": "url", "required": true },
|
||||||
|
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
|
||||||
|
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### List Plugin Instances
|
||||||
|
|
||||||
|
**`GET /api/plugins/`**
|
||||||
|
|
||||||
|
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Get One Instance
|
||||||
|
|
||||||
|
**`GET /api/plugins/:id`** — same shape as a list entry.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Create Instance
|
||||||
|
|
||||||
|
**`POST /api/plugins/`**
|
||||||
|
|
||||||
|
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"pluginType": "proxmox",
|
||||||
|
"name": "Proxmox — Home Lab",
|
||||||
|
"slug": "proxmox-homelab",
|
||||||
|
"cron": "0 * * * *",
|
||||||
|
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Instance
|
||||||
|
|
||||||
|
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Update Secrets
|
||||||
|
|
||||||
|
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Test Instance
|
||||||
|
|
||||||
|
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Load / Unload / Run Now
|
||||||
|
|
||||||
|
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
|
||||||
|
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
|
||||||
|
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Last Run Status
|
||||||
|
|
||||||
|
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Delete Instance
|
||||||
|
|
||||||
|
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
|
||||||
|
|
||||||
|
## Configuration Endpoints
|
||||||
|
|
||||||
|
Base path: `/api/conf`
|
||||||
|
|
||||||
|
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
|
||||||
|
|
||||||
|
### Get Configuration
|
||||||
|
|
||||||
|
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"discovery": { },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save Configuration
|
||||||
|
|
||||||
|
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
|
||||||
|
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:** `{ "success": true }`
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
All endpoints return errors in this format:
|
All endpoints return errors in this format:
|
||||||
|
|||||||
+428
@@ -1,9 +1,424 @@
|
|||||||
|
# v1.30.0
|
||||||
|
|
||||||
|
Adds **join keys**: installing the agent with one key is now all it takes to add a host. Fixes a set of Directory/discovery defects found on a fresh `setup.sh` install.
|
||||||
|
|
||||||
|
### theta-agent — enrollment without pre-registering
|
||||||
|
|
||||||
|
- feat: **join keys.** `POST /api/agent/join-keys` mints one credential an operator hands out. A host presenting it is enrolled automatically and immediately issued **its own** per-agent token plus the public key it must pin, delivered in the `config` frame; the agent persists both and blanks the join key. v1.29.0 required an admin to pre-register every machine before its agent would be spoken to, which made adding a host a two-system chore — the security model was right, the workflow was not.
|
||||||
|
- feat: a join key is a bootstrap credential, never the host's identity, so one key stays convenient without becoming a fleet-wide skeleton key: every host remains individually revocable and a compromised host yields nothing that works elsewhere. Revoking a join key stops new hosts joining and leaves already-enrolled agents alone.
|
||||||
|
- feat: join keys support a label and optional expiry, record their use count, and are stored as a SHA-256 (`AgentJoinKey`). Issue/revoke/delete and every self-enrollment are audited.
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- fix: **collapsing the tree did nothing.** `applyTreeCollapse` located the caret with `$row.find('.tree-caret i')` and returned early when it found nothing. Font Awesome runs in SVG-with-JS mode and its mutation observer rewrites every `<i class="fa-…">` into an `<svg>`, so moments after a render that selector matched nothing — and the early return skipped setting `hideBelowDepth`, so no row was ever hidden. Collapse state now lives on the caret *button* and is rotated by CSS, and the hide decision is made from the collapsed set alone. Never key behaviour to an element another library is free to replace.
|
||||||
|
- fix: **the Discovery Plugins delete button did nothing.** It called `deleteDiscoveryPlugin()`, which was never defined — clicking it only threw a `ReferenceError`.
|
||||||
|
- fix: the plugins pane had no `.actionMessage` element, and `app.messages` confirmations render into one. Without it the returned promise **never settles**, so an awaited confirmation hangs forever and the action it gates silently never happens. Added, along with a note that any pane asking for confirmation needs it.
|
||||||
|
- feat: **discovery plugin instances can be edited.** Name, schedule, loaded state and configuration, with secrets on their own endpoint and left blank ("unchanged") rather than prefilled with the mask — submitting `********` back would otherwise store the asterisks as the secret.
|
||||||
|
|
||||||
|
### Discovery
|
||||||
|
|
||||||
|
- fix: **a fresh install no longer presents its own containers as things to triage.** The Docker plugin recognises containers belonging to the stack's own compose project, records them as managed, and attaches each to the service it implements. `setup.sh` deploys `sso-manager`, `proxy`, `jump-host`, `openbao` and `bao-renewer`; all five arrived as unmanaged discoveries awaiting promotion.
|
||||||
|
- fix: **Docker container slugs were derived from the container id**, which changes on every recreate — so each `docker compose up` minted a brand-new resource and orphaned the previous one. Slugs now come from compose project + service, falling back to the container name.
|
||||||
|
- feat: discovered containers carry `composeProject`, `composeService`, `containerName` and `sourceId`.
|
||||||
|
|
||||||
|
### Docs
|
||||||
|
|
||||||
|
- fix: `/docs/discovery` 404'd — the slug had no entry, though the Discovery tab's help icon linked to it. New `docs/discovery.md` covering the catalog/discovered distinction, how sources are matched and merged, naming precedence, promotion and garbage collection.
|
||||||
|
- fix: the `agents` slug pointed at `plugins.md`, so `docs/agents.md` was unreachable in the app.
|
||||||
|
|
||||||
|
# v1.29.0
|
||||||
|
|
||||||
|
**Breaking:** theta-agent enrollment is now mandatory. Agents installed before this release carry a browser-generated token the server never recorded and will be rejected until re-enrolled. Requires theta-suite ≥ v1.42.0 (the `sso-broker` OpenBao policy must grant `secret/agent/*`); re-run `./setup.sh`.
|
||||||
|
|
||||||
|
### Security — theta-agent channel
|
||||||
|
|
||||||
|
- **sec: `/api/agent/ws` accepted any token.** There was no agent registry, so the endpoint authenticated nothing: any client that could reach the SSO could register as a node, publish discovery/telemetry into the admin view, and receive commands — including a signed `arbitrary_bash` — addressed to a token it guessed. Tokens were generated in the *browser* (`generateRandomHexToken`) and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new `Agent` table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent; unknown or revoked tokens are closed with `4001` and audited.
|
||||||
|
- **sec: the command signing key was ephemeral.** `AgentManager` generated an Ed25519 pair in its constructor, so it changed on every process start and the `public_key` an agent pinned in `agent.yml` stopped matching immediately. The key now lives in OpenBao at `secret/agent/signing-key` and survives restarts. If it cannot be loaded the SSO **refuses** to send high-risk commands rather than signing with a key no agent has seen (`signingAvailable: false` on `GET /api/agent/nodes`).
|
||||||
|
- **sec: commands are addressed by agent id, not token.** A credential has no business in a URL, an access log or browser history.
|
||||||
|
- **sec: agent actions are audited.** Enroll, update, rotate, revoke, delete, every command (with `signed`), and every rejected connection are emitted as structured `"component":"agent"` log records carrying the acting user.
|
||||||
|
|
||||||
|
### theta-agent — enrollment & resource binding
|
||||||
|
|
||||||
|
- feat: `POST /api/agent/enroll` mints the token server-side and returns it **once**; only its SHA-256 is stored. Plus `PUT /nodes/:id` (rename/rebind), `POST /nodes/:id/rotate`, `POST /nodes/:id/revoke`, `DELETE /nodes/:id`. Rotate, revoke and delete drop the live socket immediately (`4004`/`4003`) instead of waiting for a reconnect.
|
||||||
|
- feat: an agent binds to a **host resource** (`resourceId`). The Directory reads that link instead of guessing by hostname — the old `agentsByHost[name]` match silently failed whenever a Directory name differed from the machine's hostname, and aliased two hosts that shared one.
|
||||||
|
- feat: **agent discovery reaches the Directory.** A bound agent's facts (`os`, `kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`) are written onto its host resource, tagged `discovery_sources: ["theta-agent"]` with an `agentId` back-reference. An unbound agent goes through the normal reconciler. Previously `handleDiscovery` wrote to an in-memory record and updated nothing — the one source actually running *on* the host contributed nothing to the directory.
|
||||||
|
- feat: agent state is persisted, so an agent that is installed but **offline** is now distinguishable from one that never existed; enrollments survive a restart. The Directory status dot reflects this: red means "enrolled and not connected" (a fault), grey means no agent enrolled / revoked / service unreachable. Red previously covered both, making an ordinary directory of hosts look like an outage.
|
||||||
|
- feat: the Install Agent modal enrolls first and builds the install command from the result, including `--public-key`. `public_key` was never emitted into the generated `agent.yml` before, so no installed agent could verify anything.
|
||||||
|
- fix: `registerAgent` is synchronous. Awaiting a database write before attaching the WebSocket `message` listener lost every agent's first `discovery` frame, which it sends the instant the socket opens (`ws` drops events emitted with no listener attached).
|
||||||
|
|
||||||
|
### Directory
|
||||||
|
|
||||||
|
- feat: **the resource tree is collapsible.** Any row with children has a caret; the toolbar collapses/expands everything. State persists per browser, so the shape survives the self-heal reload that follows most edits. An active search overrides collapse so matches inside a folded subtree are never hidden.
|
||||||
|
- fix: **the Proxmox plugin mismatched MAC addresses to IPs.** It collected MACs and IPs into two flat lists and zipped them by index, so on any multi-NIC guest — or any guest where one NIC had no address — the directory recorded an address against the wrong MAC. NICs are now keyed by MAC, so a pairing can only come from the source that observed both together.
|
||||||
|
- feat: Proxmox discovery emits an **endpoint resource** (named from `/cluster/status`) with every node parented beneath it, so one endpoint is one subtree instead of several orphan roots. It deliberately carries no IP: giving it the address it is reached at made the reconciler merge it with the node answering on that address, producing a resource that was its own parent.
|
||||||
|
- feat: discovered guests carry `sourceId` (`<node>/qemu/<vmid>`), `node`, `vmid` and `macAddress`, so a row traces back to the exact guest on the exact hypervisor. Against a live 3-node cluster this took MAC coverage to 53/54 resources and `sourceId` to 54/54.
|
||||||
|
- fix: Proxmox interfaces belonging to something running *inside* a guest (`docker0`, `veth*`, `br-*`, VPN tunnels) are filtered out — one Home Assistant VM reported 16 of them alongside its single real NIC, and their 172.x addresses gave the reconciler spurious matches.
|
||||||
|
- fix: a stopped VM still reports its MAC (read from the VM config), a DHCP-configured LXC gets its address from the running container's interface list, and Proxmox **nodes** report their own IP/MAC (recovered from `enx<mac>` predictable names, since `/nodes/*/network` carries no `hwaddr`). Offline nodes are recorded with `status` instead of skipped, so a hypervisor that is down no longer looks decommissioned and get garbage-collected after a week.
|
||||||
|
- fix: **the reconciler could make a resource its own parent.** Two slugs in one payload can resolve to the same row once merged; the resulting self-edge renders as an infinitely nested tree and defeats every ancestor walk in the app. Self-edges and cycle-closing edges are now refused and logged.
|
||||||
|
- fix: **hosts were named after their MAC address.** `bestName` preferred the *longer* name, so UniFi's `ac:16:2d:b3:da:80` (17 chars) beat Proxmox's real hostname `dl380-0` (7). Names are now ranked (hostname > IP > MAC) with length only as a tie-break within a rank.
|
||||||
|
- fix: `isIp` never matched anything — `\\.` inside a regex literal matches a backslash, not a dot — so an IP-shaped placeholder name was never replaced by a real hostname a later source discovered.
|
||||||
|
- fix: a discovered device can only merge into a resource of the same kind. A VM named `gitea-runner` could match a hand-created *service* of the same name on the name rule and overwrite it.
|
||||||
|
- perf: the reconciler reads the inventory once per run instead of once per incoming resource — a ~55-resource Proxmox payload against a similar-sized inventory was doing quadratic full-table reads every run.
|
||||||
|
- fix: the Discovered Inventory table showed "Unknown IP" for almost everything, because it read `metadata.ip` while any source that enumerates interfaces stores addresses per-NIC. It now falls back to the first NIC address, and shows `vmid`, slug, `sourceId` and per-interface MAC/name.
|
||||||
|
|
||||||
|
### Profile
|
||||||
|
|
||||||
|
- fix: the API Tokens card is no longer wider than every other card on the site — the section sat outside the page's `.container`.
|
||||||
|
|
||||||
|
### Build & docs
|
||||||
|
|
||||||
|
- fix: `Dockerfile.test-runner` never copied `nodejs/plugins`, so every plugin test suite failed in CI as "Cannot find module" and plugin code was effectively untested. Suite count goes 27 → 29.
|
||||||
|
- docs: `docs/agents.md` rewritten for enrollment, the close-code table, resource binding, the persistent signing key, and a corrected `public_key` example (the documented `MCowBQYDK2VwAyEA...` was an SPKI PEM body — 44 bytes decoded — where the agent requires the raw 32).
|
||||||
|
- docs: `docs/directory.md` covers the collapsible tree and the corrected seed hierarchy; `docs/plugins.md` documents what the Proxmox plugin produces and why the endpoint has no IP.
|
||||||
|
|
||||||
|
# v1.28.0
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the previous "unconditional mount" was still inside the post-listen `onListen` hook, so the REST router landed *behind* app.js's terminal 404 catch-all and every `/api/agent/*` request 404'd. The router is now mounted synchronously in `app.js` before the 404 handler; only the agent WebSocket setup runs on `onListen`.
|
||||||
|
- feat: promoting a discovered inventory resource now opens the resource form pre-filled with the discovered data (name, kind, IP, subtype, …) for review; the modal's Save confirms the promote (creates the LDAP groups + marks it managed) instead of silently promoting.
|
||||||
|
- fix: Directory table no longer goes stale after add/remove edge — `addEdge`/`removeEdge` called an undefined `loadData()`, which threw and left the host/parent linkage stale until a manual refresh; they now call `loadResources()`. `addGroup`/`removeGroup` also refresh so the Access column stays accurate.
|
||||||
|
- feat: Vault page states it's powered by OpenBao (header badge linking to openbao.org).
|
||||||
|
|
||||||
|
# v1.27.0
|
||||||
|
- fix: Directory group names now match `docs/GROUPS.md` exactly — per-resource groups are `{site}_{kind}_{name}_{level}` (`site_local_host_theta-env_access`, `site_local_app_sso-manager_access`), with the kind always present and the resource name slug stripped of its kind prefix. Services map to the `app` kind. The access-request + resolver tests were updated to the documented convention.
|
||||||
|
- fix: a site resource now carries only `god_admin` + the site-wide groups (`{site}_super_admin`, `{site}_everyone`); the kind-scoped aggregates are still created for nesting but are no longer surfaced on the site's modal.
|
||||||
|
- fix: groups no longer appear 3× under a resource — the Directory self-heal (which runs on every load) was creating duplicate `ResourceGroup` links; linking is now idempotent (check-then-create).
|
||||||
|
- fix: `/api/agent/nodes` no longer 404s — the agent REST router is mounted unconditionally instead of being gated on the WebSocket server being up.
|
||||||
|
- fix: `POST /api/shared-secrets/` rejected valid slugs — the slug regex now allows underscores (was hyphens-only).
|
||||||
|
- fix: `GET /api/shared-secrets/` crashed with `s.path is not a function` — the list spread dropped the instance's `path()` method; now uses the static `SharedSecret.pathFor`.
|
||||||
|
- fix: promoting a discovered inventory resource crashed with `Resource.update is not a function` — `update` is an instance method; the promote handler now loads an instance and calls `update()` on it.
|
||||||
|
- feat: Vault → Apps tab now lists minted app tokens (the "Minted apps" list) — each is a scoped OpenBao credential for an external service; sso renews them and the list shows renewal state, so a minted credential no longer vanishes after its once-only token display. New `GET /api/vault/apps`.
|
||||||
|
- feat: Vault page documents itself — a `/docs/vault` help icon in the header, and the doc now covers the Apps + Shared tabs.
|
||||||
|
- feat: discovery plugin cards show last-run time + status (ok/error) and a Logs button that opens the captured run log.
|
||||||
|
|
||||||
|
# v1.26.1
|
||||||
|
- fix: the legacy `app_super_admin` group is gone — `SUPER_ADMIN_GROUP` (nested into every resource's `_admin` group by auto-provisioning) is now `god_admin`, and `docker-entrypoint.sh` no longer seeds or nests `app_super_admin` (god_admin is nested into the `app_sso_*` groups directly). `isSuperAdmin` still recognizes a pre-existing `app_super_admin` as a migration alias, so an old deployment isn't stripped of rights until it's rebuilt.
|
||||||
|
|
||||||
|
# v1.26.0
|
||||||
|
- feat: complete the group model (docs/GROUPS.md) — `god_admin` is now seeded into LDAP and nested into `app_super_admin`; every site auto-provisions `{site}_super_admin`, `{site}_hosts_*`/`{site}_apps_*` aggregates and `{site}_everyone`; per-resource `_admin`/`_access` groups (named `{site}_{slug}_{level}`, the kind carried in the resource slug) are nested into the site aggregates so the inheritance lattice exists in LDAP, not just in the resolver. Site/aggregate groups are self-healed idempotently on every Directory load, so a directory seeded by an older release picks them up without a rebuild.
|
||||||
|
- feat: the naming convention is now enforced server-side — `POST /api/directory-admin/groups` rejects a group CN that isn't a valid group for the target resource (its own `_admin`/`_access`/capability, a site aggregate, a site-level group, or `god_admin`), so the free-text field can no longer mint `*_accessmember`-style names
|
||||||
|
- feat: `god_admin` is managed from the Directory — the site resource modal surfaces `god_admin` + the site-level groups as associated groups, so its members (and the site's) are editable right there
|
||||||
|
- fix: Directory agent status dots no longer paint every host red when the `/api/agent/nodes` endpoint is unreachable (older app or transient outage) — they now show a neutral grey "agent service unreachable" instead of a false alarm
|
||||||
|
- fix: Profile + API Tokens cards are both full-width on the profile page (the API card was a narrower centered block)
|
||||||
|
- fix: in-app `/docs/<slug>` pages returned 500 — `Dockerfile.openldap` never copied the `docs/` tree into the image (only the root README/CHANGELOG/API/directory_spec), so every page but those few hit a missing-file error; the whole `docs/` dir now ships, and doc images are served at `/docs/images`
|
||||||
|
- test: group resolver tests now cover the prefixed site-slug convention (`site_local_...` is kept verbatim, not re-slugified to `site-local`)
|
||||||
|
|
||||||
|
# v1.25.0
|
||||||
|
- feat: hierarchical group & permission model (docs/GROUPS.md) — god_admin, {site}_super_admin, {site}_hosts_*/{site}_apps_* aggregates, and per-resource {site}_host_<slug>_admin/access/<capability>; inheritance resolver (admin implies access, capabilities explicit), meta everyone/{site}_everyone groups
|
||||||
|
- feat: remove the standalone Groups page — group management is tied to adopted Directory resources (help link to the model in the Directory toolbar)
|
||||||
|
- feat: console admin recognizes god_admin and site-scoped super/app-admin groups (legacy app_sso_admin/app_super_admin kept as migration aliases)
|
||||||
|
|
||||||
|
# v1.24.0
|
||||||
|
- feat: Agents merged into the Directory — removed the standalone Agents page. Host rows show a green/yellow/red theta-agent status dot (healthy / high-load / not connected) and the resource modal gained a Metrics tab with live telemetry + discovery
|
||||||
|
- feat: Discovery Plugins New-plugin modal — slug is now derived from the name (field removed), the cron field is a dropdown (hourly/daily/weekly + custom), and per-plugin settings are collected from the configSchema (e.g. Proxmox url/tokenId/tokenSecret) instead of an empty config
|
||||||
|
- feat: Directory resource slug is now read-only and derived from the name
|
||||||
|
- feat: Vault page restyled to match the rest of the site (bounded container, card + nav-tabs header, h4)
|
||||||
|
- feat: navbar — the username is no longer underlined; only the active nav link is bold + underlined
|
||||||
|
|
||||||
|
# v1.23.0
|
||||||
|
- fix: /api/vault proxy never injected X-Vault-Token — the true root cause of the recurring vault 403 "permission denied". The proxy declared its hook with http-proxy-middleware v3 syntax (`on: { proxyReq }`), which the installed HPM v2 silently ignores, so every request reached OpenBao unauthenticated (and the client's sso auth headers were never stripped). Rewritten as v2 `onProxyReq`.
|
||||||
|
- fix: vault proxy header injection ordered before `fixRequestBody` — the body write flushes headers, so setting X-Vault-Token after it silently failed on every POST/PUT (writes would still 403 even with the hook fixed)
|
||||||
|
- fix: initORM add-only schema heal — `sequelize.sync()` never ALTERs existing tables, so columns added by newer releases (e.g. `PluginInstance.lastLog`, which crashed the scheduler on every boot of an upgraded deployment) are now detected via describeTable and added with addColumn (additive only, per-column fail-soft)
|
||||||
|
- feat: external-app vault tokens are long-lived and auto-renewed — minted via the new `sso-app` token role (periodic 768h, falls back to sso-broker's 24h role until theta-suite setup.sh is re-run); sso stores each token's accessor (new VaultAppToken model — an accessor can renew/revoke but not authenticate) and renews all of them at boot + every 6h via auth/token/renew-accessor, so a downstream app's credential stays valid as long as sso runs with zero renewal code in the app
|
||||||
|
- feat: re-minting an app token revokes the app's previous token via its stored accessor — exactly one live credential per app, no zombies
|
||||||
|
- test: wire-level tests for the vault proxy (real HTTP round-trip asserting token injection, auth-header stripping, path rewrite, and POST body integrity) + app-token accessor lifecycle tests
|
||||||
|
|
||||||
|
# v1.22.0
|
||||||
|
- feat: Agents page — live list of connected theta-agent hosts with telemetry (CPU/RAM/disk/ZFS/GPU) + online status, updating via socket.io
|
||||||
|
- security: auth + admin-gate the /api/agent REST routes (previously unauthenticated)
|
||||||
|
|
||||||
|
# v1.21.0
|
||||||
|
- fix: always reconcile OpenBao policy content before serving a (possibly cached) token, so stale stored policies can no longer cause a recurring vault 403 "permission denied"
|
||||||
|
- feat: shared secrets — users can publish secrets to secret/shared/<owner>/<slug> and grant read access to other users and downstream apps (OpenBao ACL policy edits, applied live)
|
||||||
|
- feat: shared-secrets API + Shared tab in the vault UI
|
||||||
|
|
||||||
|
# v1.20.0
|
||||||
|
- fix: OpenBao 403 on vault secrets list (directory list grants + policy self-heal)
|
||||||
|
|
||||||
|
## v1.19.0
|
||||||
|
- Added WebSocket endpoint for theta-agent C2
|
||||||
|
|
||||||
|
# v1.18.0
|
||||||
|
- feat: Add messaging plugins, Docker discovery, fix reconciliation
|
||||||
|
|
||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
All notable changes to this project are documented here. Format loosely
|
All notable changes to this project are documented here. Format loosely
|
||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.17.2] - 2026-08-01
|
||||||
|
|
||||||
|
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
|
||||||
|
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
|
||||||
|
modal no longer asks for a Slug (it derived a stable, unique handle from the
|
||||||
|
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
|
||||||
|
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
|
||||||
|
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
|
||||||
|
`views/plugins.ejs`)
|
||||||
|
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
|
||||||
|
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
|
||||||
|
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
|
||||||
|
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
|
||||||
|
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
|
||||||
|
(nested paths), never on the directory path itself — so listing a directory's
|
||||||
|
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
|
||||||
|
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
|
||||||
|
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
|
||||||
|
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
|
||||||
|
already-created `user-<uid>` policies pick up the new grant on the next
|
||||||
|
vault-page visit. The matching `sso-admin` mount-root grant ships in
|
||||||
|
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
|
||||||
|
always-write so re-running `./setup.sh` applies policy edits.
|
||||||
|
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
|
||||||
|
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
|
||||||
|
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
|
||||||
|
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
|
||||||
|
buttons. The header/label are now populated by JS (the `Members` label
|
||||||
|
already had a setter pointing at a missing id); the Admin Actions block is
|
||||||
|
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
|
||||||
|
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
|
||||||
|
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
|
||||||
|
masked secret values and rendered them as fields, but `PUT /:id` only saves
|
||||||
|
non-secret config — so an edited secret was silently dropped. The Edit modal
|
||||||
|
now shows **non-secret fields only** (secrets have their own Edit-Secrets
|
||||||
|
modal), removing the confusion. (`views/plugins.ejs`)
|
||||||
|
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
|
||||||
|
binary was not installed in the app image. `Dockerfile.openldap` now `apk
|
||||||
|
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
|
||||||
|
the opaque node-nmap spawn-missing error into an actionable `lastError`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
|
||||||
|
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
|
||||||
|
config files. It now has an SMS card on `/conf` (API username, DID, API
|
||||||
|
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
|
||||||
|
the API password masked (`********`) and leave-blank-to-keep — mirroring the
|
||||||
|
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
|
||||||
|
saved change takes effect live without a restart. (`routes/api_conf.js`,
|
||||||
|
`views/conf.ejs`)
|
||||||
|
- **Terms of Service editor moved to `/conf`** from the admin Overview
|
||||||
|
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
|
||||||
|
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
|
||||||
|
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
|
||||||
|
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
|
||||||
|
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
|
||||||
|
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
|
||||||
|
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
|
||||||
|
policies self-heal on the next vault-page visit.
|
||||||
|
|
||||||
|
## [1.17.1] - 2026-08-01
|
||||||
|
|
||||||
|
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
|
||||||
|
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
|
||||||
|
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
|
||||||
|
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
|
||||||
|
(unchanged) — only how they're surfaced to the admin changes.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
|
||||||
|
(was: returned in cleartext). Non-secret fields (host, port, user, from,
|
||||||
|
secure, issuer, token lifetimes) are returned as before.
|
||||||
|
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
|
||||||
|
as "keep the current stored value" — so an admin editing the From address or
|
||||||
|
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
|
||||||
|
secret. Only a genuinely new, non-blank value overwrites. The preserved values
|
||||||
|
are re-applied to live `conf` immediately, as before.
|
||||||
|
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
|
||||||
|
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
|
||||||
|
copy notes secret fields are masked. No JSON-textarea editing is involved —
|
||||||
|
SMTP is and remains configured through structured form fields.
|
||||||
|
|
||||||
|
### Notes
|
||||||
|
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
|
||||||
|
this release (via `POST /api/conf` → `baoConf.set('sso-manager/conf')`, and
|
||||||
|
overlaid back at boot by `bao-conf.init`). This release closes the
|
||||||
|
cleartext-exposure gap; it does not move the storage path.
|
||||||
|
- No theta-suite policy change required — `secret/sso-manager/conf` was already
|
||||||
|
granted to the `sso-broker` policy.
|
||||||
|
|
||||||
|
## [1.17.0] - 2026-08-01
|
||||||
|
|
||||||
|
A real **plugin system**: the half-built discovery plugins (statically
|
||||||
|
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
|
||||||
|
**configurable, loadable/unloadable plugin instances** you manage from a
|
||||||
|
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
|
||||||
|
copies of each type and per-instance secrets stored in OpenBao.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Plugin instances** — a new `PluginInstance` ORM model
|
||||||
|
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
|
||||||
|
configured, scheduled plugin copies. Each has a `pluginType`, a unique
|
||||||
|
`slug` (the discovery source name), a cron schedule, an `enabled` flag
|
||||||
|
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
|
||||||
|
instances of the same type are supported.
|
||||||
|
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
|
||||||
|
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
|
||||||
|
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
|
||||||
|
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
|
||||||
|
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
|
||||||
|
non-secret), `mask`, and required-field helpers for the UI/API.
|
||||||
|
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
|
||||||
|
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
|
||||||
|
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
|
||||||
|
the DB. The UI only ever sees masked (`********`) values. Plugins run
|
||||||
|
in-process (BullMQ workers), so they need no OpenBao token of their own — the
|
||||||
|
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
|
||||||
|
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
|
||||||
|
with a clear error if absent.
|
||||||
|
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
|
||||||
|
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
|
||||||
|
test/load/unload/run/delete/runs. Admin-only
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
|
||||||
|
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
|
||||||
|
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
|
||||||
|
rendered from each type's `configSchema`.
|
||||||
|
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
|
||||||
|
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
|
||||||
|
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
|
||||||
|
upsert/remove one schedule without disturbing the rest. Discovery plugins
|
||||||
|
reconcile results under the instance's `slug`.
|
||||||
|
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
|
||||||
|
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
|
||||||
|
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
|
||||||
|
secret.
|
||||||
|
- The `/plugins` page route renders the page instead of redirecting to
|
||||||
|
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
|
||||||
|
(plugins are now managed on the Plugins page). The `/docs/agents` link is
|
||||||
|
aliased to `/docs/plugins`.
|
||||||
|
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
|
||||||
|
(Plugin Endpoints section) document the new system.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
|
||||||
|
`conf.discovery.plugins` has entries, one instance per configured type is seeded
|
||||||
|
automatically (secret fields copied into OpenBao). After that the static
|
||||||
|
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
|
||||||
|
empty-table check).
|
||||||
|
|
||||||
|
### Prerequisite
|
||||||
|
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
|
||||||
|
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
|
||||||
|
plugin secrets fails with a clear error.
|
||||||
|
|
||||||
|
## [1.16.1] - 2026-08-01
|
||||||
|
|
||||||
|
Fix: the Configuration (`/conf`) and Vault (`/vault`) pages returned **401** for
|
||||||
|
a logged-in admin. Both view routes did server-side auth using `req.user`, but
|
||||||
|
this app's auth-token is a header set by client-side JS (localStorage), not a
|
||||||
|
cookie — so `req.user` is undefined on a plain browser navigation.
|
||||||
|
`permission.byGroup(undefined, …)` throws status 401, and the `middleware.auth`
|
||||||
|
gate on `/vault` threw `Auth.errors.login()` (401) for the same reason.
|
||||||
|
|
||||||
|
Both routes now render the shell unconditionally (like `/users`, `/directory`,
|
||||||
|
`/overview`) and gate client-side: `conf.ejs` already called
|
||||||
|
`app.auth.forceLogin(['admin','app_sso_admin'])`; `vault.ejs` now derives
|
||||||
|
`isAdmin` + the personal namespace from `/api/user/me` after `forceLogin()`
|
||||||
|
instead of server-rendering them. The `/api/conf` and `/api/vault` endpoints
|
||||||
|
still enforce `app_sso_admin` + the OpenBao scope server-side, so protection is
|
||||||
|
unchanged — only the view-route gating moved client-side where the session
|
||||||
|
actually lives. Also removed a dead duplicate `/conf` route definition.
|
||||||
|
|
||||||
|
## [1.16.0] - 2026-08-01
|
||||||
|
|
||||||
|
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
|
||||||
|
Manager becomes its broker. This is the SSO's half of the move: it loads its
|
||||||
|
own secrets from OpenBao, mints scoped tokens for users and external apps,
|
||||||
|
and exposes a fixed, role-scoped personal-secrets UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Secrets now load from OpenBao at boot** via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config
|
||||||
|
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
|
||||||
|
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
|
||||||
|
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
|
||||||
|
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
|
||||||
|
`sso-broker`), never the root token. The admin **Configuration** UI
|
||||||
|
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
|
||||||
|
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
|
||||||
|
pass-through that never injected an `X-Vault-Token` (so the UI was both
|
||||||
|
ungated *and* broken). It is now `middleware.auth` → `scopeGuard` → a
|
||||||
|
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
|
||||||
|
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
|
||||||
|
(Redis-cached, minted through the `sso-broker` token role) and enforces a
|
||||||
|
path prefix as a second layer on top of the OpenBao policy. The client
|
||||||
|
`auth-token` is stripped; only the server-minted token reaches OpenBao.
|
||||||
|
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs` →
|
||||||
|
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
|
||||||
|
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
|
||||||
|
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
|
||||||
|
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
|
||||||
|
`curl` convention).
|
||||||
|
- Bumped package version to track the release tag.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
|
||||||
|
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
|
||||||
|
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
|
||||||
|
hardcoded Proxmox root API token and a UniFi password) were tracked on
|
||||||
|
master. They are now untracked + gitignored (`config/*-secrets.js`), and
|
||||||
|
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
|
||||||
|
placeholder template. **The secrets remain in git history — rotation at
|
||||||
|
the providers is the real remediation and is the operator's to perform.**
|
||||||
|
OpenBao is now the authoritative store; the local files are seed artifacts
|
||||||
|
only.
|
||||||
|
|
||||||
|
> Note: releases v1.12.0–v1.15.2 were tagged from merge PRs without
|
||||||
|
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
|
||||||
|
> resumes the changelog at v1.16.0.
|
||||||
|
|
||||||
|
## [1.11.0] - 2026-07-31
|
||||||
|
|
||||||
|
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **`GET /api/discovery/me` returned only `isPublic` resources for every human caller.** It resolved the caller's groups from `req.user.groups`, which does not exist — `req.user` is a `User` carrying `memberOf` (DNs). The empty list failed open into "no group-granted resources", so "My Services" on the profile page and the portal's service list were blank for everyone. The same bug made `isDirectoryAdmin()` false for real directory admins, silently downgrading them to the public metadata projection. Group CNs now come from `utils/user_groups.js`.
|
||||||
|
- **The portal's "Discover More Services" was dead for every non-admin.** It called the admin-gated `directory-admin/resources` and swallowed the 403 into an empty array — so the one discovery feature never rendered for the audience it existed for. It now calls `/api/discovery/resources`.
|
||||||
|
- **Services reported no address.** `/api/discovery/me` had reimplemented `Resource.getMyAccess` without its parent-walking address resolution, leaving clients to guess `address || ip`, which is exactly wrong for a service that is reached at its host's IP. Both paths now share `Resource.withResolvedAddress()`.
|
||||||
|
- **Approving access for a user already in the target group threw a 500** and left the request stuck pending. `groupOfNames` requires at least one member, so a resource's auto-created groups are seeded with the creator's DN; the grant is now idempotent.
|
||||||
|
- **`DELETE /api/directory-admin/resources/:id` deleted the resource before its edges and group links.** With no transaction, a failure mid-way orphaned rows pointing at a nonexistent id — invisible in the UI and poisonous to `getGraph()`. Dependents go first now.
|
||||||
|
- `PUT /api/directory-admin/resources/:id` validated the body only after loading the row, and carried a dead if/else whose branches were identical.
|
||||||
|
- `/api/directory-admin/audit-logs` shelled out to `tail` three times via `execSync`; replaced with a bounded async file read (no `child_process`, at most the trailing 256 KB).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **End-user catalog at `/`**, and the first ungated nav item — previously every nav entry was admin-only and a normal user had no signposted destination. Search/filter, per-kind icons, and a **how to reach it** block per card: the URL for a service, the SSH invocation for a host (using the jump-host `uid_-_slug@host` grammar when `directory.jumpHost` is configured).
|
||||||
|
- **Self-service access requests** — `AccessRequest` model plus `/api/access-requests` (create, list own, list decidable, approve, deny, withdraw). Approving performs the LDAP group add, so LDAP remains the access-control truth. Requests target a resource's `member`-level group, never its `_admin` one. Replaces the "coming soon" stub.
|
||||||
|
- **Admin access visibility**: an Access column on the directory table showing member and group counts (and flagging links whose LDAP group has been deleted), plus a "what can this user reach" lookup — the reverse question, which previously had no UI at all. Backed by `GET /api/directory-admin/access-summary` and `/user-access/:uid`.
|
||||||
|
- `conf.directory` — `jumpHost` and `defaultSshPort`, the connection conventions the catalog renders.
|
||||||
|
- `tests/access_request.test.js` — the request → approve → grant-is-real loop end to end, including the regression guard for the `user.groups` bug.
|
||||||
|
|
||||||
|
### Added — nested groups
|
||||||
|
- **A group can now contain another group.** `groupOfNames.member` accepts any DN, so nesting needs no new schema; what it needs is *resolution*, which no released OpenLDAP performs — `memberOf` and `(member=X)` both return direct membership only. Two halves:
|
||||||
|
- **Server-side**: the all-in-one image now builds slapd from a pinned OpenLDAP master commit (`350e9eb3`) to get the **`nestgroup`** overlay (ITS#10161), enabled with `member-filter memberof-filter memberof-values`. `member-values` is deliberately omitted — it expands `member` when reading a group, which destroys the distinction between "listed here" and "reachable via nesting" and is not recoverable afterwards. `pw-sha2` is built from contrib in the same stage; without it every existing `{SSHA512}` password would be unverifiable.
|
||||||
|
- **Client-side**: `Group.list(dn)` computes the transitive closure itself (cycle-detected, depth-capped) when the server can't, selected by `conf.ldap.nestedGroupsServerSide` — which `docker-entrypoint.sh` derives from probing for `nestgroup.so` rather than hardcoding. Both paths are covered by the full suite.
|
||||||
|
- `PUT`/`DELETE /api/group/:group/nested/:child` and `GET /api/group/:group/effective`, plus a **Nested** tab on each group card. Cycles are refused (409) rather than silently depth-truncated.
|
||||||
|
- **`app_super_admin` is now seeded** (it never was) and nested into `app_sso_admin` / `app_sso_invite` / `app_sso_oauth_admin`, so the privilege is real LDAP membership visible to SSSD and sudo — not just a special case in `utils/permission.js`. Not nested into `app_sso_service_account`, which marks non-person accounts rather than granting anything.
|
||||||
|
- Creating a directory resource nests `app_super_admin → <slug>_admin` and `<slug>_admin → <slug>_access`. Both previously required adding every super admin to every new group by hand, so they drifted.
|
||||||
|
- `ldap_group_nesting_level = 5` in ldap-client's SSSD template, for hosts pointed at a server without `nestgroup`. Against the bundled slapd the existing `memberof=` access filter is already transitive, so SSH login inherits nesting for free.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `PUT /api/group/:group/:uid` returned a bare **500** when the user was already a member — common, since `groupOfNames` requires a member and so seeds whoever created the group. Now a 409 that says so.
|
||||||
|
- Un-nesting (or removing) the last member of a group returned a 500 `ObjectClassViolationError`; now a 409 explaining that a group must keep at least one member.
|
||||||
|
- `GET /api/user/me` derived `isAdmin` from `memberOf`, which is only transitive when `nestgroup` is present. Against a stock server an admin holding their group via nesting would get `isAdmin=false` and lose the entire admin UI while still passing every server-side permission check.
|
||||||
|
- `utils/permission.js`'s `byGroup` checked `group.member.includes(user.dn)` per group, seeing only direct membership.
|
||||||
|
- `/api/directory-admin/access-summary` counted `member` values; it now counts the transitive closure, which matters precisely because `app_super_admin` is nested into every resource's admin group.
|
||||||
|
- Broken `api.html` link in the published docs (`API.md` lives at the repo root, so Jekyll never rendered one); pointed at the source, and added an API entry to the docs nav.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `@simpleworkjs/directory-schema` bumped to `^1.1.0`, which declares the ten metadata keys the admin form has always written but the schema never listed (`port`, `externalPort`, `isExternalReachable`, `os`, `gitRepo`, `isCurrentSite` as public; `vmid`, `macAddress`, `installPath`, `systemdService` as admin-only). Undeclared keys are dropped for non-admin callers, which blanked the portal's `OS:` field, hid every service's port from users, and left machine tokens unable to read the port mapping the firewall consumer exists to render.
|
||||||
|
- Resource metadata now includes `icon` and `tagline`, collected on the admin form (with a live icon preview) and rendered on the catalog cards.
|
||||||
|
|
||||||
## [1.10.0] - 2026-07-30
|
## [1.10.0] - 2026-07-30
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
@@ -338,6 +753,15 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
|
||||||
|
- Added Discovery plugin and Scheduler integration within the Directory.
|
||||||
|
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
||||||
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
||||||
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
||||||
@@ -355,3 +779,7 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
[1.1.2]: https://github.com/theta42/sso-manager-node/compare/v1.1.1...v1.1.2
|
||||||
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
|
[1.1.1]: https://github.com/theta42/sso-manager-node/compare/v1.1.0...v1.1.1
|
||||||
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
|
[1.1.0]: https://github.com/theta42/sso-manager-node/releases/tag/v1.1.0
|
||||||
|
|
||||||
|
## [1.19.6] - 2026-08-02
|
||||||
|
### Fixed
|
||||||
|
- Fixed Vault API returning 403 on the Secrets List due to `http-proxy-middleware` v2 rewriting the path incorrectly (it previously appended the `/api/vault/` mount path to the proxied Vault request).
|
||||||
|
|||||||
@@ -336,6 +336,17 @@ OAuth client). Note: re-running bootstrap resets the bootstrap-admin and
|
|||||||
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
service-account passwords to the values in `./config/sso-secrets.js`; non-theta
|
||||||
OAuth clients live in SSO Redis and are preserved by the volume.
|
OAuth clients live in SSO Redis and are preserved by the volume.
|
||||||
|
|
||||||
|
> **Note — the bundled slapd is built from source.** The all-in-one image
|
||||||
|
> compiles OpenLDAP from a pinned upstream commit to get the `nestgroup`
|
||||||
|
> overlay (nested groups; see `docs/directory.md`), because no 2.6.x release
|
||||||
|
> ships it. One consequence: master uses **LMDB 1.0.0**, whose on-disk format is
|
||||||
|
> mutually unreadable with the 0.9.x in OpenLDAP 2.6.x
|
||||||
|
> (`MDB_INVALID: File is not an LMDB file`). Moving a directory between a 2.6.x
|
||||||
|
> image and this one is a `slapcat` → `slapadd` reload, not a restart — the same
|
||||||
|
> shape as "Restore — LDAP only" above. Verify after a rebuild:
|
||||||
|
> `docker compose logs sso-manager | grep nestgroup` should report the overlay
|
||||||
|
> as available.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Method 2: Bare metal (Debian/Ubuntu)
|
## Method 2: Bare metal (Debian/Ubuntu)
|
||||||
|
|||||||
+110
-26
@@ -33,39 +33,119 @@ RUN if [ -n "$GIT_COMMIT" ]; then \
|
|||||||
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
&& git rev-parse --short HEAD > /commit.txt; } 2>/dev/null || echo unknown > /commit.txt; \
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── OpenLDAP from source ─────────────────────────────────────────────────────
|
||||||
|
# We build slapd from OpenLDAP master rather than installing Alpine's packages,
|
||||||
|
# for exactly one feature: the `nestgroup` overlay (ITS#10161, Howard Chu,
|
||||||
|
# 2024-03-21), which evaluates nested groups server-side. Nothing in any 2.6.x
|
||||||
|
# release can do this -- verified: 2.6.13 ships 26 overlay modules and
|
||||||
|
# nestgroup is not among them -- and the alternative is resolving nesting
|
||||||
|
# separately in every consumer (this app, SSSD on each host, jump-host, proxy),
|
||||||
|
# where any consumer that forgets silently under-grants access.
|
||||||
|
#
|
||||||
|
# Consequence to know about: master ships LMDB 1.0.0, whose on-disk format the
|
||||||
|
# 0.9.x used by 2.6.x cannot read, and vice versa
|
||||||
|
# ("MDB_INVALID: File is not an LMDB file"). Moving an existing directory onto
|
||||||
|
# this image is a slapcat/slapadd migration, not a restart. See DEPLOYMENT.md.
|
||||||
|
FROM node:20-alpine AS ldapbuild
|
||||||
|
|
||||||
|
# groff is not optional despite producing nothing we ship: the build descends
|
||||||
|
# into doc/man unconditionally and its Makefile calls soelim, which groff
|
||||||
|
# provides. Without it the whole `make` fails at the man-page stage
|
||||||
|
# ("soelim: not found") long after slapd itself has compiled fine.
|
||||||
|
RUN apk add --no-cache \
|
||||||
|
build-base autoconf automake libtool \
|
||||||
|
openssl-dev cyrus-sasl-dev \
|
||||||
|
git make pkgconf util-linux-dev groff
|
||||||
|
|
||||||
|
# Pinned to an exact commit, not a branch tip. This is the directory server the
|
||||||
|
# whole lab authenticates against; an unpinned `master` would mean every image
|
||||||
|
# rebuild silently ships whatever landed upstream that morning, and a bad day on
|
||||||
|
# master would take out logins with no way to tell what changed.
|
||||||
|
#
|
||||||
|
# TODO: drop this whole from-source stage once nestgroup ships in a release.
|
||||||
|
# It is master-only today (ITS#10161, 2024-03-21); the 2.7 roadmap has slipped
|
||||||
|
# from Fall 2024 to Fall 2025 and is still unreleased. When 2.7 lands with
|
||||||
|
# nestgroup, revert to `apk add openldap openldap-overlay-nestgroup ...` --
|
||||||
|
# the entrypoint already probes for nestgroup.so and needs no change, and the
|
||||||
|
# app already keys off app_ldap__nestedGroupsServerSide either way.
|
||||||
|
ARG OPENLDAP_COMMIT=350e9eb38b2270c2bad97c61ee02e85fb8f3196d
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
RUN git init -q . \
|
||||||
|
&& git remote add origin https://git.openldap.org/openldap/openldap.git \
|
||||||
|
&& git fetch -q --depth 1 origin "${OPENLDAP_COMMIT}" \
|
||||||
|
&& git checkout -q FETCH_HEAD \
|
||||||
|
&& git rev-parse HEAD > /opt-openldap-commit.txt
|
||||||
|
|
||||||
|
# Overlays are built as loadable modules (=mod) because docker-entrypoint.sh
|
||||||
|
# `moduleload`s them individually; nestgroup joins that set.
|
||||||
|
RUN ./configure \
|
||||||
|
--prefix=/opt/openldap \
|
||||||
|
--enable-slapd \
|
||||||
|
--enable-modules \
|
||||||
|
--enable-mdb \
|
||||||
|
--enable-memberof=mod \
|
||||||
|
--enable-refint=mod \
|
||||||
|
--enable-ppolicy=mod \
|
||||||
|
--enable-dynlist=mod \
|
||||||
|
--enable-nestgroup=mod \
|
||||||
|
--enable-syncprov=mod \
|
||||||
|
--enable-auditlog=mod \
|
||||||
|
--with-tls=openssl \
|
||||||
|
--with-cyrus-sasl \
|
||||||
|
&& make depend \
|
||||||
|
&& make -j"$(nproc)" \
|
||||||
|
&& make install
|
||||||
|
|
||||||
|
# pw-sha2 provides {SSHA512}, which every existing user password is stored as.
|
||||||
|
# It lives in contrib and is not covered by the configure flags above, so it is
|
||||||
|
# built separately against the just-built tree -- omitting it would make every
|
||||||
|
# user password unverifiable.
|
||||||
|
RUN cd contrib/slapd-modules/passwd/sha2 \
|
||||||
|
&& make prefix=/opt/openldap OPENLDAP_SRC=/src \
|
||||||
|
&& cp .libs/pw-sha2.so* /opt/openldap/libexec/openldap/
|
||||||
|
|
||||||
FROM node:20-alpine
|
FROM node:20-alpine
|
||||||
|
|
||||||
# Install OpenLDAP and required packages.
|
# Runtime libraries the from-source slapd links against, plus the app's own
|
||||||
# Alpine splits OpenLDAP into many small subpackages; there is no catch-all
|
# deps. No openldap* packages here: everything LDAP comes from /opt/openldap.
|
||||||
# "openldap-overlays" package. We install exactly the backends/overlays/modules
|
# libltdl (module loading -- slapd is useless without it, since every overlay
|
||||||
# the app depends on:
|
# is a loadable module) and libuuid are pulled in by the source build but are
|
||||||
# openldap-back-mdb : the mdb backend (slapd.conf uses `database mdb`)
|
# NOT dependencies of anything else here, so they must be named explicitly;
|
||||||
# openldap-overlay-ppolicy : ppolicy module + overlay (account locking)
|
# omitting them fails at runtime with "Error relocating ... lt_dlopenext:
|
||||||
# openldap-overlay-memberof : reverse group membership
|
# symbol not found", not at build time.
|
||||||
# openldap-overlay-refint : referential integrity on group members
|
|
||||||
# openldap-passwd-sha2 : pw-sha2 module ({SSHA512} user password hashing)
|
|
||||||
# Note: Alpine does NOT ship a ppolicy.schema file — on OpenLDAP 2.6 the ppolicy
|
|
||||||
# schema is built into ppolicy.so and registered when the module loads, so
|
|
||||||
# docker-entrypoint.sh loads it via `moduleload ppolicy` (no schema include).
|
|
||||||
# openssl : used by docker-entrypoint.sh to generate a JWT secret
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
openldap \
|
openssl \
|
||||||
openldap-clients \
|
libsasl \
|
||||||
openldap-back-mdb \
|
libltdl \
|
||||||
openldap-overlay-ppolicy \
|
libuuid \
|
||||||
openldap-overlay-memberof \
|
|
||||||
openldap-overlay-refint \
|
|
||||||
openldap-overlay-syncprov \
|
|
||||||
openldap-overlay-auditlog \
|
|
||||||
openldap-passwd-sha2 \
|
|
||||||
dumb-init \
|
dumb-init \
|
||||||
bash \
|
bash \
|
||||||
openssl \
|
|
||||||
redis \
|
redis \
|
||||||
|
nmap \
|
||||||
&& rm -rf /var/cache/apk/*
|
&& rm -rf /var/cache/apk/*
|
||||||
|
|
||||||
# The openldap package already creates the `ldap` user/group, which slapd runs
|
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||||
# as (see -u ldap -g ldap in docker-entrypoint.sh). Nothing to add here.
|
# Which upstream commit this slapd was built from — so a running container can
|
||||||
|
# answer "what am I actually running" without rebuilding.
|
||||||
|
COPY --from=ldapbuild /opt-openldap-commit.txt /opt/openldap/COMMIT
|
||||||
|
|
||||||
|
# The Alpine openldap package used to create these; nothing does now, and
|
||||||
|
# docker-entrypoint.sh runs slapd as -u ldap -g ldap.
|
||||||
|
RUN addgroup -S ldap 2>/dev/null || true \
|
||||||
|
&& adduser -S -D -H -G ldap ldap 2>/dev/null || true
|
||||||
|
|
||||||
|
# docker-entrypoint.sh invokes slapd/slappasswd/ldapadd/ldapsearch by bare name
|
||||||
|
# and probes a list of candidate module directories, so putting the from-source
|
||||||
|
# tree first on PATH is all that is needed to redirect it. Schemas are symlinked
|
||||||
|
# into the conventional location because the entrypoint's slapd.conf includes
|
||||||
|
# /etc/openldap/schema/*.schema, and the app's own schemas (theta42, sudo,
|
||||||
|
# openssh-lpk) are copied there too.
|
||||||
|
ENV PATH="/opt/openldap/bin:/opt/openldap/sbin:/opt/openldap/libexec:${PATH}"
|
||||||
|
RUN mkdir -p /etc/openldap/schema \
|
||||||
|
&& for f in /opt/openldap/etc/openldap/schema/*.schema; do \
|
||||||
|
ln -sf "$f" "/etc/openldap/schema/$(basename "$f")"; \
|
||||||
|
done
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
@@ -90,6 +170,7 @@ COPY nodejs/services ./services
|
|||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
|
||||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||||
@@ -101,9 +182,12 @@ COPY tos.md /tos.md
|
|||||||
# without internet access. Same flattened-path convention as tos.md above.
|
# without internet access. Same flattened-path convention as tos.md above.
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
|
# The docs/*.md tree (plus the images the docs link) is read at runtime too, so
|
||||||
|
# the whole docs/ dir must land at /docs. Without this every in-app /docs/<slug>
|
||||||
|
# page other than the root-level README/CHANGELOG/API/directory_spec 500s on the
|
||||||
|
# fs.readFileSync in routes/docs.js (files missing from the image).
|
||||||
COPY docs /docs
|
COPY docs /docs
|
||||||
|
|
||||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -36,10 +40,8 @@ RUN mkdir -p /app/config
|
|||||||
COPY tos.md /tos.md
|
COPY tos.md /tos.md
|
||||||
COPY README.md /README.md
|
COPY README.md /README.md
|
||||||
COPY CHANGELOG.md /CHANGELOG.md
|
COPY CHANGELOG.md /CHANGELOG.md
|
||||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
|
||||||
COPY API.md /API.md
|
COPY API.md /API.md
|
||||||
COPY directory_spec.md /directory_spec.md
|
COPY directory_spec.md /directory_spec.md
|
||||||
COPY docs /docs
|
|
||||||
|
|
||||||
# Seed script and utility
|
# Seed script and utility
|
||||||
COPY test_seed.js ./test_seed.js
|
COPY test_seed.js ./test_seed.js
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
|||||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Secrets are loaded from **OpenBao** at boot via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
|
||||||
|
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
|
||||||
|
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
|
||||||
|
`sso-broker`) — never the root token.
|
||||||
|
|
||||||
|
The SSO also acts as the **vault broker** for the whole stack: it mints
|
||||||
|
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
|
||||||
|
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
|
||||||
|
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
|
||||||
|
guard) and an admin **Apps** tab to mint scoped tokens for external apps
|
||||||
|
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
|
||||||
|
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
|
||||||
|
writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||||
|
|
||||||
|
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||||
|
not the authoritative store. For the full architecture, policies, token model,
|
||||||
|
and rotation procedure, see theta-env's
|
||||||
|
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
oidc: {
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
|
||||||
|
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
|
||||||
|
// writes the SSO-generated OAuth client creds into it AND into OpenBao
|
||||||
|
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
|
||||||
|
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
|
||||||
|
//
|
||||||
|
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
|
||||||
|
// This .example is tracked to document the expected shape only.
|
||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
// The SSO registers the proxy as an OAuth client and writes the real
|
||||||
|
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
|
||||||
|
clientId: 'set-me',
|
||||||
|
clientSecret: 'set-me',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Example secrets configuration file (file-based config).
|
|
||||||
//
|
|
||||||
// Bare-metal: install.sh seeds a filled-in version of this file at
|
|
||||||
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
|
|
||||||
// SMTP is left as a placeholder). Only write this one by hand if you're
|
|
||||||
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
|
|
||||||
// manually.
|
|
||||||
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
|
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
|
|
||||||
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
|
|
||||||
//
|
|
||||||
// Values here override conf/base.js and win over <environment>.js. `app_*` env
|
|
||||||
// vars (if any are set) override this file too — so the Docker stack passes NO
|
|
||||||
// app_* env, keeping this file authoritative.
|
|
||||||
//
|
|
||||||
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
|
|
||||||
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
|
|
||||||
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
|
|
||||||
// and ignored by the app — safe to leave them out for bare-metal use.
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
port: 3001,
|
|
||||||
name: 'SSO Manager', // shown in UI and outbound email
|
|
||||||
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
|
|
||||||
ldap: {
|
|
||||||
url: 'ldap://localhost', // or ldaps://host:636 for TLS
|
|
||||||
bindDN: 'cn=admin,dc=example,dc=com',
|
|
||||||
bindPassword: 'ldap-admin-pass',
|
|
||||||
userBase: 'ou=people,dc=example,dc=com',
|
|
||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
|
||||||
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
|
|
||||||
// direct LDAPS binds on /integrations. Leave empty to derive from the
|
|
||||||
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
|
|
||||||
// ldapsPort: 636,
|
|
||||||
},
|
|
||||||
smtp: {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false, // true for 465, false for other ports
|
|
||||||
user: 'noreply@example.com',
|
|
||||||
pass: 'your-smtp-password',
|
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
|
||||||
},
|
|
||||||
voipms: {
|
|
||||||
username: '', // VoIP.ms username (optional)
|
|
||||||
password: '', // VoIP.ms password (optional)
|
|
||||||
did: '', // VoIP.ms DID (optional)
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: 'https://sso.example.com', // falls back to the request host at runtime
|
|
||||||
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: 3600, // 1 hour in seconds
|
|
||||||
refresh_token: 2592000 // 30 days in seconds
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
|
|
||||||
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
|
|
||||||
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
|
|
||||||
stack: {
|
|
||||||
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
|
|
||||||
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
|
|
||||||
// in oauth.issuer — keep them consistent with this value
|
|
||||||
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
|
|
||||||
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
|
|
||||||
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
|
|
||||||
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
|
|
||||||
proxyHost: 'proxy.example.com', // public proxy hostname
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
adminUid: 'admin', // initial SSO admin username
|
|
||||||
adminPass: 'AdminPass123!', // initial SSO admin password
|
|
||||||
adminEmail: 'admin@example.com', // initial SSO admin email
|
|
||||||
},
|
|
||||||
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
|
|
||||||
};
|
|
||||||
+80
-2
@@ -76,11 +76,22 @@ fi
|
|||||||
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
# ── Locate the OpenLDAP module directory ────────────────────────────────────
|
||||||
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
# slapd.conf needs `modulepath` to find pw-sha2/ppolicy/memberof/refint. The
|
||||||
# path varies by distro; auto-detect rather than hardcode.
|
# path varies by distro; auto-detect rather than hardcode.
|
||||||
|
# /opt/openldap/libexec/openldap is first: that is the from-source build (see
|
||||||
|
# Dockerfile.openldap), which is the only one carrying the nestgroup overlay.
|
||||||
MODULE_PATH=""
|
MODULE_PATH=""
|
||||||
for p in /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
for p in /opt/openldap/libexec/openldap /usr/lib/openldap /usr/lib/ldap /usr/local/lib/openldap /opt/local/lib/openldap; do
|
||||||
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
if [[ -d "$p" ]]; then MODULE_PATH="$p"; break; fi
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nested-group support is only available when slapd was built with the
|
||||||
|
# nestgroup overlay. Detect rather than assume, so this entrypoint still
|
||||||
|
# produces a working slapd.conf against a distro OpenLDAP (where the app falls
|
||||||
|
# back to resolving nesting itself -- see nodejs/models/group_ldap.js).
|
||||||
|
NESTGROUP_AVAILABLE=0
|
||||||
|
if [[ -n "$MODULE_PATH" && -f "$MODULE_PATH/nestgroup.so" ]]; then
|
||||||
|
NESTGROUP_AVAILABLE=1
|
||||||
|
fi
|
||||||
|
|
||||||
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
# ── TLS certificate for LDAPS / StartTLS ────────────────────────────────────
|
||||||
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
# Legacy apps (e.g. the theta42/proxy, Gitea, Emby) bind to LDAP directly over the
|
||||||
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
# network. To keep password binds off the wire in cleartext we expose LDAPS
|
||||||
@@ -140,6 +151,7 @@ moduleload ppolicy
|
|||||||
moduleload memberof
|
moduleload memberof
|
||||||
moduleload refint
|
moduleload refint
|
||||||
moduleload auditlog
|
moduleload auditlog
|
||||||
|
NESTGROUP_MODULE_PLACEHOLDER
|
||||||
SYNCPROV_MODULE_PLACEHOLDER
|
SYNCPROV_MODULE_PLACEHOLDER
|
||||||
|
|
||||||
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
# TLS (LDAPS on 636 + StartTLS on 389). Cert/key paths are fixed; the files are
|
||||||
@@ -188,6 +200,8 @@ memberof-memberof-ad memberOf
|
|||||||
overlay refint
|
overlay refint
|
||||||
refint_attributes memberOf member manager owner
|
refint_attributes memberOf member manager owner
|
||||||
|
|
||||||
|
NESTGROUP_OVERLAY_PLACEHOLDER
|
||||||
|
|
||||||
# auditlog overlay (LDIF audit trail of all changes)
|
# auditlog overlay (LDIF audit trail of all changes)
|
||||||
overlay auditlog
|
overlay auditlog
|
||||||
auditlog /var/lib/ldap/auditlog.ldif
|
auditlog /var/lib/ldap/auditlog.ldif
|
||||||
@@ -221,6 +235,37 @@ else
|
|||||||
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
sed -i "/^SLAPMODULEPATH$/d" /etc/openldap/slapd.conf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Nested groups (nestgroup overlay) ──
|
||||||
|
# Three of the four flags, deliberately:
|
||||||
|
#
|
||||||
|
# member-filter (member=X) finds parent groups transitively. This is what
|
||||||
|
# Group.list(dn) rides on -- the core access question.
|
||||||
|
# memberof-filter (memberOf=X) matches members of nested groups. This is
|
||||||
|
# what SSSD's ldap_access_filter uses, so SSH/sudo inherit
|
||||||
|
# nesting without any client-side walking.
|
||||||
|
# memberof-values expands memberOf when reading a user, so anything that
|
||||||
|
# reads the attribute rather than searching still sees the
|
||||||
|
# full picture.
|
||||||
|
#
|
||||||
|
# member-values is deliberately NOT enabled. It expands the `member` attribute
|
||||||
|
# when reading a *group*, which sounds symmetric but destroys the distinction
|
||||||
|
# between "listed on this group" and "reachable through a nested one" -- and
|
||||||
|
# that distinction is not recoverable afterwards, because the raw values are
|
||||||
|
# simply not returned. The Groups UI needs it to show nested groups as nested
|
||||||
|
# rather than as a crowd of phantom users, and un-nesting needs it to know what
|
||||||
|
# it is actually removing. Transitive *answers* come from the filter flags and
|
||||||
|
# from Group.effectiveMembers(), which computes the closure explicitly.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
info "nestgroup overlay available — nested groups resolved server-side"
|
||||||
|
sed -i "s|^NESTGROUP_MODULE_PLACEHOLDER$|moduleload nestgroup|" /etc/openldap/slapd.conf
|
||||||
|
NESTGROUP_BLOCK="# nestgroup overlay (server-side nested group evaluation)\noverlay nestgroup\nnestgroup-base ou=groups,${LDAP_BASE_DN}\nnestgroup-flags member-filter memberof-filter memberof-values"
|
||||||
|
sed -i "s|^NESTGROUP_OVERLAY_PLACEHOLDER$|${NESTGROUP_BLOCK}|" /etc/openldap/slapd.conf
|
||||||
|
else
|
||||||
|
info "nestgroup overlay not present in ${MODULE_PATH:-<no module path>} — nested groups will be resolved by the app instead"
|
||||||
|
sed -i "/^NESTGROUP_MODULE_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
sed -i "/^NESTGROUP_OVERLAY_PLACEHOLDER$/d" /etc/openldap/slapd.conf
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Multi-Master Replication Configuration ──
|
# ── Multi-Master Replication Configuration ──
|
||||||
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
if [[ -n "${LDAP_SERVER_ID:-}" && -n "${LDAP_REPLICATION_HOSTS:-}" ]]; then
|
||||||
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
info "Configuring Multi-Master replication (Server ID: ${LDAP_SERVER_ID})"
|
||||||
@@ -310,7 +355,11 @@ EOF
|
|||||||
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
# Required SSO groups. The app gates admin/invite/oauth-admin on these;
|
||||||
# app_sso_service_account is a marker (not a permission gate) for
|
# app_sso_service_account is a marker (not a permission gate) for
|
||||||
# non-person accounts -- see the Users page.
|
# non-person accounts -- see the Users page.
|
||||||
for group in app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
#
|
||||||
|
# god_admin is the global super group (docs/GROUPS.md §2), the top of the
|
||||||
|
# group-inheritance lattice. It is seeded here so it exists from first boot;
|
||||||
|
# the theta-suite bootstrap puts the first admin person into it.
|
||||||
|
for group in god_admin app_sso_admin app_sso_invite app_sso_oauth_admin app_sso_service_account; do
|
||||||
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
ldapadd -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 << EOF || true
|
||||||
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
objectClass: groupOfNames
|
objectClass: groupOfNames
|
||||||
@@ -321,6 +370,27 @@ member: ${LDAP_BIND_DN}
|
|||||||
EOF
|
EOF
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Nest god_admin into the SSO admin groups, so god admins hold those rights
|
||||||
|
# by membership rather than by a special case in app code. This is what makes
|
||||||
|
# the privilege visible to every consumer -- SSSD, sudo, anything binding
|
||||||
|
# LDAP directly -- instead of only to callers that happen to route through
|
||||||
|
# utils/permission.js.
|
||||||
|
#
|
||||||
|
# app_sso_service_account is deliberately excluded: it is a marker for
|
||||||
|
# non-person accounts, not a permission, and nesting admins into it would
|
||||||
|
# misclassify them as service accounts on the Users page.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
for group in app_sso_admin app_sso_invite app_sso_oauth_admin; do
|
||||||
|
ldapmodify -x -D "$LDAP_BIND_DN" -w "$LDAP_ADMIN_PASS" -H ldap://localhost:389 >/dev/null 2>&1 << EOF || true
|
||||||
|
dn: cn=${group},ou=groups,${LDAP_BASE_DN}
|
||||||
|
changetype: modify
|
||||||
|
add: member
|
||||||
|
member: cn=god_admin,ou=groups,${LDAP_BASE_DN}
|
||||||
|
EOF
|
||||||
|
done
|
||||||
|
info "Nested god_admin into the SSO admin groups"
|
||||||
|
fi
|
||||||
|
|
||||||
info "LDAP directory initialized"
|
info "LDAP directory initialized"
|
||||||
else
|
else
|
||||||
info "LDAP directory already initialized — skipping seed"
|
info "LDAP directory already initialized — skipping seed"
|
||||||
@@ -380,6 +450,14 @@ if [[ "${SECRETS_JS_MODE:-0}" != 1 ]]; then
|
|||||||
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
export app_ldap__bindPassword="${app_ldap__bindPassword:-$LDAP_ADMIN_PASS}"
|
||||||
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
export app_ldap__userBase="${app_ldap__userBase:-ou=people,${LDAP_BASE_DN}}"
|
||||||
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
export app_ldap__groupBase="${app_ldap__groupBase:-ou=groups,${LDAP_BASE_DN}}"
|
||||||
|
# Tell the app whether slapd resolves nested groups for it. When true the app
|
||||||
|
# trusts a plain (member=) search to be transitive; when false it computes
|
||||||
|
# the closure itself. Getting this wrong in the "true" direction silently
|
||||||
|
# under-grants, so it is derived from the same nestgroup.so probe that
|
||||||
|
# decides whether the overlay is configured at all -- never hardcoded.
|
||||||
|
if [[ "$NESTGROUP_AVAILABLE" == "1" ]]; then
|
||||||
|
export app_ldap__nestedGroupsServerSide="${app_ldap__nestedGroupsServerSide:-true}"
|
||||||
|
fi
|
||||||
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
export app_oauth__jwtSecret="${app_oauth__jwtSecret:-$JWT_SECRET}"
|
||||||
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
# OIDC issuer advertised in /.well-known/openid-configuration. Default to the
|
||||||
# public https URL on the SSO subdomain of the LDAP domain; override with
|
# public https URL on the SSO subdomain of the LDAP domain; override with
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ nav:
|
|||||||
- title: Directory
|
- title: Directory
|
||||||
page: /directory.html
|
page: /directory.html
|
||||||
icon: fa-server
|
icon: fa-server
|
||||||
|
- title: Plugins
|
||||||
|
page: /plugins.html
|
||||||
|
icon: fa-plug
|
||||||
|
# API.md lives at the repo root, not under docs/, so Jekyll never renders an
|
||||||
|
# api.html for it — link the source directly, same as the Changelog.
|
||||||
|
- title: API
|
||||||
|
url: https://github.com/theta42/sso-manager-node/blob/master/API.md
|
||||||
|
icon: fa-code
|
||||||
- title: Changelog
|
- title: Changelog
|
||||||
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
url: https://github.com/theta42/sso-manager-node/blob/master/CHANGELOG.md
|
||||||
icon: fa-list
|
icon: fa-list
|
||||||
|
|||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Theta Agent & Endpoint Management
|
||||||
|
nav_order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theta Agent & Endpoint Management
|
||||||
|
|
||||||
|
The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enrollment
|
||||||
|
|
||||||
|
An agent is only real if the SSO issued its credential. **Tokens the server did
|
||||||
|
not issue are rejected** at the WebSocket handshake.
|
||||||
|
|
||||||
|
There are two ways to get a host enrolled, and the first is the normal one.
|
||||||
|
|
||||||
|
### Join key — install the agent and the host appears
|
||||||
|
|
||||||
|
Hand the machine a **join key** and nothing else. On first connect the SSO
|
||||||
|
enrolls the host, issues it its own per-agent token plus the public key it must
|
||||||
|
pin, and the agent **writes both into its own `agent.yml`** and blanks the join
|
||||||
|
key. From then on it authenticates as itself.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --join-key "tjk_..."
|
||||||
|
```
|
||||||
|
|
||||||
|
That is the whole procedure — no pre-registering the machine, no copying a
|
||||||
|
public key by hand. `setup.sh` mints a key and configures the stack's own host
|
||||||
|
this way automatically.
|
||||||
|
|
||||||
|
The join key is a *bootstrap* credential, not the host's identity. That
|
||||||
|
distinction is what keeps one key convenient without making it a fleet-wide
|
||||||
|
skeleton key: every host still ends up individually revocable, and a compromised
|
||||||
|
host does not yield a credential that works anywhere else.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/join-keys` | List keys (prefix + usage only; never the key) |
|
||||||
|
| `POST /api/agent/join-keys` | Mint one — returned **once** |
|
||||||
|
| `POST /api/agent/join-keys/:id/revoke` | Stop it enrolling new hosts |
|
||||||
|
| `DELETE /api/agent/join-keys/:id` | Remove it |
|
||||||
|
|
||||||
|
Revoking a join key does **not** disconnect hosts that already joined; they hold
|
||||||
|
their own tokens by then. Revoke the agent itself to cut a specific host off.
|
||||||
|
|
||||||
|
### Pre-registering a host
|
||||||
|
|
||||||
|
When you want the agent bound to a specific Directory host up front, enroll it
|
||||||
|
from **Directory → Install Agent**:
|
||||||
|
|
||||||
|
1. Give the agent a name and **bind it to a host resource**. The binding is what
|
||||||
|
links telemetry, status and commands to a Directory entry.
|
||||||
|
2. Press **Enroll & issue token**. The SSO mints a 256-bit token, stores only its
|
||||||
|
SHA-256, and shows the raw value **once**.
|
||||||
|
3. Copy the generated install command — it already carries the token and the
|
||||||
|
server's public key.
|
||||||
|
|
||||||
|
A host that self-enrolls with a join key arrives unbound; bind it afterwards with
|
||||||
|
`PUT /api/agent/nodes/:id` or from the Directory.
|
||||||
|
|
||||||
|
Or via the API:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST https://<SSO_HOST>/api/agent/enroll \
|
||||||
|
-H "Authorization: Bearer <admin-api-token>" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d '{"name": "web01", "resourceId": "<host-resource-uuid>"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
The response contains `token` (once only) and `publicKey`.
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `GET /api/agent/nodes` | Every enrolled agent, connected or not, plus the server public key |
|
||||||
|
| `POST /api/agent/enroll` | Mint an agent + token |
|
||||||
|
| `PUT /api/agent/nodes/:id` | Rename, or bind/unbind the host resource |
|
||||||
|
| `POST /api/agent/nodes/:id/rotate` | Issue a new token; the old one stops working immediately |
|
||||||
|
| `POST /api/agent/nodes/:id/revoke` | Disable the enrollment |
|
||||||
|
| `DELETE /api/agent/nodes/:id` | Remove the enrollment |
|
||||||
|
| `POST /api/agent/nodes/:id/command` | Send a command (signed automatically when high-risk) |
|
||||||
|
|
||||||
|
Revoke, rotate and delete **drop any live connection immediately** — they do not
|
||||||
|
wait for the agent to reconnect. Commands are addressed by agent **id**, never by
|
||||||
|
token: a token is a credential and has no business in a URL or a log.
|
||||||
|
|
||||||
|
Enrollment, revocation, rotation, every command, and every rejected connection
|
||||||
|
are written to the application log as structured `"component":"agent"` records
|
||||||
|
with the acting user.
|
||||||
|
|
||||||
|
> **Lost the token?** It cannot be recovered — only its hash is stored. Rotate
|
||||||
|
> the agent to issue a new one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Functionality
|
||||||
|
|
||||||
|
### 1. Host Discovery & Inventory
|
||||||
|
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
|
||||||
|
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
|
||||||
|
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
|
||||||
|
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
|
||||||
|
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
|
||||||
|
|
||||||
|
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
|
||||||
|
|
||||||
|
### 2. Real-Time Telemetry Streaming
|
||||||
|
Every 30 seconds, the agent streams real-time performance metrics:
|
||||||
|
- **CPU Load**: System-wide CPU utilization percentage.
|
||||||
|
- **Memory Utilization**: RAM usage percentage and available memory.
|
||||||
|
- **Disk Utilization**: Root filesystem usage percentage.
|
||||||
|
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
|
||||||
|
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Viewing in the SSO Manager
|
||||||
|
|
||||||
|
Agent status and telemetry live on the **Directory** page — there is no separate
|
||||||
|
Agents page. For each **host** resource that has a connected theta-agent, the
|
||||||
|
Directory shows a status dot in the row:
|
||||||
|
|
||||||
|
| Color | Meaning |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **Green** | Connected, healthy (CPU/RAM/disk within limits). |
|
||||||
|
| **Yellow** | Connected but under high load (CPU > 80% or RAM > 80% or disk > 90%). |
|
||||||
|
| **Red** | **Enrolled but not connected.** The agent exists and is expected — this is a fault. |
|
||||||
|
| **Grey** | No agent enrolled for this host, the enrollment is revoked, or the agent service is unreachable. |
|
||||||
|
|
||||||
|
Red and grey used to be the same colour, which made an ordinary directory of
|
||||||
|
hosts look like an outage. Because the enrollment now outlives the connection,
|
||||||
|
"installed but down" is distinguishable from "never had an agent".
|
||||||
|
|
||||||
|
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||||
|
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||||
|
|
||||||
|
An agent attaches to its host by its **enrollment binding** (`resourceId`), set
|
||||||
|
when you enroll it or later via `PUT /api/agent/nodes/:id`. Agents enrolled
|
||||||
|
without a binding fall back to matching their reported hostname against the
|
||||||
|
resource name — the old behaviour, kept only as a fallback, because it silently
|
||||||
|
failed whenever a Directory name differed from the machine's hostname and
|
||||||
|
aliased two hosts that happened to share one.
|
||||||
|
|
||||||
|
### Agent discovery feeds the Directory
|
||||||
|
|
||||||
|
A bound agent's discovery payload is written onto its host resource (`os`,
|
||||||
|
`kernel`, `cpu`, `ram_total_gb`, `disk_total_gb`, `ip`), tagged with
|
||||||
|
`discovery_sources: ["theta-agent"]` and an `agentId` back-reference. An agent
|
||||||
|
runs *on* the host it describes, so it is the most authoritative source the
|
||||||
|
directory has. An unbound agent goes through the normal discovery reconciler
|
||||||
|
instead, matching like any other source.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local-First Security & Capability Matrix
|
||||||
|
|
||||||
|
To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
|
||||||
|
|
||||||
|
| Capability | Config Key | Risk Level | Description & Impact |
|
||||||
|
| :--- | :--- | :--- | :--- |
|
||||||
|
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
|
||||||
|
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
|
||||||
|
| **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
|
||||||
|
| **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
|
||||||
|
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## High-Risk Command Verification (Protocol v1.2.0)
|
||||||
|
|
||||||
|
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
|
||||||
|
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace,
|
||||||
|
no HTML escaping, `signature` omitted).
|
||||||
|
2. The payload is signed with the SSO Manager's Ed25519 private key.
|
||||||
|
3. The Base64 signature is appended to the message payload.
|
||||||
|
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
|
||||||
|
|
||||||
|
**The signing key is persistent.** It lives in OpenBao at
|
||||||
|
`secret/agent/signing-key` and survives restarts, so the `public_key` you pin in
|
||||||
|
`agent.yml` keeps matching. (It used to be generated in memory at boot and
|
||||||
|
changed on every restart, which made pinning impossible.) If the SSO cannot load
|
||||||
|
or store a key it **refuses** to send high-risk commands rather than signing with
|
||||||
|
one no agent has seen — `GET /api/agent/nodes` reports this as
|
||||||
|
`signingAvailable: false`.
|
||||||
|
|
||||||
|
This requires the `sso-broker` OpenBao policy to grant `secret/agent/*`. Re-run
|
||||||
|
`./setup.sh` from theta-suite if you are upgrading.
|
||||||
|
|
||||||
|
**Verification is fail-closed on the agent.** An agent with no `public_key`
|
||||||
|
configured rejects every high-risk command. Earlier versions logged "skipping
|
||||||
|
signature verification" and executed them, so an agent installed without a key
|
||||||
|
would run `reboot`, `configure_ldap` and `arbitrary_bash` unverified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Installation & Deployment
|
||||||
|
|
||||||
|
### Quick One-Liner Install
|
||||||
|
Run the following command as `root` on the target Linux host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- \
|
||||||
|
--url "https://<SSO_HOST>" --token "<ISSUED_TOKEN>" --public-key "<BASE64_PUBLIC_KEY>"
|
||||||
|
```
|
||||||
|
|
||||||
|
Both values come from enrollment. The **Install Agent** modal builds this line
|
||||||
|
for you with them already filled in. Omitting `--public-key` leaves the agent
|
||||||
|
able to report telemetry but unable to accept any high-risk command.
|
||||||
|
|
||||||
|
### Custom Config Wizard
|
||||||
|
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration File Example (`/etc/theta42/agent.yml`)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# /etc/theta42/agent.yml
|
||||||
|
server_url: "wss://sso.example.com"
|
||||||
|
# Issued by the SSO. Left empty when installing with a join key -- the agent
|
||||||
|
# fills it in itself once the server enrolls it.
|
||||||
|
auth_token: "c8181ce0e55bf7302b11d719a7ae39adcd7604de461e6e363f8bb4fadf126acb"
|
||||||
|
# Bootstrap credential. Used only while auth_token is empty, and blanked by the
|
||||||
|
# agent once it has its own token.
|
||||||
|
join_key: ""
|
||||||
|
location: "dc-01-rack-12"
|
||||||
|
# Base64 of the RAW 32-byte Ed25519 public key -- exactly the `publicKey` value
|
||||||
|
# from enrollment or GET /api/agent/nodes. Not a PEM body: a base64-decoded
|
||||||
|
# SPKI blob is 44 bytes, the agent requires 32, and it will refuse every signed
|
||||||
|
# command if this is wrong.
|
||||||
|
public_key: "D0cJB3iuStTzhXlu7tFDh/eEXFxRZwkuwQJJhFSqwlQ="
|
||||||
|
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: true
|
||||||
|
reboot: false
|
||||||
|
service_control: ["nginx", "docker", "sssd"]
|
||||||
|
arbitrary_bash: false
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent is rejected (`close 4001`)
|
||||||
|
|
||||||
|
If the agent logs that the server rejected its token, the enrollment — not the
|
||||||
|
network — is the problem. The SSO accepts the WebSocket upgrade and then closes
|
||||||
|
with an application code:
|
||||||
|
|
||||||
|
| Code | Meaning | Fix |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `4001` | Token unknown, or never issued by this server | Enroll the host and put the issued token in `agent.yml` |
|
||||||
|
| `4002` | Superseded — another connection authenticated as this agent | Normal; two copies of the agent are running |
|
||||||
|
| `4003` | Enrollment revoked or deleted | Re-enroll |
|
||||||
|
| `4004` | Token rotated; `agent.yml` has the old value | Copy the new token |
|
||||||
|
|
||||||
|
The agent backs off for 5 minutes on `4001`/`4003`/`4004` rather than retrying
|
||||||
|
every 5 seconds — a credential that is wrong will not fix itself, and hammering
|
||||||
|
the SSO only floods its audit log.
|
||||||
|
|
||||||
|
An agent installed before protocol v1.2.0 carries a token generated in the
|
||||||
|
browser that the server never recorded, so it will be rejected with `4001` until
|
||||||
|
re-enrolled. The quickest fix is to put a **join key** in its `agent.yml` as
|
||||||
|
`join_key` and blank `auth_token` — it will re-enroll itself on the next
|
||||||
|
reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting: agent can't connect (`dial tcp ... i/o timeout`)
|
||||||
|
|
||||||
|
If the agent host logs `Dial error: dial tcp <ip>:443: i/o timeout` while
|
||||||
|
connecting to `wss://<sso-host>/api/agent/ws`, the WebSocket path is usually
|
||||||
|
fine — this is a **network/NAT** problem, not an agent or SSO bug. A host behind
|
||||||
|
the same NAT that owns the SSO often cannot reach its own **public IP** (no
|
||||||
|
hairpin/loopback NAT on many home routers), so the TCP dial times out even
|
||||||
|
though the same address works from outside.
|
||||||
|
|
||||||
|
Fix options:
|
||||||
|
1. Point `agent.yml` `server_url` at an address the host can reach directly —
|
||||||
|
e.g. the SSO host's LAN IP (`http://<lan-ip>` or `http://<lan-ip>:3001` for a
|
||||||
|
no-TLS direct path).
|
||||||
|
2. Enable **NAT reflection / hairpin NAT** on the router so LAN hosts can reach
|
||||||
|
their own public IP:443.
|
||||||
|
3. Add a local route/firewall rule on the agent host for its public IP.
|
||||||
|
|
||||||
|
> Note: on a deployment where the theta42 proxy fronts `sso.suite.example`, make
|
||||||
|
> sure the proxy has a **persistent Host record** for the real SSO domain — not
|
||||||
|
> just the `localtest.me` placeholder — so routing survives a proxy restart
|
||||||
|
> (an in-memory lookup cache can mask a missing Redis record for up to ~1h).
|
||||||
|
|
||||||
|
|
||||||
@@ -53,6 +53,29 @@ photo server. Once a group exists, add or remove members from the
|
|||||||
**Groups** page, and point the other app's "who's allowed in" setting at
|
**Groups** page, and point the other app's "who's allowed in" setting at
|
||||||
that group's name.
|
that group's name.
|
||||||
|
|
||||||
|
### Groups inside groups
|
||||||
|
|
||||||
|
A group can contain another group, not just people — the *Nested* tab on any
|
||||||
|
group card. Everyone in the inner group counts as a member of the outer one,
|
||||||
|
however many levels deep it goes.
|
||||||
|
|
||||||
|
This is mostly a way to stop repeating yourself. Make one `developers` group,
|
||||||
|
nest it into the handful of things developers should reach, and adding a new
|
||||||
|
developer to that one group grants all of them at once — instead of adding them
|
||||||
|
to each individually and slowly drifting out of sync. The app already does this
|
||||||
|
for itself: super admins are nested into every resource's admin group, and each
|
||||||
|
admin group into its access group, so "can administer it" always implies "can
|
||||||
|
use it".
|
||||||
|
|
||||||
|
Two things it won't let you do: put a group inside itself (directly or round a
|
||||||
|
longer loop), and empty a group completely — every group must keep at least one
|
||||||
|
member.
|
||||||
|
|
||||||
|
A note if you also manage the directory by hand: a group's member list shows
|
||||||
|
what is *directly* listed on it. Someone who gets in through a nested group is
|
||||||
|
a real member but won't appear there — the **Nested** tab shows what is nested,
|
||||||
|
and the API's `effective` view lists everyone who actually gets in.
|
||||||
|
|
||||||
## Every account's personal group
|
## Every account's personal group
|
||||||
|
|
||||||
Separately from the groups above, every single account — person or
|
Separately from the groups above, every single account — person or
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ description: A plain-language guide to personal access tokens in SSO Manager.
|
|||||||
|
|
||||||
This page explains what an API token is and when you'd want one. For the
|
This page explains what an API token is and when you'd want one. For the
|
||||||
full list of API endpoints a token can call, see the
|
full list of API endpoints a token can call, see the
|
||||||
[API reference](api.html).
|
[API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
## What's an API token, in plain terms?
|
## What's an API token, in plain terms?
|
||||||
|
|
||||||
@@ -54,6 +54,6 @@ it stops working right away.
|
|||||||
## Want more detail?
|
## Want more detail?
|
||||||
|
|
||||||
This page doesn't attempt to list every API endpoint or show request/
|
This page doesn't attempt to list every API endpoint or show request/
|
||||||
response examples — for that, see the full [API reference](api.html).
|
response examples — for that, see the full [API reference](https://github.com/theta42/sso-manager-node/blob/master/API.md).
|
||||||
|
|
||||||
[← Back to Home](index.html)
|
[← Back to Home](index.html)
|
||||||
|
|||||||
+54
-2
@@ -54,9 +54,43 @@ Resources carry a flexible `metadata` JSON object that can store essential conte
|
|||||||
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
- **Install Path**: The filesystem path where the service is installed (e.g. `/opt/app`).
|
||||||
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
|
- **Systemd Service**: The systemd unit name for the service (e.g. `app.service`).
|
||||||
|
|
||||||
|
### Who sees which metadata
|
||||||
|
|
||||||
|
Metadata keys are declared in `@simpleworkjs/directory-schema` with an `admin` flag, and every API response is passed through its projection. There are three tiers:
|
||||||
|
|
||||||
|
- **Public** — returned to any authenticated caller, including machine (`ServiceToken`) callers: `ip`, `address`, `sshPort`, `fqdn`, `dnsNames`, `port`, `externalPort`, `portMappings`, `isExternalReachable`, `os`, `gitRepo`, `subType`, `icon`, `tagline`, `isPublic`, `isProduction`, `requestable`, `isCurrentSite`.
|
||||||
|
- **Admin-only** — only for members of `app_sso_directory_admin` / `app_sso_admin`: `vmid`, `macAddress`, `installPath`, `systemdService`, and the OAuth config keys (`redirect_uris`, `scopes`, `allowed_groups`, `token_lifetime`).
|
||||||
|
- **Never returned** — `client_secret_hash`, plus any key matching `/secret|password|privatekey/i`. Stripped on every path, admins included.
|
||||||
|
|
||||||
|
Note that machine tokens are deliberately *not* admins, so anything a machine consumer needs (the firewall generator reads `port` / `externalPort` / `isExternalReachable`) has to be in the public tier. A metadata key that isn't declared at all is treated as admin-only and will silently vanish for normal users — if you add a field to the admin form, declare it in the schema package too.
|
||||||
|
|
||||||
|
## Catalog & access requests
|
||||||
|
|
||||||
|
The site root (`/`) is the end-user catalog — the only ungated page in the nav. It shows:
|
||||||
|
|
||||||
|
- **My Access** — everything the signed-in user can reach (`GET /api/discovery/me`), each card carrying a **how to reach it** block: the URL for a service, or the SSH invocation for a host. When `directory.jumpHost` is set in the config, host cards render the jump-host form `ssh <uid>_-_<slug>@<jumpHost>`; otherwise they fall back to a direct `ssh <uid>@<ip>`.
|
||||||
|
- **Discover More** — everything else in the directory, with a **Request access** button.
|
||||||
|
- **My Requests** / **Awaiting My Approval** — pending requests, and the approve/deny queue for anyone who owns a requested resource.
|
||||||
|
|
||||||
|
A request is a proposal to join an LDAP group. It targets the resource's `member`-level group (the `_access` one, never `_admin`), and approving it performs the LDAP group add — so LDAP stays the single access-control truth and the table is just the audit trail. Approvals are idempotent: approving for someone already in the group succeeds rather than erroring.
|
||||||
|
|
||||||
|
Requests are decided by the resource's `owner`, or by any directory admin. Mark a resource `metadata.requestable = false` to keep it out of self-service.
|
||||||
|
|
||||||
## 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>
|
||||||
|
|
||||||
@@ -80,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)
|
||||||
@@ -104,4 +146,14 @@ All of the above uses the same admin API the UI does (group `app_sso_directory_a
|
|||||||
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
- `GET/POST /api/directory-admin/resources`, `PUT/DELETE /api/directory-admin/resources/:id`
|
||||||
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
- `GET/POST/DELETE /api/directory-admin/edges` — parent/child links (`hosts`, `oauth` relations)
|
||||||
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
- `GET/POST/DELETE /api/directory-admin/groups` — resource ↔ LDAP group links
|
||||||
|
- `GET /api/directory-admin/access-summary` — per-resource group + member counts (the Access column)
|
||||||
|
- `GET /api/directory-admin/user-access/:uid` — the reverse lookup: every resource a given user can reach, and via which group
|
||||||
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
- Read-only graph views (any authenticated user): `GET /api/discovery/resources`, `/api/discovery/resources/:slug`, `/api/discovery/graph`, `/api/discovery/me`
|
||||||
|
|
||||||
|
Access requests are open to any authenticated user; deciding is gated per-resource inside the router (resource owner or directory admin):
|
||||||
|
|
||||||
|
- `POST /api/access-requests` — `{slug | resourceId, groupCn?, note?}`
|
||||||
|
- `GET /api/access-requests/mine` — the caller's own history
|
||||||
|
- `GET /api/access-requests` — pending requests the caller may decide
|
||||||
|
- `POST /api/access-requests/:id/approve` · `POST /api/access-requests/:id/deny`
|
||||||
|
- `DELETE /api/access-requests/:id` — the requester withdraws their own pending request
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery & Inventory
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery & Inventory
|
||||||
|
|
||||||
|
The Directory holds two different kinds of thing, and the distinction matters
|
||||||
|
for every consumer of the directory:
|
||||||
|
|
||||||
|
- **Catalog resources** — what you have declared. Created by hand, seeded by
|
||||||
|
`setup.sh`, or *promoted* from a discovery result. These get LDAP access
|
||||||
|
groups, appear in the Catalog, and are the only hosts the
|
||||||
|
[jump host](https://github.com/theta42/jump-host) will connect you to.
|
||||||
|
- **Discovered resources** — what the network reports. Produced by
|
||||||
|
[discovery plugins](plugins.html) and shown on the **Discovered Inventory**
|
||||||
|
tab. They are a queue of "this exists, do you want to manage it?", not
|
||||||
|
infrastructure you have committed to.
|
||||||
|
|
||||||
|
A resource is discovery-only when its `metadata.discovery_sources` is non-empty
|
||||||
|
and it has never been promoted. Promoting sets `metadata.managed = true`, at
|
||||||
|
which point it becomes catalog content like any other resource.
|
||||||
|
|
||||||
|
> Nothing grants access to a discovered resource. It carries no groups until it
|
||||||
|
> is promoted, and the jump host applies the same rule — an unpromoted Proxmox
|
||||||
|
> guest is not a jump target.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Where discovered data comes from
|
||||||
|
|
||||||
|
| Source | What it reports |
|
||||||
|
| :--- | :--- |
|
||||||
|
| [Proxmox](plugins.html) | The cluster endpoint, its nodes, and every VM/LXC with NICs, `vmid` and node |
|
||||||
|
| [UniFi](plugins.html) | Network devices and connected clients, by MAC |
|
||||||
|
| [nmap](plugins.html) | Hosts and open ports on a target range |
|
||||||
|
| [Docker](plugins.html) | Containers on a local or remote daemon |
|
||||||
|
| [theta-agent](agents.html) | The host it runs on — OS, kernel, CPU, RAM, disk, addresses |
|
||||||
|
| [ldap-client](directory.html) | A Linux host registering itself when it joins |
|
||||||
|
|
||||||
|
An agent is the most authoritative of these: it runs *on* the machine it
|
||||||
|
describes. A network scan is the least — it only knows what answered.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How results are matched to existing resources
|
||||||
|
|
||||||
|
Every source runs through one reconciler, so two sources seeing the same
|
||||||
|
machine converge on one resource instead of creating duplicates. Matching is
|
||||||
|
tried in order of precision:
|
||||||
|
|
||||||
|
1. **MAC address** — the strongest signal, compared across every interface.
|
||||||
|
2. **IP address** — any address on any interface, plus `metadata.address`.
|
||||||
|
3. **Slug, name, or base hostname** — last resort.
|
||||||
|
|
||||||
|
A candidate must also be **the same kind**. Without that guard a discovered VM
|
||||||
|
named `gitea-runner` would match a hand-created *service* of the same name on
|
||||||
|
rule 3 and overwrite it. (`template` counts as `host`: converting a VM to a
|
||||||
|
template is the same machine.)
|
||||||
|
|
||||||
|
When a match is found the metadata is merged, interfaces are unioned by MAC, and
|
||||||
|
the source is added to `discovery_sources` — so a resource can legitimately read
|
||||||
|
`["unifi", "proxmox"]`, meaning two independent sources agree it exists.
|
||||||
|
|
||||||
|
### Naming
|
||||||
|
|
||||||
|
Sources disagree about names, so the most human one wins: a **hostname** beats
|
||||||
|
an **IP-shaped** name, which beats a **MAC-shaped** name; length is only a
|
||||||
|
tie-break within a rank. This is why a device UniFi knows only as
|
||||||
|
`ac:16:2d:b3:da:80` is renamed `dl380-0` once Proxmox reports it.
|
||||||
|
|
||||||
|
### Relationships
|
||||||
|
|
||||||
|
Plugins emit edges as well as resources (a Proxmox node under its cluster
|
||||||
|
endpoint, a guest under its node). The reconciler refuses any edge that would
|
||||||
|
make a resource its own parent, or that would close a loop — a cycle renders as
|
||||||
|
an infinitely nested tree and breaks every ancestor walk in the app.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Promoting a discovered resource
|
||||||
|
|
||||||
|
On the **Discovered Inventory** tab, press **Promote**. The resource form opens
|
||||||
|
pre-filled with what was discovered — name, kind, address, subtype — so you can
|
||||||
|
correct it before committing. Saving marks it managed and provisions its
|
||||||
|
[LDAP groups](groups.html).
|
||||||
|
|
||||||
|
Each row shows what the directory knows about the device: its source(s), its
|
||||||
|
`vmid` where applicable, the identifier it has at that source (`sourceId`, e.g.
|
||||||
|
`dl380-0/qemu/234`), and every interface with its MAC and address. If a row
|
||||||
|
looks wrong, that detail is where to start.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stale results
|
||||||
|
|
||||||
|
Resources that are *only* auto-discovered are garbage-collected: if a source
|
||||||
|
stops reporting one for long enough it is marked
|
||||||
|
`lifecycle_state: "archived"` rather than deleted. Anything you created or
|
||||||
|
promoted is never touched — `manual` in `discovery_sources` exempts it.
|
||||||
|
|
||||||
|
A Proxmox node that is powered off is still reported (with its `status`), so
|
||||||
|
downtime does not look like decommissioning.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What the stack discovers about itself
|
||||||
|
|
||||||
|
`setup.sh` seeds its own components as catalog resources — the site, the stack
|
||||||
|
host, `theta-proxy` and `theta-jump`, and the services under them. The Docker
|
||||||
|
discovery plugin then finds the containers backing them. Containers belonging to
|
||||||
|
the theta-suite compose project are recognised and attached to the service they
|
||||||
|
implement rather than appearing as unmanaged strangers, so a fresh install has an
|
||||||
|
empty Discovered Inventory rather than five things demanding attention.
|
||||||
+312
@@ -0,0 +1,312 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Group & Permission Model
|
||||||
|
nav_order: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Theta42 Group & Permission Model
|
||||||
|
|
||||||
|
This is the canonical reference for how **groups and permissions work** across the
|
||||||
|
theta42 suite (SSO Manager, Proxy, Jump-Host) and how **downstream apps and Linux
|
||||||
|
hosts** should read and use them. It is written to be implementable by both humans
|
||||||
|
and LLM agents.
|
||||||
|
|
||||||
|
Everything below assumes LDAP is the single source of truth for identity and group
|
||||||
|
membership. Group membership is managed in the **SSO Manager Directory**, generated
|
||||||
|
from adopted resources — there is **no standalone "Groups" page**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Principles
|
||||||
|
|
||||||
|
1. **Groups are a projection of the resource graph.** Every adopted host and app
|
||||||
|
in the Directory gets its own groups, auto-created from its identity. Group
|
||||||
|
membership is managed on the resource's modal.
|
||||||
|
2. **Two orthogonal resource namespaces: `host` and `app`.** A host administers
|
||||||
|
hosts; an app administers apps. They do not inherit from each other.
|
||||||
|
3. **Three levels per resource: `admin`, `access`, and opaque `capability`.**
|
||||||
|
`admin` implies `access`. Capabilities are explicit and never implied by
|
||||||
|
`admin`.
|
||||||
|
4. **Multi-site by prefix.** Each site's groups are fully independent, scoped by
|
||||||
|
the site slug.
|
||||||
|
5. **Hosts map, LDAP stays clean.** Directory groups are `groupOfNames` (RBAC)
|
||||||
|
with **no `gidNumber`**. A Linux host uses SSSD to import only the groups it
|
||||||
|
needs and generate their GIDs on the fly (see §8) — no mass import, no GID
|
||||||
|
bloat. Only the meta groups are never imported by hosts.
|
||||||
|
6. **The directory is the only place groups are created.** `god_admin` is the sole
|
||||||
|
group that does not belong to a resource or site.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Group schema
|
||||||
|
|
||||||
|
`S` = site slug (see §7 for normalization). `<host>`/`<app>` = the resource slug.
|
||||||
|
`<capability>` = an opaque, app-defined capability token (see §4).
|
||||||
|
|
||||||
|
| Group | Scope | Meaning |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `god_admin` | global | **Everything, everywhere** (all sites, hosts, apps, consoles, all capabilities). The only non-site group. |
|
||||||
|
| `S_super_admin` | site | Everything on site `S` (all hosts, apps, consoles, all capabilities at `S`). |
|
||||||
|
| `S_hosts_admin` | site | Admin on **all hosts** at `S`. |
|
||||||
|
| `S_hosts_access` | site | Access to **all hosts** at `S`. |
|
||||||
|
| `S_hosts_<capability>` | site | Capability `<capability>` on **all hosts** at `S`. |
|
||||||
|
| `S_host_<host>_admin` | host | Admin on host `<host>`. |
|
||||||
|
| `S_host_<host>_access` | host | Access to host `<host>`. |
|
||||||
|
| `S_host_<host>_<capability>` | host | Capability `<capability>` on host `<host>`. |
|
||||||
|
| `S_apps_admin` | site | Admin on **all apps** at `S`. |
|
||||||
|
| `S_apps_access` | site | Access to **all apps** at `S`. |
|
||||||
|
| `S_apps_<capability>` | site | Capability `<capability>` on **all apps** at `S`. |
|
||||||
|
| `S_app_<app>_admin` | app | Admin on app `<app>`. |
|
||||||
|
| `S_app_<app>_access` | app | Access to app `<app>`. |
|
||||||
|
| `S_app_<app>_<capability>` | app | Capability `<capability>` on app `<app>`. |
|
||||||
|
|
||||||
|
### Meta groups (implicit membership — not POSIX, no gidNumber)
|
||||||
|
|
||||||
|
| Group | Scope | Meaning |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| `everyone` | global | **All authenticated users**, any site. |
|
||||||
|
| `S_everyone` | site | **All authenticated users** at site `S`. |
|
||||||
|
|
||||||
|
These are resolved by the directory (any authenticated user passes), never
|
||||||
|
enumerated as LDAP members, and cannot be used as Unix groups.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Naming, normalization & reserved rules
|
||||||
|
|
||||||
|
- The **structural delimiter is `_`**. It appears only between the fixed segments
|
||||||
|
of a group name.
|
||||||
|
- **Site, host, and app slugs never contain `_`.** Normalize to lowercase;
|
||||||
|
spaces and `_` → `-`; strip other non-`[a-z0-9-]`. A host named `Web 01` and a
|
||||||
|
site `Main Office` produce slugs `web-01` and `main-office`.
|
||||||
|
- **Aggregate groups use the plural kind** (`hosts`, `apps`); per-resource groups
|
||||||
|
use the singular (`host`, `app`). This makes `S_hosts_admin` unambiguous even
|
||||||
|
if a host were named `admin` (that host would be `S_host_admin_admin`).
|
||||||
|
- **The last segment is the level.** If it is `admin` or `access` it is a known
|
||||||
|
level; any other value is an **opaque capability** owned by a downstream app.
|
||||||
|
- **Total length budget:** keep a group cn under ~120 chars; reject group
|
||||||
|
creation that would exceed it.
|
||||||
|
- Groups are **`groupOfNames`** (RFC 2307bis) with **no `gidNumber`**. GIDs are
|
||||||
|
generated on the host by SSSD for only the groups that host imports (see §8).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Levels and opaque capabilities
|
||||||
|
|
||||||
|
- **`admin`** — manage (create/update/delete/config) the resource.
|
||||||
|
- **`access`** — use/read the resource.
|
||||||
|
- **`<capability>`** — an arbitrary token the SSO does **not** interpret. The SSO
|
||||||
|
manages membership and exposes the group to the app; **the downstream app
|
||||||
|
defines and enforces what the capability means** (e.g. `emby_admin`,
|
||||||
|
`gitea_maintain`, `reboot`, `backup`).
|
||||||
|
|
||||||
|
The directory recognizes `admin`, `access`, `super_admin`, and the meta groups.
|
||||||
|
Everything else on a resource group is treated as an opaque capability group and
|
||||||
|
passed through to consumers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Permission resolution (inheritance)
|
||||||
|
|
||||||
|
Define a user's **effective permission** on a resource by checking, from most
|
||||||
|
specific to most general, whether they are a member of any applicable group. The
|
||||||
|
rule: a higher group implies everything below it.
|
||||||
|
|
||||||
|
### On host `H` at site `S`
|
||||||
|
|
||||||
|
| Wanted | Granted if the user is a member of **any** of |
|
||||||
|
| :--- | :--- |
|
||||||
|
| **admin** on `H` | `god_admin` · `S_super_admin` · `S_hosts_admin` · `S_host_H_admin` |
|
||||||
|
| **access** on `H` | (any admin rule above) · `S_hosts_access` · `S_host_H_access` |
|
||||||
|
| **capability `C`** on `H` | `god_admin` · `S_super_admin` · `S_hosts_C` · `S_host_H_C` |
|
||||||
|
|
||||||
|
### On app `A` at site `S`
|
||||||
|
|
||||||
|
Identical, with `app`/`apps` substituted for `host`/`hosts`.
|
||||||
|
|
||||||
|
### Management console (SSO / Proxy / Jump-Host)
|
||||||
|
|
||||||
|
Each console is registered as an **app** on its site, so console admin is:
|
||||||
|
|
||||||
|
`god_admin` · `S_super_admin` · `S_app_<console>_admin`
|
||||||
|
|
||||||
|
### Pseudocode
|
||||||
|
|
||||||
|
```
|
||||||
|
def effective(resource, level_or_cap, site):
|
||||||
|
if user in "god_admin": return True
|
||||||
|
if user in f"{site}_super_admin": return True
|
||||||
|
if level_or_cap in ("admin","access"):
|
||||||
|
agg = f"{site}_{resource.kind}s_{level_or_cap}"
|
||||||
|
if user in agg: return True
|
||||||
|
specific = f"{site}_{resource.kind}_{resource.slug}_{level_or_cap}"
|
||||||
|
if user in specific: return True
|
||||||
|
if level_or_cap == "access": return effective(resource, "admin", site)
|
||||||
|
if level_or_cap == "admin": return False # access does not imply admin
|
||||||
|
return False
|
||||||
|
```
|
||||||
|
|
||||||
|
`everyone` / `S_everyone` are a special grantee: if a resource grants a group to
|
||||||
|
`everyone` (or `S_everyone`), any authenticated user (at that site) passes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Where groups live — the Directory, generated from adopted resources
|
||||||
|
|
||||||
|
- There is **no standalone Groups page.** Group creation/management happens on an
|
||||||
|
**adopted resource** in the Directory.
|
||||||
|
- When a host or app is **adopted** (promoted from Discovered Inventory to
|
||||||
|
managed), the directory auto-creates its `_admin` and `_access` groups (and
|
||||||
|
site aggregates if configured). Capability groups are created on demand.
|
||||||
|
- Membership (add/remove users) and capability grants are managed on that
|
||||||
|
resource's modal.
|
||||||
|
- Deleting a resource removes its per-resource groups.
|
||||||
|
- The `S_super_admin`, `S_hosts_*`, `S_apps_*`, `S_everyone` site groups and the
|
||||||
|
global `god_admin`/`everyone` are managed at the site level (not on a single
|
||||||
|
host/app resource).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Multi-site isolation
|
||||||
|
|
||||||
|
One LDAP tree can serve many sites ("Main Office", "Branch Office", "co-lo",
|
||||||
|
"Mikes Homelab", …). Each site `S` has its own fully independent set of `S_*`
|
||||||
|
groups behind its prefix. A `main-office_super_admin` or `main-office_hosts_admin`
|
||||||
|
touches nothing in `branch-office_*` or `steves-homelab_*`. Only `god_admin` and
|
||||||
|
`everyone` cross site boundaries.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Unix/POSIX groups — mapped on the host, not in LDAP
|
||||||
|
|
||||||
|
Directory groups are **`groupOfNames`** (RFC 2307bis) and carry **no `gidNumber`**.
|
||||||
|
There are hundreds of them and only a handful matter on any given host, so we do
|
||||||
|
**not** bloat LDAP with GIDs. Instead, each Linux host uses SSSD to import only the
|
||||||
|
groups it cares about and map them to GIDs **on the fly** (algorithmic ID mapping).
|
||||||
|
This keeps the directory clean and the per-host surface tiny.
|
||||||
|
|
||||||
|
### SSSD — generate GIDs on the fly, import only what you need
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[domain/example]
|
||||||
|
id_provider = ldap
|
||||||
|
auth_provider = ldap
|
||||||
|
ldap_uri = ldaps://ldap.example
|
||||||
|
ldap_search_base = dc=example,dc=com
|
||||||
|
|
||||||
|
# groupOfNames (RFC 2307bis) schema
|
||||||
|
ldap_schema = rfc2307bis
|
||||||
|
ldap_group_object_class = groupOfNames
|
||||||
|
ldap_group_member = member
|
||||||
|
|
||||||
|
# Map GIDs mathematically from the LDAP UUID — no gidNumber in LDAP
|
||||||
|
ldap_id_mapping = true
|
||||||
|
ldap_group_uuid = entryUUID
|
||||||
|
|
||||||
|
# Import ONLY the groups this host needs (e.g. a naming convention or an OU)
|
||||||
|
ldap_group_search_filter = (&(objectClass=groupOfNames)(cn=linux-*))
|
||||||
|
```
|
||||||
|
|
||||||
|
Key ideas:
|
||||||
|
- `ldap_id_mapping = true` + `ldap_group_uuid = entryUUID` make SSSD derive a
|
||||||
|
stable GID for any group it imports, so **no `gidNumber` attribute is required**
|
||||||
|
in LDAP.
|
||||||
|
- `ldap_group_search_filter` is the gatekeeper: SSSD imports only groups that
|
||||||
|
match, discarding the other hundreds. After changing the filter, clear the
|
||||||
|
cache (`sss_cache -E`; `rm -f /var/lib/sss/db/*`; restart sssd) and verify with
|
||||||
|
`getent group <cn>`.
|
||||||
|
|
||||||
|
### What filter to use — the naming convention is the answer
|
||||||
|
|
||||||
|
A host should import its **own** resource groups (plus any explicitly granted
|
||||||
|
ones). Because the schema is predictable, `ldap-client` can generate the per-host
|
||||||
|
`ldap_group_search_filter` from the enrolled host's identity, e.g. a host `web01`
|
||||||
|
at site `main-office` imports:
|
||||||
|
|
||||||
|
```
|
||||||
|
(&(objectClass=groupOfNames)(|(cn=main-office_host_web01_access)
|
||||||
|
(cn=main-office_host_web01_admin)
|
||||||
|
(cn=main-office_host_web01_sudo)))
|
||||||
|
```
|
||||||
|
|
||||||
|
So the operator (or ldap-client) selects a small allowlist of the host's `_access`
|
||||||
|
/ `_admin` / capability groups to feed sudoers, SSH `AllowGroups`, and filesystem
|
||||||
|
ACLs. **Only those groups are imported** — no GID bloat, no mass import.
|
||||||
|
|
||||||
|
### Aliasing an LDAP group into a local group (e.g. `input`)
|
||||||
|
|
||||||
|
SSSD cannot merge an LDAP group into a local group whose GID varies per host.
|
||||||
|
Two host-side mechanisms cover it:
|
||||||
|
|
||||||
|
- **pam_exec** — a script in the login stack adds the user to the local group for
|
||||||
|
the session:
|
||||||
|
```sh
|
||||||
|
#!/bin/bash
|
||||||
|
if id -Gn "$PAM_USER" | grep -q "host_input"; then usermod -a -G input "$PAM_USER"; fi
|
||||||
|
```
|
||||||
|
`session optional pam_exec.so /usr/local/bin/add_to_input.sh` in
|
||||||
|
`/etc/pam.d/common-session`.
|
||||||
|
|
||||||
|
- **nss-groupmerge** — merge an LDAP group into a local group at NSS time
|
||||||
|
(`/etc/groupmerge.conf`: `input: host_input`, then `group: files sssd groupmerge`
|
||||||
|
in `/etc/nsswitch.conf`), so any service querying `input` sees the LDAP group's
|
||||||
|
members regardless of the local GID.
|
||||||
|
|
||||||
|
### Meta groups
|
||||||
|
|
||||||
|
`god_admin`, `everyone`, and `S_everyone` are NOT imported by hosts — they have
|
||||||
|
implicit membership and are resolved by the directory only.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Downstream-app consumption guide
|
||||||
|
|
||||||
|
A downstream app (Emby, Gitea, a custom service, a shell script) reads group
|
||||||
|
membership from LDAP and interprets it as follows:
|
||||||
|
|
||||||
|
1. **Discover the user's groups** — bind with the user's credentials (or use a
|
||||||
|
service account + `memberOf`). Groups are `groupOfNames` (member DN), so query
|
||||||
|
by the user's DN, e.g. `(&(objectClass=groupOfNames)(member=<user_dn>))`, or use
|
||||||
|
the `memberOf` reverse attribute on the user's entry.
|
||||||
|
2. **Match each group to a scope:**
|
||||||
|
- `god_admin` → the user is a global administrator.
|
||||||
|
- `{site}_super_admin` → site administrator for that site.
|
||||||
|
- `{site}_hosts_*` / `{site}_app_*` (aggregate) → applies to all hosts/apps at the site.
|
||||||
|
- `{site}_host_<host>_*` / `{site}_app_<app>_*` → applies to that one resource.
|
||||||
|
- `everyone` / `{site}_everyone` → the user is implicitly a member.
|
||||||
|
3. **Interpret the last segment:**
|
||||||
|
- `admin` → full control of that resource.
|
||||||
|
- `access` → read/use.
|
||||||
|
- anything else → a capability **you** define; act on it or ignore it.
|
||||||
|
4. A user with `{site}_host_web01_access` can reach `web01`; a user with
|
||||||
|
`{site}_host_web01_reboot` (if you define `reboot`) may reboot it; a user with
|
||||||
|
`{site}_app_emby_emby_admin` administers Emby.
|
||||||
|
|
||||||
|
The app must **never** treat an unknown last segment as `admin` or `access`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Migration from the legacy `app_*` groups
|
||||||
|
|
||||||
|
The current global groups (`app_sso_admin`, `app_super_admin`,
|
||||||
|
`app_sso_directory_admin`, `app_jump_admin`) are replaced by the new model:
|
||||||
|
|
||||||
|
| Legacy | New |
|
||||||
|
| :--- | :--- |
|
||||||
|
| `app_super_admin` | `god_admin` |
|
||||||
|
| `app_sso_admin` | `S_app_sso_admin` (+ `S_super_admin` for site admins) |
|
||||||
|
| `app_sso_directory_admin` | `S_app_sso_admin` |
|
||||||
|
| `app_jump_admin` | `S_app_jump_admin` |
|
||||||
|
|
||||||
|
During the transition the legacy groups may be kept as short-lived aliases that
|
||||||
|
resolve to the same effective permission; once everything is moved, remove them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. The management consoles are apps
|
||||||
|
|
||||||
|
The SSO, Proxy, and Jump-Host each register themselves as an app on their site and
|
||||||
|
receive their auto-generated groups (`S_app_sso_admin`, `S_app_proxy_admin`,
|
||||||
|
`S_app_jump_admin`, plus `_access`). Their admin UIs gate on
|
||||||
|
`god_admin` · `S_super_admin` · `S_app_<console>_admin`. This keeps everything
|
||||||
|
self-consistent: the SSO is "just another app."
|
||||||
@@ -60,6 +60,7 @@ 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/).
|
||||||
|
- **[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.
|
||||||
|
|
||||||
## Get it
|
## Get it
|
||||||
|
|
||||||
|
|||||||
+65
-6
@@ -59,8 +59,41 @@ way or use `slappasswd -h '{SSHA512}'`.
|
|||||||
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
Groups are `cn=<name>,ou=groups,<base>` (`groupOfNames`) with a `member`
|
||||||
attribute listing member DNs. The `memberOf` overlay populates reverse
|
attribute listing member DNs. The `memberOf` overlay populates reverse
|
||||||
membership (`memberOf` on the user); `refint` keeps it consistent on
|
membership (`memberOf` on the user); `refint` keeps it consistent on
|
||||||
add/remove. **Admin permission checks read the group's `member` list**, not
|
add/remove.
|
||||||
`memberOf` on the user.
|
|
||||||
|
Note that `groupOfNames` requires **at least one member**, which has two
|
||||||
|
consequences worth knowing: whoever creates a group is automatically seeded
|
||||||
|
into it, and removing the last member (user *or* nested group) is refused with
|
||||||
|
a 409 rather than leaving an invalid entry behind.
|
||||||
|
|
||||||
|
### Nested groups
|
||||||
|
|
||||||
|
A `member` DN may be another group's, not just a user's — that is how nesting
|
||||||
|
is stored, with no extra schema. Everyone in the nested group is a member of
|
||||||
|
the outer one, at any depth. Manage it on the **Groups** page under each
|
||||||
|
group's *Nested* tab, or via the API:
|
||||||
|
|
||||||
|
```
|
||||||
|
PUT /api/group/:group/nested/:child nest :child inside :group
|
||||||
|
DELETE /api/group/:group/nested/:child un-nest
|
||||||
|
GET /api/group/:group/effective direct users, nested groups, and the
|
||||||
|
full transitive set of users
|
||||||
|
```
|
||||||
|
|
||||||
|
Cycles are refused (409) rather than truncated — a loop makes "who is in this
|
||||||
|
group" unanswerable. Two standing relationships are wired automatically: the
|
||||||
|
cross-app `app_super_admin` is nested into every resource's `<slug>_admin`
|
||||||
|
group, and each `<slug>_admin` into its `<slug>_access` group, so administering
|
||||||
|
something implies being able to use it.
|
||||||
|
|
||||||
|
**Resolving nesting is a client-side job on stock OpenLDAP.** No 2.6.x release
|
||||||
|
can evaluate nested groups; `memberOf` and a `(member=X)` filter both return
|
||||||
|
direct membership only. The bundled slapd is therefore built from source with
|
||||||
|
the `nestgroup` overlay (see *Modules + overlays* below), and the app is told so
|
||||||
|
via `ldap.nestedGroupsServerSide`. Against any other server the app computes the
|
||||||
|
closure itself — same answers, more queries. Either way, **never read `memberOf`
|
||||||
|
directly to make an access decision**; use `utils/user_groups.js`'s `groupCns()`,
|
||||||
|
which is correct in both modes.
|
||||||
|
|
||||||
### Personal groups
|
### Personal groups
|
||||||
|
|
||||||
@@ -75,15 +108,15 @@ instead from the owning user's own profile page ("Members of `<uid>`'s
|
|||||||
group", admin-only) — add other accounts as supplementary members, e.g. to
|
group", admin-only) — add other accounts as supplementary members, e.g. to
|
||||||
share write access to files owned by this group.
|
share write access to files owned by this group.
|
||||||
|
|
||||||
The SSO requires three groups (seeded automatically by the entrypoint /
|
The SSO seeds these groups automatically (entrypoint / `install.sh`):
|
||||||
`install.sh`):
|
|
||||||
|
|
||||||
| Group | Grants |
|
| Group | Grants |
|
||||||
|-------|--------|
|
|-------|--------|
|
||||||
|
| `app_super_admin` | cross-app super admin. Nested into the three below, so its members hold those rights transitively rather than by a special case in app code — and the privilege is visible to LDAP-native consumers (SSSD, sudo) too. |
|
||||||
| `app_sso_admin` | full admin (users, groups, settings) |
|
| `app_sso_admin` | full admin (users, groups, settings) |
|
||||||
| `app_sso_oauth_admin` | OAuth client management |
|
| `app_sso_oauth_admin` | OAuth client management |
|
||||||
| `app_sso_invite` | invitation management |
|
| `app_sso_invite` | invitation management |
|
||||||
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below) |
|
| `app_sso_service_account` | not a permission — marks a `posixAccount` as a non-person service account (see *Service accounts* below). Deliberately **not** nested into, since it changes how an account is displayed rather than what it may do. |
|
||||||
|
|
||||||
## TLS (LDAPS / StartTLS)
|
## TLS (LDAPS / StartTLS)
|
||||||
|
|
||||||
@@ -308,11 +341,37 @@ needs:
|
|||||||
|
|
||||||
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
- **Modules:** `pw-sha2` (the app stores user passwords as `{SSHA512}`),
|
||||||
`ppolicy`, `memberof`, `refint`.
|
`ppolicy`, `memberof`, `refint`.
|
||||||
|
- **Optional — `nestgroup`:** server-side nested-group evaluation. Not in any
|
||||||
|
released OpenLDAP (added to master as ITS#10161 in March 2024; 2.7 is still
|
||||||
|
unreleased), so the bundled image builds slapd from a pinned upstream commit.
|
||||||
|
Without it the app resolves nesting itself and everything still works — leave
|
||||||
|
`ldap.nestedGroupsServerSide` at `false`. With it, set that to `true` and
|
||||||
|
configure:
|
||||||
|
|
||||||
|
```
|
||||||
|
overlay nestgroup
|
||||||
|
nestgroup-base ou=groups,<base>
|
||||||
|
nestgroup-flags member-filter memberof-filter memberof-values
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags are **space-separated**; the comma form the man page's `{a, b, c}`
|
||||||
|
notation suggests is rejected. `member-values` is deliberately omitted — it
|
||||||
|
expands the `member` attribute when reading a group, which destroys the
|
||||||
|
distinction between "listed here" and "reachable through a nested group", and
|
||||||
|
the raw values are then unrecoverable. Transitive answers come from the filter
|
||||||
|
flags and from `GET /api/group/:group/effective`.
|
||||||
|
|
||||||
|
One more consequence of building from master: it ships **LMDB 1.0.0**, whose
|
||||||
|
on-disk format is mutually unreadable with the 0.9.x in 2.6.x
|
||||||
|
(`MDB_INVALID: File is not an LMDB file`). Moving a directory between the two
|
||||||
|
is a `slapcat` → `slapadd` reload, not a restart.
|
||||||
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
- **Custom schema:** the `theta42Person` auxiliary objectClass with
|
||||||
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
`dateOfBirth` — see `ops/ldap-setup.sh` for the LDIF.
|
||||||
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
- **Directory tree:** `ou=people`, `ou=groups`, `ou=policies` under the base DN,
|
||||||
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
a default `pwdPolicy` at `cn=ppolicy,ou=policies,<base>`.
|
||||||
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`.
|
- **Required groups:** `app_sso_admin`, `app_sso_invite`, `app_sso_oauth_admin`,
|
||||||
|
and `app_super_admin` (the cross-app super-admin group; the bundled entrypoint
|
||||||
|
also nests it into the first three).
|
||||||
|
|
||||||
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
`ops/ldap-setup.sh -p <admin-password>` configures all of the above
|
||||||
idempotently against a running slapd (auto-detects the database holding your
|
idempotently against a running slapd (auto-detects the database holding your
|
||||||
|
|||||||
+170
@@ -0,0 +1,170 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||||
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||||
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||||
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||||
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||||
|
and token on its own schedule.
|
||||||
|
|
||||||
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||||
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||||
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||||
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||||
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||||
|
|
||||||
|
## Plugin types
|
||||||
|
|
||||||
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
|
`category`. The built-ins ship under `plugins/discovery/`:
|
||||||
|
|
||||||
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
|
||||||
|
### What the Proxmox plugin produces
|
||||||
|
|
||||||
|
One endpoint becomes one subtree:
|
||||||
|
|
||||||
|
```
|
||||||
|
Proxmox endpoint (cluster name, or the endpoint hostname)
|
||||||
|
└── node (hypervisor)
|
||||||
|
├── VM / template
|
||||||
|
└── LXC / template
|
||||||
|
```
|
||||||
|
|
||||||
|
The endpoint resource stands for the cluster, not a machine, so it carries the
|
||||||
|
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
||||||
|
reached at made the reconciler merge it with the node answering on that address,
|
||||||
|
which produced a resource that was its own parent.
|
||||||
|
|
||||||
|
Every guest carries:
|
||||||
|
|
||||||
|
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
||||||
|
The MAC and the address on it are read from the same source, so they cannot be
|
||||||
|
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
||||||
|
zipped them by index, which attributed addresses to the wrong NIC on any
|
||||||
|
multi-NIC guest).
|
||||||
|
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
||||||
|
has an address.
|
||||||
|
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
||||||
|
a directory row traces back to the exact guest on the exact node.
|
||||||
|
|
||||||
|
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
||||||
|
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
||||||
|
172.x addresses would otherwise give the reconciler spurious matches.
|
||||||
|
|
||||||
|
A stopped VM still reports its MAC (read from the VM config rather than the
|
||||||
|
guest agent), and a DHCP-configured LXC gets its address from the running
|
||||||
|
container's interface list. Offline nodes are recorded with `status` rather than
|
||||||
|
skipped, so a hypervisor that is down does not look decommissioned and get
|
||||||
|
garbage-collected after a week.
|
||||||
|
|
||||||
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||||
|
// explicitly. `name`/`description` show up in the UI.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||||
|
|
||||||
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||||
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button: validate the config (don't do the work). Return
|
||||||
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||||
|
validate: async (config) => { … },
|
||||||
|
|
||||||
|
// The work. `run` is the generalized contract name; the discovery plugins
|
||||||
|
// also keep `discover` as an alias for back-compat. For `category:
|
||||||
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||||
|
run: async (config) => { return { resources, edges }; },
|
||||||
|
discover: async (config) => { return { resources, edges }; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||||
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||||
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||||
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||||
|
|
||||||
|
### Writing a custom plugin type
|
||||||
|
|
||||||
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||||
|
following the manifest above. New types are picked up at boot, so restart the
|
||||||
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||||
|
adding a new type still needs a restart.
|
||||||
|
|
||||||
|
## The Plugins page
|
||||||
|
|
||||||
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||||
|
/ `app_super_admin`):
|
||||||
|
|
||||||
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||||
|
source name + the URL the resource graph attributes results to), set a cron
|
||||||
|
schedule, and fill in the config form (secret fields are password inputs).
|
||||||
|
Creating it schedules it and kicks one immediate run.
|
||||||
|
- **Edit** — name, cron, and non-secret config.
|
||||||
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||||
|
field blank to keep its current value.
|
||||||
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||||
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||||
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||||
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||||
|
secret values masked.
|
||||||
|
|
||||||
|
| Method + path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||||
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||||
|
| `GET /api/plugins/:id` | one instance |
|
||||||
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||||
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||||
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||||
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||||
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||||
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||||
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||||
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||||
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||||
|
|
||||||
|
## Scheduler internals
|
||||||
|
|
||||||
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||||
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||||
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||||
|
prunes discovery resources not seen in > 7 days.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
|
||||||
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||||
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||||
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||||
|
table is non-empty and the static config is ignored — manage plugins from the
|
||||||
|
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
You can access the Vault UI from the application's top navigation bar.
|
||||||
|
|
||||||
|
### Creating Secrets
|
||||||
|
|
||||||
|
1. Click on the **New Secret** button.
|
||||||
|
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||||
|
3. Enter the **Secret Data** in JSON format. For example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "supersecretpassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Click **Save Secret**.
|
||||||
|
|
||||||
|
### Reading and Editing Secrets
|
||||||
|
|
||||||
|
* To view a secret, click on its name in the **Secrets List**.
|
||||||
|
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||||
|
|
||||||
|
### OpenBao Integration
|
||||||
|
|
||||||
|
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||||
|
|
||||||
|
## Apps tab (admin)
|
||||||
|
|
||||||
|
The **Apps** tab mints a scoped OpenBao token for an **external application** so it can read its own configuration out of OpenBao — a downstream-app credential, not a per-user secret.
|
||||||
|
|
||||||
|
1. Enter an app **name** (e.g. `my-service`) and click **Mint token**.
|
||||||
|
2. A token is shown **once** — copy it into the external app now; it cannot be recovered later. The app uses it as the `X-Vault-Token` header against `secret/apps/<name>/*` (see the connection convention shown on the page).
|
||||||
|
3. The **Minted apps** list shows every token you've created (metadata only — the token itself is never stored). sso keeps each token alive by renewing it periodically, so a downstream app's credential stays valid as long as sso runs. If an app shows a **renewal error**, re-mint it here — that revokes the old token and issues a fresh one.
|
||||||
|
|
||||||
|
The token is scoped to `secret/apps/<name>/*` only (policy `app-<name>`), so a compromised token can't touch any other secret.
|
||||||
|
|
||||||
|
## Shared tab
|
||||||
|
|
||||||
|
The **Shared** tab lets you share a secret with another user (or app) without copying the value around.
|
||||||
|
|
||||||
|
1. **New** — give the secret a name (slug) and its JSON data. The owner has full read/write on `secret/shared/<uid>/<slug>`.
|
||||||
|
2. Open a secret and use **Grants** to share it with a user or app; the grantee's OpenBao policy is edited immediately so the share takes effect with no token re-mint. Revoking a grant removes access at the ACL.
|
||||||
|
3. The data itself is read through the normal Vault proxy using each user's own session, so OpenBao enforces read access per-request.
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API
|
||||||
|
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
+48
-3
@@ -43,7 +43,11 @@ app.onListen.push(function(){
|
|||||||
// socket.broadcast.emit('P2PSub', msg);
|
// socket.broadcast.emit('P2PSub', msg);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
// Initialize Theta Agent WebSockets. The REST router is already mounted
|
||||||
|
// synchronously above (see the /api/agent mount); this hook only wires the WS.
|
||||||
|
require('./routes/api_agent').initAgentWebSockets(app);
|
||||||
|
});
|
||||||
|
|
||||||
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||||
// uncompressed vendor JS/CSS files on every full page navigation (a
|
// uncompressed vendor JS/CSS files on every full page navigation (a
|
||||||
@@ -69,7 +73,8 @@ app.locals.ui = require('./utils/ui');
|
|||||||
// Have express server static content( images, CSS, browser JS) from the public
|
// Have express server static content( images, CSS, browser JS) from the public
|
||||||
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
// local folder. maxAge is short since this is the app's own JS/CSS, which
|
||||||
// changes on every deploy and isn't cache-busted/fingerprinted.
|
// changes on every deploy and isn't cache-busted/fingerprinted.
|
||||||
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}))
|
app.use('/static', express.static(path.join(__dirname, 'public'), {maxAge: '1h'}));
|
||||||
|
app.use('/resources', express.static(path.join(__dirname, 'public/resources'), {maxAge: '1h'}));
|
||||||
|
|
||||||
// Routes for front end content.
|
// Routes for front end content.
|
||||||
app.use('/', require('./routes/index'));
|
app.use('/', require('./routes/index'));
|
||||||
@@ -91,18 +96,45 @@ app.use('/api/group', middleware.auth, require('./routes/group'));
|
|||||||
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
app.use('/api/notification', middleware.auth, require('./routes/notification'));
|
||||||
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
app.use('/api/discovery', middleware.auth, require('./routes/discovery'));
|
||||||
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
app.use('/api/directory-admin', middleware.auth, require('./routes/api_directory_admin'));
|
||||||
|
// Self-service access requests — any authenticated user may ask; deciding is
|
||||||
|
// gated per-resource inside the router (owner or directory admin).
|
||||||
|
app.use('/api/access-requests', middleware.auth, require('./routes/access_request'));
|
||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||||
|
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);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
|
// Plugin instances — loadable/unloadable, configurable plugin copies with
|
||||||
|
// per-instance secrets in OpenBao (secret/plugins/*). Admin-only (gated inside
|
||||||
|
// the router to app_sso_admin / app_sso_directory_admin).
|
||||||
|
app.use('/api/plugins', middleware.auth, require('./routes/api_plugins'));
|
||||||
|
|
||||||
|
// OpenBao vault API. The broker mints a server-side scoped token per user
|
||||||
|
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
|
||||||
|
// (scopeGuard), and injects ONLY that token into the proxied request — the
|
||||||
|
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
|
||||||
|
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
|
||||||
|
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
|
||||||
|
// shadowed by the catch-all /api/vault proxy.
|
||||||
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
// Shared secrets (metadata + grants; data reads go through /api/vault proxy).
|
||||||
|
app.use('/api/shared-secrets', middleware.auth, require('./routes/api_shared_secrets'));
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
@@ -125,5 +157,18 @@ app.use(function(err, req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(err.status || 500);
|
res.status(err.status || 500);
|
||||||
res.json({name: err.name, message: err.message});
|
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('./utils/build_info');
|
||||||
|
res.render('error', {
|
||||||
|
name: conf.name,
|
||||||
|
title: 'Error',
|
||||||
|
titleIcon: '',
|
||||||
|
logo: conf.logo,
|
||||||
|
error: err,
|
||||||
|
...buildInfo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({name: err.name, message: err.message});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,15 +25,48 @@ var server = http.createServer(app);
|
|||||||
var io = require('socket.io')(server);
|
var io = require('socket.io')(server);
|
||||||
app.io = io;
|
app.io = io;
|
||||||
|
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const wss = new WebSocket.Server({ noServer: true });
|
||||||
|
server.on('upgrade', (request, socket, head) => {
|
||||||
|
// We only handle upgrade for /api/agent/ws.
|
||||||
|
// Socket.IO handles its own upgrades natively because it attaches directly to `server`.
|
||||||
|
if (request.url.startsWith('/api/agent/ws')) {
|
||||||
|
wss.handleUpgrade(request, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, request);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.wss = wss;
|
||||||
|
|
||||||
const models = require('../models');
|
const models = require('../models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
models.initORM().then(() => {
|
models.initORM().then(() => {
|
||||||
|
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
|
||||||
|
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
|
||||||
|
// values and boot continues. (Same position the old conf_manager held, so
|
||||||
|
// call-time conf readers — which is how sso consumes its secrets — are
|
||||||
|
// unaffected; nothing in sso captures a secret at require time.)
|
||||||
|
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
|
||||||
|
}).then(() => {
|
||||||
server.listen(port);
|
server.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
server.on('listening', onListening);
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
// Initialize scheduler
|
||||||
|
const { initScheduler } = require('../services/scheduler');
|
||||||
|
initScheduler(conf.discovery).catch(err => {
|
||||||
|
console.error('Failed to initialize scheduler:', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep external-app vault tokens alive: renew every stored accessor now and
|
||||||
|
// on an interval (see vault_broker.startAppTokenRenewal). Only meaningful
|
||||||
|
// when OpenBao is configured; without VAULT_TOKEN the loop's calls fail soft.
|
||||||
|
if (process.env.VAULT_TOKEN) {
|
||||||
|
require('../utils/vault_broker').startAppTokenRenewal();
|
||||||
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+14
-7
@@ -29,6 +29,11 @@ module.exports = {
|
|||||||
// public 636 port forward. See docs/ldap.md.
|
// public 636 port forward. See docs/ldap.md.
|
||||||
ldapsHost: '',
|
ldapsHost: '',
|
||||||
ldapsPort: 636,
|
ldapsPort: 636,
|
||||||
|
// True when slapd carries the `nestgroup` overlay, which resolves nested
|
||||||
|
// groups server-side. Set automatically by docker-entrypoint.sh for the
|
||||||
|
// all-in-one image; leave false when pointing at a stock OpenLDAP (no
|
||||||
|
// 2.6.x release ships nestgroup) and the app resolves nesting itself.
|
||||||
|
nestedGroupsServerSide: false,
|
||||||
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
// New users/personal groups (see addPosixAccount/addPosixGroup in
|
||||||
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
// models/user_ldap.js) get the next uid/gidNumber >= uidGidMin.
|
||||||
// Existing entries >= uidGidReservedFloor are ignored when computing
|
// Existing entries >= uidGidReservedFloor are ignored when computing
|
||||||
@@ -52,13 +57,15 @@ module.exports = {
|
|||||||
password: '__in secrets file__',
|
password: '__in secrets file__',
|
||||||
did: '__in secrets file__',
|
did: '__in secrets file__',
|
||||||
},
|
},
|
||||||
smtp: {
|
directory: {
|
||||||
host: 'localhost',
|
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||||
port: 587,
|
// component). When set, a host card in the catalog shows the real
|
||||||
secure: false,
|
// invocation — `ssh <uid>_-_<slug>@<jumpHost>` — instead of a bare
|
||||||
user: 'noreply@example.com',
|
// `ssh <uid>@<ip>` that only works from inside the LAN. Empty is fine;
|
||||||
pass: '__in secrets file__',
|
// the card falls back to the direct form.
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
jumpHost: '',
|
||||||
|
// Default SSH port assumed when a host carries no metadata.sshPort.
|
||||||
|
defaultSshPort: 22,
|
||||||
},
|
},
|
||||||
service: {
|
service: {
|
||||||
updateCheck: {
|
updateCheck: {
|
||||||
|
|||||||
Binary file not shown.
@@ -0,0 +1,132 @@
|
|||||||
|
# Plugins
|
||||||
|
|
||||||
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
||||||
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
||||||
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
||||||
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
||||||
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
||||||
|
and token on its own schedule.
|
||||||
|
|
||||||
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
||||||
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
||||||
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
||||||
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
||||||
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
||||||
|
|
||||||
|
## Plugin types
|
||||||
|
|
||||||
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
||||||
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
||||||
|
`category`. The built-ins ship under `plugins/discovery/`:
|
||||||
|
|
||||||
|
- `proxmox` — Proxmox VE (URL + API token)
|
||||||
|
- `unifi` — UniFi Network controller (URL + username/password)
|
||||||
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
||||||
|
|
||||||
|
A module exports a **manifest**:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
||||||
|
// explicitly. `name`/`description` show up in the UI.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
||||||
|
|
||||||
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
||||||
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button: validate the config (don't do the work). Return
|
||||||
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
||||||
|
validate: async (config) => { … },
|
||||||
|
|
||||||
|
// The work. `run` is the generalized contract name; the discovery plugins
|
||||||
|
// also keep `discover` as an alias for back-compat. For `category:
|
||||||
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
||||||
|
run: async (config) => { return { resources, edges }; },
|
||||||
|
discover: async (config) => { return { resources, edges }; }
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
||||||
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
||||||
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
||||||
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
||||||
|
|
||||||
|
### Writing a custom plugin type
|
||||||
|
|
||||||
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
||||||
|
following the manifest above. New types are picked up at boot, so restart the
|
||||||
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
||||||
|
adding a new type still needs a restart.
|
||||||
|
|
||||||
|
## The Plugins page
|
||||||
|
|
||||||
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
||||||
|
/ `app_super_admin`):
|
||||||
|
|
||||||
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
||||||
|
source name + the URL the resource graph attributes results to), set a cron
|
||||||
|
schedule, and fill in the config form (secret fields are password inputs).
|
||||||
|
Creating it schedules it and kicks one immediate run.
|
||||||
|
- **Edit** — name, cron, and non-secret config.
|
||||||
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
||||||
|
field blank to keep its current value.
|
||||||
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
||||||
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
||||||
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
||||||
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
||||||
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
||||||
|
secret values masked.
|
||||||
|
|
||||||
|
| Method + path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
||||||
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
||||||
|
| `GET /api/plugins/:id` | one instance |
|
||||||
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
||||||
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
||||||
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
||||||
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
||||||
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
||||||
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
||||||
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
||||||
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
||||||
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
||||||
|
|
||||||
|
## Scheduler internals
|
||||||
|
|
||||||
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
||||||
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
||||||
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
||||||
|
prunes discovery resources not seen in > 7 days.
|
||||||
|
|
||||||
|
### Legacy migration
|
||||||
|
|
||||||
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
discovery: {
|
||||||
|
plugins: {
|
||||||
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
||||||
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
||||||
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
||||||
|
table is non-empty and the static config is ignored — manage plugins from the
|
||||||
|
UI/API instead. The migration is idempotent (guarded by the empty-table check).
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
You can access the Vault UI from the application's top navigation bar.
|
||||||
|
|
||||||
|
### Creating Secrets
|
||||||
|
|
||||||
|
1. Click on the **New Secret** button.
|
||||||
|
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||||
|
3. Enter the **Secret Data** in JSON format. For example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "supersecretpassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Click **Save Secret**.
|
||||||
|
|
||||||
|
### Reading and Editing Secrets
|
||||||
|
|
||||||
|
* To view a secret, click on its name in the **Secrets List**.
|
||||||
|
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||||
|
|
||||||
|
### OpenBao Integration
|
||||||
|
|
||||||
|
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||||
|
|
||||||
|
## 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):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API
|
||||||
|
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
const { Resource } = require('./models/resource');
|
||||||
|
const { initORM } = require('./models/index');
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
await initORM();
|
||||||
|
const all = await Resource.list();
|
||||||
|
console.log(`Found ${all.length} resources`);
|
||||||
|
|
||||||
|
const byIp = {};
|
||||||
|
const byName = {};
|
||||||
|
|
||||||
|
for (const r of all) {
|
||||||
|
if (!r.metadata) r.metadata = {};
|
||||||
|
|
||||||
|
// gather IPs
|
||||||
|
const ips = new Set();
|
||||||
|
if (r.metadata.address) ips.add(r.metadata.address);
|
||||||
|
if (r.metadata.interfaces) {
|
||||||
|
r.metadata.interfaces.forEach(i => { if (i.ip) ips.add(i.ip); });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ip of ips) {
|
||||||
|
if (!byIp[ip]) byIp[ip] = [];
|
||||||
|
byIp[ip].push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameLower = (r.name || '').toLowerCase();
|
||||||
|
if (nameLower) {
|
||||||
|
if (!byName[nameLower]) byName[nameLower] = [];
|
||||||
|
byName[nameLower].push(r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find duplicates
|
||||||
|
const toDelete = new Set();
|
||||||
|
|
||||||
|
for (const ip in byIp) {
|
||||||
|
if (byIp[ip].length > 1) {
|
||||||
|
// Sort so managed/older is kept
|
||||||
|
const group = byIp[ip].sort((a, b) => {
|
||||||
|
const aM = a.metadata?.managed ? 1 : 0;
|
||||||
|
const bM = b.metadata?.managed ? 1 : 0;
|
||||||
|
if (aM !== bM) return bM - aM;
|
||||||
|
return a.created_on - b.created_on;
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = group[0];
|
||||||
|
for (let i = 1; i < group.length; i++) {
|
||||||
|
const sec = group[i];
|
||||||
|
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
|
||||||
|
console.log(`Merging ${sec.name} into ${primary.name} due to IP ${ip}`);
|
||||||
|
|
||||||
|
// merge metadata
|
||||||
|
const m1 = primary.metadata || {};
|
||||||
|
const m2 = sec.metadata || {};
|
||||||
|
|
||||||
|
const mergedMeta = { ...m2, ...m1 };
|
||||||
|
|
||||||
|
// merge interfaces
|
||||||
|
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
|
||||||
|
const uniqIntfs = [];
|
||||||
|
const seenIps = new Set();
|
||||||
|
for (const intf of intfs) {
|
||||||
|
if (intf.ip && seenIps.has(intf.ip)) continue;
|
||||||
|
if (intf.ip) seenIps.add(intf.ip);
|
||||||
|
uniqIntfs.push(intf);
|
||||||
|
}
|
||||||
|
mergedMeta.interfaces = uniqIntfs;
|
||||||
|
|
||||||
|
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
|
||||||
|
mergedMeta.discovery_sources = [...sources];
|
||||||
|
|
||||||
|
await primary.update({
|
||||||
|
metadata: mergedMeta,
|
||||||
|
description: primary.description || sec.description
|
||||||
|
});
|
||||||
|
|
||||||
|
toDelete.add(sec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name in byName) {
|
||||||
|
if (byName[name].length > 1) {
|
||||||
|
// Sort so managed/older is kept
|
||||||
|
const group = byName[name].sort((a, b) => {
|
||||||
|
const aM = a.metadata?.managed ? 1 : 0;
|
||||||
|
const bM = b.metadata?.managed ? 1 : 0;
|
||||||
|
if (aM !== bM) return bM - aM;
|
||||||
|
return a.created_on - b.created_on;
|
||||||
|
});
|
||||||
|
|
||||||
|
const primary = group[0];
|
||||||
|
for (let i = 1; i < group.length; i++) {
|
||||||
|
const sec = group[i];
|
||||||
|
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
|
||||||
|
console.log(`Merging ${sec.name} into ${primary.name} due to name ${name}`);
|
||||||
|
|
||||||
|
// merge metadata
|
||||||
|
const m1 = primary.metadata || {};
|
||||||
|
const m2 = sec.metadata || {};
|
||||||
|
|
||||||
|
const mergedMeta = { ...m2, ...m1 };
|
||||||
|
|
||||||
|
// merge interfaces
|
||||||
|
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
|
||||||
|
const uniqIntfs = [];
|
||||||
|
const seenIps = new Set();
|
||||||
|
for (const intf of intfs) {
|
||||||
|
if (intf.ip && seenIps.has(intf.ip)) continue;
|
||||||
|
if (intf.ip) seenIps.add(intf.ip);
|
||||||
|
uniqIntfs.push(intf);
|
||||||
|
}
|
||||||
|
mergedMeta.interfaces = uniqIntfs;
|
||||||
|
|
||||||
|
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
|
||||||
|
mergedMeta.discovery_sources = [...sources];
|
||||||
|
|
||||||
|
await primary.update({
|
||||||
|
metadata: mergedMeta,
|
||||||
|
description: primary.description || sec.description
|
||||||
|
});
|
||||||
|
|
||||||
|
toDelete.add(sec.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete merged items
|
||||||
|
for (const id of toDelete) {
|
||||||
|
console.log(`Deleting merged resource ${id}`);
|
||||||
|
const r = all.find(r => r.id === id);
|
||||||
|
if (r) await r.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Merged ${toDelete.size} items.`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
run().catch(console.error);
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests: the "request" half of the directory catalog.
|
||||||
|
//
|
||||||
|
// A request is a *proposal to join an LDAP group*. Approving one does exactly
|
||||||
|
// what an admin would have done by hand -- add the user to `groupCn` -- so LDAP
|
||||||
|
// remains the single access-control truth and this table is only the paper
|
||||||
|
// trail of who asked, who decided, and when. Nothing here grants anything on
|
||||||
|
// its own; a row with status 'approved' whose LDAP write failed is a row that
|
||||||
|
// grants no access, which is the safe direction.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
PENDING: 'pending',
|
||||||
|
APPROVED: 'approved',
|
||||||
|
DENIED: 'denied',
|
||||||
|
CANCELLED: 'cancelled',
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccessRequest extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The requesting user's uid (not dn): dn changes if the directory is
|
||||||
|
// restructured, uid is the stable handle used everywhere else in the app.
|
||||||
|
uid: { type: 'string', isRequired: true },
|
||||||
|
resource: { type: 'hasOne', model: 'Resource' }, // creates resourceId
|
||||||
|
// The group joining which satisfies this request. Captured at request time
|
||||||
|
// so a later re-link of the resource's groups can't silently redirect a
|
||||||
|
// pending approval at a different group than the one that was reviewed.
|
||||||
|
groupCn: { type: 'string', isRequired: true },
|
||||||
|
status: { type: 'string', isRequired: true, default: STATUS.PENDING },
|
||||||
|
note: { type: 'text' },
|
||||||
|
requestedOn: { type: 'integer' },
|
||||||
|
decidedBy: { type: 'string' },
|
||||||
|
decidedOn: { type: 'integer' },
|
||||||
|
decisionNote: { type: 'text' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// The one request that blocks a new one: same user, same group, still open.
|
||||||
|
// Denied/cancelled requests deliberately do not block -- circumstances change
|
||||||
|
// and a user may ask again.
|
||||||
|
static async findOpen(uid, groupCn) {
|
||||||
|
const rows = await this.list({ where: { uid, groupCn, status: STATUS.PENDING } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listForUser(uid) {
|
||||||
|
return this.list({ where: { uid } });
|
||||||
|
}
|
||||||
|
|
||||||
|
static async listPending() {
|
||||||
|
return this.list({ where: { status: STATUS.PENDING } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { AccessRequest, STATUS };
|
||||||
@@ -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 };
|
||||||
+182
-4
@@ -112,18 +112,190 @@ async function cachedListDetail() {
|
|||||||
return promise;
|
return promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Nested groups -------------------------------------------------------
|
||||||
|
//
|
||||||
|
// `groupOfNames.member` holds DNs, and nothing says those DNs must be users --
|
||||||
|
// a group DN is a perfectly legal member. That is how nesting is stored here:
|
||||||
|
// as-is, no extra schema, no denormalization, the nesting visible in LDAP
|
||||||
|
// exactly as an admin entered it.
|
||||||
|
//
|
||||||
|
// What LDAP will NOT do is resolve it. The memberof overlay records only
|
||||||
|
// *direct* membership, and a `(member=<dn>)` filter likewise finds only the
|
||||||
|
// groups that list the DN literally. So transitivity is computed here, and
|
||||||
|
// every membership question in the app must go through these helpers or it
|
||||||
|
// will silently see one level and grant nothing for a nested group.
|
||||||
|
//
|
||||||
|
// The whole group set is one subtree search, so the closure is computed in
|
||||||
|
// memory rather than issuing a query per level. `resolverCache` keeps that
|
||||||
|
// search off the hot path for bursts; it is cleared by every write below, so
|
||||||
|
// the only staleness it can introduce is from edits made outside this app.
|
||||||
|
// Auth decisions ride on this, hence the deliberately short TTL.
|
||||||
|
|
||||||
|
const NESTING_TTL_MS = 15 * 1000;
|
||||||
|
const MAX_NESTING_DEPTH = Number(conf.groupNestingDepth) > 0 ? Number(conf.groupNestingDepth) : 10;
|
||||||
|
|
||||||
|
const resolverCache = new LRUCache({ max: 1, ttl: NESTING_TTL_MS, ttlAutopurge: true });
|
||||||
|
|
||||||
|
async function allGroupsForResolver() {
|
||||||
|
const hit = resolverCache.get('all');
|
||||||
|
if (hit) return hit;
|
||||||
|
const promise = withClient(async (client) => {
|
||||||
|
const groups = await getGroups(client);
|
||||||
|
return groups.map(g => ({ ...g }));
|
||||||
|
}).then(plain => {
|
||||||
|
resolverCache.set('all', plain);
|
||||||
|
return plain;
|
||||||
|
}).catch(err => {
|
||||||
|
resolverCache.delete('all');
|
||||||
|
throw err;
|
||||||
|
});
|
||||||
|
resolverCache.set('all', promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lc = dn => String(dn || '').toLowerCase();
|
||||||
|
|
||||||
|
// dn -> [groups that list dn as a member]. One pass, reused for every lookup.
|
||||||
|
function buildParentIndex(groups) {
|
||||||
|
const parents = new Map();
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const member of [].concat(group.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
if (!parents.has(key)) parents.set(key, []);
|
||||||
|
parents.get(key).push(group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parents;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every group `dn` belongs to, directly or through any chain of nested groups.
|
||||||
|
// Breadth-first with a visited set, so a cycle (A in B, B in A) terminates
|
||||||
|
// instead of hanging, and MAX_NESTING_DEPTH bounds a pathological chain.
|
||||||
|
function closureUp(dn, groups) {
|
||||||
|
const parents = buildParentIndex(groups);
|
||||||
|
const found = new Map(); // cn -> group
|
||||||
|
const seen = new Set([lc(dn)]);
|
||||||
|
let frontier = [lc(dn)];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const group of parents.get(current) || []) {
|
||||||
|
const groupDn = lc(group.dn);
|
||||||
|
if (seen.has(groupDn)) continue;
|
||||||
|
seen.add(groupDn);
|
||||||
|
found.set(group.cn, group);
|
||||||
|
// The group itself is now a member to look up: this is the step
|
||||||
|
// that makes the walk transitive rather than one-level.
|
||||||
|
next.push(groupDn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return [...found.values()];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every member DN reachable from a group, split into the users it effectively
|
||||||
|
// grants and the groups it nests. `direct` is kept separate so the UI can show
|
||||||
|
// "3 members, 12 effective" and so removal stays unambiguous.
|
||||||
|
function closureDown(group, groups) {
|
||||||
|
const byDn = new Map(groups.map(g => [lc(g.dn), g]));
|
||||||
|
const users = new Set();
|
||||||
|
const nested = new Map();
|
||||||
|
const seen = new Set([lc(group.dn)]);
|
||||||
|
let frontier = [group];
|
||||||
|
|
||||||
|
for (let depth = 0; depth < MAX_NESTING_DEPTH && frontier.length; depth++) {
|
||||||
|
const next = [];
|
||||||
|
for (const current of frontier) {
|
||||||
|
for (const member of [].concat(current.member || []).filter(Boolean)) {
|
||||||
|
const key = lc(member);
|
||||||
|
const asGroup = byDn.get(key);
|
||||||
|
if (asGroup) {
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
nested.set(asGroup.cn, asGroup);
|
||||||
|
next.push(asGroup);
|
||||||
|
} else {
|
||||||
|
users.add(member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frontier = next;
|
||||||
|
}
|
||||||
|
return { users: [...users], nested: [...nested.values()] };
|
||||||
|
}
|
||||||
|
|
||||||
var Group = {};
|
var Group = {};
|
||||||
|
|
||||||
|
// Set when slapd carries the nestgroup overlay (docker-entrypoint.sh exports
|
||||||
|
// app_ldap__nestedGroupsServerSide=true after detecting nestgroup.so). With it,
|
||||||
|
// a plain `(member=<dn>)` search already returns the full transitive set and the
|
||||||
|
// in-app closure is redundant work on every request. Without it -- e.g. pointed
|
||||||
|
// at a stock 2.6.x server, which no release ships nestgroup in -- the app must
|
||||||
|
// compute the closure itself or nested groups silently grant nothing.
|
||||||
|
const SERVER_SIDE_NESTING = String(conf.nestedGroupsServerSide) === 'true';
|
||||||
|
|
||||||
|
// Transitive: every group CN this member belongs to, at any nesting depth.
|
||||||
|
// Callers making an access decision must use this rather than reading
|
||||||
|
// `memberOf`, which a server without nestgroup only ever populates one level
|
||||||
|
// deep.
|
||||||
Group.list = async function(member){
|
Group.list = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => {
|
if (SERVER_SIDE_NESTING) {
|
||||||
const groups = await getGroups(client, member);
|
return withClient(async (client) => {
|
||||||
return groups.map(group => group.cn);
|
const groups = await getGroups(client, member);
|
||||||
});
|
return groups.map(group => group.cn);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
return closureUp(member, groups).map(group => group.cn);
|
||||||
}
|
}
|
||||||
return (await cachedListDetail()).map(group => group.cn);
|
return (await cachedListDetail()).map(group => group.cn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The members a group effectively grants: users reached through any chain of
|
||||||
|
// nested groups, plus the nested groups themselves for display.
|
||||||
|
Group.effectiveMembers = async function(cn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const group = groups.find(g => g.cn === cn);
|
||||||
|
if (!group) {
|
||||||
|
let error = new Error('GroupNotFound');
|
||||||
|
error.name = 'GroupNotFound';
|
||||||
|
error.message = `LDAP:${cn} does not exists`;
|
||||||
|
error.status = 404;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const { users, nested } = closureDown(group, groups);
|
||||||
|
const directMembers = [].concat(group.member || []).filter(Boolean);
|
||||||
|
const groupDns = new Set(groups.map(g => lc(g.dn)));
|
||||||
|
return {
|
||||||
|
cn: group.cn,
|
||||||
|
direct: directMembers.filter(dn => !groupDns.has(lc(dn))),
|
||||||
|
nestedGroups: nested.map(g => ({ cn: g.cn, dn: g.dn })),
|
||||||
|
effective: users,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Would adding `childDn` to `parentCn` create a cycle? A group may not contain
|
||||||
|
// itself, nor anything that already (transitively) contains it -- such a chain
|
||||||
|
// makes membership unanswerable, and callers would rely on the depth cap to
|
||||||
|
// stop rather than getting a real answer.
|
||||||
|
Group.wouldCycle = async function(parentCn, childDn){
|
||||||
|
const groups = await allGroupsForResolver();
|
||||||
|
const parent = groups.find(g => g.cn === parentCn);
|
||||||
|
if (!parent) return false;
|
||||||
|
if (lc(parent.dn) === lc(childDn)) return true;
|
||||||
|
const child = groups.find(g => lc(g.dn) === lc(childDn));
|
||||||
|
if (!child) return false; // a user DN can never close a cycle
|
||||||
|
// Adding child under parent is a cycle exactly when parent is already
|
||||||
|
// reachable downward from child.
|
||||||
|
const { nested } = closureDown(child, groups);
|
||||||
|
return nested.some(g => lc(g.dn) === lc(parent.dn));
|
||||||
|
};
|
||||||
|
|
||||||
|
Group.clearResolverCache = function(){ resolverCache.clear(); };
|
||||||
|
|
||||||
Group.listDetail = async function(member){
|
Group.listDetail = async function(member){
|
||||||
if (member) {
|
if (member) {
|
||||||
return withClient(async (client) => getGroups(client, member));
|
return withClient(async (client) => getGroups(client, member));
|
||||||
@@ -166,6 +338,7 @@ Group.add = async function(data){
|
|||||||
return withClient(async (client) => {
|
return withClient(async (client) => {
|
||||||
await addGroup(client, data);
|
await addGroup(client, data);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this.get(data);
|
return this.get(data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,6 +347,7 @@ Group.addMember = async function(user){
|
|||||||
await withClient(async (client) => addMember(client, this, user));
|
await withClient(async (client) => addMember(client, this, user));
|
||||||
this.member = [].concat(this.member || []).concat([user.dn]);
|
this.member = [].concat(this.member || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -186,6 +360,7 @@ Group.removeMember = async function(user){
|
|||||||
}
|
}
|
||||||
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
this.member = [].concat(this.member || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -193,6 +368,7 @@ Group.addOwner = async function(user){
|
|||||||
await withClient(async (client) => addOwner(client, this, user));
|
await withClient(async (client) => addOwner(client, this, user));
|
||||||
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
this.owner = [].concat(this.owner || []).concat([user.dn]);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -205,12 +381,14 @@ Group.removeOwner = async function(user){
|
|||||||
}
|
}
|
||||||
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
this.owner = [].concat(this.owner || []).filter(dn => dn !== user.dn);
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return this;
|
return this;
|
||||||
};
|
};
|
||||||
|
|
||||||
Group.remove = async function(){
|
Group.remove = async function(){
|
||||||
await withClient(async (client) => client.del(this.dn));
|
await withClient(async (client) => client.del(this.dn));
|
||||||
cache.clear();
|
cache.clear();
|
||||||
|
resolverCache.clear();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -14,7 +14,13 @@ require('./api_token');
|
|||||||
|
|
||||||
const { init } = require('@simpleworkjs/orm');
|
const { init } = require('@simpleworkjs/orm');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
|
const { AccessRequest } = require('./access_request');
|
||||||
|
const { Webhook } = require('./webhook');
|
||||||
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const { SharedSecret } = require('./shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('./shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('./vault_app_token');
|
||||||
|
const { Agent, AgentJoinKey } = require('./agent');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -28,16 +34,49 @@ async function initORM() {
|
|||||||
await init({
|
await init({
|
||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||||
|
SharedSecret, SharedSecretGrant, VaultAppToken, Agent, AgentJoinKey,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
console.log('[initORM] ORM initialized successfully');
|
console.log('[initORM] ORM initialized successfully');
|
||||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||||
|
await healSchema();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[initORM] ORM initialization failed:', err.message);
|
console.error('[initORM] ORM initialization failed:', err.message);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add-only schema heal. @simpleworkjs/orm runs sequelize.sync() WITHOUT alter,
|
||||||
|
// which creates missing tables but never touches existing ones — so a column
|
||||||
|
// added in a newer release (e.g. PluginInstance.lastLog) simply never appears
|
||||||
|
// in an upgraded deployment's database and every query on the model fails
|
||||||
|
// ("no such column"). This walks each Sequelize model and ADDs any attribute
|
||||||
|
// missing from its table. Strictly additive (never drops or retypes), works on
|
||||||
|
// any dialect via the query interface, and fail-soft per column so one bad
|
||||||
|
// attribute can't take the boot down.
|
||||||
|
async function healSchema() {
|
||||||
|
const adapter = Resource.orm && Resource.orm.adapters && Resource.orm.adapters.sequelize;
|
||||||
|
if (!adapter || !adapter.sequelize) return;
|
||||||
|
const sequelize = adapter.sequelize;
|
||||||
|
const qi = sequelize.getQueryInterface();
|
||||||
|
for (const SM of Object.values(sequelize.models)) {
|
||||||
|
const table = SM.getTableName();
|
||||||
|
let existing;
|
||||||
|
try { existing = await qi.describeTable(table); }
|
||||||
|
catch (e) { continue; } // no table yet — sync() handles creation
|
||||||
|
for (const [name, attr] of Object.entries(SM.getAttributes())) {
|
||||||
|
const col = attr.field || name;
|
||||||
|
if (existing[col]) continue;
|
||||||
|
try {
|
||||||
|
await qi.addColumn(table, col, attr);
|
||||||
|
console.log(`[initORM] schema heal: added missing column ${table}.${col}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`[initORM] schema heal: could not add ${table}.${col}:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports.initORM = initORM;
|
module.exports.initORM = initORM;
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// PluginInstance — the registry of configured, loadable plugin copies.
|
||||||
|
//
|
||||||
|
// The SSO plugin system (see nodejs/services/plugin_registry.js) distinguishes
|
||||||
|
// **plugin types** (the .js modules under nodejs/plugins/<category>/<type>.js)
|
||||||
|
// from **plugin instances** — a configured, loadable/unloadable *copy* of a
|
||||||
|
// type. You can have several instances of the same type (e.g. two Proxmox
|
||||||
|
// endpoints with their own URLs + tokens), each on its own schedule.
|
||||||
|
//
|
||||||
|
// This table holds the *non-secret* per-instance state: which type it is, its
|
||||||
|
// schedule (cron), whether it's loaded (enabled), and its non-secret config.
|
||||||
|
// Per-instance **secrets** (the configSchema fields flagged `secret:true`,
|
||||||
|
// e.g. a Proxmox `tokenSecret` or UniFi `password`) live in OpenBao at
|
||||||
|
// `secret/plugins/<id>/conf` (see nodejs/utils/plugin_secrets.js) — never in
|
||||||
|
// the DB. The DB row's `config` JSON column holds only non-secret field values.
|
||||||
|
//
|
||||||
|
// `slug` is the discovery source name passed to DiscoveryReconciler.reconcile,
|
||||||
|
// so a discovery instance's resources are attributed to a stable, human-chosen
|
||||||
|
// name rather than its uuid. Unique, so two instances can't shadow each other
|
||||||
|
// in the resource graph's `discovery_sources`.
|
||||||
|
//
|
||||||
|
// Like Resource/AccessRequest, there is no ORM auto-timestamp hook: the route
|
||||||
|
// handler stamps created_by/on + updated_by/on explicitly on every write (see
|
||||||
|
// routes/api_plugins.js). `id` (uuid) is generated by the ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const STATUS = {
|
||||||
|
OK: 'ok',
|
||||||
|
ERROR: 'error',
|
||||||
|
RUNNING: 'running',
|
||||||
|
};
|
||||||
|
|
||||||
|
class PluginInstance extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// A registered plugin type slug (matches a manifest `type`). Validated
|
||||||
|
// against the registry before a row is created.
|
||||||
|
pluginType: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// The plugin's category (e.g. 'discovery'). Copied from the manifest at
|
||||||
|
// create time so the scheduler can dispatch without re-reading the registry
|
||||||
|
// on every run (and so a later type removal still shows what the instance was).
|
||||||
|
category: { type: 'string', isRequired: true, default: 'discovery', min: 1, max: 64 },
|
||||||
|
// Human label for the instance.
|
||||||
|
name: { type: 'string', isRequired: true, min: 1, max: 120 },
|
||||||
|
// Stable handle: discovery source name + unique constraint. Lowercase
|
||||||
|
// alnum + hyphen/underscore to stay safe as a resource-graph slug.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// Loaded into the scheduler? `false` = unloaded (no scheduled runs).
|
||||||
|
enabled: { type: 'boolean', default: true },
|
||||||
|
// Cron schedule (5-field). The scheduler turns this into a BullMQ
|
||||||
|
// repeatable JobScheduler.
|
||||||
|
cron: { type: 'string', isRequired: true, default: '0 * * * *' },
|
||||||
|
// Non-secret configSchema field values. Secret fields are NOT here.
|
||||||
|
config: { type: 'json', default: {} },
|
||||||
|
// Last-run bookkeeping, updated by the scheduler worker.
|
||||||
|
lastRunAt: { type: 'integer' },
|
||||||
|
lastStatus: { type: 'string' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
lastLog: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All instances the scheduler should run: enabled only. Loaded fresh each
|
||||||
|
// boot / load; not cached on the model (the scheduler is the source of truth
|
||||||
|
// for what's actually scheduled).
|
||||||
|
static async listEnabled() {
|
||||||
|
return this.list({ where: { enabled: true } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug — used by tests + the reconciler when only a slug is known.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { PluginInstance, STATUS };
|
||||||
+40
-31
@@ -96,11 +96,47 @@ class Resource extends Model {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let maxUpdated = 0;
|
||||||
resObjs.forEach(r => {
|
resObjs.forEach(r => {
|
||||||
r.metadata.isProduction = checkProd(r.id);
|
r.metadata.isProduction = checkProd(r.id);
|
||||||
|
if (r.updated_on && r.updated_on > maxUpdated) maxUpdated = r.updated_on;
|
||||||
});
|
});
|
||||||
|
|
||||||
return { resources: resObjs, edges };
|
return { resources: resObjs, edges, updated_on: maxUpdated || Date.now() };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stamp `resolvedAddress` on each resource: its own address/ip if it has one,
|
||||||
|
// otherwise the nearest ancestor's. A service usually carries no address of
|
||||||
|
// its own -- it is reached at the host it runs on -- so "how do I reach this"
|
||||||
|
// is only answerable from the graph, never from the row alone. Every caller
|
||||||
|
// that answers that question for a user (getMyAccess, GET /api/discovery/me)
|
||||||
|
// must go through here, or services come back unreachable.
|
||||||
|
static async withResolvedAddress(resources) {
|
||||||
|
if (!resources || !resources.length) return [];
|
||||||
|
const graph = await this.getGraph();
|
||||||
|
|
||||||
|
const resolve = (resId, visited = new Set()) => {
|
||||||
|
if (visited.has(resId)) return null; // prevent cycles
|
||||||
|
visited.add(resId);
|
||||||
|
|
||||||
|
const res = graph.resources.find(r => r.id === resId);
|
||||||
|
if (!res) return null;
|
||||||
|
if (res.metadata && res.metadata.address) return res.metadata.address;
|
||||||
|
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
||||||
|
|
||||||
|
for (const edge of graph.edges.filter(e => e.childId === resId)) {
|
||||||
|
const found = resolve(edge.parentId, visited);
|
||||||
|
if (found) return found;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
return resources.map(r => {
|
||||||
|
const data = r.toJSON ? r.toJSON() : { ...r };
|
||||||
|
data.metadata = data.metadata || {};
|
||||||
|
data.resolvedAddress = resolve(data.id);
|
||||||
|
return data;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getMyAccess(userDn) {
|
static async getMyAccess(userDn) {
|
||||||
@@ -110,38 +146,11 @@ class Resource extends Model {
|
|||||||
const resourceGroups = await ResourceGroup.list({
|
const resourceGroups = await ResourceGroup.list({
|
||||||
where: { groupCn: { in: userGroups } }
|
where: { groupCn: { in: userGroups } }
|
||||||
});
|
});
|
||||||
|
|
||||||
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
const resourceIds = [...new Set(resourceGroups.map(rg => rg.resourceId))];
|
||||||
if (resourceIds.length === 0) return [];
|
if (resourceIds.length === 0) return [];
|
||||||
|
|
||||||
const resources = await this.list({ where: { id: { in: resourceIds } } });
|
return this.withResolvedAddress(await this.list({ where: { id: { in: resourceIds } } }));
|
||||||
|
|
||||||
// Resolve inherited addresses from the graph
|
|
||||||
const graph = await this.getGraph();
|
|
||||||
|
|
||||||
function resolveHost(resId, visited = new Set()) {
|
|
||||||
if (visited.has(resId)) return null; // prevent cycles
|
|
||||||
visited.add(resId);
|
|
||||||
|
|
||||||
const res = graph.resources.find(r => r.id === resId);
|
|
||||||
if (!res) return null;
|
|
||||||
if (res.metadata && res.metadata.address) return res.metadata.address;
|
|
||||||
if (res.metadata && res.metadata.ip) return res.metadata.ip;
|
|
||||||
|
|
||||||
const parentEdges = graph.edges.filter(e => e.childId === resId);
|
|
||||||
for (const edge of parentEdges) {
|
|
||||||
const found = resolveHost(edge.parentId, visited);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return resources.map(r => {
|
|
||||||
const data = { ...r };
|
|
||||||
data.metadata = data.metadata || {};
|
|
||||||
data.resolvedAddress = resolveHost(r.id);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fields = {
|
static fields = {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecret — a secret the owner has published to the shared namespace so it
|
||||||
|
// can be shared with other users and/or downstream apps.
|
||||||
|
//
|
||||||
|
// The secret DATA lives in OpenBao at `secret/shared/<ownerUid>/<slug>` (KV-v2),
|
||||||
|
// never in the DB. This row is metadata only (owner + slug + description) and is
|
||||||
|
// the source of truth for the UI (which shares exist). ACCESS CONTROL is enforced
|
||||||
|
// entirely by OpenBao ACL policies: the owner's `user-<uid>` policy grants full
|
||||||
|
// R/W on `secret/shared/<ownerUid>/*`, and each grantee's policy content is
|
||||||
|
// edited to add `read` on the exact shared path (see vault_broker.js — policy
|
||||||
|
// content is parsed live at token use, so a grant takes effect immediately with
|
||||||
|
// no token re-mint). `secretId` on SharedSecretGrant links grantees to this row.
|
||||||
|
//
|
||||||
|
// `slug` is unique and immutable in practice — it is embedded in the shared path
|
||||||
|
// and in grantee policy rules, so changing it would require rewriting policies.
|
||||||
|
// Like PluginInstance, there is no ORM auto-timestamp hook: route handlers stamp
|
||||||
|
// created_by/on + updated_by/on on every write. `id` (uuid) is generated by the
|
||||||
|
// ORM on create.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class SharedSecret extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// Human slug embedded in the OpenBao path: secret/shared/<ownerUid>/<slug>.
|
||||||
|
// Unique so two owners can't collide on the same shared path.
|
||||||
|
slug: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The publishing user's uid — also the shared path's namespace segment.
|
||||||
|
ownerUid: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Optional human description shown in the Shared tab.
|
||||||
|
description: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Full OpenBao KV-v2 path for this shared secret (logical path, no data/metadata).
|
||||||
|
static pathFor(ownerUid, slug) {
|
||||||
|
return `shared/${ownerUid}/${slug}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
path() {
|
||||||
|
return SharedSecret.pathFor(this.ownerUid, this.slug);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up by slug (unique). Returns the row or null.
|
||||||
|
static async getBySlug(slug) {
|
||||||
|
const rows = await this.list({ where: { slug } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecret };
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// SharedSecretGrant — who can read a shared secret. Each row says "grantee
|
||||||
|
// <granteeId> (a user uid or an app name) has <capability> on the shared secret
|
||||||
|
// <secretId>".
|
||||||
|
//
|
||||||
|
// This table is the metadata/UX record of a grant. The actual ENFORCEMENT lives
|
||||||
|
// in OpenBao ACL policy content: when a grant is created, vault_broker.js
|
||||||
|
// recomputes the grantee's policy HCL (`user-<uid>` or `app-<name>`) to include
|
||||||
|
// `read` on the exact shared path and rewrites it. Because OpenBao parses policy
|
||||||
|
// content live at token use, the grant applies to the grantee's existing token
|
||||||
|
// immediately (no re-mint). Revoking removes the rule and rewrites the policy.
|
||||||
|
//
|
||||||
|
// granteeType distinguishes the two principal kinds:
|
||||||
|
// 'user' — a user uid → grantee's `user-<uid>` policy is edited
|
||||||
|
// 'app' — an app name → grantee's `app-<name>` policy is edited (downstream apps)
|
||||||
|
// capability is currently always 'read' (grantees are read-only); the column is
|
||||||
|
// a string so later capabilities could be added without a migration.
|
||||||
|
//
|
||||||
|
// No ORM auto-timestamp hook: route handlers stamp created_by/on + updated_by/on.
|
||||||
|
// Uniqueness on (secretId, granteeType, granteeId) prevents duplicate grants.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
const GRANTEE_TYPES = ['user', 'app'];
|
||||||
|
const CAPABILITIES = ['read'];
|
||||||
|
|
||||||
|
class SharedSecretGrant extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// FK to SharedSecret.id.
|
||||||
|
secretId: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// 'user' (a uid) or 'app' (an app name) — which policy to edit.
|
||||||
|
granteeType: { type: 'string', isRequired: true, min: 1 },
|
||||||
|
// The grantee's uid (for 'user') or app name (for 'app').
|
||||||
|
granteeId: { type: 'string', isRequired: true, min: 1, max: 64 },
|
||||||
|
// Access level — 'read' today.
|
||||||
|
capability: { type: 'string', isRequired: true, default: 'read' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
updated_by: { type: 'string' },
|
||||||
|
updated_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// All grants for a given grantee (user uid or app name). Used to rebuild the
|
||||||
|
// grantee's policy content so every granted shared path is present/absent.
|
||||||
|
static async listForGrantee(granteeType, granteeId) {
|
||||||
|
return this.list({ where: { granteeType, granteeId } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { SharedSecretGrant, GRANTEE_TYPES, CAPABILITIES };
|
||||||
@@ -10,6 +10,21 @@ function toE164Digits(number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function send(to, message) {
|
async function send(to, message) {
|
||||||
|
const { PluginInstance } = require('./plugin_instance');
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
|
||||||
|
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
|
||||||
|
if (instances.length > 0) {
|
||||||
|
const inst = instances[0];
|
||||||
|
const manifest = registry.getManifest(inst.pluginType);
|
||||||
|
if (manifest && manifest.sendMessage) {
|
||||||
|
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
|
||||||
|
const config = { ...inst.config, ...secrets };
|
||||||
|
return manifest.sendMessage(config, { to, message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
api_username: conf.username,
|
api_username: conf.username,
|
||||||
api_password: conf.password,
|
api_password: conf.password,
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ User.listDetail = async function(){
|
|||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||||
|
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||||
|
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
@@ -770,7 +773,7 @@ User.setActive = async function(active) {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
await client.modify(this.dn, [
|
await client.modify(this.dn, [
|
||||||
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['000001010000Z'] }) }),
|
new Change({ operation: 'replace', modification: new Attribute({ type: 'pwdAccountLockedTime', values: ['00000101000000Z'] }) }),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -785,7 +788,7 @@ User.setActive = async function(active) {
|
|||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||||
this.isActive = active ? 'active' : '';
|
this.isActive = active ? 'active' : '';
|
||||||
this.isInactive = active ? '' : 'inactive';
|
this.isInactive = active ? '' : 'inactive';
|
||||||
cache.clear();
|
cache.clear();
|
||||||
@@ -904,6 +907,13 @@ User.login = async function(data){
|
|||||||
}
|
}
|
||||||
let user = await this.get(data.uid || data.username);
|
let user = await this.get(data.uid || data.username);
|
||||||
|
|
||||||
|
if (user.pwdAccountLockedTime) {
|
||||||
|
let error = new Error('Invalid Credentials, login failed.');
|
||||||
|
error.name = 'LDAPLoginFailed';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
const loginClient = makeClient();
|
const loginClient = makeClient();
|
||||||
try {
|
try {
|
||||||
await loginClient.bind(user.dn, data.password);
|
await loginClient.bind(user.dn, data.password);
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// VaultAppToken — the ACCESSOR of an OpenBao token minted for an external app
|
||||||
|
// from the vault UI (Apps tab), so sso can keep the token alive.
|
||||||
|
//
|
||||||
|
// The token itself is shown ONCE at mint and never stored (a stolen accessor
|
||||||
|
// cannot authenticate — it can only look up, renew, or revoke its token, and
|
||||||
|
// only the sso broker's policy grants those endpoints). App tokens are minted
|
||||||
|
// through the sso-app role as PERIODIC tokens: they live forever, but only if
|
||||||
|
// something renews them inside every period window. That something is sso's
|
||||||
|
// renewal loop (vault_broker.startAppTokenRenewal), which walks these rows and
|
||||||
|
// POSTs auth/token/renew-accessor on a timer — so a downstream app's credential
|
||||||
|
// stays valid as long as sso itself is running, with no renewal code needed in
|
||||||
|
// the downstream app.
|
||||||
|
//
|
||||||
|
// One row per app name: re-minting an app's token revokes the previous token
|
||||||
|
// via its accessor (no zombie credentials) and replaces the row.
|
||||||
|
|
||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class VaultAppToken extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
// The external app's name — also its policy (app-<name>) and KV namespace
|
||||||
|
// (secret/apps/<name>/). Unique: one live token per app.
|
||||||
|
name: { type: 'string', isRequired: true, unique: true, min: 1, max: 64 },
|
||||||
|
// The minted token's accessor (renew/revoke handle, cannot authenticate).
|
||||||
|
accessor: { type: 'string', isRequired: true, max: 128 },
|
||||||
|
// Renewal bookkeeping, updated by the renewal loop.
|
||||||
|
lastRenewedAt: { type: 'integer' },
|
||||||
|
lastError: { type: 'text' },
|
||||||
|
// Audit stamps (set by the route handler, not by an ORM hook).
|
||||||
|
created_by: { type: 'string' },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
|
||||||
|
static async getByName(name) {
|
||||||
|
const rows = await this.list({ where: { name } });
|
||||||
|
return rows[0] || null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { VaultAppToken };
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
const { Model } = require('@simpleworkjs/orm');
|
||||||
|
|
||||||
|
class Webhook extends Model {
|
||||||
|
static fields = {
|
||||||
|
id: { type: 'uuid', primaryKey: true },
|
||||||
|
name: { type: 'string', isRequired: true },
|
||||||
|
url: { type: 'string', isRequired: true },
|
||||||
|
events: { type: 'json', default: [] }, // e.g. ['discovery.new_device', 'resource.updated']
|
||||||
|
secret: { type: 'string' },
|
||||||
|
isActive: { type: 'boolean', default: true },
|
||||||
|
created_on: { type: 'integer' },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { Webhook };
|
||||||
Generated
+529
-31
@@ -1,29 +1,33 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.8.3",
|
"version": "1.30.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.8.3",
|
"version": "1.30.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -33,9 +37,12 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -650,6 +657,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1098,6 +1111,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1258,6 +1349,18 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/bao-conf": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1271,9 +1374,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@simpleworkjs/directory-schema": {
|
"node_modules/@simpleworkjs/directory-schema": {
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/directory-schema/-/directory-schema-1.1.0.tgz",
|
||||||
"integrity": "sha512-thZhPGNdDYlD8rlhXidnbCHTKjdSkj9ag1zE/gz1AwuclYypsKAP+v3BAvcZ/YDQP8RBDJNPXof5EpVheLovTg==",
|
"integrity": "sha512-hTXxHl7Jz5IbIAYmn8dv9f0B50ocjEg5ju+UV8ZQSaBjJYpetOVfcFvT6v9xwMVtjXSYMDwKPvD8YgKOBz7xJw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
@@ -1423,6 +1526,15 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-proxy": {
|
||||||
|
"version": "1.17.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||||
|
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2232,9 +2344,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^1.0.0"
|
"balanced-match": "^1.0.0"
|
||||||
@@ -2244,7 +2356,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -2334,6 +2445,54 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-ri/ugcNf4G/knwnMd2LVuwIdyzI9A2a2CipYvvfG6H4I1X23DhNrDtd8yuj46dqeE8kdoUSPlTJ9rEtfs4W/cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "5.6.1",
|
||||||
|
"msgpackr": "2.0.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bullmq-otel": ">=2.0.0",
|
||||||
|
"ioredis": ">=5.0.0",
|
||||||
|
"pg": ">=8.0.0",
|
||||||
|
"redis": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bullmq-otel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -2759,6 +2918,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2848,6 +3019,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -3201,6 +3381,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -3451,7 +3637,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -3518,6 +3703,26 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -3881,6 +4086,44 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
|
"follow-redirects": "^1.0.0",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-proxy": "^1.17.8",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"is-glob": "^4.0.1",
|
||||||
|
"is-plain-obj": "^3.0.0",
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/express": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -3997,10 +4240,63 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "2.0.0",
|
||||||
|
"cluster-key-slot": "1.1.1",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"denque": "2.1.0",
|
||||||
|
"redis-errors": "1.2.0",
|
||||||
|
"standard-as-callback": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 12"
|
"node": ">= 12"
|
||||||
@@ -4039,7 +4335,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4069,7 +4364,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -4082,12 +4376,23 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -5082,6 +5387,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@@ -5180,6 +5494,31 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||||
@@ -5324,6 +5663,37 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -5395,6 +5765,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||||
@@ -5404,6 +5780,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp": {
|
"node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||||
@@ -5440,6 +5836,21 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"node-gyp-build-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp/node_modules/isexe": {
|
"node_modules/node-gyp/node_modules/isexe": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||||
@@ -5486,6 +5897,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-nmap": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-nmap/-/node-nmap-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-VJGebpYsfqmUm46+Fq0qp1Y9VXGXZ7/WL03tHGy1oJHHxaJ2DvYLMjuYWYHDV0pgUL+e5/9rCN/QEsx3+fU9TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queued-up": "^2.0.2",
|
||||||
|
"xml2js": "^0.4.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
@@ -5545,16 +5966,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||||
"version": "5.0.7",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "18 || 20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
"node_modules/nodemon/node_modules/debug": {
|
||||||
@@ -6077,6 +6498,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queued-up": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -6201,6 +6628,15 @@
|
|||||||
"node": ">= 20.0.0"
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -6211,6 +6647,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-cwd": {
|
"node_modules/resolve-cwd": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||||
@@ -6305,6 +6747,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -6915,6 +7366,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -7269,9 +7726,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||||
"version": "1.1.16",
|
"version": "1.1.18",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -7342,7 +7799,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -7376,13 +7832,17 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
@@ -7458,9 +7918,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/undici": {
|
"node_modules/undici": {
|
||||||
"version": "6.27.0",
|
"version": "6.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -7613,6 +8073,22 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7774,6 +8250,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xss": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
+9
-2
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.10.0",
|
"version": "1.30.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -24,18 +24,22 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.0.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
"@simpleworkjs/ldap": "^1.0.0",
|
"@simpleworkjs/ldap": "^1.0.0",
|
||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -45,9 +49,12 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
"ws": "^8.21.1",
|
||||||
"xss": "^1.0.15"
|
"xss": "^1.0.15"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs
|
||||||
|
index c7646a4..411b56f 100644
|
||||||
|
--- a/nodejs/views/directory.ejs
|
||||||
|
+++ b/nodejs/views/directory.ejs
|
||||||
|
@@ -3,7 +3,26 @@
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
- <div class="card shadow">
|
||||||
|
+ <ul class="nav nav-tabs mb-3" id="directoryTabs" role="tablist">
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
|
||||||
|
+ <i class="fa-solid fa-server"></i> Directory
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Discovery
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ </ul>
|
||||||
|
+ <div class="tab-content" id="directoryTabsContent">
|
||||||
|
+ <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-server"></i> Directory Management
|
||||||
|
@@ -74,6 +93,148 @@
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
+
|
||||||
|
+ <!-- Discovery Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
+ </div>
|
||||||
|
+ <div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
+ <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
|
||||||
|
+ <select id="discovery-filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderDiscoveryTable()" style="width: 150px;">
|
||||||
|
+ <option value="unmanaged">Unmanaged Only</option>
|
||||||
|
+ <option value="managed">Managed Only</option>
|
||||||
|
+ <option value="all">All Resources</option>
|
||||||
|
+ </select>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
|
||||||
|
+ <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Name / Source</th>
|
||||||
|
+ <th>Type</th>
|
||||||
|
+ <th>IP Address</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th class="text-end pe-3">Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="discovery-list" jq-repeat="discoveryResources">
|
||||||
|
+ <tr id="discovery-row-{{slug}}">
|
||||||
|
+ <td class="ps-3">
|
||||||
|
+ <div class="fw-bold">{{name}}</div>
|
||||||
|
+ <div class="text-muted small">
|
||||||
|
+ <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
+ </div>
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <span class="badge bg-secondary">{{kind}}</span>
|
||||||
|
+ {{#metadata.subType}}
|
||||||
|
+ <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
+ {{/metadata.subType}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
+ {{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
+ {{#metadata.interfaces.length}}
|
||||||
|
+ <div class="mt-1 small text-muted">
|
||||||
|
+ {{#metadata.interfaces}}
|
||||||
|
+ <div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
+ {{/metadata.interfaces}}
|
||||||
|
+ </div>
|
||||||
|
+ {{/metadata.interfaces.length}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ <td class="text-end pe-3">
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
+ <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
+ Promoted
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="discovery-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
+ <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
+ <h5>No resources found</h5>
|
||||||
|
+ <p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <!-- Plugins Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/plugins">Learn how to make and use custom plugins</a>.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Plugin Name</th>
|
||||||
|
+ <th>Cron Schedule</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th>Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
+ <tr>
|
||||||
|
+ <td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
+ <td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
+ {{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
|
||||||
|
+ {{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
|
||||||
|
+ {{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="plugins-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
+ No plugins configured.
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -413,14 +574,144 @@
|
||||||
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
r.parentId = parentEdge.parentId;
|
||||||
|
- const parent = resourcesById[parentEdge.parentId];
|
||||||
|
+ const parent = resourcesById[parentEdge.parentId];
|
||||||
|
if (parent) r.hostName = parent.name;
|
||||||
|
}
|
||||||
|
rawResources.push(r);
|
||||||
|
}
|
||||||
|
+ function openAddModal(parent_id, kind) {
|
||||||
|
+ if(parent_id){
|
||||||
|
+ $('#newResourceParent').val(parent_id);
|
||||||
|
+ $('#newResourceKind').val(kind);
|
||||||
|
+ var currentLabel = "Resource";
|
||||||
|
+ if(kind === 'Host'){ currentLabel = 'Host'; }
|
||||||
|
+ else if(kind === 'Site'){ currentLabel = 'Site'; }
|
||||||
|
+
|
||||||
|
+ $('#newResourceLabel').text('Add Child ' + currentLabel);
|
||||||
|
+ }else{
|
||||||
|
+ $('#newResourceParent').val('');
|
||||||
|
+ $('#newResourceKind').val('Host');
|
||||||
|
+ $('#newResourceLabel').text('Add Resource');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear input
|
||||||
|
+ $('#newResourceName').val('');
|
||||||
|
+ $('#addResourceModal').modal('show');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- DISCOVERY SCRIPTS ---
|
||||||
|
+ let allDiscoveryResources = [];
|
||||||
|
+
|
||||||
|
+ function loadDiscoveryResources() {
|
||||||
|
+ app.api.get('discovery/resources', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ allDiscoveryResources = res.results || [];
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function renderDiscoveryTable() {
|
||||||
|
+ const search = $('#discovery-search-filter').val().toLowerCase();
|
||||||
|
+ const managedFilter = $('#discovery-filter-managed').val();
|
||||||
|
+
|
||||||
|
+ const filtered = allDiscoveryResources.filter(r => {
|
||||||
|
+ if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
+ const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
+ if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
|
+ if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
+ return true;
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ $.scope.discoveryResources.empty();
|
||||||
|
+ for(const r of filtered) {
|
||||||
|
+ $.scope.discoveryResources.push(r);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if(filtered.length === 0) {
|
||||||
|
+ $('#discovery-list').hide();
|
||||||
|
+ $('#discovery-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ $('#discovery-list').show();
|
||||||
|
+ $('#discovery-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function promoteResource(slug) {
|
||||||
|
+ if(!confirm("Are you sure you want to promote this resource? This will generate SSO LDAP groups for it.")) return;
|
||||||
|
+ app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error promoting resource: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const resource = allDiscoveryResources.find(r => r.slug === slug);
|
||||||
|
+ if(resource) {
|
||||||
|
+ resource.metadata = resource.metadata || {};
|
||||||
|
+ resource.metadata.managed = true;
|
||||||
|
+ }
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ renderTable(); // Also update directory tab
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- PLUGINS SCRIPTS ---
|
||||||
|
+ function loadPlugins() {
|
||||||
|
+ app.api.get('plugins', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error loading plugins: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const plugins = res.results || {};
|
||||||
|
+ const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
- renderTable();
|
||||||
|
+ $.scope.plugins.empty();
|
||||||
|
+ if(pluginNames.length === 0) {
|
||||||
|
+ $('#plugins-list').hide();
|
||||||
|
+ $('#plugins-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ pluginNames.forEach(name => {
|
||||||
|
+ const config = plugins[name];
|
||||||
|
+ $.scope.plugins.push({
|
||||||
|
+ name: name,
|
||||||
|
+ cron: config.cron || '',
|
||||||
|
+ enabled: config.enabled
|
||||||
|
+ });
|
||||||
|
+ });
|
||||||
|
+ $('#plugins-list').show();
|
||||||
|
+ $('#plugins-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
|
||||||
|
+ function updatePlugin(name) {
|
||||||
|
+ const cron = $('#cron-' + name).val();
|
||||||
|
+ app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to save: " + err.message); return; }
|
||||||
|
+ alert("Saved schedule successfully.");
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function togglePlugin(name, enable) {
|
||||||
|
+ app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to toggle: " + err.message); return; }
|
||||||
|
+ loadPlugins();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ $(document).ready(function(){
|
||||||
|
+ renderTable();
|
||||||
|
+ loadDiscoveryResources();
|
||||||
|
+ loadPlugins();
|
||||||
|
+
|
||||||
|
+ // Auto-open modal if hash is present
|
||||||
|
+ if(window.location.hash && window.location.hash.startsWith('#modal-')) {
|
||||||
|
+ const slug = window.location.hash.replace('#modal-', '');
|
||||||
|
+ setTimeout(() => openEditModal(slug), 500);
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
|
||||||
|
// input accepts a free-typed uid whether or not the list ever arrives.
|
||||||
|
loadDirectoryUsers().then(function(users) {
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'docker',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Docker Daemon',
|
||||||
|
description: 'Discover running containers and networks from a local or remote Docker daemon.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
|
||||||
|
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' },
|
||||||
|
// Containers in this compose project are the stack's own. They are already
|
||||||
|
// represented in the catalog as services, so they are recorded as managed
|
||||||
|
// and linked to the service they implement instead of arriving as
|
||||||
|
// unmanaged strangers a fresh install has to triage.
|
||||||
|
{ key: 'stackProject', label: 'Own compose project', type: 'text', required: false, placeholder: 'theta-suite' },
|
||||||
|
// 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) => {
|
||||||
|
if (!config.socketPath && !config.tcpHost) {
|
||||||
|
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const isTcp = !!config.tcpHost;
|
||||||
|
|
||||||
|
const requestOptions = {
|
||||||
|
path: '/containers/json',
|
||||||
|
method: 'GET'
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isTcp) {
|
||||||
|
const url = new URL(config.tcpHost);
|
||||||
|
requestOptions.host = url.hostname;
|
||||||
|
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
|
||||||
|
requestOptions.protocol = url.protocol;
|
||||||
|
} else {
|
||||||
|
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = http.request(requestOptions, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode !== 200) {
|
||||||
|
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const containers = JSON.parse(body);
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
const stackProject = (config.stackProject || '').trim();
|
||||||
|
const hostSlug = (config.hostSlug || '').trim();
|
||||||
|
|
||||||
|
for (const c of containers) {
|
||||||
|
const labels = c.Labels || {};
|
||||||
|
const composeProject = labels['com.docker.compose.project'] || '';
|
||||||
|
const composeService = labels['com.docker.compose.service'] || '';
|
||||||
|
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||||
|
|
||||||
|
// A container id changes every time the container is recreated,
|
||||||
|
// so an id-derived slug made `docker compose up` mint a brand-new
|
||||||
|
// resource on every deploy and orphan the previous one. Prefer
|
||||||
|
// identifiers that survive a recreate: the compose project+service
|
||||||
|
// it belongs to, else its name.
|
||||||
|
const stableKey = composeProject && composeService
|
||||||
|
? `${composeProject}-${composeService}`
|
||||||
|
: (name || c.Id.substring(0, 12));
|
||||||
|
const slug = `docker-${stableKey.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
|
||||||
|
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||||
|
const isOwnStack = !!(stackProject && composeProject === stackProject);
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'container',
|
||||||
|
name: composeService || name,
|
||||||
|
slug: slug,
|
||||||
|
metadata: {
|
||||||
|
image: c.Image,
|
||||||
|
state: c.State,
|
||||||
|
status: c.Status,
|
||||||
|
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 });
|
||||||
|
} catch (e) {
|
||||||
|
reject(new Error(`Failed to parse Docker response: ${e.message}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
const nmap = require('node-nmap');
|
||||||
|
nmap.nmapLocation = "nmap"; // default
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
|
||||||
|
// not secret (it's a network range to scan), so it lives in the DB row, not
|
||||||
|
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
|
||||||
|
// the range parses — running a real scan is what `run` does.
|
||||||
|
type: 'nmap',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Nmap Network Scan',
|
||||||
|
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
|
||||||
|
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
|
||||||
|
// only sanity-check shape here — reject anything with shell metacharacters
|
||||||
|
// or whitespace, since node-nmap passes this straight to the nmap binary.
|
||||||
|
if (/\s|[;|&$`<>]/.test(targetRange)) {
|
||||||
|
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const { targetRange } = config;
|
||||||
|
if (!targetRange) throw new Error("Missing targetRange for Nmap");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
// OsAndPortScan requires root (for -O). NmapScan does a basic port scan (TCP connect if non-root).
|
||||||
|
// Pass custom arguments in constructor so node-nmap includes them before spawning nmap process.
|
||||||
|
// -Pn: treat all hosts as online (skip ping/ARP host discovery which fails inside Docker containers NAT/bridge)
|
||||||
|
// -sT: TCP connect scan (unprivileged scan compatible with container environments)
|
||||||
|
// -F: fast scan (100 top ports)
|
||||||
|
// --min-rate 100: speed up scan rate
|
||||||
|
const customFlags = ['-Pn', '-sT', '-F', '--min-rate', '100'];
|
||||||
|
const scan = new nmap.NmapScan(targetRange, customFlags);
|
||||||
|
|
||||||
|
if (config.log) config.log(`Starting nmap scan: ${scan.command.join(' ')}`);
|
||||||
|
|
||||||
|
scan.on('complete', function(data) {
|
||||||
|
if (config.log) config.log(`Scan complete. Found ${data ? data.length : 0} hosts.`);
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
for (const host of data) {
|
||||||
|
if (!host.ip) continue;
|
||||||
|
const hostId = host.mac ? host.mac.replace(/:/g, '') : host.ip.replace(/\\./g, '_');
|
||||||
|
const hostSlug = `nmap-host-${hostId}`;
|
||||||
|
|
||||||
|
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: host.hostname || host.ip,
|
||||||
|
slug: hostSlug,
|
||||||
|
metadata: { interfaces, os: host.osNmap }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (host.openPorts && host.openPorts.length > 0) {
|
||||||
|
for (const port of host.openPorts) {
|
||||||
|
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'service',
|
||||||
|
name: `${port.service} on ${port.port}`,
|
||||||
|
slug: svcSlug,
|
||||||
|
metadata: { port: port.port, protocol: port.protocol }
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: hostSlug, childSlug: svcSlug, relation: 'exposes' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve({ resources, edges });
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.on('error', function(error) {
|
||||||
|
// node-nmap's spawn-missing-binary message ("NMAP not found at command
|
||||||
|
// location: nmap") is opaque to an admin reading lastError. Translate
|
||||||
|
// it into something actionable. (The Dockerfile installs nmap in the
|
||||||
|
// app image; this only fires if someone runs outside the container or
|
||||||
|
// strips the package.)
|
||||||
|
var msg = (error && error.message) || String(error);
|
||||||
|
if (/nmap.*not found|command location/i.test(msg)) {
|
||||||
|
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
|
||||||
|
} else {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
scan.startScan();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||||
|
// this references module.exports rather than `this`.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
// Custom agent to bypass self-signed certs typical in Proxmox
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
|
||||||
|
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
|
||||||
|
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
|
||||||
|
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
|
||||||
|
// the pairing honest -- the previous code collected MACs and IPs into two flat
|
||||||
|
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
|
||||||
|
class Interfaces {
|
||||||
|
constructor() { this.byMac = new Map(); this.anonymous = []; }
|
||||||
|
|
||||||
|
// Interfaces that belong to something running INSIDE the guest -- container
|
||||||
|
// engines, overlay networks, VPNs -- rather than to the guest itself. A
|
||||||
|
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
|
||||||
|
// alongside its one real NIC, which is noise in the directory and, worse,
|
||||||
|
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
|
||||||
|
// Only applied to guests; a hypervisor's own bridges are how you reach it.
|
||||||
|
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
|
||||||
|
|
||||||
|
static isVirtualName(name) {
|
||||||
|
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
|
||||||
|
// the only place the Proxmox node network API exposes a physical NIC's MAC
|
||||||
|
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
|
||||||
|
// rather than leaving every hypervisor MAC-less.
|
||||||
|
static macFromIfaceName(name) {
|
||||||
|
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
|
||||||
|
if (!m) return null;
|
||||||
|
return m[1].toLowerCase().match(/.{2}/g).join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
static normalizeMac(mac) {
|
||||||
|
const m = (mac || '').toLowerCase().trim();
|
||||||
|
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
|
||||||
|
if (m === '00:00:00:00:00:00') return null;
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `ips` are the addresses observed on this one NIC (may be empty for a
|
||||||
|
// stopped guest, where only the MAC is known).
|
||||||
|
add(mac, ips, name) {
|
||||||
|
const key = Interfaces.normalizeMac(mac);
|
||||||
|
const addrs = (ips || []).filter(Boolean);
|
||||||
|
if (!key) {
|
||||||
|
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
|
||||||
|
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const existing = this.byMac.get(key);
|
||||||
|
if (existing) {
|
||||||
|
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
|
||||||
|
existing.ip = existing.ips[0] || null;
|
||||||
|
if (!existing.name && name) existing.name = name;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
|
||||||
|
|
||||||
|
// The address/MAC the directory shows in its single-value columns, and what
|
||||||
|
// the reconciler matches on. Prefer a NIC that actually has an address.
|
||||||
|
primaryIp() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip);
|
||||||
|
return withIp ? withIp.ip : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryMac() {
|
||||||
|
const withIp = this.toArray().find(i => i.ip && i.mac);
|
||||||
|
if (withIp) return withIp.mac;
|
||||||
|
const first = this.toArray().find(i => i.mac);
|
||||||
|
return first ? first.mac : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||||
|
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||||
|
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
|
||||||
|
type: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Proxmox VE',
|
||||||
|
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||||
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||||
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||||
|
// API token to confirm the URL + token are valid before scheduling runs.
|
||||||
|
validate: async (config) => {
|
||||||
|
const { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
|
||||||
|
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
let { url, tokenId, tokenSecret } = config;
|
||||||
|
if (!url || !tokenId || !tokenSecret) {
|
||||||
|
throw new Error("Missing Proxmox config");
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ensure URL has no trailing slash
|
||||||
|
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
|
||||||
|
// several unrelated roots in the Directory tree and nothing says where any
|
||||||
|
// of them came from. Every node discovered below is parented to this, so
|
||||||
|
// one endpoint == one subtree.
|
||||||
|
const endpointHost = (() => {
|
||||||
|
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
|
||||||
|
})();
|
||||||
|
const clusterName = await (async () => {
|
||||||
|
// /cluster/status names the cluster when one exists; a standalone node
|
||||||
|
// has no cluster entry, in which case the endpoint hostname is the name.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
|
||||||
|
if (!res.ok) return null;
|
||||||
|
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
|
||||||
|
return entry ? entry.name : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
})();
|
||||||
|
|
||||||
|
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: clusterName || `Proxmox (${endpointHost})`,
|
||||||
|
slug: endpointSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'proxmox',
|
||||||
|
address: url,
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
sourceId: url,
|
||||||
|
// Deliberately NO `ip`/`interfaces`: this resource stands for the
|
||||||
|
// cluster (the API endpoint), not for a machine. Giving it the address
|
||||||
|
// it is reached at made the reconciler match it to the very node that
|
||||||
|
// answers on that address -- the endpoint and the node collapsed into
|
||||||
|
// one row, which then became its own parent. The cluster is identified
|
||||||
|
// by slug + sourceId instead, which nothing else can collide with.
|
||||||
|
interfaces: []
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1. Get Nodes
|
||||||
|
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||||
|
if(!resNodes.ok) {
|
||||||
|
const errText = await resNodes.text();
|
||||||
|
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
|
||||||
|
}
|
||||||
|
const nodes = (await resNodes.json()).data;
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
// An offline node is still a real hypervisor that belongs in the
|
||||||
|
// directory -- skipping it entirely used to make it look decommissioned
|
||||||
|
// and let the reconciler's garbage collector archive it after a week of
|
||||||
|
// downtime. Record it, mark it down, and skip only the guest enumeration
|
||||||
|
// (which needs the node to answer).
|
||||||
|
const online = node.status === 'online';
|
||||||
|
|
||||||
|
const nodeSlug = `pve-node-${node.node}`;
|
||||||
|
|
||||||
|
// A hypervisor with no address is not actionable. Read its bridges/NICs
|
||||||
|
// so the node lands in the directory reachable and MAC-identified like
|
||||||
|
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
|
||||||
|
// normally the address you actually reach the hypervisor on.
|
||||||
|
const nodeIfaces = new Interfaces();
|
||||||
|
try {
|
||||||
|
const netRes = online
|
||||||
|
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
|
||||||
|
: { ok: false };
|
||||||
|
if (netRes.ok) {
|
||||||
|
const ifaceList = (await netRes.json()).data || [];
|
||||||
|
for (const iface of ifaceList) {
|
||||||
|
if (iface.iface === 'lo') continue;
|
||||||
|
const ip = iface.address || iface.cidr;
|
||||||
|
// This endpoint has no hwaddr field, so the MAC has to be recovered
|
||||||
|
// from a predictable interface name -- either this interface's own
|
||||||
|
// or, for a bridge, one of the physical ports beneath it.
|
||||||
|
let mac = Interfaces.macFromIfaceName(iface.iface);
|
||||||
|
if (!mac) {
|
||||||
|
for (const alt of (iface.altnames || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!mac && iface.bridge_ports) {
|
||||||
|
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
|
||||||
|
mac = Interfaces.macFromIfaceName(port);
|
||||||
|
if (mac) break;
|
||||||
|
// The port may itself only carry the MAC in an altname.
|
||||||
|
const portDef = ifaceList.find(i => i.iface === port);
|
||||||
|
for (const alt of ((portDef && portDef.altnames) || [])) {
|
||||||
|
mac = Interfaces.macFromIfaceName(alt);
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
if (mac) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: 'host',
|
||||||
|
name: node.node,
|
||||||
|
slug: nodeSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: 'hypervisor',
|
||||||
|
os: 'Proxmox VE',
|
||||||
|
isProduction: true,
|
||||||
|
status: node.status,
|
||||||
|
sourceId: `${node.node}`,
|
||||||
|
node: node.node,
|
||||||
|
interfaces: nodeIfaces.toArray(),
|
||||||
|
macAddress: nodeIfaces.primaryMac(),
|
||||||
|
ip: nodeIfaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
|
||||||
|
|
||||||
|
// Everything below asks the node itself; an offline node answers none of
|
||||||
|
// it, and its guests are already recorded from previous runs.
|
||||||
|
if (!online) continue;
|
||||||
|
|
||||||
|
// 2. Get VMs for this node
|
||||||
|
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
|
||||||
|
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const vm of vms) {
|
||||||
|
const vmSlug = `vm-${vm.vmid}`;
|
||||||
|
const isTemplate = vm.template === 1;
|
||||||
|
|
||||||
|
const ifaces = new Interfaces();
|
||||||
|
|
||||||
|
// Enrich from QEMU guest agent if running. The agent is the only source
|
||||||
|
// that knows which IP sits on which NIC, so pair them here rather than
|
||||||
|
// accumulating two flat lists (zipping those by index attributed IPs to
|
||||||
|
// the wrong MAC on any guest with more than one NIC).
|
||||||
|
if (vm.status === 'running') {
|
||||||
|
try {
|
||||||
|
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
|
||||||
|
if (agentRes.ok) {
|
||||||
|
const agentData = (await agentRes.json()).data;
|
||||||
|
if (agentData && agentData.result) {
|
||||||
|
for (const iface of agentData.result) {
|
||||||
|
// Docker bridges, veth pairs and VPN tunnels are the
|
||||||
|
// guest's own plumbing, not NICs of the guest.
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ips = (iface['ip-addresses'] || [])
|
||||||
|
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
|
||||||
|
.map(ip => ip['ip-address']);
|
||||||
|
ifaces.add(iface['hardware-address'], ips, iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enrich from VM config: the MAC is declared there whether or not the
|
||||||
|
// guest agent answered, so a stopped VM still gets a stable identity.
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
if (confData[`net${i}`]) {
|
||||||
|
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
|
||||||
|
if(m) ifaces.add(m[1], [], `net${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: vm.name || `VM ${vm.vmid}`,
|
||||||
|
slug: vmSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'vm',
|
||||||
|
vmid: vm.vmid,
|
||||||
|
// The Proxmox-side identity, so a resource can be traced back to the
|
||||||
|
// exact guest on the exact node it was discovered from.
|
||||||
|
sourceId: `${node.node}/qemu/${vm.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
isProduction: vm.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get LXCs for this node
|
||||||
|
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
|
||||||
|
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
|
||||||
|
|
||||||
|
for (const lxc of lxcs) {
|
||||||
|
const lxcSlug = `lxc-${lxc.vmid}`;
|
||||||
|
const isTemplate = lxc.template === 1;
|
||||||
|
|
||||||
|
const ifaces = new Interfaces();
|
||||||
|
|
||||||
|
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
|
||||||
|
// so read them off the same line instead of into parallel lists.
|
||||||
|
try {
|
||||||
|
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
|
||||||
|
if (configRes.ok) {
|
||||||
|
const confData = (await configRes.json()).data;
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
const line = confData[`net${i}`];
|
||||||
|
if (!line) continue;
|
||||||
|
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
|
||||||
|
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
|
||||||
|
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
const nameMatch = line.match(/\bname=([^,]+)/);
|
||||||
|
if (hwMatch || ipMatch) {
|
||||||
|
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// A DHCP-configured container has no IP in its config. Ask the running
|
||||||
|
// container's interface list so it lands in the directory addressable
|
||||||
|
// instead of as an IP-less row.
|
||||||
|
if (lxc.status === 'running' && !ifaces.primaryIp()) {
|
||||||
|
try {
|
||||||
|
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
|
||||||
|
if (ifRes.ok) {
|
||||||
|
for (const iface of ((await ifRes.json()).data || [])) {
|
||||||
|
if (Interfaces.isVirtualName(iface.name)) continue;
|
||||||
|
const ip = (iface.inet || '').split('/')[0];
|
||||||
|
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const interfaces = ifaces.toArray();
|
||||||
|
|
||||||
|
resources.push({
|
||||||
|
kind: isTemplate ? 'template' : 'host',
|
||||||
|
name: lxc.name || `LXC ${lxc.vmid}`,
|
||||||
|
slug: lxcSlug,
|
||||||
|
metadata: {
|
||||||
|
subType: isTemplate ? 'template' : 'lxc',
|
||||||
|
vmid: lxc.vmid,
|
||||||
|
sourceId: `${node.node}/lxc/${lxc.vmid}`,
|
||||||
|
node: node.node,
|
||||||
|
isProduction: lxc.status === 'running',
|
||||||
|
interfaces,
|
||||||
|
macAddress: ifaces.primaryMac(),
|
||||||
|
ip: ifaces.primaryIp()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
},
|
||||||
|
|
||||||
|
// The generalized plugin contract calls `run`; the discovery plugins keep
|
||||||
|
// `discover` as their implementation name for back-compat, and `run` is just
|
||||||
|
// an alias. Referenced via module.exports (not `this`) so it survives being
|
||||||
|
// detached and called as a bare function reference.
|
||||||
|
run: async (config) => module.exports.discover(config),
|
||||||
|
|
||||||
|
// Exported for unit tests only -- not part of the plugin contract.
|
||||||
|
_Interfaces: Interfaces
|
||||||
|
};
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
const fetch = require('node-fetch');
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const agent = new https.Agent({
|
||||||
|
rejectUnauthorized: false
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
// Plugin manifest — see nodejs/services/plugin_registry.js. `password` is
|
||||||
|
// secret and stored in OpenBao (secret/plugins/<instance-id>/conf).
|
||||||
|
type: 'unifi',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'UniFi Network',
|
||||||
|
description: 'Discover UniFi network devices and clients from a UniFi Controller / UDM endpoint.',
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'Controller URL', type: 'url', required: true, placeholder: 'https://unifi.example:8443' },
|
||||||
|
{ key: 'user', label: 'Username', type: 'text', required: true },
|
||||||
|
{ key: 'password', label: 'Password', type: 'password', required: true, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
// "Test": attempt the UDM login (falls back to the legacy controller login);
|
||||||
|
// succeeds only if one of the two login endpoints returns 200.
|
||||||
|
validate: async (config) => {
|
||||||
|
const { url, user, password } = config;
|
||||||
|
if (!url || !user || !password) return { ok: false, error: 'Missing url, user, or password' };
|
||||||
|
try {
|
||||||
|
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }), agent
|
||||||
|
});
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
loginRes = await fetch(`${url}/api/login`, {
|
||||||
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }), agent
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (!loginRes.ok) return { ok: false, error: `UniFi auth failed (${loginRes.status})` };
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, error: err.message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, user, password } = config;
|
||||||
|
if (!url || !user || !password) {
|
||||||
|
throw new Error("Missing Unifi config");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Authenticate
|
||||||
|
let loginRes = await fetch(`${url}/api/auth/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }),
|
||||||
|
agent
|
||||||
|
});
|
||||||
|
|
||||||
|
let isUdm = true;
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
loginRes = await fetch(`${url}/api/login`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ username: user, password }),
|
||||||
|
agent
|
||||||
|
});
|
||||||
|
isUdm = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!loginRes.ok) {
|
||||||
|
throw new Error(`Unifi auth failed: ${loginRes.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = loginRes.headers.get('set-cookie');
|
||||||
|
// UniFi often requires the CSRF token from the cookie
|
||||||
|
let csrf = '';
|
||||||
|
if (cookie) {
|
||||||
|
const match = cookie.match(/csrf_token=([^;]+)/);
|
||||||
|
if (match) csrf = match[1];
|
||||||
|
}
|
||||||
|
const headers = { 'Cookie': cookie, 'X-Csrf-Token': csrf };
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
const basePath = isUdm ? '/proxy/network' : '';
|
||||||
|
|
||||||
|
// 2. Get Devices (Switches/APs)
|
||||||
|
const devRes = await fetch(`${url}${basePath}/api/s/default/stat/device`, { headers, agent });
|
||||||
|
const devData = (await devRes.json()).data || [];
|
||||||
|
|
||||||
|
for (const dev of devData) {
|
||||||
|
const devSlug = `unifi-device-${dev.mac.replace(/:/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'network_device',
|
||||||
|
name: dev.name || dev.model,
|
||||||
|
slug: devSlug,
|
||||||
|
metadata: {
|
||||||
|
make: 'Ubiquiti',
|
||||||
|
model: dev.model,
|
||||||
|
firmware: dev.version,
|
||||||
|
interfaces: [{ mac: dev.mac, ip: dev.ip }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get Clients
|
||||||
|
const clientRes = await fetch(`${url}${basePath}/api/s/default/stat/sta`, { headers, agent });
|
||||||
|
const clientData = (await clientRes.json()).data || [];
|
||||||
|
|
||||||
|
for (const client of clientData) {
|
||||||
|
const clientSlug = `unifi-client-${client.mac.replace(/:/g, '')}`;
|
||||||
|
resources.push({
|
||||||
|
kind: 'host', // Or unmanaged_device initially
|
||||||
|
name: client.hostname || client.name || client.mac,
|
||||||
|
slug: clientSlug,
|
||||||
|
metadata: {
|
||||||
|
interfaces: [{ mac: client.mac, ip: client.ip }]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If we know which switch/AP it's on
|
||||||
|
if (client.ap_mac) {
|
||||||
|
const apSlug = `unifi-device-${client.ap_mac.replace(/:/g, '')}`;
|
||||||
|
edges.push({ parentSlug: apSlug, childSlug: clientSlug, relation: 'connected_to' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
},
|
||||||
|
|
||||||
|
// Generalized plugin contract alias for `discover`. See proxmox.js for why
|
||||||
|
// this references module.exports rather than `this`.
|
||||||
|
run: async (config) => module.exports.discover(config)
|
||||||
|
};
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'twilio',
|
||||||
|
category: 'messaging',
|
||||||
|
name: 'Twilio SMS',
|
||||||
|
description: 'Send SMS messages (like 2FA codes) via Twilio.',
|
||||||
|
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
|
||||||
|
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
|
||||||
|
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
|
||||||
|
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (config, payload) => {
|
||||||
|
const { to, message } = payload;
|
||||||
|
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
|
||||||
|
|
||||||
|
const data = new URLSearchParams();
|
||||||
|
data.append('To', to);
|
||||||
|
data.append('From', config.fromNumber);
|
||||||
|
data.append('Body', message);
|
||||||
|
|
||||||
|
const postData = data.toString();
|
||||||
|
|
||||||
|
const options = {
|
||||||
|
hostname: 'api.twilio.com',
|
||||||
|
port: 443,
|
||||||
|
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
'Content-Length': Buffer.byteLength(postData)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve(JSON.parse(body));
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(postData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
const https = require('https');
|
||||||
|
const http = require('http');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
type: 'webhook',
|
||||||
|
category: 'messaging',
|
||||||
|
name: 'Universal REST Webhook',
|
||||||
|
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
|
||||||
|
|
||||||
|
configSchema: [
|
||||||
|
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
|
||||||
|
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
|
||||||
|
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
|
||||||
|
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
|
||||||
|
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
|
||||||
|
],
|
||||||
|
|
||||||
|
validate: async (config) => {
|
||||||
|
if (!config.url) return { ok: false, error: 'URL is required' };
|
||||||
|
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
|
||||||
|
try {
|
||||||
|
if (config.headers) JSON.parse(config.headers);
|
||||||
|
} catch (e) {
|
||||||
|
return { ok: false, error: 'Headers must be valid JSON' };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
|
||||||
|
sendMessage: async (config, payload) => {
|
||||||
|
const { to, message } = payload;
|
||||||
|
let payloadStr = config.payloadTemplate || '{}';
|
||||||
|
|
||||||
|
// Replace template variables safely
|
||||||
|
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
|
||||||
|
|
||||||
|
// If there is an API secret, replace {{secret}} in the headers or url
|
||||||
|
let headersObj = {};
|
||||||
|
if (config.headers) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(config.headers);
|
||||||
|
for (const [k, v] of Object.entries(parsed)) {
|
||||||
|
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headersObj['Content-Type']) {
|
||||||
|
headersObj['Content-Type'] = 'application/json';
|
||||||
|
}
|
||||||
|
|
||||||
|
const urlObj = new URL(config.url);
|
||||||
|
const options = {
|
||||||
|
hostname: urlObj.hostname,
|
||||||
|
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
|
||||||
|
path: urlObj.pathname + urlObj.search,
|
||||||
|
method: config.method || 'POST',
|
||||||
|
headers: headersObj
|
||||||
|
};
|
||||||
|
|
||||||
|
const client = urlObj.protocol === 'https:' ? https : http;
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = client.request(options, (res) => {
|
||||||
|
let body = '';
|
||||||
|
res.on('data', chunk => body += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
resolve({ status: res.statusCode, body });
|
||||||
|
} else {
|
||||||
|
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(payloadStr);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,12 @@ nav.navbar{
|
|||||||
padding-right: 1em;
|
padding-right: 1em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||||
|
.top-nav a.active{
|
||||||
|
font-weight: bold;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -615,9 +615,10 @@ app.util = (function(app){
|
|||||||
// Reveal every .group-required-<cn> element the current user's groups entitle
|
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||||
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
// them to. Elements carrying .group-required start hidden (styles.css), so a
|
||||||
// user who is in no groups — or who isn't logged in — simply never sees them.
|
// user who is in no groups — or who isn't logged in — simply never sees them.
|
||||||
|
// The synthetic 'login' group is special: it's true for any authenticated user.
|
||||||
app.auth.applyGroupVisibility = function(user){
|
app.auth.applyGroupVisibility = function(user){
|
||||||
var groups = app.auth.groupCNs(user);
|
var groups = app.auth.groupCNs(user);
|
||||||
if(!groups.length) return;
|
var isLoggedIn = !!user;
|
||||||
|
|
||||||
var style = document.getElementById('group-required-rules');
|
var style = document.getElementById('group-required-rules');
|
||||||
if(!style){
|
if(!style){
|
||||||
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
|
|||||||
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
// A group whose CN isn't a usable CSS identifier just gates nothing.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The 'login' group is synthetic — it means "any authenticated user".
|
||||||
|
// Reveal .group-required-login for any logged-in user.
|
||||||
|
if(isLoggedIn){
|
||||||
|
try{
|
||||||
|
style.sheet.insertRule(
|
||||||
|
`.group-required-login { display: revert !important; }`,
|
||||||
|
style.sheet.cssRules.length
|
||||||
|
);
|
||||||
|
}catch(error){
|
||||||
|
// Ignore CSS escape errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
$( document ).ready(async function(){
|
$( document ).ready(async function(){
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
# --- Configuration ---
|
||||||
|
# In a real environment, these would be derived from the script's download URL
|
||||||
|
# or passed as additional arguments. For now, we use the most recent release.
|
||||||
|
BINARY_URL="${BINARY_URL:-}"
|
||||||
|
CONFIG_DIR="/etc/theta42"
|
||||||
|
CONFIG_FILE="$CONFIG_DIR/agent.yml"
|
||||||
|
BIN_PATH="/usr/local/bin/theta-agent"
|
||||||
|
SERVICE_FILE="/etc/systemd/system/theta-agent.service"
|
||||||
|
|
||||||
|
# Colors for output
|
||||||
|
RED='\033[0;31m'
|
||||||
|
GREEN='\033[0;32m'
|
||||||
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
|
log() { echo -e "${GREEN}[+]${NC} $1"; }
|
||||||
|
error() { echo -e "${RED}[!]${NC} $1"; exit 1; }
|
||||||
|
|
||||||
|
# 1. Root check
|
||||||
|
if [ "$EUID" -ne 0 ]; then
|
||||||
|
error "This script must be run as root."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 2. Argument Parsing
|
||||||
|
URL=""
|
||||||
|
TOKEN=""
|
||||||
|
B64_CONFIG=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case $1 in
|
||||||
|
--url)
|
||||||
|
URL="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--token)
|
||||||
|
TOKEN="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
B64_CONFIG="$1"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Validation
|
||||||
|
if [ -z "$B64_CONFIG" ] && [ -z "$URL" ] || [ -z "$B64_CONFIG" ] && [ -z "$TOKEN" ]; then
|
||||||
|
error "Missing required configuration. Either provide a base64 encoded config, or both --url and --token."
|
||||||
|
echo "Usage examples:"
|
||||||
|
echo " sh install.sh \"BASE64_CONFIG\""
|
||||||
|
echo " sh install.sh --url \"https://sso.local\" --token \"secret-token\""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 3. Resolve binary URL dynamically if not specified
|
||||||
|
if [ -z "$BINARY_URL" ]; then
|
||||||
|
if [ -n "$URL" ]; then
|
||||||
|
BINARY_URL="${URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
elif [ -n "$B64_CONFIG" ]; then
|
||||||
|
EXTRACTED_URL=$(echo "$B64_CONFIG" | base64 -d 2>/dev/null | grep -E '^\s*server_url:' | awk -F'"' '{print $2}' | tr -d ' ' || true)
|
||||||
|
if [ -n "$EXTRACTED_URL" ]; then
|
||||||
|
HTTP_URL=$(echo "$EXTRACTED_URL" | sed -e 's/^wss:\/\//https:\/\//' -e 's/^ws:\/\//http:\/\//')
|
||||||
|
BINARY_URL="${HTTP_URL%/}/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
if [ -z "$BINARY_URL" ]; then
|
||||||
|
BINARY_URL="https://sso.example.com/resources/theta-agent/theta-agent-linux-amd64"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "Downloading binary from $BINARY_URL..."
|
||||||
|
curl -fsSL "$BINARY_URL" -o "$BIN_PATH" || error "Failed to download binary."
|
||||||
|
chmod +x "$BIN_PATH"
|
||||||
|
|
||||||
|
# 4. Setup configuration
|
||||||
|
log "Preparing configuration directory $CONFIG_DIR..."
|
||||||
|
mkdir -p "$CONFIG_DIR"
|
||||||
|
chmod 755 "$CONFIG_DIR"
|
||||||
|
|
||||||
|
if [ -n "$B64_CONFIG" ]; then
|
||||||
|
log "Decoding and writing configuration from base64..."
|
||||||
|
echo "$B64_CONFIG" | base64 -d > "$CONFIG_FILE" || error "Failed to decode base64 configuration."
|
||||||
|
else
|
||||||
|
log "Generating minimal configuration from arguments..."
|
||||||
|
# Create a minimal yaml with the provided URL and Token
|
||||||
|
cat <<EOF > "$CONFIG_FILE"
|
||||||
|
server_url: "$URL"
|
||||||
|
auth_token: "$TOKEN"
|
||||||
|
location: "unknown"
|
||||||
|
capabilities:
|
||||||
|
telemetry: true
|
||||||
|
configure_ldap: false
|
||||||
|
reboot: false
|
||||||
|
service_control: []
|
||||||
|
arbitrary_bash: false
|
||||||
|
EOF
|
||||||
|
fi
|
||||||
|
chmod 600 "$CONFIG_FILE"
|
||||||
|
|
||||||
|
# 5. Setup systemd service
|
||||||
|
log "Creating systemd service unit..."
|
||||||
|
cat <<EOF > "$SERVICE_FILE"
|
||||||
|
[Unit]
|
||||||
|
Description=Theta Agent Unified Endpoint Management
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=$BIN_PATH
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=syslog
|
||||||
|
StandardError=syslog
|
||||||
|
SyslogIdentifier=theta-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 6. Start the agent
|
||||||
|
log "Enabling and starting Theta Agent..."
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable theta-agent
|
||||||
|
systemctl start theta-agent
|
||||||
|
|
||||||
|
log "Theta Agent installation complete!"
|
||||||
|
log "Verify status with: systemctl status theta-agent"
|
||||||
|
log "Check logs with: journalctl -u theta-agent -f"
|
||||||
Binary file not shown.
@@ -0,0 +1,266 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests. Mounted at /api/access-requests (app.js).
|
||||||
|
//
|
||||||
|
// The loop this closes: a user browses the catalog, finds something they cannot
|
||||||
|
// reach, asks for it; the resource's owner (or a directory admin) approves; the
|
||||||
|
// approval performs the LDAP group add. LDAP stays the access-control truth --
|
||||||
|
// this router never invents a permission, it only automates the group add an
|
||||||
|
// admin would otherwise do by hand, and records who decided.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
const { AccessRequest, STATUS } = require('../models/access_request');
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { Mail } = require('../models/email');
|
||||||
|
const { groupCns } = require('../utils/user_groups');
|
||||||
|
const { envelope, projectResource } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
const DIRECTORY_ADMIN_GROUPS = ['app_sso_directory_admin', 'app_sso_admin', 'app_super_admin'];
|
||||||
|
|
||||||
|
function httpError(status, message) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// May `user` decide requests against `resource`? The resource's own owner is
|
||||||
|
// the primary approver -- that is the point of Resource.owner -- with directory
|
||||||
|
// admins as the catch-all so an unowned or orphaned resource is never stuck.
|
||||||
|
async function canDecide(user, resource, callerGroups) {
|
||||||
|
if (resource && resource.owner && resource.owner === user.uid) return true;
|
||||||
|
return callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group that satisfies a request for this resource. Prefers an explicit
|
||||||
|
// choice, else the `member`-level link (the "just let me use it" group) over an
|
||||||
|
// `owner`-level one -- requesting a resource should never silently escalate to
|
||||||
|
// its admin group.
|
||||||
|
async function resolveGroupCn(resourceId, requested) {
|
||||||
|
const links = await ResourceGroup.list({ where: { resourceId } });
|
||||||
|
if (!links.length) {
|
||||||
|
throw httpError(409, 'This resource has no access group linked, so it cannot be requested.');
|
||||||
|
}
|
||||||
|
if (requested) {
|
||||||
|
const match = links.find(l => l.groupCn === requested);
|
||||||
|
if (!match) throw httpError(400, `"${requested}" is not an access group for this resource.`);
|
||||||
|
return match.groupCn;
|
||||||
|
}
|
||||||
|
const member = links.find(l => l.accessLevel === 'member');
|
||||||
|
return (member || links[0]).groupCn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort notification. A mail failure must never fail the request itself --
|
||||||
|
// the row is the source of truth and the approver can find it in the UI.
|
||||||
|
async function notify(uid, subject, message) {
|
||||||
|
try {
|
||||||
|
const user = await User.get({ uid });
|
||||||
|
if (!user || !user.mail) return;
|
||||||
|
await Mail.sendTemplate(user.mail, 'notification', {
|
||||||
|
givenName: user.givenName || uid,
|
||||||
|
subject,
|
||||||
|
message,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`access-request: notification to ${uid} failed:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/access-requests { slug | resourceId, groupCn?, note? }
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (req.user.isMachine) throw httpError(403, 'Machine accounts cannot request access.');
|
||||||
|
|
||||||
|
let resource;
|
||||||
|
if (req.body.slug) {
|
||||||
|
const found = await Resource.list({ where: { slug: req.body.slug } });
|
||||||
|
resource = found[0];
|
||||||
|
} else if (req.body.resourceId) {
|
||||||
|
resource = await Resource.get(req.body.resourceId);
|
||||||
|
}
|
||||||
|
if (!resource) throw httpError(404, 'Resource not found');
|
||||||
|
|
||||||
|
const md = resource.metadata || {};
|
||||||
|
// Opt-out, not opt-in: everything in the catalog is requestable unless an
|
||||||
|
// admin has explicitly marked it otherwise.
|
||||||
|
if (md.requestable === false) {
|
||||||
|
throw httpError(409, 'This resource is not available for self-service requests.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupCn = await resolveGroupCn(resource.id, req.body.groupCn);
|
||||||
|
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (callerGroups.includes(groupCn)) {
|
||||||
|
throw httpError(409, 'You already have access to this resource.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await AccessRequest.findOpen(req.user.uid, groupCn);
|
||||||
|
if (existing) throw httpError(409, 'You already have a pending request for this resource.');
|
||||||
|
|
||||||
|
const request = await AccessRequest.create({
|
||||||
|
uid: req.user.uid,
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn,
|
||||||
|
status: STATUS.PENDING,
|
||||||
|
note: req.body.note || '',
|
||||||
|
requestedOn: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resource.owner) {
|
||||||
|
await notify(
|
||||||
|
resource.owner,
|
||||||
|
`Access request: ${resource.name}`,
|
||||||
|
`<p><strong>${req.user.uid}</strong> has requested access to <strong>${resource.name}</strong> (group <code>${groupCn}</code>).</p>` +
|
||||||
|
(req.body.note ? `<p>Their note: ${req.body.note}</p>` : '') +
|
||||||
|
`<p>Review it on the Directory page.</p>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(envelope(request));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/access-requests/mine — the caller's own request history.
|
||||||
|
router.get('/mine', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const rows = await AccessRequest.listForUser(req.user.uid);
|
||||||
|
res.json(envelope(await decorate(rows)));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/access-requests — pending requests the caller may decide.
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
const isAdmin = callerGroups.some(g => DIRECTORY_ADMIN_GROUPS.includes(g));
|
||||||
|
const pending = await AccessRequest.listPending();
|
||||||
|
|
||||||
|
let visible = pending;
|
||||||
|
if (!isAdmin) {
|
||||||
|
// A plain resource owner sees only requests against resources they own.
|
||||||
|
const owned = await Resource.list({ where: { owner: req.user.uid } });
|
||||||
|
const ownedIds = new Set(owned.map(r => r.id));
|
||||||
|
visible = pending.filter(r => ownedIds.has(r.resourceId));
|
||||||
|
}
|
||||||
|
res.json(envelope(await decorate(visible)));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach the resource name/slug each row refers to. The UI needs it on every
|
||||||
|
// list and would otherwise issue one lookup per row.
|
||||||
|
async function decorate(rows) {
|
||||||
|
if (!rows.length) return [];
|
||||||
|
const resources = await Resource.list();
|
||||||
|
const byId = new Map(resources.map(r => [r.id, r]));
|
||||||
|
return rows.map(row => {
|
||||||
|
const data = row.toJSON ? row.toJSON() : { ...row };
|
||||||
|
const resource = byId.get(data.resourceId);
|
||||||
|
data.resource = resource
|
||||||
|
? { id: resource.id, name: resource.name, slug: resource.slug, kind: resource.kind }
|
||||||
|
: null;
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/access-requests/:id/approve { decisionNote? }
|
||||||
|
router.post('/:id/approve', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await Resource.get(request.resourceId);
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||||
|
throw httpError(403, 'You do not have permission to decide this request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// The LDAP write happens FIRST and is allowed to throw. Marking a request
|
||||||
|
// approved without the group add would show the user a grant they do not
|
||||||
|
// actually have -- a pending row is recoverable, a lying one is not.
|
||||||
|
const group = await Group.get(request.groupCn);
|
||||||
|
const user = await User.get({ uid: request.uid });
|
||||||
|
try {
|
||||||
|
await group.addMember(user);
|
||||||
|
} catch (err) {
|
||||||
|
// "already a member" is the goal state, not a failure. This happens
|
||||||
|
// routinely: groupOfNames requires at least one member, so creating a
|
||||||
|
// resource seeds its auto-created groups with the creator's DN, and an
|
||||||
|
// admin may also grant access by hand while a request sits pending.
|
||||||
|
// Without this the request would 500 and stay pending forever.
|
||||||
|
const alreadyMember = err.name === 'TypeOrValueExistsError' || err.code === 20;
|
||||||
|
if (!alreadyMember) throw err;
|
||||||
|
}
|
||||||
|
User.clearCache(); // membership feeds cached isAdmin / group-gated nav
|
||||||
|
|
||||||
|
const updated = await request.update({
|
||||||
|
status: STATUS.APPROVED,
|
||||||
|
decidedBy: req.user.uid,
|
||||||
|
decidedOn: Date.now(),
|
||||||
|
decisionNote: req.body.decisionNote || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notify(
|
||||||
|
request.uid,
|
||||||
|
`Access approved: ${resource ? resource.name : request.groupCn}`,
|
||||||
|
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was approved by ${req.user.uid}.</p>` +
|
||||||
|
`<p>You may need to sign out and back in for the change to take effect everywhere.</p>`
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/access-requests/:id/deny { decisionNote? }
|
||||||
|
router.post('/:id/deny', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resource = await Resource.get(request.resourceId);
|
||||||
|
const callerGroups = await groupCns(req.user);
|
||||||
|
if (!(await canDecide(req.user, resource, callerGroups))) {
|
||||||
|
throw httpError(403, 'You do not have permission to decide this request.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await request.update({
|
||||||
|
status: STATUS.DENIED,
|
||||||
|
decidedBy: req.user.uid,
|
||||||
|
decidedOn: Date.now(),
|
||||||
|
decisionNote: req.body.decisionNote || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
await notify(
|
||||||
|
request.uid,
|
||||||
|
`Access request declined: ${resource ? resource.name : request.groupCn}`,
|
||||||
|
`<p>Your request for <strong>${resource ? resource.name : request.groupCn}</strong> was declined.</p>` +
|
||||||
|
(req.body.decisionNote ? `<p>Reason: ${req.body.decisionNote}</p>` : '')
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/access-requests/:id — requester withdraws their own pending request.
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const request = await AccessRequest.get(req.params.id);
|
||||||
|
if (!request) throw httpError(404, 'Request not found');
|
||||||
|
if (request.uid !== req.user.uid) {
|
||||||
|
throw httpError(403, 'You can only withdraw your own requests.');
|
||||||
|
}
|
||||||
|
if (request.status !== STATUS.PENDING) {
|
||||||
|
throw httpError(409, `This request was already ${request.status}.`);
|
||||||
|
}
|
||||||
|
const updated = await request.update({ status: STATUS.CANCELLED, decidedOn: Date.now() });
|
||||||
|
res.json(envelope(updated));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const agentManager = require('../utils/agent_manager');
|
||||||
|
const agentKeys = require('../utils/agent_keys');
|
||||||
|
const { Agent, AgentJoinKey } = require('../models/agent');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
|
||||||
|
// Commands that can change or run code on the host. They are signed with the
|
||||||
|
// SSO's persisted Ed25519 key and the agent verifies against the key pinned in
|
||||||
|
// its agent.yml.
|
||||||
|
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
|
||||||
|
|
||||||
|
// ── REST API (mounted synchronously in app.js, BEFORE the 404 catch-all) ──
|
||||||
|
// This is a plain Express Router exported directly so app.js can
|
||||||
|
// `app.use('/api/agent', require('./routes/api_agent'))` at require time. It
|
||||||
|
// must NOT be mounted from the onListen hook (which runs after the 404
|
||||||
|
// catch-all is already on the stack): a router registered behind that terminal
|
||||||
|
// handler would make every /api/agent/* request 404, no matter the WS server
|
||||||
|
// state. The WebSocket handler is separate (initAgentWebSockets below) and is
|
||||||
|
// the only part that needs the post-listen onListen hook.
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Structured audit line for anything that reaches a host. The agent channel can
|
||||||
|
// run arbitrary bash, so "who told which host to do what" has to be recoverable
|
||||||
|
// after the fact; previously nothing was recorded at all.
|
||||||
|
function logAgentAudit(action, details) {
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
component: 'agent',
|
||||||
|
action,
|
||||||
|
...details
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The agent WebSocket (/api/agent/ws) authenticates its own token against the
|
||||||
|
// Agent table (see initAgentWebSockets). These REST routes are admin-facing, so
|
||||||
|
// they're auth + admin gated.
|
||||||
|
router.use(middleware.auth);
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||||
|
next();
|
||||||
|
} catch (err) {
|
||||||
|
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||||
|
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Fleet ---
|
||||||
|
router.get('/nodes', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keyStatus = agentKeys.status();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
agents: await agentManager.listAgents(),
|
||||||
|
// Base64 of the raw 32-byte key: what goes into agent.yml's `public_key`.
|
||||||
|
publicKey: await agentManager.publicKeyBase64(),
|
||||||
|
publicKeyPem: await agentManager.publicKeyPem(),
|
||||||
|
signingAvailable: agentKeys.status().available,
|
||||||
|
signingError: keyStatus.error || null
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Enrollment ---
|
||||||
|
// The token is minted HERE, not in the browser. It is returned exactly once;
|
||||||
|
// only its hash is stored, so it cannot be recovered afterwards -- rotate to
|
||||||
|
// get a new one.
|
||||||
|
router.post('/enroll', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, resourceId, description } = req.body || {};
|
||||||
|
if (!name || !String(name).trim()) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'name is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourceId) {
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const resource = await Resource.get(resourceId);
|
||||||
|
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
|
||||||
|
if (resource.kind !== 'host') {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { agent, token } = await Agent.enroll({
|
||||||
|
name: String(name).trim(),
|
||||||
|
description,
|
||||||
|
resourceId: resourceId || null,
|
||||||
|
enrolledBy: req.user.uid
|
||||||
|
});
|
||||||
|
|
||||||
|
logAgentAudit('enroll', { actor: req.user.uid, agentId: agent.id, agentName: agent.name, resourceId: resourceId || null });
|
||||||
|
|
||||||
|
const publicKey = await agentManager.publicKeyBase64();
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
agent: agent.toPublic(agentManager.liveState(agent.id)),
|
||||||
|
// Shown once. The UI must make that clear.
|
||||||
|
token,
|
||||||
|
publicKey,
|
||||||
|
signingAvailable: agentKeys.status().available
|
||||||
|
});
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.put('/nodes/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
|
||||||
|
const patch = {};
|
||||||
|
if (req.body.name !== undefined) patch.name = req.body.name;
|
||||||
|
if (req.body.description !== undefined) patch.description = req.body.description;
|
||||||
|
if (req.body.resourceId !== undefined) {
|
||||||
|
if (req.body.resourceId) {
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const resource = await Resource.get(req.body.resourceId);
|
||||||
|
if (!resource) return res.status(400).json({ status: 'error', message: 'resourceId does not exist' });
|
||||||
|
if (resource.kind !== 'host') {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'an agent can only be bound to a host resource' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch.resourceId = req.body.resourceId || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await agent.update(patch);
|
||||||
|
logAgentAudit('update', { actor: req.user.uid, agentId: agent.id, fields: Object.keys(patch) });
|
||||||
|
res.json({ status: 'ok', agent: updated.toPublic(agentManager.liveState(agent.id)) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revoke: the token stops authenticating immediately and any live socket is
|
||||||
|
// dropped, so revocation takes effect without waiting for a reconnect.
|
||||||
|
router.post('/nodes/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
await agent.update({ revoked: true });
|
||||||
|
agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
|
||||||
|
logAgentAudit('revoke', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/nodes/:id/rotate', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
const token = await agent.rotateToken();
|
||||||
|
// The old token is dead the moment it is replaced; drop the socket that was
|
||||||
|
// using it so the agent reconnects with the new one.
|
||||||
|
agentManager.disconnect(agent.id, 4004, 'Token rotated');
|
||||||
|
logAgentAudit('rotate', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok', token, publicKey: await agentManager.publicKeyBase64() });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/nodes/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
agentManager.disconnect(agent.id, 4003, 'Enrollment deleted');
|
||||||
|
await agent.delete();
|
||||||
|
logAgentAudit('delete', { actor: req.user.uid, agentId: agent.id, agentName: agent.name });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Join keys ---
|
||||||
|
// One key an operator hands out; hosts that present it enroll themselves and
|
||||||
|
// are immediately issued their own per-agent token. Listing never returns the
|
||||||
|
// key itself -- only its prefix and usage.
|
||||||
|
router.get('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const keys = await AgentJoinKey.list();
|
||||||
|
res.json({ status: 'ok', joinKeys: keys.map(k => k.toPublic()) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { label, expiresInDays } = req.body || {};
|
||||||
|
const { key, raw } = await AgentJoinKey.issue({
|
||||||
|
label: (label && String(label).trim()) || 'default',
|
||||||
|
createdBy: req.user.uid,
|
||||||
|
expiresInDays: expiresInDays ? Number(expiresInDays) : null
|
||||||
|
});
|
||||||
|
logAgentAudit('join_key_issued', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Shown once; only the hash is stored.
|
||||||
|
res.json({ status: 'ok', joinKey: key.toPublic(), key: raw });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/join-keys/:id/revoke', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.update({ revoked: true });
|
||||||
|
logAgentAudit('join_key_revoked', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
// Agents already enrolled keep working -- they hold their own tokens now,
|
||||||
|
// which is the whole point of exchanging the join key rather than using it
|
||||||
|
// as the long-term credential.
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/join-keys/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const key = await AgentJoinKey.get(req.params.id);
|
||||||
|
if (!key) return res.status(404).json({ status: 'error', message: 'join key not found' });
|
||||||
|
await key.delete();
|
||||||
|
logAgentAudit('join_key_deleted', { actor: req.user.uid, label: key.label, keyPrefix: key.keyPrefix });
|
||||||
|
res.json({ status: 'ok' });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Commands ---
|
||||||
|
// Addressed by agent id, not by token: a token is a credential and has no
|
||||||
|
// business travelling in a URL, being logged, or sitting in browser history.
|
||||||
|
router.post('/nodes/:id/command', async (req, res, next) => {
|
||||||
|
const { command, payload, isHighRisk } = req.body || {};
|
||||||
|
if (!command) {
|
||||||
|
return res.status(400).json({ status: 'error', message: 'Command type is required' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get(req.params.id);
|
||||||
|
if (!agent) return res.status(404).json({ status: 'error', message: 'agent not found' });
|
||||||
|
if (agent.revoked) return res.status(403).json({ status: 'error', message: 'agent enrollment is revoked' });
|
||||||
|
|
||||||
|
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
|
||||||
|
const msg = await agentManager.sendCommand(agent, command, payload || {}, requiresSigning);
|
||||||
|
|
||||||
|
logAgentAudit('command', {
|
||||||
|
actor: req.user.uid,
|
||||||
|
agentId: agent.id,
|
||||||
|
agentName: agent.name,
|
||||||
|
resourceId: agent.resourceId || null,
|
||||||
|
command,
|
||||||
|
signed: requiresSigning
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ status: 'ok', sentMessage: msg });
|
||||||
|
} catch (err) {
|
||||||
|
logAgentAudit('command_failed', { actor: req.user && req.user.uid, agentId: req.params.id, command, error: err.message });
|
||||||
|
res.status(400).json({ status: 'error', message: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.HIGH_RISK_COMMANDS = HIGH_RISK_COMMANDS;
|
||||||
|
|
||||||
|
module.exports.initAgentWebSockets = function initAgentWebSockets(app) {
|
||||||
|
// WebSocket handler only needs the WS server; runs from the onListen hook.
|
||||||
|
if (!app.wss) return;
|
||||||
|
|
||||||
|
// Warm the signing key at boot so a misconfigured OpenBao policy is a loud
|
||||||
|
// startup error rather than a surprise the first time someone reboots a host.
|
||||||
|
agentKeys.load().then(keys => {
|
||||||
|
if (!keys) console.error(`[Theta Agent] signing key unavailable — high-risk commands will be refused. ${agentKeys.status().error || ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.wss.on('connection', async (ws, req) => {
|
||||||
|
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||||
|
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||||
|
const remoteAddr = req.socket.remoteAddress;
|
||||||
|
|
||||||
|
// Authenticate BEFORE doing anything else: no registration, no welcome
|
||||||
|
// payload, no acknowledgement that the token was close. Until this passes
|
||||||
|
// the peer is an anonymous stranger, and the old code treated it as a
|
||||||
|
// trusted node purely for presenting a non-empty string.
|
||||||
|
let agent = null;
|
||||||
|
let issuedToken = null; // set when this connection auto-enrolled
|
||||||
|
try {
|
||||||
|
agent = await Agent.authenticate(token);
|
||||||
|
|
||||||
|
// Not a known agent token -- try it as a join key. This is what makes
|
||||||
|
// "install the agent with a key and the host appears" work without an
|
||||||
|
// admin pre-registering every machine. The join key is exchanged for a
|
||||||
|
// per-agent token below, so it never becomes the host's long-term
|
||||||
|
// credential.
|
||||||
|
if (!agent) {
|
||||||
|
const joinKey = await AgentJoinKey.authenticate(token);
|
||||||
|
if (joinKey) {
|
||||||
|
const hostname = (url.searchParams.get('hostname') || '').trim();
|
||||||
|
const enrolled = await Agent.enroll({
|
||||||
|
name: hostname || `agent-${Date.now().toString(36)}`,
|
||||||
|
description: `Self-enrolled with join key ${joinKey.keyPrefix}`,
|
||||||
|
enrolledBy: `join-key:${joinKey.label}`
|
||||||
|
});
|
||||||
|
agent = enrolled.agent;
|
||||||
|
issuedToken = enrolled.token;
|
||||||
|
await joinKey.update({
|
||||||
|
use_count: (joinKey.use_count || 0) + 1,
|
||||||
|
last_used_on: Math.floor(Date.now() / 1000)
|
||||||
|
}).catch(() => {});
|
||||||
|
logAgentAudit('join', {
|
||||||
|
agentId: agent.id, agentName: agent.name, remoteAddr,
|
||||||
|
joinKeyLabel: joinKey.label, joinKeyPrefix: joinKey.keyPrefix
|
||||||
|
});
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" self-enrolled with join key ${joinKey.keyPrefix}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Theta Agent] authentication lookup failed:', err.message);
|
||||||
|
try { ws.close(1011, 'Authentication unavailable'); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
// Deliberately indistinguishable for unknown vs revoked vs missing: a
|
||||||
|
// caller probing tokens learns nothing about which part was wrong.
|
||||||
|
logAgentAudit('auth_rejected', { remoteAddr, tokenPrefix: token ? String(token).slice(0, 8) : null });
|
||||||
|
try { ws.close(4001, 'Unauthorized'); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) connected from ${remoteAddr}`);
|
||||||
|
logAgentAudit('connected', { agentId: agent.id, agentName: agent.name, remoteAddr });
|
||||||
|
// Must stay synchronous, and the listeners below must be attached in this
|
||||||
|
// same tick: the agent sends `discovery` the instant the socket opens, and
|
||||||
|
// `ws` discards messages emitted while no listener is attached.
|
||||||
|
agentManager.registerAgent(agent, ws, remoteAddr);
|
||||||
|
|
||||||
|
ws.on('message', async (message) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message);
|
||||||
|
if (!data || typeof data.type !== 'string') return;
|
||||||
|
|
||||||
|
// Re-read the row per message so a revoke mid-session takes effect on
|
||||||
|
// the next thing the agent says, not only on reconnect.
|
||||||
|
const current = await Agent.get(agent.id).catch(() => null);
|
||||||
|
if (!current || current.revoked) {
|
||||||
|
try { ws.close(4003, 'Enrollment revoked'); } catch (e) {}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = data.payload || {};
|
||||||
|
|
||||||
|
switch (data.type) {
|
||||||
|
case 'discovery':
|
||||||
|
await agentManager.handleDiscovery(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.discovery', { agentId: current.id, payload });
|
||||||
|
break;
|
||||||
|
case 'telemetry':
|
||||||
|
await agentManager.handleTelemetry(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.telemetry', { agentId: current.id, payload });
|
||||||
|
break;
|
||||||
|
case 'heartbeat':
|
||||||
|
await agentManager.handleHeartbeat(current, payload, ws);
|
||||||
|
break;
|
||||||
|
case 'response':
|
||||||
|
await agentManager.handleResponse(current, payload);
|
||||||
|
if (app.io) app.io.emit('agent.response', { agentId: current.id, payload });
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log(`[Theta Agent] Received message type '${data.type}' from ${current.id}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Theta Agent] Error handling message:', err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on('close', () => {
|
||||||
|
console.log(`[Theta Agent] "${agent.name}" (${agent.id}) disconnected`);
|
||||||
|
agentManager.unregisterAgent(agent.id, ws);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send initial welcome/config payload. When this connection enrolled via a
|
||||||
|
// join key it also carries the credentials the agent should persist and use
|
||||||
|
// from now on: its own token, and the public key it must pin to verify
|
||||||
|
// signed commands. Handing the public key over here is what removes the
|
||||||
|
// last manual step -- an agent installed with only a join key ends up fully
|
||||||
|
// configured without anyone copying values between two machines.
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
message: 'Connected to SSO Manager C2',
|
||||||
|
protocol_version: '1.2.0',
|
||||||
|
agent_id: agent.id
|
||||||
|
};
|
||||||
|
if (issuedToken) {
|
||||||
|
payload.enrolled = true;
|
||||||
|
payload.auth_token = issuedToken;
|
||||||
|
payload.public_key = await agentManager.publicKeyBase64();
|
||||||
|
}
|
||||||
|
ws.send(JSON.stringify({ type: 'config', payload }));
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
|
||||||
|
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
|
||||||
|
// mask-valued submission preserves the stored value so an admin editing an
|
||||||
|
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
|
||||||
|
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
|
||||||
|
const MASK = '********';
|
||||||
|
const SECRET_PATHS = [
|
||||||
|
['smtp', 'pass'],
|
||||||
|
['oauth', 'jwtSecret'],
|
||||||
|
['voipms', 'password'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function maskSecrets(obj) {
|
||||||
|
const out = JSON.parse(JSON.stringify(obj));
|
||||||
|
for (const [grp, key] of SECRET_PATHS) {
|
||||||
|
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const editable = maskSecrets({
|
||||||
|
smtp: conf.smtp || {},
|
||||||
|
discovery: conf.discovery || {},
|
||||||
|
oauth: conf.oauth || {},
|
||||||
|
voipms: conf.voipms || {}
|
||||||
|
});
|
||||||
|
res.json(editable);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shallow-per-key merge of `src` into the live conf object (matches the old
|
||||||
|
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
|
||||||
|
// so call-time conf readers see saved values without a restart).
|
||||||
|
function applyToLiveConf(src) {
|
||||||
|
if (!src) return;
|
||||||
|
for (const key of Object.keys(src)) {
|
||||||
|
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
|
||||||
|
conf[key] = { ...(conf[key] || {}), ...src[key] };
|
||||||
|
} else {
|
||||||
|
conf[key] = src[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||||
|
const incoming = req.body || {};
|
||||||
|
|
||||||
|
// Preserve secret fields the admin left blank (or left showing the mask):
|
||||||
|
// drop them from the incoming merge so the stored value survives. Only a
|
||||||
|
// genuinely new, non-blank, non-mask value overwrites.
|
||||||
|
for (const [grp, key] of SECRET_PATHS) {
|
||||||
|
if (incoming[grp] && incoming[grp][key] !== undefined) {
|
||||||
|
const submitted = incoming[grp][key];
|
||||||
|
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deep merge incoming into existing
|
||||||
|
for (const key of Object.keys(incoming)) {
|
||||||
|
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
|
||||||
|
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
|
||||||
|
} else {
|
||||||
|
existing[key] = incoming[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await baoConf.set('sso-manager/conf', existing);
|
||||||
|
// Reflect the saved values in the live conf immediately (the next boot's
|
||||||
|
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||||
|
// current without a restart, as the old conf_manager did). `existing`
|
||||||
|
// carries the preserved secret values, so live conf keeps them too.
|
||||||
|
applyToLiveConf(existing);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.get('/proxy', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const proxyConf = await baoConf.get('proxy/conf') || {};
|
||||||
|
const editable = JSON.parse(JSON.stringify(proxyConf));
|
||||||
|
if (editable.oidc && editable.oidc.clientSecret) editable.oidc.clientSecret = MASK;
|
||||||
|
if (editable.ldap && editable.ldap.bindPassword) editable.ldap.bindPassword = MASK;
|
||||||
|
res.json(editable);
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/proxy', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await baoConf.get('proxy/conf') || {};
|
||||||
|
const incoming = req.body || {};
|
||||||
|
|
||||||
|
if (incoming.oidc && incoming.oidc.clientSecret !== undefined) {
|
||||||
|
if (incoming.oidc.clientSecret === '' || incoming.oidc.clientSecret === MASK) delete incoming.oidc.clientSecret;
|
||||||
|
}
|
||||||
|
if (incoming.ldap && incoming.ldap.bindPassword !== undefined) {
|
||||||
|
if (incoming.ldap.bindPassword === '' || incoming.ldap.bindPassword === MASK) delete incoming.ldap.bindPassword;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(incoming)) {
|
||||||
|
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
|
||||||
|
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
|
||||||
|
} else {
|
||||||
|
existing[key] = incoming[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await baoConf.set('proxy/conf', existing);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a test email to verify SMTP configuration
|
||||||
|
router.post('/test-email', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to, subject, body } = req.body || {};
|
||||||
|
if (!to) {
|
||||||
|
return res.status(400).json({ error: 'Recipient email address is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the email model to send the test message
|
||||||
|
const Email = require('../models/email');
|
||||||
|
const testSubject = subject || 'SSO Manager Test Email';
|
||||||
|
const testBody = body || `<p>This is a test email from SSO Manager.</p><p>If you received this, your SMTP configuration is working correctly.</p><p>Sent at: ${new Date().toISOString()}</p>`;
|
||||||
|
|
||||||
|
await Email.send(to, testSubject, testBody);
|
||||||
|
res.json({ success: true, message: `Test email sent to ${to}` });
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send a test SMS to verify VoIP.ms configuration
|
||||||
|
router.post('/test-sms', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { to, message } = req.body || {};
|
||||||
|
if (!to) {
|
||||||
|
return res.status(400).json({ error: 'Recipient phone number is required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const voipmsConf = conf.voipms || {};
|
||||||
|
if (!voipmsConf.username || !voipmsConf.password || !voipmsConf.did) {
|
||||||
|
return res.status(400).json({ error: 'VoIP.ms credentials not configured. Please configure username, DID, and password in the SMS tab.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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.`;
|
||||||
|
|
||||||
|
// VoIP.ms SMS API endpoint
|
||||||
|
const voipmsApiUrl = 'https://api.voip.ms/v1.0';
|
||||||
|
const authHeader = Buffer.from(`${voipmsConf.username}:${voipmsConf.password}`).toString('base64');
|
||||||
|
|
||||||
|
const response = await fetch(`${voipmsApiUrl}/sms/send`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Basic ${authHeader}`,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded'
|
||||||
|
},
|
||||||
|
body: new URLSearchParams({
|
||||||
|
did: voipmsConf.did,
|
||||||
|
to: to,
|
||||||
|
message: testMessage
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.status === 'success') {
|
||||||
|
res.json({ success: true, message: `Test SMS sent to ${to}` });
|
||||||
|
} else {
|
||||||
|
res.status(400).json({ error: `VoIP.ms API error: ${result.message || 'Unknown error'}` });
|
||||||
|
}
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -3,8 +3,187 @@ const router = require('express').Router();
|
|||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
const { Group } = require('../models/group_ldap');
|
const { Group } = require('../models/group_ldap');
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { cnFromDn } = require('../utils/user_groups');
|
||||||
const { projectResources } = require('@simpleworkjs/directory-schema');
|
const { projectResources } = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
const SUPER_ADMIN_GROUP = permission.SUPER_ADMIN_GROUP;
|
||||||
|
const groups = require('../utils/groups');
|
||||||
|
|
||||||
|
// Make `childCn` a member of `parentCn`, i.e. everyone in the child is
|
||||||
|
// transitively in the parent. Idempotent and non-fatal: "already a member" is
|
||||||
|
// the goal state, and a missing group (e.g. god_admin absent on a
|
||||||
|
// directory seeded by an older entrypoint) is a reason to skip, not to fail the
|
||||||
|
// caller's real work.
|
||||||
|
async function nestGroup(childCn, parentCn) {
|
||||||
|
try {
|
||||||
|
const parent = await Group.get(parentCn);
|
||||||
|
const child = await Group.get(childCn);
|
||||||
|
if (await Group.wouldCycle(parentCn, child.dn)) {
|
||||||
|
console.error(`nestGroup: refusing ${childCn} -> ${parentCn} (would create a cycle)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await parent.addMember({ dn: child.dn });
|
||||||
|
} catch (err) {
|
||||||
|
const benign = err.name === 'TypeOrValueExistsError' || err.code === 20 || err.name === 'GroupNotFound';
|
||||||
|
if (!benign) console.error(`nestGroup: ${childCn} -> ${parentCn} failed:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Group-model provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
|
// The directory is the single place groups are created, as a projection of the
|
||||||
|
// resource graph. These helpers materialize the group-inheritance lattice for
|
||||||
|
// a resource so it exists in LDAP as well as in the resolver (utils/groups.js).
|
||||||
|
// All of them are idempotent, so calling them again for a resource a newer
|
||||||
|
// release is backfilling is a no-op.
|
||||||
|
|
||||||
|
// Map a directory resource kind onto a group-model kind (GROUPS.md §2).
|
||||||
|
// host -> host; service -> app (services/consoles are the group model's "apps");
|
||||||
|
// site gets site-level groups (handled separately); oauth/container get no
|
||||||
|
// per-resource groups (oauth clients hang off their owning service).
|
||||||
|
function groupKind(resource) {
|
||||||
|
if (resource.kind === 'host') return 'host';
|
||||||
|
if (resource.kind === 'service') return 'app';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a groupOfNames if it doesn't already exist. Idempotent; `ownerDn`
|
||||||
|
// seeds the mandatory first member. Returns true when created.
|
||||||
|
async function ensureGroup(name, ownerDn, description) {
|
||||||
|
try {
|
||||||
|
await Group.add({ name, owner: ownerDn, description });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
||||||
|
console.error(`ensureGroup: failed to create ${name}:`, err);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link a group to a resource only if that link doesn't already exist. The
|
||||||
|
// ResourceGroup table has no unique constraint on (resourceId, groupCn), so a
|
||||||
|
// naive create on every Directory self-heal (which runs ensureSiteGroups /
|
||||||
|
// provisionResourceGroups on each load) was accumulating duplicate links -- the
|
||||||
|
// "groups appear 3x under a resource" bug. Always check first.
|
||||||
|
async function ensureResourceGroup(resourceId, groupCn, accessLevel) {
|
||||||
|
const existing = await ResourceGroup.list({ where: { resourceId, groupCn } });
|
||||||
|
if (existing.length) return existing[0];
|
||||||
|
return ResourceGroup.create({ resourceId, groupCn, accessLevel });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the site-level groups + the aggregates the per-resource groups nest
|
||||||
|
// into. Idempotent -- called on every directory list so a site seeded by an
|
||||||
|
// older release gets its groups without a rebuild:
|
||||||
|
//
|
||||||
|
// god_admin -> {site}_super_admin
|
||||||
|
// {site}_super_admin -> {site}_hosts_admin, {site}_apps_admin
|
||||||
|
// {site}_hosts_admin -> {site}_hosts_access ; {site}_apps_admin -> {site}_apps_access
|
||||||
|
//
|
||||||
|
// `{site}_everyone` is created for completeness; it has implicit membership and
|
||||||
|
// is granted to a resource as a grantee, never enumerated.
|
||||||
|
async function ensureSiteGroups(siteSlug, ownerDn, siteName, siteResourceId) {
|
||||||
|
if (!siteSlug) return;
|
||||||
|
|
||||||
|
// Link a site group to the site resource (so it shows + is member-manageable
|
||||||
|
// on the site's modal). Idempotent. Admin groups link as owner; access/meta
|
||||||
|
// groups as member.
|
||||||
|
const link = async (cn, isAdmin) => {
|
||||||
|
if (!siteResourceId) return;
|
||||||
|
await ensureResourceGroup(siteResourceId, cn, isAdmin ? 'owner' : 'member');
|
||||||
|
};
|
||||||
|
|
||||||
|
const sAdmin = groups.siteSuperAdminCns(siteSlug);
|
||||||
|
await ensureGroup(sAdmin, ownerDn, `Site admin for ${siteName || siteSlug}`);
|
||||||
|
await link(sAdmin, true);
|
||||||
|
// The kind-scoped aggregates are CREATED here (per-resource groups nest into
|
||||||
|
// them), but are NOT linked to the site resource: a site carries only the god
|
||||||
|
// and site-wide groups (S_super_admin, S_everyone), per the user's model. The
|
||||||
|
// aggregates have no modal home; site-wide access is granted via S_super_admin
|
||||||
|
// and per-resource access via the host/app groups.
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'admin'), ownerDn, `Admin on all ${kind}s at ${siteSlug}`);
|
||||||
|
await ensureGroup(groups.aggregateGroupCns(siteSlug, kind, 'access'), ownerDn, `Access to all ${kind}s at ${siteSlug}`);
|
||||||
|
}
|
||||||
|
await ensureGroup(groups.siteEveryoneCns(siteSlug), ownerDn, `All users at ${siteSlug}`);
|
||||||
|
await link(groups.siteEveryoneCns(siteSlug), false);
|
||||||
|
// god_admin is the global group; surface it on the site modal so its members
|
||||||
|
// can be managed from the Directory (it has no home on a single resource).
|
||||||
|
await link(groups.GOD_ADMIN, true);
|
||||||
|
|
||||||
|
// Wire the lattice as nesting so LDAP-level consumers (SSSD, sudo, anything
|
||||||
|
// binding directly) resolve it transitively, not just utils/permission.js.
|
||||||
|
// nestGroup(child, parent) makes child a member of parent -- membership flows
|
||||||
|
// child -> parent ("up"), so a group's members inherit what its parents hold.
|
||||||
|
await nestGroup(groups.GOD_ADMIN, sAdmin); // god admins are site admins everywhere
|
||||||
|
for (const kind of ['host', 'app']) {
|
||||||
|
const aggAdmin = groups.aggregateGroupCns(siteSlug, kind, 'admin');
|
||||||
|
const aggAccess = groups.aggregateGroupCns(siteSlug, kind, 'access');
|
||||||
|
await nestGroup(sAdmin, aggAdmin); // site admins administer all hosts/apps
|
||||||
|
await nestGroup(aggAdmin, aggAccess); // site admin implies site access
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision the per-resource groups for a host/app and nest them into the site
|
||||||
|
// aggregates (so a site/aggregate admin reaches this resource by membership).
|
||||||
|
// Group names follow docs/GROUPS.md §2: `{site}_{kind}_{nameSlug}_{level}` where
|
||||||
|
// nameSlug is the resource name with the kind prefix stripped (`host_theta-env` ->
|
||||||
|
// `theta-env`). `kind` (host/app) both goes in the name and selects the aggregate:
|
||||||
|
//
|
||||||
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}_{slug}_access
|
||||||
|
// {site}_{kind}_{slug}_admin -> {site}_{kind}s_admin (aggregate)
|
||||||
|
// {site}_{kind}_{slug}_access -> {site}_{kind}s_access (aggregate)
|
||||||
|
// god_admin -> {site}_{kind}_{slug}_admin (global super admin)
|
||||||
|
async function provisionResourceGroups(resource, kind, siteSlug, ownerDn) {
|
||||||
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
|
const accessCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access');
|
||||||
|
const adminCn = groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin');
|
||||||
|
|
||||||
|
await ensureGroup(accessCn, ownerDn, `Access group for ${resource.name}`);
|
||||||
|
await ensureGroup(adminCn, ownerDn, `Admin group for ${resource.name}`);
|
||||||
|
|
||||||
|
// Link both groups to the resource so the Directory can show/revoke them.
|
||||||
|
await ensureResourceGroup(resource.id, accessCn, 'member');
|
||||||
|
await ensureResourceGroup(resource.id, adminCn, 'owner');
|
||||||
|
|
||||||
|
await nestGroup(adminCn, accessCn); // administering implies using
|
||||||
|
await nestGroup(adminCn, groups.aggregateGroupCns(siteSlug, kind, 'admin')); // aggregate admin reaches this resource
|
||||||
|
await nestGroup(accessCn, groups.aggregateGroupCns(siteSlug, kind, 'access')); // aggregate access reaches this resource
|
||||||
|
await nestGroup(SUPER_ADMIN_GROUP, adminCn); // global super admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// The group CNs it is valid to associate with a given resource (docs/GROUPS.md
|
||||||
|
// §2/§3). This is what "force the correct naming convention" means: a group
|
||||||
|
// linked to a resource must be one that parses for consumers -- the resource's
|
||||||
|
// own specific groups, its site's aggregates, site-level groups, or the global
|
||||||
|
// god_admin. Returns a Set of the fixed valid CNs plus a RegExp for opaque
|
||||||
|
// capability groups following the same shapes.
|
||||||
|
function validGroupCnsForResource(resource, siteSlug) {
|
||||||
|
const valid = new Set();
|
||||||
|
// A site resource only carries god_admin (added by the route) + the site-wide
|
||||||
|
// groups (S_super_admin, S_everyone). The kind-scoped host/app aggregates and
|
||||||
|
// specific groups belong to host/app resources, not to the site.
|
||||||
|
if (resource.kind === 'site') {
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_super_admin$|^${siteSlug}_everyone$`) };
|
||||||
|
}
|
||||||
|
const kind = groupKind(resource); // 'host'|'app'|null
|
||||||
|
if (kind) {
|
||||||
|
const nameSlug = groups.resourceNameSlug(resource.slug);
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'admin'));
|
||||||
|
valid.add(groups.resourceGroupCns(siteSlug, kind, nameSlug, 'access'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'admin'));
|
||||||
|
valid.add(groups.aggregateGroupCns(siteSlug, kind, 'access'));
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
valid.add(groups.siteEveryoneCns(siteSlug));
|
||||||
|
return { valid, capRe: new RegExp(`^${siteSlug}_${kind}_${nameSlug}_[a-z0-9-]+$|^${siteSlug}_${kind}s_[a-z0-9-]+$`) };
|
||||||
|
}
|
||||||
|
// oauth/container etc. — only the global god_admin makes sense to pin here.
|
||||||
|
valid.add(groups.siteSuperAdminCns(siteSlug));
|
||||||
|
return { valid, capRe: null };
|
||||||
|
}
|
||||||
|
|
||||||
// Require the admin group
|
// Require the admin group
|
||||||
router.use(async (req, res, next) => {
|
router.use(async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
@@ -18,9 +197,44 @@ router.use(async (req, res, next) => {
|
|||||||
// --- Resources ---
|
// --- Resources ---
|
||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
|
resources = resources.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
return !isAuto || isManaged;
|
||||||
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
|
|
||||||
|
// Self-heal the group model (docs/GROUPS.md): ensure every site has its
|
||||||
|
// site-level groups (S_super_admin, S_hosts_*, S_apps_*, S_everyone) + the
|
||||||
|
// aggregates, and every host/app resource has its per-resource groups nested
|
||||||
|
// into them. Idempotent, so this is a cheap no-op once present -- it's what
|
||||||
|
// backfills a directory seeded by an older release without a rebuild.
|
||||||
|
// Never fails the list.
|
||||||
|
const sites = resources.filter(r => r.kind === 'site');
|
||||||
|
await Promise.all(sites.map(site =>
|
||||||
|
ensureSiteGroups(site.slug, req.user.dn, site.name, site.id)
|
||||||
|
.catch(err => console.error(`ensureSiteGroups(${site.slug}) failed:`, err.message))
|
||||||
|
));
|
||||||
|
const siteByResource = new Map();
|
||||||
|
for (const site of sites) siteByResource.set(site.id, site.slug);
|
||||||
|
const siteOf = async (r) => {
|
||||||
|
const direct = siteByResource.get(r.id);
|
||||||
|
if (direct) return direct;
|
||||||
|
// findAncestorSiteSlug returns the site's full slug (`site_local`) -- the
|
||||||
|
// group-model builders take it verbatim, so do NOT strip the `site_` prefix.
|
||||||
|
return await Resource.findAncestorSiteSlug(r.id).catch(() => null);
|
||||||
|
};
|
||||||
|
await Promise.all(resources.map(async (r) => {
|
||||||
|
const gKind = groupKind(r);
|
||||||
|
if (!gKind) return;
|
||||||
|
const siteSlug = await siteOf(r);
|
||||||
|
if (!siteSlug) return;
|
||||||
|
await provisionResourceGroups(r, gKind, siteSlug, req.user.dn)
|
||||||
|
.catch(err => console.error(`provisionResourceGroups(${r.slug}) failed:`, err.message));
|
||||||
|
}));
|
||||||
|
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -66,29 +280,25 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
await ResourceEdge.create({ parentId: req.body.hostId, childId: r.id, relation: r.kind === 'oauth' ? 'oauth' : 'hosts' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (r.kind === 'host' || r.kind === 'service') {
|
// ── Group provisioning (docs/GROUPS.md) ───────────────────────────────
|
||||||
const siteSlug = await Resource.findAncestorSiteSlug(r.id);
|
// Materialize the group-model for the new resource. Site resources get the
|
||||||
const createGroup = async (suffix, accessLevel) => {
|
// site-level groups; host/app resources get their per-resource groups nested
|
||||||
const cn = siteSlug ? `${siteSlug}_${r.slug}_${suffix}` : `${r.slug}_${suffix}`;
|
// into the site aggregates. Idempotent -- safe for a resource created by an
|
||||||
try {
|
// older release. A provisioning failure must not fail resource creation: the
|
||||||
await Group.add({
|
// resource already exists and the groups are repairable (re-run ensures them).
|
||||||
name: cn,
|
//
|
||||||
owner: req.user.dn,
|
// `siteSlug` is the site resource's slug verbatim (`site_local`) -- the
|
||||||
description: `${suffix === 'admin' ? 'Admin' : 'Access'} group for ${r.name}`
|
// group-model builders treat it as opaque (docs/GROUPS.md §3) and re-apply
|
||||||
});
|
// the kind prefix themselves.
|
||||||
} catch (err) {
|
const gKind = groupKind(r);
|
||||||
if (err.name !== 'EntryAlreadyExistsError' && err.code !== 68) {
|
const ancestorSite = await Resource.findAncestorSiteSlug(r.id);
|
||||||
console.error(`Failed to create LDAP group ${cn}:`, err);
|
if (r.kind === 'site') {
|
||||||
}
|
await ensureSiteGroups(r.slug, req.user.dn, r.name, r.id);
|
||||||
}
|
} else if (gKind && ancestorSite) {
|
||||||
try {
|
await ensureSiteGroups(ancestorSite, req.user.dn, r.name); // backfill site tier if missing
|
||||||
await ResourceGroup.create({ resourceId: r.id, groupCn: cn, accessLevel });
|
await provisionResourceGroups(r, gKind, ancestorSite, req.user.dn);
|
||||||
} catch(err) { /* ignore duplicate links */ }
|
|
||||||
};
|
|
||||||
await createGroup('access', 'member');
|
|
||||||
await createGroup('admin', 'owner');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res.json({ results: r });
|
res.json({ results: r });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.name === 'SequelizeUniqueConstraintError') {
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
@@ -103,18 +313,8 @@ router.post('/resources', async (req, res, next) => {
|
|||||||
|
|
||||||
router.put('/resources/:id', async (req, res, next) => {
|
router.put('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
let r;
|
// Validate before loading anything -- a rejected body should never have
|
||||||
if (req.body.kind === 'oauth') {
|
// touched the store.
|
||||||
const { OAuthClient } = require('../models/oauth_client');
|
|
||||||
r = await OAuthClient.get(req.params.id);
|
|
||||||
} else {
|
|
||||||
r = await Resource.get(req.params.id);
|
|
||||||
}
|
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
|
||||||
|
|
||||||
req.body.updated_by = req.user.uid;
|
|
||||||
req.body.updated_on = Date.now();
|
|
||||||
|
|
||||||
if (req.body.kind === 'host' && !req.body.hostId) {
|
if (req.body.kind === 'host' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
return res.status(400).json({ error: 'Hosts must have a parent Site or Host' });
|
||||||
}
|
}
|
||||||
@@ -124,14 +324,20 @@ router.put('/resources/:id', async (req, res, next) => {
|
|||||||
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
if (req.body.kind === 'oauth' && !req.body.hostId) {
|
||||||
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
return res.status(400).json({ error: 'OAuth Integrations must have a parent Service' });
|
||||||
}
|
}
|
||||||
|
|
||||||
let updated;
|
// OAuthClient is a wrapper over the same `resource` row, but its .update()
|
||||||
if (req.body.kind === 'oauth') {
|
// handles the oauth-specific body fields (redirect_uris, scopes,
|
||||||
updated = await r.update(req.body);
|
// token_lifetime) that a bare Resource would drop into metadata unvalidated.
|
||||||
} else {
|
const { OAuthClient } = require('../models/oauth_client');
|
||||||
updated = await r.update(req.body);
|
const model = req.body.kind === 'oauth' ? OAuthClient : Resource;
|
||||||
}
|
const r = await model.get(req.params.id);
|
||||||
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
|
|
||||||
|
req.body.updated_by = req.user.uid;
|
||||||
|
req.body.updated_on = Date.now();
|
||||||
|
|
||||||
|
const updated = await r.update(req.body);
|
||||||
|
|
||||||
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
if ((updated.kind === 'host' || updated.kind === 'service' || updated.kind === 'oauth') && req.body.hostId !== undefined) {
|
||||||
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
const existingEdges = await ResourceEdge.list({ where: { childId: r.id } });
|
||||||
for (const e of existingEdges) {
|
for (const e of existingEdges) {
|
||||||
@@ -159,17 +365,32 @@ router.post('/resources/:id/rotate-secret', async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.post('/resources/:id/service-token', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { ServiceToken } = require('../models/token');
|
||||||
|
const token = await ServiceToken.issue(req.params.id, req.user.uid);
|
||||||
|
res.json({ results: { token: token.token } });
|
||||||
|
} catch (err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete('/resources/:id', async (req, res, next) => {
|
router.delete('/resources/:id', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const r = await Resource.get(req.params.id);
|
const r = await Resource.get(req.params.id);
|
||||||
if (!r) return res.status(404).json({ error: 'Not found' });
|
if (!r) return res.status(404).json({ error: 'Not found' });
|
||||||
await r.delete();
|
// Clear the dependents FIRST. There is no transaction here, so ordering is
|
||||||
// Also delete edges and groups involving this resource
|
// the only thing protecting us: if a dependent delete throws after the
|
||||||
|
// resource row is gone, the leftovers are edges/links pointing at a
|
||||||
|
// nonexistent id -- invisible in the UI and poisonous to getGraph(). Failing
|
||||||
|
// with the resource still present is the recoverable direction (retry the
|
||||||
|
// delete); the caller sees the error either way.
|
||||||
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
const edgesParent = await ResourceEdge.list({ where: { parentId: req.params.id } });
|
||||||
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
const edgesChild = await ResourceEdge.list({ where: { childId: req.params.id } });
|
||||||
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
const groups = await ResourceGroup.list({ where: { resourceId: req.params.id } });
|
||||||
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
for (const e of [...edgesParent, ...edgesChild]) await e.delete();
|
||||||
for (const g of groups) await g.delete();
|
for (const g of groups) await g.delete();
|
||||||
|
await r.delete();
|
||||||
res.json({ results: true });
|
res.json({ results: true });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -208,7 +429,29 @@ router.get('/groups', async (req, res, next) => {
|
|||||||
|
|
||||||
router.post('/groups', async (req, res, next) => {
|
router.post('/groups', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const g = await ResourceGroup.create(req.body);
|
const { resourceId, groupCn } = req.body;
|
||||||
|
if (!resourceId || !groupCn) return res.status(400).json({ error: 'resourceId and groupCn are required' });
|
||||||
|
|
||||||
|
// Enforce the group-model naming convention (docs/GROUPS.md §3). The CN must
|
||||||
|
// be a valid group for this resource; reject free-form names so the groups
|
||||||
|
// consumers read are always parseable. god_admin is always allowed (it is
|
||||||
|
// the global group and is managed from a site's modal).
|
||||||
|
const resource = await Resource.get(resourceId);
|
||||||
|
// Full site slug verbatim (`site_local`) -- the builders take it as-is. A
|
||||||
|
// site resource's own slug is its site; a host/app uses its ancestor site.
|
||||||
|
const siteSlug = resource && resource.kind === 'site'
|
||||||
|
? resource.slug
|
||||||
|
: await Resource.findAncestorSiteSlug(resourceId);
|
||||||
|
if (resource && siteSlug && groupCn !== groups.GOD_ADMIN) {
|
||||||
|
const { valid, capRe } = validGroupCnsForResource(resource, siteSlug);
|
||||||
|
if (!valid.has(groupCn) && !(capRe && capRe.test(groupCn))) {
|
||||||
|
const err = new Error(`"${groupCn}" is not a valid group for this ${resource.kind}. Use the resource's own groups, a site aggregate, a site-level group, or god_admin (e.g. ${[...valid].join(', ')}).`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = await ensureResourceGroup(req.body.resourceId, groupCn, req.body.accessLevel);
|
||||||
res.json({ results: g });
|
res.json({ results: g });
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -222,19 +465,138 @@ router.delete('/groups/:id', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Access visibility ---
|
||||||
|
//
|
||||||
|
// The two questions an access-control pane has to answer, neither of which the
|
||||||
|
// directory could answer before: "who can reach this resource" (a column on the
|
||||||
|
// table, rather than three clicks into a modal) and "what can this user reach"
|
||||||
|
// (which had no UI at all). Both are joins of the same two sets, so both are
|
||||||
|
// served from one cached Group.listDetail() rather than a lookup per row.
|
||||||
|
|
||||||
|
// dn -> uid, so member DNs can be reported as the uids admins actually think in.
|
||||||
|
async function dnToUidMap() {
|
||||||
|
const users = await User.listDetail();
|
||||||
|
return new Map(users.map(u => [String(u.dn).toLowerCase(), u.uid]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /access-summary — { resourceId: { groups: [...], memberCount } }
|
||||||
|
router.get('/access-summary', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const [links, groups, uidByDn] = await Promise.all([
|
||||||
|
ResourceGroup.list(),
|
||||||
|
Group.listDetail(),
|
||||||
|
dnToUidMap(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupByCn = new Map(groups.map(g => [g.cn, g]));
|
||||||
|
const summary = {};
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
const group = groupByCn.get(link.groupCn);
|
||||||
|
// A link whose LDAP group has been deleted out from under it: report it
|
||||||
|
// rather than skipping, since a dangling link grants nothing and the
|
||||||
|
// admin needs to see that it is dead.
|
||||||
|
//
|
||||||
|
// Counts come from the transitive closure, not from `member`. Reading the
|
||||||
|
// attribute would report only who is listed on the group, missing anyone
|
||||||
|
// who reaches it through a nested group -- and since god_admin is
|
||||||
|
// nested into every resource's _admin group, that is not an edge case.
|
||||||
|
let members = [];
|
||||||
|
if (group) {
|
||||||
|
const eff = await Group.effectiveMembers(link.groupCn);
|
||||||
|
members = eff.effective.map(dn => uidByDn.get(String(dn).toLowerCase()) || cnFromDn(dn));
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = summary[link.resourceId] || (summary[link.resourceId] = { groups: [], members: [] });
|
||||||
|
entry.groups.push({
|
||||||
|
cn: link.groupCn,
|
||||||
|
accessLevel: link.accessLevel,
|
||||||
|
exists: !!group,
|
||||||
|
memberCount: members.length,
|
||||||
|
});
|
||||||
|
for (const uid of members) {
|
||||||
|
if (!entry.members.includes(uid)) entry.members.push(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const id of Object.keys(summary)) {
|
||||||
|
summary[id].memberCount = summary[id].members.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: summary });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /user-access/:uid — every resource a given user can reach, and via which
|
||||||
|
// group. This is the reverse lookup; previously an admin could only see their
|
||||||
|
// own access, via /api/discovery/me.
|
||||||
|
router.get('/user-access/:uid', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const user = await User.get({ uid: req.params.uid });
|
||||||
|
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||||
|
|
||||||
|
const dn = String(user.dn).toLowerCase();
|
||||||
|
const groups = await Group.listDetail();
|
||||||
|
const memberOf = groups
|
||||||
|
.filter(g => [].concat(g.member || []).some(m => String(m).toLowerCase() === dn))
|
||||||
|
.map(g => g.cn);
|
||||||
|
|
||||||
|
const [links, resources] = await Promise.all([ResourceGroup.list(), Resource.list()]);
|
||||||
|
const byId = new Map(resources.map(r => [r.id, r]));
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
for (const link of links) {
|
||||||
|
if (!memberOf.includes(link.groupCn)) continue;
|
||||||
|
const resource = byId.get(link.resourceId);
|
||||||
|
if (!resource) continue;
|
||||||
|
results.push({
|
||||||
|
id: resource.id,
|
||||||
|
name: resource.name,
|
||||||
|
slug: resource.slug,
|
||||||
|
kind: resource.kind,
|
||||||
|
groupCn: link.groupCn,
|
||||||
|
accessLevel: link.accessLevel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ results: { uid: user.uid, groups: memberOf, resources: results } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tail the last `lines` lines of a log file without shelling out. Reads at most
|
||||||
|
// the trailing MAX_TAIL_BYTES so an unrotated multi-GB log can't blow up the
|
||||||
|
// heap. A missing/unreadable file is normal (the log only exists once slapd has
|
||||||
|
// written to it), so it yields '' rather than an error.
|
||||||
|
const MAX_TAIL_BYTES = 256 * 1024;
|
||||||
|
|
||||||
|
async function tailFile(filePath, lines = 100) {
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
let fh;
|
||||||
|
try {
|
||||||
|
fh = await fs.open(filePath, 'r');
|
||||||
|
const { size } = await fh.stat();
|
||||||
|
const start = Math.max(0, size - MAX_TAIL_BYTES);
|
||||||
|
const buf = Buffer.alloc(Math.min(size, MAX_TAIL_BYTES));
|
||||||
|
await fh.read(buf, 0, buf.length, start);
|
||||||
|
const text = buf.toString('utf8');
|
||||||
|
// A partial first line when we started mid-file; drop it.
|
||||||
|
const rows = (start > 0 ? text.slice(text.indexOf('\n') + 1) : text).split('\n');
|
||||||
|
return rows.slice(-lines).join('\n');
|
||||||
|
} catch (err) {
|
||||||
|
return '';
|
||||||
|
} finally {
|
||||||
|
if (fh) await fh.close().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
router.get('/audit-logs', async (req, res, next) => {
|
router.get('/audit-logs', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const fs = require('fs');
|
const [ldap, oauth, audit] = await Promise.all([
|
||||||
const { execSync } = require('child_process');
|
tailFile('/var/lib/ldap/slapd.log'),
|
||||||
let ldapLogs = '';
|
tailFile('/var/lib/ldap/oauth.log'),
|
||||||
let oauthLogs = '';
|
tailFile('/var/lib/ldap/auditlog.ldif'),
|
||||||
let auditLogs = '';
|
]);
|
||||||
|
res.json({ results: { ldap, oauth, audit } });
|
||||||
try { ldapLogs = execSync('tail -n 100 /var/lib/ldap/slapd.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { oauthLogs = execSync('tail -n 100 /var/lib/ldap/oauth.log 2>/dev/null').toString(); } catch(e){}
|
|
||||||
try { auditLogs = execSync('tail -n 100 /var/lib/ldap/auditlog.ldif 2>/dev/null').toString(); } catch(e){}
|
|
||||||
|
|
||||||
res.json({ results: { ldap: ldapLogs, oauth: oauthLogs, audit: auditLogs } });
|
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,293 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
|
||||||
|
//
|
||||||
|
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
|
||||||
|
// config via a Redis hash). Here every plugin is a PluginInstance row (see
|
||||||
|
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
|
||||||
|
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
|
||||||
|
//
|
||||||
|
// Gated router-wide to the same admin groups as the directory admin API, so
|
||||||
|
// existing directory admins keep access. Secrets are never returned in
|
||||||
|
// cleartext — only masked (`********`) — and never persisted in the DB.
|
||||||
|
|
||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||||
|
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
|
||||||
|
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
// Derive a stable, unique slug from an instance name when the caller didn't
|
||||||
|
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
|
||||||
|
// and prefixes `plugin-` if the result would otherwise start with a character
|
||||||
|
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
|
||||||
|
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
|
||||||
|
function slugify(name) {
|
||||||
|
let s = String(name || '').toLowerCase().trim();
|
||||||
|
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
|
if (!s) s = 'plugin';
|
||||||
|
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
|
||||||
|
return s.slice(0, 64);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function makeSlug(name, isTaken) {
|
||||||
|
const base = slugify(name);
|
||||||
|
if (!await isTaken(base)) return base;
|
||||||
|
for (let i = 2; i <= 16; i++) {
|
||||||
|
const cand = `${base}-${i}`.slice(0, 64);
|
||||||
|
if (!await isTaken(cand)) return cand;
|
||||||
|
}
|
||||||
|
return null; // exhausted
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
|
||||||
|
// (app_super_admin is always allowed by permission.byGroup).
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Plain object for the wire, with masked secret values attached under
|
||||||
|
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
|
||||||
|
async function serialize(instance) {
|
||||||
|
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
|
||||||
|
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
|
||||||
|
obj.secrets = registry.mask(instance.pluginType, secrets);
|
||||||
|
return obj;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a create/update payload against a plugin type's configSchema.
|
||||||
|
// Returns an error string or null. `flat` is the merged config + secret values
|
||||||
|
// (the UI sends one flat object; the API splits it).
|
||||||
|
function validateFields(type, flat) {
|
||||||
|
const required = registry.requiredKeys(type);
|
||||||
|
for (const key of required) {
|
||||||
|
const v = flat && flat[key];
|
||||||
|
if (v === undefined || v === null || v === '') {
|
||||||
|
return `Missing required field: ${key}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plugin types (for the create-instance picker + form) ---
|
||||||
|
router.get('/types', (req, res) => {
|
||||||
|
res.json({ results: registry.getTypes() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- List instances ---
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const instances = await PluginInstance.list();
|
||||||
|
const out = [];
|
||||||
|
for (const inst of instances) out.push(await serialize(inst));
|
||||||
|
res.json({ results: out });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ results: await serialize(inst) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Create instance ---
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { pluginType, name, slug, cron } = req.body;
|
||||||
|
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
|
||||||
|
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
// Slug is optional: derive it from the name when absent. When supplied,
|
||||||
|
// validate it (admins editing via API may still pass one explicitly).
|
||||||
|
let finalSlug = slug;
|
||||||
|
if (finalSlug) {
|
||||||
|
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
|
||||||
|
} else {
|
||||||
|
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
|
||||||
|
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
|
||||||
|
}
|
||||||
|
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||||
|
|
||||||
|
// `config` from the client is a flat object of all field values (secret +
|
||||||
|
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
|
||||||
|
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
|
||||||
|
const fieldErr = validateFields(pluginType, flat);
|
||||||
|
if (fieldErr) return res.status(400).json({ error: fieldErr });
|
||||||
|
|
||||||
|
const manifest = registry.getManifest(pluginType);
|
||||||
|
const { config, secrets } = registry.splitConfig(pluginType, flat);
|
||||||
|
const enabled = req.body.enabled !== false; // default true
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
const instance = await PluginInstance.create({
|
||||||
|
pluginType,
|
||||||
|
category: manifest.category,
|
||||||
|
name,
|
||||||
|
slug: finalSlug,
|
||||||
|
enabled,
|
||||||
|
cron: cron || '0 * * * *',
|
||||||
|
config,
|
||||||
|
created_by: req.user.uid,
|
||||||
|
created_on: now,
|
||||||
|
updated_by: req.user.uid,
|
||||||
|
updated_on: now
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await pluginSecrets.write(instance.id, secrets);
|
||||||
|
} catch (err) {
|
||||||
|
// Most likely the sso-broker policy lacks secret/plugins/* — the
|
||||||
|
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
|
||||||
|
// secret write doesn't strand a half-created instance.
|
||||||
|
await instance.delete().catch(() => {});
|
||||||
|
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
await scheduleInstance(instance);
|
||||||
|
await runInstanceNow(instance.id);
|
||||||
|
}
|
||||||
|
res.json({ results: await serialize(instance) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Update instance (name/cron/enabled/non-secret config) ---
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
if (req.body.name !== undefined) updates.name = req.body.name;
|
||||||
|
if (req.body.cron !== undefined) {
|
||||||
|
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
|
||||||
|
updates.cron = req.body.cron;
|
||||||
|
}
|
||||||
|
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
|
||||||
|
|
||||||
|
// Non-secret config: split the client's flat config so secret fields are
|
||||||
|
// never written to the DB. Secrets are changed via PUT /:id/secrets.
|
||||||
|
if (req.body.config !== undefined && typeof req.body.config === 'object') {
|
||||||
|
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
|
||||||
|
updates.config = config;
|
||||||
|
}
|
||||||
|
|
||||||
|
updates.updated_by = req.user.uid;
|
||||||
|
updates.updated_on = Date.now();
|
||||||
|
|
||||||
|
const updated = await inst.update(updates);
|
||||||
|
|
||||||
|
// Re-schedule if the schedule-relevant fields moved.
|
||||||
|
if (updates.cron !== undefined || updates.enabled !== undefined) {
|
||||||
|
await scheduleInstance(updated);
|
||||||
|
}
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.name === 'SequelizeUniqueConstraintError') {
|
||||||
|
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
|
||||||
|
}
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Update secrets only ---
|
||||||
|
router.put('/:id/secrets', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
|
||||||
|
|
||||||
|
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
|
||||||
|
// values so an unchanged masked field is a no-op.
|
||||||
|
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
|
||||||
|
await pluginSecrets.write(inst.id, secrets);
|
||||||
|
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Test (validate) ---
|
||||||
|
router.post('/:id/test', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const mod = registry.getModule(inst.pluginType);
|
||||||
|
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
|
||||||
|
const cfg = await pluginSecrets.mergeForRun(inst);
|
||||||
|
const result = await mod.validate(cfg);
|
||||||
|
if (result && result.ok) return res.json(result);
|
||||||
|
return res.status(400).json(result || { ok: false, error: 'validation failed' });
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(400).json({ ok: false, error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Load (enable + schedule + run now) ---
|
||||||
|
router.post('/:id/load', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
await scheduleInstance(updated);
|
||||||
|
await runInstanceNow(updated.id);
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Unload (unschedule + disable) ---
|
||||||
|
router.post('/:id/unload', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await unscheduleInstance(inst.id);
|
||||||
|
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
|
||||||
|
res.json({ results: await serialize(updated) });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Run now (regardless of enabled) ---
|
||||||
|
router.post('/:id/run', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await runInstanceNow(inst.id);
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Last-run status ---
|
||||||
|
router.get('/:id/runs', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError, lastLog: inst.lastLog } });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Delete (unschedule + remove secrets + delete row) ---
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const inst = await PluginInstance.get(req.params.id);
|
||||||
|
if (!inst) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await unscheduleInstance(inst.id);
|
||||||
|
await pluginSecrets.remove(inst.id); // best-effort
|
||||||
|
await inst.delete();
|
||||||
|
res.json({ results: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Shared-secrets API.
|
||||||
|
//
|
||||||
|
// A shared secret is metadata in the DB (SharedSecret + SharedSecretGrant) with
|
||||||
|
// its DATA in OpenBao at secret/shared/<ownerUid>/<slug> (KV-v2). The owner has
|
||||||
|
// full R/W/list on their own secret/shared/<ownerUid>/* subtree; each grantee's
|
||||||
|
// OpenBao policy content is edited to add read on the exact shared path (see
|
||||||
|
// vault_broker.js grantSharedSecret/revokeSharedSecret). Enforcement is entirely
|
||||||
|
// the OpenBao ACL — the broker's policy reconciliation makes a grant effective
|
||||||
|
// immediately, with no token re-mint.
|
||||||
|
//
|
||||||
|
// Reads of the secret DATA are intentionally NOT proxied here: the UI fetches
|
||||||
|
// them through the existing /api/vault proxy using the requester's own session
|
||||||
|
// token, so OpenBao ACL enforces read access per-request. This router handles
|
||||||
|
// metadata CRUD + grant management; KV writes (create/update/delete) are made
|
||||||
|
// server-side using the acting user's scoped token.
|
||||||
|
|
||||||
|
const express = require('express');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
// Allow hyphens AND underscores (matching the plugin-instance slug convention);
|
||||||
|
// only reject values that can't be a sane secret path segment (spaces, slashes,
|
||||||
|
// leading non-alnum, too long).
|
||||||
|
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Machine/service tokens cannot manage shared secrets (mirrors scopeGuard on the
|
||||||
|
// /api/vault proxy — personal, per-user secret management only).
|
||||||
|
router.use((req, res, next) => {
|
||||||
|
if (req.user && req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot manage shared secrets' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try { await permission.byGroup(user, ADMIN_GROUPS); return true; }
|
||||||
|
catch (e) { return false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped OpenBao token for an actor, used for server-side KV writes. Owner uses
|
||||||
|
// their own token (R/W on secret/shared/<ownerUid>/*); an admin uses the
|
||||||
|
// sso-admin token (R/W on secret/*).
|
||||||
|
async function actorToken(user, ownerUid) {
|
||||||
|
if (user.uid === ownerUid) return vaultBroker.getOrCreateUserToken(ownerUid);
|
||||||
|
if (await isAdmin(user)) return vaultBroker.getOrCreateAdminToken(user.uid);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Does this user manage the given shared secret? Owner or admin.
|
||||||
|
async function canManage(user, secret) {
|
||||||
|
if (user.uid === secret.ownerUid) return true;
|
||||||
|
return isAdmin(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecret(req, res) {
|
||||||
|
const secret = await SharedSecret.get(req.params.id);
|
||||||
|
if (!secret) { res.status(404).json({ error: 'not found' }); return null; }
|
||||||
|
return secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── List: mine + shared-with-me ─────────────────────────────────────────────
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const mine = await SharedSecret.list({ where: { ownerUid: uid } });
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee('user', uid);
|
||||||
|
const granteeSecretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const granted = granteeSecretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: granteeSecretIds } } }) : [];
|
||||||
|
const byId = new Map(mine.map(s => [s.id, { role: 'owner', ...s }]));
|
||||||
|
for (const g of granted) {
|
||||||
|
if (byId.has(g.id)) continue; // already owner
|
||||||
|
byId.set(g.id, { role: 'grantee', ...g });
|
||||||
|
}
|
||||||
|
// The `{ role, ...s }` spread above copies only own properties, so the
|
||||||
|
// instance method `path()` is dropped -- call the static builder instead.
|
||||||
|
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: SharedSecret.pathFor(s.ownerUid, s.slug), role: s.role })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Create ──────────────────────────────────────────────────────────────────
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const slug = String(req.body.slug || '').trim().toLowerCase();
|
||||||
|
if (!SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/hyphens/underscores, 1-64 chars' });
|
||||||
|
const description = String(req.body.description || '').trim();
|
||||||
|
const data = (req.body.data && typeof req.body.data === 'object') ? req.body.data : {};
|
||||||
|
|
||||||
|
if (await SharedSecret.getBySlug(slug)) {
|
||||||
|
return res.status(409).json({ error: `a shared secret named '${slug}' already exists` });
|
||||||
|
}
|
||||||
|
const token = await actorToken(req.user, uid);
|
||||||
|
if (!token) return res.status(403).json({ error: 'not allowed' });
|
||||||
|
const path = SharedSecret.pathFor(uid, slug);
|
||||||
|
await baoConf.set(path, data, { token });
|
||||||
|
|
||||||
|
const secret = await SharedSecret.create({
|
||||||
|
slug, ownerUid: uid, description,
|
||||||
|
created_by: uid, created_on: Date.now(), updated_by: uid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
res.status(201).json({ id: secret.id, slug, ownerUid: uid, description, path, role: 'owner' });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Detail (metadata; data is read via /api/vault proxy) ────────────────────
|
||||||
|
router.get('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
const grantee = (await SharedSecretGrant.listForGrantee('user', uid)).some(g => g.secretId === secret.id);
|
||||||
|
if (!admin && uid !== secret.ownerUid && !grantee) return res.status(403).json({ error: 'not shared with you' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path(), role: uid === secret.ownerUid ? 'owner' : (admin ? 'admin' : 'grantee'), grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Update data / description ───────────────────────────────────────────────
|
||||||
|
router.put('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can edit a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
const update = {};
|
||||||
|
if (req.body && typeof req.body.data === 'object') {
|
||||||
|
await baoConf.set(secret.path(), req.body.data, { token });
|
||||||
|
}
|
||||||
|
if (req.body && req.body.description !== undefined) {
|
||||||
|
update.description = String(req.body.description).trim();
|
||||||
|
}
|
||||||
|
if (Object.keys(update).length) {
|
||||||
|
update.updated_by = req.user.uid;
|
||||||
|
update.updated_on = Date.now();
|
||||||
|
await secret.update(update);
|
||||||
|
}
|
||||||
|
res.json({ id: secret.id, slug: secret.slug, ownerUid: secret.ownerUid, description: secret.description, path: secret.path() });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Delete (KV + DB row + all grants) ───────────────────────────────────────
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can delete a shared secret' });
|
||||||
|
const token = await actorToken(req.user, secret.ownerUid);
|
||||||
|
// Revoke all grants first so grantees' policies drop the path.
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
for (const g of grants) await vaultBroker.revokeSharedSecret(g.id, req.user.uid);
|
||||||
|
// Delete the KV data (metadata delete removes all versions), then the row.
|
||||||
|
try { await baoConf.request('DELETE', `secret/metadata/${secret.path()}`, undefined, { token }); } catch (e) { /* best-effort */ }
|
||||||
|
await secret.delete();
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: list ────────────────────────────────────────────────────────────
|
||||||
|
router.get('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grants = await SharedSecretGrant.list({ where: { secretId: secret.id } });
|
||||||
|
res.json({ grants: grants.map(g => ({ id: g.id, granteeType: g.granteeType, granteeId: g.granteeId, capability: g.capability })) });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: create ──────────────────────────────────────────────────────────
|
||||||
|
router.post('/:id/grants', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const granteeType = String(req.body.granteeType || '').trim();
|
||||||
|
const granteeId = String(req.body.granteeId || '').trim();
|
||||||
|
if (!['user', 'app'].includes(granteeType)) return res.status(400).json({ error: 'granteeType must be user or app' });
|
||||||
|
if (!granteeId) return res.status(400).json({ error: 'granteeId is required' });
|
||||||
|
if (granteeId === secret.ownerUid && granteeType === 'user') {
|
||||||
|
return res.status(400).json({ error: 'the owner already has access' });
|
||||||
|
}
|
||||||
|
// Idempotent: skip if the grant already exists.
|
||||||
|
const existing = (await SharedSecretGrant.list({ where: { secretId: secret.id, granteeType, granteeId } }))[0];
|
||||||
|
if (existing) return res.json({ id: existing.id, granteeType, granteeId, capability: existing.capability });
|
||||||
|
const grant = await vaultBroker.grantSharedSecret(secret.id, granteeType, granteeId, req.user.uid);
|
||||||
|
res.status(201).json({ id: grant.id, granteeType, granteeId, capability: grant.capability });
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Grants: revoke ──────────────────────────────────────────────────────────
|
||||||
|
router.delete('/:id/grants/:grantId', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const secret = await loadSecret(req, res);
|
||||||
|
if (!secret) return;
|
||||||
|
if (!(await canManage(req.user, secret))) return res.status(403).json({ error: 'only the owner (or admin) can manage grants' });
|
||||||
|
const grant = await SharedSecretGrant.get(req.params.grantId);
|
||||||
|
if (!grant || grant.secretId !== secret.id) return res.status(404).json({ error: 'grant not found' });
|
||||||
|
await vaultBroker.revokeSharedSecret(grant.id, req.user.uid);
|
||||||
|
res.status(204).end();
|
||||||
|
} catch (e) { next(e); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('conf', {
|
||||||
|
title: 'Configuration',
|
||||||
|
user: req.user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
+131
-12
@@ -10,9 +10,14 @@
|
|||||||
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
// jump-host's `data.results || []` silently collapsed to `[]`, so no user could
|
||||||
// bridge) and absorbs the dead /me handler that used to live in
|
// bridge) and absorbs the dead /me handler that used to live in
|
||||||
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
// routes/api_discovery.js (mounted after the 404, so unreachable).
|
||||||
|
//
|
||||||
|
// Group CNs come from utils/user_groups — `req.user` has `memberOf` (DNs) and
|
||||||
|
// no `.groups`, so reading `.groups` off it directly yields [] for every human
|
||||||
|
// caller. See that file for what that silently broke.
|
||||||
|
|
||||||
const router = require('express').Router();
|
const router = require('express').Router();
|
||||||
const { Resource, ResourceGroup } = require('../models/resource');
|
const { Resource, ResourceGroup } = require('../models/resource');
|
||||||
|
const { withGroups } = require('../utils/user_groups');
|
||||||
const {
|
const {
|
||||||
envelope,
|
envelope,
|
||||||
projectResource,
|
projectResource,
|
||||||
@@ -20,20 +25,29 @@ const {
|
|||||||
isDirectoryAdmin,
|
isDirectoryAdmin,
|
||||||
} = require('@simpleworkjs/directory-schema');
|
} = require('@simpleworkjs/directory-schema');
|
||||||
|
|
||||||
|
// Resolve the caller's groups once per request and hand back the projection
|
||||||
|
// flag. Every handler needs both, and both are wrong if taken off req.user raw.
|
||||||
|
async function callerView(req) {
|
||||||
|
const user = await withGroups(req.user);
|
||||||
|
return { user, fullMetadata: isDirectoryAdmin(user) };
|
||||||
|
}
|
||||||
|
|
||||||
// GET /api/discovery/resources[?kind=&group=&parent=]
|
// GET /api/discovery/resources[?kind=&group=&parent=]
|
||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
const resources = await Resource.search(req.query);
|
const resources = await Resource.search(req.query);
|
||||||
res.json(envelope(projectResources(resources, { fullMetadata: isDirectoryAdmin(req.user) })));
|
res.json(envelope(projectResources(resources, { fullMetadata })));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/discovery/resources/:slug
|
// GET /api/discovery/resources/:slug
|
||||||
router.get('/resources/:slug', async (req, res, next) => {
|
router.get('/resources/:slug', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
const resource = await Resource.getBySlug(req.params.slug);
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
// parents/children are edges (no secrets); project only the resource body.
|
// parents/children are edges (no secrets); project only the resource body.
|
||||||
const projected = projectResource(resource, { fullMetadata: isDirectoryAdmin(req.user) });
|
const projected = projectResource(resource, { fullMetadata });
|
||||||
projected.parents = resource.parents;
|
projected.parents = resource.parents;
|
||||||
projected.children = resource.children;
|
projected.children = resource.children;
|
||||||
res.json(envelope(projected));
|
res.json(envelope(projected));
|
||||||
@@ -43,10 +57,12 @@ router.get('/resources/:slug', async (req, res, next) => {
|
|||||||
// GET /api/discovery/graph
|
// GET /api/discovery/graph
|
||||||
router.get('/graph', async (req, res, next) => {
|
router.get('/graph', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
const graph = await Resource.getGraph();
|
const graph = await Resource.getGraph();
|
||||||
res.json(envelope({
|
res.json(envelope({
|
||||||
resources: projectResources(graph.resources, { fullMetadata: isDirectoryAdmin(req.user) }),
|
resources: projectResources(graph.resources, { fullMetadata }),
|
||||||
edges: graph.edges,
|
edges: graph.edges,
|
||||||
|
updated_on: graph.updated_on
|
||||||
}));
|
}));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
@@ -54,26 +70,129 @@ router.get('/graph', async (req, res, next) => {
|
|||||||
// GET /api/discovery/me
|
// GET /api/discovery/me
|
||||||
// Returns the resources the current caller can reach. Machines see only their
|
// Returns the resources the current caller can reach. Machines see only their
|
||||||
// own resource; humans get the union of their LDAP groups' resources plus
|
// own resource; humans get the union of their LDAP groups' resources plus
|
||||||
// anything flagged isPublic. Uses req.user.groups (populated by the auth
|
// anything flagged isPublic.
|
||||||
// middleware for session/PAT callers) rather than re-querying LDAP by DN, so it
|
|
||||||
// works for every auth transport without assuming a .dn is present.
|
|
||||||
router.get('/me', async (req, res, next) => {
|
router.get('/me', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
const { user, fullMetadata } = await callerView(req);
|
||||||
let accessible;
|
let accessible;
|
||||||
if (req.user && req.user.isMachine) {
|
if (req.user && req.user.isMachine) {
|
||||||
accessible = await Resource.list({ where: { id: req.resourceId } });
|
accessible = await Resource.list({ where: { id: req.resourceId } });
|
||||||
} else {
|
} else {
|
||||||
const userGroups = (req.user && req.user.groups) || [];
|
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
if (userGroups.length) {
|
if (user.groups.length) {
|
||||||
const rgs = await ResourceGroup.list({ where: { groupCn: { in: userGroups } } });
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: user.groups } } });
|
||||||
for (const rg of rgs) ids.add(rg.resourceId);
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
}
|
}
|
||||||
const all = await Resource.list();
|
const all = await Resource.list();
|
||||||
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
|
accessible = all.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
if (isAuto && !isManaged) return false;
|
||||||
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
res.json(envelope(projectResources(accessible, { fullMetadata: isDirectoryAdmin(req.user) })));
|
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
||||||
|
// service inherits it from its host, so it must be computed here rather
|
||||||
|
// than left to each caller to guess at address || ip.
|
||||||
|
accessible = await Resource.withResolvedAddress(accessible);
|
||||||
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = router;
|
// GET /api/discovery/access/:uid[/:slug]
|
||||||
|
// Answers per-user access for a machine caller (e.g. jump-host).
|
||||||
|
router.get(['/access/:uid', '/access/:uid/:slug'], async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { fullMetadata } = await callerView(req);
|
||||||
|
if (!req.user || (!req.user.isMachine && !fullMetadata)) {
|
||||||
|
return res.status(403).json(envelope({ error: 'Only machine identities or admins may query access for other users.' }));
|
||||||
|
}
|
||||||
|
const { User } = require('../models/user_ldap');
|
||||||
|
const { groupCns } = require('../utils/user_groups');
|
||||||
|
|
||||||
|
const targetUser = await User.get(req.params.uid).catch(() => null);
|
||||||
|
if (!targetUser) return res.status(404).json(envelope({ error: 'User not found' }));
|
||||||
|
|
||||||
|
const groups = await groupCns(targetUser);
|
||||||
|
const ids = new Set();
|
||||||
|
if (groups.length) {
|
||||||
|
const rgs = await ResourceGroup.list({ where: { groupCn: { in: groups } } });
|
||||||
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
let all = await Resource.list();
|
||||||
|
if (req.params.slug) all = all.filter(r => r.slug === req.params.slug);
|
||||||
|
|
||||||
|
let accessible = all.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
if (isAuto && !isManaged) return false;
|
||||||
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
||||||
|
});
|
||||||
|
|
||||||
|
accessible = await Resource.withResolvedAddress(accessible);
|
||||||
|
res.json(envelope(projectResources(accessible, { fullMetadata })));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/sync
|
||||||
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
||||||
|
router.post('/sync', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
// Assuming the caller provides a source name and payload
|
||||||
|
const source = req.body.source || 'agent';
|
||||||
|
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
|
||||||
|
res.json(envelope({ success: true }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/promote/:slug
|
||||||
|
// Promotes an unmanaged device to managed by creating its LDAP groups.
|
||||||
|
router.post('/promote/:slug', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
|
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
|
||||||
|
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const accessGroup = `${resource.slug}_access`;
|
||||||
|
const adminGroup = `${resource.slug}_admin`;
|
||||||
|
|
||||||
|
// Create groups if they don't exist
|
||||||
|
try { await Group.get(accessGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
try { await Group.get(adminGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link them
|
||||||
|
const crypto = require('crypto');
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: accessGroup,
|
||||||
|
accessLevel: 'user'
|
||||||
|
});
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: adminGroup,
|
||||||
|
accessLevel: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = resource.metadata || {};
|
||||||
|
meta.managed = true;
|
||||||
|
// `Resource.update` is not a static — `update` is an instance method
|
||||||
|
// (@simpleworkjs/orm). Load a fresh instance and call it on that.
|
||||||
|
const inst = await Resource.get(resource.id);
|
||||||
|
await inst.update({ metadata: meta });
|
||||||
|
|
||||||
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|||||||
@@ -34,6 +34,14 @@ 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` pointed at plugins.md, so docs/agents.md -- the theta-agent
|
||||||
|
// guide the Directory links to -- was unreachable in the app.
|
||||||
|
agents: {title: 'Theta Agent', file: path.join(__dirname, '../../docs/agents.md')},
|
||||||
|
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||||
|
// The Discovery tab's help icon links here; without an entry it 404'd.
|
||||||
|
discovery: {title: 'Discovery & Inventory', file: path.join(__dirname, '../../docs/discovery.md')},
|
||||||
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
|
groups: {title: 'Groups & Permissions', file: path.join(__dirname, '../../docs/groups.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
@@ -51,7 +59,7 @@ const docList = Object.entries(DOCS).map(([slug, d]) => ({slug, title: d.title})
|
|||||||
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
// only resolves correctly on GitHub. Serve that same folder here and rewrite
|
||||||
// the rendered markup to point at it absolutely, so the images work when
|
// the rendered markup to point at it absolutely, so the images work when
|
||||||
// read from /docs/overview too.
|
// read from /docs/overview too.
|
||||||
router.use('/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
router.use('/docs/images', require('express').static(path.join(__dirname, '../../docs/images')));
|
||||||
function fixImagePaths(html) {
|
function fixImagePaths(html) {
|
||||||
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
return html.replace(/(["(])docs\/images\//g, '$1/docs/images/');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,87 @@ router.get('/:name', async function(req, res, next){
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Nested groups ───────────────────────────────────────────────────────────
|
||||||
|
// A groupOfNames `member` may be any DN, including another group's, which is
|
||||||
|
// how nesting is stored. These routes are mounted before /:group/:uid so the
|
||||||
|
// literal "nested"/"effective" path segments are not swallowed by that
|
||||||
|
// wildcard, which would otherwise try to resolve them as a uid.
|
||||||
|
|
||||||
|
// GET /api/group/:group/effective — who this group actually grants, split into
|
||||||
|
// directly-listed users, the groups nested into it, and the full transitive set
|
||||||
|
// of users. The UI shows "3 direct, 12 effective"; a plain member read cannot
|
||||||
|
// answer that, and on a server with nestgroup it silently returns the expanded
|
||||||
|
// list with no indication which entries are direct.
|
||||||
|
router.get('/:group/effective', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
return res.json({ results: await Group.effectiveMembers(req.params.group) });
|
||||||
|
}catch(error){
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/group/:group/nested/:child — nest :child inside :group.
|
||||||
|
router.put('/:group/nested/:child', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||||
|
|
||||||
|
const parent = await Group.get(req.params.group);
|
||||||
|
const child = await Group.get(req.params.child);
|
||||||
|
|
||||||
|
if(parent.dn === child.dn){
|
||||||
|
return res.status(400).json({message: 'A group cannot contain itself.'});
|
||||||
|
}
|
||||||
|
// Refuse rather than rely on the resolver's depth cap: a cycle makes
|
||||||
|
// "who is in this group" unanswerable, and the cap would quietly return
|
||||||
|
// a truncated answer instead of an error anyone would notice.
|
||||||
|
if(await Group.wouldCycle(req.params.group, child.dn)){
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `"${req.params.child}" already contains "${req.params.group}" — nesting them would create a loop.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await parent.addMember({dn: child.dn});
|
||||||
|
User.clearCache();
|
||||||
|
return res.json({
|
||||||
|
results,
|
||||||
|
message: `Nested ${req.params.child} inside ${req.params.group}.`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||||
|
return res.status(409).json({message: `"${req.params.child}" is already nested in "${req.params.group}".`});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/group/:group/nested/:child — un-nest.
|
||||||
|
router.delete('/:group/nested/:child', async function(req, res, next){
|
||||||
|
try{
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin'], [req.params.group]);
|
||||||
|
|
||||||
|
const parent = await Group.get(req.params.group);
|
||||||
|
const child = await Group.get(req.params.child);
|
||||||
|
const results = await parent.removeMember({dn: child.dn});
|
||||||
|
User.clearCache();
|
||||||
|
return res.json({
|
||||||
|
results,
|
||||||
|
message: `Removed ${req.params.child} from ${req.params.group}.`
|
||||||
|
});
|
||||||
|
}catch(error){
|
||||||
|
// groupOfNames requires at least one member, so emptying a group is a
|
||||||
|
// schema violation rather than a permission problem. Surfacing the raw
|
||||||
|
// error as a 500 makes it look like a bug in the server; it is really a
|
||||||
|
// "you cannot do that, and here is why" -- the same reason the last user
|
||||||
|
// cannot be removed from a group either.
|
||||||
|
if(error.name === 'ObjectClassViolationError' || error.code === 65){
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `"${req.params.child}" is the only member of "${req.params.group}". A group must keep at least one member — add another first.`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.put('/owner/:group/:uid', async function(req, res, next){
|
router.put('/owner/:group/:uid', async function(req, res, next){
|
||||||
try{
|
try{
|
||||||
|
|
||||||
@@ -92,6 +173,15 @@ router.put('/:group/:uid', async function(req, res, next){
|
|||||||
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
message: `Added user ${req.params.uid} to ${req.params.group} group.`
|
||||||
});
|
});
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
// Already a member -- surfaced as a plain 500 before, which read as a
|
||||||
|
// server fault for what is really a no-op. Common in practice because
|
||||||
|
// groupOfNames needs at least one member, so whoever creates a group is
|
||||||
|
// seeded into it and is then "added" again by the obvious next click.
|
||||||
|
if(error.name === 'TypeOrValueExistsError' || error.code === 20){
|
||||||
|
return res.status(409).json({
|
||||||
|
message: `"${req.params.uid}" is already a member of "${req.params.group}".`
|
||||||
|
});
|
||||||
|
}
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-4
@@ -17,6 +17,13 @@ const values ={
|
|||||||
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
titleIcon: conf.environment !== 'production' ? `<i class="fa-brands fa-dev"></i>` : '',
|
||||||
name: conf.name,
|
name: conf.name,
|
||||||
logo: conf.logo,
|
logo: conf.logo,
|
||||||
|
// Connection conventions the catalog needs to render "how to reach this"
|
||||||
|
// (conf/base.js `directory`). Safe to expose: a jump-host name and a default
|
||||||
|
// port are public connection info, not credentials.
|
||||||
|
directoryConf: {
|
||||||
|
jumpHost: (conf.directory && conf.directory.jumpHost) || '',
|
||||||
|
defaultSshPort: (conf.directory && conf.directory.defaultSshPort) || 22,
|
||||||
|
},
|
||||||
...buildInfo,
|
...buildInfo,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,10 +64,45 @@ router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
|||||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
|
router.get('/conf', function(req, res) {
|
||||||
|
// Admin-only Configuration page. The view renders the shell for anyone
|
||||||
|
// (like /users, /directory, etc.); the client gates access with
|
||||||
|
// app.auth.forceLogin(['admin','app_sso_admin']) and the /api/conf endpoint
|
||||||
|
// enforces app_sso_admin server-side. The previous server-side
|
||||||
|
// permission.byGroup(req.user,…) 401'd on a browser navigation because this
|
||||||
|
// app's auth-token is a header set by client JS (localStorage), not a
|
||||||
|
// cookie — so req.user is undefined on a plain page load.
|
||||||
|
res.render('conf', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/directory', function(req, res) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('directory', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/discovery', function(req, res, next) {
|
||||||
|
res.redirect('/directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/plugins', function(req, res, next) {
|
||||||
|
res.redirect('/directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/vault', function(req, res) {
|
||||||
|
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||||
|
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||||
|
// tokens for external apps. The view renders the shell for any logged-in
|
||||||
|
// user; the client gates login via app.auth.forceLogin() and derives the
|
||||||
|
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
|
||||||
|
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
|
||||||
|
// the client-derived scope is only cosmetic. vaultAddr is the only
|
||||||
|
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
|
||||||
|
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
|
||||||
|
res.render('vault', {
|
||||||
|
...values,
|
||||||
|
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||||
@@ -146,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) {
|
|||||||
res.render('profile', {...values});
|
res.render('profile', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
router.get('/groups', function(req, res, next) {
|
|
||||||
res.render('groups', {...values});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get('/token', function(req, res, next) {
|
router.get('/token', function(req, res, next) {
|
||||||
res.render('token', {...values});
|
res.render('token', {...values});
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-6
@@ -4,6 +4,7 @@ const router = require('express').Router();
|
|||||||
const {User} = require('../models/user');
|
const {User} = require('../models/user');
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
const permission = require('../utils/permission');
|
const permission = require('../utils/permission');
|
||||||
|
const {groupCns} = require('../utils/user_groups');
|
||||||
const {UserVerification} = require('../models/verification');
|
const {UserVerification} = require('../models/verification');
|
||||||
const {InviteToken} = require('../models/token');
|
const {InviteToken} = require('../models/token');
|
||||||
|
|
||||||
@@ -78,12 +79,24 @@ router.get('/me', async function(req, res, next){
|
|||||||
|
|
||||||
// The shared client framework gates the UI on a single effective-rights
|
// The shared client framework gates the UI on a single effective-rights
|
||||||
// flag (the OIDC-client apps send the same key). Here "admin" means
|
// flag (the OIDC-client apps send the same key). Here "admin" means
|
||||||
// membership in app_sso_admin or the cross-app app_super_admin group;
|
// membership in app_sso_admin or the cross-app app_super_admin group.
|
||||||
// group-level gating still reads memberOf.
|
//
|
||||||
const groups = (user.memberOf || []).map(function(dn){
|
// Resolved via groupCns rather than read off `memberOf` directly: with
|
||||||
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
// nested groups, memberOf is only transitive when the directory carries
|
||||||
});
|
// the nestgroup overlay. Against a server without it, an admin who holds
|
||||||
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
// the group through nesting would get isAdmin=false here and silently
|
||||||
|
// lose the whole admin UI -- while still passing every server-side
|
||||||
|
// permission check, which resolves nesting properly. groupCns gives the
|
||||||
|
// same answer in both modes.
|
||||||
|
const groups = await groupCns(user);
|
||||||
|
user.groups = groups;
|
||||||
|
// Console admin under the group model (docs/GROUPS.md §11): god_admin,
|
||||||
|
// a site super admin, the SSO-as-app admin ({site}_app_sso_admin), or the
|
||||||
|
// legacy app_sso_admin/app_super_admin during migration.
|
||||||
|
user.isAdmin = groups.some((g) =>
|
||||||
|
g === 'app_sso_admin' || g === 'app_super_admin' ||
|
||||||
|
g === 'god_admin' || g === permission.SUPER_ADMIN_GROUP ||
|
||||||
|
g.endsWith('_super_admin') || g.endsWith('_app_sso_admin'));
|
||||||
|
|
||||||
return res.json(user);
|
return res.json(user);
|
||||||
}catch(error){
|
}catch(error){
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// GET /api/webhooks
|
||||||
|
router.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hooks = await Webhook.list();
|
||||||
|
res.json({ results: hooks });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/webhooks
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { name, url, events, secret } = req.body;
|
||||||
|
const hook = await Webhook.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name, url, events, secret,
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
res.json({ results: hook });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/webhooks/:id
|
||||||
|
router.delete('/:id', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const hook = await Webhook.get(req.params.id);
|
||||||
|
if (!hook) return res.status(404).json({ error: 'Not found' });
|
||||||
|
await hook.delete();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -0,0 +1,285 @@
|
|||||||
|
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
|
||||||
|
const { WebhookEmitter } = require('./webhook_emitter');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
|
||||||
|
// edge that would close a loop. Carries its own visited set so it terminates
|
||||||
|
// even if the stored graph already contains a cycle from an older release.
|
||||||
|
function isDescendant(candidateId, rootId, edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (id === candidateId) return true;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DiscoveryReconciler {
|
||||||
|
static async reconcile(sourceName, payload) {
|
||||||
|
const { resources = [], edges = [] } = payload;
|
||||||
|
let newDevices = 0;
|
||||||
|
|
||||||
|
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||||
|
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||||
|
|
||||||
|
// Read the inventory ONCE, not once per incoming resource. A Proxmox
|
||||||
|
// cluster reports ~55 resources against an inventory of similar size, so
|
||||||
|
// the per-iteration Resource.list() was doing quadratic full-table reads
|
||||||
|
// every discovery run. Newly created rows are pushed onto this list as we
|
||||||
|
// go, so later resources in the same payload still match against them.
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
|
||||||
|
for (const res of resources) {
|
||||||
|
if (!res.metadata) res.metadata = {};
|
||||||
|
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||||
|
|
||||||
|
let existing = null;
|
||||||
|
|
||||||
|
// A discovered device may only merge into a resource of the same kind
|
||||||
|
// (or into a placeholder from an earlier, kind-less discovery). Without
|
||||||
|
// this a VM called "gitea-runner" matches a hand-created *service* of
|
||||||
|
// the same name on rule 3 and silently overwrites it -- the discovered
|
||||||
|
// host's metadata lands on a service row, and the operator's entry is
|
||||||
|
// gone. `template` counts as `host`: a VM converted to a template is the
|
||||||
|
// same device, and it should update in place rather than fork a row.
|
||||||
|
const kindClass = (k) => (k === 'template' ? 'host' : k);
|
||||||
|
const incomingKind = kindClass(res.kind || 'unmanaged_device');
|
||||||
|
const kindCompatible = (r) => {
|
||||||
|
const k = kindClass(r.kind);
|
||||||
|
if (k === 'unmanaged_device' || incomingKind === 'unmanaged_device') return true;
|
||||||
|
return k === incomingKind;
|
||||||
|
};
|
||||||
|
const candidates = allRes.filter(kindCompatible);
|
||||||
|
|
||||||
|
// 1. Attempt matching by MAC (highest precision)
|
||||||
|
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||||
|
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||||
|
if (macs.length > 0) {
|
||||||
|
existing = candidates.find(r =>
|
||||||
|
r.metadata && (
|
||||||
|
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||||
|
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Fallback matching by IP address
|
||||||
|
let ipsToMatch = [];
|
||||||
|
if (res.metadata.interfaces) {
|
||||||
|
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
|
||||||
|
}
|
||||||
|
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
|
||||||
|
if (res.metadata.address) {
|
||||||
|
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
|
||||||
|
}
|
||||||
|
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
|
||||||
|
|
||||||
|
if (!existing && ipsToMatch.length > 0) {
|
||||||
|
existing = candidates.find(r => {
|
||||||
|
if (!r.metadata) return false;
|
||||||
|
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
|
||||||
|
if (r.metadata.address) {
|
||||||
|
const addrs = r.metadata.address.split(',').map(a => a.trim());
|
||||||
|
if (addrs.some(a => ipsToMatch.includes(a))) return true;
|
||||||
|
}
|
||||||
|
if (r.metadata.interfaces && r.metadata.interfaces.some(i => ipsToMatch.includes(i.ip))) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||||
|
if (!existing && (res.slug || res.name)) {
|
||||||
|
const inputName = normalizeHost(res.name || res.slug);
|
||||||
|
existing = candidates.find(r => {
|
||||||
|
if (res.slug && r.slug === res.slug) return true;
|
||||||
|
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
|
||||||
|
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
|
||||||
|
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// Merge metadata
|
||||||
|
const mergedMeta = { ...existing.metadata, ...res.metadata };
|
||||||
|
|
||||||
|
// Merge interfaces cleanly
|
||||||
|
if (res.metadata.interfaces) {
|
||||||
|
const existingIntfs = existing.metadata.interfaces || [];
|
||||||
|
const newIntfs = res.metadata.interfaces;
|
||||||
|
// Simple union based on mac or ip
|
||||||
|
for (const ni of newIntfs) {
|
||||||
|
const idx = existingIntfs.findIndex(ei =>
|
||||||
|
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
|
||||||
|
(ni.ip && ei.ip && ei.ip === ni.ip)
|
||||||
|
);
|
||||||
|
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
|
||||||
|
else existingIntfs.push(ni);
|
||||||
|
}
|
||||||
|
mergedMeta.interfaces = existingIntfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add discovery source
|
||||||
|
const sources = new Set(mergedMeta.discovery_sources || []);
|
||||||
|
sources.add(sourceName);
|
||||||
|
mergedMeta.discovery_sources = [...sources];
|
||||||
|
|
||||||
|
mergedMeta.last_seen = Date.now();
|
||||||
|
|
||||||
|
// Pick the most human name across sources. Rank first, length only as
|
||||||
|
// a tie-break within a rank -- comparing lengths alone let a UniFi
|
||||||
|
// client named after its MAC ("ac:16:2d:b3:da:80", 17 chars) beat the
|
||||||
|
// hypervisor's real hostname from Proxmox ("dl380-0", 7), so the
|
||||||
|
// Directory listed MAC addresses where host names belong.
|
||||||
|
//
|
||||||
|
// NB: `\\.` inside a regex LITERAL matches a backslash, not a dot, so
|
||||||
|
// the old isIp returned false for every input and IP-shaped names were
|
||||||
|
// never replaced either. It is `\.` here.
|
||||||
|
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
|
||||||
|
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
|
||||||
|
// 2 = a real name, 1 = an IP (at least routable/recognizable), 0 = a
|
||||||
|
// MAC or nothing (pure machine identifier, the worst thing to show).
|
||||||
|
const nameRank = (str) => {
|
||||||
|
if (!str || !String(str).trim()) return 0;
|
||||||
|
if (isMac(str)) return 0;
|
||||||
|
if (isIp(str)) return 1;
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
|
||||||
|
let bestName = existing.name;
|
||||||
|
if (res.name) {
|
||||||
|
const incoming = nameRank(res.name);
|
||||||
|
const current = nameRank(bestName);
|
||||||
|
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
|
||||||
|
bestName = res.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await existing.update({
|
||||||
|
name: bestName,
|
||||||
|
description: res.description || existing.description,
|
||||||
|
metadata: mergedMeta,
|
||||||
|
updated_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
res._actualId = existing.id;
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
const sources = new Set([sourceName]);
|
||||||
|
res.metadata.discovery_sources = [...sources];
|
||||||
|
res.metadata.last_seen = Date.now();
|
||||||
|
|
||||||
|
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
|
||||||
|
|
||||||
|
const created = await Resource.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
kind: res.kind || 'unmanaged_device',
|
||||||
|
name: res.name || slug,
|
||||||
|
slug: slug,
|
||||||
|
metadata: res.metadata,
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
|
||||||
|
newDevices++;
|
||||||
|
res._actualId = created.id; // Map original slug to actual ID
|
||||||
|
// Make it visible to the rest of THIS payload: a Proxmox run reports
|
||||||
|
// the endpoint, then its nodes, then their guests, and two of them can
|
||||||
|
// legitimately share a MAC/IP. Without this the same device could be
|
||||||
|
// created twice in a single run.
|
||||||
|
allRes.push(created);
|
||||||
|
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now process edges. `allRes` above is already current -- rows created in
|
||||||
|
// the loop were pushed onto it -- so no second full read is needed.
|
||||||
|
const existingEdges = await ResourceEdge.list();
|
||||||
|
|
||||||
|
for (const edge of edges) {
|
||||||
|
// Find parent ID. It might be in the current payload (mapped to _actualId) or in DB by slug
|
||||||
|
let parentId = null;
|
||||||
|
const parentResInPayload = resources.find(r => r._originalSlug === edge.parentSlug);
|
||||||
|
if (parentResInPayload && parentResInPayload._actualId) {
|
||||||
|
parentId = parentResInPayload._actualId;
|
||||||
|
} else {
|
||||||
|
const parentResInDb = allRes.find(r => r.slug === edge.parentSlug);
|
||||||
|
if (parentResInDb) parentId = parentResInDb.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find child ID
|
||||||
|
let childId = null;
|
||||||
|
const childResInPayload = resources.find(r => r._originalSlug === edge.childSlug);
|
||||||
|
if (childResInPayload && childResInPayload._actualId) {
|
||||||
|
childId = childResInPayload._actualId;
|
||||||
|
} else {
|
||||||
|
const childResInDb = allRes.find(r => r.slug === edge.childSlug);
|
||||||
|
if (childResInDb) childId = childResInDb.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two slugs in one payload can resolve to the SAME resource once the
|
||||||
|
// matcher has merged them -- a Proxmox endpoint reached at the address
|
||||||
|
// of the node that answers for it is the case that produced this. The
|
||||||
|
// edge would then make a resource its own parent, which renders as an
|
||||||
|
// infinitely nested tree and defeats every ancestor walk in the app
|
||||||
|
// (findAncestorSiteSlug, withResolvedAddress) that relies on a cycle
|
||||||
|
// guard to terminate rather than to be correct.
|
||||||
|
if (parentId && childId && parentId === childId) {
|
||||||
|
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping self-edge on ${edge.parentSlug} -> ${edge.childSlug} (both resolved to the same resource)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Likewise refuse an edge that closes a loop: if the proposed parent is
|
||||||
|
// already a descendant of the proposed child, adding this makes a cycle.
|
||||||
|
if (parentId && childId && isDescendant(parentId, childId, existingEdges)) {
|
||||||
|
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping ${edge.parentSlug} -> ${edge.childSlug} (would create a cycle)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentId && childId) {
|
||||||
|
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||||
|
if (!edgeExists) {
|
||||||
|
const created = await ResourceEdge.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
parentId,
|
||||||
|
childId,
|
||||||
|
relation: edge.relation
|
||||||
|
});
|
||||||
|
// Keep the in-memory edge list current so the cycle check above sees
|
||||||
|
// edges added earlier in this same payload.
|
||||||
|
existingEdges.push(created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newDevices > 0) {
|
||||||
|
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async garbageCollect(staleMs = 7 * 24 * 60 * 60 * 1000) {
|
||||||
|
const allRes = await Resource.list();
|
||||||
|
const cutoff = Date.now() - staleMs;
|
||||||
|
let archived = 0;
|
||||||
|
|
||||||
|
for (const res of allRes) {
|
||||||
|
const meta = res.metadata || {};
|
||||||
|
const sources = meta.discovery_sources || [];
|
||||||
|
// Only garbage collect things that are exclusively auto-discovered
|
||||||
|
if (sources.length > 0 && !sources.includes('manual')) {
|
||||||
|
if (meta.last_seen && meta.last_seen < cutoff && meta.lifecycle_state !== 'archived') {
|
||||||
|
meta.lifecycle_state = 'archived';
|
||||||
|
await res.update({ metadata: meta, updated_on: Math.floor(Date.now() / 1000) });
|
||||||
|
archived++;
|
||||||
|
WebhookEmitter.emit('discovery.device_archived', res.toJSON());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (archived > 0) console.log(`[DiscoveryReconciler] Garbage collected ${archived} stale devices.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { DiscoveryReconciler };
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Plugin type registry.
|
||||||
|
//
|
||||||
|
// A **plugin type** is a module under nodejs/plugins/<category>/<type>.js
|
||||||
|
// exporting a manifest:
|
||||||
|
//
|
||||||
|
// { type, category, name, description, configSchema[], validate(), run() }
|
||||||
|
//
|
||||||
|
// `configSchema` is an array of field descriptors that drive the admin UI form
|
||||||
|
// and API validation. Fields with `secret: true` are stored in OpenBao
|
||||||
|
// (secret/plugins/<instance-id>/conf via utils/plugin_secrets.js); all other
|
||||||
|
// field values live in the PluginInstance DB row's `config` JSON column.
|
||||||
|
//
|
||||||
|
// `run(cfg)` does the work; the discovery plugins keep their historical
|
||||||
|
// `discover(cfg)` name and add `run` as an alias (the loader uses `run`).
|
||||||
|
//
|
||||||
|
// A **plugin instance** (models/plugin_instance.js) is a configured, loadable
|
||||||
|
// copy of a type — you can have several of the same type. This registry only
|
||||||
|
// knows about *types*; instances live in the DB.
|
||||||
|
//
|
||||||
|
// The scan happens once at require time (the set of installed .js files does
|
||||||
|
// not change without a redeploy). Runtime load/unload is per-instance, not
|
||||||
|
// per-type — adding a new plugin type still needs a restart.
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const pluginsRoot = path.join(__dirname, '../plugins');
|
||||||
|
const MASK = '********';
|
||||||
|
|
||||||
|
// type -> module. Built once.
|
||||||
|
const _modules = new Map();
|
||||||
|
// type -> manifest summary (a safe, serializable subset for the UI/API).
|
||||||
|
const _summaries = [];
|
||||||
|
|
||||||
|
function loadAll() {
|
||||||
|
_modules.clear();
|
||||||
|
_summaries.length = 0;
|
||||||
|
if (!fs.existsSync(pluginsRoot)) return;
|
||||||
|
for (const category of fs.readdirSync(pluginsRoot)) {
|
||||||
|
const catDir = path.join(pluginsRoot, category);
|
||||||
|
const stat = fs.statSync(catDir);
|
||||||
|
if (!stat.isDirectory()) continue;
|
||||||
|
for (const file of fs.readdirSync(catDir)) {
|
||||||
|
if (!file.endsWith('.js')) continue;
|
||||||
|
const type = path.basename(file, '.js');
|
||||||
|
// require fresh-ish: a plugin file should be idempotent to load. Clear
|
||||||
|
// from the cache so a future re-scan (e.g. in tests) picks up edits.
|
||||||
|
const full = path.join(catDir, file);
|
||||||
|
delete require.cache[require.resolve(full)];
|
||||||
|
const mod = require(full);
|
||||||
|
// Backfill manifest defaults so older plugins (only exporting discover)
|
||||||
|
// still register with a usable summary.
|
||||||
|
const manifest = {
|
||||||
|
type: mod.type || type,
|
||||||
|
category: mod.category || category,
|
||||||
|
name: mod.name || type,
|
||||||
|
description: mod.description || '',
|
||||||
|
configSchema: Array.isArray(mod.configSchema) ? mod.configSchema : [],
|
||||||
|
validate: typeof mod.validate === 'function' ? mod.validate : null,
|
||||||
|
run: typeof mod.run === 'function' ? mod.run
|
||||||
|
: typeof mod.discover === 'function' ? mod.discover : null
|
||||||
|
};
|
||||||
|
_modules.set(manifest.type, { mod, manifest });
|
||||||
|
_summaries.push({
|
||||||
|
type: manifest.type,
|
||||||
|
category: manifest.category,
|
||||||
|
name: manifest.name,
|
||||||
|
description: manifest.description,
|
||||||
|
configSchema: manifest.configSchema
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAll();
|
||||||
|
|
||||||
|
// All registered plugin types, as serializable summaries (no functions).
|
||||||
|
// Used by GET /api/plugins/types to build the "New Plugin" picker + form.
|
||||||
|
function getTypes() {
|
||||||
|
return _summaries.map(s => ({ ...s }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// The raw module for a type (has run/validate/discover). Throws if unknown.
|
||||||
|
function getModule(type) {
|
||||||
|
const entry = _modules.get(type);
|
||||||
|
if (!entry) {
|
||||||
|
const err = new Error(`Unknown plugin type: ${type}`);
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return entry.mod;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The manifest summary for a type. Returns null if unknown (callers gate on
|
||||||
|
// this to validate a pluginType before creating an instance).
|
||||||
|
function getManifest(type) {
|
||||||
|
const entry = _modules.get(type);
|
||||||
|
return entry ? entry.manifest : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys of the secret fields in a type's configSchema.
|
||||||
|
function secretKeys(type) {
|
||||||
|
const m = getManifest(type);
|
||||||
|
if (!m) return [];
|
||||||
|
return m.configSchema.filter(f => f.secret).map(f => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-secret field keys in a type's configSchema.
|
||||||
|
function publicKeys(type) {
|
||||||
|
const m = getManifest(type);
|
||||||
|
if (!m) return [];
|
||||||
|
return m.configSchema.filter(f => !f.secret).map(f => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All declared field keys (secret + non-secret) — for required-field validation.
|
||||||
|
function fieldKeys(type) {
|
||||||
|
const m = getManifest(type);
|
||||||
|
if (!m) return [];
|
||||||
|
return m.configSchema.map(f => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Required field keys.
|
||||||
|
function requiredKeys(type) {
|
||||||
|
const m = getManifest(type);
|
||||||
|
if (!m) return [];
|
||||||
|
return m.configSchema.filter(f => f.required).map(f => f.key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Replace each present secret value with MASK, keeping the keys so the UI can
|
||||||
|
// render a prefilled (masked) password field. Non-secret values are passed
|
||||||
|
// through unchanged. `values` is a plain object of field->value.
|
||||||
|
function mask(type, values) {
|
||||||
|
if (!values || typeof values !== 'object') return values;
|
||||||
|
const sk = new Set(secretKeys(type));
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(values)) {
|
||||||
|
out[k] = sk.has(k) && v ? MASK : v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split a flat {field: value} object (as the UI/API sends it) into non-secret
|
||||||
|
// config (for the DB row) and secret values (for OpenBao). Unknown keys are
|
||||||
|
// dropped — only declared configSchema fields are kept.
|
||||||
|
function splitConfig(type, flat) {
|
||||||
|
const manifest = getManifest(type);
|
||||||
|
const config = {};
|
||||||
|
const secrets = {};
|
||||||
|
if (!manifest || !flat) return { config, secrets };
|
||||||
|
for (const f of manifest.configSchema) {
|
||||||
|
if (!(f.key in flat)) continue;
|
||||||
|
if (f.secret) secrets[f.key] = flat[f.key];
|
||||||
|
else config[f.key] = flat[f.key];
|
||||||
|
}
|
||||||
|
return { config, secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getTypes,
|
||||||
|
getModule,
|
||||||
|
getManifest,
|
||||||
|
secretKeys,
|
||||||
|
publicKeys,
|
||||||
|
fieldKeys,
|
||||||
|
requiredKeys,
|
||||||
|
mask,
|
||||||
|
splitConfig,
|
||||||
|
// for tests
|
||||||
|
_reload: loadAll
|
||||||
|
};
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Discovery / plugin scheduler.
|
||||||
|
//
|
||||||
|
// Generalized from the one-shot discovery-plugin loader: plugin *types* live
|
||||||
|
// under nodejs/plugins/<category>/<type>.js (see services/plugin_registry.js),
|
||||||
|
// and configured, loadable/unloadable *instances* live in the PluginInstance
|
||||||
|
// table (models/plugin_instance.js). This module schedules enabled instances
|
||||||
|
// on cron via BullMQ JobSchedulers and runs them in a Worker.
|
||||||
|
//
|
||||||
|
// Each instance owns a stable JobScheduler id (`plugin:<instanceId>`) so load/
|
||||||
|
// unload can add/remove a single schedule without disturbing the others —
|
||||||
|
// `upsertJobScheduler`/`removeJobScheduler` (BullMQ v6) take that id directly.
|
||||||
|
//
|
||||||
|
// Per-instance secrets are merged in from OpenBao (utils/plugin_secrets.js) at
|
||||||
|
// run time; the plugin's run()/discover() receives the combined non-secret
|
||||||
|
// config + secret values as a single `config` object, exactly as the legacy
|
||||||
|
// static-config path did.
|
||||||
|
|
||||||
|
const { Queue, Worker } = require('bullmq');
|
||||||
|
const { DiscoveryReconciler } = require('./discovery_reconciler');
|
||||||
|
const pluginRegistry = require('./plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
const { PluginInstance, STATUS } = require('../models/plugin_instance');
|
||||||
|
const Redis = require('ioredis');
|
||||||
|
|
||||||
|
// Ensure Redis connection works for BullMQ
|
||||||
|
const redisOpts = { maxRetriesPerRequest: null };
|
||||||
|
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisOpts);
|
||||||
|
|
||||||
|
const discoveryQueue = new Queue('discovery', { connection });
|
||||||
|
|
||||||
|
const RUN = 'run_plugin';
|
||||||
|
const GC = 'garbage_collect';
|
||||||
|
function pluginSchedulerId(id) { return `plugin:${id}`; }
|
||||||
|
|
||||||
|
const worker = new Worker('discovery', async job => {
|
||||||
|
if (job.name === RUN) {
|
||||||
|
await runPluginJob(job.data && job.data.instanceId);
|
||||||
|
} else if (job.name === GC) {
|
||||||
|
console.log('[Scheduler] Running garbage collection');
|
||||||
|
await DiscoveryReconciler.garbageCollect();
|
||||||
|
}
|
||||||
|
}, { connection });
|
||||||
|
|
||||||
|
// Run one plugin instance. Loads the row (skip silently if it was deleted or
|
||||||
|
// disabled after the job was enqueued), merges its OpenBao secrets into its
|
||||||
|
// config, calls the plugin's run()/discover(), and — for discovery plugins —
|
||||||
|
// reconciles the result into the resource graph under the instance's slug.
|
||||||
|
// Bookkeeping (lastRunAt/lastStatus/lastError) is stamped on the row so the UI
|
||||||
|
// can show run state without querying BullMQ.
|
||||||
|
async function runPluginJob(instanceId) {
|
||||||
|
if (!instanceId) { console.warn('[Scheduler] run_plugin job with no instanceId'); return; }
|
||||||
|
const instance = await PluginInstance.get(instanceId);
|
||||||
|
if (!instance) { console.warn(`[Scheduler] instance ${instanceId} gone — skipping`); return; }
|
||||||
|
if (!instance.enabled) { console.warn(`[Scheduler] instance ${instance.slug} (${instanceId}) disabled — skipping`); return; }
|
||||||
|
|
||||||
|
let mod;
|
||||||
|
try { mod = pluginRegistry.getModule(instance.pluginType); }
|
||||||
|
catch (err) {
|
||||||
|
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} unavailable:`, err.message);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: `plugin type unavailable: ${instance.pluginType}` });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const runFn = mod.run || mod.discover;
|
||||||
|
if (typeof runFn !== 'function') {
|
||||||
|
console.error(`[Scheduler] instance ${instance.slug}: type ${instance.pluginType} has no run()/discover()`);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.ERROR, lastError: 'plugin type has no run()/discover()' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
|
||||||
|
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null, lastLog: null });
|
||||||
|
let logs = [];
|
||||||
|
try {
|
||||||
|
const cfg = await pluginSecrets.mergeForRun(instance);
|
||||||
|
cfg.log = (msg) => {
|
||||||
|
logs.push(`[${new Date().toISOString()}] ${msg}`);
|
||||||
|
console.log(`[Plugin ${instance.slug}] ${msg}`);
|
||||||
|
if (logs.length > 1000) logs.shift();
|
||||||
|
};
|
||||||
|
const payload = await runFn(cfg);
|
||||||
|
if (instance.category === 'discovery') {
|
||||||
|
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
||||||
|
}
|
||||||
|
await instance.update({ lastStatus: STATUS.OK, lastError: null, lastLog: logs.join('\n') });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[Scheduler] Plugin ${instance.slug} failed:`, err.message);
|
||||||
|
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err), lastLog: logs.join('\n') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule one instance: upsert a repeatable JobScheduler keyed by its id. Does
|
||||||
|
// NOT trigger an immediate run — call runInstanceNow(id) separately for that
|
||||||
|
// (used on boot and on "load"). Safe to call repeatedly (upsert is idempotent
|
||||||
|
// and will update the cron if it changed).
|
||||||
|
async function scheduleInstance(instance) {
|
||||||
|
if (!instance || !instance.id) return;
|
||||||
|
if (!instance.enabled) { await unscheduleInstance(instance.id); return; }
|
||||||
|
const cron = instance.cron || '0 * * * *';
|
||||||
|
await discoveryQueue.upsertJobScheduler(pluginSchedulerId(instance.id), { pattern: cron }, {
|
||||||
|
name: RUN,
|
||||||
|
data: { instanceId: instance.id }
|
||||||
|
});
|
||||||
|
console.log(`[Scheduler] Scheduled instance ${instance.slug} with cron ${cron}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove an instance's repeatable schedule. No-op if it had none.
|
||||||
|
async function unscheduleInstance(id) {
|
||||||
|
if (!id) return;
|
||||||
|
try { await discoveryQueue.removeJobScheduler(pluginSchedulerId(id)); }
|
||||||
|
catch (err) { /* missing scheduler is fine */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue a single immediate run for an instance (the "Run now" button / boot
|
||||||
|
// kick). Runs once regardless of enabled, on top of any schedule.
|
||||||
|
async function runInstanceNow(id) {
|
||||||
|
if (!id) return;
|
||||||
|
await discoveryQueue.add(RUN, { instanceId: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time legacy migration: if the PluginInstance table is empty AND
|
||||||
|
// conf.discovery.plugins has entries (the old static-config shape), seed one
|
||||||
|
// instance per configured type and copy its secret fields into OpenBao. After
|
||||||
|
// the first boot, the table is non-empty and the static config is ignored.
|
||||||
|
// Idempotent (guarded by the empty-table check).
|
||||||
|
async function migrateLegacyPlugins(discoveryConfig) {
|
||||||
|
const existing = await PluginInstance.list();
|
||||||
|
if (existing && existing.length) return;
|
||||||
|
|
||||||
|
const legacy = discoveryConfig && discoveryConfig.plugins;
|
||||||
|
if (!legacy || typeof legacy !== 'object') return;
|
||||||
|
const names = Object.keys(legacy);
|
||||||
|
if (!names.length) return;
|
||||||
|
|
||||||
|
console.log(`[Scheduler] Migrating ${names.length} legacy discovery plugin(s) to instances…`);
|
||||||
|
for (const name of names) {
|
||||||
|
const entry = legacy[name] || {};
|
||||||
|
const manifest = pluginRegistry.getManifest(name);
|
||||||
|
if (!manifest) {
|
||||||
|
console.warn(`[Scheduler] legacy plugin '${name}' has no registered type — skipping`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// splitConfig keeps only declared configSchema fields and separates secret
|
||||||
|
// from non-secret. Legacy `enabled`/`cron` are not in configSchema, so they
|
||||||
|
// are dropped here and read from the entry directly below.
|
||||||
|
const { config, secrets } = pluginRegistry.splitConfig(name, entry);
|
||||||
|
const instance = await PluginInstance.create({
|
||||||
|
pluginType: name,
|
||||||
|
category: manifest.category,
|
||||||
|
name: manifest.name,
|
||||||
|
slug: name,
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
cron: entry.cron || '0 * * * *',
|
||||||
|
config,
|
||||||
|
created_by: 'legacy-migration'
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await pluginSecrets.write(instance.id, secrets);
|
||||||
|
console.log(`[Scheduler] migrated '${name}' -> instance ${instance.id} (slug ${instance.slug})`);
|
||||||
|
} catch (err) {
|
||||||
|
// The instance row exists; if we can't write secrets (e.g. the sso-broker
|
||||||
|
// policy predates theta-suite v1.30.1) the operator gets a clear error
|
||||||
|
// from the API on edit, and the instance still runs with its non-secret
|
||||||
|
// config. Don't delete the row — the operator just needs to re-run
|
||||||
|
// setup.sh and edit/save the secrets.
|
||||||
|
console.error(`[Scheduler] migrated '${name}' row but FAILED to write secrets:`, err.message);
|
||||||
|
await instance.update({ lastStatus: STATUS.ERROR, lastError: `secret migration failed: ${err.message}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Boot-time initialization: clear stale schedulers, schedule garbage collection,
|
||||||
|
// migrate any legacy static-config plugins, then schedule every enabled
|
||||||
|
// instance and kick one immediate run for each.
|
||||||
|
async function initScheduler(discoveryConfig) {
|
||||||
|
// Clear stale plugin/gc schedulers from a previous boot. Other-named
|
||||||
|
// schedulers (none in this app) are left alone.
|
||||||
|
try {
|
||||||
|
const schedulers = await discoveryQueue.getJobSchedulers();
|
||||||
|
for (const s of schedulers) {
|
||||||
|
if (s.name === RUN || s.name === GC) {
|
||||||
|
await discoveryQueue.removeJobScheduler(s.key || s.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('[Scheduler] Could not clear old job schedulers:', e.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Daily garbage collection of stale discovery resources.
|
||||||
|
await discoveryQueue.upsertJobScheduler(GC, { pattern: '0 0 * * *' }, { name: GC, data: {} });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateLegacyPlugins(discoveryConfig);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Scheduler] legacy migration failed:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = await PluginInstance.listEnabled();
|
||||||
|
for (const instance of enabled) {
|
||||||
|
await scheduleInstance(instance);
|
||||||
|
await runInstanceNow(instance.id); // boot kick
|
||||||
|
}
|
||||||
|
console.log(`[Scheduler] initialized — ${enabled.length} instance(s) scheduled`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
initScheduler,
|
||||||
|
scheduleInstance,
|
||||||
|
unscheduleInstance,
|
||||||
|
runInstanceNow,
|
||||||
|
discoveryQueue,
|
||||||
|
connection
|
||||||
|
};
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const fetch = require('node-fetch');
|
||||||
|
|
||||||
|
class WebhookEmitter {
|
||||||
|
static async emit(event, payload) {
|
||||||
|
try {
|
||||||
|
const hooks = await Webhook.list({ where: { isActive: true } });
|
||||||
|
const matched = hooks.filter(h => !h.events || h.events.length === 0 || h.events.includes(event));
|
||||||
|
|
||||||
|
for (const hook of matched) {
|
||||||
|
this.sendPayload(hook, event, payload).catch(err => console.error(`Webhook ${hook.name} failed:`, err.message));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error emitting webhook:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static async sendPayload(hook, event, payload) {
|
||||||
|
const body = JSON.stringify({ event, payload, timestamp: Date.now() });
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
|
||||||
|
if (hook.secret) {
|
||||||
|
const signature = crypto.createHmac('sha256', hook.secret).update(body).digest('hex');
|
||||||
|
headers['X-Theta-Signature'] = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(hook.url, { method: 'POST', body, headers, timeout: 5000 });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Status ${res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { WebhookEmitter };
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Self-service access requests, end to end: request -> approve -> the grant is
|
||||||
|
// real (visible through /api/discovery/me), plus the guards that keep the flow
|
||||||
|
// from being abused or double-applied.
|
||||||
|
//
|
||||||
|
// The seed `test` user is in app_sso_admin, so it is both the requester and an
|
||||||
|
// eligible approver here. That is unusual in production but exactly what makes
|
||||||
|
// a single-user test able to walk the whole loop.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
let siteSlug;
|
||||||
|
let hostSlug;
|
||||||
|
let hostId;
|
||||||
|
let accessGroupCn;
|
||||||
|
|
||||||
|
// Unique per run: these create real LDAP groups and SQL rows, and a rerun must
|
||||||
|
// not collide with the previous run's leftovers.
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
|
||||||
|
siteSlug = `artest-site-${stamp}`;
|
||||||
|
const site = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: `AR Test Site ${stamp}`, slug: siteSlug, kind: 'site' });
|
||||||
|
expect(site.status).toBe(200);
|
||||||
|
|
||||||
|
hostSlug = `artest-host-${stamp}`;
|
||||||
|
const host = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({
|
||||||
|
name: `AR Test Host ${stamp}`,
|
||||||
|
slug: hostSlug,
|
||||||
|
kind: 'host',
|
||||||
|
parentSlug: siteSlug,
|
||||||
|
metadata: { ip: '10.99.99.9' },
|
||||||
|
});
|
||||||
|
expect(host.status).toBe(200);
|
||||||
|
hostId = host.body.results.id;
|
||||||
|
|
||||||
|
// Creating a host auto-provisions <site>_host_<slug>_access / _admin
|
||||||
|
// (docs/GROUPS.md §2 — the kind is part of the name).
|
||||||
|
accessGroupCn = `${siteSlug}_host_${hostSlug}_access`;
|
||||||
|
const adminGroupCn = `${siteSlug}_host_${hostSlug}_admin`;
|
||||||
|
|
||||||
|
// The creator is seeded into both groups -- groupOfNames requires at least
|
||||||
|
// one member, so Group.add puts the owner's DN there -- and _admin is nested
|
||||||
|
// into _access, so membership of either grants access. A user who already
|
||||||
|
// has access cannot request it (correctly), so step out of both to be a
|
||||||
|
// legitimate requester. Removing only _access would leave the grant intact
|
||||||
|
// through the nesting, which is exactly the kind of thing these tests exist
|
||||||
|
// to catch.
|
||||||
|
for (const cn of [adminGroupCn, accessGroupCn]) {
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — the request half', () => {
|
||||||
|
let requestId;
|
||||||
|
|
||||||
|
test('POST /api/access-requests creates a pending request on the member group', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug, note: 'need it for testing' });
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toBeDefined();
|
||||||
|
expect(res.body.results.status).toBe('pending');
|
||||||
|
expect(res.body.results.uid).toBe('test');
|
||||||
|
// Must target the _access group, never the _admin one: asking to use a
|
||||||
|
// resource may not silently escalate to administering it.
|
||||||
|
expect(res.body.results.groupCn).toBe(accessGroupCn);
|
||||||
|
requestId = res.body.results.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second request for the same resource is rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/access-requests/mine lists it with the resource attached', async () => {
|
||||||
|
const res = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.results.find(r => r.id === requestId);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
expect(found.resource.slug).toBe(hostSlug);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/access-requests shows it to an approver', async () => {
|
||||||
|
const res = await request(app).get('/api/access-requests').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.some(r => r.id === requestId)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requesting an unknown resource is a 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: `no-such-resource-${stamp}` });
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — approval actually grants', () => {
|
||||||
|
let requestId;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const mine = await request(app).get('/api/access-requests/mine').set('auth-token', token);
|
||||||
|
const pending = mine.body.results.find(r => r.groupCn === accessGroupCn && r.status === 'pending');
|
||||||
|
requestId = pending && pending.id;
|
||||||
|
expect(requestId).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the resource is NOT in /api/discovery/me before approval', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.some(r => r.id === hostId)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST /:id/approve marks it approved', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/access-requests/${requestId}/approve`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ decisionNote: 'ok' });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.status).toBe('approved');
|
||||||
|
expect(res.body.results.decidedBy).toBe('test');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('approving twice is rejected', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post(`/api/access-requests/${requestId}/approve`)
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({});
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The payoff, and the regression guard for the user.groups bug: /me resolved
|
||||||
|
// groups off req.user.groups, which does not exist on a User (it carries
|
||||||
|
// memberOf), so this endpoint used to return only isPublic resources no
|
||||||
|
// matter what the caller was actually a member of.
|
||||||
|
test('the resource IS in /api/discovery/me after approval', async () => {
|
||||||
|
const res = await request(app).get('/api/discovery/me').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const found = res.body.results.find(r => r.id === hostId);
|
||||||
|
expect(found).toBeDefined();
|
||||||
|
// And it answers "how do I reach it" rather than just naming the thing.
|
||||||
|
expect(found.resolvedAddress).toBe('10.99.99.9');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an already-granted resource cannot be requested again', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug: hostSlug });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Admin access visibility', () => {
|
||||||
|
test('GET /api/directory-admin/access-summary counts the host\'s groups + members', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/access-summary')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const summary = res.body.results[hostId];
|
||||||
|
expect(summary).toBeDefined();
|
||||||
|
// _access and _admin were both auto-created and linked.
|
||||||
|
expect(summary.groups.length).toBe(2);
|
||||||
|
expect(summary.groups.every(g => g.exists)).toBe(true);
|
||||||
|
// The approval above put `test` in the access group.
|
||||||
|
expect(summary.memberCount).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /api/directory-admin/user-access/:uid answers the reverse question', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/user-access/test')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.uid).toBe('test');
|
||||||
|
const entry = res.body.results.resources.find(r => r.id === hostId);
|
||||||
|
expect(entry).toBeDefined();
|
||||||
|
expect(entry.groupCn).toBe(accessGroupCn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('user-access for an unknown uid is a 404', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.get('/api/directory-admin/user-access/definitely-not-a-user')
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Access requests — withdrawal', () => {
|
||||||
|
test('a requester can withdraw their own pending request', async () => {
|
||||||
|
// A second resource, so this does not disturb the approved one above.
|
||||||
|
const slug = `artest-host2-${stamp}`;
|
||||||
|
const host = await request(app)
|
||||||
|
.post('/api/directory-admin/resources')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: `AR Test Host2 ${stamp}`, slug, kind: 'host', parentSlug: siteSlug });
|
||||||
|
expect(host.status).toBe(200);
|
||||||
|
|
||||||
|
// Same as the top-level setup: step out of the auto-created groups the
|
||||||
|
// creator is seeded into (docs/GROUPS.md §2 — kind is part of the name),
|
||||||
|
// or this is a request for access already held.
|
||||||
|
for (const cn of [`${siteSlug}_host_${slug}_admin`, `${siteSlug}_host_${slug}_access`]) {
|
||||||
|
await request(app)
|
||||||
|
.delete(`/api/group/${encodeURIComponent(cn)}/test`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created = await request(app)
|
||||||
|
.post('/api/access-requests')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ slug });
|
||||||
|
expect(created.status).toBe(200);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/access-requests/${created.body.results.id}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results.status).toBe('cancelled');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
// In-memory stand-in for OpenBao. The signing key lives at secret/agent/
|
||||||
|
// signing-key in production; here we only need it to persist across calls so
|
||||||
|
// the "same key every time" property is actually exercised rather than mocked
|
||||||
|
// away.
|
||||||
|
const mockBaoStore = new Map();
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(async (path) => mockBaoStore.get(path) || null),
|
||||||
|
set: jest.fn(async (path, value) => { mockBaoStore.set(path, value); }),
|
||||||
|
request: jest.fn(async () => ({ ok: true, status: 200 }))
|
||||||
|
}));
|
||||||
|
|
||||||
|
const agentManager = require('../utils/agent_manager');
|
||||||
|
const agentKeys = require('../utils/agent_keys');
|
||||||
|
|
||||||
|
// The manager is now keyed by enrolled Agent rows rather than by a bare token
|
||||||
|
// string, so these use a stub row with the same surface the real model gives:
|
||||||
|
// an id, and an update() that records what would be persisted.
|
||||||
|
function stubAgent(overrides = {}) {
|
||||||
|
const row = {
|
||||||
|
id: overrides.id || crypto.randomUUID(),
|
||||||
|
name: overrides.name || 'test-agent',
|
||||||
|
resourceId: overrides.resourceId || null,
|
||||||
|
revoked: false,
|
||||||
|
persisted: {},
|
||||||
|
...overrides
|
||||||
|
};
|
||||||
|
row.update = jest.fn(async (patch) => {
|
||||||
|
Object.assign(row.persisted, patch);
|
||||||
|
Object.assign(row, patch);
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('AgentManager PROTOCOL.md v1.2.0 Compliance', () => {
|
||||||
|
let mockWs;
|
||||||
|
let sentMessages;
|
||||||
|
let agent;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
sentMessages = [];
|
||||||
|
mockWs = {
|
||||||
|
readyState: 1, // OPEN
|
||||||
|
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
|
||||||
|
close: jest.fn()
|
||||||
|
};
|
||||||
|
agent = stubAgent();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('registers an agent and reports it as connected', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
const state = agentManager.liveState(agent.id);
|
||||||
|
expect(state.connected).toBe(true);
|
||||||
|
expect(state.ipAddress).toBe('192.168.1.100');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// registerAgent must not be async: the WS `message` listener is attached in
|
||||||
|
// the same tick, and `ws` drops events emitted before a listener exists. An
|
||||||
|
// awaited DB write here swallowed every agent's first discovery frame, which
|
||||||
|
// is the one it sends immediately on connect.
|
||||||
|
test('registerAgent is synchronous so no message can be missed', () => {
|
||||||
|
const result = agentManager.registerAgent(agent, mockWs, '10.0.0.1');
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists discovery to the agent row (Section 3.1)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleDiscovery(agent, {
|
||||||
|
hostname: 'node-01.local',
|
||||||
|
ip_addresses: ['192.168.1.100', '10.0.0.5'],
|
||||||
|
os: 'Ubuntu 24.04 LTS',
|
||||||
|
kernel: '6.8.0-31-generic',
|
||||||
|
cpu: 'AMD EPYC 7763',
|
||||||
|
ram_total_gb: 32.0,
|
||||||
|
disk_total_gb: 500.0,
|
||||||
|
location: 'dc-chicago-rack-4'
|
||||||
|
});
|
||||||
|
|
||||||
|
const saved = agent.persisted.lastDiscovery;
|
||||||
|
expect(saved.hostname).toBe('node-01.local');
|
||||||
|
expect(saved.os).toBe('Ubuntu 24.04 LTS');
|
||||||
|
expect(saved.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
|
||||||
|
// Durable, not just in memory: an agent that goes offline keeps its facts.
|
||||||
|
expect(agent.persisted.last_seen).toEqual(expect.any(Number));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persists telemetry to the agent row (Section 3.2)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleTelemetry(agent, {
|
||||||
|
cpu_usage_percent: 14.5,
|
||||||
|
ram_usage_percent: 42.1,
|
||||||
|
disk_usage_percent: 68.0,
|
||||||
|
zfs_health: 'ONLINE',
|
||||||
|
gpu_usage_percent: -1.0,
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.persisted.lastTelemetry.cpu_usage_percent).toBe(14.5);
|
||||||
|
expect(agent.persisted.lastTelemetry.zfs_health).toBe('ONLINE');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('responds to heartbeat with heartbeat_ack (Section 3.3)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
await agentManager.handleHeartbeat(agent, { timestamp: new Date().toISOString() }, mockWs);
|
||||||
|
|
||||||
|
expect(mockWs.send).toHaveBeenCalled();
|
||||||
|
const lastMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(lastMsg.type).toBe('heartbeat_ack');
|
||||||
|
expect(lastMsg.payload.timestamp).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('canonicalizes and signs high-risk commands with Ed25519 (Section 5)', async () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
|
||||||
|
const rawPayload = { script: 'uptime', location: 'datacenter' };
|
||||||
|
const msg = await agentManager.sendCommand(agent, 'arbitrary_bash', rawPayload, true);
|
||||||
|
|
||||||
|
expect(msg.type).toBe('arbitrary_bash');
|
||||||
|
expect(typeof msg.payload.signature).toBe('string');
|
||||||
|
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
const isValid = crypto.verify(
|
||||||
|
null,
|
||||||
|
Buffer.from(agentManager.canonicalize(rawPayload), 'utf8'),
|
||||||
|
crypto.createPublicKey(keys.publicKeyPem),
|
||||||
|
Buffer.from(msg.payload.signature, 'base64')
|
||||||
|
);
|
||||||
|
expect(isValid).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The canonical form has to match the Go agent's byte for byte. Go's
|
||||||
|
// encoding/json escapes <, > and & by default and JSON.stringify does not, so
|
||||||
|
// the agent uses SetEscapeHTML(false); this pins the server's half of that
|
||||||
|
// contract. See theta-agent TestCanonicalizeMatchesServerForm.
|
||||||
|
test('canonical form is sorted, unescaped, and omits the signature', () => {
|
||||||
|
const canonical = agentManager.canonicalize({
|
||||||
|
script: 'echo a > b && c',
|
||||||
|
comment: 'x&y',
|
||||||
|
signature: 'should-not-appear'
|
||||||
|
});
|
||||||
|
expect(canonical).toBe('{"comment":"x&y","script":"echo a > b && c"}');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refuses to send to an agent that is not connected', async () => {
|
||||||
|
await expect(agentManager.sendCommand(agent, 'reload_config', {}, false))
|
||||||
|
.rejects.toThrow(/not connected/);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revocation that only applies on the next reconnect is not revocation.
|
||||||
|
test('disconnect drops the live socket immediately', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(true);
|
||||||
|
|
||||||
|
const dropped = agentManager.disconnect(agent.id, 4003, 'Enrollment revoked');
|
||||||
|
expect(dropped).toBe(true);
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4003, 'Enrollment revoked');
|
||||||
|
expect(agentManager.isConnected(agent.id)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a second connection for the same agent supersedes the first', () => {
|
||||||
|
agentManager.registerAgent(agent, mockWs, '192.168.1.100');
|
||||||
|
const secondWs = { readyState: 1, send: jest.fn(), close: jest.fn() };
|
||||||
|
agentManager.registerAgent(agent, secondWs, '192.168.1.101');
|
||||||
|
|
||||||
|
expect(mockWs.close).toHaveBeenCalledWith(4002, 'Superseded by new connection');
|
||||||
|
expect(agentManager.liveState(agent.id).ipAddress).toBe('192.168.1.101');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unknown agent id is simply not connected', () => {
|
||||||
|
expect(agentManager.isConnected('no-such-agent')).toBe(false);
|
||||||
|
expect(agentManager.liveState('no-such-agent')).toEqual({ connected: false, lastResponse: null });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('agent signing key', () => {
|
||||||
|
// The old manager generated a key pair in its constructor, so it changed on
|
||||||
|
// every restart and the public_key pinned in agent.yml stopped matching.
|
||||||
|
test('the same key is returned across repeated loads', async () => {
|
||||||
|
const first = await agentKeys.load();
|
||||||
|
const second = await agentKeys.load();
|
||||||
|
expect(first.publicKeyBase64).toBe(second.publicKeyBase64);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the exported public key is the raw 32 bytes agents pin', async () => {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
expect(Buffer.from(keys.publicKeyBase64, 'base64')).toHaveLength(32);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rawPublicKeyBase64 strips the SPKI wrapper', () => {
|
||||||
|
const { publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||||
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||||
|
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
||||||
|
});
|
||||||
|
const raw = Buffer.from(agentKeys.rawPublicKeyBase64(publicKey), 'base64');
|
||||||
|
expect(raw).toHaveLength(32);
|
||||||
|
// and it is the tail of the DER encoding
|
||||||
|
const der = crypto.createPublicKey(publicKey).export({ type: 'spki', format: 'der' });
|
||||||
|
expect(raw.equals(der.subarray(der.length - 32))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
const request = require('supertest');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
// Mock dependencies before requiring the route
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
}));
|
||||||
|
jest.mock('../utils/permission', () => ({
|
||||||
|
byGroup: jest.fn().mockResolvedValue(true),
|
||||||
|
}));
|
||||||
|
jest.mock('@simpleworkjs/conf', () => ({}));
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const apiConf = require('../routes/api_conf');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
// Add a mock user for the permission check
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
req.user = { uid: 'testadmin' };
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use('/api/conf', apiConf);
|
||||||
|
|
||||||
|
describe('Proxy Conf API (Vault Integration)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/conf/proxy returns proxy conf with masked secrets', async () => {
|
||||||
|
baoConf.get.mockResolvedValueOnce({
|
||||||
|
oidc: { issuer: 'https://test', clientId: 'cid', clientSecret: 'real_secret' },
|
||||||
|
ldap: { bindPassword: 'real_ldap_password' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await request(app).get('/api/conf/proxy');
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.oidc.issuer).toBe('https://test');
|
||||||
|
expect(res.body.oidc.clientSecret).toBe('********'); // MASKED
|
||||||
|
expect(res.body.ldap.bindPassword).toBe('********'); // MASKED
|
||||||
|
expect(baoConf.get).toHaveBeenCalledWith('proxy/conf');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /api/conf/proxy merges configuration securely to OpenBao', async () => {
|
||||||
|
baoConf.get.mockResolvedValueOnce({
|
||||||
|
oidc: { clientSecret: 'old_secret' },
|
||||||
|
ldap: { bindPassword: 'old_ldap' }
|
||||||
|
});
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
oidc: { issuer: 'https://new', clientSecret: '********' }, // Admin left it unchanged
|
||||||
|
ldap: { bindPassword: 'new_password' }
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/conf/proxy')
|
||||||
|
.send(payload);
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(baoConf.set).toHaveBeenCalledTimes(1);
|
||||||
|
const saved = baoConf.set.mock.calls[0][1];
|
||||||
|
|
||||||
|
expect(saved.oidc.issuer).toBe('https://new');
|
||||||
|
expect(saved.oidc.clientSecret).toBe('old_secret'); // Preserved because incoming was mask
|
||||||
|
expect(saved.ldap.bindPassword).toBe('new_password'); // Overwritten because incoming was new
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Pure-logic coverage for the two reconciler rules that real Proxmox + UniFi
|
||||||
|
// data broke. Both were found by running discovery against a live cluster:
|
||||||
|
// the directory came back listing MAC addresses as host names, and one
|
||||||
|
// resource ended up as its own parent.
|
||||||
|
|
||||||
|
// Mirrors the ranking in services/discovery_reconciler.js. Kept here (rather
|
||||||
|
// than exported) because it is a few lines of predicate that the reconciler
|
||||||
|
// applies inline while merging; if it grows, export it and drop this copy.
|
||||||
|
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
|
||||||
|
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
|
||||||
|
const nameRank = (str) => {
|
||||||
|
if (!str || !String(str).trim()) return 0;
|
||||||
|
if (isMac(str)) return 0;
|
||||||
|
if (isIp(str)) return 1;
|
||||||
|
return 2;
|
||||||
|
};
|
||||||
|
function bestNameOf(existingName, incomingName) {
|
||||||
|
let best = existingName;
|
||||||
|
if (incomingName) {
|
||||||
|
const a = nameRank(incomingName);
|
||||||
|
const b = nameRank(best);
|
||||||
|
if (a > b || (a === b && incomingName.length > (best || '').length)) best = incomingName;
|
||||||
|
}
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('discovery name ranking', () => {
|
||||||
|
test('a real hostname beats a MAC even when shorter', () => {
|
||||||
|
// The exact regression: UniFi named the host by MAC, Proxmox knew the
|
||||||
|
// hostname, and length-only comparison kept the MAC.
|
||||||
|
expect(bestNameOf('ac:16:2d:b3:da:80', 'dl380-0')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a MAC never displaces a real hostname', () => {
|
||||||
|
expect(bestNameOf('dl380-0', 'ac:16:2d:b3:da:80')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a real hostname beats an IP-shaped name', () => {
|
||||||
|
expect(bestNameOf('192.168.1.27', 'hass.io')).toBe('hass.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an IP beats a MAC', () => {
|
||||||
|
expect(bestNameOf('bc:24:11:3f:cd:c8', '192.168.1.27')).toBe('192.168.1.27');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an IP does not displace a hostname', () => {
|
||||||
|
expect(bestNameOf('gitea-runner', '192.168.1.176')).toBe('gitea-runner');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('within the same rank the longer/more specific name wins', () => {
|
||||||
|
expect(bestNameOf('pve', 'pve-dl380-1')).toBe('pve-dl380-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dash-separated MACs are recognized too', () => {
|
||||||
|
expect(bestNameOf('ac-16-2d-b3-da-80', 'dl380-0')).toBe('dl380-0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty existing name is always replaced', () => {
|
||||||
|
expect(bestNameOf('', 'anything')).toBe('anything');
|
||||||
|
expect(bestNameOf(null, 'ac:16:2d:b3:da:80')).toBe('ac:16:2d:b3:da:80');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mirrors isDescendant() in the reconciler.
|
||||||
|
function isDescendant(candidateId, rootId, edges) {
|
||||||
|
const seen = new Set();
|
||||||
|
const stack = [rootId];
|
||||||
|
while (stack.length) {
|
||||||
|
const id = stack.pop();
|
||||||
|
if (id === candidateId) return true;
|
||||||
|
if (seen.has(id)) continue;
|
||||||
|
seen.add(id);
|
||||||
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('discovery edge cycle guard', () => {
|
||||||
|
const edges = [
|
||||||
|
{ parentId: 'cluster', childId: 'node1' },
|
||||||
|
{ parentId: 'node1', childId: 'vm1' },
|
||||||
|
];
|
||||||
|
|
||||||
|
test('detects a direct parent/child inversion', () => {
|
||||||
|
// Proposing node1 -> cluster when cluster -> node1 already exists.
|
||||||
|
expect(isDescendant('node1', 'cluster', edges)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detects a deeper loop', () => {
|
||||||
|
expect(isDescendant('vm1', 'cluster', edges)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allows an unrelated new parent', () => {
|
||||||
|
expect(isDescendant('node2', 'cluster', edges)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminates on a graph that already contains a cycle', () => {
|
||||||
|
// A self-edge written by an earlier release must not hang the walk.
|
||||||
|
const cyclic = [{ parentId: 'a', childId: 'a' }, { parentId: 'a', childId: 'b' }];
|
||||||
|
expect(isDescendant('zzz', 'a', cyclic)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const {
|
||||||
|
slugify,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
GOD_ADMIN,
|
||||||
|
} = require('../utils/groups');
|
||||||
|
|
||||||
|
// Resource fixtures mirror the directory: hosts carry a `host_` prefix, services
|
||||||
|
// are stored bare. The builders take the *name* slug (kind stripped) + a kind, so
|
||||||
|
// a host `host_web-01` gives `main-office_host_web-01_*` and a service `emby`
|
||||||
|
// gives `main-office_app_emby_*` -- matching docs/GROUPS.md §2.
|
||||||
|
const HOST = { site: 'main-office', kind: 'host', slug: 'host_web-01' };
|
||||||
|
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||||
|
const SERVICE = { site: 'main-office', kind: 'service', slug: 'emby' };
|
||||||
|
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: 'host_db' };
|
||||||
|
|
||||||
|
describe('slugify', () => {
|
||||||
|
test('lowercases, spaces and underscores become hyphens, no leading/trailing dash', () => {
|
||||||
|
expect(slugify('Web 01')).toBe('web-01');
|
||||||
|
expect(slugify('Main Office')).toBe('main-office');
|
||||||
|
expect(slugify('my_host')).toBe('my-host');
|
||||||
|
expect(slugify(' Mixed CASE--name ')).toBe('mixed-case-name');
|
||||||
|
expect(slugify('')).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('group cn builders', () => {
|
||||||
|
test('per-resource names the kind + name slug (docs §2)', () => {
|
||||||
|
expect(resourceGroupCns('main-office', 'host', 'web-01', 'admin')).toBe('main-office_host_web-01_admin');
|
||||||
|
expect(resourceGroupCns('main-office', 'app', 'emby', 'access')).toBe('main-office_app_emby_access');
|
||||||
|
});
|
||||||
|
test('a prefixed site slug is kept verbatim; the resource name slug is kind-stripped', () => {
|
||||||
|
expect(resourceGroupCns('site_local', 'host', 'theta-env', 'access')).toBe('site_local_host_theta-env_access');
|
||||||
|
expect(resourceGroupCns('site_local', 'app', 'sso-manager', 'access')).toBe('site_local_app_sso-manager_access');
|
||||||
|
});
|
||||||
|
test('aggregate uses the plural kind', () => {
|
||||||
|
expect(aggregateGroupCns('main-office', 'host', 'admin')).toBe('main-office_hosts_admin');
|
||||||
|
expect(aggregateGroupCns('main-office', 'app', 'access')).toBe('main-office_apps_access');
|
||||||
|
});
|
||||||
|
test('site super admin + everyone', () => {
|
||||||
|
expect(siteSuperAdminCns('main-office')).toBe('main-office_super_admin');
|
||||||
|
expect(siteEveryoneCns('main-office')).toBe('main-office_everyone');
|
||||||
|
});
|
||||||
|
test('a directory site slug with a kind prefix is kept verbatim', () => {
|
||||||
|
expect(siteSuperAdminCns('site_local')).toBe('site_local_super_admin');
|
||||||
|
expect(siteEveryoneCns('site_local')).toBe('site_local_everyone');
|
||||||
|
expect(aggregateGroupCns('site_local', 'host', 'admin')).toBe('site_local_hosts_admin');
|
||||||
|
});
|
||||||
|
test('invalid kind throws', () => {
|
||||||
|
expect(() => resourceGroupCns('s', 'service', 'x', 'admin')).toThrow();
|
||||||
|
expect(() => aggregateGroupCns('s', 'service', 'admin')).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('levels', () => {
|
||||||
|
test('admin/access known; capabilities opaque', () => {
|
||||||
|
expect(isKnownLevel('admin')).toBe(true);
|
||||||
|
expect(isKnownLevel('access')).toBe(true);
|
||||||
|
expect(isKnownLevel('reboot')).toBe(false);
|
||||||
|
expect(isKnownLevel('emby_admin')).toBe(false);
|
||||||
|
});
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(levelGrants('admin', 'access')).toBe(true);
|
||||||
|
expect(levelGrants('access', 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('hasPermission — inheritance', () => {
|
||||||
|
test('god_admin grants everything everywhere', () => {
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([GOD_ADMIN], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('site super admin grants everything on its site, not other sites', () => {
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_super_admin'], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('aggregate (all hosts) grants on any host at the site', () => {
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_access'], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission(['main-office_hosts_admin'], HOST, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('specific host group grants only that host', () => {
|
||||||
|
const cn = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([cn], HOST, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([cn], OTHER_SITE_HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('admin implies access; access does not imply admin', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'access')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'access')], HOST, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('capabilities are exact — admin does not grant a capability', () => {
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'reboot')], HOST, 'reboot')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], HOST, 'reboot')).toBe(false);
|
||||||
|
expect(hasPermission(['main-office_hosts_reboot'], HOST, 'reboot')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('hosts and apps are orthogonal namespaces', () => {
|
||||||
|
const hostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([hostAdmin], APP, 'access')).toBe(false);
|
||||||
|
const appAdmin = resourceGroupCns('main-office', 'app', 'emby', 'admin');
|
||||||
|
expect(hasPermission([appAdmin], APP, 'access')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service maps to the app kind (docs §11)', () => {
|
||||||
|
// The directory `service` kind is the group model's `app`.
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'app', 'emby', 'admin')], SERVICE, 'admin')).toBe(true);
|
||||||
|
expect(hasPermission([resourceGroupCns('main-office', 'host', 'web-01', 'admin')], SERVICE, 'admin')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cross-site isolation', () => {
|
||||||
|
const mainHostAdmin = resourceGroupCns('main-office', 'host', 'web-01', 'admin');
|
||||||
|
expect(hasPermission([mainHostAdmin], OTHER_SITE_HOST, 'access')).toBe(false);
|
||||||
|
expect(hasPermission(['branch-office_hosts_admin'], OTHER_SITE_HOST, 'admin')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Nested groups: the API for putting a group inside a group, the cycle guard,
|
||||||
|
// and the thing that makes it worth doing -- membership resolving transitively
|
||||||
|
// through the chain.
|
||||||
|
//
|
||||||
|
// Fixture note that is easy to get wrong: groupOfNames requires at least one
|
||||||
|
// member, so whoever creates a group is seeded into it. `test` creates all
|
||||||
|
// three groups here and would therefore be a *direct* member of each, which
|
||||||
|
// would make "resolved via nesting" indistinguishable from "was already in it".
|
||||||
|
// Setup below strips that back so test's only direct membership is the
|
||||||
|
// innermost group -- and the strip has to happen after nesting, or removing the
|
||||||
|
// sole member would violate the objectClass.
|
||||||
|
|
||||||
|
const { login, request, app } = require('./setup');
|
||||||
|
|
||||||
|
let token;
|
||||||
|
const stamp = Date.now().toString(36);
|
||||||
|
const A = `nesttest-a-${stamp}`; // outermost
|
||||||
|
const B = `nesttest-b-${stamp}`; // middle
|
||||||
|
const C = `nesttest-c-${stamp}`; // innermost, holds the user
|
||||||
|
// A second group nested into A purely so that un-nesting B later does not
|
||||||
|
// empty A -- groupOfNames requires at least one member, and the API correctly
|
||||||
|
// refuses (409) rather than leaving an invalid entry behind.
|
||||||
|
const D = `nesttest-d-${stamp}`;
|
||||||
|
|
||||||
|
async function nest(parent, child) {
|
||||||
|
return request(app).put(`/api/group/${parent}/nested/${child}`).set('auth-token', token).send({});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
token = await login();
|
||||||
|
|
||||||
|
for (const cn of [A, B, C, D]) {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/api/group')
|
||||||
|
.set('auth-token', token)
|
||||||
|
.send({ name: cn, description: `nesting test ${cn}` });
|
||||||
|
expect([200, 201]).toContain(res.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
expect((await nest(A, B)).status).toBe(200);
|
||||||
|
expect((await nest(B, C)).status).toBe(200);
|
||||||
|
expect((await nest(A, D)).status).toBe(200);
|
||||||
|
|
||||||
|
// Now that A holds B and B holds C, neither would be left memberless.
|
||||||
|
for (const cn of [A, B]) {
|
||||||
|
const res = await request(app).delete(`/api/group/${cn}/test`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — API guards', () => {
|
||||||
|
test('nesting the same pair twice is a 409, not a duplicate', async () => {
|
||||||
|
const res = await nest(A, B);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a group cannot contain itself', async () => {
|
||||||
|
const res = await nest(A, A);
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The guard that matters: without it the resolver would silently return a
|
||||||
|
// depth-capped answer instead of an error anyone would notice.
|
||||||
|
test('a direct cycle is refused (A contains B, so B may not contain A)', async () => {
|
||||||
|
const res = await nest(B, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/loop/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an indirect cycle is refused too (A>B>C, so C may not contain A)', async () => {
|
||||||
|
const res = await nest(C, A);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — resolution', () => {
|
||||||
|
test('membership resolves through the whole chain', async () => {
|
||||||
|
const res = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.results).toContain(C); // direct
|
||||||
|
expect(res.body.results).toContain(B); // via C
|
||||||
|
expect(res.body.results).toContain(A); // via B -> C
|
||||||
|
});
|
||||||
|
|
||||||
|
test('GET /:group/effective separates direct members from nested ones', async () => {
|
||||||
|
const res = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const { direct, nestedGroups, effective } = res.body.results;
|
||||||
|
|
||||||
|
expect(nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
// `direct` is users only -- a nested group must never be reported as one.
|
||||||
|
expect(direct.every(dn => !/,ou=groups,/i.test(dn))).toBe(true);
|
||||||
|
// test is not listed on A at all, yet is effectively a member two levels down.
|
||||||
|
expect(direct.some(dn => /cn=test,/i.test(dn))).toBe(false);
|
||||||
|
expect(effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Nested groups — un-nesting', () => {
|
||||||
|
test('DELETE removes the nesting and the membership it carried', async () => {
|
||||||
|
// Before: A holds B (which holds C, which holds test) and D.
|
||||||
|
const before = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(before.body.results.nestedGroups.map(g => g.cn)).toContain(B);
|
||||||
|
expect(before.body.results.effective.some(dn => /cn=test,/i.test(dn))).toBe(true);
|
||||||
|
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${A}/nested/${B}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
|
const after = await request(app).get(`/api/group/${A}/effective`).set('auth-token', token);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).not.toContain(B);
|
||||||
|
expect(after.body.results.nestedGroups.map(g => g.cn)).toContain(D); // untouched
|
||||||
|
|
||||||
|
// test still resolves to B and C directly/through C; only the A path via
|
||||||
|
// B is gone. It is deliberately NOT asserted that test loses A entirely:
|
||||||
|
// D is also nested in A and test created D, so that path remains -- which
|
||||||
|
// is itself a fair illustration of why "who can reach this" has to be
|
||||||
|
// computed rather than eyeballed.
|
||||||
|
const groups = await request(app).get('/api/group?member=test').set('auth-token', token);
|
||||||
|
expect(groups.body.results).toContain(C);
|
||||||
|
expect(groups.body.results).toContain(B);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('un-nesting the last member is refused rather than emptying the group', async () => {
|
||||||
|
// B now holds only C. Removing it would leave B with no members at all,
|
||||||
|
// which groupOfNames forbids.
|
||||||
|
const res = await request(app)
|
||||||
|
.delete(`/api/group/${B}/nested/${C}`)
|
||||||
|
.set('auth-token', token);
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(res.body.message).toMatch(/at least one member/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const nmapPlugin = require('../plugins/discovery/nmap');
|
||||||
|
|
||||||
|
jest.mock('node-nmap', () => {
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
class MockNmapScan extends EventEmitter {
|
||||||
|
constructor(targetRange, customFlags) {
|
||||||
|
super();
|
||||||
|
this.targetRange = targetRange;
|
||||||
|
this.customFlags = customFlags;
|
||||||
|
this.command = ['-oX', '-', ...(customFlags || []), targetRange];
|
||||||
|
}
|
||||||
|
startScan() {
|
||||||
|
setImmediate(() => {
|
||||||
|
this.emit('complete', [
|
||||||
|
{ ip: '192.168.1.10', hostname: 'host-10', openPorts: [{ port: 80, protocol: 'tcp', service: 'http' }] }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
NmapScan: MockNmapScan,
|
||||||
|
nmapLocation: 'nmap'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('nmap discovery plugin', () => {
|
||||||
|
test('discover passes custom flags (-Pn, -sT, -F, --min-rate) to constructor', async () => {
|
||||||
|
const logs = [];
|
||||||
|
const result = await nmapPlugin.discover({
|
||||||
|
targetRange: '192.168.1.0/24',
|
||||||
|
log: (msg) => { logs.push(msg); }
|
||||||
|
});
|
||||||
|
|
||||||
|
const startLog = logs.find(l => l.startsWith('Starting nmap scan'));
|
||||||
|
expect(startLog).toBeDefined();
|
||||||
|
expect(startLog).toContain('-Pn');
|
||||||
|
expect(startLog).toContain('-sT');
|
||||||
|
expect(startLog).toContain('-F');
|
||||||
|
expect(startLog).toContain('--min-rate 100');
|
||||||
|
expect(result.resources).toHaveLength(2); // host + service
|
||||||
|
expect(result.resources[0].name).toBe('host-10');
|
||||||
|
expect(result.edges).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Tests for the plugin system:
|
||||||
|
// - plugin_registry: pure type discovery + configSchema helpers (no ORM, no
|
||||||
|
// OpenBao, no LDAP) — the registry just requires the plugins/discovery/*.js
|
||||||
|
// modules, which are real deps (node-fetch, node-nmap).
|
||||||
|
// - plugin_secrets: OpenBao read/write/mergeForRun, with @simpleworkjs/bao-conf
|
||||||
|
// mocked so no live OpenBao is needed.
|
||||||
|
// - PluginInstance model: ORM round-trip against the same sqlite store the
|
||||||
|
// rest of the suite uses (initORM), incl. the unique-slug constraint and
|
||||||
|
// listEnabled. Like resource_site_slug.test.js, this is direct model use
|
||||||
|
// rather than the LDAP-gated HTTP routes.
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
request: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const registry = require('../services/plugin_registry');
|
||||||
|
const pluginSecrets = require('../utils/plugin_secrets');
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { PluginInstance } = require('../models/plugin_instance');
|
||||||
|
|
||||||
|
describe('plugin_registry', () => {
|
||||||
|
test('getTypes lists the built-in discovery plugins', () => {
|
||||||
|
const types = registry.getTypes();
|
||||||
|
const byType = Object.fromEntries(types.map(t => [t.type, t]));
|
||||||
|
expect(byType.proxmox).toBeDefined();
|
||||||
|
expect(byType.unifi).toBeDefined();
|
||||||
|
expect(byType.nmap).toBeDefined();
|
||||||
|
expect(byType.proxmox.category).toBe('discovery');
|
||||||
|
expect(byType.proxmox.configSchema.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('configSchema marks secret fields', () => {
|
||||||
|
const m = registry.getManifest('proxmox');
|
||||||
|
const secret = m.configSchema.find(f => f.key === 'tokenSecret');
|
||||||
|
expect(secret.secret).toBe(true);
|
||||||
|
expect(secret.required).toBe(true);
|
||||||
|
expect(m.configSchema.find(f => f.key === 'url').secret).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('requiredKeys / secretKeys / publicKeys split correctly', () => {
|
||||||
|
expect(registry.requiredKeys('proxmox').sort()).toEqual(['tokenId', 'tokenSecret', 'url']);
|
||||||
|
expect(registry.secretKeys('proxmox')).toEqual(['tokenSecret']);
|
||||||
|
expect(registry.secretKeys('unifi')).toEqual(['password']);
|
||||||
|
expect(registry.secretKeys('nmap')).toEqual([]);
|
||||||
|
expect(registry.publicKeys('nmap')).toEqual(['targetRange']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('splitConfig separates secret from non-secret and drops undeclared keys', () => {
|
||||||
|
const { config, secrets } = registry.splitConfig('proxmox', {
|
||||||
|
url: 'https://pve:8006',
|
||||||
|
tokenId: 'u@pam!t',
|
||||||
|
tokenSecret: 'shh',
|
||||||
|
enabled: true, // not in configSchema -> dropped
|
||||||
|
cron: '0 * * * *' // not in configSchema -> dropped
|
||||||
|
});
|
||||||
|
expect(config).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t' });
|
||||||
|
expect(secrets).toEqual({ tokenSecret: 'shh' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mask redacts only secret values', () => {
|
||||||
|
const masked = registry.mask('proxmox', { url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
|
||||||
|
expect(masked.url).toBe('https://pve:8006');
|
||||||
|
expect(masked.tokenId).toBe('u@pam!t');
|
||||||
|
expect(masked.tokenSecret).toBe('********');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getModule throws for an unknown type', () => {
|
||||||
|
expect(() => registry.getModule('does-not-exist')).toThrow(/Unknown plugin type/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getModule returns a module with run()/discover()', () => {
|
||||||
|
const mod = registry.getModule('proxmox');
|
||||||
|
expect(typeof mod.run).toBe('function');
|
||||||
|
expect(typeof mod.discover).toBe('function');
|
||||||
|
expect(typeof mod.validate).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('plugin_secrets', () => {
|
||||||
|
const VALID_ID = '11111111-1111-4111-8111-111111111111';
|
||||||
|
|
||||||
|
beforeEach(() => { baoConf.get.mockReset(); baoConf.set.mockReset(); baoConf.request.mockReset(); });
|
||||||
|
|
||||||
|
test('read returns the data object', async () => {
|
||||||
|
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
|
||||||
|
const out = await pluginSecrets.read(VALID_ID);
|
||||||
|
expect(out).toEqual({ tokenSecret: 'shh' });
|
||||||
|
expect(baoConf.get).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read returns {} when none stored', async () => {
|
||||||
|
baoConf.get.mockResolvedValue(null);
|
||||||
|
expect(await pluginSecrets.read(VALID_ID)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('write drops blank and masked placeholder values', async () => {
|
||||||
|
await pluginSecrets.write(VALID_ID, { tokenSecret: 'new', keep: '********', blank: '' });
|
||||||
|
expect(baoConf.set).toHaveBeenCalledWith(`plugins/${VALID_ID}/conf`, { tokenSecret: 'new' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mergeForRun layers secrets over the row config', async () => {
|
||||||
|
baoConf.get.mockResolvedValue({ tokenSecret: 'shh' });
|
||||||
|
const instance = { id: VALID_ID, config: { url: 'https://pve:8006', tokenId: 'u@pam!t' } };
|
||||||
|
const cfg = await pluginSecrets.mergeForRun(instance);
|
||||||
|
expect(cfg).toEqual({ url: 'https://pve:8006', tokenId: 'u@pam!t', tokenSecret: 'shh' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read rejects a non-uuid id', async () => {
|
||||||
|
await expect(pluginSecrets.read('not-a-uuid')).rejects.toThrow(/invalid plugin instance id/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('remove is best-effort (404 is fine)', async () => {
|
||||||
|
baoConf.request.mockResolvedValue({ status: 404 });
|
||||||
|
await expect(pluginSecrets.remove(VALID_ID)).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PluginInstance model', () => {
|
||||||
|
const marker = 'test_plugin_' + Date.now();
|
||||||
|
const created = [];
|
||||||
|
|
||||||
|
async function makeInstance(slug, extra = {}) {
|
||||||
|
const r = await PluginInstance.create({
|
||||||
|
pluginType: 'proxmox',
|
||||||
|
category: 'discovery',
|
||||||
|
name: 'Test ' + slug,
|
||||||
|
slug: `${marker}_${slug}`,
|
||||||
|
enabled: true,
|
||||||
|
cron: '0 * * * *',
|
||||||
|
config: { url: 'https://pve:8006' },
|
||||||
|
...extra
|
||||||
|
});
|
||||||
|
created.push(r);
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const { initORM } = require('../models');
|
||||||
|
await initORM();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
for (const r of created) {
|
||||||
|
try { await r.delete(); } catch (_) {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create generates a uuid id and round-trips json config', async () => {
|
||||||
|
const r = await makeInstance('a');
|
||||||
|
expect(r.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||||
|
const fetched = await PluginInstance.get(r.id);
|
||||||
|
expect(fetched.slug).toBe(`${marker}_a`);
|
||||||
|
expect(fetched.config).toEqual({ url: 'https://pve:8006' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slug is unique', async () => {
|
||||||
|
await makeInstance('dup');
|
||||||
|
await expect(makeInstance('dup')).rejects.toThrow(/Validation error|SequelizeUniqueConstraint/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getBySlug resolves', async () => {
|
||||||
|
const r = await makeInstance('bySlug');
|
||||||
|
const found = await PluginInstance.getBySlug(`${marker}_bySlug`);
|
||||||
|
expect(found.id).toBe(r.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listEnabled returns only enabled instances', async () => {
|
||||||
|
const on = await makeInstance('on', { enabled: true });
|
||||||
|
const off = await makeInstance('off', { enabled: false });
|
||||||
|
const enabled = await PluginInstance.listEnabled();
|
||||||
|
const slugs = enabled.map(e => e.slug);
|
||||||
|
expect(slugs).toContain(on.slug);
|
||||||
|
expect(slugs).not.toContain(off.slug);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,75 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
|
||||||
|
describe('DiscoveryReconciler', () => {
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear resources before each test
|
||||||
|
const all = await Resource.list();
|
||||||
|
for (const r of all) {
|
||||||
|
await r.delete();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create a new device if no MAC or IP matches', async () => {
|
||||||
|
const payload = {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: 'New Host',
|
||||||
|
slug: 'new-host',
|
||||||
|
metadata: {
|
||||||
|
interfaces: [{ mac: '00:11:22:33:44:55', ip: '192.168.1.100' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
};
|
||||||
|
|
||||||
|
await DiscoveryReconciler.reconcile('test-plugin', payload);
|
||||||
|
|
||||||
|
const all = await Resource.list();
|
||||||
|
expect(all).toHaveLength(1);
|
||||||
|
expect(all[0].name).toBe('New Host');
|
||||||
|
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should merge into an existing device if MAC matches', async () => {
|
||||||
|
// 1. Initial creation
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-A', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'unmanaged_device',
|
||||||
|
name: 'Old Host',
|
||||||
|
slug: 'old-host',
|
||||||
|
metadata: {
|
||||||
|
os: 'Linux',
|
||||||
|
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.5' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Secondary discovery from a different plugin, same MAC but new IP
|
||||||
|
await DiscoveryReconciler.reconcile('plugin-B', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: 'Updated Host', // Name updates aren't overwritten in simple merge, but let's see
|
||||||
|
metadata: {
|
||||||
|
cpu_cores: 4,
|
||||||
|
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.6' }]
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
const all = await Resource.list();
|
||||||
|
expect(all).toHaveLength(1); // Should have merged, not created a new one
|
||||||
|
|
||||||
|
const merged = all[0];
|
||||||
|
expect(merged.metadata.discovery_sources).toContain('plugin-A');
|
||||||
|
expect(merged.metadata.discovery_sources).toContain('plugin-B');
|
||||||
|
|
||||||
|
// Metadata should be merged
|
||||||
|
expect(merged.metadata.os).toBe('Linux');
|
||||||
|
expect(merged.metadata.cpu_cores).toBe(4);
|
||||||
|
|
||||||
|
// Interface array should be merged/updated
|
||||||
|
expect(merged.metadata.interfaces).toHaveLength(1);
|
||||||
|
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
jest.mock('@simpleworkjs/bao-conf', () => ({
|
||||||
|
get: jest.fn(),
|
||||||
|
set: jest.fn(),
|
||||||
|
request: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.mock('redis', () => ({
|
||||||
|
createClient: () => ({
|
||||||
|
on: jest.fn(),
|
||||||
|
connect: jest.fn().mockResolvedValue(),
|
||||||
|
get: jest.fn().mockResolvedValue(null),
|
||||||
|
set: jest.fn().mockResolvedValue(),
|
||||||
|
})
|
||||||
|
}));
|
||||||
|
|
||||||
|
// In-memory stand-ins for the ORM-backed models so mintAppToken/renewAppTokens
|
||||||
|
// can run without a database.
|
||||||
|
jest.mock('../models/shared_secret', () => ({
|
||||||
|
SharedSecret: { list: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/shared_secret_grant', () => ({
|
||||||
|
SharedSecretGrant: { listForGrantee: jest.fn().mockResolvedValue([]) },
|
||||||
|
}));
|
||||||
|
jest.mock('../models/vault_app_token', () => {
|
||||||
|
const rows = [];
|
||||||
|
const VaultAppToken = {
|
||||||
|
_rows: rows,
|
||||||
|
list: jest.fn(async () => rows),
|
||||||
|
getByName: jest.fn(async (name) => rows.find(r => r.name === name) || null),
|
||||||
|
create: jest.fn(async (data) => {
|
||||||
|
const row = {
|
||||||
|
...data,
|
||||||
|
update: jest.fn(async function (patch) { Object.assign(this, patch); }),
|
||||||
|
delete: jest.fn(async function () { rows.splice(rows.indexOf(this), 1); }),
|
||||||
|
};
|
||||||
|
rows.push(row);
|
||||||
|
return row;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return { VaultAppToken };
|
||||||
|
});
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const vaultBroker = require('../utils/vault_broker');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
|
describe('vault_broker admin policy', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
baoConf.request.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getOrCreateAdminToken ensures sso-admin policy with list capabilities on metadata', async () => {
|
||||||
|
baoConf.request.mockImplementation(async (method, path, body) => {
|
||||||
|
if (method === 'GET' && path === 'sys/policies/acl/sso-admin') {
|
||||||
|
return { status: 404, text: async () => '' };
|
||||||
|
}
|
||||||
|
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
|
||||||
|
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
|
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
|
||||||
|
return { status: 204, ok: true };
|
||||||
|
}
|
||||||
|
if (method === 'POST' && path === 'auth/token/create/sso-broker') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ auth: { client_token: 'test-admin-token', lease_duration: 3600 } })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { status: 200, ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = await vaultBroker.getOrCreateAdminToken('adminuser');
|
||||||
|
expect(token).toBe('test-admin-token');
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('PUT', 'sys/policies/acl/sso-admin', expect.objectContaining({
|
||||||
|
policy: expect.stringContaining('path "secret/metadata/"')
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('app token lifecycle (accessor storage + renewal)', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
baoConf.request.mockReset();
|
||||||
|
VaultAppToken._rows.length = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function mockBao({ mintAccessor = 'acc-1', renewOk = true } = {}) {
|
||||||
|
baoConf.request.mockImplementation(async (method, path, body) => {
|
||||||
|
if (path.startsWith('sys/policies/acl/')) {
|
||||||
|
if (method === 'GET') return { status: 404, text: async () => '' };
|
||||||
|
return { status: 204, ok: true };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/create/sso-app') {
|
||||||
|
return { ok: true, json: async () => ({ auth: { client_token: 'app-tok', accessor: mintAccessor, lease_duration: 2764800 } }) };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/renew-accessor') {
|
||||||
|
return renewOk ? { ok: true, json: async () => ({}) } : { ok: false, status: 400, text: async () => 'invalid accessor' };
|
||||||
|
}
|
||||||
|
if (path === 'auth/token/revoke-accessor') {
|
||||||
|
return { ok: true, status: 204, text: async () => '' };
|
||||||
|
}
|
||||||
|
return { status: 200, ok: true, json: async () => ({}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('mintAppToken stores the accessor; re-mint revokes the old accessor and replaces the row', async () => {
|
||||||
|
mockBao({ mintAccessor: 'acc-old' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0]).toMatchObject({ name: 'demo', accessor: 'acc-old', created_by: 'adminuser' });
|
||||||
|
|
||||||
|
mockBao({ mintAccessor: 'acc-new' });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/revoke-accessor', { accessor: 'acc-old' });
|
||||||
|
expect(VaultAppToken._rows).toHaveLength(1);
|
||||||
|
expect(VaultAppToken._rows[0].accessor).toBe('acc-new');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens renews each accessor and stamps lastRenewedAt', async () => {
|
||||||
|
mockBao();
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
VaultAppToken._rows[0].lastRenewedAt = 0;
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(baoConf.request).toHaveBeenCalledWith('POST', 'auth/token/renew-accessor', { accessor: 'acc-1' });
|
||||||
|
expect(VaultAppToken._rows[0].lastRenewedAt).toBeGreaterThan(0);
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renewAppTokens records the failure on the row without throwing', async () => {
|
||||||
|
mockBao({ renewOk: false });
|
||||||
|
await vaultBroker.mintAppToken('demo', 'adminuser');
|
||||||
|
await vaultBroker.renewAppTokens();
|
||||||
|
expect(VaultAppToken._rows[0].lastError).toMatch(/renew failed \(400\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Real HTTP round-trip through vaultProxy() against an in-process fake OpenBao.
|
||||||
|
// This exists because the proxy once shipped with a hook shape the installed
|
||||||
|
// http-proxy-middleware version ignored (v3 `on: { proxyReq }` vs v2
|
||||||
|
// `onProxyReq`), so NO X-Vault-Token was ever injected and every /api/vault
|
||||||
|
// request 403'd. A unit test on options can't catch that — only a wire test can.
|
||||||
|
describe('vaultProxy wire behavior', () => {
|
||||||
|
const http = require('http');
|
||||||
|
const express = require('express');
|
||||||
|
|
||||||
|
let target; // fake OpenBao
|
||||||
|
let seen; // last request the fake OpenBao received
|
||||||
|
let app; // sso app fragment: scopeGuard stub + vaultProxy
|
||||||
|
let server;
|
||||||
|
|
||||||
|
beforeAll((done) => {
|
||||||
|
target = http.createServer((req, res) => {
|
||||||
|
let body = '';
|
||||||
|
req.on('data', (c) => { body += c; });
|
||||||
|
req.on('end', () => {
|
||||||
|
seen = { method: req.method, url: req.url, headers: req.headers, body };
|
||||||
|
res.setHeader('content-type', 'application/json');
|
||||||
|
res.end('{"ok":true}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
target.listen(0, '127.0.0.1', () => {
|
||||||
|
process.env.VAULT_ADDR = `http://127.0.0.1:${target.address().port}`;
|
||||||
|
jest.resetModules();
|
||||||
|
const broker = require('../utils/vault_broker');
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/vault', (req, res, next) => { req.vaultToken = 'scoped-token-123'; next(); }, broker.vaultProxy());
|
||||||
|
server = app.listen(0, '127.0.0.1', done);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll((done) => {
|
||||||
|
server.close(() => target.close(done));
|
||||||
|
});
|
||||||
|
|
||||||
|
function call(path, opts = {}) {
|
||||||
|
const port = server.address().port;
|
||||||
|
return fetch(`http://127.0.0.1:${port}${path}`, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('GET list rewrites /api/vault -> /v1, injects X-Vault-Token, strips sso auth headers', async () => {
|
||||||
|
const res = await call('/api/vault/secret/metadata/users/alice?list=true', {
|
||||||
|
headers: { 'auth-token': 'sso-session-token', authorization: 'Bearer sso_x_y', 'content-type': 'application/json' },
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.url).toBe('/v1/secret/metadata/users/alice?list=true');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(seen.headers['auth-token']).toBeUndefined();
|
||||||
|
expect(seen.headers['authorization']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('POST body survives the express.json + fixRequestBody round-trip', async () => {
|
||||||
|
const res = await call('/api/vault/secret/data/users/alice/foo', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json', 'auth-token': 'sso-session-token' },
|
||||||
|
body: JSON.stringify({ data: { hello: 'world' } }),
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(seen.method).toBe('POST');
|
||||||
|
expect(seen.url).toBe('/v1/secret/data/users/alice/foo');
|
||||||
|
expect(seen.headers['x-vault-token']).toBe('scoped-token-123');
|
||||||
|
expect(JSON.parse(seen.body)).toEqual({ data: { hello: 'world' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const { WebhookEmitter } = require('../services/webhook_emitter');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
describe('WebhookEmitter', () => {
|
||||||
|
let webhook;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear webhooks before each test
|
||||||
|
const all = await Webhook.list();
|
||||||
|
for (const w of all) {
|
||||||
|
await w.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook = await Webhook.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'Test Webhook',
|
||||||
|
url: 'http://localhost:9999/dummy',
|
||||||
|
events: ['discovery.new_device'],
|
||||||
|
secret: 'mysecret',
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when emitting an event', async () => {
|
||||||
|
// We expect this to fail network connection but be caught gracefully by the emitter
|
||||||
|
await WebhookEmitter.emit('discovery.new_device', { name: 'Device1' });
|
||||||
|
// If it doesn't throw, test passes
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const crypto = require('crypto');
|
||||||
|
const agentKeys = require('./agent_keys');
|
||||||
|
const { Agent } = require('../models/agent');
|
||||||
|
|
||||||
|
// Tracks the live WebSocket for each enrolled agent and brokers commands to it.
|
||||||
|
//
|
||||||
|
// The durable facts about an agent (identity, host binding, last seen, last
|
||||||
|
// discovery/telemetry) live in the Agent table; this class holds only what
|
||||||
|
// cannot be persisted -- the open socket. That split is what makes an installed
|
||||||
|
// -but-offline agent visible, and what stops a restart from erasing the fleet.
|
||||||
|
class AgentManager {
|
||||||
|
constructor() {
|
||||||
|
// agentId -> { ws, ipAddress, connectedAt, lastResponse, pending }
|
||||||
|
this.live = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalize payload for signing per PROTOCOL.md v1.1.0 section 5:
|
||||||
|
* Sort keys alphabetically, remove whitespace, omit 'signature' key.
|
||||||
|
*/
|
||||||
|
canonicalize(payload) {
|
||||||
|
const cleanObj = {};
|
||||||
|
const sortedKeys = Object.keys(payload).filter(k => k !== 'signature').sort();
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
cleanObj[key] = payload[key];
|
||||||
|
}
|
||||||
|
return JSON.stringify(cleanObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign payload using the persisted Ed25519 private key. Throws when no key is
|
||||||
|
* available rather than minting a throwaway one -- an agent verifies against
|
||||||
|
* the key pinned in its agent.yml, so a signature from a key it has never
|
||||||
|
* seen is not a weaker signature, it is a broken command that looks fine from
|
||||||
|
* this side.
|
||||||
|
*/
|
||||||
|
async signPayload(payload) {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
if (!keys) {
|
||||||
|
const { error } = agentKeys.status();
|
||||||
|
throw new Error(`agent command signing is unavailable: ${error || 'no signing key'}`);
|
||||||
|
}
|
||||||
|
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
|
||||||
|
return crypto.sign(null, canonicalBytes, keys.privateKeyPem).toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicKeyBase64() {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
return keys ? keys.publicKeyBase64 : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async publicKeyPem() {
|
||||||
|
const keys = await agentKeys.load();
|
||||||
|
return keys ? keys.publicKeyPem : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bind a freshly authenticated socket to an enrolled agent. `agent` is an
|
||||||
|
// Agent row that Agent.authenticate() has already vouched for -- this method
|
||||||
|
// never sees a raw token and must never be called with an unauthenticated one.
|
||||||
|
// Synchronous by design. The caller must attach its `message` listener in the
|
||||||
|
// same tick as the connection is accepted: `ws` drops events emitted before a
|
||||||
|
// listener exists, and the agent sends `discovery` immediately on open, so
|
||||||
|
// awaiting a database round-trip here silently lost every agent's first
|
||||||
|
// discovery frame. The connect timestamp is persisted in the background.
|
||||||
|
registerAgent(agent, ws, remoteAddress) {
|
||||||
|
const existing = this.live.get(agent.id);
|
||||||
|
if (existing && existing.ws && existing.ws !== ws) {
|
||||||
|
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.live.set(agent.id, {
|
||||||
|
ws,
|
||||||
|
ipAddress: remoteAddress,
|
||||||
|
connectedAt: new Date().toISOString(),
|
||||||
|
lastResponse: null
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.update({
|
||||||
|
last_seen: Math.floor(Date.now() / 1000),
|
||||||
|
last_ip: remoteAddress || null
|
||||||
|
}).catch(err => console.error(`[AgentManager] could not record connect for ${agent.id}:`, err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterAgent(agentId, ws) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (state && state.ws === ws) this.live.delete(agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop an agent's live socket now. Revocation that only takes effect on the
|
||||||
|
// next reconnect is not revocation -- a connected agent would keep receiving
|
||||||
|
// commands indefinitely.
|
||||||
|
disconnect(agentId, code = 4003, reason = 'Disconnected by server') {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (!state || !state.ws) return false;
|
||||||
|
try { state.ws.close(code, reason); } catch (e) {}
|
||||||
|
this.live.delete(agentId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
isConnected(agentId) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
return !!(state && state.ws && state.ws.readyState === 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async touch(agent, extra = {}) {
|
||||||
|
await agent.update({
|
||||||
|
last_seen: Math.floor(Date.now() / 1000),
|
||||||
|
...extra
|
||||||
|
}).catch(err => console.error(`[AgentManager] could not persist agent ${agent.id}:`, err.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDiscovery(agent, payload) {
|
||||||
|
const discovery = {
|
||||||
|
hostname: payload.hostname || '',
|
||||||
|
ip_addresses: Array.isArray(payload.ip_addresses) ? payload.ip_addresses : [],
|
||||||
|
os: payload.os || '',
|
||||||
|
kernel: payload.kernel || '',
|
||||||
|
cpu: payload.cpu || '',
|
||||||
|
ram_total_gb: payload.ram_total_gb || 0,
|
||||||
|
disk_total_gb: payload.disk_total_gb || 0,
|
||||||
|
location: payload.location || 'default'
|
||||||
|
};
|
||||||
|
await this.touch(agent, { lastDiscovery: discovery });
|
||||||
|
await this.applyDiscoveryToDirectory(agent, discovery);
|
||||||
|
}
|
||||||
|
|
||||||
|
// An agent runs ON the host it describes, which makes it the most
|
||||||
|
// authoritative source the directory has -- more so than a hypervisor API or
|
||||||
|
// a network scan. It previously updated nothing at all: the facts sat on an
|
||||||
|
// in-memory record and were lost on disconnect.
|
||||||
|
//
|
||||||
|
// When the agent is bound to a resource we write that row directly; guessing
|
||||||
|
// is only for an unbound agent, and then we let the shared reconciler do the
|
||||||
|
// matching (same MAC/IP/name rules every other source goes through) rather
|
||||||
|
// than inventing a second matcher here.
|
||||||
|
async applyDiscoveryToDirectory(agent, discovery) {
|
||||||
|
try {
|
||||||
|
const { Resource } = require('../models/resource');
|
||||||
|
const metadata = {
|
||||||
|
os: discovery.os || undefined,
|
||||||
|
kernel: discovery.kernel || undefined,
|
||||||
|
cpu: discovery.cpu || undefined,
|
||||||
|
ram_total_gb: discovery.ram_total_gb || undefined,
|
||||||
|
disk_total_gb: discovery.disk_total_gb || undefined,
|
||||||
|
ip: (discovery.ip_addresses || [])[0] || undefined,
|
||||||
|
agentId: agent.id,
|
||||||
|
last_seen: Date.now()
|
||||||
|
};
|
||||||
|
// Drop undefined so a field the agent could not determine never
|
||||||
|
// overwrites a good value already in the directory.
|
||||||
|
for (const k of Object.keys(metadata)) if (metadata[k] === undefined) delete metadata[k];
|
||||||
|
|
||||||
|
if (agent.resourceId) {
|
||||||
|
const resource = await Resource.get(agent.resourceId);
|
||||||
|
if (!resource) return;
|
||||||
|
const merged = { ...(resource.metadata || {}), ...metadata };
|
||||||
|
const sources = new Set(merged.discovery_sources || []);
|
||||||
|
sources.add('theta-agent');
|
||||||
|
merged.discovery_sources = [...sources];
|
||||||
|
await resource.update({ metadata: merged, updated_on: Math.floor(Date.now() / 1000) });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!discovery.hostname) return;
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
await DiscoveryReconciler.reconcile('theta-agent', {
|
||||||
|
resources: [{
|
||||||
|
kind: 'host',
|
||||||
|
name: discovery.hostname,
|
||||||
|
slug: `agent-${agent.id.slice(0, 8)}`,
|
||||||
|
metadata: { ...metadata, subType: 'linux' }
|
||||||
|
}],
|
||||||
|
edges: []
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Never let a directory write break the agent connection.
|
||||||
|
console.error(`[AgentManager] discovery -> directory failed for agent ${agent.id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleTelemetry(agent, payload) {
|
||||||
|
await this.touch(agent, {
|
||||||
|
lastTelemetry: {
|
||||||
|
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
||||||
|
ram_usage_percent: payload.ram_usage_percent || 0,
|
||||||
|
disk_usage_percent: payload.disk_usage_percent || 0,
|
||||||
|
zfs_health: payload.zfs_health || 'N/A',
|
||||||
|
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
||||||
|
timestamp: payload.timestamp || new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleHeartbeat(agent, payload, ws) {
|
||||||
|
await this.touch(agent);
|
||||||
|
try {
|
||||||
|
ws.send(JSON.stringify({
|
||||||
|
type: 'heartbeat_ack',
|
||||||
|
payload: { timestamp: new Date().toISOString() }
|
||||||
|
}));
|
||||||
|
} catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleResponse(agent, payload) {
|
||||||
|
const state = this.live.get(agent.id);
|
||||||
|
if (state) {
|
||||||
|
state.lastResponse = {
|
||||||
|
status: payload.status || 'ok',
|
||||||
|
message: payload.message || '',
|
||||||
|
output: payload.output || '',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
await this.touch(agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendCommand(agent, commandType, payload = {}, isHighRisk = false) {
|
||||||
|
const state = this.live.get(agent.id);
|
||||||
|
if (!state || !state.ws || state.ws.readyState !== 1) {
|
||||||
|
throw new Error(`Agent "${agent.name}" is not connected`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPayload = { ...payload };
|
||||||
|
if (isHighRisk) finalPayload.signature = await this.signPayload(finalPayload);
|
||||||
|
|
||||||
|
const message = { type: commandType, payload: finalPayload };
|
||||||
|
state.ws.send(JSON.stringify(message));
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Live view for one agent, for merging into its row.
|
||||||
|
liveState(agentId) {
|
||||||
|
const state = this.live.get(agentId);
|
||||||
|
if (!state) return { connected: false, lastResponse: null };
|
||||||
|
return {
|
||||||
|
connected: !!(state.ws && state.ws.readyState === 1),
|
||||||
|
ipAddress: state.ipAddress,
|
||||||
|
connectedAt: state.connectedAt,
|
||||||
|
lastResponse: state.lastResponse || null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every enrolled agent, connected or not.
|
||||||
|
async listAgents() {
|
||||||
|
const rows = await Agent.list();
|
||||||
|
return rows.map(a => a.toPublic(this.liveState(a.id)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new AgentManager();
|
||||||
|
module.exports.AgentManager = AgentManager;
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Theta42 group & permission model.
|
||||||
|
//
|
||||||
|
// Canonical spec: theta-suite/docs/GROUPS.md. Group names follow a fixed,
|
||||||
|
// parseable structure. The structural delimiter is `_`; site/host/app slugs
|
||||||
|
// never contain it. Aggregates use the plural kind (hosts/apps); per-resource
|
||||||
|
// uses the singular (host/app).
|
||||||
|
//
|
||||||
|
// god_admin global — everything, everywhere
|
||||||
|
// {site}_super_admin everything on the site
|
||||||
|
// {site}_hosts_<level> admin/access/capability on ALL hosts at the site
|
||||||
|
// {site}_hosts_<level>
|
||||||
|
// {site}_host_<slug>_<level> admin/access/capability on ONE host
|
||||||
|
// {site}_apps_<level> ... on ALL apps at the site
|
||||||
|
// {site}_app_<slug>_<level> ... on ONE app
|
||||||
|
// {site}_everyone / everyone meta groups (implicit membership)
|
||||||
|
//
|
||||||
|
// `level` is 'admin', 'access', or an opaque `<capability>`. `admin` implies
|
||||||
|
// `access`; capabilities are explicit and never implied by `admin`. Groups are
|
||||||
|
// `groupOfNames` (RBAC) — no gidNumber; hosts map GIDs on the fly (SSSD).
|
||||||
|
//
|
||||||
|
// This module is pure logic (no LDAP/DB) so it is fully unit-testable. Callers
|
||||||
|
// supply the user's group memberships (e.g. from Group.list(user.dn)).
|
||||||
|
|
||||||
|
const GOD_ADMIN = 'god_admin';
|
||||||
|
const KNOWN_LEVELS = ['admin', 'access'];
|
||||||
|
const KINDS = ['host', 'app'];
|
||||||
|
|
||||||
|
// Normalize a site/host/app slug: lowercase; runs of non-alnum -> '-'; never
|
||||||
|
// contains '_' (the structural delimiter), so group names parse unambiguously.
|
||||||
|
function slugify(name) {
|
||||||
|
return String(name || '')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate a kind (host/app) — throw on anything else.
|
||||||
|
function assertKind(kind) {
|
||||||
|
if (!KINDS.includes(kind)) throw new Error(`invalid resource kind: ${kind} (must be host or app)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip the kind prefix a directory resource slug may carry (`host_theta-env` ->
|
||||||
|
// `theta-env`), leaving the resource's name slug. Services are stored bare
|
||||||
|
// (`sso-manager`), so this is a no-op for them.
|
||||||
|
function resourceNameSlug(slug) {
|
||||||
|
return String(slug || '').replace(/^(site|host|app)_/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_{kind}_{nameSlug}_{level} — the per-resource group for ONE resource.
|
||||||
|
// Matches docs/GROUPS.md §2 (`S_host_<host>_<level>` / `S_app_<app>_<level>`):
|
||||||
|
// `site` is the site resource's slug verbatim (`site_local`), `kind` is the
|
||||||
|
// group-model kind (`host`/`app`), `nameSlug` is the resource's name (kind
|
||||||
|
// stripped, e.g. `theta-env` from `host_theta-env`). So a host `host_theta-env`
|
||||||
|
// yields `site_local_host_theta-env_access` and a service `sso-manager` yields
|
||||||
|
// `site_local_app_sso-manager_access`.
|
||||||
|
function resourceGroupCns(site, kind, nameSlug, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}_${slugify(nameSlug)}_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||||
|
function aggregateGroupCns(site, kind, level) {
|
||||||
|
assertKind(kind);
|
||||||
|
return `${site}_${kind}s_${level}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_super_admin
|
||||||
|
function siteSuperAdminCns(site) {
|
||||||
|
return `${site}_super_admin`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// {site}_everyone
|
||||||
|
function siteEveryoneCns(site) {
|
||||||
|
return `${site}_everyone`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if `level` is a known admin/access level (not an opaque capability).
|
||||||
|
function isKnownLevel(level) {
|
||||||
|
return KNOWN_LEVELS.includes(level);
|
||||||
|
}
|
||||||
|
|
||||||
|
// True if holding `level` grants `wanted` (admin implies access).
|
||||||
|
function levelGrants(level, wanted) {
|
||||||
|
if (level === wanted) return true;
|
||||||
|
return level === 'admin' && wanted === 'access';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve whether a user (given `memberOf` — the group cns they belong to) has
|
||||||
|
// `level` on a resource. Applies the inheritance lattice:
|
||||||
|
// god_admin ⊇ {site}_super_admin ⊇ aggregate ⊇ specific; admin ⊇ access.
|
||||||
|
//
|
||||||
|
// memberOf: array of group cns the user is a member of.
|
||||||
|
// resource: { site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
//
|
||||||
|
// Meta-group grants (`everyone` / `{site}_everyone`) are NOT handled here — they
|
||||||
|
// are resource-level grants, resolved by the caller against the resource's own
|
||||||
|
// granted groups (see permission.onResource). This keeps the function pure over
|
||||||
|
// the user's membership only.
|
||||||
|
function hasPermission(memberOf, resource, level) {
|
||||||
|
// `site` is used verbatim (`site_local`); `kind` maps the directory `service`
|
||||||
|
// kind onto the group model's `app` (docs/GROUPS.md §11 — consoles/services are
|
||||||
|
// apps); `nameSlug` is the resource name with any kind prefix stripped.
|
||||||
|
const site = resource && resource.site;
|
||||||
|
const rawKind = resource && resource.kind;
|
||||||
|
const kind = rawKind === 'service' ? 'app' : rawKind;
|
||||||
|
const nameSlug = resourceNameSlug(resource && resource.slug);
|
||||||
|
const set = new Set(memberOf || []);
|
||||||
|
|
||||||
|
if (set.has(GOD_ADMIN)) return true;
|
||||||
|
if (set.has(siteSuperAdminCns(site))) return true;
|
||||||
|
|
||||||
|
if (isKnownLevel(level)) {
|
||||||
|
// admin / access
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
|
if (level === 'access' && hasPermission(memberOf, resource, 'admin')) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Opaque capability — exact aggregate or specific grant only.
|
||||||
|
if (set.has(aggregateGroupCns(site, kind, level))) return true;
|
||||||
|
if (set.has(resourceGroupCns(site, kind, nameSlug, level))) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GOD_ADMIN,
|
||||||
|
KNOWN_LEVELS,
|
||||||
|
KINDS,
|
||||||
|
slugify,
|
||||||
|
resourceNameSlug,
|
||||||
|
resourceGroupCns,
|
||||||
|
aggregateGroupCns,
|
||||||
|
siteSuperAdminCns,
|
||||||
|
siteEveryoneCns,
|
||||||
|
isKnownLevel,
|
||||||
|
levelGrants,
|
||||||
|
hasPermission,
|
||||||
|
};
|
||||||
+77
-13
@@ -1,26 +1,48 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const {Group} = require('../models/group_ldap');
|
const {Group} = require('../models/group_ldap');
|
||||||
|
const groups = require('./groups');
|
||||||
|
|
||||||
const SUPER_ADMIN_GROUP = 'app_super_admin';
|
// The group nested into every resource's _admin group by api_directory_admin
|
||||||
|
// (cross-resource super-admin administration). This is `god_admin` -- the global
|
||||||
|
// super group of the new model (docs/GROUPS.md), seeded by docker-entrypoint.sh.
|
||||||
|
// It used to be the legacy `app_super_admin`, which existed while god_admin
|
||||||
|
// didn't; now that god_admin is created at boot, the provisioning nests it.
|
||||||
|
// LEGACY_SUPER_ADMIN_ALIASES still recognizes a `app_super_admin` that predates
|
||||||
|
// the migration, so an existing deployment isn't stripped of rights until it's
|
||||||
|
// rebuilt.
|
||||||
|
const SUPER_ADMIN_GROUP = 'god_admin';
|
||||||
|
const LEGACY_SUPER_ADMIN_ALIASES = ['app_super_admin'];
|
||||||
|
|
||||||
let byGroup = async function(user, groups, ownerOf){
|
// True if the user (by resolved member cns) is a global god/super admin.
|
||||||
|
// Recognizes BOTH the new schema's `god_admin` and the legacy `app_super_admin`.
|
||||||
|
async function isSuperAdmin(memberOfCns) {
|
||||||
|
return memberOfCns.includes(groups.GOD_ADMIN) ||
|
||||||
|
memberOfCns.some((cn) => LEGACY_SUPER_ADMIN_ALIASES.includes(cn));
|
||||||
|
}
|
||||||
|
|
||||||
|
let byGroup = async function(user, checkGroups, ownerOf){
|
||||||
|
// Membership is resolved once, transitively: a user placed in an admin group
|
||||||
|
// through a nested group is as much a member as one listed on it directly.
|
||||||
|
// Checking `group.member.includes(user.dn)` per group -- as this used to --
|
||||||
|
// only ever sees the literal member list and would deny them.
|
||||||
|
let memberOfCns = [];
|
||||||
try{
|
try{
|
||||||
let superAdmin = await Group.get(SUPER_ADMIN_GROUP);
|
memberOfCns = await Group.list(user.dn);
|
||||||
if(superAdmin.member.includes(user.dn)) return true
|
|
||||||
}catch(error){
|
}catch(error){
|
||||||
// group not found, continue checking
|
// Fall through to the per-group checks below rather than hard-failing;
|
||||||
|
// they still catch direct membership if the resolver is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
for(let group of groups){
|
if(await isSuperAdmin(memberOfCns)) return true;
|
||||||
try{
|
|
||||||
group = await Group.get(group);
|
for(let group of checkGroups){
|
||||||
if(group.member.includes(user.dn)) return true
|
if(memberOfCns.includes(group)) return true;
|
||||||
}catch(error){
|
|
||||||
// group not found, continue checking
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// `owner` is deliberately NOT transitive. It designates accountable people,
|
||||||
|
// and inheriting ownership through a nested group would hand approval rights
|
||||||
|
// to anyone transitively in it -- an escalation nobody asked for.
|
||||||
for(let group of ownerOf || []){
|
for(let group of ownerOf || []){
|
||||||
try{
|
try{
|
||||||
group = await Group.get(group);
|
group = await Group.get(group);
|
||||||
@@ -37,4 +59,46 @@ let byGroup = async function(user, groups, ownerOf){
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {byGroup, SUPER_ADMIN_GROUP};
|
// Resolve whether a user has `level` on a directory resource under the group
|
||||||
|
// model (see utils/groups.js). Applies the inheritance lattice and the
|
||||||
|
// `everyone`/`{site}_everyone` meta grants when the resource grants them.
|
||||||
|
//
|
||||||
|
// user: the auth user ({ dn, isMachine }).
|
||||||
|
// resource:{ site, kind: 'host'|'app', slug }.
|
||||||
|
// level: 'admin' | 'access' | an opaque capability token.
|
||||||
|
// grantedGroups: optional array of the resource's granted group cns (used only
|
||||||
|
// for meta `everyone` handling). Omit to skip meta grants.
|
||||||
|
async function onResource(user, resource, level, grantedGroups) {
|
||||||
|
let memberOfCns = [];
|
||||||
|
try { memberOfCns = await Group.list(user.dn); } catch (e) { /* ignore */ }
|
||||||
|
|
||||||
|
if (await isSuperAdmin(memberOfCns)) return true;
|
||||||
|
if (groups.hasPermission(memberOfCns, resource, level)) return true;
|
||||||
|
|
||||||
|
// Meta grants: `everyone` / `{site}_everyone` confer access to any
|
||||||
|
// authenticated (non-machine) user when the resource grants them.
|
||||||
|
if (level === 'access' && !user.isMachine && Array.isArray(grantedGroups)) {
|
||||||
|
const siteEveryone = groups.siteEveryoneCns(resource.site);
|
||||||
|
if (grantedGroups.includes('everyone') || grantedGroups.includes(siteEveryone)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Like onResource but throws Insufficient Permission when denied — for guards.
|
||||||
|
async function requireResource(user, resource, level, grantedGroups) {
|
||||||
|
if (await onResource(user, resource, level, grantedGroups)) return;
|
||||||
|
const error = new Error('Insufficient Permission');
|
||||||
|
error.name = 'Insufficient Permission';
|
||||||
|
error.status = 401;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
byGroup,
|
||||||
|
onResource,
|
||||||
|
requireResource,
|
||||||
|
isSuperAdmin,
|
||||||
|
SUPER_ADMIN_GROUP,
|
||||||
|
LEGACY_SUPER_ADMIN_ALIASES,
|
||||||
|
...groups, // group schema builders (slugify, resourceGroupCns, ...)
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Per-instance plugin secrets, stored in OpenBao at `secret/plugins/<id>/conf`.
|
||||||
|
//
|
||||||
|
// Plugins run in-process (as BullMQ workers in the SSO Node process), so they
|
||||||
|
// need no OpenBao token of their own — the SSO reads/writes their secrets
|
||||||
|
// server-side through the `sso-broker` token (@simpleworkjs/bao-conf), exactly
|
||||||
|
// like it reads its own `secret/sso-manager/conf`. This mirrors the per-user
|
||||||
|
// (`secret/users/<uid>/*`) and per-app (`secret/apps/<name>/*`) namespaces.
|
||||||
|
//
|
||||||
|
// Only the configSchema fields flagged `secret:true` are stored here; the rest
|
||||||
|
// of an instance's config lives in the PluginInstance DB row. The admin UI
|
||||||
|
// only ever sees these masked (`********`).
|
||||||
|
//
|
||||||
|
// Requires theta-suite >= v1.30.1: the sso-broker policy must grant
|
||||||
|
// `secret/data/plugins/*` + `secret/metadata/plugins/*`. Without it, write/
|
||||||
|
// read fail with a 403 — the API surfaces that as a clear error so the operator
|
||||||
|
// knows to re-run `./setup.sh`.
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
|
||||||
|
// Instance ids are ORM-generated uuids, so this is defense-in-depth against a
|
||||||
|
// bogus id ever being interpolated into a secret path. 404s are expected
|
||||||
|
// (no secret written yet); other malformed input is rejected hard.
|
||||||
|
function assertId(id) {
|
||||||
|
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
|
||||||
|
const err = new Error('invalid plugin instance id for secret path');
|
||||||
|
err.status = 400;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function path(id) {
|
||||||
|
return `plugins/${id}/conf`; // baoConf.get/set add the secret/data prefix
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the secret field values for an instance. Returns {} when none are
|
||||||
|
// stored yet (a brand-new instance, or one with no secret fields). A 404 from
|
||||||
|
// OpenBao is normal — anything else propagates.
|
||||||
|
async function read(id) {
|
||||||
|
assertId(id);
|
||||||
|
try {
|
||||||
|
const data = await baoConf.get(path(id));
|
||||||
|
return (data && typeof data === 'object') ? data : {};
|
||||||
|
} catch (err) {
|
||||||
|
// bao-conf treats a missing KV path as null/empty, but a 403 means the
|
||||||
|
// sso-broker policy lacks secret/plugins/* — surface that distinctly.
|
||||||
|
if (err && /403|permission/i.test(err.message)) throw err;
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write (replace) the secret field values for an instance. `secrets` is a flat
|
||||||
|
// {field: value} object of only the secret configSchema fields. Empty/blank
|
||||||
|
// values are dropped so we never store a masked placeholder back as a secret.
|
||||||
|
async function write(id, secrets) {
|
||||||
|
assertId(id);
|
||||||
|
const clean = {};
|
||||||
|
for (const [k, v] of Object.entries(secrets || {})) {
|
||||||
|
if (v === undefined || v === null || v === '' || v === '********') continue;
|
||||||
|
clean[k] = v;
|
||||||
|
}
|
||||||
|
await baoConf.set(path(id), clean);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the stored secret field values over the instance's non-secret config,
|
||||||
|
// producing the single `config` object the plugin's run()/validate() receive.
|
||||||
|
// Non-secret values come from the DB row; secret values come from OpenBao.
|
||||||
|
async function mergeForRun(instance) {
|
||||||
|
if (!instance) return {};
|
||||||
|
const config = (instance.config && typeof instance.config === 'object') ? instance.config : {};
|
||||||
|
const secrets = await read(instance.id);
|
||||||
|
return { ...config, ...secrets };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort delete of the instance's secret namespace. Called when an
|
||||||
|
// instance is deleted. A 404 (already gone / never written) is fine; anything
|
||||||
|
// else is logged and swallowed so a stuck OpenBao can't strand an instance row.
|
||||||
|
async function remove(id) {
|
||||||
|
assertId(id);
|
||||||
|
try {
|
||||||
|
const res = await baoConf.request('DELETE', `secret/metadata/plugins/${id}/conf`);
|
||||||
|
if (res && res.status && res.status !== 404 && !res.ok) {
|
||||||
|
console.error(`[plugin_secrets] delete for ${id} returned ${res.status}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[plugin_secrets] failed to delete secrets for ${id}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { read, write, remove, mergeForRun };
|
||||||
+5
-1
@@ -38,9 +38,13 @@ module.exports = {
|
|||||||
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
// app-base.js, which reveals .group-required-<cn> for each group the user is
|
||||||
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
// in (plus the synthetic `admin` group when user/me reports isAdmin).
|
||||||
nav: [
|
nav: [
|
||||||
|
// Catalog requires login - it's the end-user view of their accessible resources.
|
||||||
|
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: ['login']},
|
||||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']},
|
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
|
// Vault requires login - per-user secrets at secret/users/<uid>/*.
|
||||||
|
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
|
||||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Resolve a request user's LDAP group CNs.
|
||||||
|
//
|
||||||
|
// Why this exists: `req.user` is a `User.get()` result, which carries
|
||||||
|
// `memberOf` -- a list of full group DNs -- and has no `groups` property at
|
||||||
|
// all. Anything reading `req.user.groups` therefore silently sees an empty
|
||||||
|
// list rather than failing, which is how GET /api/discovery/me came to return
|
||||||
|
// only `isPublic` resources for every human caller, and how
|
||||||
|
// isDirectoryAdmin() came to be false even for real directory admins.
|
||||||
|
//
|
||||||
|
// routes/user.js:83 already derives the admin gate from `memberOf` the same
|
||||||
|
// way, so the overlay is known to be populated in production; the Group.list()
|
||||||
|
// fallback covers a user object assembled without it (and costs an LDAP round
|
||||||
|
// trip, so it is genuinely the fallback).
|
||||||
|
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
// 'cn=app_sso_admin,ou=groups,dc=example,dc=com' -> 'app_sso_admin'
|
||||||
|
function cnFromDn(dn) {
|
||||||
|
return String(dn).split(',')[0].replace(/^cn=/i, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function groupCns(user) {
|
||||||
|
if (!user || user.isMachine) return [];
|
||||||
|
|
||||||
|
// Group.list(dn) resolves nested groups transitively. `memberOf` cannot: the
|
||||||
|
// memberof overlay records only direct membership, so a user who reaches a
|
||||||
|
// resource group through a nested group is absent from it entirely. That
|
||||||
|
// makes memberOf a fallback for when there is no DN to query with, never the
|
||||||
|
// preferred source -- reading it first would silently drop every nested grant.
|
||||||
|
if (user.dn) {
|
||||||
|
try {
|
||||||
|
return await Group.list(user.dn);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`groupCns: LDAP lookup failed for ${user.uid}:`, err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(user.memberOf)) return user.memberOf.map(cnFromDn);
|
||||||
|
// memberOf is single-valued when the user is in exactly one group.
|
||||||
|
if (user.memberOf) return [cnFromDn(user.memberOf)];
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The shape @simpleworkjs/directory-schema's isDirectoryAdmin() expects: it
|
||||||
|
// matches against `.groups`, which the raw request user does not have.
|
||||||
|
async function withGroups(user) {
|
||||||
|
if (!user) return user;
|
||||||
|
return Object.assign(Object.create(Object.getPrototypeOf(user) || Object.prototype), user, {
|
||||||
|
groups: await groupCns(user),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { groupCns, withGroups, cnFromDn };
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Vault broker — mints scoped OpenBao tokens for end users, admins, and
|
||||||
|
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||||
|
// `sso-broker` token role created by theta-env/setup.sh.
|
||||||
|
//
|
||||||
|
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||||
|
// secret/shared/<uid>/* user-owned shared KV (user-<uid> policy)
|
||||||
|
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||||
|
// secret/shared/<owner>/<slug> granted read (added to grantee's policy)
|
||||||
|
// secret/* admin UI sessions (sso-admin policy)
|
||||||
|
//
|
||||||
|
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
||||||
|
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
||||||
|
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
||||||
|
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
||||||
|
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
||||||
|
//
|
||||||
|
// Policy reconciliation is the load-bearing part: OpenBao parses policy CONTENT
|
||||||
|
// live at token use (only the SET of policy names on a token is fixed at mint),
|
||||||
|
// so we ALWAYS reconcile a subject's policy content BEFORE returning any token
|
||||||
|
// — cached or freshly minted. That way a stale cached token immediately gains
|
||||||
|
// corrected/revoked capabilities, and a new shared-secret grant takes effect for
|
||||||
|
// an existing grantee token with no re-mint. The Redis cache only short-circuits
|
||||||
|
// token MINTING, never policy reconciliation.
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const express = require('express');
|
||||||
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const permission = require('./permission');
|
||||||
|
const { SharedSecret } = require('../models/shared_secret');
|
||||||
|
const { SharedSecretGrant } = require('../models/shared_secret_grant');
|
||||||
|
const { VaultAppToken } = require('../models/vault_app_token');
|
||||||
|
|
||||||
|
const ROLE = 'sso-broker';
|
||||||
|
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||||
|
|
||||||
|
let redisClient;
|
||||||
|
async function getRedis() {
|
||||||
|
if (!redisClient) {
|
||||||
|
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
|
||||||
|
: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
|
||||||
|
redisClient = createClient({ url });
|
||||||
|
redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
|
||||||
|
await redisClient.connect();
|
||||||
|
}
|
||||||
|
return redisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheGet(key) {
|
||||||
|
try { return await (await getRedis()).get(key); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
async function cacheSet(key, value, ttl) {
|
||||||
|
try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
|
||||||
|
// SSO_VAULT_TOKEN). Throws on non-2xx.
|
||||||
|
async function bao(method, path, body) {
|
||||||
|
const res = await baoConf.request(method, path, body);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure an ACL policy carries exactly `hcl`. Compare-and-skip: read the current
|
||||||
|
// content and only PUT when it differs. `bao policy write` is an idempotent
|
||||||
|
// overwrite, so this is safe to call on every token fetch — edits (e.g. adding a
|
||||||
|
// grant) propagate immediately because OpenBao parses policy content at use.
|
||||||
|
async function ensurePolicy(name, hcl) {
|
||||||
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
|
if (existing.status !== 200 && existing.status !== 404) {
|
||||||
|
const t = await existing.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
|
}
|
||||||
|
if (existing.status === 200) {
|
||||||
|
const body = await existing.json().catch(() => null);
|
||||||
|
if (body && typeof body.policy === 'string' && body.policy === hcl) return; // unchanged
|
||||||
|
}
|
||||||
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a token through a token role with the given policies. Returns
|
||||||
|
// { token, accessor, ttl } (ttl = lease_duration seconds, falls back to
|
||||||
|
// DEFAULT_TTL). Roles: sso-broker (24h period — user/admin tokens, re-minted
|
||||||
|
// from cache) and sso-app (768h period — long-lived external-app credentials,
|
||||||
|
// kept alive via their stored accessor by the renewal loop below).
|
||||||
|
async function mintToken(policies, role = ROLE) {
|
||||||
|
const res = await bao('POST', `auth/token/create/${role}`, { policies });
|
||||||
|
const json = await res.json();
|
||||||
|
const token = json && json.auth && json.auth.client_token;
|
||||||
|
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||||
|
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||||
|
return { token, accessor: json.auth.accessor, ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared-secret policy rules ───────────────────────────────────────────────
|
||||||
|
// Returns the HCL rules granting `read` on every shared secret the given
|
||||||
|
// grantee (a user uid or an app name) has been granted. Enforcement is
|
||||||
|
// OpenBao ACL policy CONTENT — live-evaluated at token use, so these rules take
|
||||||
|
// effect for the grantee's existing token immediately (no re-mint).
|
||||||
|
async function sharedPolicyRules(granteeType, granteeId) {
|
||||||
|
const grants = await SharedSecretGrant.listForGrantee(granteeType, granteeId);
|
||||||
|
if (!grants.length) return '';
|
||||||
|
const secretIds = [...new Set(grants.map(g => g.secretId))];
|
||||||
|
const secrets = secretIds.length
|
||||||
|
? await SharedSecret.list({ where: { id: { in: secretIds } } }) : [];
|
||||||
|
const byId = new Map(secrets.map(s => [s.id, s]));
|
||||||
|
const rules = [];
|
||||||
|
for (const g of grants) {
|
||||||
|
const sec = byId.get(g.secretId);
|
||||||
|
if (!sec) continue;
|
||||||
|
const p = sec.path(); // shared/<ownerUid>/<slug>
|
||||||
|
rules.push(`path "secret/data/${p}" { capabilities = ["read"] }`);
|
||||||
|
rules.push(`path "secret/metadata/${p}" { capabilities = ["read", "list"] }`);
|
||||||
|
}
|
||||||
|
return rules.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||||
|
async function userPolicyHcl(uid) {
|
||||||
|
const granted = await sharedPolicyRules('user', uid);
|
||||||
|
return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/shared/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint (or return the cached) per-user token. The policy is ALWAYS reconciled
|
||||||
|
// (compare-and-skip) before the cache is consulted, so a cached token can never
|
||||||
|
// outlive a policy change; the cache only short-circuits re-minting. Re-minted
|
||||||
|
// when the cache entry expires (a little before the token's own TTL).
|
||||||
|
async function getOrCreateUserToken(uid) {
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
||||||
|
await ensurePolicy(`user-${uid}`, await userPolicyHcl(uid));
|
||||||
|
const cacheKey = `vault_token:${uid}`;
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||||
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||||
|
function adminPolicyHcl() {
|
||||||
|
return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOrCreateAdminToken(uid) {
|
||||||
|
await ensurePolicy('sso-admin', adminPolicyHcl());
|
||||||
|
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { token, ttl } = await mintToken(['sso-admin']);
|
||||||
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
|
async function appPolicyHcl(name) {
|
||||||
|
const granted = await sharedPolicyRules('app', name);
|
||||||
|
return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
${granted}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||||
|
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
||||||
|
// a later compromise of an admin session cannot recover previously-minted app
|
||||||
|
// tokens. The caller must record it in the external app immediately. Later
|
||||||
|
// grants to the app edit app-<name> policy content (live-applied to this token).
|
||||||
|
//
|
||||||
|
// What IS stored is the token's ACCESSOR (VaultAppToken row): an accessor
|
||||||
|
// cannot authenticate, but it lets the renewal loop below keep the (periodic)
|
||||||
|
// token alive and lets a re-mint revoke the app's previous token so exactly
|
||||||
|
// one credential per app is ever live.
|
||||||
|
async function mintAppToken(name, actorUid) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||||
|
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||||
|
}
|
||||||
|
await ensurePolicy(`app-${name}`, await appPolicyHcl(name));
|
||||||
|
// App tokens are long-lived credentials: mint via the sso-app role (768h
|
||||||
|
// period) so a renewal inside every 32-day window keeps them alive forever.
|
||||||
|
// Fall back to the broker's own 24h role on deployments whose setup.sh
|
||||||
|
// predates the sso-app role (re-running setup.sh creates it).
|
||||||
|
let minted;
|
||||||
|
try {
|
||||||
|
minted = await mintToken([`app-${name}`], 'sso-app');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`vault_broker: sso-app token role unavailable (${e.message}); falling back to sso-broker (24h period). Re-run theta-env setup.sh to create the sso-app role.`);
|
||||||
|
minted = await mintToken([`app-${name}`]);
|
||||||
|
}
|
||||||
|
const { token, accessor, ttl } = minted;
|
||||||
|
// Replace the app's accessor row; revoke the superseded token (best-effort —
|
||||||
|
// it may already be expired) so re-minting never leaves a zombie credential.
|
||||||
|
try {
|
||||||
|
const existing = await VaultAppToken.getByName(name);
|
||||||
|
if (existing) {
|
||||||
|
await baoConf.request('POST', 'auth/token/revoke-accessor', { accessor: existing.accessor });
|
||||||
|
await existing.delete();
|
||||||
|
}
|
||||||
|
if (accessor) {
|
||||||
|
await VaultAppToken.create({
|
||||||
|
name, accessor,
|
||||||
|
lastRenewedAt: Date.now(),
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Accessor bookkeeping must never block handing the token out; without a
|
||||||
|
// row the token simply isn't auto-renewed (it still lives one full period).
|
||||||
|
console.error(`vault_broker: could not store accessor for app-${name}:`, e.message);
|
||||||
|
}
|
||||||
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App-token renewal loop ──────────────────────────────────────────────────
|
||||||
|
// Walks the stored accessors and renews each token (auth/token/renew-accessor),
|
||||||
|
// resetting its periodic clock. Runs at boot and then every RENEW_INTERVAL_MS —
|
||||||
|
// far inside both possible periods (24h fallback and 768h), so a downstream
|
||||||
|
// app's token stays valid for as long as sso is running. Failures are recorded
|
||||||
|
// on the row (visible to admins in the DB / future UI) and never throw.
|
||||||
|
const RENEW_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h — several chances per 24h period
|
||||||
|
let renewTimer;
|
||||||
|
|
||||||
|
async function renewAppTokens() {
|
||||||
|
let rows;
|
||||||
|
try { rows = await VaultAppToken.list(); }
|
||||||
|
catch (e) { console.error('vault_broker: app-token renewal: could not list accessors:', e.message); return; }
|
||||||
|
for (const row of rows) {
|
||||||
|
try {
|
||||||
|
const res = await baoConf.request('POST', 'auth/token/renew-accessor', { accessor: row.accessor });
|
||||||
|
if (res.ok) {
|
||||||
|
await row.update({ lastRenewedAt: Date.now(), lastError: null });
|
||||||
|
} else {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
// 400 "invalid accessor" = token expired or was revoked out-of-band;
|
||||||
|
// keep the row + error so the admin can see the app needs a re-mint.
|
||||||
|
await row.update({ lastError: `renew failed (${res.status}) ${text}` });
|
||||||
|
console.warn(`vault_broker: renew of app token '${row.name}' failed (${res.status}) — re-mint it from the vault UI if the app is still in use.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
try { await row.update({ lastError: e.message }); } catch (e2) { /* best-effort */ }
|
||||||
|
console.error(`vault_broker: renew of app token '${row.name}' errored:`, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the loop (idempotent). unref() so an open handle never blocks exit.
|
||||||
|
function startAppTokenRenewal() {
|
||||||
|
if (renewTimer) return renewTimer;
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: initial app-token renewal failed:', e.message));
|
||||||
|
renewTimer = setInterval(() => {
|
||||||
|
renewAppTokens().catch((e) => console.error('vault_broker: app-token renewal failed:', e.message));
|
||||||
|
}, RENEW_INTERVAL_MS);
|
||||||
|
if (renewTimer.unref) renewTimer.unref();
|
||||||
|
return renewTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Grant / revoke shared-secret access ─────────────────────────────────────
|
||||||
|
// Creating a grant writes the DB row and then edits the grantee's policy content
|
||||||
|
// to add read on the shared path; revoking removes both. Because OpenBao parses
|
||||||
|
// policy content live, the change applies to the grantee's existing token
|
||||||
|
// immediately — no token re-mint, no cache invalidation needed.
|
||||||
|
async function grantSharedSecret(secretId, granteeType, granteeId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.create({
|
||||||
|
secretId, granteeType, granteeId, capability: 'read',
|
||||||
|
created_by: actorUid, created_on: Date.now(),
|
||||||
|
updated_by: actorUid, updated_on: Date.now(),
|
||||||
|
});
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeSharedSecret(grantId, actorUid) {
|
||||||
|
const grant = await SharedSecretGrant.get(grantId);
|
||||||
|
if (!grant) return null;
|
||||||
|
const { granteeType, granteeId } = grant;
|
||||||
|
await grant.delete();
|
||||||
|
await reconcileGrantee(granteeType, granteeId);
|
||||||
|
return grant;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recompute and rewrite a grantee's policy content after a grant/revoke.
|
||||||
|
async function reconcileGrantee(granteeType, granteeId) {
|
||||||
|
if (granteeType === 'user') {
|
||||||
|
await ensurePolicy(`user-${granteeId}`, await userPolicyHcl(granteeId));
|
||||||
|
} else if (granteeType === 'app') {
|
||||||
|
await ensurePolicy(`app-${granteeId}`, await appPolicyHcl(granteeId));
|
||||||
|
} else {
|
||||||
|
throw new Error(`invalid granteeType: ${granteeType}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||||
|
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||||
|
// nothing). The guard mints a server-side token for the user (per-user or
|
||||||
|
// admin) and enforces the path prefix as defense-in-depth on top of the
|
||||||
|
// token's own policy; the proxy injects ONLY that token and strips the
|
||||||
|
// client's sso auth headers so OpenBao never sees them.
|
||||||
|
|
||||||
|
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||||
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(user, ADMIN_GROUPS);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a KV-v2 request path by stripping the data/metadata segment so the
|
||||||
|
// prefix check works on the logical path: /secret/data/users/alice/foo ->
|
||||||
|
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
|
||||||
|
function normalizeVaultPath(p) {
|
||||||
|
const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
|
||||||
|
if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scopeGuard(req, res, next) {
|
||||||
|
if (!req.user || req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
|
||||||
|
}
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const norm = normalizeVaultPath(req.path);
|
||||||
|
if (norm === null) {
|
||||||
|
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||||
|
}
|
||||||
|
const userBase = `/secret/users/${uid}`;
|
||||||
|
const sharedBase = `/secret/shared`;
|
||||||
|
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.vaultToken = token;
|
||||||
|
req.vaultIsAdmin = admin;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultProxy() {
|
||||||
|
return createProxyMiddleware({
|
||||||
|
target: VAULT_ADDR,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/api/vault': '/v1' },
|
||||||
|
// http-proxy-middleware v2 API: hooks are top-level onProxyReq/onError,
|
||||||
|
// NOT the v3 `on: { proxyReq }` shape. v2 silently ignores an `on` key,
|
||||||
|
// which shipped this proxy with NO token injection — every /api/vault
|
||||||
|
// call reached OpenBao unauthenticated and 403'd.
|
||||||
|
onProxyReq(proxyReq, req, res, options) {
|
||||||
|
// Header ops MUST precede fixRequestBody: it write()s the parsed body
|
||||||
|
// onto proxyReq, which flushes headers — setHeader after that throws
|
||||||
|
// (swallowed upstream), silently dropping the token on every write.
|
||||||
|
// Inject ONLY the server-minted scoped token; strip the client's
|
||||||
|
// sso session/api auth so it never reaches OpenBao.
|
||||||
|
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||||
|
proxyReq.removeHeader('auth-token');
|
||||||
|
proxyReq.removeHeader('authorization');
|
||||||
|
fixRequestBody(proxyReq, req, res, options);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
|
||||||
|
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
|
||||||
|
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
|
||||||
|
const mintAppRouter = express.Router();
|
||||||
|
mintAppRouter.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const name = (req.body && req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const result = await mintAppToken(name, req.user && req.user.uid);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// List the minted external-app tokens (metadata only — the token itself is shown
|
||||||
|
// once at mint and never stored; the accessor is a renewal/revoke handle and is
|
||||||
|
// never exposed). Lets the Apps tab show what has been minted instead of a
|
||||||
|
// credential vanishing into the void.
|
||||||
|
mintAppRouter.get('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const rows = await VaultAppToken.list();
|
||||||
|
res.json({ apps: rows.map((r) => ({
|
||||||
|
name: r.name,
|
||||||
|
createdBy: r.created_by,
|
||||||
|
createdOn: r.created_on,
|
||||||
|
lastRenewedAt: r.lastRenewedAt || null,
|
||||||
|
lastError: r.lastError || null,
|
||||||
|
})) });
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrCreateUserToken,
|
||||||
|
getOrCreateAdminToken,
|
||||||
|
mintAppToken,
|
||||||
|
ensurePolicy,
|
||||||
|
scopeGuard,
|
||||||
|
vaultProxy,
|
||||||
|
mintAppRouter,
|
||||||
|
// app-token lifecycle
|
||||||
|
renewAppTokens,
|
||||||
|
startAppTokenRenewal,
|
||||||
|
VaultAppToken,
|
||||||
|
// sharing
|
||||||
|
SharedSecret,
|
||||||
|
SharedSecretGrant,
|
||||||
|
userPolicyHcl,
|
||||||
|
appPolicyHcl,
|
||||||
|
grantSharedSecret,
|
||||||
|
revokeSharedSecret,
|
||||||
|
reconcileGrantee,
|
||||||
|
};
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||||
|
|
||||||
|
var messagingTypes = {};
|
||||||
|
var messagingPlugins = [];
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadConf();
|
||||||
|
loadProxyConf();
|
||||||
|
loadTos();
|
||||||
|
loadMessagingPlugins();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConf() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('conf');
|
||||||
|
// Populate SMTP
|
||||||
|
if (data.smtp) {
|
||||||
|
$('#smtp-host').val(data.smtp.host || '');
|
||||||
|
$('#smtp-port').val(data.smtp.port || 587);
|
||||||
|
$('#smtp-user').val(data.smtp.user || '');
|
||||||
|
$('#smtp-pass').val(data.smtp.pass || '');
|
||||||
|
$('#smtp-from').val(data.smtp.from || '');
|
||||||
|
$('#smtp-secure').prop('checked', !!data.smtp.secure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate OAuth
|
||||||
|
if (data.oauth) {
|
||||||
|
$('#oauth-issuer').val(data.oauth.issuer || '');
|
||||||
|
$('#oauth-jwtsecret').val(data.oauth.jwtSecret || '');
|
||||||
|
if (data.oauth.token_lifetime) {
|
||||||
|
$('#oauth-token-access').val(data.oauth.token_lifetime.access_token || 3600);
|
||||||
|
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate SMS (VoIP.ms)
|
||||||
|
if (data.voipms) {
|
||||||
|
$('#voipms-username').val(data.voipms.username || '');
|
||||||
|
$('#voipms-did').val(data.voipms.did || '');
|
||||||
|
$('#voipms-password').val(data.voipms.password || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConf() {
|
||||||
|
const btn = $('#btn-save');
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
smtp: {
|
||||||
|
host: $('#smtp-host').val(),
|
||||||
|
port: parseInt($('#smtp-port').val(), 10) || 587,
|
||||||
|
user: $('#smtp-user').val(),
|
||||||
|
pass: $('#smtp-pass').val(),
|
||||||
|
from: $('#smtp-from').val(),
|
||||||
|
secure: $('#smtp-secure').is(':checked')
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
issuer: $('#oauth-issuer').val(),
|
||||||
|
jwtSecret: $('#oauth-jwtsecret').val(),
|
||||||
|
token_lifetime: {
|
||||||
|
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
||||||
|
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
||||||
|
}
|
||||||
|
},
|
||||||
|
voipms: {
|
||||||
|
username: $('#voipms-username').val(),
|
||||||
|
did: $('#voipms-did').val(),
|
||||||
|
password: $('#voipms-password').val()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
app.messages.toast('Configuration saved successfully!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestEmail() {
|
||||||
|
const to = $('#test-email-to').val().trim();
|
||||||
|
if (!to) {
|
||||||
|
app.messages.toast('Please enter a recipient email address', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = $('#btn-test-email');
|
||||||
|
const originalHtml = btn.html();
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
smtp: {
|
||||||
|
host: $('#smtp-host').val(),
|
||||||
|
port: parseInt($('#smtp-port').val(), 10) || 587,
|
||||||
|
user: $('#smtp-user').val(),
|
||||||
|
pass: $('#smtp-pass').val(),
|
||||||
|
from: $('#smtp-from').val(),
|
||||||
|
secure: $('#smtp-secure').is(':checked')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
const result = await app.api.post('conf/test-email', { to });
|
||||||
|
app.messages.toast(result.message || 'Test email sent!', 'success');
|
||||||
|
$('#test-email-to').val('');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to send test email: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html(originalHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendTestSms() {
|
||||||
|
const to = $('#test-sms-to').val().trim();
|
||||||
|
if (!to) {
|
||||||
|
app.messages.toast('Please enter a recipient phone number', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const btn = $('#btn-test-sms');
|
||||||
|
const originalHtml = btn.html();
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
voipms: {
|
||||||
|
username: $('#voipms-username').val(),
|
||||||
|
did: $('#voipms-did').val(),
|
||||||
|
password: $('#voipms-password').val()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
const result = await app.api.post('conf/test-sms', { to });
|
||||||
|
app.messages.toast(result.message || 'Test SMS sent!', 'success');
|
||||||
|
$('#test-sms-to').val('');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to send test SMS: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html(originalHtml);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassword(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el.type === 'password') {
|
||||||
|
el.type = 'text';
|
||||||
|
} else {
|
||||||
|
el.type = 'password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProxyConf() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('conf/proxy');
|
||||||
|
if (data.oidc) {
|
||||||
|
$('#proxy-issuer').val(data.oidc.issuer || '');
|
||||||
|
$('#proxy-client-id').val(data.oidc.clientId || '');
|
||||||
|
$('#proxy-client-secret').val(data.oidc.clientSecret || '');
|
||||||
|
}
|
||||||
|
if (data.ldap) {
|
||||||
|
$('#proxy-ldap-bindpass').val(data.ldap.bindPassword || '');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load Proxy conf:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProxyConf() {
|
||||||
|
const btn = $('#btn-save-proxy');
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
oidc: {
|
||||||
|
issuer: $('#proxy-issuer').val(),
|
||||||
|
clientId: $('#proxy-client-id').val(),
|
||||||
|
clientSecret: $('#proxy-client-secret').val()
|
||||||
|
},
|
||||||
|
ldap: {
|
||||||
|
bindPassword: $('#proxy-ldap-bindpass').val()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.api.post('conf/proxy', payload);
|
||||||
|
app.messages.toast('Proxy configuration saved securely to OpenBao!', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTos() {
|
||||||
|
try {
|
||||||
|
const tos = await app.tos.get();
|
||||||
|
if (tos && tos.content) {
|
||||||
|
document.getElementById('tos-content').value = tos.content;
|
||||||
|
document.getElementById('tos-meta').textContent =
|
||||||
|
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
console.error('Failed to load ToS:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTos() {
|
||||||
|
const content = document.getElementById('tos-content').value.trim();
|
||||||
|
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
||||||
|
const msgEl = document.getElementById('tos-result');
|
||||||
|
|
||||||
|
if (!content) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
app.tos.update({content, resetAcceptance}, function(error, data) {
|
||||||
|
if (error) {
|
||||||
|
msgEl.className = 'alert alert-danger mt-2';
|
||||||
|
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
||||||
|
msgEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
msgEl.className = 'alert alert-success mt-2';
|
||||||
|
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
||||||
|
msgEl.style.display = '';
|
||||||
|
document.getElementById('tos-reset-acceptance').checked = false;
|
||||||
|
loadTos();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Messaging Plugins ──────────────────────────────────────────────
|
||||||
|
function loadMessagingPlugins() {
|
||||||
|
app.api.get('plugins/types', function(err, res) {
|
||||||
|
if (!err && res && res.results) {
|
||||||
|
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
|
||||||
|
}
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if (err) return;
|
||||||
|
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
|
||||||
|
renderMessagingPlugins();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessagingPlugins() {
|
||||||
|
const $list = $('#messaging-plugins-list').empty();
|
||||||
|
if (messagingPlugins.length === 0) {
|
||||||
|
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
messagingPlugins.forEach(p => {
|
||||||
|
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
|
||||||
|
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
|
||||||
|
const card = `
|
||||||
|
<div class="card mb-3 border shadow-sm">
|
||||||
|
<div class="card-body d-flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
|
||||||
|
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<span class="badge ${badgeClass} me-2">${statusText}</span>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
$list.append(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePlugin(id, state) {
|
||||||
|
const endpoint = state ? 'load' : 'unload';
|
||||||
|
try {
|
||||||
|
await app.api.post(`plugins/${id}/${endpoint}`, {});
|
||||||
|
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePlugin(id) {
|
||||||
|
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
|
||||||
|
if (!ok) return;
|
||||||
|
try {
|
||||||
|
await app.api.delete(`plugins/${id}`);
|
||||||
|
app.messages.toast('Plugin deleted', 'success');
|
||||||
|
loadMessagingPlugins();
|
||||||
|
} catch (e) {
|
||||||
|
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card shadow">
|
||||||
|
<!-- Header with Sub-Nav Tabs matching directory.ejs -->
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
|
||||||
|
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
|
||||||
|
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
|
||||||
|
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
|
||||||
|
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
|
||||||
|
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
|
||||||
|
<button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-body p-4">
|
||||||
|
<div class="tab-content" id="confTabContent">
|
||||||
|
|
||||||
|
<!-- OAuth & JWT Tab -->
|
||||||
|
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
|
||||||
|
<h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
|
||||||
|
<p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
|
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">JWT Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMTP Tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
|
||||||
|
<h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
|
||||||
|
<p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-8 mb-3">
|
||||||
|
<label class="form-label fw-semibold">SMTP Host</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Port</label>
|
||||||
|
<input type="number" class="form-control" id="smtp-port" placeholder="587">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">User</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-user">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">From Address</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||||
|
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
|
||||||
|
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test Email
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">Saves current SMTP config and sends a test message.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SMS & Messaging Tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
|
||||||
|
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
|
||||||
|
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">API Username</label>
|
||||||
|
<input type="text" class="form-control" id="voipms-username">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">DID Sender Number</label>
|
||||||
|
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">API Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="voipms-password" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-3 bg-light rounded border mb-4">
|
||||||
|
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
|
||||||
|
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
|
||||||
|
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="my-4">
|
||||||
|
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
|
||||||
|
</div>
|
||||||
|
<div id="messaging-plugins-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Proxy Secrets Tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
|
||||||
|
<h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
|
||||||
|
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
|
||||||
|
|
||||||
|
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Issuer URL</label>
|
||||||
|
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client ID</label>
|
||||||
|
<input type="text" class="form-control" id="proxy-client-id">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 mb-3">
|
||||||
|
<label class="form-label fw-semibold">Client Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Proxy Bind Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Terms of Service Tab -->
|
||||||
|
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
|
||||||
|
<span class="small text-muted" id="tos-meta"></span>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
|
||||||
|
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="form-check mb-4">
|
||||||
|
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
||||||
|
<label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
|
||||||
|
<div id="tos-result" style="display:none" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
+1355
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,170 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> View discovered network resources and promote them to managed SSO groups.
|
||||||
|
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Name / Source</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end pe-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="discovery-list" jq-repeat="resources">
|
||||||
|
<tr id="resource-row-{{slug}}">
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold">{{name}}</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{kind}}</span>
|
||||||
|
{{#metadata.subType}}
|
||||||
|
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
{{/metadata.subType}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
{{#metadata.interfaces.length}}
|
||||||
|
<div class="mt-1 small text-muted">
|
||||||
|
{{#metadata.interfaces}}
|
||||||
|
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
{{/metadata.interfaces}}
|
||||||
|
</div>
|
||||||
|
{{/metadata.interfaces.length}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
Promoted
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
<h5>No resources found</h5>
|
||||||
|
<p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
|
let allResources = [];
|
||||||
|
|
||||||
|
function loadResources() {
|
||||||
|
app.api.get('discovery/resources', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allResources = res.results || [];
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const search = $('#search-filter').val().toLowerCase();
|
||||||
|
const managedFilter = $('#filter-managed').val();
|
||||||
|
|
||||||
|
const filtered = allResources.filter(r => {
|
||||||
|
// Name search
|
||||||
|
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
|
||||||
|
// Always hide items that have been committed to the catalog (managed)
|
||||||
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
if(isManaged) return false;
|
||||||
|
|
||||||
|
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
if(!isAuto) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$.scope.resources.empty();
|
||||||
|
for(const r of filtered) {
|
||||||
|
$.scope.resources.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(filtered.length === 0) {
|
||||||
|
$('#discovery-list').hide();
|
||||||
|
$('#empty-state').show();
|
||||||
|
} else {
|
||||||
|
$('#discovery-list').show();
|
||||||
|
$('#empty-state').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteResource(slug) {
|
||||||
|
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('.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();
|
||||||
|
loadResources();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadResources();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||||
|
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
<% if (error.status === 404) { %>
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
<% } else { %>
|
||||||
|
An unexpected error occurred. Please try again later.
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||||
|
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
<%- include('top') %>
|
|
||||||
|
|
||||||
<script type="text/javascript">
|
|
||||||
var userlist;
|
|
||||||
var allGroups = [];
|
|
||||||
|
|
||||||
function processGroup(value){
|
|
||||||
if (!Array.isArray(value.member)) value.member = value.member ? [value.member] : [];
|
|
||||||
if (!Array.isArray(value.owner)) value.owner = value.owner ? [value.owner] : [];
|
|
||||||
value.toAdd = userlist.filter(function(user){
|
|
||||||
return !value.member.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.toAddOwner = userlist.filter(function(user){
|
|
||||||
return !value.owner.includes(user.dn);
|
|
||||||
});
|
|
||||||
value.member = value.member.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.owner = value.owner.map(function(user){
|
|
||||||
return {
|
|
||||||
dn: user,
|
|
||||||
uid: user.match(/cn=[a-zA-Z0-9\_\-\@\.]+/)[0].replace('cn=', '')
|
|
||||||
};
|
|
||||||
});
|
|
||||||
value.memberCount = value.member.length;
|
|
||||||
value.createTimestamp = moment(value.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.modifyTimestamp = moment(value.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
|
||||||
value.groupCN = value.cn;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// app_sso_service_account is a marker group: membership hides an account
|
|
||||||
// from the Users page's People tab entirely (see users.ejs), which is
|
|
||||||
// exactly right for a non-person account but has silently made a real
|
|
||||||
// person's account look "gone" before (nothing else about it changes).
|
|
||||||
// Everywhere else in this dropdown just fires the PUT directly; only
|
|
||||||
// this one group gets a confirmation first.
|
|
||||||
function addMemberClick(event, groupCN, uid, el){
|
|
||||||
event.preventDefault();
|
|
||||||
const $el = $(el);
|
|
||||||
(async function(){
|
|
||||||
if (groupCN === 'app_sso_service_account') {
|
|
||||||
const ok = await app.messages.confirm(
|
|
||||||
`Mark "${uid}" as a service account? This hides them from the Users page's People tab (Service Accounts tab only) — only do this for a non-person account.`,
|
|
||||||
$el.closest('.card'), 'warning'
|
|
||||||
);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const data = await app.api.put(`group/${groupCN}/${uid}`, {});
|
|
||||||
await addedUser(data.message, groupCN, uid, $el);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to add member', $el.closest('.card'), 'danger');
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function addedUser(message, group, user, $form){
|
|
||||||
let data = await app.group.get(group);
|
|
||||||
$.scope.groupCard.update('cn', group, processGroup(data.results));
|
|
||||||
app.messages.action(message, $("#group-card-"+group), 'success');
|
|
||||||
$('a[href="#'+$form.closest('.tab-pane').attr('id')+'"]').tab('show');
|
|
||||||
setTimeout(function(){ app.util.revealItem($("#group-card-" + group)); }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applySort() {
|
|
||||||
const sort = $('#groupSort').val();
|
|
||||||
const scope = $.scope.groupCard;
|
|
||||||
if (sort === 'name-asc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = false; }
|
|
||||||
if (sort === 'name-desc') { scope.__jqOrderBy = 'cn'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-desc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = true; }
|
|
||||||
if (sort === 'members-asc') { scope.__jqOrderBy = 'memberCount'; scope.__jqOrderReverse = false; }
|
|
||||||
}
|
|
||||||
|
|
||||||
function matchesSearch(g) {
|
|
||||||
const q = $('#groupSearch').val().toLowerCase().trim();
|
|
||||||
return !q || g.cn.toLowerCase().includes(q) || (g.description || '').toLowerCase().includes(q);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyFilters() {
|
|
||||||
applySort();
|
|
||||||
const groups = allGroups.filter(matchesSearch);
|
|
||||||
$.scope.groupCard.empty();
|
|
||||||
$.scope.groupCard.push(...groups);
|
|
||||||
$('#groupCount').text(groups.length + ' of ' + allGroups.length + ' group' + (allGroups.length !== 1 ? 's' : ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tableAJAX(revealCn) {
|
|
||||||
let data = await app.group.list();
|
|
||||||
allGroups = data.results.map(processGroup);
|
|
||||||
applyFilters();
|
|
||||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeMember(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" from "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove member', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function removeOwner(groupCN, uid, btn) {
|
|
||||||
const $item = $(btn).closest('li');
|
|
||||||
$item.addClass('list-group-item-warning');
|
|
||||||
const confirmed = await app.messages.confirm(`Remove "${uid}" as owner of "${groupCN}"?`, $item, 'warning');
|
|
||||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
|
||||||
try {
|
|
||||||
const data = await app.api.delete(`group/owner/${groupCN}/${uid}`);
|
|
||||||
const groupData = await app.group.get(groupCN);
|
|
||||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
|
||||||
app.messages.action(data.message, $('#group-card-' + groupCN), 'success');
|
|
||||||
} catch(e) {
|
|
||||||
$item.removeClass('list-group-item-warning');
|
|
||||||
app.messages.action(e.message || 'Failed to remove owner', $('#group-card-' + groupCN), 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteGroup(cn, btn) {
|
|
||||||
const $card = $(btn).closest('.card');
|
|
||||||
const confirmed = await app.messages.confirm(`Delete group "${cn}"?`, $card, 'danger');
|
|
||||||
if (!confirmed) return;
|
|
||||||
try {
|
|
||||||
await app.api.delete(`group/${cn}`);
|
|
||||||
$.scope.groupCard.remove('cn', cn);
|
|
||||||
} catch(e) {
|
|
||||||
app.messages.action(e.message || 'Failed to delete group', $card, 'danger');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
|
||||||
|
|
||||||
$(document).ready(async function(){
|
|
||||||
userlist = (await app.user.list()).results;
|
|
||||||
tableAJAX();
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
<div class="container mt-4">
|
|
||||||
|
|
||||||
<div class="d-flex flex-wrap gap-2 align-items-center sticky-top bg-body py-2" style="top: var(--sw-content-offset, 0);">
|
|
||||||
<div class="input-group" style="flex: 1 1 200px;">
|
|
||||||
<span class="input-group-text"><i class="fa-solid fa-magnifying-glass"></i></span>
|
|
||||||
<input type="text" id="groupSearch" class="form-control" placeholder="Search groups…" oninput="applyFilters()">
|
|
||||||
</div>
|
|
||||||
<select id="groupSort" class="form-select" style="width:auto; min-width:175px" onchange="applyFilters()">
|
|
||||||
<option value="name-asc">Name A → Z</option>
|
|
||||||
<option value="name-desc">Name Z → A</option>
|
|
||||||
<option value="members-desc">Most members</option>
|
|
||||||
<option value="members-asc">Fewest members</option>
|
|
||||||
</select>
|
|
||||||
<span id="groupCount" class="text-muted text-nowrap small"></span>
|
|
||||||
</div>
|
|
||||||
<div class="row row-cols-1 row-cols-md-3 g-4 mt-0">
|
|
||||||
<div class="col">
|
|
||||||
<div class="card shadow">
|
|
||||||
<div class="card-header">
|
|
||||||
<i class="fa-solid fa-object-group"></i>
|
|
||||||
Add new group
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<form action="group/" method="post" onsubmit="formAJAX(this)" evalAJAX="tableAJAX(data.results.cn)">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Name</label>
|
|
||||||
<input type="text" class="form-control shadow" name="name" placeholder="app_gitea_admin" validate=":3" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Description</label>
|
|
||||||
<textarea class="form-control shadow" name="description" placeholder="Admin group for gitea app" validate=":3"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" class="btn btn-outline-dark">Add</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col" jq-repeat="groupCard" jq-index-key="cn" jr-order-by="cn" id="group-card-{{cn}}">
|
|
||||||
<div class="card shadow col">
|
|
||||||
<div class="card-header">
|
|
||||||
<h5>
|
|
||||||
<i class="fa-solid fa-arrows-down-to-people"></i>
|
|
||||||
Group: {{ cn }}
|
|
||||||
<a href="/docs/accounts" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
</h5>
|
|
||||||
<ul class="nav nav-tabs card-header-tabs" id="myTab" role="tablist">
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link active" id="group-members-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-memmbers-{{cn}}" href="#group-memmbers-{{cn}}" role="tab" aria-controls="member" aria-selected="true">
|
|
||||||
<i class="fa-solid fa-users"></i>
|
|
||||||
Members
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item">
|
|
||||||
<a class="nav-link" id="group-admins-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-admins-{{cn}}" href="#group-admins-{{cn}}" role="tab" aria-controls="admin" aria-selected="false">
|
|
||||||
<i class="fa-solid fa-user-tie"></i>
|
|
||||||
Owners
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item float-end">
|
|
||||||
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<div class="card-header actionMessage" style="display:none"></div>
|
|
||||||
<div class="card-body">
|
|
||||||
<p>
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<div class="tab-content" id="myTabContent">
|
|
||||||
<div class="tab-pane fade show active" id="group-memmbers-{{cn}}" role="tabpanel" aria-labelledby="member-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #member }}
|
|
||||||
<li id="group-card-{{cn}}-{{uid}}" class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeMember('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /member }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
<div class="dropdown">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_member" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_member">
|
|
||||||
{{ #toAdd }}{{#.}}
|
|
||||||
<a class="dropdown-item" href="#" onclick="return addMemberClick(event, '{{groupCN}}', '{{uid}}', this);">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAdd }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="tab-pane fade" id="group-admins-{{cn}}" role="tabpanel" aria-labelledby="admin-tab">
|
|
||||||
<p>
|
|
||||||
<ul class="list-group">
|
|
||||||
{{ #owner }}
|
|
||||||
<li class="list-group-item shadow">
|
|
||||||
<i class="fa-solid fa-user"></i> {{ uid }}
|
|
||||||
<button type="button" onclick="removeOwner('{{groupCN}}', '{{uid}}', this)" class="btn btn-sm btn-danger float-end">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
{{ /owner }}
|
|
||||||
</ul>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="dropdown float-start">
|
|
||||||
<button class="btn btn-secondary dropdown-toggle" type="button" id="group_add_admin" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
|
||||||
<i class="fa-solid fa-user-plus"></i>
|
|
||||||
</button>
|
|
||||||
<div class="dropdown-menu shadow-lg" aria-labelledby="group_add_admin">
|
|
||||||
{{ #toAddOwner }}{{#.}}
|
|
||||||
<a class="dropdown-item" action="group/owner/{{groupCN}}/{{uid}}" method="put" onclick="formAJAX(this)" evalAJAX="addedUser(data.message, '{{groupCN}}', '{{uid}}', $form)">
|
|
||||||
<i class="fa-solid fa-user"></i> {{uid}}
|
|
||||||
</a>
|
|
||||||
{{/.}}{{ /toAddOwner }}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer">
|
|
||||||
<div class="float-end">
|
|
||||||
<button type="button" onclick="" class="btn btn-warning btn-lg shadow">
|
|
||||||
<i class="fa-solid fa-edit"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" onclick="deleteGroup('{{cn}}', this)" class="btn btn-danger btn-lg">
|
|
||||||
<i class="fa-solid fa-trash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Created: {{createTimestamp}}<br />
|
|
||||||
Last Modified: {{modifyTimestamp}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<%- include('bottom') %>
|
|
||||||
+344
-109
@@ -1,136 +1,371 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* App Portal styling using Bootstrap defaults */
|
.catalog-grid {
|
||||||
.portal-banner {
|
display: grid;
|
||||||
background-color: var(--bs-primary);
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
color: white;
|
gap: 1.25rem;
|
||||||
padding: 3rem 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.portal-banner h1 {
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
.carousel-container {
|
|
||||||
display: flex;
|
|
||||||
overflow-x: auto;
|
|
||||||
gap: 1.5rem;
|
|
||||||
padding-bottom: 1.5rem;
|
|
||||||
scrollbar-width: thin;
|
|
||||||
}
|
}
|
||||||
.service-card {
|
.service-card {
|
||||||
min-width: 280px;
|
|
||||||
height: 100%;
|
height: 100%;
|
||||||
transition: transform 0.2s, box-shadow 0.2s;
|
transition: transform .15s, box-shadow .15s;
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
.service-card:hover {
|
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||||
transform: translateY(-5px);
|
.service-card .card-body { flex: 1; }
|
||||||
box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important;
|
.card-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
width: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.service-card .card-body {
|
.card-icon-img {
|
||||||
flex: 1;
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
.howto code {
|
||||||
|
display: block;
|
||||||
|
background: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
border-radius: .25rem;
|
||||||
|
font-size: .8rem;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.empty-note { color: var(--bs-secondary-color, #6c757d); font-style: italic; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="portal-banner text-center">
|
<div class="row mb-4">
|
||||||
<h1>SSO Portal</h1>
|
<div class="col-md-8">
|
||||||
<p class="lead">Explore and access all your services in one place.</p>
|
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
||||||
<a href="/profile" class="btn btn-light shadow-sm mt-2"><i class="fa-solid fa-user"></i> My Profile</a>
|
placeholder="Search services and hosts..." onkeyup="renderAll()">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mt-2 mt-md-0">
|
||||||
|
<select id="catalog-kind" class="form-select shadow-sm" onchange="renderAll()">
|
||||||
|
<option value="">All kinds</option>
|
||||||
|
<option value="service">Services & apps</option>
|
||||||
|
<option value="host">Hosts</option>
|
||||||
|
<option value="site">Sites</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-primary"></i> My Apps & Services</h3>
|
<div id="my-requests-section" style="display:none;">
|
||||||
<div class="carousel-container mb-5" id="my-services" jq-repeat="myservices">
|
<h3 class="mb-3"><i class="fa-solid fa-hourglass-half text-warning"></i> My Requests</h3>
|
||||||
<a href="{{resolvedAddress}}" target="_blank" style="text-decoration: none; color: inherit; min-width: 280px;">
|
<ul class="list-group mb-5 shadow-sm" id="my-requests"></ul>
|
||||||
<div class="card shadow-sm service-card border-success">
|
|
||||||
<div class="card-body">
|
|
||||||
<h5 class="card-title text-success"><i class="fa-solid fa-rocket"></i> {{name}}</h5>
|
|
||||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
|
||||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
|
||||||
<span class="badge bg-success">Access Granted</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More Services</h3>
|
<div id="approvals-section" style="display:none;">
|
||||||
<div class="carousel-container mb-5" id="other-services" jq-repeat="otherservices">
|
<h3 class="mb-3"><i class="fa-solid fa-user-check text-danger"></i> Awaiting My Approval</h3>
|
||||||
<div class="card shadow-sm service-card" style="min-width: 280px;" onclick="requestAccess('{{id}}')">
|
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
||||||
<div class="card-body">
|
</div>
|
||||||
<h5 class="card-title"><i class="fa-solid fa-cloud"></i> {{name}}</h5>
|
|
||||||
<p class="card-text text-muted mb-1">{{kind}}{{#metadata.subType}} - {{metadata.subType}}{{/metadata.subType}}</p>
|
<!-- My Access section with tabs -->
|
||||||
<p class="card-text text-truncate small" title="{{description}}">{{description}}</p>
|
<div class="card shadow mb-4">
|
||||||
</div>
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<div class="card-footer bg-transparent border-top-0 pt-0">
|
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
|
||||||
<span class="badge bg-secondary">Request Access</span>
|
</div>
|
||||||
</div>
|
<div class="px-3 pt-3 border-bottom">
|
||||||
</div>
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||||
</div>
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-server text-info"></i> Hosts & Infrastructure</h3>
|
<i class="fa-solid fa-cube"></i> Services
|
||||||
<div class="carousel-container mb-5" id="hosts" jq-repeat="hosts">
|
</button>
|
||||||
<div class="card shadow-sm service-card" style="min-width: 280px;">
|
</li>
|
||||||
<div class="card-body">
|
<li class="nav-item" role="presentation">
|
||||||
<h5 class="card-title"><i class="fa-solid fa-desktop"></i> {{name}}</h5>
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
|
||||||
<p class="card-text text-muted mb-1">IP: {{metadata.ip}}</p>
|
<i class="fa-solid fa-server"></i> Hosts
|
||||||
<p class="card-text small mb-0">OS: {{metadata.os}}</p>
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-services"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-hosts"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
||||||
|
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
||||||
|
<div class="catalog-grid mb-5" id="other-services"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
app.auth.forceLogin();
|
app.auth.forceLogin();
|
||||||
|
|
||||||
$(document).ready(async function() {
|
// Connection conventions from conf/base.js `directory`, injected server-side
|
||||||
try {
|
// so the "how to reach this" block renders the invocation that actually
|
||||||
let res = await app.api.get('discovery/me');
|
// works in this deployment rather than a guess.
|
||||||
let allAccessible = res.results || [];
|
var DIRECTORY_CONF = <%- JSON.stringify(directoryConf) %>;
|
||||||
|
|
||||||
let allRes = await app.api.get('directory-admin/resources').catch(e => { return {results:[]}; });
|
var state = { mine: [], others: [], requests: [], approvals: [], uid: null };
|
||||||
|
|
||||||
let myServices = [];
|
var KIND_ICONS = {
|
||||||
let otherServices = [];
|
site: 'fa-solid fa-city',
|
||||||
let hosts = [];
|
host: 'fa-solid fa-server',
|
||||||
|
service: 'fa-solid fa-cube',
|
||||||
allAccessible.forEach(r => {
|
oauth: 'fa-solid fa-key'
|
||||||
r.resolvedAddress = (r.metadata && r.metadata.address) || (r.metadata && r.metadata.ip) || '#';
|
};
|
||||||
r.description = r.description || 'No description provided';
|
|
||||||
if (r.kind === 'service' || r.kind === 'oauth') myServices.push(r);
|
function esc(s) {
|
||||||
if (r.kind === 'host') hosts.push(r);
|
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
|
||||||
});
|
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c];
|
||||||
|
});
|
||||||
if (allRes && allRes.results) {
|
|
||||||
allRes.results.forEach(r => {
|
|
||||||
r.description = r.description || 'No description provided';
|
|
||||||
if (r.kind === 'service' && !myServices.find(s => s.id === r.id)) {
|
|
||||||
otherServices.push(r);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$.scope.myservices.empty();
|
|
||||||
$.scope.myservices.push(...myServices);
|
|
||||||
|
|
||||||
$.scope.otherservices.empty();
|
|
||||||
$.scope.otherservices.push(...otherServices);
|
|
||||||
|
|
||||||
$.scope.hosts.empty();
|
|
||||||
$.scope.hosts.push(...hosts);
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Failed to load discovery data:', e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
function requestAccess(id) {
|
|
||||||
app.modal.open({title: 'Access Request', bodyHtml: 'This feature is coming soon!'});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render icon as either Font Awesome class or <img> for URL
|
||||||
|
function renderIcon(icon, kind) {
|
||||||
|
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
|
||||||
|
// If it starts with http, treat it as an image URL
|
||||||
|
if (/^https?:\/\//i.test(icon)) {
|
||||||
|
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
|
||||||
|
}
|
||||||
|
// Otherwise it's a Font Awesome class
|
||||||
|
return '<i class="' + esc(icon) + ' card-icon"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// "How do I actually use this?" — the question the directory exists to
|
||||||
|
// answer and the one the old portal never did. Everything here is derived
|
||||||
|
// from directory metadata; nothing is hardcoded per-service.
|
||||||
|
function howTo(r) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var addr = r.resolvedAddress || md.address || md.ip;
|
||||||
|
var lines = [];
|
||||||
|
|
||||||
|
if (r.kind === 'host') {
|
||||||
|
var sshPort = md.sshPort || DIRECTORY_CONF.defaultSshPort;
|
||||||
|
var portArg = String(sshPort) === '22' ? '' : ' -p ' + sshPort;
|
||||||
|
if (DIRECTORY_CONF.jumpHost) {
|
||||||
|
// The jump-host username grammar: one string, no interactive
|
||||||
|
// menu, so it works in WinSCP/FileZilla as well as a terminal.
|
||||||
|
lines.push('ssh ' + state.uid + '_-_' + r.slug + '@' + DIRECTORY_CONF.jumpHost + portArg);
|
||||||
|
} else if (addr) {
|
||||||
|
lines.push('ssh ' + state.uid + '@' + addr + portArg);
|
||||||
|
}
|
||||||
|
} else if (addr) {
|
||||||
|
var isUrl = /^https?:\/\//i.test(addr);
|
||||||
|
if (isUrl) {
|
||||||
|
lines.push(addr);
|
||||||
|
} else {
|
||||||
|
var port = md.externalPort || md.port;
|
||||||
|
lines.push(port ? 'https://' + addr + ':' + port : 'https://' + addr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (md.gitRepo) lines.push('Source: ' + md.gitRepo);
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkFor(r) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var addr = r.resolvedAddress || md.address || md.ip;
|
||||||
|
if (!addr || r.kind === 'host') return null;
|
||||||
|
if (/^https?:\/\//i.test(addr)) return addr;
|
||||||
|
var port = md.externalPort || md.port;
|
||||||
|
return port ? 'https://' + addr + ':' + port : 'https://' + addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardHtml(r, accessible) {
|
||||||
|
var md = r.metadata || {};
|
||||||
|
var blurb = md.tagline || r.description || 'No description provided';
|
||||||
|
var href = accessible ? linkFor(r) : null;
|
||||||
|
var lines = accessible ? howTo(r) : [];
|
||||||
|
|
||||||
|
var badges = '';
|
||||||
|
if (md.isProduction) badges += '<span class="badge bg-danger ms-1">Prod</span>';
|
||||||
|
if (md.isExternalReachable) badges += '<span class="badge bg-info ms-1">External</span>';
|
||||||
|
if (md.os) badges += '<span class="badge bg-light text-dark border ms-1">' + esc(md.os) + '</span>';
|
||||||
|
|
||||||
|
var footer;
|
||||||
|
if (accessible) {
|
||||||
|
footer = href
|
||||||
|
? '<a class="btn btn-sm btn-success w-100" target="_blank" rel="noopener" href="' + esc(href) + '">Open <i class="fa-solid fa-arrow-up-right-from-square"></i></a>'
|
||||||
|
: '<span class="badge bg-success">Access granted</span>';
|
||||||
|
} else if (md.requestable === false) {
|
||||||
|
footer = '<span class="badge bg-secondary">Not requestable</span>';
|
||||||
|
} else if (state.requests.some(function(q){ return q.resourceId === r.id && q.status === 'pending'; })) {
|
||||||
|
footer = '<span class="badge bg-warning text-dark">Request pending</span>';
|
||||||
|
} else {
|
||||||
|
footer = '<button class="btn btn-sm btn-outline-primary w-100" onclick="requestAccess(\'' + esc(r.id) + '\')">'
|
||||||
|
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var iconHtml = renderIcon(md.icon, r.kind);
|
||||||
|
|
||||||
|
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||||
|
+ '<div class="card-body">'
|
||||||
|
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||||
|
+ iconHtml
|
||||||
|
+ '<span>' + esc(r.name) + '</span>'
|
||||||
|
+ '</h5>'
|
||||||
|
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||||
|
+ (md.subType ? ' · ' + esc(md.subType) : '') + '</span>' + badges + '</div>'
|
||||||
|
+ '<p class="card-text small text-muted">' + esc(blurb) + '</p>'
|
||||||
|
+ (lines.length
|
||||||
|
? '<div class="howto small"><div class="text-muted mb-1">How to reach it</div>'
|
||||||
|
+ lines.map(function(l){ return '<code>' + esc(l) + '</code>'; }).join('')
|
||||||
|
+ '</div>'
|
||||||
|
: '')
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="card-footer bg-transparent border-top-0">' + footer + '</div>'
|
||||||
|
+ '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(r) {
|
||||||
|
var q = ($('#catalog-search').val() || '').toLowerCase();
|
||||||
|
var kind = $('#catalog-kind').val() || '';
|
||||||
|
if (kind && r.kind !== kind) return false;
|
||||||
|
if (!q) return true;
|
||||||
|
var md = r.metadata || {};
|
||||||
|
return [r.name, r.slug, r.description, md.tagline, md.subType, md.ip, md.address]
|
||||||
|
.filter(Boolean).join(' ').toLowerCase().indexOf(q) !== -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGrid(elId, list, accessible) {
|
||||||
|
var items = list.filter(matchesFilter);
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (!items.length) {
|
||||||
|
el.innerHTML = '<p class="empty-note">Nothing to show here.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
el.innerHTML = items.map(function(r){ return cardHtml(r, accessible); }).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRequests() {
|
||||||
|
var open = state.requests.filter(function(q){ return q.status === 'pending'; });
|
||||||
|
document.getElementById('my-requests-section').style.display = open.length ? '' : 'none';
|
||||||
|
document.getElementById('my-requests').innerHTML = open.map(function(q){
|
||||||
|
var label = q.resource ? q.resource.name : q.groupCn;
|
||||||
|
return '<li class="list-group-item d-flex justify-content-between align-items-center">'
|
||||||
|
+ '<span><strong>' + esc(label) + '</strong> '
|
||||||
|
+ '<small class="text-muted">via <code>' + esc(q.groupCn) + '</code></small></span>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="withdraw(\'' + esc(q.id) + '\')">Withdraw</button>'
|
||||||
|
+ '</li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderApprovals() {
|
||||||
|
document.getElementById('approvals-section').style.display = state.approvals.length ? '' : 'none';
|
||||||
|
document.getElementById('approvals').innerHTML = state.approvals.map(function(q){
|
||||||
|
var label = q.resource ? q.resource.name : q.groupCn;
|
||||||
|
return '<li class="list-group-item d-flex justify-content-between align-items-center flex-wrap gap-2">'
|
||||||
|
+ '<span><strong>' + esc(q.uid) + '</strong> requests <strong>' + esc(label) + '</strong> '
|
||||||
|
+ '<small class="text-muted">(<code>' + esc(q.groupCn) + '</code>)</small>'
|
||||||
|
+ (q.note ? '<br><small class="text-muted">' + esc(q.note) + '</small>' : '')
|
||||||
|
+ '</span>'
|
||||||
|
+ '<span class="d-flex gap-2">'
|
||||||
|
+ '<button class="btn btn-sm btn-success" onclick="decide(\'' + esc(q.id) + '\',\'approve\')">Approve</button>'
|
||||||
|
+ '<button class="btn btn-sm btn-outline-danger" onclick="decide(\'' + esc(q.id) + '\',\'deny\')">Deny</button>'
|
||||||
|
+ '</span></li>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAll() {
|
||||||
|
// Split mine into services and hosts
|
||||||
|
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
|
||||||
|
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
|
||||||
|
|
||||||
|
renderGrid('my-services', myServices, true);
|
||||||
|
renderGrid('my-hosts', myHosts, true);
|
||||||
|
renderGrid('other-services', state.others, false);
|
||||||
|
renderRequests();
|
||||||
|
renderApprovals();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
var me = await app.auth.asyncUser;
|
||||||
|
state.uid = me.uid;
|
||||||
|
|
||||||
|
// Both endpoints are the *discovery* API, not directory-admin. The old
|
||||||
|
// portal called directory-admin/resources and swallowed the 403, so
|
||||||
|
// "Discover More" was permanently empty for every non-admin — i.e. for
|
||||||
|
// exactly the people it was built for.
|
||||||
|
var mineRes = await app.api.get('discovery/me');
|
||||||
|
var allRes = await app.api.get('discovery/resources');
|
||||||
|
|
||||||
|
state.mine = (mineRes.results || []).filter(function(r){ return r.kind !== 'site'; });
|
||||||
|
var mineIds = {};
|
||||||
|
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
||||||
|
state.others = (allRes.results || []).filter(function(r){
|
||||||
|
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth' && (r.metadata && r.metadata.managed);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requests are best-effort: a failure here must not blank the catalog.
|
||||||
|
try {
|
||||||
|
var mineReq = await app.api.get('access-requests/mine');
|
||||||
|
state.requests = mineReq.results || [];
|
||||||
|
} catch (e) { state.requests = []; }
|
||||||
|
try {
|
||||||
|
var pending = await app.api.get('access-requests');
|
||||||
|
state.approvals = pending.results || [];
|
||||||
|
} catch (e) { state.approvals = []; }
|
||||||
|
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestAccess(id) {
|
||||||
|
var resource = state.others.find(function(r){ return r.id === id; });
|
||||||
|
if (!resource) return;
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Request access to ' + resource.name,
|
||||||
|
bodyHtml: '<div class="actionMessage" style="display:none"></div>'
|
||||||
|
+ '<p class="text-muted small">Your request goes to the resource owner for approval.</p>'
|
||||||
|
+ '<label class="form-label">Why do you need it? <span class="text-muted">(optional)</span></label>'
|
||||||
|
+ '<textarea id="req-note" class="form-control" rows="3"></textarea>',
|
||||||
|
footer: {
|
||||||
|
buttonsHtml: '<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||||
|
+ '<button class="btn btn-primary ms-2" onclick="submitRequest(\'' + esc(id) + '\')">Send request</button>'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitRequest(id) {
|
||||||
|
try {
|
||||||
|
await app.api.post('access-requests', { resourceId: id, note: $('#req-note').val() });
|
||||||
|
app.modal.close();
|
||||||
|
app.messages.toast('Request sent', 'success');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.action((err && err.message) || 'Could not send request', app.modal.body(), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withdraw(id) {
|
||||||
|
try {
|
||||||
|
await app.api.delete('access-requests/' + id);
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast((err && err.message) || 'Could not withdraw', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(id, action) {
|
||||||
|
try {
|
||||||
|
await app.api.post('access-requests/' + id + '/' + action, {});
|
||||||
|
app.messages.toast('Request ' + (action === 'approve' ? 'approved' : 'denied'), 'success');
|
||||||
|
await load();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast((err && err.message) || 'Could not update request', 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
load().catch(function(e){
|
||||||
|
console.error('Failed to load catalog:', e);
|
||||||
|
app.messages.toast('Could not load the catalog', 'danger');
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<%- include("bottom") %>
|
||||||
|
|||||||
@@ -162,50 +162,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Terms of Service ──────────────────────────────────────────────────
|
|
||||||
async function loadTos() {
|
|
||||||
try {
|
|
||||||
const tos = await app.tos.get();
|
|
||||||
document.getElementById('tos-content').value = tos.content;
|
|
||||||
document.getElementById('tos-meta').textContent =
|
|
||||||
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
|
|
||||||
} catch(e) {
|
|
||||||
console.error('Failed to load ToS:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveTos() {
|
|
||||||
const content = document.getElementById('tos-content').value.trim();
|
|
||||||
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
|
|
||||||
const msgEl = document.getElementById('tos-result');
|
|
||||||
|
|
||||||
if (!content) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Terms of Service text cannot be empty.';
|
|
||||||
msgEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
app.tos.update({content, resetAcceptance}, function(error, data) {
|
|
||||||
if (error) {
|
|
||||||
msgEl.className = 'alert alert-danger mt-2';
|
|
||||||
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
|
|
||||||
msgEl.style.display = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
msgEl.className = 'alert alert-success mt-2';
|
|
||||||
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
|
|
||||||
msgEl.style.display = '';
|
|
||||||
document.getElementById('tos-reset-acceptance').checked = false;
|
|
||||||
loadTos();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
loadDashboard();
|
loadDashboard();
|
||||||
loadHistory();
|
loadHistory();
|
||||||
toggleFilterInputs();
|
toggleFilterInputs();
|
||||||
loadTos();
|
|
||||||
loadMetrics();
|
loadMetrics();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -385,30 +345,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- TOS Card -->
|
|
||||||
<div class="card shadow mb-5">
|
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
|
||||||
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
|
|
||||||
<small class="text-muted" id="tos-meta"></small>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
|
|
||||||
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
|
|
||||||
</div>
|
|
||||||
<div class="form-check mb-3">
|
|
||||||
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
|
|
||||||
<label class="form-check-label" for="tos-reset-acceptance">
|
|
||||||
Require all users to re-accept these terms
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
|
|
||||||
<i class="fa-solid fa-floppy-disk"></i> Save
|
|
||||||
</button>
|
|
||||||
<div id="tos-result" style="display:none" class="mt-3"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Actionable Metrics Card -->
|
<!-- Actionable Metrics Card -->
|
||||||
<div class="card shadow mb-5">
|
<div class="card shadow mb-5">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user