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/<category>/<type>.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/<id>/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:<id>) 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 <noreply@anthropic.com>
This commit is contained in:
+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.1",
|
||||
"version": "1.17.0",
|
||||
"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");
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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')},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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']},
|
||||
],
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
+297
-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,265 @@
|
||||
<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).
|
||||
function configFormHtml(type, prefix, values) {
|
||||
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
|
||||
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
|
||||
var v = values || {};
|
||||
var html = '';
|
||||
schema.forEach(function(f) {
|
||||
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;
|
||||
}
|
||||
|
||||
// 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">Slug <span class="text-danger">*</span></label>' +
|
||||
'<input type="text" class="form-control font-monospace" id="np-slug" placeholder="proxmox-homelab"></div>' +
|
||||
'<div class="mb-3"><label class="form-label">Cron Schedule</label>' +
|
||||
'<input type="text" class="form-control font-monospace" id="np-cron" value="0 * * * *"></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 slug = document.getElementById('np-slug').value.trim();
|
||||
var cron = document.getElementById('np-cron').value.trim() || '0 * * * *';
|
||||
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
|
||||
if (!/^[a-z0-9][a-z0-9_-]{0,63}$/.test(slug)) return app.messages.action('Slug must be lowercase letters/digits/_/- (max 64).', app.modal.body(), 'danger');
|
||||
var config = collectConfig(type, 'np-');
|
||||
try {
|
||||
await app.api.post('plugins', { pluginType: type, name: name, slug: slug, 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">Cron Schedule</label>' +
|
||||
'<input type="text" class="form-control font-monospace" id="ed-cron" value="' + (p.cron || '0 * * * *') + '"></div>' +
|
||||
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', Object.assign({}, p.config, p.secrets)) + '</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 = document.getElementById('ed-cron').value.trim() || '0 * * * *';
|
||||
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') %>
|
||||
Reference in New Issue
Block a user