87339da1b2
SECURITY /api/agent/ws authenticated nothing. There was no agent registry, so any client reaching the SSO could register as a node, publish discovery and telemetry into the admin view, and receive commands -- including a signed arbitrary_bash -- addressed to a token it guessed. Tokens were generated in the BROWSER and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new Agent table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent. Tokens are minted by POST /api/agent/enroll and shown once. Revoke and rotate drop the live socket immediately. All agent actions are audited. The Ed25519 command-signing key was generated in the AgentManager constructor, so it changed on every restart and the public_key pinned in an agent's agent.yml stopped matching. It now lives in OpenBao at secret/agent/signing-key; if it cannot be loaded the SSO refuses to send high-risk commands rather than signing with a key no agent has seen. DIRECTORY Agents bind to a host resource instead of being matched by hostname, and a bound agent's discovery is written onto that resource -- previously the one source running ON the host contributed nothing to the directory. The resource tree is collapsible, with state persisted per browser. DISCOVERY The Proxmox plugin zipped MACs and IPs from two flat lists by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC. Adds an endpoint resource parenting each node, sourceId/ vmid/node identity, container-interface filtering, node IP/MAC, and offline-node handling. The reconciler could make a resource its own parent, named hosts after their MAC address, had a dead isIp() regex (\\. matches a backslash), merged across kinds, and re-read the whole inventory per resource. Dockerfile.test-runner never copied nodejs/plugins, so every plugin test suite failed in CI as "Cannot find module". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
170 lines
7.7 KiB
Markdown
170 lines
7.7 KiB
Markdown
# Plugins
|
|
|
|
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
|
|
**type** is an installed module; a plugin **instance** is a configured, loadable
|
|
copy of a type. You can create, edit, load/unload, run, and delete instances
|
|
from the **Plugins** page (or the `/api/plugins` API), and you can run several
|
|
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
|
|
and token on its own schedule.
|
|
|
|
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
|
|
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
|
|
ever shows them masked (`********`); the plugin reads them at run time. This
|
|
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
|
|
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
|
|
|
|
## Plugin types
|
|
|
|
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
|
|
filename basename (without `.js`) is the `type`; the parent directory is the
|
|
`category`. The built-ins ship under `plugins/discovery/`:
|
|
|
|
- `proxmox` — Proxmox VE (URL + API token)
|
|
- `unifi` — UniFi Network controller (URL + username/password)
|
|
- `nmap` — nmap OS + port scan (a target range; no credentials)
|
|
|
|
### What the Proxmox plugin produces
|
|
|
|
One endpoint becomes one subtree:
|
|
|
|
```
|
|
Proxmox endpoint (cluster name, or the endpoint hostname)
|
|
└── node (hypervisor)
|
|
├── VM / template
|
|
└── LXC / template
|
|
```
|
|
|
|
The endpoint resource stands for the cluster, not a machine, so it carries the
|
|
API URL and a `sourceId` but deliberately no IP — giving it the address it is
|
|
reached at made the reconciler merge it with the node answering on that address,
|
|
which produced a resource that was its own parent.
|
|
|
|
Every guest carries:
|
|
|
|
- `interfaces[]` — one entry per NIC with its own `mac`, `ip`/`ips` and `name`.
|
|
The MAC and the address on it are read from the same source, so they cannot be
|
|
mismatched (an earlier version collected MACs and IPs into two flat lists and
|
|
zipped them by index, which attributed addresses to the wrong NIC on any
|
|
multi-NIC guest).
|
|
- `macAddress` / `ip` — the primary NIC's values, preferring one that actually
|
|
has an address.
|
|
- `vmid`, `node` and `sourceId` (`<node>/qemu/<vmid>` or `<node>/lxc/<vmid>`), so
|
|
a directory row traces back to the exact guest on the exact node.
|
|
|
|
Interfaces belonging to something running *inside* a guest — `docker0`, `veth*`,
|
|
`br-*`, VPN tunnels — are filtered out. They are not NICs of the host, and their
|
|
172.x addresses would otherwise give the reconciler spurious matches.
|
|
|
|
A stopped VM still reports its MAC (read from the VM config rather than the
|
|
guest agent), and a DHCP-configured LXC gets its address from the running
|
|
container's interface list. Offline nodes are recorded with `status` rather than
|
|
skipped, so a hypervisor that is down does not look decommissioned and get
|
|
garbage-collected after a week.
|
|
|
|
A module exports a **manifest**:
|
|
|
|
```javascript
|
|
module.exports = {
|
|
// Identity — `type`/`category` default to the file/dir name but can be set
|
|
// explicitly. `name`/`description` show up in the UI.
|
|
type: 'proxmox',
|
|
category: 'discovery',
|
|
name: 'Proxmox VE',
|
|
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
|
|
|
|
// Drives the admin UI form, API validation, and secret masking. Fields with
|
|
// `secret: true` are stored in OpenBao; the rest live in the DB row.
|
|
configSchema: [
|
|
{ key: 'url', label: 'API URL', type: 'url', required: true },
|
|
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
|
|
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
|
|
],
|
|
|
|
// "Test" button: validate the config (don't do the work). Return
|
|
// { ok: true } or { ok: false, error: '...' }. Optional.
|
|
validate: async (config) => { … },
|
|
|
|
// The work. `run` is the generalized contract name; the discovery plugins
|
|
// also keep `discover` as an alias for back-compat. For `category:
|
|
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
|
|
run: async (config) => { return { resources, edges }; },
|
|
discover: async (config) => { return { resources, edges }; }
|
|
};
|
|
```
|
|
|
|
`run(config)` receives the merged non-secret config + secret values as one flat
|
|
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
|
|
returns `{ resources, edges }`; the reconciler upserts them into the resource
|
|
graph attributed to the instance's **slug** (the `discovery_sources` name).
|
|
|
|
### Writing a custom plugin type
|
|
|
|
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
|
|
following the manifest above. New types are picked up at boot, so restart the
|
|
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
|
|
adding a new type still needs a restart.
|
|
|
|
## The Plugins page
|
|
|
|
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
|
|
/ `app_super_admin`):
|
|
|
|
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
|
|
source name + the URL the resource graph attributes results to), set a cron
|
|
schedule, and fill in the config form (secret fields are password inputs).
|
|
Creating it schedules it and kicks one immediate run.
|
|
- **Edit** — name, cron, and non-secret config.
|
|
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
|
|
field blank to keep its current value.
|
|
- **Test** (vial icon) — runs the plugin's `validate`.
|
|
- **Run now** (play icon) — enqueues one immediate run regardless of state.
|
|
- **Load / Unload** — enable/disable the schedule without deleting the instance.
|
|
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
|
|
|
|
## API
|
|
|
|
All endpoints are mounted at `/api/plugins`, require an authenticated admin
|
|
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
|
|
secret values masked.
|
|
|
|
| Method + path | Purpose |
|
|
|---|---|
|
|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
|
|
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
|
|
| `GET /api/plugins/:id` | one instance |
|
|
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
|
|
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
|
|
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
|
|
| `POST /api/plugins/:id/test` | run `validate` → `{ ok }` or `{ ok:false, error }` |
|
|
| `POST /api/plugins/:id/load` | enable + schedule + run now |
|
|
| `POST /api/plugins/:id/unload` | unschedule + disable |
|
|
| `POST /api/plugins/:id/run` | enqueue one immediate run |
|
|
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
|
|
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
|
|
|
|
## Scheduler internals
|
|
|
|
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
|
|
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
|
|
that one schedule without disturbing the others. A daily `garbage_collect` job
|
|
prunes discovery resources not seen in > 7 days.
|
|
|
|
### Legacy migration
|
|
|
|
Before this system, plugins were configured statically in `sso-secrets.js`:
|
|
|
|
```javascript
|
|
module.exports = {
|
|
discovery: {
|
|
plugins: {
|
|
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
|
|
}
|
|
}
|
|
};
|
|
```
|
|
|
|
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
|
|
empty **and** `conf.discovery.plugins` has entries, one instance per configured
|
|
type is seeded automatically (secret fields copied into OpenBao). After that the
|
|
table is non-empty and the static config is ignored — manage plugins from the
|
|
UI/API instead. The migration is idempotent (guarded by the empty-table check). |