From cec0d92c258f2bc82e4d3a844f6be8fac19fa747 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sat, 1 Aug 2026 20:33:57 -0400 Subject: [PATCH] feat: real plugin system with loadable instances + OpenBao secrets (v1.17.0) Generalize the half-built discovery plugins into a real plugin system: plugin TYPES (the plugins//.js modules with manifests) and loadable, configurable, multi-copy plugin INSTANCES (PluginInstance ORM model) managed from a dedicated /plugins page and /api/plugins API, with per-instance secrets in OpenBao at secret/plugins//conf. - plugin_registry.js: getTypes/getModule/splitConfig/mask + required-field helpers - PluginInstance model (Sequelize): id/pluginType/category/name/slug(unique)/ enabled/cron/config(json, non-secret)/lastRun*; registered in models/index.js - plugin_secrets.js: read/write/remove/mergeForRun over @simpleworkjs/bao-conf - scheduler.js: schedules from the DB registry; per-instance stable BullMQ JobScheduler ids (plugin:) for load/unload; legacy migration from conf.discovery.plugins on first boot (idempotent, empty-table-guarded) - api_plugins.js (replaces routes/plugins.js): types/list/get/create/update/ secrets/test/load/unload/run/delete/runs; admin-gated; secrets always masked - /plugins page (plugins.ejs) + nav; Agents & Scheduler tab removed from /directory; /docs/agents aliased to /docs/plugins - proxmox/unifi/nmap gained manifests (configSchema/validate/run alias) - tests/plugins.test.js: registry unit + plugin_secrets (mocked bao-conf) + PluginInstance model round-trip/unique-slug - docs (plugins.md, vault.md, _config.yml, API.md) + 1.16.1 -> 1.17.0 Requires theta-suite >= v1.30.1 for the sso-broker secret/plugins/* grant; fails-soft with a clear error if absent. Co-Authored-By: Claude --- API.md | 104 +++++++++ CHANGELOG.md | 67 ++++++ docs/_config.yml | 3 + docs/vault.md | 8 +- nodejs/app.js | 5 +- nodejs/docs/plugins.md | 152 ++++++++---- nodejs/models/index.js | 3 +- nodejs/models/plugin_instance.js | 82 +++++++ nodejs/package.json | 2 +- nodejs/plugins/discovery/nmap.js | 30 ++- nodejs/plugins/discovery/proxmox.js | 35 ++- nodejs/plugins/discovery/unifi.js | 41 +++- nodejs/routes/api_plugins.js | 262 +++++++++++++++++++++ nodejs/routes/docs.js | 3 +- nodejs/routes/index.js | 10 +- nodejs/routes/plugins.js | 59 ----- nodejs/services/plugin_registry.js | 172 ++++++++++++++ nodejs/services/scheduler.js | 240 ++++++++++++++----- nodejs/tests/plugins.test.js | 179 ++++++++++++++ nodejs/utils/plugin_secrets.js | 91 ++++++++ nodejs/utils/ui.js | 1 + nodejs/views/directory.ejs | 106 +-------- nodejs/views/plugins.ejs | 350 +++++++++++++++++++++++----- 23 files changed, 1679 insertions(+), 326 deletions(-) create mode 100644 nodejs/models/plugin_instance.js create mode 100644 nodejs/routes/api_plugins.js delete mode 100644 nodejs/routes/plugins.js create mode 100644 nodejs/services/plugin_registry.js create mode 100644 nodejs/tests/plugins.test.js create mode 100644 nodejs/utils/plugin_secrets.js diff --git a/API.md b/API.md index 73ab8ec..a881186 100644 --- a/API.md +++ b/API.md @@ -1199,6 +1199,110 @@ 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//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. + ## Error Responses All endpoints return errors in this format: diff --git a/CHANGELOG.md b/CHANGELOG.md index 08b14d3..9e3a28e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,73 @@ 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.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//.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//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:`) 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 diff --git a/docs/_config.yml b/docs/_config.yml index be9e714..f07b372 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -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 diff --git a/docs/vault.md b/docs/vault.md index 4f4caac..2dfcf55 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -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//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. diff --git a/nodejs/app.js b/nodejs/app.js index 677ae12..4eafe4b 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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- or, for admins, sso-admin), enforces the path prefix diff --git a/nodejs/docs/plugins.md b/nodejs/docs/plugins.md index b240c27..4e27295 100644 --- a/nodejs/docs/plugins.md +++ b/nodejs/docs/plugins.md @@ -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//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//.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:`); 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). \ No newline at end of file diff --git a/nodejs/models/index.js b/nodejs/models/index.js index aa89de7..d80dc86 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -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 ] }); diff --git a/nodejs/models/plugin_instance.js b/nodejs/models/plugin_instance.js new file mode 100644 index 0000000..f3c0ec8 --- /dev/null +++ b/nodejs/models/plugin_instance.js @@ -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//.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//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 }; \ No newline at end of file diff --git a/nodejs/package.json b/nodejs/package.json index 1bba5cd..a19c1cc 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.16.1", + "version": "1.17.0", "description": "A very simple LDAP management and SSO system", "author": [ { diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 81f909b..465f8a7 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -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"); @@ -47,5 +71,9 @@ module.exports = { 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) }; diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 1d881a1..231457e 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -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//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) }; diff --git a/nodejs/plugins/discovery/unifi.js b/nodejs/plugins/discovery/unifi.js index 6b4373e..9e216ea 100644 --- a/nodejs/plugins/discovery/unifi.js +++ b/nodejs/plugins/discovery/unifi.js @@ -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//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) }; diff --git a/nodejs/routes/api_plugins.js b/nodejs/routes/api_plugins.js new file mode 100644 index 0000000..efda2f2 --- /dev/null +++ b/nodejs/routes/api_plugins.js @@ -0,0 +1,262 @@ +'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}$/; + +// 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' }); + if (!slug || !SLUG_RE.test(slug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' }); + 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, + 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; \ No newline at end of file diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 8cd6b9f..5351b96 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -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')}, diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index 0b8f4f7..212f6f3 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -83,8 +83,14 @@ 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', function(req, res) { diff --git a/nodejs/routes/plugins.js b/nodejs/routes/plugins.js deleted file mode 100644 index eebedfd..0000000 --- a/nodejs/routes/plugins.js +++ /dev/null @@ -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; diff --git a/nodejs/services/plugin_registry.js b/nodejs/services/plugin_registry.js new file mode 100644 index 0000000..7a5c8a7 --- /dev/null +++ b/nodejs/services/plugin_registry.js @@ -0,0 +1,172 @@ +'use strict'; + +// Plugin type registry. +// +// A **plugin type** is a module under nodejs/plugins//.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//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 +}; \ No newline at end of file diff --git a/nodejs/services/scheduler.js b/nodejs/services/scheduler.js index edc26c5..d183145 100644 --- a/nodejs/services/scheduler.js +++ b/nodejs/services/scheduler.js @@ -1,5 +1,27 @@ +'use strict'; + +// Discovery / plugin scheduler. +// +// Generalized from the one-shot discovery-plugin loader: plugin *types* live +// under nodejs/plugins//.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:`) 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 +}; \ No newline at end of file diff --git a/nodejs/tests/plugins.test.js b/nodejs/tests/plugins.test.js new file mode 100644 index 0000000..691b996 --- /dev/null +++ b/nodejs/tests/plugins.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/nodejs/utils/plugin_secrets.js b/nodejs/utils/plugin_secrets.js new file mode 100644 index 0000000..2b227c4 --- /dev/null +++ b/nodejs/utils/plugin_secrets.js @@ -0,0 +1,91 @@ +'use strict'; + +// Per-instance plugin secrets, stored in OpenBao at `secret/plugins//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//*`) and per-app (`secret/apps//*`) 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 }; \ No newline at end of file diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index 4ec5b09..fe3fd1e 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -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']}, ], diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index 0355326..098f55c 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -16,11 +16,6 @@ Discovery -
@@ -189,54 +184,6 @@
- - -
-
-
-
- Agents & Scheduler -
-
-
- Manage background tasks and schedules. Learn how to make and use custom agents. -
-
- - - - - - - - - - - - - - - - - - - - - - -
Agent NameCron ScheduleStatusActions
{{name}} - {{#enabled}}Enabled{{/enabled}} - {{^enabled}}Disabled{{/enabled}} - - - {{#enabled}}{{/enabled}} - {{^enabled}}{{/enabled}} -
-
-
-
@@ -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(); }); diff --git a/nodejs/views/plugins.ejs b/nodejs/views/plugins.ejs index 75827cd..3df1d92 100644 --- a/nodejs/views/plugins.ejs +++ b/nodejs/views/plugins.ejs @@ -3,54 +3,72 @@
- -
+
- Plugins & Scheduler + Plugins +
+
+
- View configured background plugins and scheduler status. Note: Plugins are configured statically in sso-secrets.js. + 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. Learn more.
-
- - - - + + + + + + - - + + + - + - + - @@ -64,39 +82,265 @@ -<%- include('bottom') %> +<%- include('bottom') %> \ No newline at end of file
Plugin NameCron ScheduleStatusDetailsNameTypeScheduleStateLast RunActions
{{name}}
+ {{name}} +
{{slug}}
+
{{pluginType}} {{cron}} - {{#enabled}}Enabled{{/enabled}} - {{^enabled}}Disabled{{/enabled}} + {{#enabled}}Loaded{{/enabled}} + {{^enabled}}Unloaded{{/enabled}} - {{details}} + + {{#lastRunAt}}{{lastRunFmt}}{{/lastRunAt}} + {{^lastRunAt}}never{{/lastRunAt}} + {{#lastStatus}} + {{#isOk}}ok{{/isOk}} + {{#isError}}error{{/isError}} + {{#isRunning}}running{{/isRunning}} + {{/lastStatus}} + + + + + + {{#enabled}}{{/enabled}} + {{^enabled}}{{/enabled}} +