Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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:
|
||||
|
||||
+181
@@ -4,6 +4,187 @@ 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-2
@@ -20,7 +20,7 @@ When the environment is initialized via `setup.sh`, OpenBao is automatically uns
|
||||
|
||||
The SSO Manager Vault can be accessed in two ways:
|
||||
|
||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
|
||||
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
|
||||
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||
|
||||
### API Example
|
||||
@@ -36,4 +36,10 @@ Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using th
|
||||
|
||||
## 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.
|
||||
Plugin instances store their per-instance secrets in OpenBao at
|
||||
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
|
||||
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
|
||||
in-process, so the SSO Manager reads/writes those secrets server-side through
|
||||
the `sso-broker` token; the admin UI only ever sees masked values, and external
|
||||
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
|
||||
consistently enforced instead of hardcoding them.
|
||||
|
||||
+4
-1
@@ -107,7 +107,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
|
||||
|
||||
+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).
|
||||
@@ -16,6 +16,7 @@ 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');
|
||||
async function initORM() {
|
||||
const ormConf = conf.orm || {
|
||||
dialect: 'sqlite',
|
||||
@@ -29,7 +30,7 @@ async function initORM() {
|
||||
await init({
|
||||
conf: { orm: ormConf },
|
||||
models: [
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook,
|
||||
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
|
||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||
]
|
||||
});
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
'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' },
|
||||
// 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 };
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.16.0",
|
||||
"version": "1.17.2",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,30 @@ 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");
|
||||
@@ -42,10 +66,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,6 +7,33 @@ const agent = new https.Agent({
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
|
||||
// drives the admin UI form and validation; fields flagged `secret:true` are
|
||||
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
|
||||
type: 'proxmox',
|
||||
category: 'discovery',
|
||||
name: 'Proxmox VE',
|
||||
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
|
||||
configSchema: [
|
||||
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
|
||||
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
|
||||
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
||||
],
|
||||
|
||||
// "Test" button in the UI: hit the unauthenticated version endpoint with the
|
||||
// API token to confirm the URL + token are valid before scheduling runs.
|
||||
validate: async (config) => {
|
||||
const { url, tokenId, tokenSecret } = config;
|
||||
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
|
||||
try {
|
||||
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
|
||||
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err.message };
|
||||
}
|
||||
},
|
||||
|
||||
discover: async (config) => {
|
||||
const { url, tokenId, tokenSecret } = config;
|
||||
if (!url || !tokenId || !tokenSecret) {
|
||||
@@ -151,5 +178,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)
|
||||
};
|
||||
|
||||
@@ -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,18 +59,31 @@ 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) {
|
||||
|
||||
@@ -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 } });
|
||||
} 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;
|
||||
@@ -34,7 +34,8 @@ 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')},
|
||||
|
||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||
|
||||
+25
-26
@@ -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,28 @@ router.get('/discovery', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
router.get('/plugins', function(req, res, next) {
|
||||
// Plugin instances page — loadable/unloadable, configurable plugin copies
|
||||
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
|
||||
// client gates with app.auth.forceLogin(['app_sso_admin',
|
||||
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
|
||||
// the same server-side. Same header-vs-navigation auth model as /conf and
|
||||
// /vault (auth-token is a client-set header, not a cookie).
|
||||
res.render('plugins', {...values});
|
||||
});
|
||||
|
||||
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 +140,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});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
};
|
||||
+181
-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,180 @@ 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 });
|
||||
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);
|
||||
const payload = await runFn(cfg);
|
||||
if (instance.category === 'discovery') {
|
||||
await DiscoveryReconciler.reconcile(instance.slug, payload);
|
||||
}
|
||||
await instance.update({ lastStatus: STATUS.OK, lastError: null });
|
||||
} 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) });
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
};
|
||||
@@ -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,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 };
|
||||
@@ -46,6 +46,7 @@ module.exports = {
|
||||
{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: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
|
||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||
],
|
||||
|
||||
@@ -54,11 +54,14 @@ async function bao(method, path, body) {
|
||||
return res;
|
||||
}
|
||||
|
||||
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
||||
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
|
||||
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
|
||||
// a list grant on a directory path) propagate on the next vault-page visit
|
||||
// without an operator re-running setup.sh. Skipping on an existing policy
|
||||
// would strand the old, narrower HCL forever.
|
||||
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}`);
|
||||
}
|
||||
@@ -80,7 +83,11 @@ async function mintToken(policies) {
|
||||
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.
|
||||
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
|
||||
// contents of the namespace: `.../*` covers nested paths but NOT the
|
||||
// directory itself, so without it the /vault secrets list 403s.
|
||||
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
@@ -109,7 +116,10 @@ async function getOrCreateAdminToken(uid) {
|
||||
|
||||
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||
function appPolicyHcl(name) {
|
||||
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
|
||||
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
|
||||
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
|
||||
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||
}
|
||||
|
||||
|
||||
+115
-4
@@ -4,6 +4,7 @@
|
||||
|
||||
$(document).ready(function() {
|
||||
loadConf();
|
||||
loadTos();
|
||||
});
|
||||
|
||||
async function loadConf() {
|
||||
@@ -28,6 +29,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');
|
||||
}
|
||||
@@ -53,6 +61,11 @@
|
||||
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()
|
||||
}
|
||||
};
|
||||
|
||||
@@ -74,6 +87,50 @@
|
||||
el.type = 'password';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Terms of Service editor ──────────────────────────────────────────
|
||||
// Moved here from the admin Overview dashboard — it's a configuration
|
||||
// control, so it belongs on the System Configuration page. The API is
|
||||
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
|
||||
// matches this page's gate). app.tos.get/update are the shared frontend
|
||||
// helpers (@simpleworkjs/frontend).
|
||||
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();
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="container py-4">
|
||||
@@ -82,8 +139,10 @@
|
||||
<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.
|
||||
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
|
||||
settings. These are stored securely in OpenBao and take effect immediately.
|
||||
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
|
||||
are masked — leave them unchanged to keep the stored value.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
@@ -115,9 +174,10 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Password</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="smtp-pass">
|
||||
<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 class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">From Address</label>
|
||||
@@ -144,9 +204,10 @@
|
||||
<div class="mb-3">
|
||||
<label class="form-label">JWT Secret</label>
|
||||
<div class="input-group">
|
||||
<input type="password" class="form-control" id="oauth-jwtsecret">
|
||||
<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">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||
@@ -160,6 +221,56 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">API Username</label>
|
||||
<input type="text" class="form-control" id="voipms-username">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">DID (sender number)</label>
|
||||
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">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 class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
|
||||
</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 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
|
||||
<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" id="tos-content" rows="8"></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" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
|
||||
<div id="tos-result" style="display:none" class="mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%- include('bottom') %>
|
||||
|
||||
+3
-103
@@ -16,11 +16,6 @@
|
||||
<i class="fa-solid fa-network-wired"></i> Discovery
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||
<i class="fa-solid fa-robot"></i> Agents & Scheduler
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
@@ -189,54 +184,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agents 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>
|
||||
<i class="fa-solid fa-robot"></i> Agents & Scheduler
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||
<i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1325,59 +1272,12 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 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');
|
||||
return;
|
||||
}
|
||||
loadPlugins();
|
||||
});
|
||||
}
|
||||
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
|
||||
// Scheduler tab here was its old home). Discovery inventory + the discovery
|
||||
// results table remain on this page.
|
||||
|
||||
$(document).ready(function(){
|
||||
loadDiscoveryResources();
|
||||
loadPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
+347
-53
@@ -3,54 +3,72 @@
|
||||
<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>
|
||||
{{#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 +82,315 @@
|
||||
<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 (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 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>
|
||||
|
||||
+28
-23
@@ -2,15 +2,10 @@
|
||||
|
||||
<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>
|
||||
<h2 id="vault-title"><i class="fas fa-lock"></i> 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>
|
||||
<% } %>
|
||||
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -52,8 +47,7 @@
|
||||
</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="col-md-5">
|
||||
@@ -89,7 +83,6 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% } %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,10 +96,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 +117,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'));
|
||||
@@ -309,7 +301,20 @@ 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();
|
||||
(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();
|
||||
})();
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user