Compare commits

...

15 Commits

Author SHA1 Message Date
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
47 changed files with 3657 additions and 106 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
+79
View File
@@ -4,6 +4,76 @@ 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.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 +449,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
+1
View File
@@ -169,6 +169,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
};
+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.
+39
View File
@@ -0,0 +1,39 @@
---
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.
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
When building custom Agents or integrations, they can utilize the local Vault to retrieve API tokens instead of hardcoding them. Always use the `/api/vault` proxy to ensure permissions are consistently enforced.
+27 -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,19 @@ 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'));
app.use('/api/plugins', middleware.auth, require('./routes/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 +141,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.
+64
View File
@@ -0,0 +1,64 @@
# Plugins & Scheduler
The SSO Manager includes a flexible background task runner and discovery system. Plugins are defined statically in your deployment configuration (`sso-secrets.js`) and run based on their defined `cron` schedule.
## Writing Custom Plugins
You can write custom plugins to discover resources, manage internal state, or run automated scripts. Plugins must be placed in the `plugins/discovery/` directory of the SSO Manager node codebase.
A plugin file must export a `discover` method.
**Example Plugin (`plugins/discovery/my_plugin.js`):**
```javascript
module.exports = {
discover: async function(config) {
// The config object contains any keys passed in sso-secrets.js for this plugin.
// Perform discovery logic, hit external APIs, etc.
const resources = [
{
slug: 'my-custom-resource-1',
name: 'My Resource 1',
kind: 'Host',
metadata: {
ip: '10.0.0.100',
source: 'My Custom Plugin'
}
}
];
// Return the discovered resources array. The discovery reconciler will
// automatically save these to the Network Discovery database.
return resources;
}
};
```
## Configuring Plugins
In your `sso-secrets.js` file, add your plugin to the `discovery.plugins` object:
```javascript
module.exports = {
// ...
discovery: {
plugins: {
my_plugin: {
enabled: true,
cron: "0 * * * *", // Run every hour
my_custom_key: "my_custom_value" // Passed to the config argument in discover()
}
}
}
// ...
};
```
### Overriding Timing and Enable/Disable
From the **Plugins & Scheduler** tab in the Directory Dashboard, you can override the schedule and enable/disable state for each plugin. These overrides take precedence over `sso-secrets.js` and are stored internally.
## Scheduler Internals
The scheduler uses BullMQ backed by Redis to manage execution. It automatically performs garbage collection on stale network resources (resources not updated in > 7 days) and triggers your plugins at the defined intervals.
+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);
+2 -2
View File
@@ -15,7 +15,7 @@ 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');
async function initORM() { async function initORM() {
const ormConf = conf.orm || { const ormConf = conf.orm || {
dialect: 'sqlite', dialect: 'sqlite',
@@ -29,7 +29,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,
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
] ]
}); });
+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.16.1",
"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) {
+51
View File
@@ -0,0 +1,51 @@
const nmap = require('node-nmap');
nmap.nmapLocation = "nmap"; // default
module.exports = {
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) {
reject(error);
});
scan.startScan();
});
}
};
+155
View File
@@ -0,0 +1,155 @@
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 = {
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 };
}
};
+95
View File
@@ -0,0 +1,95 @@
const fetch = require('node-fetch');
const https = require('https');
const agent = new https.Agent({
rejectUnauthorized: false
});
module.exports = {
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 };
}
};
+60
View File
@@ -0,0 +1,60 @@
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);
}
});
router.get('/', async (req, res) => {
const editable = {
smtp: conf.smtp || {},
discovery: conf.discovery || {},
oauth: conf.oauth || {}
};
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') || {};
// Deep merge req.body into existing
for (const key of Object.keys(req.body)) {
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
} else {
existing[key] = req.body[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).
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 }) });
+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;
+2
View File
@@ -34,6 +34,8 @@ 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: 'Agents & Scheduler', file: path.join(__dirname, '../../docs/agents.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')},
+35
View File
@@ -64,10 +64,45 @@ 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) {
res.redirect('/directory');
});
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
+59
View File
@@ -0,0 +1,59 @@
const router = require('express').Router();
const conf = require('@simpleworkjs/conf');
const permission = require('../utils/permission');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_directory_admin', 'app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
const Redis = require('ioredis');
const connection = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', { maxRetriesPerRequest: null });
const { initScheduler } = require('../services/scheduler');
router.get('/', async (req, res) => {
const plugins = conf.discovery && conf.discovery.plugins ? conf.discovery.plugins : {};
let overrides = {};
try {
const data = await connection.hgetall('discovery_plugins');
for (const [k, v] of Object.entries(data)) {
overrides[k] = JSON.parse(v);
}
} catch(e) {}
// Mask secrets before sending
const masked = JSON.parse(JSON.stringify(plugins));
for (const name in masked) {
masked[name] = { ...masked[name], ...(overrides[name] || {}) };
if (masked[name].tokenSecret) masked[name].tokenSecret = '********';
if (masked[name].password) masked[name].password = '********';
}
res.json({ results: masked });
});
router.put('/:name', async (req, res) => {
const name = req.params.name;
const updates = req.body;
let current = {};
try {
const data = await connection.hget('discovery_plugins', name);
if (data) current = JSON.parse(data);
} catch(e) {}
if (updates.cron !== undefined) current.cron = updates.cron;
if (updates.enabled !== undefined) current.enabled = updates.enabled === true || updates.enabled === 'true';
await connection.hset('discovery_plugins', name, JSON.stringify(current));
// Re-init scheduler to apply changes
await initScheduler(conf.discovery).catch(console.error);
res.json({ success: true, message: 'Plugin updated' });
});
module.exports = router;
+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 };
+87
View File
@@ -0,0 +1,87 @@
const { Queue, Worker } = require('bullmq');
const { DiscoveryReconciler } = require('./discovery_reconciler');
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 });
// Load plugins
const fs = require('fs');
const path = require('path');
const pluginsDir = path.join(__dirname, '../plugins/discovery');
let plugins = {};
if (fs.existsSync(pluginsDir)) {
fs.readdirSync(pluginsDir).forEach(file => {
if (file.endsWith('.js')) {
const name = path.basename(file, '.js');
plugins[name] = require(path.join(pluginsDir, file));
}
});
}
const worker = new Worker('discovery', async job => {
if (job.name === 'run_plugin') {
const { pluginName, config } = job.data;
if (plugins[pluginName]) {
console.log(`[Scheduler] Running plugin: ${pluginName}`);
try {
const payload = await plugins[pluginName].discover(config);
await DiscoveryReconciler.reconcile(pluginName, payload);
} catch (err) {
console.error(`[Scheduler] Plugin ${pluginName} failed:`, err);
}
}
} else if (job.name === 'garbage_collect') {
console.log(`[Scheduler] Running garbage collection`);
await DiscoveryReconciler.garbageCollect();
}
}, { connection });
// Function to start scheduling
async function initScheduler(discoveryConfig) {
// Clear old repeatable jobs (BullMQ v6 uses JobSchedulers)
try {
const schedulers = await discoveryQueue.getJobSchedulers();
for (const job of schedulers) {
await discoveryQueue.removeJobScheduler(job.id);
}
} catch (e) {
console.log('[Scheduler] Could not clear old job schedulers (may not be supported or none exist)');
}
// Schedule Garbage Collection
await discoveryQueue.add('garbage_collect', {}, { repeat: { pattern: '0 0 * * *' } }); // Daily
// Load plugin overrides from Redis
let overrides = {};
try {
const data = await connection.hgetall('discovery_plugins');
for (const [k, v] of Object.entries(data)) {
overrides[k] = JSON.parse(v);
}
} catch (err) {
console.error('[Scheduler] Failed to load plugin overrides from Redis', err);
}
// Schedule Plugins based on config + overrides
if (discoveryConfig && discoveryConfig.plugins) {
for (const [name, config] of Object.entries(discoveryConfig.plugins)) {
const mergedConfig = { ...config, ...(overrides[name] || {}) };
if (mergedConfig.enabled && plugins[name]) {
const cron = mergedConfig.cron || '0 * * * *'; // Default hourly
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig }, { repeat: { pattern: cron } });
console.log(`[Scheduler] Scheduled plugin ${name} with cron ${cron}`);
// Also run once immediately
await discoveryQueue.add('run_plugin', { pluginName: name, config: mergedConfig });
}
}
}
}
module.exports = { initScheduler, 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);
+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);
});
});
+3 -1
View File
@@ -43,8 +43,10 @@ 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: '/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']},
], ],
}; };
+233
View File
@@ -0,0 +1,233 @@
'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 (idempotent). 200 = exists, 404 = create.
async function ensurePolicy(name, hcl) {
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
if (existing.status === 200) return;
if (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.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
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) {
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
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,
};
+165
View File
@@ -0,0 +1,165 @@
<%- include('top') %>
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
$(document).ready(function() {
loadConf();
});
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);
}
}
} 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
}
}
};
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';
}
}
</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 settings and OAuth parameters.
These secrets are stored securely in OpenBao Vault.
</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">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</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">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</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>
<%- include('bottom') %>
+277 -2
View File
@@ -3,7 +3,30 @@
<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>
<li class="nav-item" role="presentation">
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
<i class="fa-solid fa-robot"></i> Agents & Scheduler
</button>
</li>
</ul>
</div>
<div class="card-body p-0">
<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
@@ -74,6 +97,149 @@
</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>
<!-- Agents Tab Pane -->
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
<div class="border-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div>
<i class="fa-solid fa-robot"></i> Agents & Scheduler
</div>
</div>
<div class="p-3 pb-0 text-muted small border-bottom">
<i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/agents">Learn how to make and use custom agents</a>.
</div>
<div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Agent Name</th>
<th>Cron Schedule</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="plugins-list" jq-repeat="plugins">
<tr>
<td class="ps-3 fw-bold">{{name}}</td>
<td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
<td>
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
{{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
{{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
</td>
</tr>
</tbody>
<tbody id="plugins-empty-state" style="display: none;">
<tr>
<td colspan="4" class="text-center py-4 text-muted">
No agents configured.
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -124,7 +290,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>
@@ -1104,6 +1270,115 @@
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
});
}
// --- AGENT SCRIPTS ---
function loadPlugins() {
app.api.get('plugins', function(err, res) {
if(err) {
app.messages.toast("Error loading agents: " + (err.message || err), 'danger');
return;
}
const plugins = res.results || {};
const pluginNames = Object.keys(plugins);
$.scope.plugins.empty();
if(pluginNames.length === 0) {
$('#plugins-list').hide();
$('#plugins-empty-state').show();
} else {
pluginNames.forEach(name => {
const config = plugins[name];
$.scope.plugins.push({
name: name,
cron: config.cron || '',
enabled: !!config.enabled
});
});
$('#plugins-list').show();
$('#plugins-empty-state').hide();
}
});
}
function updatePlugin(name) {
const cron = $('#cron-' + name).val();
app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
if(err) {
app.messages.toast("Error saving agent schedule: " + (err.message || err), 'danger');
return;
}
app.messages.toast("Agent schedule saved successfully.", 'success');
});
}
function togglePlugin(name, enable) {
app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
if(err) {
app.messages.toast("Error toggling agent: " + (err.message || err), 'danger');
return;
}
loadPlugins();
});
}
$(document).ready(function(){
loadDiscoveryResources();
loadPlugins();
});
</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') %>
+1 -1
View File
@@ -298,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.
+102
View File
@@ -0,0 +1,102 @@
<%- 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" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
</li>
<li class="nav-item">
<a class="nav-link active" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
</li>
</ul>
<div class="card shadow border-top-0">
<div class="card-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> View configured background plugins and scheduler status. Note: Plugins are configured statically in <code>sso-secrets.js</code>.
</div>
<div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light">
<tr>
<th class="ps-3">Plugin Name</th>
<th>Cron Schedule</th>
<th>Status</th>
<th>Details</th>
</tr>
</thead>
<tbody id="plugins-list" jq-repeat="plugins">
<tr>
<td class="ps-3 fw-bold">{{name}}</td>
<td><code class="text-dark">{{cron}}</code></td>
<td>
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
</td>
<td class="small text-muted font-monospace">
{{details}}
</td>
</tr>
</tbody>
<tbody id="empty-state" style="display: none;">
<tr>
<td colspan="4" class="text-center py-4 text-muted">
No plugins configured in sso-secrets.js
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
$(document).ready(function() {
app.api.get('plugins', function(err, res) {
if(err) {
app.messages.toast("Error loading plugins: " + (err.message || err));
return;
}
const plugins = res.results || {};
const pluginNames = Object.keys(plugins);
$.scope.plugins.empty();
if(pluginNames.length === 0) {
$('#plugins-list').hide();
$('#empty-state').show();
} else {
pluginNames.forEach(name => {
const config = plugins[name];
const details = Object.entries(config)
.filter(([k, v]) => k !== 'enabled' && k !== 'cron')
.map(([k, v]) => `${k}: ${v}`)
.join(', ');
$.scope.plugins.push({
name: name,
cron: config.cron || 'N/A',
enabled: config.enabled,
details: details
});
});
$('#plugins-list').show();
$('#empty-state').hide();
}
});
});
</script>
<%- include('bottom') %>
+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') %>