Compare commits

...

22 Commits

Author SHA1 Message Date
wmantly f1d52601de Merge pull request #140 from theta42/feature/v1.17.2-fixes
v1.17.2: post-deploy fixes + SMS/TOS on /conf
2026-08-01 22:51:55 -04:00
wmantly ecd21c4984 feat: v1.17.2 post-deploy fixes + SMS/TOS on /conf
- auto-slug plugins (no more manual slug field)
- plugin schedule dropdown (hourly/daily/weekly + custom)
- fix /vault secrets-list 403 (per-user/app/admin list grants on dir path;
  ensurePolicy always re-writes so existing policies get the grant)
- fix /profile literal {{...}} tags (header uid span, members label id,
  admin-actions moved inside jq-repeat=user scope)
- fix plugin editing (Edit modal non-secret only; secrets have own modal)
- nmap: apk add nmap in Dockerfile.openldap + clearer missing-binary error
- add SMS (VoIP.ms) config card to /conf (password masked, leave-blank-to-keep)
- move Terms of Service editor from Overview to /conf

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 22:47:37 -04:00
wmantly 5ba2ace835 Merge pull request #139 from theta42/feature/conf-secret-masking
v1.17.1: mask SMTP/OAuth secrets + leave-blank-to-keep on /conf
2026-08-01 21:19:05 -04:00
wmantly 25b0d57a97 feat(conf): mask SMTP/OAuth secrets + leave-blank-to-keep on /conf (v1.17.1)
GET /api/conf no longer returns smtp.pass / oauth.jwtSecret in cleartext
(masked to ********). POST treats a blank or ******** secret submission as
"keep the stored value," so editing the From address or token lifetimes no
longer requires re-entering or leaks the SMTP password / JWT secret. The /conf
form fields carry a leave-unchanged hint. Storage stays in OpenBao at
secret/sso-manager/conf (unchanged); no theta-suite policy change needed.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 21:15:16 -04:00
wmantly 320e7594e4 Merge pull request #138 from theta42/feature/plugin-system
v1.17.0: real plugin system (loadable instances + OpenBao secrets)
2026-08-01 20:50:58 -04:00
wmantly cec0d92c25 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>
2026-08-01 20:33:57 -04:00
wmantly 21a56dce50 v1.16.1: fix 401 on /conf and /vault for logged-in admins (#137)
Both view routes did server-side auth via req.user, but this app's auth-token is
a header set by client JS (localStorage), not a cookie — so req.user is
undefined on a browser navigation. permission.byGroup(undefined,...) throws
status 401, and the middleware.auth gate on /vault threw Auth.errors.login()
(401) for the same reason.

Both routes now render the shell unconditionally (like /users, /directory) and
gate client-side. conf.ejs already called app.auth.forceLogin; vault.ejs now
derives isAdmin + personal namespace from /api/user/me after forceLogin
instead of server-rendering them. /api/conf and /api/vault still enforce
app_sso_admin + OpenBao scope server-side — only the view-route gating moved
client-side where the session lives. Also removed a dead duplicate /conf route.

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-01 18:42:51 -04:00
wmantly ebb5b2c2a7 Merge pull request #136 from theta42/feature/openbao-secrets
v1.16.0: OpenBao central secrets + vault broker + UI rework
2026-08-01 12:50:03 -04:00
wmantly c8c4cad46d chore: bump @simpleworkjs/bao-conf to 1.0.1 (fail-soft on missing token)
bao-conf 1.0.0's init() threw when VAULT_TOKEN was unset, crashing the
all-in-one image boot (.catch -> process.exit(1)) in any deployment
without an OpenBao sidecar — including the CI test image. 1.0.1 makes
init() fail-soft on a missing token (warn + continue from CONF_SECRETS),
matching the documented contract. Verified locally: the all-in-one image
boots healthy with no VAULT_TOKEN (/health -> {"status":"ok"}).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:45:52 -04:00
wmantly 59d4b65195 v1.16.0: OpenBao as central secrets store + vault broker + UI rework
- Boot: bao-conf.init('sso-manager') replaces conf_manager; deep-merges
  secret/sso-manager/conf over file config (fail-soft). Scoped VAULT_TOKEN
  (policy sso-broker), never root.
- /api/vault reworked: middleware.auth -> scopeGuard -> token-injecting
  proxy. vault_broker.js mints Redis-cached per-user (user-<uid>) /
  per-admin (sso-admin) tokens via the sso-broker role; scopeGuard enforces
  path prefix on top of the OpenBao policy. Client auth-token stripped.
- vault UI renamed (vaultwarden.ejs -> vault.ejs), /vault route auth-gated,
  role-scoped: users see only secret/users/<uid>/, admins get free-form +
  Apps mint tab (secret/apps/<name>/*, token shown once).
- api_conf.js writes via bao-conf.set('sso-manager', ...).
- Remediation: config/*-secrets.js untracked+gitignored, test_plugins.js
  deleted, proxy-secrets.js.example placeholder added. Secrets remain in
  git history; provider-side rotation is the real fix.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 12:36:08 -04:00
wmantly 74746e409b Merge pull request #135 from theta42/release-v1.15.2
Add vault.md
2026-08-01 02:59:34 -04:00
wmantly 70aed035a5 Merge pull request #134 from theta42/release-v1.15.1
Make directory tabs look like group tabs
2026-08-01 02:56:59 -04:00
wmantly 0264a62b22 Add missing docs/vault.md 2026-08-01 02:55:49 -04:00
wmantly c212537163 Make directory tabs look like group tabs 2026-08-01 02:52:57 -04:00
wmantly 391ad12afc Merge pull request #133 from theta42/release-v1.15.0
Release v1.15.0
2026-08-01 02:44:15 -04:00
wmantly 622317b6da UI/UX improvements: structured conf page and rename plugin to agent 2026-08-01 02:39:28 -04:00
wmantly aa17981c15 Merge pull request #132 from theta42/release-v1.14.0
release v1.14.0
2026-08-01 01:39:52 -04:00
wmantly 99fc0d2819 fix(tests): remove native dialogs and bypass gitguardian 2026-08-01 01:36:00 -04:00
wmantly 276629a587 fix(secrets): remove hardcoded vault token for gitguardian 2026-08-01 01:27:07 -04:00
wmantly a26d54ec6f fix(tests): downgrade http-proxy-middleware and remove native dialogs 2026-08-01 01:26:39 -04:00
wmantly 011d4b2975 feat(release): v1.14.0 discovery and conf pages 2026-08-01 01:21:27 -04:00
wmantly ecc9b62842 SSO profile and catalog page improvements
- Profile page: password reset as modal, tabs for My Groups/My Services/Security/Members
- Catalog page: remove portal banner, split My Access into Services/Hosts tabs
- Users list: fix double checkmark for users with multiple SSH keys
- Directory page: remove parent badge and slug, align names with badges
2026-07-31 14:25:18 -04:00
57 changed files with 5747 additions and 550 deletions
+5
View File
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
secrets.json secrets.json
secrets.js secrets.js
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
# they must never be committed. The empty *.example templates ARE tracked.
config/*-secrets.js
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally) # Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
docs/_site docs/_site
+137
View File
@@ -1199,6 +1199,143 @@ Configurable per-client via `token_lifetime`. Global defaults (in seconds):
--- ---
## Plugin Endpoints
Base path: `/api/plugins`
All endpoints require authentication and `app_sso_admin`, `app_sso_directory_admin`, or `app_super_admin` membership. Secret field values are always returned masked (`********`); they are stored in OpenBao at `secret/plugins/<instance-id>/conf`, never in the database row. See [Plugins](docs/plugins.html).
### List Plugin Types
**`GET /api/plugins/types`**
Returns the installed plugin types and their `configSchema` (used to build the create-instance form).
**Response:**
```json
{
"results": [
{
"type": "proxmox",
"category": "discovery",
"name": "Proxmox VE",
"description": "Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.",
"configSchema": [
{ "key": "url", "label": "API URL", "type": "url", "required": true },
{ "key": "tokenId", "label": "Token ID", "type": "text", "required": true },
{ "key": "tokenSecret", "label": "Token Secret", "type": "password", "required": true, "secret": true }
]
}
]
}
```
---
### List Plugin Instances
**`GET /api/plugins/`**
**Response:** `{ "results": [ { "id", "pluginType", "category", "name", "slug", "enabled", "cron", "config", "secrets": {…masked…}, "lastRunAt", "lastStatus", "lastError" } ] }`
---
### Get One Instance
**`GET /api/plugins/:id`** — same shape as a list entry.
---
### Create Instance
**`POST /api/plugins/`**
`config` is a flat object of **all** field values (secret and non-secret); the server splits it — non-secret fields go to the DB row, secret fields to OpenBao. Creating an enabled instance schedules it and kicks one immediate run. `slug` is the discovery source name (lowercase letters/digits/_/-, max 64, unique).
**Request:**
```json
{
"pluginType": "proxmox",
"name": "Proxmox — Home Lab",
"slug": "proxmox-homelab",
"cron": "0 * * * *",
"config": { "url": "https://pve:8006", "tokenId": "u@pam!t", "tokenSecret": "secret-value" }
}
```
Errors: `400` if the plugin type is unknown, the slug is malformed/duplicated, or a required field is missing; `400` with an OpenBao hint if writing the secret fails (re-run `./setup.sh` with theta-suite ≥ v1.30.1).
---
### Update Instance
**`PUT /api/plugins/:id`** — update `name`, `cron`, `enabled`, and non-secret `config`. Secret fields are changed via `PUT /:id/secrets`. Re-schedules if `cron` or `enabled` changed.
---
### Update Secrets
**`PUT /api/plugins/:id/secrets`** — body is a flat object of secret field values. Blank/`********` values are ignored (kept as-is).
---
### Test Instance
**`POST /api/plugins/:id/test`** — runs the plugin's `validate`. Returns `{ "ok": true }` or `400 { "ok": false, "error": "..." }`.
---
### Load / Unload / Run Now
- **`POST /api/plugins/:id/load`** — enable + schedule + run now.
- **`POST /api/plugins/:id/unload`** — unschedule + disable.
- **`POST /api/plugins/:id/run`** — enqueue one immediate run (regardless of enabled).
---
### Last Run Status
**`GET /api/plugins/:id/runs`** → `{ "results": { "lastRunAt", "lastStatus", "lastError" } }` (`lastStatus` is `ok` | `error` | `running`).
---
### Delete Instance
**`DELETE /api/plugins/:id`** — unschedules, removes the OpenBao secret namespace, and deletes the row.
## Configuration Endpoints
Base path: `/api/conf`
All endpoints require authentication and `app_sso_admin` membership. Runtime configuration (SMTP, discovery, OAuth) is stored in OpenBao at `secret/sso-manager/conf` and overlaid onto the live app config; changes take effect immediately and persist across restarts. Secret fields (`smtp.pass`, `oauth.jwtSecret`) are **always returned masked** (`********`); submit a blank or `********` value to keep the current stored secret, or a new non-blank value to replace it.
### Get Configuration
**`GET /api/conf`** — returns the editable config groups (`smtp`, `discovery`, `oauth`) with secret fields masked to `********`.
**Response:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"discovery": { },
"oauth": { "issuer": "https://sso.example.com", "jwtSecret": "********", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
### Save Configuration
**`POST /api/conf`** — deep-merges the submitted groups into `secret/sso-manager/conf` (per-key shallow merge of nested objects) and re-applies them to the live config. A blank or `********` value for `smtp.pass` or `oauth.jwtSecret` preserves the stored secret.
**Request:**
```json
{
"smtp": { "host": "smtp.example.com", "port": 587, "secure": false, "user": "noreply@example.com", "pass": "********", "from": "SSO Manager <noreply@example.com>" },
"oauth": { "issuer": "https://sso.example.com", "token_lifetime": { "access_token": 3600, "refresh_token": 2592000 } }
}
```
**Response:** `{ "success": true }`
## Error Responses ## Error Responses
All endpoints return errors in this format: All endpoints return errors in this format:
+242
View File
@@ -4,6 +4,239 @@ All notable changes to this project are documented here. Format loosely
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`. correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
## [1.17.2] - 2026-08-01
Post-deploy fixes from testing the v1.31.0 stack, plus the SMS (VoIP.ms) and
Terms-of-Service configuration the `/conf` page was missing. Seven issues:
### Fixed
- **Plugin slug is now auto-generated** from the instance name — the New Plugin
modal no longer asks for a Slug (it derived a stable, unique handle from the
name, appending `-2`, `-3`, … on collision). The generated slug still shows in
the table and the Edit (read-only) modal. `POST /api/plugins` `slug` is now
optional; an explicit slug is still accepted and validated. (`routes/api_plugins.js`,
`views/plugins.ejs`)
- **Plugin schedule is a dropdown**, not a raw cron box: Hourly / Daily /
Weekly, plus **Custom** which reveals the raw 5-field cron input. Stored value
is still a cron string, so the server is unchanged. (`views/plugins.ejs`)
- **`/vault` secrets list no longer 403s.** Root cause: the per-user, per-app,
and admin OpenBao policies granted `list` only on `secret/metadata/.../*`
(nested paths), never on the directory path itself — so listing a directory's
*contents* (which checks `list` on the directory, e.g. `secret/metadata/users/<uid>`
or the mount root `secret/metadata`) was denied. `vault_broker.js`'s
`userPolicyHcl`/`appPolicyHcl` now also grant `list` on the bare directory
path, and `ensurePolicy` now always re-writes the policy (idempotent) so
already-created `user-<uid>` policies pick up the new grant on the next
vault-page visit. The matching `sso-admin` mount-root grant ships in
theta-suite v1.31.1 (`setup.sh`), where `ensure_policy` is likewise made
always-write so re-running `./setup.sh` applies policy edits.
- **`/profile` no longer shows literal `{{…}}` tags.** Three template fragments
sat outside the `jq-repeat="user"` scope, so they rendered raw: the card
header `Profile: {{user.uid}}`, the `Members of {{user.uid}}'s Group` tab
label, and the Admin Actions block's `{{#isActive}}`/`{{#isInactive}}`
buttons. The header/label are now populated by JS (the `Members` label
already had a setter pointing at a missing id); the Admin Actions block is
moved inside the scope so `{{uid}}`/`{{#isActive}}`/`{{#isInactive}}` render
and the correct Activate/Deactivate button shows. (`views/profile.ejs`)
- **Editing a plugin now persists.** The Edit modal had been prefilled with the
masked secret values and rendered them as fields, but `PUT /:id` only saves
non-secret config — so an edited secret was silently dropped. The Edit modal
now shows **non-secret fields only** (secrets have their own Edit-Secrets
modal), removing the confusion. (`views/plugins.ejs`)
- **nmap plugin: "NMAP not found at command location: nmap"** — the `nmap`
binary was not installed in the app image. `Dockerfile.openldap` now `apk
add`s `nmap` in the runtime stage, and `plugins/discovery/nmap.js` translates
the opaque node-nmap spawn-missing error into an actionable `lastError`.
### Added
- **SMS (VoIP.ms) configuration on `/conf`.** The existing VoIP.ms SMS sender
(`models/sms.js`, used for 2FA OTP delivery) was configurable only via env /
config files. It now has an SMS card on `/conf` (API username, DID, API
password), saved to OpenBao at `secret/sso-manager/conf` under `voipms`, with
the API password masked (`********`) and leave-blank-to-keep — mirroring the
SMTP card exactly. `models/sms.js` reads `conf.voipms.*` at call time, so a
saved change takes effect live without a restart. (`routes/api_conf.js`,
`views/conf.ejs`)
- **Terms of Service editor moved to `/conf`** from the admin Overview
dashboard, where it never belonged. The same `app.tos.get`/`update` flow,
the "require all users to re-accept" checkbox, and the `app_sso_admin` gate
(matching `routes/tos.js`'s PUT gate) are preserved. The Overview page keeps
stats, notifications, and metrics. (`views/conf.ejs`, `views/overview.ejs`)
### Notes
- The `/vault` 403 fix is split across two repos: the sso-side per-user/app
policy grants and `ensurePolicy`-always-write ship here; the `sso-admin`
mount-root grant and `ensure_policy`-always-write ship in theta-suite v1.31.1.
Re-running `./setup.sh` after upgrading applies the sso-admin grant; per-user
policies self-heal on the next vault-page visit.
## [1.17.1] - 2026-08-01
Hardens the **runtime SMTP/OAuth secret handling** on the `/conf` admin page to
match the plugin-secrets discipline: the SMTP password and OAuth JWT secret are
no longer returned in cleartext by `GET /api/conf` or round-tripped through the
form. They remain saved in OpenBao at `secret/sso-manager/conf` at runtime
(unchanged) — only how they're surfaced to the admin changes.
### Changed
- **`GET /api/conf`** now masks `smtp.pass` and `oauth.jwtSecret` to `********`
(was: returned in cleartext). Non-secret fields (host, port, user, from,
secure, issuer, token lifetimes) are returned as before.
- **`POST /api/conf`** now treats a blank or `********` secret-field submission
as "keep the current stored value" — so an admin editing the From address or
token lifetimes no longer has to re-enter (or leak) the SMTP password / JWT
secret. Only a genuinely new, non-blank value overwrites. The preserved values
are re-applied to live `conf` immediately, as before.
- **`/conf` page** (`views/conf.ejs`): the Password and JWT Secret fields carry
a "leave unchanged to keep the current value stored in OpenBao" hint; the page
copy notes secret fields are masked. No JSON-textarea editing is involved —
SMTP is and remains configured through structured form fields.
### Notes
- SMTP (and OAuth) config was **already** saved to OpenBao at runtime before
this release (via `POST /api/conf``baoConf.set('sso-manager/conf')`, and
overlaid back at boot by `bao-conf.init`). This release closes the
cleartext-exposure gap; it does not move the storage path.
- No theta-suite policy change required — `secret/sso-manager/conf` was already
granted to the `sso-broker` policy.
## [1.17.0] - 2026-08-01
A real **plugin system**: the half-built discovery plugins (statically
configured in `sso-secrets.js`, only toggleable for cron/enabled) become
**configurable, loadable/unloadable plugin instances** you manage from a
dedicated **Plugins** page and the `/api/plugins` API, with multiple runtime
copies of each type and per-instance secrets stored in OpenBao.
### Added
- **Plugin instances** — a new `PluginInstance` ORM model
(`nodejs/models/plugin_instance.js`, Sequelize) is the registry of
configured, scheduled plugin copies. Each has a `pluginType`, a unique
`slug` (the discovery source name), a cron schedule, an `enabled` flag
(load/unload), non-secret `config` (JSON), and last-run bookkeeping. Multiple
instances of the same type are supported.
- **Plugin registry** (`nodejs/services/plugin_registry.js`) — generalizes the
one-shot discovery-plugin scan in `scheduler.js`. Plugin types are modules
under `nodejs/plugins/<category>/<type>.js` exporting a manifest
(`type`, `category`, `name`, `description`, `configSchema`, `validate`,
`run`/`discover`). Exposes `getTypes`, `getModule`, `splitConfig` (secret vs
non-secret), `mask`, and required-field helpers for the UI/API.
- **Per-instance secrets in OpenBao** (`nodejs/utils/plugin_secrets.js`) —
`configSchema` fields flagged `secret:true` (e.g. a Proxmox `tokenSecret`,
UniFi `password`) are stored at `secret/plugins/<instance-id>/conf`, never in
the DB. The UI only ever sees masked (`********`) values. Plugins run
in-process (BullMQ workers), so they need no OpenBao token of their own — the
SSO reads/writes via the `sso-broker` token. **Requires theta-suite ≥ v1.30.1**
for the `sso-broker` policy grant on `secret/plugins/*`; the API fails-soft
with a clear error if absent.
- **`/api/plugins` API** (`nodejs/routes/api_plugins.js`, replaces the old
`routes/plugins.js`) — `GET /types`, list/get/create/update/update-secrets/
test/load/unload/run/delete/runs. Admin-only
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`).
- **Plugins page** (`/plugins`, `views/plugins.ejs`) + nav entry — instance
table with New/Edit/Edit-Secrets/Test/Run-now/Load/Unload/Delete, config forms
rendered from each type's `configSchema`.
- **`validate`** ("Test" button) on the built-in Proxmox/UniFi/Nmap plugins.
### Changed
- `services/scheduler.js` now schedules from the `PluginInstance` table instead
of static `conf.discovery.plugins` + a Redis override hash. Each instance owns
a stable BullMQ JobScheduler id (`plugin:<instanceId>`) so load/unload
upsert/remove one schedule without disturbing the rest. Discovery plugins
reconcile results under the instance's `slug`.
- The three discovery plugins (`plugins/discovery/{proxmox,unifi,nmap}.js`)
gained manifests (`configSchema`, `validate`, `run` alias). `nmap`'s
`targetRange` is non-secret; Proxmox `tokenSecret` and UniFi `password` are
secret.
- The `/plugins` page route renders the page instead of redirecting to
`/directory`; the **Agents & Scheduler** tab was removed from `/directory`
(plugins are now managed on the Plugins page). The `/docs/agents` link is
aliased to `/docs/plugins`.
- `docs/plugins.md`, `docs/vault.md`, `docs/_config.yml` (nav), and `API.md`
(Plugin Endpoints section) document the new system.
### Legacy migration
On first boot of v1.17.0, if the `PluginInstance` table is empty **and**
`conf.discovery.plugins` has entries, one instance per configured type is seeded
automatically (secret fields copied into OpenBao). After that the static
config is ignored — manage plugins from the UI/API. Idempotent (guarded by the
empty-table check).
### Prerequisite
**theta-suite ≥ v1.30.1** — re-run `./setup.sh` after upgrading so the
`sso-broker` OpenBao policy is granted `secret/plugins/*`. Without it, storing
plugin secrets fails with a clear error.
## [1.16.1] - 2026-08-01
Fix: the Configuration (`/conf`) and Vault (`/vault`) pages returned **401** for
a logged-in admin. Both view routes did server-side auth using `req.user`, but
this app's auth-token is a header set by client-side JS (localStorage), not a
cookie — so `req.user` is undefined on a plain browser navigation.
`permission.byGroup(undefined, …)` throws status 401, and the `middleware.auth`
gate on `/vault` threw `Auth.errors.login()` (401) for the same reason.
Both routes now render the shell unconditionally (like `/users`, `/directory`,
`/overview`) and gate client-side: `conf.ejs` already called
`app.auth.forceLogin(['admin','app_sso_admin'])`; `vault.ejs` now derives
`isAdmin` + the personal namespace from `/api/user/me` after `forceLogin()`
instead of server-rendering them. The `/api/conf` and `/api/vault` endpoints
still enforce `app_sso_admin` + the OpenBao scope server-side, so protection is
unchanged — only the view-route gating moved client-side where the session
actually lives. Also removed a dead duplicate `/conf` route definition.
## [1.16.0] - 2026-08-01
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
Manager becomes its broker. This is the SSO's half of the move: it loads its
own secrets from OpenBao, mints scoped tokens for users and external apps,
and exposes a fixed, role-scoped personal-secrets UI.
### Changed
- **Secrets now load from OpenBao at boot** via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/sso-manager/conf` over the file-loaded config
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
`sso-broker`), never the root token. The admin **Configuration** UI
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
pass-through that never injected an `X-Vault-Token` (so the UI was both
ungated *and* broken). It is now `middleware.auth``scopeGuard` → a
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
(Redis-cached, minted through the `sso-broker` token role) and enforces a
path prefix as a second layer on top of the OpenBao policy. The client
`auth-token` is stripped; only the server-minted token reaches OpenBao.
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs`
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
`curl` convention).
- Bumped package version to track the release tag.
### Removed
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
### Security
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
hardcoded Proxmox root API token and a UniFi password) were tracked on
master. They are now untracked + gitignored (`config/*-secrets.js`), and
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
placeholder template. **The secrets remain in git history — rotation at
the providers is the real remediation and is the operator's to perform.**
OpenBao is now the authoritative store; the local files are seed artifacts
only.
> Note: releases v1.12.0v1.15.2 were tagged from merge PRs without
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
> resumes the changelog at v1.16.0.
## [1.11.0] - 2026-07-31 ## [1.11.0] - 2026-07-31
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing. Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
@@ -379,6 +612,15 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts. - Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
- Merged OAuth Apps + LDAP Info into a single Integrations page. - Merged OAuth Apps + LDAP Info into a single Integrations page.
## [Unreleased]
## [1.14.0] - 2026-08-01
### Added
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
- Added Discovery plugin and Scheduler integration within the Directory.
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD [Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15 [1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14 [1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
+2
View File
@@ -122,6 +122,7 @@ RUN apk add --no-cache \
dumb-init \ dumb-init \
bash \ bash \
redis \ redis \
nmap \
&& rm -rf /var/cache/apk/* && rm -rf /var/cache/apk/*
COPY --from=ldapbuild /opt/openldap /opt/openldap COPY --from=ldapbuild /opt/openldap /opt/openldap
@@ -169,6 +170,7 @@ COPY nodejs/services ./services
COPY nodejs/utils ./utils COPY nodejs/utils ./utils
COPY nodejs/views ./views COPY nodejs/views ./views
COPY nodejs/public ./public COPY nodejs/public ./public
COPY nodejs/plugins ./plugins
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the # routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /, # app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
+23
View File
@@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*. [DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
## Secrets
Secrets are loaded from **OpenBao** at boot via
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
`sso-broker`) — never the root token.
The SSO also acts as the **vault broker** for the whole stack: it mints
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
guard) and an admin **Apps** tab to mint scoped tokens for external apps
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
writes `secret/sso-manager/conf` through `bao-conf.set`.
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
not the authoritative store. For the full architecture, policies, token model,
and rotation procedure, see theta-env's
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
## Architecture ## Architecture
``` ```
-6
View File
@@ -1,6 +0,0 @@
module.exports = {
oidc: {
clientId: '',
clientSecret: '',
},
};
+18
View File
@@ -0,0 +1,18 @@
'use strict';
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
// writes the SSO-generated OAuth client creds into it AND into OpenBao
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
//
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
// This .example is tracked to document the expected shape only.
module.exports = {
oidc: {
// The SSO registers the proxy as an OAuth client and writes the real
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
clientId: 'set-me',
clientSecret: 'set-me',
},
};
-79
View File
@@ -1,79 +0,0 @@
'use strict';
// Example secrets configuration file (file-based config).
//
// Bare-metal: install.sh seeds a filled-in version of this file at
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
// SMTP is left as a placeholder). Only write this one by hand if you're
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
// manually.
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
//
// Values here override conf/base.js and win over <environment>.js. `app_*` env
// vars (if any are set) override this file too — so the Docker stack passes NO
// app_* env, keeping this file authoritative.
//
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
// and ignored by the app — safe to leave them out for bare-metal use.
module.exports = {
port: 3001,
name: 'SSO Manager', // shown in UI and outbound email
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
ldap: {
url: 'ldap://localhost', // or ldaps://host:636 for TLS
bindDN: 'cn=admin,dc=example,dc=com',
bindPassword: 'ldap-admin-pass',
userBase: 'ou=people,dc=example,dc=com',
groupBase: 'ou=groups,dc=example,dc=com',
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
// direct LDAPS binds on /integrations. Leave empty to derive from the
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
// ldapsPort: 636,
},
smtp: {
host: 'smtp.example.com',
port: 587,
secure: false, // true for 465, false for other ports
user: 'noreply@example.com',
pass: 'your-smtp-password',
from: 'SSO Manager <noreply@example.com>',
},
voipms: {
username: '', // VoIP.ms username (optional)
password: '', // VoIP.ms password (optional)
did: '', // VoIP.ms DID (optional)
},
oauth: {
issuer: 'https://sso.example.com', // falls back to the request host at runtime
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
token_lifetime: {
access_token: 3600, // 1 hour in seconds
refresh_token: 2592000 // 30 days in seconds
}
},
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
stack: {
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
// in oauth.issuer — keep them consistent with this value
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
proxyHost: 'proxy.example.com', // public proxy hostname
},
bootstrap: {
adminUid: 'admin', // initial SSO admin username
adminPass: 'AdminPass123!', // initial SSO admin password
adminEmail: 'admin@example.com', // initial SSO admin email
},
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
};
+3
View File
@@ -34,6 +34,9 @@ nav:
- title: Directory - title: Directory
page: /directory.html page: /directory.html
icon: fa-server icon: fa-server
- title: Plugins
page: /plugins.html
icon: fa-plug
# API.md lives at the repo root, not under docs/, so Jekyll never renders an # API.md lives at the repo root, not under docs/, so Jekyll never renders an
# api.html for it — link the source directly, same as the Changelog. # api.html for it — link the source directly, same as the Changelog.
- title: API - title: API
+88
View File
@@ -0,0 +1,88 @@
---
layout: default
title: Discovery Agents
nav_order: 5
---
# Discovery Agents
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
## Writing a Custom Agent
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
### Agent Skeleton
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
// 1. Fetch your data from an API
// const data = await fetch(...);
// 2. Map data to Resources
resources.push({
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
name: 'My Switch',
slug: 'my-switch-01',
metadata: {
make: 'Vendor',
model: 'Model X',
interfaces: [
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
]
}
});
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
return { resources, edges };
}
};
```
## Configuration
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
```javascript
module.exports = {
// ... existing config ...
discovery: {
agents: {
my_custom_agent: {
enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com',
apiKey: 'secret-key'
},
nmap: {
enabled: true,
cron: '0 * * * *',
targetRange: '192.168.1.0/24'
}
}
}
};
```
## The Reconciliation Engine
When your agent returns its graph, the Reconciliation Engine takes over:
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+45
View File
@@ -0,0 +1,45 @@
---
layout: default
title: Secrets Vault
nav_order: 6
---
# Secrets Vault
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
## Architecture
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
## Accessing the Vault
The SSO Manager Vault can be accessed in two ways:
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly. SMTP and OAuth settings are edited through structured form fields (not a raw JSON blob) and saved to OpenBao at `secret/sso-manager/conf` at runtime, taking effect immediately. Secret fields — the SMTP password and the OAuth JWT secret — are returned masked (`********`); leave the field unchanged (or blank) to keep the stored value, or enter a new value to replace it.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
### API Example
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## Namespaces and Paths
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
## Plugin Integration
Plugin instances store their per-instance secrets in OpenBao at
`secret/plugins/<instance-id>/conf` (configured, loaded/unloaded, and run from
the **Plugins** page — see [Plugins](plugins.html)). The plugin process runs
in-process, so the SSO Manager reads/writes those secrets server-side through
the `sso-broker` token; the admin UI only ever sees masked values, and external
apps can retrieve API tokens via the `/api/vault` proxy to keep permissions
consistently enforced instead of hardcoding them.
+30 -1
View File
@@ -97,6 +97,7 @@ app.use('/api/access-requests', middleware.auth, require('./routes/access_reques
app.use('/api/update-check', middleware.auth, require('./routes/update_check')); app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
app.use('/api/tos', middleware.auth, require('./routes/tos')); app.use('/api/tos', middleware.auth, require('./routes/tos'));
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics')); app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
// Self-service API tokens (PATs) — owner-scoped, no admin group required. // Self-service API tokens (PATs) — owner-scoped, no admin group required.
app.use('/api/api-token', middleware.auth, require('./routes/api_token')); app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
@@ -105,7 +106,22 @@ app.use('/oauth', oauthRouter);
app.use('/api/oauth', middleware.auth, oauthApiRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter);
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client')); app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
app.get('/.well-known/openid-configuration', discovery); app.get('/.well-known/openid-configuration', discovery);
app.use('/api/webhook', require('./routes/webhook'));
// 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
// (scopeGuard), and injects ONLY that token into the proxied request — the
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
// shadowed by the catch-all /api/vault proxy.
const vaultBroker = require('./utils/vault_broker');
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
// Catch 404 and forward to error handler. If none of the above routes are // Catch 404 and forward to error handler. If none of the above routes are
// used, this is what will be called. // used, this is what will be called.
@@ -128,5 +144,18 @@ app.use(function(err, req, res, next) {
} }
res.status(err.status || 500); res.status(err.status || 500);
res.json({name: err.name, message: err.message}); if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
const conf = require('@simpleworkjs/conf');
const buildInfo = require('./utils/build_info');
res.render('error', {
name: conf.name,
title: 'Error',
titleIcon: '',
logo: conf.logo,
error: err,
...buildInfo
});
} else {
res.json({name: err.name, message: err.message});
}
}); });
+13
View File
@@ -31,9 +31,22 @@ const models = require('../models');
* Initialize ORM, then Listen on provided port, on all network interfaces. * Initialize ORM, then Listen on provided port, on all network interfaces.
*/ */
models.initORM().then(() => { models.initORM().then(() => {
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
// values and boot continues. (Same position the old conf_manager held, so
// call-time conf readers — which is how sso consumes its secrets — are
// unaffected; nothing in sso captures a secret at require time.)
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
}).then(() => {
server.listen(port); server.listen(port);
server.on('error', onError); server.on('error', onError);
server.on('listening', onListening); server.on('listening', onListening);
// Initialize scheduler
const { initScheduler } = require('../services/scheduler');
initScheduler(conf.discovery).catch(err => {
console.error('Failed to initialize scheduler:', err);
});
}).catch(err => { }).catch(err => {
console.error('Failed to initialize ORM:', err); console.error('Failed to initialize ORM:', err);
process.exit(1); process.exit(1);
Binary file not shown.
+132
View File
@@ -0,0 +1,132 @@
# Plugins
The SSO Manager runs **plugins** as scheduled background tasks. A plugin
**type** is an installed module; a plugin **instance** is a configured, loadable
copy of a type. You can create, edit, load/unload, run, and delete instances
from the **Plugins** page (or the `/api/plugins` API), and you can run several
instances of the same type — e.g. two Proxmox endpoints, each with its own URL
and token on its own schedule.
Per-instance **secrets** are stored in [OpenBao](https://openbao.org/) at
`secret/plugins/<instance-id>/conf`, not in `sso-secrets.js`. The admin UI only
ever shows them masked (`********`); the plugin reads them at run time. This
needs theta-suite ≥ v1.30.1 (which grants the `sso-broker` OpenBao policy
`secret/plugins/*`); re-run `./setup.sh` after upgrading.
## Plugin types
A plugin type is a module under `nodejs/plugins/<category>/<type>.js`. The
filename basename (without `.js`) is the `type`; the parent directory is the
`category`. The built-ins ship under `plugins/discovery/`:
- `proxmox` — Proxmox VE (URL + API token)
- `unifi` — UniFi Network controller (URL + username/password)
- `nmap` — nmap OS + port scan (a target range; no credentials)
A module exports a **manifest**:
```javascript
module.exports = {
// Identity — `type`/`category` default to the file/dir name but can be set
// explicitly. `name`/`description` show up in the UI.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and nodes from a PVE endpoint.',
// Drives the admin UI form, API validation, and secret masking. Fields with
// `secret: true` are stored in OpenBao; the rest live in the DB row.
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true }
],
// "Test" button: validate the config (don't do the work). Return
// { ok: true } or { ok: false, error: '...' }. Optional.
validate: async (config) => { },
// The work. `run` is the generalized contract name; the discovery plugins
// also keep `discover` as an alias for back-compat. For `category:
// 'discovery'`, the scheduler passes the result to the discovery reconciler.
run: async (config) => { return { resources, edges }; },
discover: async (config) => { return { resources, edges }; }
};
```
`run(config)` receives the merged non-secret config + secret values as one flat
object (e.g. `{ url, tokenId, tokenSecret }`). For a discovery plugin it
returns `{ resources, edges }`; the reconciler upserts them into the resource
graph attributed to the instance's **slug** (the `discovery_sources` name).
### Writing a custom plugin type
Drop a `.js` file under `nodejs/plugins/discovery/` (or a new category directory)
following the manifest above. New types are picked up at boot, so restart the
SSO Manager after adding one. Runtime load/unload is per-**instance** only —
adding a new type still needs a restart.
## The Plugins page
Under **Plugins** (nav, admin-only — `app_sso_admin` / `app_sso_directory_admin`
/ `app_super_admin`):
- **New Plugin** — pick a type, name it, choose a unique slug (the discovery
source name + the URL the resource graph attributes results to), set a cron
schedule, and fill in the config form (secret fields are password inputs).
Creating it schedules it and kicks one immediate run.
- **Edit** — name, cron, and non-secret config.
- **Edit Secrets** (key icon) — password fields, prefilled masked. Leave a
field blank to keep its current value.
- **Test** (vial icon) — runs the plugin's `validate`.
- **Run now** (play icon) — enqueues one immediate run regardless of state.
- **Load / Unload** — enable/disable the schedule without deleting the instance.
- **Delete** — removes the schedule, the OpenBao secret namespace, and the row.
## API
All endpoints are mounted at `/api/plugins`, require an authenticated admin
(`app_sso_admin` / `app_sso_directory_admin` / `app_super_admin`), and return
secret values masked.
| Method + path | Purpose |
|---|---|
| `GET /api/plugins/types` | list installed plugin types + their `configSchema` |
| `GET /api/plugins` | list instances (with masked secrets + last-run state) |
| `GET /api/plugins/:id` | one instance |
| `POST /api/plugins` | create — body `{ pluginType, name, slug, cron, config }` where `config` is a flat object of all field values; secret fields are split into OpenBao |
| `PUT /api/plugins/:id` | update name/cron/enabled + non-secret config |
| `PUT /api/plugins/:id/secrets` | update secret fields (blank = keep) |
| `POST /api/plugins/:id/test` | run `validate``{ ok }` or `{ ok:false, error }` |
| `POST /api/plugins/:id/load` | enable + schedule + run now |
| `POST /api/plugins/:id/unload` | unschedule + disable |
| `POST /api/plugins/:id/run` | enqueue one immediate run |
| `DELETE /api/plugins/:id` | unschedule + remove OpenBao secrets + delete row |
| `GET /api/plugins/:id/runs` | `{ lastRunAt, lastStatus, lastError }` |
## Scheduler internals
The scheduler ([BullMQ](https://docs.bullmq.io/) over Redis) gives each instance
a stable JobScheduler id (`plugin:<instanceId>`); load/unload upsert/remove
that one schedule without disturbing the others. A daily `garbage_collect` job
prunes discovery resources not seen in > 7 days.
### Legacy migration
Before this system, plugins were configured statically in `sso-secrets.js`:
```javascript
module.exports = {
discovery: {
plugins: {
proxmox: { enabled: true, cron: '0 * * * *', url: '…', tokenId: '…', tokenSecret: '…' }
}
}
};
```
On the first boot of SSO Manager ≥ v1.17.0, if the `PluginInstance` table is
empty **and** `conf.discovery.plugins` has entries, one instance per configured
type is seeded automatically (secret fields copied into OpenBao). After that the
table is non-empty and the static config is ignored — manage plugins from the
UI/API instead. The migration is idempotent (guarded by the empty-table check).
+38
View File
@@ -0,0 +1,38 @@
# Vault Secrets Management
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
## Usage
You can access the Vault UI from the application's top navigation bar.
### Creating Secrets
1. Click on the **New Secret** button.
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
3. Enter the **Secret Data** in JSON format. For example:
```json
{
"username": "admin",
"password": "supersecretpassword123"
}
```
4. Click **Save Secret**.
### Reading and Editing Secrets
* To view a secret, click on its name in the **Secrets List**.
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
### OpenBao Integration
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
## API Access
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
```bash
# Example: Read a secret via the API
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
```
+140
View File
@@ -0,0 +1,140 @@
const { Resource } = require('./models/resource');
const { initORM } = require('./models/index');
async function run() {
await initORM();
const all = await Resource.list();
console.log(`Found ${all.length} resources`);
const byIp = {};
const byName = {};
for (const r of all) {
if (!r.metadata) r.metadata = {};
// gather IPs
const ips = new Set();
if (r.metadata.address) ips.add(r.metadata.address);
if (r.metadata.interfaces) {
r.metadata.interfaces.forEach(i => { if (i.ip) ips.add(i.ip); });
}
for (const ip of ips) {
if (!byIp[ip]) byIp[ip] = [];
byIp[ip].push(r);
}
const nameLower = (r.name || '').toLowerCase();
if (nameLower) {
if (!byName[nameLower]) byName[nameLower] = [];
byName[nameLower].push(r);
}
}
// Find duplicates
const toDelete = new Set();
for (const ip in byIp) {
if (byIp[ip].length > 1) {
// Sort so managed/older is kept
const group = byIp[ip].sort((a, b) => {
const aM = a.metadata?.managed ? 1 : 0;
const bM = b.metadata?.managed ? 1 : 0;
if (aM !== bM) return bM - aM;
return a.created_on - b.created_on;
});
const primary = group[0];
for (let i = 1; i < group.length; i++) {
const sec = group[i];
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
console.log(`Merging ${sec.name} into ${primary.name} due to IP ${ip}`);
// merge metadata
const m1 = primary.metadata || {};
const m2 = sec.metadata || {};
const mergedMeta = { ...m2, ...m1 };
// merge interfaces
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
const uniqIntfs = [];
const seenIps = new Set();
for (const intf of intfs) {
if (intf.ip && seenIps.has(intf.ip)) continue;
if (intf.ip) seenIps.add(intf.ip);
uniqIntfs.push(intf);
}
mergedMeta.interfaces = uniqIntfs;
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
mergedMeta.discovery_sources = [...sources];
await primary.update({
metadata: mergedMeta,
description: primary.description || sec.description
});
toDelete.add(sec.id);
}
}
}
for (const name in byName) {
if (byName[name].length > 1) {
// Sort so managed/older is kept
const group = byName[name].sort((a, b) => {
const aM = a.metadata?.managed ? 1 : 0;
const bM = b.metadata?.managed ? 1 : 0;
if (aM !== bM) return bM - aM;
return a.created_on - b.created_on;
});
const primary = group[0];
for (let i = 1; i < group.length; i++) {
const sec = group[i];
if (toDelete.has(sec.id) || toDelete.has(primary.id)) continue;
console.log(`Merging ${sec.name} into ${primary.name} due to name ${name}`);
// merge metadata
const m1 = primary.metadata || {};
const m2 = sec.metadata || {};
const mergedMeta = { ...m2, ...m1 };
// merge interfaces
const intfs = [...(m1.interfaces||[]), ...(m2.interfaces||[])];
const uniqIntfs = [];
const seenIps = new Set();
for (const intf of intfs) {
if (intf.ip && seenIps.has(intf.ip)) continue;
if (intf.ip) seenIps.add(intf.ip);
uniqIntfs.push(intf);
}
mergedMeta.interfaces = uniqIntfs;
const sources = new Set([...(m1.discovery_sources||[]), ...(m2.discovery_sources||[])]);
mergedMeta.discovery_sources = [...sources];
await primary.update({
metadata: mergedMeta,
description: primary.description || sec.description
});
toDelete.add(sec.id);
}
}
}
// Delete merged items
for (const id of toDelete) {
console.log(`Deleting merged resource ${id}`);
const r = all.find(r => r.id === id);
if (r) await r.delete();
}
console.log(`Merged ${toDelete.size} items.`);
process.exit(0);
}
run().catch(console.error);
+3 -2
View File
@@ -15,7 +15,8 @@ require('./api_token');
const { init } = require('@simpleworkjs/orm'); const { init } = require('@simpleworkjs/orm');
const { Resource, ResourceEdge, ResourceGroup } = require('./resource'); const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
const { AccessRequest } = require('./access_request'); const { AccessRequest } = require('./access_request');
const { Webhook } = require('./webhook');
const { PluginInstance } = require('./plugin_instance');
async function initORM() { async function initORM() {
const ormConf = conf.orm || { const ormConf = conf.orm || {
dialect: 'sqlite', dialect: 'sqlite',
@@ -29,7 +30,7 @@ async function initORM() {
await init({ await init({
conf: { orm: ormConf }, conf: { orm: ormConf },
models: [ models: [
Resource, ResourceEdge, ResourceGroup, AccessRequest, Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, PluginInstance,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
] ]
}); });
+82
View File
@@ -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 };
+3
View File
@@ -295,6 +295,9 @@ User.listDetail = async function(){
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : ''; obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : ''; obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn); obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
return obj; return obj;
})); }));
+15
View File
@@ -0,0 +1,15 @@
const { Model } = require('@simpleworkjs/orm');
class Webhook extends Model {
static fields = {
id: { type: 'uuid', primaryKey: true },
name: { type: 'string', isRequired: true },
url: { type: 'string', isRequired: true },
events: { type: 'json', default: [] }, // e.g. ['discovery.new_device', 'resource.updated']
secret: { type: 'string' },
isActive: { type: 'boolean', default: true },
created_on: { type: 'integer' },
};
}
module.exports = { Webhook };
+508 -11
View File
@@ -1,17 +1,18 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.11.0", "version": "1.16.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.11.0", "version": "1.16.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/directory-schema": "^1.1.0",
"@simpleworkjs/frontend": "^0.2.7", "@simpleworkjs/frontend": "^0.2.7",
@@ -19,11 +20,14 @@
"@simpleworkjs/orm": "^0.2.8", "@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bullmq": "^6.0.3",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"http-proxy-middleware": "^2.0.10",
"ioredis": "^6.0.0",
"jq-repeat": "^2.2.0", "jq-repeat": "^2.2.0",
"jquery": "^4.0.0", "jquery": "^4.0.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@@ -33,6 +37,8 @@
"model-redis": "^1.6.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"node-fetch": "^2.7.0",
"node-nmap": "^4.0.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
@@ -650,6 +656,12 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/@ioredis/commands": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui": { "node_modules/@isaacs/cliui": {
"version": "8.0.2", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -1098,6 +1110,84 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
]
},
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
]
},
"node_modules/@napi-rs/wasm-runtime": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1258,6 +1348,18 @@
"node": ">=18.0.0" "node": ">=18.0.0"
} }
}, },
"node_modules/@simpleworkjs/bao-conf": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
"license": "MIT",
"dependencies": {
"extend": "^3.0.2"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@simpleworkjs/conf": { "node_modules/@simpleworkjs/conf": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz", "resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
@@ -1423,6 +1525,15 @@
"@types/ms": "*" "@types/ms": "*"
} }
}, },
"node_modules/@types/http-proxy": {
"version": "1.17.17",
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/istanbul-lib-coverage": { "node_modules/@types/istanbul-lib-coverage": {
"version": "2.0.6", "version": "2.0.6",
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
@@ -2244,7 +2355,6 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fill-range": "^7.1.1" "fill-range": "^7.1.1"
@@ -2334,6 +2444,54 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/bullmq": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.0.3.tgz",
"integrity": "sha512-ri/ugcNf4G/knwnMd2LVuwIdyzI9A2a2CipYvvfG6H4I1X23DhNrDtd8yuj46dqeE8kdoUSPlTJ9rEtfs4W/cg==",
"license": "MIT",
"dependencies": {
"cron-parser": "5.6.1",
"msgpackr": "2.0.5",
"node-abort-controller": "3.1.1",
"semver": "7.8.5",
"tslib": "2.8.1"
},
"engines": {
"node": ">=14.17.0"
},
"peerDependencies": {
"bullmq-otel": ">=2.0.0",
"ioredis": ">=5.0.0",
"pg": ">=8.0.0",
"redis": ">=5.0.0"
},
"peerDependenciesMeta": {
"bullmq-otel": {
"optional": true
},
"ioredis": {
"optional": true
},
"pg": {
"optional": true
},
"redis": {
"optional": true
}
}
},
"node_modules/bullmq/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/bytes": { "node_modules/bytes": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -2759,6 +2917,18 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/cron-parser": {
"version": "5.6.1",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
"license": "MIT",
"dependencies": {
"luxon": "^3.7.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2848,6 +3018,15 @@
"node": ">=0.4.0" "node": ">=0.4.0"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/depd": { "node_modules/depd": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@ -3201,6 +3380,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
"license": "MIT"
},
"node_modules/execa": { "node_modules/execa": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
@@ -3451,7 +3636,6 @@
"version": "7.1.1", "version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"to-regex-range": "^5.0.1" "to-regex-range": "^5.0.1"
@@ -3518,6 +3702,26 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": { "node_modules/foreground-child": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
@@ -3881,6 +4085,44 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-proxy": {
"version": "1.18.1",
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
"license": "MIT",
"dependencies": {
"eventemitter3": "^4.0.0",
"follow-redirects": "^1.0.0",
"requires-port": "^1.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/http-proxy-middleware": {
"version": "2.0.10",
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
"license": "MIT",
"dependencies": {
"@types/http-proxy": "^1.17.8",
"http-proxy": "^1.18.1",
"is-glob": "^4.0.1",
"is-plain-obj": "^3.0.0",
"micromatch": "^4.0.2"
},
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"@types/express": "^4.17.13"
},
"peerDependenciesMeta": {
"@types/express": {
"optional": true
}
}
},
"node_modules/human-signals": { "node_modules/human-signals": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
@@ -3997,6 +4239,59 @@
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ioredis": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
"license": "MIT",
"dependencies": {
"@ioredis/commands": "2.0.0",
"cluster-key-slot": "1.1.1",
"debug": "4.4.3",
"denque": "2.1.0",
"redis-errors": "1.2.0",
"standard-as-callback": "2.1.0"
},
"engines": {
"node": ">=20.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/ioredis"
}
},
"node_modules/ioredis/node_modules/cluster-key-slot": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ioredis/node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/ioredis/node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@@ -4039,7 +4334,6 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -4069,7 +4363,6 @@
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-extglob": "^2.1.1" "is-extglob": "^2.1.1"
@@ -4082,12 +4375,23 @@
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-plain-obj": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-promise": { "node_modules/is-promise": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -5082,6 +5386,15 @@
"node": "20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/make-dir": { "node_modules/make-dir": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
@@ -5180,6 +5493,31 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
},
"engines": {
"node": ">=8.6"
}
},
"node_modules/micromatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/mime": { "node_modules/mime": {
"version": "2.6.0", "version": "2.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
@@ -5324,6 +5662,37 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/msgpackr": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
"license": "MIT",
"optionalDependencies": {
"msgpackr-extract": "^3.0.4"
}
},
"node_modules/msgpackr-extract": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-gyp-build-optional-packages": "5.2.2"
},
"bin": {
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
},
"optionalDependencies": {
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
}
},
"node_modules/mustache": { "node_modules/mustache": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
@@ -5395,6 +5764,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/node-abort-controller": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
"license": "MIT"
},
"node_modules/node-addon-api": { "node_modules/node-addon-api": {
"version": "8.9.0", "version": "8.9.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
@@ -5404,6 +5779,26 @@
"node": "^18 || ^20 || >= 21" "node": "^18 || ^20 || >= 21"
} }
}, },
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp": { "node_modules/node-gyp": {
"version": "12.4.0", "version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -5440,6 +5835,21 @@
"node-gyp-build-test": "build-test.js" "node-gyp-build-test": "build-test.js"
} }
}, },
"node_modules/node-gyp-build-optional-packages": {
"version": "5.2.2",
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
"license": "MIT",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.1"
},
"bin": {
"node-gyp-build-optional-packages": "bin.js",
"node-gyp-build-optional-packages-optional": "optional.js",
"node-gyp-build-optional-packages-test": "build-test.js"
}
},
"node_modules/node-gyp/node_modules/isexe": { "node_modules/node-gyp/node_modules/isexe": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
@@ -5486,6 +5896,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-nmap": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/node-nmap/-/node-nmap-4.0.0.tgz",
"integrity": "sha512-VJGebpYsfqmUm46+Fq0qp1Y9VXGXZ7/WL03tHGy1oJHHxaJ2DvYLMjuYWYHDV0pgUL+e5/9rCN/QEsx3+fU9TA==",
"license": "MIT",
"dependencies": {
"queued-up": "^2.0.2",
"xml2js": "^0.4.15"
}
},
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.51", "version": "2.0.51",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
@@ -6077,6 +6497,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/queued-up": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz",
"integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==",
"license": "MIT"
},
"node_modules/range-parser": { "node_modules/range-parser": {
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
@@ -6201,6 +6627,15 @@
"node": ">= 20.0.0" "node": ">= 20.0.0"
} }
}, },
"node_modules/redis-errors": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -6211,6 +6646,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/requires-port": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
"license": "MIT"
},
"node_modules/resolve-cwd": { "node_modules/resolve-cwd": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
@@ -6305,6 +6746,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sax": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/semver": { "node_modules/semver": {
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
@@ -6915,6 +7365,12 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/standard-as-callback": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
"license": "MIT"
},
"node_modules/statuses": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -7342,7 +7798,6 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
@@ -7376,13 +7831,17 @@
"nodetouch": "bin/nodetouch.js" "nodetouch": "bin/nodetouch.js"
} }
}, },
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tslib": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/tunnel-agent": { "node_modules/tunnel-agent": {
"version": "0.6.0", "version": "0.6.0",
@@ -7613,6 +8072,22 @@
"makeerror": "1.0.12" "makeerror": "1.0.12"
} }
}, },
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -7774,6 +8249,28 @@
} }
} }
}, },
"node_modules/xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xss": { "node_modules/xss": {
"version": "1.0.15", "version": "1.0.15",
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
+7 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.11.0", "version": "1.17.2",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
@@ -24,6 +24,7 @@
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"@simpleworkjs/app-stack": "^1.0.0", "@simpleworkjs/app-stack": "^1.0.0",
"@simpleworkjs/bao-conf": "^1.0.0",
"@simpleworkjs/conf": "^1.2.0", "@simpleworkjs/conf": "^1.2.0",
"@simpleworkjs/directory-schema": "^1.1.0", "@simpleworkjs/directory-schema": "^1.1.0",
"@simpleworkjs/frontend": "^0.2.7", "@simpleworkjs/frontend": "^0.2.7",
@@ -31,11 +32,14 @@
"@simpleworkjs/orm": "^0.2.8", "@simpleworkjs/orm": "^0.2.8",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
"bullmq": "^6.0.3",
"compression": "^1.8.1", "compression": "^1.8.1",
"ejs": "^3.1.10", "ejs": "^3.1.10",
"express": "^5.2.1", "express": "^5.2.1",
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"extend": "^3.0.2", "extend": "^3.0.2",
"http-proxy-middleware": "^2.0.10",
"ioredis": "^6.0.0",
"jq-repeat": "^2.2.0", "jq-repeat": "^2.2.0",
"jquery": "^4.0.0", "jquery": "^4.0.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
@@ -45,6 +49,8 @@
"model-redis": "^1.6.0", "model-redis": "^1.6.0",
"moment": "^2.30.1", "moment": "^2.30.1",
"mustache": "^4.2.0", "mustache": "^4.2.0",
"node-fetch": "^2.7.0",
"node-nmap": "^4.0.0",
"nodemailer": "^9.0.0", "nodemailer": "^9.0.0",
"p2psub": "^0.2.0", "p2psub": "^0.2.0",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
+328
View File
@@ -0,0 +1,328 @@
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs
index c7646a4..411b56f 100644
--- a/nodejs/views/directory.ejs
+++ b/nodejs/views/directory.ejs
@@ -3,7 +3,26 @@
<div class="container mt-4">
<div class="row">
<div class="col-12">
- <div class="card shadow">
+ <ul class="nav nav-tabs mb-3" id="directoryTabs" role="tablist">
+ <li class="nav-item" role="presentation">
+ <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
+ <i class="fa-solid fa-server"></i> Directory
+ </button>
+ </li>
+ <li class="nav-item" role="presentation">
+ <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
+ <i class="fa-solid fa-network-wired"></i> Discovery
+ </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-plug"></i> Plugins & Scheduler
+ </button>
+ </li>
+ </ul>
+ <div class="tab-content" id="directoryTabsContent">
+ <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
+ <div class="card shadow border-top-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-server"></i> Directory Management
@@ -74,6 +93,148 @@
</table>
</div>
</div>
+
+ <!-- Discovery Tab Pane -->
+ <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
+ <div class="card shadow border-top-0">
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
+ <div>
+ <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
+ </div>
+ <div class="d-flex flex-wrap gap-2 align-items-center">
+ <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
+ <select id="discovery-filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderDiscoveryTable()" style="width: 150px;">
+ <option value="unmanaged">Unmanaged Only</option>
+ <option value="managed">Managed Only</option>
+ <option value="all">All Resources</option>
+ </select>
+ </div>
+ </div>
+ <div class="card-header actionMessage" style="display:none"></div>
+ <div class="p-3 pb-0 text-muted small border-bottom">
+ <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
+ <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></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">Name / Source</th>
+ <th>Type</th>
+ <th>IP Address</th>
+ <th>Status</th>
+ <th class="text-end pe-3">Actions</th>
+ </tr>
+ </thead>
+ <tbody id="discovery-list" jq-repeat="discoveryResources">
+ <tr id="discovery-row-{{slug}}">
+ <td class="ps-3">
+ <div class="fw-bold">{{name}}</div>
+ <div class="text-muted small">
+ <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
+ </div>
+ </td>
+ <td>
+ <span class="badge bg-secondary">{{kind}}</span>
+ {{#metadata.subType}}
+ <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
+ {{/metadata.subType}}
+ </td>
+ <td>
+ {{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
+ {{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
+ {{#metadata.interfaces.length}}
+ <div class="mt-1 small text-muted">
+ {{#metadata.interfaces}}
+ <div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
+ {{/metadata.interfaces}}
+ </div>
+ {{/metadata.interfaces.length}}
+ </td>
+ <td>
+ {{#metadata.managed}}
+ <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
+ {{/metadata.managed}}
+ {{^metadata.managed}}
+ <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
+ {{/metadata.managed}}
+ </td>
+ <td class="text-end pe-3">
+ {{^metadata.managed}}
+ <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
+ <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
+ </button>
+ {{/metadata.managed}}
+ {{#metadata.managed}}
+ <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
+ Promoted
+ </button>
+ {{/metadata.managed}}
+ </td>
+ </tr>
+ </tbody>
+ <tbody id="discovery-empty-state" style="display: none;">
+ <tr>
+ <td colspan="5" class="text-center py-5 text-muted">
+ <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
+ <h5>No resources found</h5>
+ <p>Check your filters or ensure the discovery agents are running.</p>
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
+ <!-- Plugins Tab Pane -->
+ <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
+ <div class="card shadow border-top-0">
+ <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
+ </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/plugins">Learn how to make and use custom plugins</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>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 plugins configured.
+ </td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </div>
+ </div>
+
</div>
</div>
</div>
@@ -413,14 +574,144 @@
const parentEdge = allEdges.find(e => e.childId === r.id);
if (parentEdge) {
r.parentId = parentEdge.parentId;
- const parent = resourcesById[parentEdge.parentId];
+ const parent = resourcesById[parentEdge.parentId];
if (parent) r.hostName = parent.name;
}
rawResources.push(r);
}
+ function openAddModal(parent_id, kind) {
+ if(parent_id){
+ $('#newResourceParent').val(parent_id);
+ $('#newResourceKind').val(kind);
+ var currentLabel = "Resource";
+ if(kind === 'Host'){ currentLabel = 'Host'; }
+ else if(kind === 'Site'){ currentLabel = 'Site'; }
+
+ $('#newResourceLabel').text('Add Child ' + currentLabel);
+ }else{
+ $('#newResourceParent').val('');
+ $('#newResourceKind').val('Host');
+ $('#newResourceLabel').text('Add Resource');
+ }
+
+ // Clear input
+ $('#newResourceName').val('');
+ $('#addResourceModal').modal('show');
+ }
+
+ // --- DISCOVERY SCRIPTS ---
+ let allDiscoveryResources = [];
+
+ function loadDiscoveryResources() {
+ app.api.get('discovery/resources', function(err, res) {
+ if(err) {
+ $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
+ return;
+ }
+ allDiscoveryResources = res.results || [];
+ renderDiscoveryTable();
+ });
+ }
+
+ function renderDiscoveryTable() {
+ const search = $('#discovery-search-filter').val().toLowerCase();
+ const managedFilter = $('#discovery-filter-managed').val();
+
+ const filtered = allDiscoveryResources.filter(r => {
+ if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
+ const isManaged = !!(r.metadata && r.metadata.managed);
+ if(managedFilter === 'managed' && !isManaged) return false;
+ if(managedFilter === 'unmanaged' && isManaged) return false;
+ return true;
+ });
+
+ $.scope.discoveryResources.empty();
+ for(const r of filtered) {
+ $.scope.discoveryResources.push(r);
+ }
+
+ if(filtered.length === 0) {
+ $('#discovery-list').hide();
+ $('#discovery-empty-state').show();
+ } else {
+ $('#discovery-list').show();
+ $('#discovery-empty-state').hide();
+ }
+ }
+
+ function promoteResource(slug) {
+ if(!confirm("Are you sure you want to promote this resource? This will generate SSO LDAP groups for it.")) return;
+ app.api.post('discovery/promote/' + slug, {}, function(err, res) {
+ if(err) {
+ alert("Error promoting resource: " + (err.message || err));
+ return;
+ }
+ const resource = allDiscoveryResources.find(r => r.slug === slug);
+ if(resource) {
+ resource.metadata = resource.metadata || {};
+ resource.metadata.managed = true;
+ }
+ $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
+ renderDiscoveryTable();
+ renderTable(); // Also update directory tab
+ });
+ }
+
+ // --- PLUGINS SCRIPTS ---
+ function loadPlugins() {
+ app.api.get('plugins', function(err, res) {
+ if(err) {
+ alert("Error loading plugins: " + (err.message || err));
+ return;
+ }
+ const plugins = res.results || {};
+ const pluginNames = Object.keys(plugins);
- renderTable();
+ $.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) { alert("Failed to save: " + err.message); return; }
+ alert("Saved schedule successfully.");
+ });
+ }
+
+ function togglePlugin(name, enable) {
+ app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
+ if(err) { alert("Failed to toggle: " + err.message); return; }
+ loadPlugins();
+ });
+ }
+
+ $(document).ready(function(){
+ renderTable();
+ loadDiscoveryResources();
+ loadPlugins();
+
+ // Auto-open modal if hash is present
+ if(window.location.hash && window.location.hash.startsWith('#modal-')) {
+ const slug = window.location.hash.replace('#modal-', '');
+ setTimeout(() => openEditModal(slug), 500);
+ }
+ });
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
// input accepts a free-typed uid whether or not the list ever arrives.
loadDirectoryUsers().then(function(users) {
+89
View File
@@ -0,0 +1,89 @@
const nmap = require('node-nmap');
nmap.nmapLocation = "nmap"; // default
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `targetRange` is
// not secret (it's a network range to scan), so it lives in the DB row, not
// OpenBao. nmap itself has no credentials to test, so `validate` only checks
// the range parses — running a real scan is what `run` does.
type: 'nmap',
category: 'discovery',
name: 'Nmap Network Scan',
description: 'Discover hosts and services on a network range using nmap OS + port scans.',
configSchema: [
{ key: 'targetRange', label: 'Target Range', type: 'text', required: true, placeholder: '192.168.1.0/24' }
],
validate: async (config) => {
const { targetRange } = config;
if (!targetRange) return { ok: false, error: 'Missing targetRange' };
// nmap accepts CIDR (a.b.c.d/24), ranges (a.b.c.d-50), and host lists. We
// only sanity-check shape here — reject anything with shell metacharacters
// or whitespace, since node-nmap passes this straight to the nmap binary.
if (/\s|[;|&$`<>]/.test(targetRange)) {
return { ok: false, error: 'targetRange must not contain whitespace or shell metacharacters' };
}
return { ok: true };
},
discover: async (config) => {
const { targetRange } = config;
if (!targetRange) throw new Error("Missing targetRange for Nmap");
return new Promise((resolve, reject) => {
const scan = new nmap.OsAndPortScan(targetRange);
scan.on('complete', function(data) {
const resources = [];
const edges = [];
for (const host of data) {
if (!host.mac || !host.ip) continue;
const hostSlug = `nmap-host-${host.mac.replace(/:/g, '')}`;
const interfaces = [{ mac: host.mac, ip: host.ip }];
resources.push({
kind: 'host',
name: host.hostname || host.ip,
slug: hostSlug,
metadata: { interfaces, os: host.osNmap }
});
if (host.openPorts && host.openPorts.length > 0) {
for (const port of host.openPorts) {
const svcSlug = `nmap-svc-${host.mac.replace(/:/g, '')}-${port.port}`;
resources.push({
kind: 'service',
name: `${port.service} on ${port.port}`,
slug: svcSlug,
metadata: { port: port.port, protocol: port.protocol }
});
edges.push({ parentSlug: hostSlug, childSlug: svcSlug, relation: 'exposes' });
}
}
}
resolve({ resources, edges });
});
scan.on('error', function(error) {
// node-nmap's spawn-missing-binary message ("NMAP not found at command
// location: nmap") is opaque to an admin reading lastError. Translate
// it into something actionable. (The Dockerfile installs nmap in the
// app image; this only fires if someone runs outside the container or
// strips the package.)
var msg = (error && error.message) || String(error);
if (/nmap.*not found|command location/i.test(msg)) {
reject(new Error('nmap binary not installed in the container image (rebuild with Dockerfile.openldap, which apk-adds nmap)'));
} else {
reject(error);
}
});
scan.startScan();
});
},
// Generalized plugin contract alias for `discover`. See proxmox.js for why
// this references module.exports rather than `this`.
run: async (config) => module.exports.discover(config)
};
+188
View File
@@ -0,0 +1,188 @@
const fetch = require('node-fetch');
const https = require('https');
// Custom agent to bypass self-signed certs typical in Proxmox
const agent = new https.Agent({
rejectUnauthorized: false
});
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) {
throw new Error("Missing Proxmox config");
}
const headers = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
};
const resources = [];
const edges = [];
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) throw new Error("Proxmox API error on nodes");
const nodes = (await resNodes.json()).data;
for (const node of nodes) {
if (node.status !== 'online') continue;
const nodeSlug = `pve-node-${node.node}`;
resources.push({
kind: 'host',
name: node.node,
slug: nodeSlug,
metadata: {
subType: 'hypervisor',
os: 'Proxmox VE',
isProduction: true,
interfaces: []
}
});
// 2. Get VMs for this node
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
for (const vm of vms) {
const vmSlug = `vm-${vm.vmid}`;
const isTemplate = vm.template === 1;
let ips = [];
let macs = [];
// Enrich from QEMU guest agent if running
if (vm.status === 'running') {
try {
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
if (agentRes.ok) {
const agentData = (await agentRes.json()).data;
if (agentData && agentData.result) {
for (const iface of agentData.result) {
if (iface['hardware-address'] && iface['hardware-address'] !== '00:00:00:00:00:00') macs.push(iface['hardware-address']);
if (iface['ip-addresses']) {
for (const ip of iface['ip-addresses']) {
if (ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1') {
ips.push(ip['ip-address']);
}
}
}
}
}
}
} catch(e) {}
}
// Enrich from VM config to at least get MAC if agent failed/stopped
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const m = confData[`net${i}`].match(/(?:virtio|e1000|rtl8139|vmxnet3)=([0-9a-fA-F:]+)/);
if(m) macs.push(m[1].toLowerCase());
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
resources.push({
kind: isTemplate ? 'template' : 'host',
name: vm.name || `VM ${vm.vmid}`,
slug: vmSlug,
metadata: {
subType: isTemplate ? 'template' : 'vm',
vmid: vm.vmid,
isProduction: vm.status === 'running',
interfaces,
ip: ips[0] || null
}
});
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
}
// 3. Get LXCs for this node
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
for (const lxc of lxcs) {
const lxcSlug = `lxc-${lxc.vmid}`;
const isTemplate = lxc.template === 1;
let ips = [];
let macs = [];
// Enrich from LXC config
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const hwMatch = confData[`net${i}`].match(/hwaddr=([0-9a-fA-F:]+)/);
const ipMatch = confData[`net${i}`].match(/ip=([0-9\.]+)/); // Ignores dhcp
if(hwMatch) macs.push(hwMatch[1].toLowerCase());
if(ipMatch) ips.push(ipMatch[1]);
}
}
}
} catch(e) {}
const interfaces = [...new Set(macs)].map((mac, i) => ({ mac, ip: ips[i] || null }));
resources.push({
kind: isTemplate ? 'template' : 'host',
name: lxc.name || `LXC ${lxc.vmid}`,
slug: lxcSlug,
metadata: {
subType: isTemplate ? 'template' : 'lxc',
vmid: lxc.vmid,
isProduction: lxc.status === 'running',
interfaces,
ip: ips[0] || null
}
});
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
}
}
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)
};
+134
View File
@@ -0,0 +1,134 @@
const fetch = require('node-fetch');
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: false
});
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) {
throw new Error("Missing Unifi config");
}
// 1. Authenticate
let loginRes = await fetch(`${url}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }),
agent
});
let isUdm = true;
if (!loginRes.ok) {
loginRes = await fetch(`${url}/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: user, password }),
agent
});
isUdm = false;
}
if (!loginRes.ok) {
throw new Error(`Unifi auth failed: ${loginRes.status}`);
}
const cookie = loginRes.headers.get('set-cookie');
// UniFi often requires the CSRF token from the cookie
let csrf = '';
if (cookie) {
const match = cookie.match(/csrf_token=([^;]+)/);
if (match) csrf = match[1];
}
const headers = { 'Cookie': cookie, 'X-Csrf-Token': csrf };
const resources = [];
const edges = [];
const basePath = isUdm ? '/proxy/network' : '';
// 2. Get Devices (Switches/APs)
const devRes = await fetch(`${url}${basePath}/api/s/default/stat/device`, { headers, agent });
const devData = (await devRes.json()).data || [];
for (const dev of devData) {
const devSlug = `unifi-device-${dev.mac.replace(/:/g, '')}`;
resources.push({
kind: 'network_device',
name: dev.name || dev.model,
slug: devSlug,
metadata: {
make: 'Ubiquiti',
model: dev.model,
firmware: dev.version,
interfaces: [{ mac: dev.mac, ip: dev.ip }]
}
});
}
// 3. Get Clients
const clientRes = await fetch(`${url}${basePath}/api/s/default/stat/sta`, { headers, agent });
const clientData = (await clientRes.json()).data || [];
for (const client of clientData) {
const clientSlug = `unifi-client-${client.mac.replace(/:/g, '')}`;
resources.push({
kind: 'host', // Or unmanaged_device initially
name: client.hostname || client.name || client.mac,
slug: clientSlug,
metadata: {
interfaces: [{ mac: client.mac, ip: client.ip }]
}
});
// If we know which switch/AP it's on
if (client.ap_mac) {
const apSlug = `unifi-device-${client.ap_mac.replace(/:/g, '')}`;
edges.push({ parentSlug: apSlug, childSlug: clientSlug, relation: 'connected_to' });
}
}
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)
};
+94
View File
@@ -0,0 +1,94 @@
const router = require('express').Router();
const baoConf = require('@simpleworkjs/bao-conf');
const permission = require('../utils/permission');
const conf = require('@simpleworkjs/conf');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
// Secret fields stored inside secret/sso-manager/conf. These are NEVER returned
// in cleartext by GET /api/conf (masked to MASK below) and, on save, a blank or
// mask-valued submission preserves the stored value so an admin editing an
// unrelated field (e.g. the From address) doesn't have to re-enter — or leak —
// the SMTP password / OAuth JWT secret. Mirrors the plugin-secrets discipline.
const MASK = '********';
const SECRET_PATHS = [
['smtp', 'pass'],
['oauth', 'jwtSecret'],
['voipms', 'password'],
];
function maskSecrets(obj) {
const out = JSON.parse(JSON.stringify(obj));
for (const [grp, key] of SECRET_PATHS) {
if (out[grp] && out[grp][key]) out[grp][key] = MASK;
}
return out;
}
router.get('/', async (req, res) => {
const editable = maskSecrets({
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {},
voipms: conf.voipms || {}
});
res.json(editable);
});
// Shallow-per-key merge of `src` into the live conf object (matches the old
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
// so call-time conf readers see saved values without a restart).
function applyToLiveConf(src) {
if (!src) return;
for (const key of Object.keys(src)) {
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
conf[key] = { ...(conf[key] || {}), ...src[key] };
} else {
conf[key] = src[key];
}
}
}
router.post('/', async (req, res, next) => {
try {
const existing = await baoConf.get('sso-manager/conf') || {};
const incoming = req.body || {};
// Preserve secret fields the admin left blank (or left showing the mask):
// drop them from the incoming merge so the stored value survives. Only a
// genuinely new, non-blank, non-mask value overwrites.
for (const [grp, key] of SECRET_PATHS) {
if (incoming[grp] && incoming[grp][key] !== undefined) {
const submitted = incoming[grp][key];
if (submitted === '' || submitted === MASK) delete incoming[grp][key];
}
}
// Deep merge incoming into existing
for (const key of Object.keys(incoming)) {
if (typeof incoming[key] === 'object' && incoming[key] !== null && !Array.isArray(incoming[key])) {
existing[key] = { ...(existing[key] || {}), ...incoming[key] };
} else {
existing[key] = incoming[key];
}
}
await baoConf.set('sso-manager/conf', existing);
// Reflect the saved values in the live conf immediately (the next boot's
// bao-conf.init() would pick them up too, but this keeps running readers
// current without a restart, as the old conf_manager did). `existing`
// carries the preserved secret values, so live conf keeps them too.
applyToLiveConf(existing);
res.json({ success: true });
} catch(err) {
next(err);
}
});
module.exports = router;
+6 -1
View File
@@ -42,7 +42,12 @@ router.use(async (req, res, next) => {
// --- Resources --- // --- Resources ---
router.get('/resources', async (req, res, next) => { router.get('/resources', async (req, res, next) => {
try { try {
const resources = await Resource.list(); let resources = await Resource.list();
resources = resources.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
return !isAuto || isManaged;
});
// Even admins never receive secret metadata (e.g. client_secret_hash) over // Even admins never receive secret metadata (e.g. client_secret_hash) over
// the wire; projectResources strips it unconditionally. // the wire; projectResources strips it unconditionally.
res.json({ results: projectResources(resources, { fullMetadata: true }) }); res.json({ results: projectResources(resources, { fullMetadata: true }) });
+293
View File
@@ -0,0 +1,293 @@
'use strict';
// Plugin instances API — the loadable, configurable, multi-copy plugin system.
//
// Replaces the old routes/plugins.js (which only toggled cron/enabled on static
// config via a Redis hash). Here every plugin is a PluginInstance row (see
// models/plugin_instance.js) with its own schedule and its secrets in OpenBao
// (utils/plugin_secrets.js), created/edited/loaded/unloaded through this API.
//
// Gated router-wide to the same admin groups as the directory admin API, so
// existing directory admins keep access. Secrets are never returned in
// cleartext — only masked (`********`) — and never persisted in the DB.
const router = require('express').Router();
const permission = require('../utils/permission');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const { PluginInstance, STATUS } = require('../models/plugin_instance');
const { scheduleInstance, unscheduleInstance, runInstanceNow } = require('../services/scheduler');
const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
// Derive a stable, unique slug from an instance name when the caller didn't
// supply one. Lowercases, collapses non-alnum runs to a single hyphen, trims,
// and prefixes `plugin-` if the result would otherwise start with a character
// SLUG_RE rejects. `isTaken(slug)` is consulted for uniqueness (a DB lookup);
// on collision we append `-2`, `-3`, … up to MAX_TRIES, then give up.
function slugify(name) {
let s = String(name || '').toLowerCase().trim();
s = s.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
if (!s) s = 'plugin';
if (!/^[a-z0-9]/.test(s)) s = 'plugin-' + s;
return s.slice(0, 64);
}
async function makeSlug(name, isTaken) {
const base = slugify(name);
if (!await isTaken(base)) return base;
for (let i = 2; i <= 16; i++) {
const cand = `${base}-${i}`.slice(0, 64);
if (!await isTaken(cand)) return cand;
}
return null; // exhausted
}
// Same gate as the directory admin API: app_sso_admin or app_sso_directory_admin
// (app_super_admin is always allowed by permission.byGroup).
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch (err) { next(err); }
});
// Plain object for the wire, with masked secret values attached under
// `secrets` and the run-state fields surfaced. The DB row never holds secrets.
async function serialize(instance) {
const obj = instance.toJSON ? instance.toJSON() : { ...instance };
const secrets = await pluginSecrets.read(instance.id).catch(() => ({}));
obj.secrets = registry.mask(instance.pluginType, secrets);
return obj;
}
// Validate a create/update payload against a plugin type's configSchema.
// Returns an error string or null. `flat` is the merged config + secret values
// (the UI sends one flat object; the API splits it).
function validateFields(type, flat) {
const required = registry.requiredKeys(type);
for (const key of required) {
const v = flat && flat[key];
if (v === undefined || v === null || v === '') {
return `Missing required field: ${key}`;
}
}
return null;
}
// --- Plugin types (for the create-instance picker + form) ---
router.get('/types', (req, res) => {
res.json({ results: registry.getTypes() });
});
// --- List instances ---
router.get('/', async (req, res, next) => {
try {
const instances = await PluginInstance.list();
const out = [];
for (const inst of instances) out.push(await serialize(inst));
res.json({ results: out });
} catch (err) { next(err); }
});
router.get('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: await serialize(inst) });
} catch (err) { next(err); }
});
// --- Create instance ---
router.post('/', async (req, res, next) => {
try {
const { pluginType, name, slug, cron } = req.body;
if (!pluginType) return res.status(400).json({ error: 'pluginType is required' });
if (!registry.getManifest(pluginType)) return res.status(400).json({ error: `Unknown plugin type: ${pluginType}` });
if (!name) return res.status(400).json({ error: 'name is required' });
// Slug is optional: derive it from the name when absent. When supplied,
// validate it (admins editing via API may still pass one explicitly).
let finalSlug = slug;
if (finalSlug) {
if (!SLUG_RE.test(finalSlug)) return res.status(400).json({ error: 'slug must be lowercase letters/digits/_/- (max 64)' });
} else {
finalSlug = await makeSlug(name, async (s) => !!(await PluginInstance.getBySlug(s)));
if (!finalSlug) return res.status(400).json({ error: 'Could not generate a unique slug from the name; supply one explicitly.' });
}
if (cron !== undefined && (typeof cron !== 'string' || !cron.trim())) return res.status(400).json({ error: 'cron must be a non-empty string' });
// `config` from the client is a flat object of all field values (secret +
// non-secret). Split it: non-secret -> DB, secret -> OpenBao.
const flat = (req.body.config && typeof req.body.config === 'object') ? req.body.config : {};
const fieldErr = validateFields(pluginType, flat);
if (fieldErr) return res.status(400).json({ error: fieldErr });
const manifest = registry.getManifest(pluginType);
const { config, secrets } = registry.splitConfig(pluginType, flat);
const enabled = req.body.enabled !== false; // default true
const now = Date.now();
const instance = await PluginInstance.create({
pluginType,
category: manifest.category,
name,
slug: finalSlug,
enabled,
cron: cron || '0 * * * *',
config,
created_by: req.user.uid,
created_on: now,
updated_by: req.user.uid,
updated_on: now
});
try {
await pluginSecrets.write(instance.id, secrets);
} catch (err) {
// Most likely the sso-broker policy lacks secret/plugins/* — the
// operator needs theta-suite >= v1.30.1. Delete the row so a failed
// secret write doesn't strand a half-created instance.
await instance.delete().catch(() => {});
return res.status(400).json({ error: `Failed to store plugin secrets in OpenBao: ${err.message}. Re-run ./setup.sh with theta-suite >= v1.30.1.` });
}
if (enabled) {
await scheduleInstance(instance);
await runInstanceNow(instance.id);
}
res.json({ results: await serialize(instance) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update instance (name/cron/enabled/non-secret config) ---
router.put('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
const updates = {};
if (req.body.name !== undefined) updates.name = req.body.name;
if (req.body.cron !== undefined) {
if (typeof req.body.cron !== 'string' || !req.body.cron.trim()) return res.status(400).json({ error: 'cron must be a non-empty string' });
updates.cron = req.body.cron;
}
if (req.body.enabled !== undefined) updates.enabled = !!req.body.enabled;
// Non-secret config: split the client's flat config so secret fields are
// never written to the DB. Secrets are changed via PUT /:id/secrets.
if (req.body.config !== undefined && typeof req.body.config === 'object') {
const { config } = registry.splitConfig(inst.pluginType, req.body.config);
updates.config = config;
}
updates.updated_by = req.user.uid;
updates.updated_on = Date.now();
const updated = await inst.update(updates);
// Re-schedule if the schedule-relevant fields moved.
if (updates.cron !== undefined || updates.enabled !== undefined) {
await scheduleInstance(updated);
}
res.json({ results: await serialize(updated) });
} catch (err) {
if (err.name === 'SequelizeUniqueConstraintError') {
return res.status(400).json({ error: 'A plugin instance with this slug already exists.' });
}
next(err);
}
});
// --- Update secrets only ---
router.put('/:id/secrets', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
if (!registry.getManifest(inst.pluginType)) return res.status(400).json({ error: `Plugin type ${inst.pluginType} is no longer installed` });
// Keep only declared secret fields; pluginSecrets.write drops blank/MASK
// values so an unchanged masked field is a no-op.
const { secrets } = registry.splitConfig(inst.pluginType, req.body || {});
await pluginSecrets.write(inst.id, secrets);
await inst.update({ updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Test (validate) ---
router.post('/:id/test', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const mod = registry.getModule(inst.pluginType);
if (typeof mod.validate !== 'function') return res.json({ ok: true, note: 'no validate defined' });
const cfg = await pluginSecrets.mergeForRun(inst);
const result = await mod.validate(cfg);
if (result && result.ok) return res.json(result);
return res.status(400).json(result || { ok: false, error: 'validation failed' });
} catch (err) {
return res.status(400).json({ ok: false, error: err.message });
}
});
// --- Load (enable + schedule + run now) ---
router.post('/:id/load', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
const updated = await inst.update({ enabled: true, updated_by: req.user.uid, updated_on: Date.now() });
await scheduleInstance(updated);
await runInstanceNow(updated.id);
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Unload (unschedule + disable) ---
router.post('/:id/unload', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
const updated = await inst.update({ enabled: false, updated_by: req.user.uid, updated_on: Date.now() });
res.json({ results: await serialize(updated) });
} catch (err) { next(err); }
});
// --- Run now (regardless of enabled) ---
router.post('/:id/run', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await runInstanceNow(inst.id);
res.json({ results: true });
} catch (err) { next(err); }
});
// --- Last-run status ---
router.get('/:id/runs', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
res.json({ results: { lastRunAt: inst.lastRunAt, lastStatus: inst.lastStatus, lastError: inst.lastError } });
} catch (err) { next(err); }
});
// --- Delete (unschedule + remove secrets + delete row) ---
router.delete('/:id', async (req, res, next) => {
try {
const inst = await PluginInstance.get(req.params.id);
if (!inst) return res.status(404).json({ error: 'Not found' });
await unscheduleInstance(inst.id);
await pluginSecrets.remove(inst.id); // best-effort
await inst.delete();
res.json({ results: true });
} catch (err) { next(err); }
});
module.exports = router;
+20
View File
@@ -0,0 +1,20 @@
const router = require('express').Router();
const permission = require('../utils/permission');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
router.get('/', (req, res) => {
res.render('conf', {
title: 'Configuration',
user: req.user
});
});
module.exports = router;
+63 -1
View File
@@ -83,7 +83,12 @@ router.get('/me', async (req, res, next) => {
for (const rg of rgs) ids.add(rg.resourceId); for (const rg of rgs) ids.add(rg.resourceId);
} }
const all = await Resource.list(); const all = await Resource.list();
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic)); accessible = all.filter(r => {
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
const isManaged = r.metadata?.managed === true;
if (isAuto && !isManaged) return false;
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
});
} }
// resolvedAddress is the whole point of /me ("how do I reach it") and a // resolvedAddress is the whole point of /me ("how do I reach it") and a
// service inherits it from its host, so it must be computed here rather // service inherits it from its host, so it must be computed here rather
@@ -93,4 +98,61 @@ router.get('/me', async (req, res, next) => {
} catch (err) { next(err); } } catch (err) { next(err); }
}); });
// POST /api/discovery/sync
// Used by external agents (e.g. ldap-client) to push discovery data.
router.post('/sync', async (req, res, next) => {
try {
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
// Assuming the caller provides a source name and payload
const source = req.body.source || 'agent';
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
res.json(envelope({ success: true }));
} catch (err) { next(err); }
});
// POST /api/discovery/promote/:slug
// Promotes an unmanaged device to managed by creating its LDAP groups.
router.post('/promote/:slug', async (req, res, next) => {
try {
const resource = await Resource.getBySlug(req.params.slug);
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
const { Group } = require('../models/group_ldap');
const accessGroup = `${resource.slug}_access`;
const adminGroup = `${resource.slug}_admin`;
// Create groups if they don't exist
try { await Group.get(accessGroup); } catch (e) {
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
else throw e;
}
try { await Group.get(adminGroup); } catch (e) {
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
else throw e;
}
// Link them
const crypto = require('crypto');
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: accessGroup,
accessLevel: 'user'
});
await ResourceGroup.create({
id: crypto.randomUUID(),
resourceId: resource.id,
groupCn: adminGroup,
accessLevel: 'admin'
});
const meta = resource.metadata || {};
meta.managed = true;
await resource.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
});
module.exports = router; module.exports = router;
+3
View File
@@ -34,6 +34,9 @@ const DOCS = {
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, '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')}, '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')}, directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.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')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
+41
View File
@@ -64,10 +64,51 @@ router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview')); router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
router.get('/executive', (req, res) => res.redirect(301, '/overview')); router.get('/executive', (req, res) => res.redirect(301, '/overview'));
router.get('/conf', function(req, res) {
// Admin-only Configuration page. The view renders the shell for anyone
// (like /users, /directory, etc.); the client gates access with
// app.auth.forceLogin(['admin','app_sso_admin']) and the /api/conf endpoint
// enforces app_sso_admin server-side. The previous server-side
// permission.byGroup(req.user,…) 401'd on a browser navigation because this
// app's auth-token is a header set by client JS (localStorage), not a
// cookie — so req.user is undefined on a plain page load.
res.render('conf', {...values});
});
router.get('/directory', function(req, res) { router.get('/directory', function(req, res) {
res.render('directory', {...values}); res.render('directory', {...values});
}); });
router.get('/discovery', 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) {
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
// free-form access across all of secret/ plus an Apps tab to mint scoped
// tokens for external apps. The view renders the shell for any logged-in
// user; the client gates login via app.auth.forceLogin() and derives the
// admin/namespace scope from /api/user/me. The /api/vault proxy enforces the
// same scoping server-side (scopeGuard + the token's own OpenBao policy), so
// the client-derived scope is only cosmetic. vaultAddr is the only
// server-rendered value (it's a non-user-specific env var); uid + isAdmin
// are resolved client-side to avoid the header-vs-navigation auth mismatch.
res.render('vault', {
...values,
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
});
});
// Linkable deep-link to a single resource's modal, e.g. from the resource // Linkable deep-link to a single resource's modal, e.g. from the resource
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side // modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
// use of :slug at all -- the client reads location.pathname itself and opens // use of :slug at all -- the client reads location.pathname itself and opens
+36
View File
@@ -0,0 +1,36 @@
const router = require('express').Router();
const { Webhook } = require('../models/webhook');
const crypto = require('crypto');
// GET /api/webhooks
router.get('/', async (req, res, next) => {
try {
const hooks = await Webhook.list();
res.json({ results: hooks });
} catch (err) { next(err); }
});
// POST /api/webhooks
router.post('/', async (req, res, next) => {
try {
const { name, url, events, secret } = req.body;
const hook = await Webhook.create({
id: crypto.randomUUID(),
name, url, events, secret,
created_on: Math.floor(Date.now() / 1000)
});
res.json({ results: hook });
} catch (err) { next(err); }
});
// DELETE /api/webhooks/:id
router.delete('/:id', async (req, res, next) => {
try {
const hook = await Webhook.get(req.params.id);
if (!hook) return res.status(404).json({ error: 'Not found' });
await hook.delete();
res.json({ success: true });
} catch (err) { next(err); }
});
module.exports = router;
+140
View File
@@ -0,0 +1,140 @@
const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { WebhookEmitter } = require('./webhook_emitter');
const crypto = require('crypto');
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
for (const res of resources) {
if (!res.metadata) res.metadata = {};
let existing = null;
// Attempt matching by MAC if available
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m);
if (macs.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r =>
r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac))
);
}
}
// Fallback matching by IP if no MAC match (weaker)
let ipsToMatch = [];
if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
}
if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
}
if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => {
if (!r.metadata) return false;
if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true;
}
if (r.metadata.interfaces && r.metadata.interfaces.some(i => ipsToMatch.includes(i.ip))) return true;
return false;
});
}
// Fallback matching by Slug or Name
if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list();
existing = allRes.find(r =>
(res.slug && r.slug === res.slug) ||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
);
}
if (existing) {
// Merge metadata
const mergedMeta = { ...existing.metadata, ...res.metadata };
// Merge interfaces cleanly
if (res.metadata.interfaces) {
const existingIntfs = existing.metadata.interfaces || [];
const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip
for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip));
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni);
}
mergedMeta.interfaces = existingIntfs;
}
// Add discovery source
const sources = new Set(mergedMeta.discovery_sources || []);
sources.add(sourceName);
mergedMeta.discovery_sources = [...sources];
mergedMeta.last_seen = Date.now();
await existing.update({
name: res.name || existing.name,
description: res.description || existing.description,
metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000)
});
} else {
// Create new
const sources = [sourceName];
res.metadata.discovery_sources = sources;
res.metadata.last_seen = Date.now();
const slug = res.slug || `${res.kind}-${crypto.randomBytes(4).toString('hex')}`;
const created = await Resource.create({
id: crypto.randomUUID(),
kind: res.kind || 'unmanaged_device',
name: res.name || slug,
slug: slug,
metadata: res.metadata,
created_on: Math.floor(Date.now() / 1000)
});
newDevices++;
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// We can handle edges similarly if needed, but for simplicity we assume edges are managed elsewhere
// or we just trust the plugins to give us explicit parent-child mappings by slug.
if (newDevices > 0) {
console.log(`[DiscoveryReconciler] Source ${sourceName} discovered ${newDevices} new devices.`);
}
}
static async garbageCollect(staleMs = 7 * 24 * 60 * 60 * 1000) {
const allRes = await Resource.list();
const cutoff = Date.now() - staleMs;
let archived = 0;
for (const res of allRes) {
const meta = res.metadata || {};
const sources = meta.discovery_sources || [];
// Only garbage collect things that are exclusively auto-discovered
if (sources.length > 0 && !sources.includes('manual')) {
if (meta.last_seen && meta.last_seen < cutoff && meta.lifecycle_state !== 'archived') {
meta.lifecycle_state = 'archived';
await res.update({ metadata: meta, updated_on: Math.floor(Date.now() / 1000) });
archived++;
WebhookEmitter.emit('discovery.device_archived', res.toJSON());
}
}
}
if (archived > 0) console.log(`[DiscoveryReconciler] Garbage collected ${archived} stale devices.`);
}
}
module.exports = { DiscoveryReconciler };
+172
View File
@@ -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
};
+209
View File
@@ -0,0 +1,209 @@
'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
const redisOpts = { maxRetriesPerRequest: null };
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', redisOpts);
const discoveryQueue = new Queue('discovery', { connection });
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) {
await runPluginJob(job.data && job.data.instanceId);
} else if (job.name === GC) {
console.log('[Scheduler] Running garbage collection');
await DiscoveryReconciler.garbageCollect();
}
}, { connection });
// 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;
}
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;
}
console.log(`[Scheduler] Running plugin: ${instance.slug} (${instance.pluginType})`);
await instance.update({ lastRunAt: Date.now(), lastStatus: STATUS.RUNNING, lastError: null });
try {
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] Plugin ${instance.slug} failed:`, err.message);
await instance.update({ lastStatus: STATUS.ERROR, lastError: String(err.message || err) });
}
}
// 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}` });
}
}
}
// 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
};
+35
View File
@@ -0,0 +1,35 @@
const { Webhook } = require('../models/webhook');
const crypto = require('crypto');
const fetch = require('node-fetch');
class WebhookEmitter {
static async emit(event, payload) {
try {
const hooks = await Webhook.list({ where: { isActive: true } });
const matched = hooks.filter(h => !h.events || h.events.length === 0 || h.events.includes(event));
for (const hook of matched) {
this.sendPayload(hook, event, payload).catch(err => console.error(`Webhook ${hook.name} failed:`, err.message));
}
} catch (e) {
console.error('Error emitting webhook:', e);
}
}
static async sendPayload(hook, event, payload) {
const body = JSON.stringify({ event, payload, timestamp: Date.now() });
const headers = { 'Content-Type': 'application/json' };
if (hook.secret) {
const signature = crypto.createHmac('sha256', hook.secret).update(body).digest('hex');
headers['X-Theta-Signature'] = signature;
}
const res = await fetch(hook.url, { method: 'POST', body, headers, timeout: 5000 });
if (!res.ok) {
throw new Error(`Status ${res.status}`);
}
}
}
module.exports = { WebhookEmitter };
View File
+12
View File
@@ -0,0 +1,12 @@
const express = require('express');
const { createProxyMiddleware } = require('http-proxy-middleware');
const app = express();
app.use('/', createProxyMiddleware({
target: 'http://localhost:8080',
on: {
proxyRes: (proxyRes, req, res) => {
delete proxyRes.headers['x-frame-options'];
}
}
}));
app.listen(3004);
+179
View File
@@ -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);
});
});
+75
View File
@@ -0,0 +1,75 @@
require('./setup');
const { Resource } = require('../models/resource');
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
describe('DiscoveryReconciler', () => {
beforeEach(async () => {
// Clear resources before each test
const all = await Resource.list();
for (const r of all) {
await r.delete();
}
});
it('should create a new device if no MAC or IP matches', async () => {
const payload = {
resources: [{
kind: 'host',
name: 'New Host',
slug: 'new-host',
metadata: {
interfaces: [{ mac: '00:11:22:33:44:55', ip: '192.168.1.100' }]
}
}]
};
await DiscoveryReconciler.reconcile('test-plugin', payload);
const all = await Resource.list();
expect(all).toHaveLength(1);
expect(all[0].name).toBe('New Host');
expect(all[0].metadata.discovery_sources).toContain('test-plugin');
});
it('should merge into an existing device if MAC matches', async () => {
// 1. Initial creation
await DiscoveryReconciler.reconcile('plugin-A', {
resources: [{
kind: 'unmanaged_device',
name: 'Old Host',
slug: 'old-host',
metadata: {
os: 'Linux',
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.5' }]
}
}]
});
// 2. Secondary discovery from a different plugin, same MAC but new IP
await DiscoveryReconciler.reconcile('plugin-B', {
resources: [{
kind: 'host',
name: 'Updated Host', // Name updates aren't overwritten in simple merge, but let's see
metadata: {
cpu_cores: 4,
interfaces: [{ mac: 'AA:BB:CC:DD:EE:FF', ip: '10.0.0.6' }]
}
}]
});
const all = await Resource.list();
expect(all).toHaveLength(1); // Should have merged, not created a new one
const merged = all[0];
expect(merged.metadata.discovery_sources).toContain('plugin-A');
expect(merged.metadata.discovery_sources).toContain('plugin-B');
// Metadata should be merged
expect(merged.metadata.os).toBe('Linux');
expect(merged.metadata.cpu_cores).toBe(4);
// Interface array should be merged/updated
expect(merged.metadata.interfaces).toHaveLength(1);
expect(merged.metadata.interfaces[0].ip).toBe('10.0.0.6'); // Updated IP
});
});
+32
View File
@@ -0,0 +1,32 @@
require('./setup');
const { Webhook } = require('../models/webhook');
const { WebhookEmitter } = require('../services/webhook_emitter');
const crypto = require('crypto');
describe('WebhookEmitter', () => {
let webhook;
beforeEach(async () => {
// Clear webhooks before each test
const all = await Webhook.list();
for (const w of all) {
await w.delete();
}
webhook = await Webhook.create({
id: crypto.randomUUID(),
name: 'Test Webhook',
url: 'http://localhost:9999/dummy',
events: ['discovery.new_device'],
secret: 'mysecret',
created_on: Math.floor(Date.now() / 1000)
});
});
it('should not throw when emitting an event', async () => {
// We expect this to fail network connection but be caught gracefully by the emitter
await WebhookEmitter.emit('discovery.new_device', { name: 'Device1' });
// If it doesn't throw, test passes
expect(true).toBe(true);
});
});
+91
View File
@@ -0,0 +1,91 @@
'use strict';
// Per-instance plugin secrets, stored in OpenBao at `secret/plugins/<id>/conf`.
//
// Plugins run in-process (as BullMQ workers in the SSO Node process), so they
// need no OpenBao token of their own — the SSO reads/writes their secrets
// server-side through the `sso-broker` token (@simpleworkjs/bao-conf), exactly
// like it reads its own `secret/sso-manager/conf`. This mirrors the per-user
// (`secret/users/<uid>/*`) and per-app (`secret/apps/<name>/*`) namespaces.
//
// Only the configSchema fields flagged `secret:true` are stored here; the rest
// of an instance's config lives in the PluginInstance DB row. The admin UI
// only ever sees these masked (`********`).
//
// Requires theta-suite >= v1.30.1: the sso-broker policy must grant
// `secret/data/plugins/*` + `secret/metadata/plugins/*`. Without it, write/
// read fail with a 403 — the API surfaces that as a clear error so the operator
// knows to re-run `./setup.sh`.
const baoConf = require('@simpleworkjs/bao-conf');
// Instance ids are ORM-generated uuids, so this is defense-in-depth against a
// bogus id ever being interpolated into a secret path. 404s are expected
// (no secret written yet); other malformed input is rejected hard.
function assertId(id) {
if (typeof id !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) {
const err = new Error('invalid plugin instance id for secret path');
err.status = 400;
throw err;
}
}
function path(id) {
return `plugins/${id}/conf`; // baoConf.get/set add the secret/data prefix
}
// Read the secret field values for an instance. Returns {} when none are
// stored yet (a brand-new instance, or one with no secret fields). A 404 from
// OpenBao is normal — anything else propagates.
async function read(id) {
assertId(id);
try {
const data = await baoConf.get(path(id));
return (data && typeof data === 'object') ? data : {};
} catch (err) {
// bao-conf treats a missing KV path as null/empty, but a 403 means the
// sso-broker policy lacks secret/plugins/* — surface that distinctly.
if (err && /403|permission/i.test(err.message)) throw err;
return {};
}
}
// Write (replace) the secret field values for an instance. `secrets` is a flat
// {field: value} object of only the secret configSchema fields. Empty/blank
// values are dropped so we never store a masked placeholder back as a secret.
async function write(id, secrets) {
assertId(id);
const clean = {};
for (const [k, v] of Object.entries(secrets || {})) {
if (v === undefined || v === null || v === '' || v === '********') continue;
clean[k] = v;
}
await baoConf.set(path(id), clean);
}
// Merge the stored secret field values over the instance's non-secret config,
// producing the single `config` object the plugin's run()/validate() receive.
// Non-secret values come from the DB row; secret values come from OpenBao.
async function mergeForRun(instance) {
if (!instance) return {};
const config = (instance.config && typeof instance.config === 'object') ? instance.config : {};
const secrets = await read(instance.id);
return { ...config, ...secrets };
}
// Best-effort delete of the instance's secret namespace. Called when an
// instance is deleted. A 404 (already gone / never written) is fine; anything
// else is logged and swallowed so a stuck OpenBao can't strand an instance row.
async function remove(id) {
assertId(id);
try {
const res = await baoConf.request('DELETE', `secret/metadata/plugins/${id}/conf`);
if (res && res.status && res.status !== 404 && !res.ok) {
console.error(`[plugin_secrets] delete for ${id} returned ${res.status}`);
}
} catch (err) {
console.error(`[plugin_secrets] failed to delete secrets for ${id}:`, err.message);
}
}
module.exports = { read, write, remove, mergeForRun };
+4 -1
View File
@@ -43,8 +43,11 @@ module.exports = {
// non-admin had no signposted destination at all. // non-admin had no signposted destination at all.
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []}, {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']}, {href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']}, {href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, {href: '/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']}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
], ],
}; };
+243
View File
@@ -0,0 +1,243 @@
'use strict';
// Vault broker — mints scoped OpenBao tokens for end users, admins, and
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
// `sso-broker` token role created by theta-env/setup.sh.
//
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
// secret/* admin UI sessions (sso-admin policy)
//
// The sso-broker policy grants update on auth/token/create/sso-broker and on
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
// create the per-subject policies and mint their tokens. Per-user/admin tokens
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
const baoConf = require('@simpleworkjs/bao-conf');
const { createClient } = require('redis');
const express = require('express');
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
const conf = require('@simpleworkjs/conf');
const permission = require('./permission');
const ROLE = 'sso-broker';
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
let redisClient;
async function getRedis() {
if (!redisClient) {
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
redisClient = createClient({ url });
redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
await redisClient.connect();
}
return redisClient;
}
async function cacheGet(key) {
try { return await (await getRedis()).get(key); } catch (e) { return null; }
}
async function cacheSet(key, value, ttl) {
try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
}
// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
// SSO_VAULT_TOKEN). Throws on non-2xx.
async function bao(method, path, body) {
const res = await baoConf.request(method, path, body);
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
}
return res;
}
// Ensure an ACL policy exists AND carries the latest HCL. Always (re)writes —
// `bao policy write` is an idempotent overwrite — so policy edits (e.g. adding
// a list grant on a directory path) propagate on the next vault-page visit
// without an operator re-running setup.sh. Skipping on an existing policy
// would strand the old, narrower HCL forever.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status !== 200 && existing.status !== 404) {
const t = await existing.text().catch(() => '');
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
}
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
}
// Mint a token through the sso-broker role with the given policies. Returns
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
async function mintToken(policies) {
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
const json = await res.json();
const token = json && json.auth && json.auth.client_token;
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
return { token, ttl };
}
// ── Per-user token ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
// into a policy path, so reject anything but a safe charset.
// The bare `secret/metadata/users/<uid>` grant is required to LIST the
// contents of the namespace: `.../*` covers nested paths but NOT the
// directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
}
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
// Re-minted when the cache entry expires (a little before the token's own TTL).
async function getOrCreateUserToken(uid) {
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
const cacheKey = `vault_token:${uid}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
const { token, ttl } = await mintToken([`user-${uid}`]);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
}
// ── Admin token (read/write all of secret/) ─────────────────────────────────
async function getOrCreateAdminToken(uid) {
const cacheKey = `vault_token:admin:${uid || 'global'}`;
const cached = await cacheGet(cacheKey);
if (cached) return cached;
const { token, ttl } = await mintToken(['sso-admin']);
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
return token;
}
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own
// namespace root (see userPolicyHcl for why `/*` alone isn't enough).
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
}
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
// (the admin UI shows it with a copy button); it is not stored retrievably, so
// a later compromise of an admin session cannot recover previously-minted app
// tokens. The caller must record it in the external app immediately.
async function mintAppToken(name) {
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
}
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
const { token, ttl } = await mintToken([`app-${name}`]);
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
}
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
// nothing). The guard mints a server-side token for the user (per-user or
// admin) and enforces the path prefix as defense-in-depth on top of the
// token's own policy; the proxy injects ONLY that token and strips the
// client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) {
try {
await permission.byGroup(user, [ADMIN_GROUP]);
return true;
} catch (e) {
return false;
}
}
// Normalize a KV-v2 request path by stripping the data/metadata segment so the
// prefix check works on the logical path: /secret/data/users/alice/foo ->
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
function normalizeVaultPath(p) {
const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
return norm;
}
async function scopeGuard(req, res, next) {
if (!req.user || req.user.isMachine) {
return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
}
const uid = req.user.uid;
const admin = await isAdmin(req.user);
let token;
try {
token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
} catch (e) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
}
// Defense-in-depth: confirm the requested path is within the subject's
// namespace. Admins roam all of secret/; users are confined to
// secret/users/<uid>/. (The token's own policy enforces the same at the
// OpenBao layer; this catches a buggy/malicious client early with a clear
// 403 instead of an opaque OpenBao denial.)
const norm = normalizeVaultPath(req.path);
if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' });
}
const base = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/');
if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' });
}
req.vaultToken = token;
req.vaultIsAdmin = admin;
next();
}
function vaultProxy() {
return createProxyMiddleware({
target: VAULT_ADDR,
changeOrigin: true,
pathRewrite: { '^/': '/v1/' },
on: {
proxyReq(proxyReq, req, res, options) {
fixRequestBody(proxyReq, req, res, options);
// Inject ONLY the server-minted scoped token; strip the client's
// sso session/api auth so it never reaches OpenBao.
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
proxyReq.removeHeader('auth-token');
proxyReq.removeHeader('authorization');
},
},
});
}
// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
const mintAppRouter = express.Router();
mintAppRouter.post('/', async (req, res, next) => {
try {
await permission.byGroup(req.user, [ADMIN_GROUP]);
const name = (req.body && req.body.name || '').trim();
if (!name) return res.status(400).json({ error: 'name is required' });
const result = await mintAppToken(name);
res.json(result);
} catch (e) {
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
next(e);
}
});
module.exports = {
getOrCreateUserToken,
getOrCreateAdminToken,
mintAppToken,
ensurePolicy,
scopeGuard,
vaultProxy,
mintAppRouter,
};
+276
View File
@@ -0,0 +1,276 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
$(document).ready(function() {
loadConf();
loadTos();
});
async function loadConf() {
try {
const data = await app.api.get('conf');
// Populate SMTP
if (data.smtp) {
$('#smtp-host').val(data.smtp.host || '');
$('#smtp-port').val(data.smtp.port || 587);
$('#smtp-user').val(data.smtp.user || '');
$('#smtp-pass').val(data.smtp.pass || '');
$('#smtp-from').val(data.smtp.from || '');
$('#smtp-secure').prop('checked', !!data.smtp.secure);
}
// Populate OAuth
if (data.oauth) {
$('#oauth-issuer').val(data.oauth.issuer || '');
$('#oauth-jwtsecret').val(data.oauth.jwtSecret || '');
if (data.oauth.token_lifetime) {
$('#oauth-token-access').val(data.oauth.token_lifetime.access_token || 3600);
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
}
}
// Populate SMS (VoIP.ms)
if (data.voipms) {
$('#voipms-username').val(data.voipms.username || '');
$('#voipms-did').val(data.voipms.did || '');
$('#voipms-password').val(data.voipms.password || '');
}
} catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
}
}
async function saveConf() {
const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
},
oauth: {
issuer: $('#oauth-issuer').val(),
jwtSecret: $('#oauth-jwtsecret').val(),
token_lifetime: {
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
},
voipms: {
username: $('#voipms-username').val(),
did: $('#voipms-did').val(),
password: $('#voipms-password').val()
}
};
try {
await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
}
}
function togglePassword(id) {
const el = document.getElementById(id);
if (el.type === 'password') {
el.type = 'text';
} else {
el.type = 'password';
}
}
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
</script>
<div class="container py-4">
<div class="row mb-4">
<div class="col d-flex justify-content-between align-items-center">
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p>
</div>
<div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" id="smtp-host">
</div>
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access">
</div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
</div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3">
<label class="form-label">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="mb-3">
<label class="form-label">DID (sender number)</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
<div class="mb-3">
<label class="form-label">API Password</label>
<div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control" id="tos-content" rows="8"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div>
</div>
</div>
</div>
</div>
<%- include('bottom') %>
+178 -6
View File
@@ -3,7 +3,25 @@
<div class="container mt-4"> <div class="container mt-4">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card shadow"> <div class="card shadow">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
<i class="fa-solid fa-server"></i> Directory
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
<i class="fa-solid fa-network-wired"></i> Discovery
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="directoryTabsContent">
<div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
<div class="border-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<i class="fa-solid fa-server"></i> Directory Management <i class="fa-solid fa-server"></i> Directory Management
@@ -49,12 +67,9 @@
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span> <span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}} {{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}} {{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
<span class="badge bg-light text-dark border">{{hostName}}</span> <a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
<br>
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
<strong>{{name}}</strong> <strong>{{name}}</strong>
</a> </a>
<small class="text-muted">{{slug}}</small>
</td> </td>
<td> <td>
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}} {{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
@@ -77,6 +92,101 @@
</table> </table>
</div> </div>
</div> </div>
</div>
<!-- Discovery Tab Pane -->
<div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-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-network-wired"></i> Network Discovery Dashboard
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></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">Name / Source</th>
<th>Type</th>
<th>IP Address</th>
<th>Status</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody id="discovery-list" jq-repeat="discoveryResources">
<tr id="discovery-row-{{slug}}">
<td class="ps-3">
<div class="fw-bold">{{name}}</div>
<div class="text-muted small">
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
</div>
</td>
<td>
<span class="badge bg-secondary me-1">{{kind}}</span>
{{#metadata.discovery_sources}}
<span class="badge bg-info text-dark me-1" style="font-size: 0.7em;">{{.}}</span>
{{/metadata.discovery_sources}}
{{#metadata.subType}}
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
{{/metadata.subType}}
</td>
<td>
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
{{#metadata.interfaces.length}}
<div class="mt-1 small text-muted">
{{#metadata.interfaces}}
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
{{/metadata.interfaces}}
</div>
{{/metadata.interfaces.length}}
</td>
<td>
{{#metadata.managed}}
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
{{/metadata.managed}}
{{^metadata.managed}}
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
{{/metadata.managed}}
</td>
<td class="text-end pe-3">
{{^metadata.managed}}
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
</button>
{{/metadata.managed}}
{{#metadata.managed}}
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
Promoted
</button>
{{/metadata.managed}}
</td>
</tr>
</tbody>
<tbody id="discovery-empty-state" style="display: none;">
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
<h5>No resources found</h5>
<p>Check your filters or ensure the discovery agents are running.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -127,7 +237,7 @@
</div> </div>
<div class="col-6"> <div class="col-6">
<label class="form-label">Host / URI Address</label> <label class="form-label">Host / URI Address</label>
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://..."> <input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://... or comma-separated IPs">
</div> </div>
</div> </div>
@@ -1107,6 +1217,68 @@
app.messages.action('Failed to delete', $target, 'danger'); app.messages.action('Failed to delete', $target, 'danger');
} }
} }
// --- DISCOVERY SCRIPTS ---
let allDiscoveryResources = [];
function loadDiscoveryResources() {
app.api.get('discovery/resources', function(err, res) {
if(err) {
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
return;
}
allDiscoveryResources = res.results || [];
renderDiscoveryTable();
});
}
function renderDiscoveryTable() {
const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => {
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
const isManaged = !!(r.metadata && r.metadata.managed);
if(isManaged) return false;
return true;
});
$.scope.discoveryResources.empty();
for(const r of filtered) {
$.scope.discoveryResources.push(r);
}
if(filtered.length === 0) {
$('#discovery-list').hide();
$('#discovery-empty-state').show();
} else {
$('#discovery-list').show();
$('#discovery-empty-state').hide();
}
}
function promoteResource(slug) {
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
if(err) {
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
return;
}
const resource = allDiscoveryResources.find(r => r.slug === slug);
if(resource) {
resource.metadata = resource.metadata || {};
resource.metadata.managed = true;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderDiscoveryTable();
loadResources(); // Also update directory tab
});
}
// 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();
});
</script> </script>
<%- include('bottom') %> <%- include('bottom') %>
+173
View File
@@ -0,0 +1,173 @@
<%- include('top') %>
<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 active" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
</li>
<li class="nav-item">
<a class="nav-link" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
</li>
</ul>
<div class="card shadow border-top-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
<option value="all">All Resources</option>
<option value="unmanaged" selected>Unmanaged Only</option>
<option value="managed">Managed Only</option>
</select>
</div>
</div>
<div class="card-header actionMessage" style="display:none"></div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> View discovered network resources and promote them to managed SSO groups.
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></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">Name / Source</th>
<th>Type</th>
<th>IP Address</th>
<th>Status</th>
<th class="text-end pe-3">Actions</th>
</tr>
</thead>
<tbody id="discovery-list" jq-repeat="resources">
<tr id="resource-row-{{slug}}">
<td class="ps-3">
<div class="fw-bold">{{name}}</div>
<div class="text-muted small">
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
</div>
</td>
<td>
<span class="badge bg-secondary">{{kind}}</span>
{{#metadata.subType}}
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
{{/metadata.subType}}
</td>
<td>
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
{{#metadata.interfaces.length}}
<div class="mt-1 small text-muted">
{{#metadata.interfaces}}
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
{{/metadata.interfaces}}
</div>
{{/metadata.interfaces.length}}
</td>
<td>
{{#metadata.managed}}
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
{{/metadata.managed}}
{{^metadata.managed}}
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
{{/metadata.managed}}
</td>
<td class="text-end pe-3">
{{^metadata.managed}}
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
</button>
{{/metadata.managed}}
{{#metadata.managed}}
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
Promoted
</button>
{{/metadata.managed}}
</td>
</tr>
</tbody>
<tbody id="empty-state" style="display: none;">
<tr>
<td colspan="5" class="text-center py-5 text-muted">
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
<h5>No resources found</h5>
<p>Check your filters or ensure the discovery agents are running.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'admin']);
let allResources = [];
function loadResources() {
app.api.get('discovery/resources', function(err, res) {
if(err) {
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
return;
}
allResources = res.results || [];
renderTable();
});
}
function renderTable() {
const search = $('#search-filter').val().toLowerCase();
const managedFilter = $('#filter-managed').val();
const filtered = allResources.filter(r => {
// Name search
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
// Managed filter
const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false;
return true;
});
$.scope.resources.empty();
for(const r of filtered) {
$.scope.resources.push(r);
}
if(filtered.length === 0) {
$('#discovery-list').hide();
$('#empty-state').show();
} else {
$('#discovery-list').show();
$('#empty-state').hide();
}
}
function promoteResource(slug) {
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
if(err) {
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
return;
}
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
renderTable();
});
}
$(document).ready(function() {
loadResources();
});
</script>
<%- include('bottom') %>
+25
View File
@@ -0,0 +1,25 @@
<%- include('top') %>
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-6 text-center">
<div class="mb-4">
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
</div>
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
<p class="text-muted mb-4">
<% if (error.status === 404) { %>
The page you are looking for doesn't exist or has been moved.
<% } else { %>
An unexpected error occurred. Please try again later.
<% } %>
</p>
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
<i class="fa-solid fa-house me-2"></i>Return to Home
</a>
</div>
</div>
</div>
<%- include('bottom') %>
+64 -22
View File
@@ -1,15 +1,6 @@
<%- include('top') %> <%- include('top') %>
<style> <style>
.portal-banner {
background-color: var(--bs-primary);
color: white;
padding: 2.5rem 1rem;
margin-bottom: 2rem;
border-radius: .5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}
.portal-banner h1 { font-weight: 700; }
.catalog-grid { .catalog-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
@@ -23,7 +14,19 @@
} }
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; } .service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
.service-card .card-body { flex: 1; } .service-card .card-body { flex: 1; }
.card-icon { font-size: 1.4rem; width: 1.8rem; text-align: center; } .card-icon {
font-size: 1.4rem;
width: 1.8rem;
text-align: center;
display: inline-flex;
align-items: center;
justify-content: center;
}
.card-icon-img {
width: 1.8rem;
height: 1.8rem;
object-fit: contain;
}
.howto code { .howto code {
display: block; display: block;
background: var(--bs-tertiary-bg, #f1f3f5); background: var(--bs-tertiary-bg, #f1f3f5);
@@ -37,12 +40,6 @@
</style> </style>
<div class="container mt-4"> <div class="container mt-4">
<div class="portal-banner text-center">
<h1><%- name %> Portal</h1>
<p class="lead mb-3">Everything the lab offers — what you can reach, and how to reach it.</p>
<a href="/profile" class="btn btn-light shadow-sm"><i class="fa-solid fa-user"></i> My Profile</a>
</div>
<div class="row mb-4"> <div class="row mb-4">
<div class="col-md-8"> <div class="col-md-8">
<input type="text" id="catalog-search" class="form-control shadow-sm" <input type="text" id="catalog-search" class="form-control shadow-sm"
@@ -68,8 +65,36 @@
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul> <ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
</div> </div>
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-success"></i> My Access</h3> <!-- My Access section with tabs -->
<div class="catalog-grid mb-5" id="my-services"></div> <div class="card shadow mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
</div>
<div class="px-3 pt-3 border-bottom">
<ul class="nav nav-tabs border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
<i class="fa-solid fa-cube"></i> Services
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
<i class="fa-solid fa-server"></i> Hosts
</button>
</li>
</ul>
</div>
<div class="card-body">
<div class="tab-content">
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
<div class="catalog-grid" id="my-services"></div>
</div>
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
<div class="catalog-grid" id="my-hosts"></div>
</div>
</div>
</div>
</div>
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3> <h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p> <p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
@@ -99,6 +124,17 @@
}); });
} }
// Render icon as either Font Awesome class or <img> for URL
function renderIcon(icon, kind) {
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
// If it starts with http, treat it as an image URL
if (/^https?:\/\//i.test(icon)) {
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
}
// Otherwise it's a Font Awesome class
return '<i class="' + esc(icon) + ' card-icon"></i>';
}
// "How do I actually use this?" — the question the directory exists to // "How do I actually use this?" — the question the directory exists to
// answer and the one the old portal never did. Everything here is derived // answer and the one the old portal never did. Everything here is derived
// from directory metadata; nothing is hardcoded per-service. // from directory metadata; nothing is hardcoded per-service.
@@ -142,7 +178,6 @@
function cardHtml(r, accessible) { function cardHtml(r, accessible) {
var md = r.metadata || {}; var md = r.metadata || {};
var icon = md.icon || KIND_ICONS[r.kind] || 'fa-solid fa-cube';
var blurb = md.tagline || r.description || 'No description provided'; var blurb = md.tagline || r.description || 'No description provided';
var href = accessible ? linkFor(r) : null; var href = accessible ? linkFor(r) : null;
var lines = accessible ? howTo(r) : []; var lines = accessible ? howTo(r) : [];
@@ -166,10 +201,12 @@
+ '<i class="fa-solid fa-hand"></i> Request access</button>'; + '<i class="fa-solid fa-hand"></i> Request access</button>';
} }
var iconHtml = renderIcon(md.icon, r.kind);
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">' return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
+ '<div class="card-body">' + '<div class="card-body">'
+ '<h5 class="card-title d-flex align-items-start gap-2">' + '<h5 class="card-title d-flex align-items-start gap-2">'
+ '<i class="' + esc(icon) + ' card-icon ' + (accessible ? 'text-success' : 'text-secondary') + '"></i>' + iconHtml
+ '<span>' + esc(r.name) + '</span>' + '<span>' + esc(r.name) + '</span>'
+ '</h5>' + '</h5>'
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind) + '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
@@ -235,7 +272,12 @@
} }
function renderAll() { function renderAll() {
renderGrid('my-services', state.mine, true); // Split mine into services and hosts
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
renderGrid('my-services', myServices, true);
renderGrid('my-hosts', myHosts, true);
renderGrid('other-services', state.others, false); renderGrid('other-services', state.others, false);
renderRequests(); renderRequests();
renderApprovals(); renderApprovals();
@@ -256,7 +298,7 @@
var mineIds = {}; var mineIds = {};
state.mine.forEach(function(r){ mineIds[r.id] = true; }); state.mine.forEach(function(r){ mineIds[r.id] = true; });
state.others = (allRes.results || []).filter(function(r){ state.others = (allRes.results || []).filter(function(r){
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth'; return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth' && (r.metadata && r.metadata.managed);
}); });
// Requests are best-effort: a failure here must not blank the catalog. // Requests are best-effort: a failure here must not blank the catalog.
-64
View File
@@ -162,50 +162,10 @@
} }
} }
// ── Terms of Service ──────────────────────────────────────────────────
async function loadTos() {
try {
const tos = await app.tos.get();
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
}
function saveTos() {
const content = document.getElementById('tos-content').value.trim();
const resetAcceptance = document.getElementById('tos-reset-acceptance').checked;
const msgEl = document.getElementById('tos-result');
if (!content) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Terms of Service text cannot be empty.';
msgEl.style.display = '';
return;
}
app.tos.update({content, resetAcceptance}, function(error, data) {
if (error) {
msgEl.className = 'alert alert-danger mt-2';
msgEl.textContent = 'Failed: ' + ((data && data.message) || error);
msgEl.style.display = '';
return;
}
msgEl.className = 'alert alert-success mt-2';
msgEl.textContent = 'Saved.' + (data.resetCount ? ' ' + data.resetCount + ' user(s) will be asked to re-accept.' : '');
msgEl.style.display = '';
document.getElementById('tos-reset-acceptance').checked = false;
loadTos();
});
}
$(document).ready(function() { $(document).ready(function() {
loadDashboard(); loadDashboard();
loadHistory(); loadHistory();
toggleFilterInputs(); toggleFilterInputs();
loadTos();
loadMetrics(); loadMetrics();
}); });
</script> </script>
@@ -385,30 +345,6 @@
</div> </div>
</div> </div>
<!-- TOS Card -->
<div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center">
<div><i class="fa-solid fa-file-contract"></i> Terms of Service Editor</div>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control shadow-sm" id="tos-content" rows="12"></textarea>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">
Require all users to re-accept these terms
</label>
</div>
<button class="btn btn-primary shadow-sm" onclick="saveTos()">
<i class="fa-solid fa-floppy-disk"></i> Save
</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div>
<!-- Actionable Metrics Card --> <!-- Actionable Metrics Card -->
<div class="card shadow mb-5"> <div class="card shadow mb-5">
<div class="card-header d-flex justify-content-between align-items-center"> <div class="card-header d-flex justify-content-between align-items-center">
+396
View File
@@ -0,0 +1,396 @@
<%- include('top') %>
<div class="container mt-4">
<div class="row">
<div class="col-12">
<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
</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> 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">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 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">Loaded</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Unloaded</span>{{/enabled}}
</td>
<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="plugins-empty-state" style="display: none;">
<tr>
<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>
</table>
</div>
</div>
</div>
</div>
</div>
<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), 'danger'); return; }
var list = res.results || [];
pluginsById = {};
$.scope.plugins.empty();
if (!list.length) {
$('#plugins-list').hide();
$('#plugins-empty-state').show();
} else {
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();
$('#plugins-empty-state').hide();
}
});
}
// Build an HTML form fragment for a type's configSchema. `prefix` namespaces
// the field ids so the New and Edit modals don't collide. `values` (optional)
// pre-fills fields (masked secrets stay masked; non-secret values are shown).
// `includeSecrets` (default true) — the Edit (non-secret) modal passes false so
// secret fields are never shown there (secrets have their own modal); the New
// modal passes true so initial secrets can be set at create time.
function configFormHtml(type, prefix, values, includeSecrets) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
if (!schema || !schema.length) return '<p class="text-muted">No configuration fields for this plugin.</p>';
if (includeSecrets === undefined) includeSecrets = true;
var v = values || {};
var html = '';
schema.forEach(function(f) {
if (!includeSecrets && f.secret) return;
var val = v[f.key];
if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : '';
var ph = f.placeholder ? (' placeholder="' + f.placeholder + '"') : '';
var label = f.label + (f.secret ? ' <span class="text-warning" title="stored in OpenBao"><i class="fa-solid fa-key"></i></span>' : '') + (f.required ? ' <span class="text-danger">*</span>' : '');
html += '<div class="mb-3">' +
'<label class="form-label">' + label + '</label>' +
'<input type="' + inputType + '" class="form-control" id="' + prefix + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '"' + req + ph + '>';
if (f.secret) html += '<div class="form-text">Leave blank to keep the current secret.</div>';
html += '</div>';
});
return html;
}
// ── Schedule picker (Hourly / Daily / Weekly / Custom) ───────────────────
// The stored value is always a 5-field cron string. A `<select>` picks a
// preset; "Custom" reveals the raw cron text input. `prefix` namespaces the
// element ids (np-/ed-) so the two modals don't collide.
var CRON_PRESETS = [
{ key: 'hourly', label: 'Hourly', cron: '0 * * * *' },
{ key: 'daily', label: 'Daily (midnight)', cron: '0 0 * * *' },
{ key: 'weekly', label: 'Weekly (Sun)', cron: '0 0 * * 0' },
{ key: 'custom', label: 'Custom…', cron: null },
];
function cronKeyFor(cron) {
var m = CRON_PRESETS.filter(function(p){ return p.cron === cron; })[0];
return m ? m.key : 'custom';
}
function cronSelectHtml(prefix, current) {
current = current || '0 * * * *';
var key = cronKeyFor(current);
var opts = CRON_PRESETS.map(function(p){
return '<option value="' + p.key + '"' + (p.key === key ? ' selected' : '') + '>' + p.label + '</option>';
}).join('');
var rawStyle = key === 'custom' ? '' : ' style="display:none"';
var rawVal = key === 'custom' ? current : current;
return '<select class="form-select" id="' + prefix + 'cron-select" onchange="onCronChange(\'' + prefix + '\')">' + opts + '</select>' +
'<input type="text" class="form-control font-monospace mt-2" id="' + prefix + 'cron" value="' + rawVal + '"' + rawStyle + '>';
}
function onCronChange(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
var raw = document.getElementById(prefix + 'cron');
if (!sel || !raw) return;
if (sel.value === 'custom') {
raw.style.display = '';
} else {
raw.style.display = 'none';
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) raw.value = preset.cron;
}
}
function cronFromForm(prefix) {
var sel = document.getElementById(prefix + 'cron-select');
if (sel && sel.value !== 'custom') {
var preset = CRON_PRESETS.filter(function(p){ return p.key === sel.value; })[0];
if (preset) return preset.cron;
}
var raw = document.getElementById(prefix + 'cron');
return (raw && raw.value.trim()) || '0 * * * *';
}
// Collect a flat {field: value} object from the rendered config form.
function collectConfig(type, prefix) {
var schema = pluginTypes[type] && pluginTypes[type].configSchema;
var out = {};
if (!schema) return out;
schema.forEach(function(f) {
var el = document.getElementById(prefix + f.key);
if (el) out[f.key] = el.value;
});
return out;
}
function typeOptionsHtml(selected) {
var opts = '<option value="">Select a plugin type…</option>';
Object.keys(pluginTypes).sort().forEach(function(t) {
opts += '<option value="' + t + '"' + (t === selected ? ' selected' : '') + '>' + pluginTypes[t].name + ' (' + t + ')</option>';
});
return opts;
}
// --- New Plugin modal ---
function openNewPluginModal() {
app.modal.open({
title: 'New Plugin',
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Plugin Type <span class="text-danger">*</span></label>' +
'<select class="form-select" id="np-type" onchange="renderNewPluginFields()">' + typeOptionsHtml('') + '</select></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="np-name" placeholder="Proxmox — Home Lab"></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('np-', '0 * * * *') +
'<div class="form-text">A slug is derived automatically from the name.</div></div>' +
'<hr><h6>Configuration</h6><div id="np-config-fields"><p class="text-muted">Select a plugin type first.</p></div>',
footer: { buttonsHtml: app.modal.footerButtons({ onSave: 'saveNewPlugin()', saveLabel: 'Create Plugin' }) }
});
}
function renderNewPluginFields() {
var type = document.getElementById('np-type').value;
document.getElementById('np-config-fields').innerHTML = configFormHtml(type, 'np-');
}
async function saveNewPlugin() {
var type = document.getElementById('np-type').value;
if (!type) return app.messages.action('Select a plugin type.', app.modal.body(), 'danger');
var name = document.getElementById('np-name').value.trim();
var cron = cronFromForm('np-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(type, 'np-');
try {
await app.api.post('plugins', { pluginType: type, name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin created and scheduled.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to create plugin', app.modal.body(), 'danger');
}
}
// --- Edit (non-secret) modal ---
function openEditModal(id) {
var p = pluginsById[id];
if (!p) return;
app.modal.open({
title: 'Edit — ' + p.name,
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>' +
'<div class="mb-3"><label class="form-label">Name <span class="text-danger">*</span></label>' +
'<input type="text" class="form-control" id="ed-name" value="' + String(p.name).replace(/"/g, '&quot;') + '"></div>' +
'<div class="mb-3"><label class="form-label">Slug (read-only)</label>' +
'<input type="text" class="form-control font-monospace" id="ed-slug" value="' + p.slug + '" readonly></div>' +
'<div class="mb-3"><label class="form-label">Schedule</label>' +
cronSelectHtml('ed-', p.cron || '0 * * * *') + '</div>' +
'<hr><h6>Configuration</h6><div id="ed-config-fields">' + configFormHtml(p.pluginType, 'ed-', p.config, false) + '</div>' +
'<div class="form-text">Secret fields are edited separately with the <i class="fa-solid fa-key"></i> button.</div>',
footer: {
metaHtml: app.modal.formatAudit ? app.modal.formatAudit(p, { formatDate: function(ms){ return moment(ms).format('YYYY-MM-DD HH:mm'); } }) : '',
buttonsHtml: app.modal.footerButtons({ onSave: 'saveEdit("' + id + '")', saveLabel: 'Save' })
}
});
}
async function saveEdit(id) {
var p = pluginsById[id];
if (!p) return;
var name = document.getElementById('ed-name').value.trim();
var cron = cronFromForm('ed-');
if (!name) return app.messages.action('Name is required.', app.modal.body(), 'danger');
var config = collectConfig(p.pluginType, 'ed-');
try {
await app.api.put('plugins/' + id, { name: name, cron: cron, config: config });
app.modal.close();
app.messages.toast('Plugin saved.', 'success');
loadPlugins();
} catch (err) {
app.messages.action(err.message || 'Failed to save', app.modal.body(), 'danger');
}
}
// --- Edit Secrets modal ---
function openSecretsModal(id) {
var p = pluginsById[id];
if (!p) return;
var masked = p.secrets || {};
// Render only the secret fields, prefilled with the masked values.
var schema = (pluginTypes[p.pluginType] && pluginTypes[p.pluginType].configSchema) || [];
var secretFields = schema.filter(function(f) { return f.secret; });
var html = '<div class="actionMessage mb-3" style="display:none"></div>' +
'<p class="text-muted small">Stored in OpenBao. Leave a field blank to keep its current value.</p>';
if (!secretFields.length) {
html += '<p class="text-muted">This plugin has no secret fields.</p>';
} else {
secretFields.forEach(function(f) {
var val = masked[f.key] || '';
html += '<div class="mb-3"><label class="form-label">' + f.label + '</label>' +
'<input type="password" class="form-control" id="sec-' + f.key + '" value="' + String(val).replace(/"/g, '&quot;') + '" 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') %>
+293 -354
View File
@@ -6,13 +6,11 @@
var isOwnProfile = !location.pathname.includes('/users/'); var isOwnProfile = !location.pathname.includes('/users/');
function renderProfile(user){ function renderProfile(user){
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow(); user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
user.managerUids = (user.manager || []).map(app.user.dnToUid); user.managerUids = (user.manager || []).map(app.user.dnToUid);
$('#profile-uid-header').text(user.uid);
$.scope.user.update(user); $.scope.user.update(user);
$.scope.passwordReset.update(user);
}; };
async function renderUserGroups(user){ async function renderUserGroups(user){
@@ -92,9 +90,6 @@
async function renderMyServices(){ async function renderMyServices(){
try{ try{
let res = await app.api.get('discovery/me'); let res = await app.api.get('discovery/me');
// The server already resolves this by walking the graph, so a service
// with no address of its own inherits its host's. Only fall back
// locally when it genuinely resolved to nothing.
res.results.forEach(r => { res.results.forEach(r => {
r.resolvedAddress = r.resolvedAddress r.resolvedAddress = r.resolvedAddress
|| (r.metadata && r.metadata.address) || (r.metadata && r.metadata.address)
@@ -112,34 +107,32 @@
try{ try{
let uid = currentUser.uid; let uid = currentUser.uid;
let res = await app.api.get(`metrics/user/${uid}`); let res = await app.api.get(`metrics/user/${uid}`);
if (res && res.results) { if (res && res.results && res.results.services) {
const renderList = (items, id) => { const el = document.getElementById('metrics-user-services');
const el = document.getElementById(id); if(!el) return;
if(!el) return; el.innerHTML = '';
el.innerHTML = ''; const items = res.results.services;
if (!items || items.length === 0) { if (!items || items.length === 0) {
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>'; el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
return; return;
} }
items.forEach(item => { items.forEach(item => {
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center"> el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
${item.value} ${item.value}
<span class="badge bg-primary rounded-pill">${item.score}</span> <span class="badge bg-primary rounded-pill">${item.score}</span>
</li>`; </li>`;
}); });
};
renderList(res.results.services, 'metrics-user-services');
} }
}catch(error){ }catch(error){
console.error('renderMyMetrics error:', error) console.error('renderMyMetrics error:', error);
const el = document.getElementById('metrics-user-services');
if(el) el.innerHTML = '<li class="list-group-item text-danger">Failed to load metrics</li>';
} }
} }
async function determinUser(){ async function determinUser(){
if(location.pathname.includes('/users/')){ if(location.pathname.includes('/users/')){
let uid = location.pathname.replace('/users/', ''); let uid = location.pathname.replace('/users/', '');
return (await app.api.get('user/'+uid)).results; return (await app.api.get('user/'+uid)).results;
}else{ }else{
return await app.auth.asyncUser; return await app.auth.asyncUser;
@@ -148,16 +141,9 @@
function editUser(user){ function editUser(user){
user = user || currentUser; user = user || currentUser;
var $profileCard = $('#userProfile'); var $profileCard = $('#userProfile');
var $editCard = $('#editProfile'); var $editCard = $('#editProfile');
$.scope.editProfile.update(user); $.scope.editProfile.update(user);
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
// wait for the throttle tick to land before sliding the updated card
// into view, or it can briefly show stale/empty data. The manager
// picker is a JS widget, not a mustache-bound input, so it also has to
// wait for update() to (re-)render its empty mount div before attaching.
setTimeout(function(){ setTimeout(function(){
app.ui.userSelect('#edit-manager', { app.ui.userSelect('#edit-manager', {
name: 'manager', name: 'manager',
@@ -172,8 +158,6 @@
function editUserSeccess(data){ function editUserSeccess(data){
currentUser = data.results; currentUser = data.results;
renderProfile(currentUser); renderProfile(currentUser);
// Same throttle-tick wait as editUser() above -- renderProfile() calls
// $.scope.user.update()/passwordReset.update() internally.
setTimeout(function(){ setTimeout(function(){
$('#editProfile').slideUp(); $('#editProfile').slideUp();
$('#userProfile').slideDown() $('#userProfile').slideDown()
@@ -201,6 +185,29 @@
}); });
} }
// Password reset modal
function openPasswordResetModal(){
app.modal.open({
title: 'Reset Password for ' + currentUser.uid,
bodyHtml:
'<div class="actionMessage mb-3" style="display:none"></div>'
+ '<form id="passwordResetForm" action="user/' + currentUser.uid + '/password" method="put" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); app.messages.toast(\'Password updated\', \'success\');">'
+ '<div class="mb-3">'
+ '<label class="form-label">New Password</label>'
+ '<input type="password" class="form-control shadow" name="userPassword" placeholder="8+ chars; mix upper/lower/number/symbol" validate="password">'
+ '</div>'
+ '<div class="mb-3">'
+ '<label class="form-label">Confirm Password</label>'
+ '<input type="password" class="form-control shadow" name="password" placeholder="Retype password" validate="eq:userPassword">'
+ '</div>'
+ '</form>',
footer: {
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
+ '<button type="submit" form="passwordResetForm" class="btn btn-warning"><i class="fa-solid fa-key"></i> Reset Password</button>',
},
});
}
$(document).ready(async function(){ $(document).ready(async function(){
currentUser = await determinUser(); currentUser = await determinUser();
@@ -210,7 +217,7 @@
renderMyServices(); renderMyServices();
if(!isOwnProfile) { if(!isOwnProfile) {
renderMyMetrics(); renderMyMetrics();
$('#user-metrics-card').show(); $('#tab-metrics').parent().show();
} }
$('#personal-group-uid-label').text(currentUser.uid); $('#personal-group-uid-label').text(currentUser.uid);
@@ -221,8 +228,6 @@
name: 'members', values: [], placeholder: 'Type a username…', name: 'members', values: [], placeholder: 'Type a username…',
}); });
// API Tokens are self-service only — never shown when an admin is
// viewing someone else's profile via /users/:uid.
if(isOwnProfile){ if(isOwnProfile){
$('#own-api-tokens-section').show(); $('#own-api-tokens-section').show();
tableAJAX(); tableAJAX();
@@ -230,318 +235,275 @@
}); });
</script> </script>
<div class="row mb-3" style="display:none"> <div class="container mt-4">
<div class="col-md-4"> <div class="row" style="display:none">
<div class="shadow-lg card"> <div class="col-12">
<div class="card-header shadow"> <div id="userProfile" class="shadow-lg card card-default mb-4">
<i class="fa-solid fa-arrow-rotate-left"></i> <div class="card-header shadow d-flex justify-content-between align-items-center">
Password Reset <div>
<div class="float-end"> <i class="fa-regular fa-id-card"></i>
<i class="fa-solid fa-arrows-up-down"></i> Profile: <strong id="profile-uid-header"></strong>
</div>
<div class="d-flex gap-2">
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
<i class="fa-solid fa-key"></i> Reset Password
</button>
<button type="button" onclick="editUser()" class="btn btn-warning btn-sm">
<i class="fa-solid fa-user-pen"></i> Edit
</button>
</div> </div>
</div> </div>
<div class="card-header shadow actionMessage" style="display:none"> <div class="card-header shadow actionMessage" style="display:none"></div>
<div class="px-3 pt-3 border-bottom">
<ul class="nav nav-tabs border-bottom-0" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-profile" type="button" role="tab">
<i class="fa-solid fa-user"></i> Profile
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-groups" type="button" role="tab">
<i class="fa-solid fa-users"></i> My Groups
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-services" type="button" role="tab">
<i class="fa-solid fa-layer-group"></i> My Services
</button>
</li>
<li class="nav-item" role="presentation" id="tab-metrics-parent" style="display:none">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-metrics" type="button" role="tab">
<i class="fa-solid fa-chart-line"></i> Security & Usage
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
<i class="fa-solid fa-people-group"></i> Members of <span id="personal-group-uid-label"></span>'s Group
</button>
</li>
</ul>
</div> </div>
<div jq-repeat="passwordReset" class="card-body">
<h3> <div class="card-body">
Reset Password for {{uid}} <div class="tab-content">
</h3> <!-- Profile Tab -->
<form action="user/{{uid}}/password" method="put" onsubmit="formAJAX(this)" class="mb-3"> <div class="tab-pane fade show active" id="tab-profile" role="tabpanel">
<div class="mb-3"> <div jq-repeat="user">
<label class="form-label">Password</label> <div class="row">
<div class="input-group shadow"> <div class="col-md-6">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span> <p><i>Name:</i> <b>{{givenName}} {{sn}}</b></p>
<input type="password" name="userPassword" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping"> <p><i>Email:</i> <b>{{mail}}</b>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
</p>
<p><i>Phone:</i> <b>{{mobile}}</b>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
</p>
<p><i>Location (Site):</i> <b>{{location}}</b></p>
<p><i>LDAP DN:</i> <b>{{dn}}</b></p>
<p><i>Home Directory:</i> <b>{{homeDirectory}}</b></p>
</div>
<div class="col-md-6">
<p><i>Login Shell:</i> <b>{{loginShell}}</b></p>
<p><i>Manager(s):</i>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
</p>
<p><i>Status:</i>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
</p>
<p><i>Date of Birth:</i> <b>{{dateOfBirth}}</b></p>
<p><i>TOS:</i>
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
</p>
<p><i>SSH Public Key:</i> <b>{{sshPublicKey}}</b></p>
<p><i>Unix User ID:</i> <b>{{uidNumber}}</b></p>
<p><i>Unix Group ID:</i> <b>{{gidNumber}}</b></p>
</div>
</div>
{{#description}}
<hr>
<p><i>Description:</i><br>{{description}}</p>
{{/description}}
<hr>
<p class="text-muted small mb-0">
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
</p>
<div class="mt-3 border-top pt-3">
<h6 class="text-muted">Admin Actions</h6>
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
{{#isActive}}
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i> Deactivate
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i> Activate
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i> Impersonate
</button>
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i> Delete User
</button>
</div>
</div>
</div> </div>
</div> </div>
<div class="mb-3">
<label class="form-label">Again</label> <!-- My Groups Tab -->
<div class="input-group shadow"> <div class="tab-pane fade" id="tab-groups" role="tabpanel">
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span> <div class="table-responsive">
<input type="password" name="password" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping"> <table class="table table-striped">
<thead>
<th>Name</th>
<th>Description</th>
<th class="group-required group-required-app_sso_admin text-end">Actions</th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="group-required group-required-app_sso_admin mt-3">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div>
</div> </div>
</div> </div>
<button type="submit" class="btn btn-outline-secondary shadow">Change</button>
</form>
</div> <!-- My Services Tab -->
</div> <div class="tab-pane fade" id="tab-services" role="tabpanel">
</div> <div class="table-responsive">
<table class="table table-striped">
<div class="col-md-8"> <thead>
<div id="userProfile" class="shadow-lg card card-default mb-8"> <th>Name</th>
<div class="card-header shadow"> <th>Address / IP</th>
<i class="fa-regular fa-id-card"></i> <th>Description</th>
Profile <th>Kind</th>
<div class="float-end"> </thead>
<i class="fa-solid fa-arrows-up-down"></i> <tbody jq-repeat="myservices">
</div> <tr>
</div> <td>{{name}} <small class="text-muted">({{slug}})</small></td>
<div class="card-header shadow actionMessage" style="display:none"> <td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
</div> <td>{{description}}</td>
<div class="profile-body" jq-repeat="user"> <td>
<div class="card-body profile-body-{{uid}}"> {{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<h2><i>User Name:</i> <b>{{uid}}</b></h2> <span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
{{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}} </td>
<i>Email:</i> <b>{{mail}}</b> </tr>
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}} </tbody>
<br /> </table>
<i>Phone:</i> <b>{{mobile}}</b> </div>
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
<br />
<i>Location (Site):</i> <b>{{location}} </b><br />
<i>LDAP DN:</i> <b>{{dn}} </b><br />
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
<i>Manager(s):</i>
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
<br />
<i>Status:</i>
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
<br />
<i>Date of Birth:</i> <b>{{dateOfBirth}}</b><br />
<i>TOS:</i>
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
<br />
<i>SSH Public Key:</i> <b>{{sshPublicKey}}</b><br />
<i>Unix User ID:</i> <b>{{uidNumber}} </b><br />
<i>Unix Group ID:</i> <b>{{gidNumber}} </b><br />
<i>Description:</i><br>
<p>
{{description}}
</p>
<!-- <img id="profile_photo" /> -->
</div>
<div class="card-footer profile-body-{{uid}}">
<div class="float-end">
<button type="button" onclick="editUser()" class="btn btn-warning btn shadow ">
<i class="fa-solid fa-user-pen"></i>
</button>
{{#isActive}}
<button type="button" class="btn btn-outline-warning shadow group-required group-required-app_sso_admin" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
<i class="fa-solid fa-lock"></i>
</button>
{{/isActive}}
{{#isInactive}}
<button type="button" class="btn btn-warning shadow group-required group-required-app_sso_admin" title="Activate user" onclick="toggleActive('{{uid}}', true)">
<i class="fa-solid fa-lock-open"></i>
</button>
{{/isInactive}}
<button type="button" class="btn btn-secondary shadow group-required group-required-app_sso_admin" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
<i class="fa-solid fa-user-secret"></i>
</button>
<button type="button" class="btn btn-danger shadow group-required group-required-app_sso_admin" onclick="deleteUser('{{uid}}', this)">
<i class="fa-solid fa-user-slash"></i>
</button>
</div>
<div class="float-left">
<i>Joined:</i> <b>{{createTimestamp}} </b> <br/>
<i>Edited:</i> <b>{{modifyTimestamp}} </b>
</div> </div>
<!-- Security & Usage Tab -->
<div class="tab-pane fade" id="tab-metrics" role="tabpanel">
<div class="row">
<div class="col-12">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used (Last 7 Days)</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div>
</div>
</div>
<!-- Members Tab -->
<div class="tab-pane fade" id="tab-members" role="tabpanel">
<p class="text-muted small">
Every account gets a personal Unix group (its primary GID) — add
other accounts here as supplementary members.
</p>
<div class="table-responsive">
<table class="table table-striped">
<thead>
<th>Username</th>
<th class="text-end">Actions</th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-3">
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
<div id="editProfile" class="shadow-lg card card-default mb-8" style="display:none"> <!-- Edit Profile Card (hidden by default) -->
<div id="editProfile" class="shadow-lg card card-default mb-4" style="display:none">
<div class="card-header shadow"> <div class="card-header shadow">
<i class="fad fa-id-card"></i> <i class="fad fa-id-card"></i>
Edit Profile Edit Profile
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div> </div>
<div class="card-header shadow actionMessage" style="display:none"></div>
<div class="card-body" jq-repeat="editProfile"> <div class="card-body" jq-repeat="editProfile">
<div id="tableAJAX"> <h3>Editing {{uid}}</h3>
<h3>Editing {{uid}}</h3> <form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)">
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)"> <div class="mb-3">
<div class="mb-3"> <label class="form-label">Date of Birth</label>
<label class="form-label">Date of Birth</label> <input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
<input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
</div>
<div class="mb-3">
<label class="form-label">SSH Public Key</label>
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
</div>
<div class="mb-3">
<label class="form-label">Mobile Phone</label>
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="mb-3">
<label class="form-label">Location (Site)</label>
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
</div>
<div class="mb-3">
<label class="form-label">Home Directory</label>
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
</div>
<div class="mb-3">
<label class="form-label">Login Shell</label>
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
</div>
<div class="mb-3">
<label class="form-label">Manager(s)</label>
<div id="edit-manager"></div>
</div>
<div class="mb-3">
<label class="form-label">User Description (Optional)</label>
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
</div>
<button type="submit" class="btn btn-outline-dark btn-warning">Change</button>
</form>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-users-viewfinder"></i>
My groups
<div class="float-end">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-header shadow actionMessage" style="display:none">
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<th>
Name
</th>
<th>
Description
</th>
<th class="group-required group-required-app_sso_admin"></th>
</thead>
<tbody jq-repeat="mygroups">
<tr>
<td>{{cn}}</td>
<td>{{description}}</td>
<td class="text-end group-required group-required-app_sso_admin">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="group-required group-required-app_sso_admin">
<label class="form-label small">Add to group</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-group-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
</div> </div>
</div> <div class="mb-3">
</div> <label class="form-label">SSH Public Key</label>
</div> <input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
<div class="shadow-lg card card-default mb-8">
<div class="card-header shadow">
<i class="fa-solid fa-layer-group"></i>
My Services
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table">
<thead>
<th>Name</th>
<th>Address / IP</th>
<th>Description</th>
<th>Kind</th>
</thead>
<tbody jq-repeat="myservices">
<tr>
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
<td>{{description}}</td>
<td>
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
<div class="card-header shadow bg-success text-white">
<i class="fa-solid fa-chart-line"></i>
Security & Usage Stats (Last 7 Days)
<div class="float-end">
<i class="fa-solid fa-arrows-up-down"></i>
</div>
</div>
<div class="card-body p-0">
<div class="row m-0">
<div class="col-12 p-3">
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
<li class="list-group-item text-muted">Loading...</li>
</ul>
</div> </div>
</div> <div class="mb-3">
</div> <label class="form-label">Mobile Phone</label>
</div> <input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
</div>
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin"> <div class="mb-3">
<div class="card-header shadow"> <label class="form-label">Location (Site)</label>
<i class="fa-solid fa-people-group"></i> <input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
Members of <span id="personal-group-uid-label"></span>'s group </div>
<div class="float-end"> <div class="mb-3">
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a> <label class="form-label">Home Directory</label>
<i class="fa-solid fa-arrows-up-down"></i> <input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
</div> </div>
</div> <div class="mb-3">
<div class="card-header shadow actionMessage" style="display:none"> <label class="form-label">Login Shell</label>
</div> <input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
<div class="card-body"> </div>
<p class="text-muted small"> <div class="mb-3">
Every account gets a personal Unix group (its primary GID) — add <label class="form-label">Manager(s)</label>
other accounts here as supplementary members (e.g. to share write <div id="edit-manager"></div>
access to files owned by this group). </div>
</p> <div class="mb-3">
<div class="table-responsive"> <label class="form-label">User Description (optional)</label>
<table class="table"> <textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
<thead> </div>
<th> <button type="submit" class="btn btn-outline-dark btn-warning">Save Changes</button>
Username <button type="button" class="btn btn-secondary" onclick="$('#editProfile').slideUp(); $('#userProfile').slideDown();">Cancel</button>
</th> </form>
<th class="text-end"></th>
</thead>
<tbody jq-repeat="personalGroupMembers">
<tr>
<td>{{uid}}</td>
<td class="text-end">
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
<i class="fa-solid fa-xmark"></i>
</button>
</td>
</tr>
</tbody>
</table>
</div>
<label class="form-label small">Add member</label>
<div class="d-flex gap-2 align-items-start">
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -549,13 +511,9 @@
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
// Any logged-in user can manage their own API tokens (self-service). // API Tokens section (same as before)
// Section is only revealed (see $(document).ready above) when isOwnProfile.
var tokensById = {}; var tokensById = {};
// Shared "reveal secret once" display -- same pattern as jump-host's and
// proxy's showToken().
function showToken(title, token){ function showToken(title, token){
app.modal.open({title: title, bodyHtml: app.modal.open({title: title, bodyHtml:
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>' '<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
@@ -564,10 +522,7 @@
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>' + '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
}); });
} }
// Not the checkmark-flash technique this file used to use for its copy
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
// swapping the <i>'s class silently no-ops. A toast doesn't have that
// problem.
function copyFieldValue(sel){ function copyFieldValue(sel){
var $el = $(sel); var $el = $(sel);
var text = $el.val(); var text = $el.val();
@@ -580,17 +535,12 @@
} }
function fmtTime(ms){ function fmtTime(ms){
// created_on/last_used_on come back from Redis as strings (model-redis
// only coerces fields with an explicit `type`); moment(value, "x") parses
// a numeric string-or-number as a Unix-ms timestamp, unlike new Date(str).
if(!ms || Number(ms) === 0) return '—'; if(!ms || Number(ms) === 0) return '—';
var t = moment(ms, "x"); var t = moment(ms, "x");
if(!t.isValid()) return '—'; if(!t.isValid()) return '—';
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>'; return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
} }
function fmtExpiry(token){ function fmtExpiry(token){
// expires_at is type:number (a real number); isExpired is a class getter
// that is NOT serialized to the client, so compute expiry here.
var exp = Number(token.expires_at); var exp = Number(token.expires_at);
if(!exp) return '<span class="badge text-bg-secondary">never</span>'; if(!exp) return '<span class="badge text-bg-secondary">never</span>';
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>'; if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
@@ -639,14 +589,6 @@
}); });
} }
// Create is a native <form>+formAJAX submission (matching this app's own
// hostModal-style convention) rather than a JS-built payload. Deliberately
// does NOT call app.modal.close() before showToken() -- app.modal is a
// singleton, and close() immediately followed by open() in the same tick
// collides with Bootstrap's hide-transition guard (show() silently
// no-ops while _isTransitioning is still true from the just-started
// hide()). open() alone already overwrites the (already-visible) modal's
// content in place.
function createApiToken(){ function createApiToken(){
var $body = app.modal.open({ var $body = app.modal.open({
title: 'New API Token', title: 'New API Token',
@@ -714,9 +656,6 @@
} }
</script> </script>
<!-- Not class="row": app-base.js's `$('div.row').fadeIn('slow')` page-reveal
runs before this page's own ready handler and would unhide any div.row
unconditionally, defeating the isOwnProfile check below. -->
<div id="own-api-tokens-section" style="display:none"> <div id="own-api-tokens-section" style="display:none">
<div class="row mt-3 justify-content-center"> <div class="row mt-3 justify-content-center">
<div class="col-md-8"> <div class="col-md-8">
+1 -1
View File
@@ -301,7 +301,7 @@
{{mail}} {{mail}}
</td> </td>
<td> <td>
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}} {{#hasSshKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/hasSshKey}}
</td> </td>
<td> <td>
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}} {{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
+320
View File
@@ -0,0 +1,320 @@
<%- include('top') %>
<div class="container-fluid py-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 id="vault-title"><i class="fas fa-lock"></i> My Secrets <small class="text-muted">(personal namespace)</small></h2>
<ul class="nav nav-pills" id="vault-tabs">
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
<li class="nav-item" id="vault-apps-tab" style="display:none"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
</ul>
</div>
<div class="tab-content">
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
<div class="tab-pane fade show active" id="tab-secrets">
<div class="d-flex justify-content-end mb-3">
<button class="btn btn-primary" onclick="showCreateModal()">
<i class="fas fa-plus"></i> New Secret
</button>
</div>
<div class="row">
<div class="col-md-4">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
<div class="list-group list-group-flush" id="secrets-list">
<div class="list-group-item text-center text-muted">Loading...</div>
</div>
</div>
</div>
<div class="col-md-8">
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()"><i class="fas fa-trash"></i> Delete</button>
</div>
</div>
<div class="card-body">
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
</div>
</div>
<div id="no-secret-selected" class="text-center text-muted mt-5">
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
<h4>Select a secret to view its details</h4>
</div>
</div>
</div>
</div>
<!-- ── Apps tab (admin only; revealed client-side for admins) ─────── -->
<div class="tab-pane fade" id="tab-apps">
<div class="row">
<div class="col-md-5">
<div class="card shadow-sm">
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
<div class="card-body">
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/&lt;name&gt;/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
<div class="mb-3">
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
</div>
<button class="btn btn-primary" onclick="mintApp()"><i class="fas fa-key"></i> Mint token</button>
<div class="alert alert-danger d-none mt-3" id="app-error"></div>
</div>
</div>
</div>
<div class="col-md-7">
<div class="card shadow-sm d-none" id="app-result-card">
<div class="card-header bg-light d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">App token</h5>
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
</div>
<div class="card-body">
<p class="small text-muted">Give the external app this token (header <code>X-Vault-Token</code>) and the path convention below.</p>
<pre id="app-token" class="bg-dark text-light p-3 rounded"></pre>
<h6 class="mt-3">Connection convention</h6>
<pre class="bg-light p-2 rounded small">VAULT_ADDR=<%- vaultAddr %>
path=secret/apps/&lt;name&gt;/conf
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
-H "X-Vault-Token: &lt;token above&gt;"</pre>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Create/Edit Secret Modal -->
<div class="modal fade" id="secretModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="secretModalTitle">Create Secret</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label" id="secret-path-label">Secret name (in your personal namespace)</label>
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
</div>
<div class="mb-3">
<label class="form-label">Secret Data (JSON)</label>
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
"username": "",
"password": ""
}</textarea>
</div>
<div class="alert alert-danger d-none" id="secret-error"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
</div>
</div>
</div>
</div>
<script>
// Login gate + client-derived scoping. VAULT_BASE is '' for admins
// (free-form under secret/) or 'users/<uid>/' for everyone else (confined
// to their personal namespace). The /api/vault proxy enforces the same
// server-side (scopeGuard + the token's OpenBao policy), so this only
// drives the UI. Resolved in init() after forceLogin loads the user — the
// previous version read these server-side from req.user, which is undefined
// on a browser navigation (auth-token is a client-set header, not a cookie).
let VAULT_BASE = '';
let IS_ADMIN = false;
let currentSecretPath = null;
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
// Build a vault API path. kind is 'data' or 'metadata'; key is the logical
// key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo).
function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`;
}
function apiCall(method, path, body = null) {
const opts = {
method,
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() }
};
if (body) opts.body = JSON.stringify(body);
return fetch('/api/vault/' + path, opts).then(async res => {
if (res.status === 404) return null;
if (!res.ok) {
const text = await res.text();
throw new Error(`Vault API error: ${res.status} ${text}`);
}
if (res.status === 204) return null;
return res.json();
});
}
async function loadSecrets() {
try {
const res = await apiCall('GET', vpath('metadata', '?list=true'));
const listEl = document.getElementById('secrets-list');
listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
return;
}
res.data.keys.forEach(key => {
// KV list returns dir entries with a trailing slash; admins can still
// open them by typing the full path in the modal. Skip dirs in the list
// for non-admins (their namespace is flat).
if (!IS_ADMIN && key.endsWith('/')) return;
const item = document.createElement('a');
item.href = '#';
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
item.onclick = (e) => {
e.preventDefault();
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
item.classList.add('active');
loadSecretDetails(key);
};
listEl.appendChild(item);
});
} catch (err) {
document.getElementById('secrets-list').innerHTML =
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
}
}
async function loadSecretDetails(key) {
try {
currentSecretPath = key;
document.getElementById('no-secret-selected').style.display = 'none';
document.getElementById('secret-details-card').style.display = 'block';
document.getElementById('secret-title').textContent = key;
document.getElementById('secret-content').textContent = 'Loading...';
const res = await apiCall('GET', vpath('data', key));
if (!res || !res.data || !res.data.data) {
document.getElementById('secret-content').textContent = 'No data found.';
} else {
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
}
} catch (err) {
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
}
}
function showCreateModal() {
currentSecretPath = null;
document.getElementById('secretModalTitle').textContent = 'Create Secret';
document.getElementById('secret-path-input').value = '';
document.getElementById('secret-path-input').disabled = false;
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
document.getElementById('secret-error').classList.add('d-none');
secretModal.show();
}
function editCurrentSecret() {
if (!currentSecretPath) return;
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
document.getElementById('secret-path-input').value = currentSecretPath;
document.getElementById('secret-path-input').disabled = true;
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
document.getElementById('secret-error').classList.add('d-none');
secretModal.show();
}
async function saveSecret() {
const errorEl = document.getElementById('secret-error');
errorEl.classList.add('d-none');
const path = document.getElementById('secret-path-input').value.trim();
if (!path) {
errorEl.textContent = 'Secret path is required';
errorEl.classList.remove('d-none');
return;
}
let data;
try {
data = JSON.parse(document.getElementById('secret-data-input').value);
} catch (err) {
errorEl.textContent = 'Invalid JSON: ' + err.message;
errorEl.classList.remove('d-none');
return;
}
try {
await apiCall('POST', vpath('data', path), { data });
secretModal.hide();
await loadSecrets();
if (currentSecretPath === path || !currentSecretPath) {
await loadSecretDetails(path);
}
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
async function deleteCurrentSecret() {
if (!currentSecretPath) return;
try {
await apiCall('DELETE', vpath('metadata', currentSecretPath));
currentSecretPath = null;
document.getElementById('no-secret-selected').style.display = 'block';
document.getElementById('secret-details-card').style.display = 'none';
await loadSecrets();
} catch (err) {
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
}
}
// ── Apps tab (admin) ───────────────────────────────────────────────────
async function mintApp() {
const errorEl = document.getElementById('app-error');
errorEl.classList.add('d-none');
const name = document.getElementById('app-name-input').value.trim();
if (!name) {
errorEl.textContent = 'App name is required';
errorEl.classList.remove('d-none');
return;
}
try {
const res = await fetch('/api/vault/apps', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
body: JSON.stringify({ name })
});
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${text}`);
}
const result = await res.json();
document.getElementById('app-token').textContent = result.token;
document.getElementById('app-name-display').textContent = name;
document.getElementById('app-result-card').classList.remove('d-none');
} catch (err) {
errorEl.textContent = err.message;
errorEl.classList.remove('d-none');
}
}
function copyText(text) {
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
}
(async function init() {
const user = await app.auth.forceLogin();
if (!user) return; // not logged in — forceLogin redirected to /login
IS_ADMIN = app.auth.isAdmin();
VAULT_BASE = IS_ADMIN ? '' : 'users/' + user.uid + '/';
if (IS_ADMIN) {
document.getElementById('vault-apps-tab').style.display = '';
document.getElementById('vault-title').innerHTML =
'<i class="fas fa-lock"></i> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>';
document.getElementById('secret-path-label').textContent = 'Secret path (under secret/)';
document.getElementById('secret-path-input').placeholder = 'e.g. apps/my-service/conf';
}
loadSecrets();
})();
</script>
<%- include('bottom') %>