Compare commits
70 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -1199,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
|
||||
|
||||
All endpoints return errors in this format:
|
||||
|
||||
+223
@@ -1,9 +1,228 @@
|
||||
# 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
|
||||
|
||||
All notable changes to this project are documented here. Format loosely
|
||||
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`.
|
||||
|
||||
## [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
|
||||
@@ -457,3 +676,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.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.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).
|
||||
|
||||
+1
-2
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
|
||||
dumb-init \
|
||||
bash \
|
||||
redis \
|
||||
nmap \
|
||||
&& rm -rf /var/cache/apk/*
|
||||
|
||||
COPY --from=ldapbuild /opt/openldap /opt/openldap
|
||||
@@ -181,10 +182,8 @@ COPY tos.md /tos.md
|
||||
# without internet access. Same flattened-path convention as tos.md above.
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Baked commit hash from the gitinfo stage (see build_info.js).
|
||||
COPY --from=gitinfo /commit.txt ./.build_commit
|
||||
|
||||
@@ -36,10 +36,8 @@ RUN mkdir -p /app/config
|
||||
COPY tos.md /tos.md
|
||||
COPY README.md /README.md
|
||||
COPY CHANGELOG.md /CHANGELOG.md
|
||||
COPY DEPLOYMENT.md /DEPLOYMENT.md
|
||||
COPY API.md /API.md
|
||||
COPY directory_spec.md /directory_spec.md
|
||||
COPY docs /docs
|
||||
|
||||
# Seed script and utility
|
||||
COPY test_seed.js ./test_seed.js
|
||||
|
||||
@@ -34,6 +34,9 @@ nav:
|
||||
- title: Directory
|
||||
page: /directory.html
|
||||
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
|
||||
|
||||
+115
-67
@@ -1,88 +1,136 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery Agents
|
||||
title: Theta Agent & Endpoint Management
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Discovery Agents
|
||||
# Theta Agent & Endpoint Management
|
||||
|
||||
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||
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.
|
||||
|
||||
## Writing a Custom Agent
|
||||
---
|
||||
|
||||
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
||||
## Core Functionality
|
||||
|
||||
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||
### 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`.
|
||||
|
||||
### Agent Skeleton
|
||||
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
|
||||
|
||||
```javascript
|
||||
// nodejs/agents/discovery/my_custom_agent.js
|
||||
module.exports = {
|
||||
discover: async (config) => {
|
||||
const { url, apiKey } = config; // Provided by your configuration
|
||||
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
### 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`).
|
||||
|
||||
// 1. Fetch your data from an API
|
||||
// const data = await fetch(...);
|
||||
---
|
||||
|
||||
// 2. Map data to Resources
|
||||
resources.push({
|
||||
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
||||
name: 'My Switch',
|
||||
slug: 'my-switch-01',
|
||||
metadata: {
|
||||
make: 'Vendor',
|
||||
model: 'Model X',
|
||||
interfaces: [
|
||||
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
||||
]
|
||||
}
|
||||
});
|
||||
## Viewing in the SSO Manager
|
||||
|
||||
// 3. Map relations to Edges (optional)
|
||||
edges.push({
|
||||
parentSlug: 'my-switch-01',
|
||||
childSlug: 'some-connected-client-slug',
|
||||
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
||||
});
|
||||
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:
|
||||
|
||||
return { resources, edges };
|
||||
}
|
||||
};
|
||||
| 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** | Not connected (no agent, or the agent is offline). |
|
||||
|
||||
Opening a host's resource modal reveals a **Metrics** tab with the agent's live
|
||||
telemetry (CPU/RAM/disk/ZFS/GPU) and discovery info (OS, kernel, IPs, location).
|
||||
The agent is joined to its host by hostname (`agent.discovery.hostname` ↔ the
|
||||
resource name), so name the Directory host the same as the machine's hostname.
|
||||
|
||||
---
|
||||
|
||||
## 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.1.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).
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
## 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 "<HOST_TOKEN>"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
### 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:
|
||||
|
||||
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
||||
|
||||
```javascript
|
||||
module.exports = {
|
||||
// ... existing config ...
|
||||
discovery: {
|
||||
agents: {
|
||||
my_custom_agent: {
|
||||
enabled: true,
|
||||
cron: '*/30 * * * *', // Run every 30 minutes
|
||||
url: 'https://api.example.com',
|
||||
apiKey: 'secret-key'
|
||||
},
|
||||
nmap: {
|
||||
enabled: true,
|
||||
cron: '0 * * * *',
|
||||
targetRange: '192.168.1.0/24'
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
```bash
|
||||
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
|
||||
```
|
||||
|
||||
## The Reconciliation Engine
|
||||
---
|
||||
|
||||
## Configuration File Example (`/etc/theta42/agent.yml`)
|
||||
|
||||
```yaml
|
||||
# /etc/theta42/agent.yml
|
||||
server_url: "wss://sso.example.com"
|
||||
auth_token: "your-unique-host-token"
|
||||
location: "dc-01-rack-12"
|
||||
public_key: "MCowBQYDK2VwAyEA..."
|
||||
|
||||
capabilities:
|
||||
telemetry: true
|
||||
configure_ldap: true
|
||||
reboot: false
|
||||
service_control: ["nginx", "docker", "sssd"]
|
||||
arbitrary_bash: false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 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).
|
||||
|
||||
|
||||
When your agent returns its graph, the Reconciliation Engine takes over:
|
||||
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
||||
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
|
||||
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
||||
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
||||
|
||||
+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.
|
||||
- **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/).
|
||||
- **[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
|
||||
|
||||
|
||||
+132
@@ -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).
|
||||
+26
-27
@@ -1,39 +1,38 @@
|
||||
---
|
||||
layout: default
|
||||
title: Secrets Vault
|
||||
nav_order: 6
|
||||
---
|
||||
# Vault Secrets Management
|
||||
|
||||
# Secrets Vault
|
||||
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.
|
||||
|
||||
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
||||
## Usage
|
||||
|
||||
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
||||
You can access the Vault UI from the application's top navigation bar.
|
||||
|
||||
## Architecture
|
||||
### Creating Secrets
|
||||
|
||||
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
|
||||
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**.
|
||||
|
||||
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
|
||||
### Reading and Editing Secrets
|
||||
|
||||
## Accessing the Vault
|
||||
* 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.
|
||||
|
||||
The SSO Manager Vault can be accessed in two ways:
|
||||
### OpenBao Integration
|
||||
|
||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
|
||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||
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 Example
|
||||
## API Access
|
||||
|
||||
To read secrets from the default key-value store, issue a `GET` request to:
|
||||
`/api/vault/v1/secret/data/sso-manager/conf`
|
||||
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||
|
||||
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
|
||||
|
||||
## Namespaces and Paths
|
||||
|
||||
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
||||
|
||||
## Plugin Integration
|
||||
|
||||
When building custom Agents or integrations, they can utilize the local Vault to retrieve API tokens instead of hardcoding them. Always use the `/api/vault` proxy to ensure permissions are consistently enforced.
|
||||
```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>
|
||||
```
|
||||
|
||||
+11
-2
@@ -43,6 +43,9 @@ app.onListen.push(function(){
|
||||
// socket.broadcast.emit('P2PSub', msg);
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize Theta Agent WebSockets
|
||||
require('./routes/api_agent')(app);
|
||||
});
|
||||
|
||||
// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate,
|
||||
@@ -69,7 +72,8 @@ app.locals.ui = require('./utils/ui');
|
||||
// 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
|
||||
// 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.
|
||||
app.use('/', require('./routes/index'));
|
||||
@@ -107,7 +111,10 @@ app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.get('/.well-known/openid-configuration', discovery);
|
||||
app.use('/api/webhook', require('./routes/webhook'));
|
||||
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||
// 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
|
||||
@@ -119,6 +126,8 @@ app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||
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
|
||||
// used, this is what will be called.
|
||||
|
||||
@@ -25,6 +25,19 @@ var server = http.createServer(app);
|
||||
var io = require('socket.io')(server);
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -47,6 +60,13 @@ models.initORM().then(() => {
|
||||
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 => {
|
||||
console.error('Failed to initialize ORM:', err);
|
||||
process.exit(1);
|
||||
|
||||
@@ -57,14 +57,6 @@ module.exports = {
|
||||
password: '__in secrets file__',
|
||||
did: '__in secrets file__',
|
||||
},
|
||||
smtp: {
|
||||
host: 'localhost',
|
||||
port: 587,
|
||||
secure: false,
|
||||
user: 'noreply@example.com',
|
||||
pass: '__in secrets file__',
|
||||
from: 'SSO Manager <noreply@example.com>',
|
||||
},
|
||||
directory: {
|
||||
// Public SSH jump host fronting the lab, if there is one (the jump-host
|
||||
// component). When set, a host card in the catalog shows the real
|
||||
|
||||
Binary file not shown.
+110
-42
@@ -1,64 +1,132 @@
|
||||
# Plugins & Scheduler
|
||||
# Plugins
|
||||
|
||||
The SSO Manager includes a flexible background task runner and discovery system. Plugins are defined statically in your deployment configuration (`sso-secrets.js`) and run based on their defined `cron` schedule.
|
||||
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.
|
||||
|
||||
## Writing Custom Plugins
|
||||
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.
|
||||
|
||||
You can write custom plugins to discover resources, manage internal state, or run automated scripts. Plugins must be placed in the `plugins/discovery/` directory of the SSO Manager node codebase.
|
||||
## Plugin types
|
||||
|
||||
A plugin file must export a `discover` method.
|
||||
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/`:
|
||||
|
||||
**Example Plugin (`plugins/discovery/my_plugin.js`):**
|
||||
- `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 = {
|
||||
discover: async function(config) {
|
||||
// The config object contains any keys passed in sso-secrets.js for this plugin.
|
||||
|
||||
// Perform discovery logic, hit external APIs, etc.
|
||||
const resources = [
|
||||
{
|
||||
slug: 'my-custom-resource-1',
|
||||
name: 'My Resource 1',
|
||||
kind: 'Host',
|
||||
metadata: {
|
||||
ip: '10.0.0.100',
|
||||
source: 'My Custom Plugin'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
// Return the discovered resources array. The discovery reconciler will
|
||||
// automatically save these to the Network Discovery database.
|
||||
return resources;
|
||||
}
|
||||
// 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 }; }
|
||||
};
|
||||
```
|
||||
|
||||
## Configuring Plugins
|
||||
`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).
|
||||
|
||||
In your `sso-secrets.js` file, add your plugin to the `discovery.plugins` object:
|
||||
### 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: {
|
||||
my_plugin: {
|
||||
enabled: true,
|
||||
cron: "0 * * * *", // Run every hour
|
||||
my_custom_key: "my_custom_value" // Passed to the config argument in discover()
|
||||
}
|
||||
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
||||
}
|
||||
}
|
||||
// ...
|
||||
};
|
||||
```
|
||||
|
||||
### Overriding Timing and Enable/Disable
|
||||
|
||||
From the **Plugins & Scheduler** tab in the Directory Dashboard, you can override the schedule and enable/disable state for each plugin. These overrides take precedence over `sso-secrets.js` and are stored internally.
|
||||
|
||||
## Scheduler Internals
|
||||
|
||||
The scheduler uses BullMQ backed by Redis to manage execution. It automatically performs garbage collection on stale network resources (resources not updated in > 7 days) and triggers your plugins at the defined intervals.
|
||||
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).
|
||||
+38
-1
@@ -16,6 +16,10 @@ const { init } = require('@simpleworkjs/orm');
|
||||
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');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -29,16 +33,49 @@ async function initORM() {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook,
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
SharedSecret, SharedSecretGrant, VaultAppToken,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
console.log('[initORM] ORM initialized successfully');
|
||||
console.log('[initORM] Resource.orm =', !!Resource.orm, 'Token.orm =', !!Token.orm);
|
||||
await healSchema();
|
||||
} catch (err) {
|
||||
console.error('[initORM] ORM initialization failed:', err.message);
|
||||
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;
|
||||
|
||||
@@ -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 };
|
||||
@@ -96,11 +96,13 @@ class Resource extends Model {
|
||||
return false;
|
||||
}
|
||||
|
||||
let maxUpdated = 0;
|
||||
resObjs.forEach(r => {
|
||||
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,
|
||||
|
||||
@@ -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) {
|
||||
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({
|
||||
api_username: conf.username,
|
||||
api_password: conf.password,
|
||||
|
||||
@@ -773,7 +773,7 @@ User.setActive = async function(active) {
|
||||
]);
|
||||
} else {
|
||||
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'] }) }),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -788,7 +788,7 @@ User.setActive = async function(active) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
this.pwdAccountLockedTime = active ? undefined : '000001010000Z';
|
||||
this.pwdAccountLockedTime = active ? undefined : '00000101000000Z';
|
||||
this.isActive = active ? 'active' : '';
|
||||
this.isInactive = active ? '' : 'inactive';
|
||||
cache.clear();
|
||||
@@ -907,6 +907,13 @@ User.login = async function(data){
|
||||
}
|
||||
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();
|
||||
try {
|
||||
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 };
|
||||
Generated
+19
-18
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.16.0",
|
||||
"version": "1.25.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.16.0",
|
||||
"version": "1.25.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
@@ -42,6 +42,7 @@
|
||||
"nodemailer": "^9.0.0",
|
||||
"p2psub": "^0.2.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ws": "^8.21.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -2343,9 +2344,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0"
|
||||
@@ -4293,9 +4294,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.4.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
|
||||
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
@@ -5965,16 +5966,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/brace-expansion": {
|
||||
"version": "5.0.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
|
||||
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon/node_modules/debug": {
|
||||
@@ -7725,9 +7726,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7917,9 +7918,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.27.0.tgz",
|
||||
"integrity": "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==",
|
||||
"version": "6.28.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz",
|
||||
"integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.16.0",
|
||||
"version": "1.25.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -54,6 +54,7 @@
|
||||
"nodemailer": "^9.0.0",
|
||||
"p2psub": "^0.2.0",
|
||||
"socket.io": "^4.8.3",
|
||||
"ws": "^8.21.1",
|
||||
"xss": "^1.0.15"
|
||||
},
|
||||
"license": "MIT",
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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: '' }
|
||||
],
|
||||
|
||||
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 = [];
|
||||
|
||||
for (const c of containers) {
|
||||
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\//, '') : c.Id.substring(0, 12);
|
||||
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
|
||||
|
||||
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
|
||||
|
||||
resources.push({
|
||||
kind: 'container',
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
image: c.Image,
|
||||
state: c.State,
|
||||
status: c.Status,
|
||||
ports: ports
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,21 +2,57 @@ 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) => {
|
||||
const scan = new nmap.OsAndPortScan(targetRange);
|
||||
// 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.mac || !host.ip) continue;
|
||||
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
|
||||
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, ip: host.ip }];
|
||||
const interfaces = [{ mac: host.mac || null, ip: host.ip }];
|
||||
|
||||
resources.push({
|
||||
kind: 'host',
|
||||
@@ -27,7 +63,7 @@ module.exports = {
|
||||
|
||||
if (host.openPorts && host.openPorts.length > 0) {
|
||||
for (const port of host.openPorts) {
|
||||
const svcSlug = `nmap-svc-${host.mac.replace(/:/g, '')}-${port.port}`;
|
||||
const svcSlug = `nmap-svc-${hostId}-${port.port}`;
|
||||
resources.push({
|
||||
kind: 'service',
|
||||
name: `${port.service} on ${port.port}`,
|
||||
@@ -42,10 +78,24 @@ module.exports = {
|
||||
});
|
||||
|
||||
scan.on('error', function(error) {
|
||||
reject(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)
|
||||
};
|
||||
|
||||
@@ -7,8 +7,35 @@ const agent = new https.Agent({
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
discover: async (config) => {
|
||||
// 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");
|
||||
}
|
||||
@@ -16,13 +43,19 @@ module.exports = {
|
||||
const headers = {
|
||||
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
|
||||
};
|
||||
|
||||
// Ensure URL has no trailing slash
|
||||
url = url.endsWith('/') ? url.slice(0, -1) : url;
|
||||
|
||||
const resources = [];
|
||||
const edges = [];
|
||||
|
||||
// 1. Get Nodes
|
||||
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
|
||||
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
|
||||
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) {
|
||||
@@ -151,5 +184,11 @@ module.exports = {
|
||||
}
|
||||
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -6,6 +6,41 @@ const agent = new https.Agent({
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -91,5 +126,9 @@ module.exports = {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* Only the active top-nav link is bold + underlined; the username is plain. */
|
||||
.top-nav a.active{
|
||||
font-weight: bold;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -615,9 +615,10 @@ app.util = (function(app){
|
||||
// Reveal every .group-required-<cn> element the current user's groups entitle
|
||||
// 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.
|
||||
// The synthetic 'login' group is special: it's true for any authenticated user.
|
||||
app.auth.applyGroupVisibility = function(user){
|
||||
var groups = app.auth.groupCNs(user);
|
||||
if(!groups.length) return;
|
||||
var isLoggedIn = !!user;
|
||||
|
||||
var style = document.getElementById('group-required-rules');
|
||||
if(!style){
|
||||
@@ -636,6 +637,19 @@ app.auth.applyGroupVisibility = function(user){
|
||||
// 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(){
|
||||
|
||||
@@ -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,124 @@
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
|
||||
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
|
||||
|
||||
module.exports = function initAgentWebSockets(app) {
|
||||
if (!app.wss) {
|
||||
console.warn("WebSocket server for agents is not initialized.");
|
||||
return;
|
||||
}
|
||||
|
||||
app.wss.on('connection', (ws, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
||||
const token = url.searchParams.get('token') || req.headers['authorization'];
|
||||
|
||||
if (!token) {
|
||||
ws.close(4001, 'Unauthorized: Missing token');
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAddr = req.socket.remoteAddress;
|
||||
console.log(`[Theta Agent] Agent connected from ${remoteAddr} with token ${token.substring(0, 8)}...`);
|
||||
|
||||
agentManager.registerAgent(token, ws, remoteAddr);
|
||||
|
||||
ws.on('message', (message) => {
|
||||
try {
|
||||
const data = JSON.parse(message);
|
||||
if (!data || typeof data.type !== 'string') return;
|
||||
|
||||
const payload = data.payload || {};
|
||||
|
||||
switch (data.type) {
|
||||
case 'discovery':
|
||||
agentManager.handleDiscovery(token, payload);
|
||||
if (app.io) app.io.emit('agent.discovery', { token, payload });
|
||||
break;
|
||||
case 'telemetry':
|
||||
agentManager.handleTelemetry(token, payload);
|
||||
if (app.io) app.io.emit('agent.telemetry', { token, payload });
|
||||
break;
|
||||
case 'heartbeat':
|
||||
agentManager.handleHeartbeat(token, payload, ws);
|
||||
break;
|
||||
case 'response':
|
||||
agentManager.handleResponse(token, payload);
|
||||
if (app.io) app.io.emit('agent.response', { token, payload });
|
||||
break;
|
||||
default:
|
||||
console.log(`[Theta Agent] Received message type '${data.type}' from ${token}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[Theta Agent] Error parsing message:", err);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`[Theta Agent] Agent disconnected (${token})`);
|
||||
agentManager.unregisterAgent(token, ws);
|
||||
});
|
||||
|
||||
// Send initial welcome/config payload
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'config',
|
||||
payload: {
|
||||
message: 'Connected to SSO Manager C2',
|
||||
protocol_version: '1.1.0'
|
||||
}
|
||||
}));
|
||||
} catch (e) {}
|
||||
});
|
||||
|
||||
// REST API routes for Agent Management (mounted under /api/agent). The agent
|
||||
// WebSocket (/api/agent/ws) is handled by the raw `wss` upgrade server in
|
||||
// bin/www with its own ?token= auth — unaffected by the express middleware
|
||||
// here. These REST routes are admin-facing, so they're auth + admin gated.
|
||||
const router = express.Router();
|
||||
router.use(middleware.auth);
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ADMIN_GROUPS);
|
||||
next();
|
||||
} catch (err) {
|
||||
if (err && (err.status === 401 || err.name === 'Insufficient Permission')) {
|
||||
return res.status(403).json({ status: 'error', message: 'admin only' });
|
||||
}
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/nodes', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
agents: agentManager.getConnectedAgents(),
|
||||
publicKey: agentManager.publicKeyPem
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/nodes/:token/command', (req, res) => {
|
||||
const { token } = req.params;
|
||||
const { command, payload, isHighRisk } = req.body;
|
||||
|
||||
if (!command) {
|
||||
return res.status(400).json({ status: 'error', message: 'Command type is required' });
|
||||
}
|
||||
|
||||
try {
|
||||
const HIGH_RISK_COMMANDS = ['reboot', 'service_restart', 'configure_ldap', 'arbitrary_bash', 'update_binary'];
|
||||
const requiresSigning = isHighRisk || HIGH_RISK_COMMANDS.includes(command);
|
||||
|
||||
const msg = agentManager.sendCommand(token, command, payload || {}, requiresSigning);
|
||||
res.json({ status: 'ok', sentMessage: msg });
|
||||
} catch (err) {
|
||||
res.status(400).json({ status: 'error', message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/agent', router);
|
||||
};
|
||||
+143
-9
@@ -12,12 +12,33 @@ router.use(async (req, res, next) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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 = {
|
||||
const editable = maskSecrets({
|
||||
smtp: conf.smtp || {},
|
||||
discovery: conf.discovery || {},
|
||||
oauth: conf.oauth || {}
|
||||
};
|
||||
oauth: conf.oauth || {},
|
||||
voipms: conf.voipms || {}
|
||||
});
|
||||
res.json(editable);
|
||||
});
|
||||
|
||||
@@ -38,23 +59,136 @@ function applyToLiveConf(src) {
|
||||
router.post('/', async (req, res, next) => {
|
||||
try {
|
||||
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||
// Deep merge req.body into existing
|
||||
for (const key of Object.keys(req.body)) {
|
||||
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
||||
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
|
||||
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] = req.body[key];
|
||||
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).
|
||||
// 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;
|
||||
@@ -201,6 +201,16 @@ 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) => {
|
||||
try {
|
||||
const r = await Resource.get(req.params.id);
|
||||
|
||||
@@ -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,208 @@
|
||||
'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'];
|
||||
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 });
|
||||
}
|
||||
res.json({ items: [...byId.values()].map(s => ({ id: s.id, slug: s.slug, ownerUid: s.ownerUid, description: s.description, path: s.path(), role: s.role })) });
|
||||
} 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, 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;
|
||||
@@ -62,6 +62,7 @@ router.get('/graph', async (req, res, next) => {
|
||||
res.json(envelope({
|
||||
resources: projectResources(graph.resources, { fullMetadata }),
|
||||
edges: graph.edges,
|
||||
updated_on: graph.updated_on
|
||||
}));
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
@@ -98,6 +99,42 @@ router.get('/me', async (req, res, next) => {
|
||||
} catch (err) { next(err); }
|
||||
});
|
||||
|
||||
// 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) => {
|
||||
@@ -149,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
|
||||
|
||||
const meta = resource.metadata || {};
|
||||
meta.managed = true;
|
||||
await resource.update({ metadata: meta });
|
||||
await Resource.update(resource.id, { metadata: meta });
|
||||
|
||||
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||
} catch (err) { next(err); }
|
||||
|
||||
@@ -34,8 +34,10 @@ const DOCS = {
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
agents: {title: 'Agents & Scheduler', file: path.join(__dirname, '../../docs/agents.md')},
|
||||
agents: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
plugins: {title: 'Plugins', file: path.join(__dirname, '../../docs/plugins.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')},
|
||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||
|
||||
+18
-29
@@ -11,8 +11,6 @@ const {Tos} = require('../models/tos');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||
const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
|
||||
const values ={
|
||||
title: conf.environment !== 'production' ? `dev` : '',
|
||||
@@ -66,13 +64,15 @@ router.get('/notifications', (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('/conf', async function(req, res, next) {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
res.render('conf', {...values});
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
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) {
|
||||
@@ -83,25 +83,22 @@ router.get('/discovery', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/vault', middleware.auth, async function(req, res, next) {
|
||||
router.get('/vault', function(req, res) {
|
||||
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||
// tokens for external apps. The /api/vault proxy enforces the same scoping
|
||||
// server-side (scopeGuard + the token's own OpenBao policy).
|
||||
let isAdmin = false;
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||
isAdmin = true;
|
||||
} catch (e) { /* non-admin: personal namespace only */ }
|
||||
// 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,
|
||||
vaultUid: req.user.uid,
|
||||
vaultIsAdmin: isAdmin,
|
||||
vaultBase: isAdmin ? '' : `users/${req.user.uid}/`,
|
||||
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||
});
|
||||
});
|
||||
@@ -137,10 +134,6 @@ router.get('/users', async function(req, res, next) {
|
||||
res.render('users', {...values});
|
||||
});
|
||||
|
||||
router.get('/conf', async function(req, res, next) {
|
||||
res.render('conf', {...values});
|
||||
});
|
||||
|
||||
router.get('/login', async function(req, res, next) {
|
||||
res.render('login', {...values, redirect: req.query.redirect});
|
||||
});
|
||||
@@ -195,10 +188,6 @@ router.get('/users/:uid', function(req, res, next) {
|
||||
res.render('profile', {...values});
|
||||
});
|
||||
|
||||
router.get('/groups', function(req, res, next) {
|
||||
res.render('groups', {...values});
|
||||
});
|
||||
|
||||
router.get('/token', function(req, res, next) {
|
||||
res.render('token', {...values});
|
||||
});
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
const router = require('express').Router();
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const permission = require('../utils/permission');
|
||||
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
|
||||
next();
|
||||
} catch(err) {
|
||||
next(err);
|
||||
}
|
||||
});
|
||||
|
||||
const Redis = require('ioredis');
|
||||
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null });
|
||||
const { initScheduler } = require('../services/scheduler');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
const plugins = conf.discovery && conf.discovery.plugins ? conf.discovery.plugins : {};
|
||||
let overrides = {};
|
||||
try {
|
||||
const data = await connection.hgetall('discovery_plugins');
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
overrides[k] = JSON.parse(v);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
// Mask secrets before sending
|
||||
const masked = JSON.parse(JSON.stringify(plugins));
|
||||
for (const name in masked) {
|
||||
masked[name] = { ...masked[name], ...(overrides[name] || {}) };
|
||||
if (masked[name].tokenSecret) masked[name].tokenSecret = '********';
|
||||
if (masked[name].password) masked[name].password = '********';
|
||||
}
|
||||
res.json({ results: masked });
|
||||
});
|
||||
|
||||
router.put('/:name', async (req, res) => {
|
||||
const name = req.params.name;
|
||||
const updates = req.body;
|
||||
|
||||
let current = {};
|
||||
try {
|
||||
const data = await connection.hget('discovery_plugins', name);
|
||||
if (data) current = JSON.parse(data);
|
||||
} catch(e) {}
|
||||
|
||||
if (updates.cron !== undefined) current.cron = updates.cron;
|
||||
if (updates.enabled !== undefined) current.enabled = updates.enabled === true || updates.enabled === 'true';
|
||||
|
||||
await connection.hset('discovery_plugins', name, JSON.stringify(current));
|
||||
|
||||
// Re-init scheduler to apply changes
|
||||
await initScheduler(conf.discovery).catch(console.error);
|
||||
|
||||
res.json({ success: true, message: 'Plugin updated' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -90,7 +90,13 @@ router.get('/me', async function(req, res, next){
|
||||
// same answer in both modes.
|
||||
const groups = await groupCns(user);
|
||||
user.groups = groups;
|
||||
user.isAdmin = groups.includes('app_sso_admin') || groups.includes(permission.SUPER_ADMIN_GROUP);
|
||||
// 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);
|
||||
}catch(error){
|
||||
|
||||
@@ -9,34 +9,42 @@ class DiscoveryReconciler {
|
||||
|
||||
for (const res of resources) {
|
||||
if (!res.metadata) res.metadata = {};
|
||||
res._originalSlug = res.slug; // Keep track for edge mapping
|
||||
|
||||
let existing = null;
|
||||
|
||||
// Attempt matching by MAC if available
|
||||
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
|
||||
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
|
||||
|
||||
const allRes = await Resource.list();
|
||||
|
||||
// 1. Attempt matching by MAC (highest precision)
|
||||
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
|
||||
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
|
||||
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
|
||||
if (macs.length > 0) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r =>
|
||||
r.metadata && r.metadata.interfaces &&
|
||||
r.metadata.interfaces.some(i => macs.includes(i.mac))
|
||||
r.metadata && (
|
||||
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
|
||||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback matching by IP if no MAC match (weaker)
|
||||
|
||||
// 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) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.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;
|
||||
@@ -45,14 +53,17 @@ class DiscoveryReconciler {
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback matching by Slug or Name
|
||||
|
||||
// 3. Fallback matching by Slug, Name, or Base Hostname
|
||||
if (!existing && (res.slug || res.name)) {
|
||||
const allRes = await Resource.list();
|
||||
existing = allRes.find(r =>
|
||||
(res.slug && r.slug === res.slug) ||
|
||||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
|
||||
);
|
||||
const inputName = normalizeHost(res.name || res.slug);
|
||||
existing = allRes.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) {
|
||||
@@ -65,7 +76,10 @@ class DiscoveryReconciler {
|
||||
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 === ni.mac) || (ni.ip && ei.ip === ni.ip));
|
||||
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);
|
||||
}
|
||||
@@ -79,16 +93,23 @@ class DiscoveryReconciler {
|
||||
|
||||
mergedMeta.last_seen = Date.now();
|
||||
|
||||
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
|
||||
let bestName = existing.name;
|
||||
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
|
||||
bestName = res.name;
|
||||
}
|
||||
|
||||
await existing.update({
|
||||
name: res.name || existing.name,
|
||||
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 = [sourceName];
|
||||
res.metadata.discovery_sources = sources;
|
||||
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')}`;
|
||||
@@ -103,12 +124,48 @@ class DiscoveryReconciler {
|
||||
});
|
||||
|
||||
newDevices++;
|
||||
res._actualId = created.id; // Map original slug to actual ID
|
||||
WebhookEmitter.emit('discovery.new_device', created.toJSON());
|
||||
}
|
||||
}
|
||||
|
||||
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
|
||||
// or we just trust the plugins to give us explicit parent-child mappings by slug.
|
||||
// Now process edges
|
||||
const allRes = await Resource.list();
|
||||
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;
|
||||
}
|
||||
|
||||
if (parentId && childId) {
|
||||
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
|
||||
if (!edgeExists) {
|
||||
await ResourceEdge.create({
|
||||
id: crypto.randomUUID(),
|
||||
parentId,
|
||||
childId,
|
||||
relation: edge.relation
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (newDevices > 0) {
|
||||
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+187
-59
@@ -1,5 +1,27 @@
|
||||
'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
|
||||
@@ -8,80 +30,186 @@ const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379',
|
||||
|
||||
const discoveryQueue = new Queue('discovery', { connection });
|
||||
|
||||
// Load plugins
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const pluginsDir = path.join(__dirname, '../plugins/discovery');
|
||||
|
||||
let plugins = {};
|
||||
|
||||
if (fs.existsSync(pluginsDir)) {
|
||||
fs.readdirSync(pluginsDir).forEach(file => {
|
||||
if (file.endsWith('.js')) {
|
||||
const name = path.basename(file, '.js');
|
||||
plugins[name] = require(path.join(pluginsDir, file));
|
||||
}
|
||||
});
|
||||
}
|
||||
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_plugin') {
|
||||
const { pluginName, config } = job.data;
|
||||
if (plugins[pluginName]) {
|
||||
console.log(`[Scheduler] Running plugin: ${pluginName}`);
|
||||
try {
|
||||
const payload = await plugins[pluginName].discover(config);
|
||||
await DiscoveryReconciler.reconcile(pluginName, payload);
|
||||
} catch (err) {
|
||||
console.error(`[Scheduler] Plugin ${pluginName} failed:`, err);
|
||||
}
|
||||
}
|
||||
} else if (job.name === 'garbage_collect') {
|
||||
console.log(`[Scheduler] Running garbage collection`);
|
||||
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 });
|
||||
|
||||
// Function to start scheduling
|
||||
async function initScheduler(discoveryConfig) {
|
||||
// Clear old repeatable jobs (BullMQ v6 uses JobSchedulers)
|
||||
try {
|
||||
const schedulers = await discoveryQueue.getJobSchedulers();
|
||||
for (const job of schedulers) {
|
||||
await discoveryQueue.removeJobScheduler(job.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Scheduler] Could not clear old job schedulers (may not be supported or none exist)');
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Schedule Garbage Collection
|
||||
await discoveryQueue.add('garbage_collect', {}, { repeat: { pattern: '0 0 * * *' } }); // Daily
|
||||
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;
|
||||
}
|
||||
|
||||
// Load plugin overrides from Redis
|
||||
let overrides = {};
|
||||
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 data = await connection.hgetall('discovery_plugins');
|
||||
for (const [k, v] of Object.entries(data)) {
|
||||
overrides[k] = JSON.parse(v);
|
||||
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] Failed to load plugin overrides from Redis', 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 Plugins based on config + overrides
|
||||
if (discoveryConfig && discoveryConfig.plugins) {
|
||||
for (const [name, config] of Object.entries(discoveryConfig.plugins)) {
|
||||
const mergedConfig = { ...config, ...(overrides[name] || {}) };
|
||||
if (mergedConfig.enabled && plugins[name]) {
|
||||
const cron = mergedConfig.cron || '0 * * * *'; // Default hourly
|
||||
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig }, { repeat: { pattern: cron } });
|
||||
console.log(`[Scheduler] Scheduled plugin ${name} with cron ${cron}`);
|
||||
|
||||
// Also run once immediately
|
||||
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig });
|
||||
}
|
||||
// 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}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { initScheduler, discoveryQueue, connection };
|
||||
// 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
|
||||
};
|
||||
@@ -1,12 +0,0 @@
|
||||
const express = require('express');
|
||||
const { createProxyMiddleware } = require('http-proxy-middleware');
|
||||
const app = express();
|
||||
app.use('/', createProxyMiddleware({
|
||||
target: 'http://localhost:8080',
|
||||
on: {
|
||||
proxyRes: (proxyRes, req, res) => {
|
||||
delete proxyRes.headers['x-frame-options'];
|
||||
}
|
||||
}
|
||||
}));
|
||||
app.listen(3004);
|
||||
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const agentManager = require('../utils/agent_manager');
|
||||
|
||||
describe('AgentManager PROTOCOL.md v1.1.0 Compliance', () => {
|
||||
let mockWs;
|
||||
let sentMessages;
|
||||
|
||||
beforeEach(() => {
|
||||
sentMessages = [];
|
||||
mockWs = {
|
||||
readyState: 1, // OPEN
|
||||
send: jest.fn((msg) => sentMessages.push(JSON.parse(msg))),
|
||||
close: jest.fn()
|
||||
};
|
||||
});
|
||||
|
||||
test('registers agent and tracks initial connection state', () => {
|
||||
const record = agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
||||
expect(record.token).toBe('test-token-123');
|
||||
expect(record.ipAddress).toBe('192.168.1.100');
|
||||
|
||||
const agents = agentManager.getConnectedAgents();
|
||||
const found = agents.find(a => a.token === 'test-token-123');
|
||||
expect(found).toBeDefined();
|
||||
expect(found.isOnline).toBe(true);
|
||||
});
|
||||
|
||||
test('processes discovery payload per PROTOCOL.md v1.1.0 Section 3.1', () => {
|
||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
||||
|
||||
const discoveryPayload = {
|
||||
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'
|
||||
};
|
||||
|
||||
agentManager.handleDiscovery('test-token-123', discoveryPayload);
|
||||
|
||||
const agents = agentManager.getConnectedAgents();
|
||||
const agent = agents.find(a => a.token === 'test-token-123');
|
||||
expect(agent.hostname).toBe('node-01.local');
|
||||
expect(agent.discovery.os).toBe('Ubuntu 24.04 LTS');
|
||||
expect(agent.discovery.ip_addresses).toEqual(['192.168.1.100', '10.0.0.5']);
|
||||
});
|
||||
|
||||
test('processes telemetry payload per PROTOCOL.md v1.1.0 Section 3.2', () => {
|
||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
||||
|
||||
const telemetryPayload = {
|
||||
cpu_usage_percent: 14.5,
|
||||
ram_usage_percent: 42.1,
|
||||
disk_usage_percent: 68.0,
|
||||
zfs_health: 'ONLINE',
|
||||
gpu_usage_percent: -1.0,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
agentManager.handleTelemetry('test-token-123', telemetryPayload);
|
||||
|
||||
const agents = agentManager.getConnectedAgents();
|
||||
const agent = agents.find(a => a.token === 'test-token-123');
|
||||
expect(agent.telemetry.cpu_usage_percent).toBe(14.5);
|
||||
expect(agent.telemetry.zfs_health).toBe('ONLINE');
|
||||
});
|
||||
|
||||
test('responds to heartbeat with heartbeat_ack per Section 3.3', () => {
|
||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
||||
|
||||
agentManager.handleHeartbeat('test-token-123', { timestamp: new Date().toISOString() }, mockWs);
|
||||
|
||||
expect(mockWs.send).toHaveBeenCalled();
|
||||
const lastMsg = sentMessages[sentMessages.length - 1];
|
||||
expect(lastMsg.type).toBe('heartbeat_ack');
|
||||
expect(lastMsg.payload.timestamp).toBeDefined();
|
||||
});
|
||||
|
||||
test('canonicalizes payload and signs high-risk commands using Ed25519 per Section 5', () => {
|
||||
agentManager.registerAgent('test-token-123', mockWs, '192.168.1.100');
|
||||
|
||||
const rawPayload = { script: 'uptime', location: 'datacenter' };
|
||||
const msg = agentManager.sendCommand('test-token-123', 'arbitrary_bash', rawPayload, true);
|
||||
|
||||
expect(msg.type).toBe('arbitrary_bash');
|
||||
expect(msg.payload.signature).toBeDefined();
|
||||
expect(typeof msg.payload.signature).toBe('string');
|
||||
|
||||
// Verify signature with public key
|
||||
const signatureBuffer = Buffer.from(msg.payload.signature, 'base64');
|
||||
const canonicalStr = agentManager.canonicalize(rawPayload);
|
||||
const isValid = crypto.verify(null, Buffer.from(canonicalStr, 'utf8'), agentManager.publicKeyPem, signatureBuffer);
|
||||
expect(isValid).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,114 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
slugify,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
GOD_ADMIN,
|
||||
} = require('../utils/groups');
|
||||
|
||||
const HOST = { site: 'Main Office', kind: 'host', slug: 'Web 01' };
|
||||
const APP = { site: 'main-office', kind: 'app', slug: 'emby' };
|
||||
const OTHER_SITE_HOST = { site: 'branch-office', kind: 'host', slug: '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('');
|
||||
});
|
||||
test('never contains an underscore (the structural delimiter)', () => {
|
||||
expect(slugify('a_b_c')).not.toContain('_');
|
||||
expect(resourceGroupCns('Main Office', 'host', 'Web 01', 'access')).not.toContain('__');
|
||||
});
|
||||
});
|
||||
|
||||
describe('group cn builders', () => {
|
||||
test('per-resource uses singular kind', () => {
|
||||
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('aggregate uses 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('invalid kind throws', () => {
|
||||
expect(() => resourceGroupCns('s', 'service', 'x', '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);
|
||||
// aggregate capability
|
||||
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('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,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,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,180 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
class AgentManager {
|
||||
constructor() {
|
||||
this.agents = new Map(); // token -> agentRecord
|
||||
this.privateKeyPem = null;
|
||||
this.publicKeyPem = null;
|
||||
this.initKeyPair();
|
||||
}
|
||||
|
||||
initKeyPair() {
|
||||
try {
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519', {
|
||||
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
||||
publicKeyEncoding: { type: 'spki', format: 'pem' }
|
||||
});
|
||||
this.privateKeyPem = privateKey;
|
||||
this.publicKeyPem = publicKey;
|
||||
} catch (err) {
|
||||
console.error('[AgentManager] Failed to generate Ed25519 key pair:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Ed25519 private key.
|
||||
* Returns base64 encoded signature.
|
||||
*/
|
||||
signPayload(payload) {
|
||||
if (!this.privateKeyPem) {
|
||||
throw new Error('Ed25519 private key is not initialized');
|
||||
}
|
||||
const canonicalBytes = Buffer.from(this.canonicalize(payload), 'utf8');
|
||||
const signature = crypto.sign(null, canonicalBytes, this.privateKeyPem);
|
||||
return signature.toString('base64');
|
||||
}
|
||||
|
||||
registerAgent(token, ws, remoteAddress) {
|
||||
const existing = this.agents.get(token);
|
||||
if (existing && existing.ws && existing.ws !== ws) {
|
||||
try { existing.ws.close(4002, 'Superseded by new connection'); } catch (e) {}
|
||||
}
|
||||
|
||||
const agentRecord = {
|
||||
token,
|
||||
ws,
|
||||
ipAddress: remoteAddress,
|
||||
hostname: 'unknown',
|
||||
connectedAt: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
discovery: {},
|
||||
telemetry: {},
|
||||
pendingResponses: new Map()
|
||||
};
|
||||
|
||||
this.agents.set(token, agentRecord);
|
||||
return agentRecord;
|
||||
}
|
||||
|
||||
unregisterAgent(token, ws) {
|
||||
const record = this.agents.get(token);
|
||||
if (record && record.ws === ws) {
|
||||
this.agents.delete(token);
|
||||
}
|
||||
}
|
||||
|
||||
handleDiscovery(token, payload) {
|
||||
const agent = this.agents.get(token);
|
||||
if (!agent) return;
|
||||
|
||||
agent.lastSeen = new Date().toISOString();
|
||||
agent.hostname = payload.hostname || agent.hostname;
|
||||
agent.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'
|
||||
};
|
||||
}
|
||||
|
||||
handleTelemetry(token, payload) {
|
||||
const agent = this.agents.get(token);
|
||||
if (!agent) return;
|
||||
|
||||
agent.lastSeen = new Date().toISOString();
|
||||
agent.telemetry = {
|
||||
cpu_usage_percent: payload.cpu_usage_percent || 0,
|
||||
ram_usage_percent: payload.ram_usage_percent || 0,
|
||||
disk_usage_percent: payload.disk_usage_percent || 0,
|
||||
zfs_health: payload.zfs_health || 'N/A',
|
||||
gpu_usage_percent: payload.gpu_usage_percent ?? -1,
|
||||
timestamp: payload.timestamp || new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
handleHeartbeat(token, payload, ws) {
|
||||
const agent = this.agents.get(token);
|
||||
if (agent) {
|
||||
agent.lastSeen = new Date().toISOString();
|
||||
}
|
||||
try {
|
||||
ws.send(JSON.stringify({
|
||||
type: 'heartbeat_ack',
|
||||
payload: { timestamp: new Date().toISOString() }
|
||||
}));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
handleResponse(token, payload) {
|
||||
const agent = this.agents.get(token);
|
||||
if (agent) {
|
||||
agent.lastSeen = new Date().toISOString();
|
||||
agent.lastResponse = {
|
||||
status: payload.status || 'ok',
|
||||
message: payload.message || '',
|
||||
output: payload.output || '',
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
sendCommand(token, commandType, payload = {}, isHighRisk = false) {
|
||||
const agent = this.agents.get(token);
|
||||
if (!agent || !agent.ws || agent.ws.readyState !== 1) {
|
||||
throw new Error(`Agent with token "${token}" is not connected`);
|
||||
}
|
||||
|
||||
const finalPayload = { ...payload };
|
||||
if (isHighRisk) {
|
||||
finalPayload.signature = this.signPayload(finalPayload);
|
||||
}
|
||||
|
||||
const message = {
|
||||
type: commandType,
|
||||
payload: finalPayload
|
||||
};
|
||||
|
||||
agent.ws.send(JSON.stringify(message));
|
||||
return message;
|
||||
}
|
||||
|
||||
getConnectedAgents() {
|
||||
const list = [];
|
||||
const now = new Date();
|
||||
for (const [token, agent] of this.agents.entries()) {
|
||||
list.push({
|
||||
token,
|
||||
hostname: agent.hostname,
|
||||
ipAddress: agent.ipAddress,
|
||||
connectedAt: agent.connectedAt,
|
||||
lastSeen: agent.lastSeen,
|
||||
discovery: agent.discovery,
|
||||
telemetry: agent.telemetry,
|
||||
lastResponse: agent.lastResponse || null,
|
||||
isOnline: (now - new Date(agent.lastSeen)) < 90000
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new AgentManager();
|
||||
@@ -0,0 +1,123 @@
|
||||
'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)`);
|
||||
}
|
||||
|
||||
// {site}_host_<slug>_<level> / {site}_app_<slug>_<level>
|
||||
function resourceGroupCns(site, kind, slug, level) {
|
||||
assertKind(kind);
|
||||
return `${slugify(site)}_${kind}_${slugify(slug)}_${level}`;
|
||||
}
|
||||
|
||||
// {site}_hosts_<level> / {site}_apps_<level> (plural kind — the aggregate).
|
||||
function aggregateGroupCns(site, kind, level) {
|
||||
assertKind(kind);
|
||||
return `${slugify(site)}_${kind}s_${level}`;
|
||||
}
|
||||
|
||||
// {site}_super_admin
|
||||
function siteSuperAdminCns(site) {
|
||||
return `${slugify(site)}_super_admin`;
|
||||
}
|
||||
|
||||
// {site}_everyone
|
||||
function siteEveryoneCns(site) {
|
||||
return `${slugify(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) {
|
||||
const site = slugify(resource && resource.site);
|
||||
const kind = resource && resource.kind;
|
||||
const slug = slugify(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, slug, 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, slug, level))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GOD_ADMIN,
|
||||
KNOWN_LEVELS,
|
||||
KINDS,
|
||||
slugify,
|
||||
resourceGroupCns,
|
||||
aggregateGroupCns,
|
||||
siteSuperAdminCns,
|
||||
siteEveryoneCns,
|
||||
isKnownLevel,
|
||||
levelGrants,
|
||||
hasPermission,
|
||||
};
|
||||
@@ -1,10 +1,26 @@
|
||||
'use strict';
|
||||
|
||||
const {Group} = require('../models/group_ldap');
|
||||
const groups = require('./groups');
|
||||
|
||||
// The group nested into every resource's _admin group by api_directory_admin
|
||||
// (cross-resource super-admin administration). KEEP the legacy `app_super_admin`
|
||||
// here: it is the group that actually exists and gets nested. The new schema's
|
||||
// global `god_admin` is recognized in isSuperAdmin() below, and api_directory_admin
|
||||
// nests SUPER_ADMIN_GROUP -- so until `god_admin` is created during bootstrap, this
|
||||
// must stay `app_super_admin` or resource auto-provisioning's nesting silently
|
||||
// no-ops (leaving only the creator as the group's sole member).
|
||||
const SUPER_ADMIN_GROUP = 'app_super_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 --
|
||||
@@ -17,9 +33,9 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
// they still catch direct membership if the resolver is unavailable.
|
||||
}
|
||||
|
||||
if(memberOfCns.includes(SUPER_ADMIN_GROUP)) return true;
|
||||
if(await isSuperAdmin(memberOfCns)) return true;
|
||||
|
||||
for(let group of groups){
|
||||
for(let group of checkGroups){
|
||||
if(memberOfCns.includes(group)) return true;
|
||||
}
|
||||
|
||||
@@ -42,4 +58,46 @@ let byGroup = async function(user, groups, ownerOf){
|
||||
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 };
|
||||
+4
-6
@@ -38,15 +38,13 @@ module.exports = {
|
||||
// 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).
|
||||
nav: [
|
||||
// Ungated on purpose: the catalog is the one page that exists for
|
||||
// ordinary users. Before this, every nav item was admin-only and a
|
||||
// non-admin had no signposted destination at all.
|
||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
||||
// 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: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_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: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
|
||||
// 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']},
|
||||
],
|
||||
};
|
||||
|
||||
+246
-46
@@ -4,15 +4,25 @@
|
||||
// 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/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||
// secret/* admin UI sessions (sso-admin policy)
|
||||
// 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');
|
||||
@@ -20,6 +30,9 @@ 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)
|
||||
@@ -54,51 +67,104 @@ async function bao(method, path, body) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
||||
// 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) return;
|
||||
if (existing.status !== 404) {
|
||||
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 the sso-broker role with the given policies. Returns
|
||||
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
||||
async function mintToken(policies) {
|
||||
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
||||
// 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, 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 ──────────────────────────────────────────────────────────
|
||||
function userPolicyHcl(uid) {
|
||||
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||
// into a policy path, so reject anything but a safe charset.
|
||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
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 confined to secret/users/<uid>/*.
|
||||
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
||||
// 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;
|
||||
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
||||
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;
|
||||
@@ -108,24 +174,144 @@ async function getOrCreateAdminToken(uid) {
|
||||
}
|
||||
|
||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||
function appPolicyHcl(name) {
|
||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
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.
|
||||
async function mintAppToken(name) {
|
||||
// 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}`, appPolicyHcl(name));
|
||||
const { token, ttl } = await mintToken([`app-${name}`]);
|
||||
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
|
||||
@@ -134,11 +320,12 @@ async function mintAppToken(name) {
|
||||
// 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_GROUP]);
|
||||
await permission.byGroup(user, ADMIN_GROUPS);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
@@ -167,17 +354,13 @@ async function scopeGuard(req, res, next) {
|
||||
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||
}
|
||||
|
||||
// Defense-in-depth: confirm the requested path is within the subject's
|
||||
// namespace. Admins roam all of secret/; users are confined to
|
||||
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
||||
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
||||
// 403 instead of an opaque OpenBao denial.)
|
||||
const norm = normalizeVaultPath(req.path);
|
||||
if (norm === null) {
|
||||
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||
}
|
||||
const base = `/secret/users/${uid}`;
|
||||
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
||||
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' });
|
||||
}
|
||||
@@ -191,16 +374,21 @@ function vaultProxy() {
|
||||
return createProxyMiddleware({
|
||||
target: VAULT_ADDR,
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/': '/v1/' },
|
||||
on: {
|
||||
proxyReq(proxyReq, req, res, options) {
|
||||
fixRequestBody(proxyReq, req, res, options);
|
||||
// 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');
|
||||
},
|
||||
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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -214,7 +402,7 @@ mintAppRouter.post('/', async (req, res, next) => {
|
||||
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);
|
||||
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' });
|
||||
@@ -230,4 +418,16 @@ module.exports = {
|
||||
scopeGuard,
|
||||
vaultProxy,
|
||||
mintAppRouter,
|
||||
};
|
||||
// app-token lifecycle
|
||||
renewAppTokens,
|
||||
startAppTokenRenewal,
|
||||
VaultAppToken,
|
||||
// sharing
|
||||
SharedSecret,
|
||||
SharedSecretGrant,
|
||||
userPolicyHcl,
|
||||
appPolicyHcl,
|
||||
grantSharedSecret,
|
||||
revokeSharedSecret,
|
||||
reconcileGrantee,
|
||||
};
|
||||
|
||||
+441
-79
@@ -1,9 +1,16 @@
|
||||
<%- 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() {
|
||||
@@ -28,6 +35,13 @@
|
||||
$('#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');
|
||||
}
|
||||
@@ -35,8 +49,8 @@
|
||||
|
||||
async function saveConf() {
|
||||
const btn = $('#btn-save');
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||
|
||||
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
|
||||
|
||||
const payload = {
|
||||
smtp: {
|
||||
host: $('#smtp-host').val(),
|
||||
@@ -53,19 +67,87 @@
|
||||
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! It will take effect immediately.', 'success');
|
||||
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"></i> Save Configuration');
|
||||
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') {
|
||||
@@ -74,87 +156,367 @@
|
||||
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 py-4">
|
||||
<div class="row mb-4">
|
||||
<div class="col d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||
<p class="text-muted mb-0">
|
||||
Manage runtime configuration such as SMTP settings and OAuth parameters.
|
||||
These secrets are stored securely in OpenBao Vault.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
||||
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Host</label>
|
||||
<input type="text" class="form-control" id="smtp-host">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Port</label>
|
||||
<input type="number" class="form-control" id="smtp-port">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">User</label>
|
||||
<input type="text" class="form-control" id="smtp-user">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp-pass">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">From Address</label>
|
||||
<input type="text" class="form-control" id="smtp-from">
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||
<label class="form-check-label">Use Secure (TLS)</label>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card shadow-sm border-0 h-100">
|
||||
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Issuer URL</label>
|
||||
<input type="text" class="form-control" id="oauth-issuer">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="oauth-jwtsecret">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||
<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>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-access">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Refresh Token Lifetime (seconds)</label>
|
||||
<input type="number" class="form-control" id="oauth-token-refresh">
|
||||
|
||||
<!-- 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>
|
||||
|
||||
+550
-92
@@ -13,12 +13,12 @@
|
||||
</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
|
||||
<i class="fa-solid fa-network-wired"></i> Discovered Inventory
|
||||
</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-robot"></i> Agents & Scheduler
|
||||
<i class="fa-solid fa-plug"></i> Discovery Plugins
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -30,6 +30,7 @@
|
||||
<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
|
||||
<a href="/docs/groups" class="text-reset ms-1" title="Group & permission model"><i class="fa-solid fa-circle-question"></i></a>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search..." onkeyup="renderTable()" style="width: 200px;">
|
||||
@@ -45,6 +46,9 @@
|
||||
<datalist id="access-uid-list"></datalist>
|
||||
<button class="btn btn-outline-secondary" onclick="openUserAccessModal()">Check</button>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-primary ms-1 shadow-sm" onclick="openAgentInstallModal()">
|
||||
<i class="fa-solid fa-shield-halved me-1"></i> Install Agent
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary ms-1 shadow-sm" onclick="openAddModal()">
|
||||
<i class="fas fa-plus"></i> Add Resource
|
||||
</button>
|
||||
@@ -69,6 +73,7 @@
|
||||
<tr id="resource-row-{{id}}">
|
||||
<td class="ps-3">
|
||||
{{{indentHtml}}}
|
||||
{{#isHost}}<span class="d-inline-block rounded-circle me-1" style="width:10px;height:10px;background:{{agentColor}};" title="{{agentStatusTitle}}"></span>{{/isHost}}
|
||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||
@@ -189,52 +194,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents Tab Pane -->
|
||||
|
||||
<!-- Discovery Plugins Tab Pane -->
|
||||
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||
<div class="border-0">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div class="p-4 bg-white border-top">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<i class="fa-solid fa-robot"></i> Agents & Scheduler
|
||||
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
|
||||
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
|
||||
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
|
||||
</div>
|
||||
</div>
|
||||
<div 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/agents">Learn how to make and use custom agents</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">Agent 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 agents configured.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div id="discovery-plugins-list" class="mt-3"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -263,7 +237,8 @@
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Slug</label>
|
||||
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace">
|
||||
<input type="text" id="res-slug" class="form-control shadow-sm font-monospace" readonly>
|
||||
<div class="form-text">Derived from the name; read-only.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -504,6 +479,7 @@
|
||||
{id: 'details', label: 'Details', bodyHtml: detailsTabHtml},
|
||||
{id: 'groups', label: 'Associated LDAP Groups', bodyHtml: groupsTabHtml},
|
||||
{id: 'children', label: 'Children', bodyHtml: childrenTabHtml},
|
||||
{id: 'metrics', label: 'Metrics', bodyHtml: metricsTabHtml(resourcesById[id] && resourcesById[id].agent)},
|
||||
],
|
||||
footer: {
|
||||
metaHtml: id ? app.modal.formatAudit(resourcesById[id], {formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); }}) : '',
|
||||
@@ -549,24 +525,34 @@
|
||||
}
|
||||
});
|
||||
|
||||
// Connected theta-agent join: hostname->agent and token->agent (case-insensitive
|
||||
// hostname). Populated by loadResources/refreshAgents; host rows + the Metrics
|
||||
// tab read from these. Agent data comes from /api/agent/nodes (admin-gated).
|
||||
var agentsByHost = {};
|
||||
var agentsByToken = {};
|
||||
|
||||
async function loadResources() {
|
||||
try {
|
||||
const [resResources, resGroups, resEdges, resAccess] = await Promise.all([
|
||||
const [resResources, resGroups, resEdges, resAccess, resAgents] = await Promise.all([
|
||||
app.api.get('directory-admin/resources'),
|
||||
app.api.get('directory-admin/groups'),
|
||||
app.api.get('directory-admin/edges'),
|
||||
// Access counts are a nicety, not load-bearing: if the LDAP join fails
|
||||
// the table still renders, just without the Access column populated.
|
||||
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; })
|
||||
app.api.get('directory-admin/access-summary').catch(function(){ return {results: {}}; }),
|
||||
// Agents are a nicety too: never block the directory on them.
|
||||
app.api.get('agent/nodes').catch(function(){ return {agents: []}; })
|
||||
]);
|
||||
|
||||
accessSummary = (resAccess && resAccess.results) || {};
|
||||
resourcesById = {};
|
||||
|
||||
|
||||
for (const r of resResources.results) {
|
||||
r.metadata = r.metadata || {};
|
||||
resourcesById[r.id] = r;
|
||||
}
|
||||
|
||||
indexAgents((resAgents && resAgents.agents) || []);
|
||||
|
||||
allGroups = resGroups.results;
|
||||
allEdges = resEdges.results;
|
||||
@@ -600,6 +586,79 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Build the hostname->agent and token->agent lookup maps from /api/agent/nodes.
|
||||
function indexAgents(agents) {
|
||||
agentsByHost = {};
|
||||
agentsByToken = {};
|
||||
for (const a of agents || []) {
|
||||
const hn = (a.hostname || (a.discovery && a.discovery.hostname) || '').toLowerCase();
|
||||
if (hn) agentsByHost[hn] = a;
|
||||
if (a.token) agentsByToken[a.token] = a;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) { return s == null ? '' : app.util.escapeHtml(String(s)); }
|
||||
function timeAgo(iso) { if (!iso) return ''; var m = moment(iso); return m.isValid() ? m.fromNow() : ''; }
|
||||
|
||||
// Green (online, healthy) / Yellow (online, high load) / Red (not connected
|
||||
// or offline). Attaches n.isHost + a colored dot + tooltip for host rows, and
|
||||
// stores the agent on resourcesById so the Metrics tab can find it.
|
||||
function attachAgentStatus(n) {
|
||||
n.isHost = true;
|
||||
const name = (n.name || '').toLowerCase();
|
||||
const slug = (n.slug || '').replace(/^host_/, '').toLowerCase();
|
||||
const a = agentsByHost[name] || (slug && agentsByHost[slug]);
|
||||
n.agent = a || null;
|
||||
if (resourcesById[n.id]) resourcesById[n.id].agent = a || null;
|
||||
if (!a) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'No theta-agent connected'; return; }
|
||||
if (!a.isOnline) { n.agentColor = '#dc3545'; n.agentStatusTitle = 'Agent offline (' + (a.hostname || 'unknown') + ')'; return; }
|
||||
const t = a.telemetry || {};
|
||||
const high = (t.cpu_usage_percent > 80) || (t.ram_usage_percent > 80) || (t.disk_usage_percent > 90);
|
||||
n.agentColor = high ? '#ffc107' : '#198754';
|
||||
n.agentStatusTitle = high ? 'Connected — high load' : 'Connected — healthy';
|
||||
}
|
||||
|
||||
// Metrics tab body for the resource modal (snapshot of the joined agent).
|
||||
function metricsTabHtml(agent) {
|
||||
if (!agent) {
|
||||
return '<div class="p-3 text-center text-muted"><i class="fa-solid fa-microchip fa-3x mb-3"></i><h6>No theta-agent connected</h6><p class="small">Install the agent on this host to see live metrics.</p></div>';
|
||||
}
|
||||
const d = agent.discovery || {};
|
||||
const t = agent.telemetry || {};
|
||||
const bar = (val) => `<div class="progress" style="height:8px"><div class="progress-bar" style="width:${Math.max(0, Math.min(100, val || 0))}%"></div></div>`;
|
||||
const online = agent.isOnline ? '<span class="badge bg-success">Online</span>' : '<span class="badge bg-secondary">Offline</span>';
|
||||
const gpu = (t.gpu_usage_percent != null && t.gpu_usage_percent >= 0) ? t.gpu_usage_percent + '%' : 'N/A';
|
||||
return `<div class="p-3">
|
||||
<div class="mb-3 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">${esc(agent.hostname || 'unknown')} ${online}</h5>
|
||||
<small class="text-muted">Last seen ${timeAgo(agent.lastSeen)}</small>
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-6">CPU <strong>${t.cpu_usage_percent ?? 0}%</strong>${bar(t.cpu_usage_percent)}</div>
|
||||
<div class="col-6">RAM <strong>${t.ram_usage_percent ?? 0}%</strong>${bar(t.ram_usage_percent)}</div>
|
||||
<div class="col-6">Disk <strong>${t.disk_usage_percent ?? 0}%</strong>${bar(t.disk_usage_percent)}</div>
|
||||
<div class="col-6">GPU <strong>${gpu}</strong></div>
|
||||
<div class="col-6">ZFS <strong>${esc(t.zfs_health || 'N/A')}</strong></div>
|
||||
</div>
|
||||
<hr><h6>Discovery</h6>
|
||||
<div class="row small text-muted">
|
||||
<div class="col-6">OS: ${esc(d.os || '')}</div>
|
||||
<div class="col-6">Kernel: ${esc(d.kernel || '')}</div>
|
||||
<div class="col-6">IPs: ${esc((d.ip_addresses || []).join(', '))}</div>
|
||||
<div class="col-6">Location: ${esc(d.location || '')}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Re-fetch agents (every 30s + on socket events) so status dots stay live.
|
||||
async function refreshAgents() {
|
||||
try {
|
||||
const res = await app.api.get('agent/nodes');
|
||||
indexAgents((res && res.agents) || []);
|
||||
renderTable();
|
||||
} catch (e) { /* non-fatal */ }
|
||||
}
|
||||
|
||||
// "Who can reach this?" at a glance. A resource with no linked group is not a
|
||||
// locked-down resource -- it is an unreachable one, and a group whose LDAP
|
||||
// entry has been deleted grants nothing, so both get called out rather than
|
||||
@@ -707,6 +766,7 @@
|
||||
}
|
||||
n.indentHtml = indentHtml;
|
||||
n.accessHtml = accessCellHtml(n.id);
|
||||
if (n.kind === 'host') attachAgentStatus(n);
|
||||
finalRenderList.push(n);
|
||||
if (n.children.length > 0) {
|
||||
flatten(n.children, depth + 1);
|
||||
@@ -1238,6 +1298,7 @@
|
||||
allEdges.push(res.results);
|
||||
refreshEdgesUI(resourceId);
|
||||
$('#new-edge-target').val('');
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
|
||||
@@ -1249,6 +1310,7 @@
|
||||
await app.api.delete('directory-admin/edges/' + id);
|
||||
allEdges = allEdges.filter(e => e.id !== id);
|
||||
refreshEdgesUI($('#res-id').val());
|
||||
await loadData();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
|
||||
@@ -1288,9 +1350,10 @@
|
||||
function renderDiscoveryTable() {
|
||||
const search = $('#discovery-search-filter').val().toLowerCase();
|
||||
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(isManaged) return false;
|
||||
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
// Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
|
||||
const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
|
||||
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1325,59 +1388,454 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- AGENT SCRIPTS ---
|
||||
function loadPlugins() {
|
||||
app.api.get('plugins', function(err, res) {
|
||||
if(err) {
|
||||
app.messages.toast("Error loading agents: " + (err.message || err), 'danger');
|
||||
return;
|
||||
}
|
||||
const plugins = res.results || {};
|
||||
const pluginNames = Object.keys(plugins);
|
||||
|
||||
$.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();
|
||||
}
|
||||
// --- THETA AGENT INSTALL MODAL & WIZARD ---
|
||||
function generateRandomHexToken(byteLen) {
|
||||
const arr = new Uint8Array(byteLen || 16);
|
||||
(window.crypto || window.msCrypto).getRandomValues(arr);
|
||||
return Array.from(arr, b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function regenerateAgentToken(inputId) {
|
||||
const newToken = generateRandomHexToken(16);
|
||||
$('#' + inputId).val(newToken);
|
||||
if (inputId === 'agent-quick-token') $('#agent-custom-token').val(newToken);
|
||||
else $('#agent-quick-token').val(newToken);
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
function updateAgentCommands() {
|
||||
const quickUrl = ($('#agent-quick-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||
const quickToken = $('#agent-quick-token').val() || '';
|
||||
const quickCmd = `curl -fsSL ${quickUrl}/resources/theta-agent/install.sh | sh -s -- --url "${quickUrl}" --token "${quickToken}"`;
|
||||
$('#agent-quick-command').text(quickCmd);
|
||||
|
||||
const customUrl = ($('#agent-custom-url').val() || window.location.origin).replace(/\/+$/, '');
|
||||
const customToken = $('#agent-custom-token').val() || '';
|
||||
const customLocation = $('#agent-custom-location').val() || 'default';
|
||||
|
||||
const telemetry = $('#cap-telemetry').is(':checked');
|
||||
const configureLdap = $('#cap-configure-ldap').is(':checked');
|
||||
const reboot = $('#cap-reboot').is(':checked');
|
||||
const arbitraryBash = $('#cap-arbitrary-bash').is(':checked');
|
||||
|
||||
const servicesRaw = $('#cap-services').val() || '';
|
||||
const servicesList = servicesRaw.split(',').map(s => s.trim()).filter(Boolean);
|
||||
const servicesYaml = servicesList.length > 0
|
||||
? '[' + servicesList.map(s => `"${s}"`).join(', ') + ']'
|
||||
: '[]';
|
||||
|
||||
const yamlStr = [
|
||||
`server_url: "${customUrl}"`,
|
||||
`auth_token: "${customToken}"`,
|
||||
`location: "${customLocation}"`,
|
||||
`capabilities:`,
|
||||
` telemetry: ${telemetry}`,
|
||||
` configure_ldap: ${configureLdap}`,
|
||||
` reboot: ${reboot}`,
|
||||
` service_control: ${servicesYaml}`,
|
||||
` arbitrary_bash: ${arbitraryBash}`
|
||||
].join('\n');
|
||||
|
||||
$('#agent-yaml-preview').text(yamlStr);
|
||||
|
||||
try {
|
||||
const b64Config = btoa(yamlStr);
|
||||
const customCmd = `curl -fsSL ${customUrl}/resources/theta-agent/install.sh | sh -s -- "${b64Config}"`;
|
||||
$('#agent-custom-command').text(customCmd);
|
||||
} catch (e) {
|
||||
$('#agent-custom-command').text('Error encoding config to Base64');
|
||||
}
|
||||
}
|
||||
|
||||
function copyAgentCommand(elementId, btnId) {
|
||||
const text = $('#' + elementId).text();
|
||||
if (!text) return;
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
const $btn = $('#' + btnId);
|
||||
const origHtml = $btn.html();
|
||||
$btn.html('<i class="fa-solid fa-check me-1"></i> Copied!').removeClass('btn-success').addClass('btn-outline-success');
|
||||
setTimeout(() => {
|
||||
$btn.html(origHtml).removeClass('btn-outline-success').addClass('btn-success');
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
app.messages.toast('Failed to copy: ' + err, 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function updatePlugin(name) {
|
||||
const cron = $('#cron-' + name).val();
|
||||
app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||
if(err) {
|
||||
app.messages.toast("Error saving agent schedule: " + (err.message || err), 'danger');
|
||||
return;
|
||||
}
|
||||
app.messages.toast("Agent schedule saved successfully.", 'success');
|
||||
function openAgentInstallModal() {
|
||||
const currentOrigin = window.location.origin;
|
||||
const initialToken = generateRandomHexToken(16);
|
||||
|
||||
const bodyHtml = `
|
||||
<div class="mb-3 p-3 bg-light rounded border">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fa-solid fa-shield-halved fa-2x text-primary me-3"></i>
|
||||
<div>
|
||||
<h6 class="mb-0 fw-bold">Theta Agent Endpoint Management Daemon</h6>
|
||||
<small class="text-muted">A 2-way Command & Control (C2) daemon that streams real-time telemetry and enables secure, capability-controlled management operations on Linux hosts.</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills mb-3" id="agent-install-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link active" id="tab-quick-btn" data-bs-toggle="pill" data-bs-target="#tab-quick-pane" type="button" role="tab">
|
||||
<i class="fa-solid fa-bolt me-1"></i> Quick Install
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="tab-custom-btn" data-bs-toggle="pill" data-bs-target="#tab-custom-pane" type="button" role="tab">
|
||||
<i class="fa-solid fa-sliders me-1"></i> Custom Config Wizard
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content" id="agent-install-tab-content">
|
||||
<!-- ── Tab 1: Quick Install ──────────────────────────────────────── -->
|
||||
<div class="tab-pane fade show active" id="tab-quick-pane" role="tabpanel">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
|
||||
<input type="text" id="agent-quick-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label small fw-bold mb-1">Host Token</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="agent-quick-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-quick-token')" title="Regenerate Token">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="form-label small fw-bold mb-1">Run this command on the target host (as root):</label>
|
||||
<div class="position-relative mb-2">
|
||||
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-quick-command" style="user-select: all;"></pre>
|
||||
</div>
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-sm btn-success" id="btn-copy-quick" onclick="copyAgentCommand('agent-quick-command', 'btn-copy-quick')">
|
||||
<i class="fa-solid fa-copy me-1"></i> Copy Quick Install Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Tab 2: Custom Config Wizard ────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-custom-pane" role="tabpanel">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-md-5">
|
||||
<label class="form-label small fw-bold mb-1">SSO Server URL</label>
|
||||
<input type="text" id="agent-custom-url" class="form-control form-control-sm" value="${currentOrigin}" oninput="updateAgentCommands()">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label small fw-bold mb-1">Host Token</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<input type="text" id="agent-custom-token" class="form-control font-monospace" value="${initialToken}" oninput="updateAgentCommands()">
|
||||
<button class="btn btn-outline-secondary" type="button" onclick="regenerateAgentToken('agent-custom-token')" title="Regenerate Token">
|
||||
<i class="fa-solid fa-rotate"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label small fw-bold mb-1">Location Identifier</label>
|
||||
<input type="text" id="agent-custom-location" class="form-control form-control-sm" placeholder="e.g. dc-01-rack-12" value="default" oninput="updateAgentCommands()">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card bg-light border mb-3">
|
||||
<div class="card-header py-2 bg-light fw-bold small"><i class="fa-solid fa-key me-1"></i> Capability Matrix (Local-First Security Controls)</div>
|
||||
<div class="card-body py-2">
|
||||
<div class="row g-2">
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="cap-telemetry" checked onchange="updateAgentCommands()">
|
||||
<label class="form-check-label small" for="cap-telemetry"><strong>Telemetry</strong> <span class="text-muted">(CPU, RAM, Disk, ZFS stats)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="cap-configure-ldap" checked onchange="updateAgentCommands()">
|
||||
<label class="form-check-label small" for="cap-configure-ldap"><strong>Configure LDAP</strong> <span class="text-muted">(SSSD config & SSH keys)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="cap-reboot" onchange="updateAgentCommands()">
|
||||
<label class="form-check-label small" for="cap-reboot"><strong>Reboot</strong> <span class="text-muted">(remote system reboot)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-check form-switch">
|
||||
<input class="form-check-input" type="checkbox" id="cap-arbitrary-bash" onchange="updateAgentCommands()">
|
||||
<label class="form-check-label small text-danger" for="cap-arbitrary-bash"><strong>Arbitrary Bash</strong> <span class="text-muted">(remote root execution)</span></label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<label class="form-label small fw-bold mb-1">Service Control Allowlist <span class="text-muted font-normal">(comma-separated services, e.g. nginx, gitea, sssd)</span></label>
|
||||
<input type="text" id="cap-services" class="form-control form-control-sm" placeholder="nginx, docker, sssd" oninput="updateAgentCommands()">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-tabs nav-tabs-sm mb-2" id="preview-sub-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<button class="nav-link active py-1 px-3 small" id="subtab-cmd-btn" data-bs-toggle="tab" data-bs-target="#subtab-cmd-pane" type="button">Base64 Install Command</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<button class="nav-link py-1 px-3 small" id="subtab-yaml-btn" data-bs-toggle="tab" data-bs-target="#subtab-yaml-pane" type="button">Generated agent.yml</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="tab-content mb-2">
|
||||
<div class="tab-pane fade show active" id="subtab-cmd-pane" role="tabpanel">
|
||||
<pre class="bg-dark text-light p-3 rounded font-monospace small mb-0 text-wrap text-break" id="agent-custom-command" style="user-select: all;"></pre>
|
||||
</div>
|
||||
<div class="tab-pane fade" id="subtab-yaml-pane" role="tabpanel">
|
||||
<pre class="bg-light text-dark p-3 rounded border font-monospace small mb-0" id="agent-yaml-preview"></pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<button class="btn btn-sm btn-success" id="btn-copy-custom" onclick="copyAgentCommand('agent-custom-command', 'btn-copy-custom')">
|
||||
<i class="fa-solid fa-copy me-1"></i> Copy Base64 Command
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
title: 'Install Theta Agent',
|
||||
bodyHtml: bodyHtml,
|
||||
size: 'lg'
|
||||
});
|
||||
|
||||
updateAgentCommands();
|
||||
}
|
||||
|
||||
var discoveryPlugins = [];
|
||||
|
||||
function loadDiscoveryPlugins() {
|
||||
app.api.get('plugins', function(err, res) {
|
||||
if (err) return;
|
||||
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
|
||||
renderDiscoveryPlugins();
|
||||
});
|
||||
}
|
||||
|
||||
function togglePlugin(name, enable) {
|
||||
app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
|
||||
if(err) {
|
||||
app.messages.toast("Error toggling agent: " + (err.message || err), 'danger');
|
||||
|
||||
function renderDiscoveryPlugins() {
|
||||
const $list = $('#discovery-plugins-list').empty();
|
||||
if (discoveryPlugins.length === 0) {
|
||||
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
|
||||
return;
|
||||
}
|
||||
discoveryPlugins.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="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
|
||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
$list.append(card);
|
||||
});
|
||||
}
|
||||
|
||||
async function toggleDiscoveryPlugin(id, state) {
|
||||
const endpoint = state ? 'load' : 'unload';
|
||||
try {
|
||||
await app.api.post(`plugins/${id}/${endpoint}`, {});
|
||||
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function runDiscoveryPluginNow(id) {
|
||||
try {
|
||||
await app.api.post(`plugins/${id}/run`, {});
|
||||
app.messages.toast('Enqueued discovery plugin run', 'success');
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.toast('Error running plugin: ' + e.message, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
var discoveryPluginTypes = [];
|
||||
|
||||
// ── Discovery plugin config helpers (ported from plugins.ejs) ─────────────
|
||||
// Stored value is always a 5-field cron string; the dropdown picks a preset
|
||||
// and "Custom…" reveals the raw input. Config fields are driven by each
|
||||
// plugin type's configSchema so per-plugin settings (e.g. Proxmox url /
|
||||
// tokenId / tokenSecret) are collected at create time.
|
||||
var DP_CRON_PRESETS = [
|
||||
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||
{ key: 'custom', label: 'Custom…', cron: null },
|
||||
];
|
||||
function dpCronKeyFor(cron) {
|
||||
var m = DP_CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||
return m ? m.key : 'custom';
|
||||
}
|
||||
function dpCronSelectHtml(prefix, current) {
|
||||
current = current || '0 * * * *';
|
||||
var key = dpCronKeyFor(current);
|
||||
var opts = DP_CRON_PRESETS.map(function(p){
|
||||
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
|
||||
}).join('');
|
||||
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
|
||||
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="dpOnCronChange(\'' + prefix + '\')">' + opts + '</select>' +
|
||||
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + current + '"' + rawStyle + '>';
|
||||
}
|
||||
function dpOnCronChange(prefix) {
|
||||
var sel = document.getElementById(prefix + 'cron-select');
|
||||
var raw = document.getElementById(prefix + 'cron');
|
||||
if (!sel || !raw) return;
|
||||
if (sel.value === 'custom') { raw.style.display = ''; }
|
||||
else {
|
||||
raw.style.display = 'none';
|
||||
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||
if (preset) raw.value = preset.cron;
|
||||
}
|
||||
}
|
||||
function dpCronFromForm(prefix) {
|
||||
var sel = document.getElementById(prefix + 'cron-select');
|
||||
if (sel && sel.value !== 'custom') {
|
||||
var preset = DP_CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||
if (preset) return preset.cron;
|
||||
}
|
||||
var raw = document.getElementById(prefix + 'cron');
|
||||
return (raw && raw.value.trim()) || '0 * * * *';
|
||||
}
|
||||
function dpConfigFormHtml(type, prefix) {
|
||||
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||
var schema = t && t.configSchema;
|
||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||
var html = '';
|
||||
schema.forEach(function(f) {
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
var req = f.required ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||
html += '<div class="mb-3"><label class="form-label">' + label + '</label>' +
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '"' + req + ph + '></div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
function dpCollectConfig(type, prefix) {
|
||||
var t = discoveryPluginTypes.filter(function(x){ return x.type === type; })[0];
|
||||
var schema = t && t.configSchema;
|
||||
var out = {};
|
||||
if (!schema) return out;
|
||||
schema.forEach(function(f) { var el = document.getElementById(prefix + f.key); if (el) out[f.key] = el.value; });
|
||||
return out;
|
||||
}
|
||||
function dpRenderFields() {
|
||||
var type = document.getElementById('new-plugin-type').value;
|
||||
document.getElementById('new-plugin-config-fields').innerHTML = dpConfigFormHtml(type, 'np-');
|
||||
}
|
||||
|
||||
function openNewDiscoveryPluginModal() {
|
||||
app.api.get('plugins/types', function(err, res) {
|
||||
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
|
||||
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
|
||||
if (discoveryPluginTypes.length === 0) {
|
||||
app.messages.toast('No discovery plugin types available', 'warning');
|
||||
return;
|
||||
}
|
||||
loadPlugins();
|
||||
|
||||
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
|
||||
const bodyHtml = `
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Plugin Type</label>
|
||||
<select id="new-plugin-type" class="form-select shadow-sm" onchange="dpRenderFields()">${options}</select>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Instance Name</label>
|
||||
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
|
||||
<div class="form-text">A slug is derived automatically from the name.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-bold">Schedule</label>
|
||||
${dpCronSelectHtml('np-', '0 * * * *')}
|
||||
</div>
|
||||
<div class="form-check mb-3">
|
||||
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
|
||||
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
|
||||
</div>
|
||||
<hr><h6 class="fw-bold">Configuration</h6><div id="new-plugin-config-fields">${dpConfigFormHtml(discoveryPluginTypes[0].type, 'np-')}</div>
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
|
||||
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
app.modal.open({
|
||||
title: 'Configure New Discovery Plugin',
|
||||
bodyHtml: bodyHtml,
|
||||
size: 'lg'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveNewDiscoveryPlugin() {
|
||||
const type = $('#new-plugin-type').val();
|
||||
const name = $('#new-plugin-name').val().trim();
|
||||
const cron = dpCronFromForm('np-');
|
||||
const enabled = $('#new-plugin-enabled').is(':checked');
|
||||
const config = dpCollectConfig(type, 'np-');
|
||||
|
||||
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
||||
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
|
||||
|
||||
try {
|
||||
await app.api.post('plugins', {
|
||||
pluginType: type,
|
||||
name,
|
||||
cron,
|
||||
enabled,
|
||||
config
|
||||
});
|
||||
app.messages.toast('Discovery plugin created successfully!', 'success');
|
||||
app.modal.close();
|
||||
loadDiscoveryPlugins();
|
||||
} catch (e) {
|
||||
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
$(document).ready(function(){
|
||||
loadDiscoveryResources();
|
||||
loadPlugins();
|
||||
loadDiscoveryPlugins();
|
||||
// Keep the host status dots live: refresh the agent join periodically and on
|
||||
// socket.io agent.* broadcasts (dedicated socket — the app default is P2PSub).
|
||||
refreshAgents();
|
||||
setInterval(refreshAgents, 30000);
|
||||
try {
|
||||
const dirAgentSocket = io({ auth: { token: app.auth.getToken() } });
|
||||
dirAgentSocket.on('agent.telemetry', function(msg){
|
||||
const a = msg && agentsByToken[msg.token];
|
||||
if (a) { a.telemetry = msg.payload; a.isOnline = true; renderTable(); }
|
||||
});
|
||||
dirAgentSocket.on('agent.discovery', function(msg){
|
||||
const a = msg && agentsByToken[msg.token];
|
||||
if (a) { a.discovery = msg.payload; if (msg.payload && msg.payload.hostname) a.hostname = msg.payload.hostname; a.isOnline = true; renderTable(); }
|
||||
});
|
||||
} catch (e) { /* socket is optional; periodic refresh still runs */ }
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -21,11 +21,6 @@
|
||||
</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;">
|
||||
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||
<option value="all">All Resources</option>
|
||||
<option value="unmanaged" selected>Unmanaged Only</option>
|
||||
<option value="managed">Managed Only</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-header actionMessage" style="display:none"></div>
|
||||
@@ -132,10 +127,12 @@
|
||||
// Name search
|
||||
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||
|
||||
// Managed filter
|
||||
// Always hide items that have been committed to the catalog (managed)
|
||||
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||
if(managedFilter === 'managed' && !isManaged) return false;
|
||||
if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||
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;
|
||||
});
|
||||
@@ -161,7 +158,7 @@
|
||||
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();
|
||||
renderTable();
|
||||
loadResources();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,406 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<script type="text/javascript">
|
||||
var userlist;
|
||||
var allGroups = [];
|
||||
|
||||
// A member DN under the groups base is a nested group, not a person. Both
|
||||
// live in the same `member` attribute, so they have to be told apart here --
|
||||
// otherwise a nested group renders as a user whose name happens to be the
|
||||
// group's, and its remove button calls the user endpoint and 404s.
|
||||
function isGroupDn(dn){
|
||||
return /,ou=groups,/i.test(String(dn));
|
||||
}
|
||||
|
||||
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] : [];
|
||||
|
||||
// Split before anything else consumes `member`.
|
||||
value.nested = value.member.filter(isGroupDn).map(function(dn){
|
||||
return {
|
||||
dn: dn,
|
||||
cn: dn.match(/cn=[^,]+/)[0].replace('cn=', ''),
|
||||
groupCN: value.cn
|
||||
};
|
||||
});
|
||||
value.member = value.member.filter(function(dn){ return !isGroupDn(dn); });
|
||||
value.nestedCount = value.nested.length;
|
||||
value.hasNested = value.nestedCount > 0;
|
||||
|
||||
// Candidates to nest: every other group not already nested here. Self is
|
||||
// excluded; deeper loops are refused server-side by Group.wouldCycle,
|
||||
// which is the only place that can see the whole graph.
|
||||
var nestedDns = value.nested.map(function(g){ return g.dn.toLowerCase(); });
|
||||
value.toNest = allGroups.filter(function(g){
|
||||
return g.cn !== value.cn && nestedDns.indexOf(String(g.dn).toLowerCase()) === -1;
|
||||
}).map(function(g){ return {cn: g.cn, groupCN: value.cn}; });
|
||||
|
||||
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();
|
||||
// processGroup builds each card's "nest a group" list from allGroups, so
|
||||
// it has to see the full set before the map runs -- assigning only the
|
||||
// mapped result would leave every dropdown empty on first load (and one
|
||||
// render stale thereafter). The raw entries carry the cn/dn it needs.
|
||||
allGroups = data.results;
|
||||
allGroups = data.results.map(processGroup);
|
||||
applyFilters();
|
||||
if (revealCn) setTimeout(function(){ app.util.revealItem($('#group-card-' + revealCn)); }, 100);
|
||||
}
|
||||
|
||||
function addNestedClick(event, groupCN, childCN, el){
|
||||
event.preventDefault();
|
||||
const $card = $('#group-card-' + groupCN);
|
||||
(async function(){
|
||||
try {
|
||||
const data = await app.api.put(`group/${groupCN}/nested/${childCN}`, {});
|
||||
const groupData = await app.group.get(groupCN);
|
||||
$.scope.groupCard.update('cn', groupCN, processGroup(groupData.results));
|
||||
app.messages.action(data.message, $card, 'success');
|
||||
} catch(e) {
|
||||
// 409 here is the cycle guard or an already-nested group -- both
|
||||
// carry a specific server message worth showing verbatim.
|
||||
app.messages.action((e && e.message) || 'Failed to nest group', $card, 'danger');
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
async function removeNested(groupCN, childCN, btn) {
|
||||
const $item = $(btn).closest('li');
|
||||
$item.addClass('list-group-item-warning');
|
||||
const confirmed = await app.messages.confirm(
|
||||
`Remove "${childCN}" from "${groupCN}"? Its members lose access granted through this group.`,
|
||||
$item, 'warning');
|
||||
if (!confirmed) { $item.removeClass('list-group-item-warning'); return; }
|
||||
try {
|
||||
const data = await app.api.delete(`group/${groupCN}/nested/${childCN}`);
|
||||
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 un-nest group', $('#group-card-' + groupCN), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
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-nested-tab-{{cn}}" data-bs-toggle="tab" data-bs-target="#group-nested-{{cn}}" href="#group-nested-{{cn}}" role="tab" aria-controls="nested" aria-selected="false">
|
||||
<i class="fa-solid fa-layer-group"></i>
|
||||
Nested{{#hasNested}} <span class="badge bg-secondary">{{nestedCount}}</span>{{/hasNested}}
|
||||
</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-nested-{{cn}}" role="tabpanel" aria-labelledby="nested-tab">
|
||||
<p class="text-muted small mb-2">
|
||||
Everyone in a nested group is a member of this one, at any depth.
|
||||
</p>
|
||||
<ul class="list-group">
|
||||
{{ #nested }}
|
||||
<li id="group-card-{{groupCN}}-nested-{{cn}}" class="list-group-item shadow">
|
||||
<i class="fa-solid fa-layer-group"></i> {{ cn }}
|
||||
<button type="button" onclick="removeNested('{{groupCN}}', '{{cn}}', this)" class="btn btn-sm btn-danger float-end">
|
||||
<i class="fa-solid fa-link-slash"></i>
|
||||
</button>
|
||||
</li>
|
||||
{{ /nested }}
|
||||
{{ ^hasNested }}
|
||||
<li class="list-group-item text-muted fst-italic">No groups nested here.</li>
|
||||
{{ /hasNested }}
|
||||
</ul>
|
||||
<div class="dropdown mt-2">
|
||||
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<i class="fa-solid fa-diagram-project"></i> Nest a group
|
||||
</button>
|
||||
<div class="dropdown-menu" style="max-height: 300px; overflow-y: auto;">
|
||||
{{ #toNest }}
|
||||
<a class="dropdown-item" href="#" onclick="addNestedClick(event, '{{groupCN}}', '{{cn}}', this)">{{ cn }}</a>
|
||||
{{ /toNest }}
|
||||
</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') %>
|
||||
@@ -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() {
|
||||
loadDashboard();
|
||||
loadHistory();
|
||||
toggleFilterInputs();
|
||||
loadTos();
|
||||
loadMetrics();
|
||||
});
|
||||
</script>
|
||||
@@ -385,30 +345,6 @@
|
||||
</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 -->
|
||||
<div class="card shadow mb-5">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
|
||||
+365
-53
@@ -3,54 +3,73 @@
|
||||
<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" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link active" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="card shadow border-top-0">
|
||||
<div class="card shadow">
|
||||
<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
|
||||
<i class="fa-solid fa-plug"></i> Plugins
|
||||
</div>
|
||||
<div class="d-flex gap-2 align-items-center">
|
||||
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewPluginModal()">
|
||||
<i class="fas fa-plus"></i> New Plugin
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> View configured background plugins and scheduler status. Note: Plugins are configured statically in <code>sso-secrets.js</code>.
|
||||
<i class="fa-solid fa-circle-info"></i> Configured plugin instances. Each is a loadable, scheduled copy of a
|
||||
plugin type (e.g. Proxmox, UniFi, Nmap) — you can run several of the same type with different settings.
|
||||
Secrets are stored in OpenBao and shown masked. <a href="/docs/plugins">Learn more</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>Details</th>
|
||||
<th class="ps-3">Name</th>
|
||||
<th>Type</th>
|
||||
<th>Schedule</th>
|
||||
<th>State</th>
|
||||
<th>Last Run</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="plugins-list" jq-repeat="plugins">
|
||||
<tr>
|
||||
<td class="ps-3 fw-bold">{{name}}</td>
|
||||
<tr id="plugin-row-{{id}}">
|
||||
<td class="ps-3">
|
||||
<strong>{{name}}</strong>
|
||||
<div class="small text-muted font-monospace">{{slug}}</div>
|
||||
</td>
|
||||
<td><span class="badge bg-secondary">{{pluginType}}</span></td>
|
||||
<td><code class="text-dark">{{cron}}</code></td>
|
||||
<td>
|
||||
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||
{{#enabled}}<span class="badge bg-success">Loaded</span>{{/enabled}}
|
||||
{{^enabled}}<span class="badge bg-secondary">Unloaded</span>{{/enabled}}
|
||||
</td>
|
||||
<td class="small text-muted font-monospace">
|
||||
{{details}}
|
||||
<td class="small">
|
||||
{{#lastRunAt}}<span title="{{lastRunAt}}">{{lastRunFmt}}</span>{{/lastRunAt}}
|
||||
{{^lastRunAt}}<span class="text-muted">never</span>{{/lastRunAt}}
|
||||
{{#lastStatus}}
|
||||
{{#isOk}}<span class="badge bg-success-subtle text-success-emphasis ms-1">ok</span>{{/isOk}}
|
||||
{{#isError}}<span class="badge bg-danger-subtle text-danger-emphasis ms-1" title="{{lastError}}">error</span>{{/isError}}
|
||||
{{#isRunning}}<span class="badge bg-info-subtle text-info-emphasis ms-1">running</span>{{/isRunning}}
|
||||
{{/lastStatus}}
|
||||
</td>
|
||||
<td>
|
||||
<button class="btn btn-sm btn-primary" title="Edit" onclick="openEditModal('{{id}}')"><i class="fa-solid fa-pen"></i></button>
|
||||
<button class="btn btn-sm btn-warning" title="Edit Secrets" onclick="openSecretsModal('{{id}}')"><i class="fa-solid fa-key"></i></button>
|
||||
<button class="btn btn-sm btn-info" title="Test" onclick="testPlugin('{{id}}')"><i class="fa-solid fa-vial"></i></button>
|
||||
<button class="btn btn-sm btn-success" title="Run now" onclick="runNow('{{id}}')"><i class="fa-solid fa-play"></i></button>
|
||||
{{#lastRunAt}}<button class="btn btn-sm btn-secondary" title="View Logs" onclick="showLogs('{{id}}')"><i class="fa-solid fa-file-lines"></i></button>{{/lastRunAt}}
|
||||
{{#enabled}}<button class="btn btn-sm btn-outline-danger" title="Unload" onclick="togglePlugin('{{id}}', false)">Unload</button>{{/enabled}}
|
||||
{{^enabled}}<button class="btn btn-sm btn-outline-success" title="Load" onclick="togglePlugin('{{id}}', true)">Load</button>{{/enabled}}
|
||||
<button class="btn btn-sm btn-outline-danger" title="Delete" onclick="deletePlugin('{{id}}')"><i class="fa-solid fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
<tbody id="empty-state" style="display: none;">
|
||||
<tbody id="plugins-empty-state" style="display: none;">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4 text-muted">
|
||||
No plugins configured in sso-secrets.js
|
||||
<td colspan="6" class="text-center py-5 text-muted">
|
||||
<i class="fa-solid fa-plug fs-2 mb-3 text-black-50"></i>
|
||||
<h5>No plugin instances</h5>
|
||||
<p>Click <strong>New Plugin</strong> to configure one.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -64,39 +83,332 @@
|
||||
<script>
|
||||
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
|
||||
|
||||
// type -> manifest (configSchema etc.), loaded once for the New-plugin form.
|
||||
var pluginTypes = {};
|
||||
// id -> instance (plain), kept current after each load so modals can resolve a row.
|
||||
var pluginsById = {};
|
||||
|
||||
$(document).ready(function() {
|
||||
app.api.get('plugins/types', function(err, res) {
|
||||
if (err) { app.messages.toast('Error loading plugin types: ' + (err.message || err), 'danger'); return; }
|
||||
(res.results || []).forEach(function(t) { pluginTypes[t.type] = t; });
|
||||
});
|
||||
loadPlugins();
|
||||
});
|
||||
|
||||
function fmtRun(ms) {
|
||||
if (!ms) return '';
|
||||
var d = new Date(Number(ms));
|
||||
return moment(d).fromNow();
|
||||
}
|
||||
|
||||
function loadPlugins() {
|
||||
app.api.get('plugins', function(err, res) {
|
||||
if(err) {
|
||||
app.messages.toast("Error loading plugins: " + (err.message || err));
|
||||
return;
|
||||
}
|
||||
const plugins = res.results || {};
|
||||
const pluginNames = Object.keys(plugins);
|
||||
|
||||
if (err) { app.messages.toast('Error loading plugins: ' + (err.message || err), 'danger'); return; }
|
||||
var list = res.results || [];
|
||||
pluginsById = {};
|
||||
$.scope.plugins.empty();
|
||||
if(pluginNames.length === 0) {
|
||||
if (!list.length) {
|
||||
$('#plugins-list').hide();
|
||||
$('#empty-state').show();
|
||||
$('#plugins-empty-state').show();
|
||||
} else {
|
||||
pluginNames.forEach(name => {
|
||||
const config = plugins[name];
|
||||
const details = Object.entries(config)
|
||||
.filter(([k, v]) => k !== 'enabled' && k !== 'cron')
|
||||
.map(([k, v]) => `${k}: ${v}`)
|
||||
.join(', ');
|
||||
|
||||
$.scope.plugins.push({
|
||||
name: name,
|
||||
cron: config.cron || 'N/A',
|
||||
enabled: config.enabled,
|
||||
details: details
|
||||
});
|
||||
list.forEach(function(p) {
|
||||
pluginsById[p.id] = p;
|
||||
p.lastRunFmt = fmtRun(p.lastRunAt);
|
||||
p.isOk = p.lastStatus === 'ok';
|
||||
p.isError = p.lastStatus === 'error';
|
||||
p.isRunning = p.lastStatus === 'running';
|
||||
$.scope.plugins.push(p);
|
||||
});
|
||||
$('#plugins-list').show();
|
||||
$('#empty-state').hide();
|
||||
$('#plugins-empty-state').hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
|
||||
// the field ids so the New and Edit modals don't collide. `values` (optional)
|
||||
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
|
||||
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
|
||||
// secret fields are never shown there (secrets have their own modal); the New
|
||||
// modal passes true so initial secrets can be set at create time.
|
||||
function configFormHtml(type, prefix, values, includeSecrets) {
|
||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||
if (includeSecrets === undefined) includeSecrets = true;
|
||||
var v = values || {};
|
||||
var html = '';
|
||||
schema.forEach(function(f) {
|
||||
if (!includeSecrets && f.secret) return;
|
||||
var val = v[f.key];
|
||||
if (f.secret) val = '';
|
||||
if (val === undefined || val === null) val = '';
|
||||
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
|
||||
var req = f.required ? ' required' : '';
|
||||
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
|
||||
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
|
||||
html += '<div class="mb-3">' +
|
||||
'<label class="form-label">' + label + '</label>' +
|
||||
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '"') + '"' + req + ph + '>';
|
||||
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
|
||||
html += '</div>';
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
|
||||
// The stored value is always a 5-field cron string. A `<select>` picks a
|
||||
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
|
||||
// element ids (np-/ed-) so the two modals don't collide.
|
||||
var CRON_PRESETS = [
|
||||
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
|
||||
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
|
||||
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
|
||||
{ key: 'custom', label: 'Custom…', cron: null },
|
||||
];
|
||||
function cronKeyFor(cron) {
|
||||
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
|
||||
return m ? m.key : 'custom';
|
||||
}
|
||||
function cronSelectHtml(prefix, current) {
|
||||
current = current || '0 * * * *';
|
||||
var key = cronKeyFor(current);
|
||||
var opts = CRON_PRESETS.map(function(p){
|
||||
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
|
||||
}).join('');
|
||||
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
|
||||
var rawVal = key === 'custom' ? current : current;
|
||||
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
|
||||
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
|
||||
}
|
||||
function onCronChange(prefix) {
|
||||
var sel = document.getElementById(prefix + 'cron-select');
|
||||
var raw = document.getElementById(prefix + 'cron');
|
||||
if (!sel || !raw) return;
|
||||
if (sel.value === 'custom') {
|
||||
raw.style.display = '';
|
||||
} else {
|
||||
raw.style.display = 'none';
|
||||
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||
if (preset) raw.value = preset.cron;
|
||||
}
|
||||
}
|
||||
function cronFromForm(prefix) {
|
||||
var sel = document.getElementById(prefix + 'cron-select');
|
||||
if (sel && sel.value !== 'custom') {
|
||||
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
|
||||
if (preset) return preset.cron;
|
||||
}
|
||||
var raw = document.getElementById(prefix + 'cron');
|
||||
return (raw && raw.value.trim()) || '0 * * * *';
|
||||
}
|
||||
|
||||
// Collect a flat {field: value} object from the rendered config form.
|
||||
function collectConfig(type, prefix) {
|
||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||
var out = {};
|
||||
if (!schema) return out;
|
||||
schema.forEach(function(f) {
|
||||
var el = document.getElementById(prefix + f.key);
|
||||
if (el) out[f.key] = el.value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function typeOptionsHtml(selected) {
|
||||
var opts = '<option value="">Select a plugin type…</option>';
|
||||
Object.keys(pluginTypes).sort().forEach(function(t) {
|
||||
opts += '<option value="' + t + '"' + (t === selected ? ' selected' : '') + '>' + pluginTypes[t].name + ' (' + t + ')</option>';
|
||||
});
|
||||
return opts;
|
||||
}
|
||||
|
||||
// --- New Plugin modal ---
|
||||
function openNewPluginModal() {
|
||||
app.modal.open({
|
||||
title: 'New Plugin',
|
||||
bodyHtml:
|
||||
'<div class="actionMessage mb-3" style="display:none"></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Plugin Type <span class="text-danger">*</span></label>' +
|
||||
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
||||
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||
cronSelectHtml('np-', '0 * * * *') +
|
||||
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
|
||||
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
|
||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
|
||||
});
|
||||
}
|
||||
|
||||
function renderNewPluginFields() {
|
||||
var type = document.getElementById('np-type').value;
|
||||
document.getElementById('np-config-fields').innerHTML = configFormHtml(type, 'np-');
|
||||
}
|
||||
|
||||
async function saveNewPlugin() {
|
||||
var type = document.getElementById('np-type').value;
|
||||
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
|
||||
var name = document.getElementById('np-name').value.trim();
|
||||
var cron = cronFromForm('np-');
|
||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||
var config = collectConfig(type, 'np-');
|
||||
try {
|
||||
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
|
||||
app.modal.close();
|
||||
app.messages.toast('Plugin created and scheduled.', 'success');
|
||||
loadPlugins();
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Failed to create plugin', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit (non-secret) modal ---
|
||||
function openEditModal(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
app.modal.open({
|
||||
title: 'Edit — ' + p.name,
|
||||
bodyHtml:
|
||||
'<div class="actionMessage mb-3" style="display:none"></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
|
||||
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '"') + '"></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
|
||||
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Schedule</label>' +
|
||||
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
|
||||
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
|
||||
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
|
||||
footer: {
|
||||
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
|
||||
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit(\'' + id + '\')', saveLabel: 'Save' })
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function saveEdit(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
var name = document.getElementById('ed-name').value.trim();
|
||||
var cron = cronFromForm('ed-');
|
||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||
var config = collectConfig(p.pluginType, 'ed-');
|
||||
try {
|
||||
await app.api.put('plugins/' + id, { name: name, cron: cron, config: config });
|
||||
app.modal.close();
|
||||
app.messages.toast('Plugin saved.', 'success');
|
||||
loadPlugins();
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
// --- Edit Secrets modal ---
|
||||
function openSecretsModal(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
var masked = p.secrets || {};
|
||||
// Render only the secret fields, prefilled with the masked values.
|
||||
var schema = (pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema) || [];
|
||||
var secretFields = schema.filter(function(f) { return f.secret; });
|
||||
var html = '<div class="actionMessage mb-3" style="display:none"></div>' +
|
||||
'<p class="text-muted small">Stored in OpenBao. Leave a field blank to keep its current value.</p>';
|
||||
if (!secretFields.length) {
|
||||
html += '<p class="text-muted">This plugin has no secret fields.</p>';
|
||||
} else {
|
||||
secretFields.forEach(function(f) {
|
||||
var val = masked[f.key] || '';
|
||||
html += '<div class="mb-3"><label class="form-label">' + f.label + '</label>' +
|
||||
'<input type="password" class="form-control" id="sec-' + f.key + '" value="' + String(val).replace(/"/g, '"') + '" placeholder="' + (val ? '******** (unchanged)' : 'new value') + '"></div>';
|
||||
});
|
||||
}
|
||||
app.modal.open({
|
||||
title: 'Edit Secrets — ' + p.name,
|
||||
bodyHtml: html,
|
||||
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveSecrets(\'' + id + '\')', saveLabel: 'Save Secrets' }) }
|
||||
});
|
||||
}
|
||||
|
||||
async function saveSecrets(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
var schema = pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema;
|
||||
var secrets = {};
|
||||
if (schema) {
|
||||
schema.forEach(function(f) {
|
||||
if (!f.secret) return;
|
||||
var el = document.getElementById('sec-' + f.key);
|
||||
if (el) secrets[f.key] = el.value;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await app.api.put('plugins/' + id + '/secrets', secrets);
|
||||
app.modal.close();
|
||||
app.messages.toast('Secrets saved.', 'success');
|
||||
loadPlugins();
|
||||
} catch (err) {
|
||||
app.messages.action(err.message || 'Failed to save secrets', app.modal.body(), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function testPlugin(id) {
|
||||
try {
|
||||
var res = await app.api.post('plugins/' + id + '/test', {});
|
||||
app.messages.toast('Test passed.', 'success');
|
||||
} catch (err) {
|
||||
app.messages.toast('Test failed: ' + (err.message || 'validation failed'), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function runNow(id) {
|
||||
try {
|
||||
await app.api.post('plugins/' + id + '/run', {});
|
||||
app.messages.toast('Run enqueued. Refresh shortly for status.', 'info');
|
||||
setTimeout(loadPlugins, 3000);
|
||||
} catch (err) {
|
||||
app.messages.toast('Failed to run: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function showLogs(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
try {
|
||||
const res = await app.api.get('plugins/' + id + '/runs');
|
||||
const logText = (res.results && res.results.lastLog) || (res.results && res.results.lastError) || 'No logs available.';
|
||||
app.modal.open({
|
||||
title: 'Logs — ' + p.name,
|
||||
bodyHtml: '<pre class="bg-dark text-white p-3 rounded" style="white-space: pre-wrap; font-size: 0.85em;">' + String(logText).replace(/</g, '<').replace(/>/g, '>') + '</pre>',
|
||||
footer: { buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>' }
|
||||
});
|
||||
} catch (err) {
|
||||
app.messages.toast('Failed to load logs: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(id, enable) {
|
||||
try {
|
||||
await app.api.post('plugins/' + id + (enable ? '/load' : '/unload'), {});
|
||||
app.messages.toast(enable ? 'Plugin loaded.' : 'Plugin unloaded.', 'success');
|
||||
loadPlugins();
|
||||
} catch (err) {
|
||||
app.messages.toast('Failed: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
async function deletePlugin(id) {
|
||||
var p = pluginsById[id];
|
||||
if (!p) return;
|
||||
var ok = await app.messages.confirm('Delete plugin "' + p.name + '"? Its schedule and OpenBao secrets will be removed.', app.modal.body ? app.modal.body() : null, 'danger');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await app.api.delete('plugins/' + id);
|
||||
app.messages.toast('Plugin deleted.', 'success');
|
||||
loadPlugins();
|
||||
} catch (err) {
|
||||
app.messages.toast('Failed to delete: ' + (err.message || err), 'danger');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
<%- include('bottom') %>
|
||||
+23
-22
@@ -9,6 +9,7 @@
|
||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||
$('#profile-uid-header').text(user.uid);
|
||||
$.scope.user.update(user);
|
||||
};
|
||||
|
||||
@@ -241,7 +242,7 @@
|
||||
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<i class="fa-regular fa-id-card"></i>
|
||||
Profile: <strong>{{user.uid}}</strong>
|
||||
Profile: <strong id="profile-uid-header"></strong>
|
||||
</div>
|
||||
<div class="d-flex gap-2">
|
||||
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
||||
@@ -278,7 +279,7 @@
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
||||
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
|
||||
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -329,27 +330,27 @@
|
||||
<p class="text-muted small mb-0">
|
||||
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<h6 class="text-muted">Admin Actions</h6>
|
||||
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
||||
{{#isActive}}
|
||||
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i> Deactivate
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i> Activate
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i> Impersonate
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i> Delete User
|
||||
</button>
|
||||
<div class="mt-3 border-top pt-3">
|
||||
<h6 class="text-muted">Admin Actions</h6>
|
||||
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
||||
{{#isActive}}
|
||||
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
||||
<i class="fa-solid fa-lock"></i> Deactivate
|
||||
</button>
|
||||
{{/isActive}}
|
||||
{{#isInactive}}
|
||||
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
||||
<i class="fa-solid fa-lock-open"></i> Activate
|
||||
</button>
|
||||
{{/isInactive}}
|
||||
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
||||
<i class="fa-solid fa-user-secret"></i> Impersonate
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||
<i class="fa-solid fa-user-slash"></i> Delete User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</ul>
|
||||
<div class="form-inline mt-2 mt-md-0">
|
||||
<% if(ui.profileUrl){ %>
|
||||
<a id="cl-username" class="navbar-text text-light me-3" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<a id="cl-username" class="navbar-text text-light me-3 text-decoration-none" href="<%- ui.profileUrl %>" style="display: none;">
|
||||
<i class="fa-solid fa-user me-1"></i><span id="cl-username-text"></span>
|
||||
</a>
|
||||
<% } else { %>
|
||||
|
||||
+309
-43
@@ -1,31 +1,29 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2><i class="fas fa-lock"></i>
|
||||
<% if (vaultIsAdmin) { %> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>
|
||||
<% } else { %> My Secrets <small class="text-muted">(personal namespace)</small><% } %>
|
||||
</h2>
|
||||
<ul class="nav nav-pills" id="vault-tabs">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
||||
<% if (vaultIsAdmin) { %>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||
<% } %>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="tab-content">
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow">
|
||||
<div class="card-header">
|
||||
<ul class="nav nav-tabs card-header-tabs" id="vault-tabs" role="tablist">
|
||||
<li class="nav-item"><button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-secrets" type="button"><i class="fa-solid fa-lock"></i> Secrets</button></li>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-apps" type="button"><i class="fa-solid fa-key"></i> Apps</button></li>
|
||||
<li class="nav-item"><button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-shared" type="button"><i class="fa-solid fa-share-nodes"></i> Shared</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="tab-content">
|
||||
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade show active" id="tab-secrets">
|
||||
<div class="d-flex justify-content-end mb-3">
|
||||
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||
<i class="fas fa-plus"></i> New Secret
|
||||
</button>
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h5 class="mb-0" id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h5>
|
||||
<button class="btn btn-primary btn-sm" onclick="showCreateModal()"><i class="fas fa-plus"></i> New Secret</button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="p-3">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
|
||||
<div class="card-header"><h5 class="card-title mb-0">Secrets List</h5></div>
|
||||
<div class="list-group list-group-flush" id="secrets-list">
|
||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||
</div>
|
||||
@@ -33,7 +31,7 @@
|
||||
</div>
|
||||
<div class="col-md-8">
|
||||
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||
<div>
|
||||
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||
@@ -49,18 +47,20 @@
|
||||
<h4>Select a secret to view its details</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Apps tab (admin only) ───────────────────────────────────────── -->
|
||||
<% if (vaultIsAdmin) { %>
|
||||
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
|
||||
<div class="tab-pane fade" id="tab-apps">
|
||||
<div class="row">
|
||||
<div class="p-3">
|
||||
<div class="row">
|
||||
<div class="col-md-5">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
||||
<div class="card-header"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
||||
<div class="card-body">
|
||||
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
||||
<p class="text-muted small">The token is periodic: it stays valid as long as the app renews it within its period (<code>POST /v1/auth/token/renew-self</code>). If it lapses, mint a new one here — the app's policy and stored secrets are kept.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
||||
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
||||
@@ -72,7 +72,7 @@
|
||||
</div>
|
||||
<div class="col-md-7">
|
||||
<div class="card shadow-sm d-none" id="app-result-card">
|
||||
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">App token</h5>
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||
</div>
|
||||
@@ -87,9 +87,110 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
|
||||
<!-- ── Shared tab ─────────────────────────────────────────────────── -->
|
||||
<div class="tab-pane fade" id="tab-shared">
|
||||
<div class="p-3">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title mb-0">My shared secrets</h5>
|
||||
<button class="btn btn-sm btn-primary" onclick="showCreateSharedModal()"><i class="fas fa-plus"></i> New</button>
|
||||
</div>
|
||||
<div class="list-group list-group-flush" id="shared-mine-list">
|
||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header"><h5 class="card-title mb-0">Shared with me</h5></div>
|
||||
<div class="list-group list-group-flush" id="shared-granted-list">
|
||||
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Shared Secret Modal -->
|
||||
<div class="modal fade" id="sharedCreateModal" tabindex="-1">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">New Shared Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Name (slug)</label>
|
||||
<input type="text" class="form-control" id="shared-slug-input" placeholder="e.g. db-creds">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Description</label>
|
||||
<input type="text" class="form-control" id="shared-desc-input" placeholder="optional">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret Data (JSON)</label>
|
||||
<textarea class="form-control" id="shared-data-input" rows="6" style="font-family: monospace;">{
|
||||
"key": "value"
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="shared-create-error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSharedSecret()">Create</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Manage Grants Modal -->
|
||||
<div class="modal fade" id="sharedGrantsModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">Share</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="row g-2 mb-3">
|
||||
<div class="col-4"><select class="form-select" id="grant-type-input"><option value="user">User</option><option value="app">App</option></select></div>
|
||||
<div class="col-5"><input class="form-control" id="grant-id-input" placeholder="uid or app name"></div>
|
||||
<div class="col-3"><button class="btn btn-primary w-100" onclick="addGrant()">Grant</button></div>
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="grants-error"></div>
|
||||
<div class="list-group" id="grants-list"><div class="list-group-item text-muted">No grants yet.</div></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Shared Secret Modal -->
|
||||
<div class="modal fade" id="sharedViewModal" tabindex="-1">
|
||||
<div class="modal-dialog modal-lg">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="shared-view-title">Secret</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
</div>
|
||||
<div class="modal-body"><pre id="shared-view-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,10 +204,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">
|
||||
<% if (vaultIsAdmin) { %>Secret path (under secret/)<% } else { %>Secret name (in your personal namespace)<% } %>
|
||||
</label>
|
||||
<input type="text" class="form-control" id="secret-path-input" placeholder="<% if (vaultIsAdmin) { %>e.g. apps/my-service/conf<% } else { %>e.g. database-creds<% } %>">
|
||||
<label class="form-label" id="secret-path-label">Secret name (in your personal namespace)</label>
|
||||
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret Data (JSON)</label>
|
||||
@@ -126,14 +225,15 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
|
||||
<script>
|
||||
app.auth.forceLogin();
|
||||
|
||||
// Server-derived scoping. VAULT_BASE is '' for admins (free-form under
|
||||
// secret/) or 'users/<uid>/' for everyone else (confined to their personal
|
||||
// namespace). The /api/vault proxy enforces the same server-side; these only
|
||||
// drive the UI.
|
||||
const VAULT_BASE = <%- JSON.stringify(vaultBase) %>;
|
||||
const IS_ADMIN = <%- JSON.stringify(vaultIsAdmin) %>;
|
||||
// Login gate + client-derived scoping. VAULT_BASE is '' for admins
|
||||
// (free-form under secret/) or 'users/<uid>/' for everyone else (confined
|
||||
// to their personal namespace). The /api/vault proxy enforces the same
|
||||
// server-side (scopeGuard + the token's OpenBao policy), so this only
|
||||
// drives the UI. Resolved in init() after forceLogin loads the user — the
|
||||
// previous version read these server-side from req.user, which is undefined
|
||||
// on a browser navigation (auth-token is a client-set header, not a cookie).
|
||||
let VAULT_BASE = '';
|
||||
let IS_ADMIN = false;
|
||||
|
||||
let currentSecretPath = null;
|
||||
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||
@@ -142,7 +242,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
// key relative to the subject's namespace (so 'foo' for a user means
|
||||
// secret/data/users/<uid>/foo).
|
||||
function vpath(kind, key) {
|
||||
return `secret/${kind}/${VAULT_BASE}${key}`;
|
||||
let cleanKey = key || '';
|
||||
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
|
||||
if (VAULT_BASE) {
|
||||
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
|
||||
}
|
||||
return `secret/${kind}/${cleanKey}`;
|
||||
}
|
||||
|
||||
function apiCall(method, path, body = null) {
|
||||
@@ -164,7 +269,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
|
||||
async function loadSecrets() {
|
||||
try {
|
||||
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
||||
const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
|
||||
const res = await apiCall('GET', listPath);
|
||||
const listEl = document.getElementById('secrets-list');
|
||||
listEl.innerHTML = '';
|
||||
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||
@@ -309,7 +415,167 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||
}
|
||||
|
||||
loadSecrets();
|
||||
// ── Shared secrets tab ──────────────────────────────────────────────
|
||||
let currentShared = null;
|
||||
const sharedCreateModal = new bootstrap.Modal(document.getElementById('sharedCreateModal'));
|
||||
const sharedGrantsModal = new bootstrap.Modal(document.getElementById('sharedGrantsModal'));
|
||||
const sharedViewModal = new bootstrap.Modal(document.getElementById('sharedViewModal'));
|
||||
|
||||
function sharedApi(path, method = 'GET', body = null) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() } };
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
return fetch('/api/shared-secrets' + path, opts).then(async res => {
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) { const t = await res.text(); throw new Error(`${res.status} ${t}`); }
|
||||
if (res.status === 204) return null;
|
||||
return res.json();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadShared() {
|
||||
try {
|
||||
const res = await sharedApi('/');
|
||||
const items = (res && res.items) || [];
|
||||
renderSharedMine(items.filter(i => i.role === 'owner'));
|
||||
renderSharedGranted(items.filter(i => i.role === 'grantee'));
|
||||
} catch (err) {
|
||||
document.getElementById('shared-mine-list').innerHTML =
|
||||
`<div class="list-group-item text-danger">Error: ${err.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderSharedMine(items) {
|
||||
const el = document.getElementById('shared-mine-list');
|
||||
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">No shared secrets yet</div>'; return; }
|
||||
el.innerHTML = '';
|
||||
items.forEach(s => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
row.innerHTML = `<div><i class="fas fa-share-alt text-secondary me-2"></i><strong>${s.slug}</strong><div class="small text-muted">${s.path}</div></div>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-primary" onclick="openGrants('${s.id}')"><i class="fas fa-users"></i> Share</button>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="deleteShared('${s.id}')"><i class="fas fa-trash"></i></button>
|
||||
</div>`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSharedGranted(items) {
|
||||
const el = document.getElementById('shared-granted-list');
|
||||
if (!items.length) { el.innerHTML = '<div class="list-group-item text-center text-muted">Nothing shared with you yet</div>'; return; }
|
||||
el.innerHTML = '';
|
||||
items.forEach(s => {
|
||||
const row = document.createElement('a');
|
||||
row.href = '#';
|
||||
row.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||
row.innerHTML = `<i class="fas fa-key text-secondary me-3"></i><span>${s.slug}</span><small class="text-muted ms-auto">by ${s.ownerUid}</small>`;
|
||||
row.onclick = (e) => { e.preventDefault(); viewShared(s); };
|
||||
el.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function showCreateSharedModal() {
|
||||
currentShared = null;
|
||||
document.getElementById('shared-slug-input').value = '';
|
||||
document.getElementById('shared-desc-input').value = '';
|
||||
document.getElementById('shared-data-input').value = '{\n "key": "value"\n}';
|
||||
document.getElementById('shared-create-error').classList.add('d-none');
|
||||
sharedCreateModal.show();
|
||||
}
|
||||
|
||||
async function saveSharedSecret() {
|
||||
const err = document.getElementById('shared-create-error');
|
||||
err.classList.add('d-none');
|
||||
let data;
|
||||
try { data = JSON.parse(document.getElementById('shared-data-input').value); }
|
||||
catch (e) { err.textContent = 'Invalid JSON: ' + e.message; err.classList.remove('d-none'); return; }
|
||||
try {
|
||||
await sharedApi('/', 'POST', {
|
||||
slug: document.getElementById('shared-slug-input').value.trim(),
|
||||
description: document.getElementById('shared-desc-input').value.trim(),
|
||||
data
|
||||
});
|
||||
sharedCreateModal.hide();
|
||||
await loadShared();
|
||||
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||
}
|
||||
|
||||
async function viewShared(s) {
|
||||
document.getElementById('shared-view-title').textContent = s.slug + ' (by ' + s.ownerUid + ')';
|
||||
document.getElementById('shared-view-content').textContent = 'Loading...';
|
||||
sharedViewModal.show();
|
||||
try {
|
||||
const res = await apiCall('GET', 'secret/data/' + s.path);
|
||||
document.getElementById('shared-view-content').textContent =
|
||||
(res && res.data && res.data.data) ? JSON.stringify(res.data.data, null, 2) : 'No data found.';
|
||||
} catch (e) {
|
||||
document.getElementById('shared-view-content').textContent = 'Error: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function openGrants(id) {
|
||||
currentShared = id;
|
||||
document.getElementById('grants-error').classList.add('d-none');
|
||||
document.getElementById('grant-id-input').value = '';
|
||||
sharedGrantsModal.show();
|
||||
try {
|
||||
const res = await sharedApi('/' + id + '/grants');
|
||||
const grants = (res && res.grants) || [];
|
||||
const el = document.getElementById('grants-list');
|
||||
el.innerHTML = '';
|
||||
if (!grants.length) el.innerHTML = '<div class="list-group-item text-muted">No grants yet.</div>';
|
||||
grants.forEach(g => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'list-group-item d-flex justify-content-between align-items-center';
|
||||
row.innerHTML = `<span><span class="badge bg-secondary me-2">${g.granteeType}</span>${g.granteeId}</span>
|
||||
<button class="btn btn-sm btn-outline-danger" onclick="revokeGrant('${g.id}')"><i class="fas fa-times"></i></button>`;
|
||||
el.appendChild(row);
|
||||
});
|
||||
} catch (e) {
|
||||
document.getElementById('grants-list').innerHTML = `<div class="list-group-item text-danger">${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function addGrant() {
|
||||
const err = document.getElementById('grants-error');
|
||||
err.classList.add('d-none');
|
||||
try {
|
||||
await sharedApi('/' + currentShared + '/grants', 'POST', {
|
||||
granteeType: document.getElementById('grant-type-input').value,
|
||||
granteeId: document.getElementById('grant-id-input').value.trim()
|
||||
});
|
||||
document.getElementById('grant-id-input').value = '';
|
||||
openGrants(currentShared);
|
||||
} catch (e) { err.textContent = e.message; err.classList.remove('d-none'); }
|
||||
}
|
||||
|
||||
async function revokeGrant(grantId) {
|
||||
try { await sharedApi('/' + currentShared + '/grants/' + grantId, 'DELETE'); openGrants(currentShared); }
|
||||
catch (e) { app.messages.toast('Error revoking: ' + e.message, 'danger'); }
|
||||
}
|
||||
|
||||
async function deleteShared(id) {
|
||||
const confirmed = await app.messages.confirm('Delete this shared secret? Grantees will immediately lose access.', $('#shared-mine-list'), 'warning');
|
||||
if (!confirmed) return;
|
||||
try { await sharedApi('/' + id, 'DELETE'); await loadShared(); }
|
||||
catch (e) { app.messages.toast('Error deleting: ' + e.message, 'danger'); }
|
||||
}
|
||||
|
||||
(async function init() {
|
||||
const user = await app.auth.forceLogin();
|
||||
if (!user) return; // not logged in — forceLogin redirected to /login
|
||||
IS_ADMIN = app.auth.isAdmin();
|
||||
VAULT_BASE = IS_ADMIN ? '' : 'users/' + user.uid + '/';
|
||||
if (IS_ADMIN) {
|
||||
document.getElementById('vault-apps-tab').style.display = '';
|
||||
document.getElementById('vault-title').innerHTML =
|
||||
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
|
||||
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
|
||||
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
|
||||
}
|
||||
loadSecrets();
|
||||
loadShared();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user