diff --git a/CHANGELOG.md b/CHANGELOG.md index 47d10b1..d82ce42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -379,6 +379,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. - 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 [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 diff --git a/Dockerfile.openldap b/Dockerfile.openldap index e2544cc..813c3b0 100644 --- a/Dockerfile.openldap +++ b/Dockerfile.openldap @@ -169,6 +169,7 @@ COPY nodejs/services ./services COPY nodejs/utils ./utils COPY nodejs/views ./views COPY nodejs/public ./public +COPY nodejs/plugins ./plugins # routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the # app flattened into /app, __dirname is /app/routes and ../../ resolves to /, diff --git a/config/sso-secrets.js b/config/sso-secrets.js index f791864..b4a0ef1 100644 --- a/config/sso-secrets.js +++ b/config/sso-secrets.js @@ -1,79 +1,35 @@ '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 .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. - +// Local per-deployment configuration for this Theta42 instance. +// This file is gitignored — it contains real secrets and per-deployment +// values. The committed conf/base.js now ships generic defaults +// (example.com / localhost); the theta42-specific non-secret values that +// used to live in base.js have been migrated here so this instance keeps +// working. New deployments should put their own values here or in app_* env. 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 ', - }, - 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,, ou=people,, ou=groups,, https://). - 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 + port: 3001, + name: 'Theta42 SSO', + ldap: { + url: 'ldap://10.2.0.54', + bindDN: 'cn=admin,dc=theta42,dc=com', + bindPassword: 'Tomisgaypalm7', + userBase: 'ou=people,dc=theta42,dc=com', + groupBase: 'ou=groups,dc=theta42,dc=com', + }, + smtp: { + host: 'mail.wgnode.com', + user: 'noreply@users.theta42.com', + // user: '', + pass: 'ZxAsQw!2', + from: 'Theta42 Accounts ', + }, + voipms: { + username: 'wmantly@gmail.com', + password: 'EMjQvAuHhD!d5dm', + did: '9297353350', + }, + oauth: { + issuer: 'https://sso.theta42.com', + jwtSecret: '09e2501a1c93aef4d5d713c7db17c800c6d7d6f5f9e9cf2efbdfa37549021bf9', + }, }; \ No newline at end of file diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..4402cda --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,88 @@ +--- +layout: default +title: Discovery Plugins +nav_order: 5 +--- + +# Discovery Plugins + +The SSO Manager supports a robust plugin architecture for auto-discovering devices, hosts, and services across your home lab or data center. Plugins 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 Plugin + +Plugins are simple JavaScript files placed in `nodejs/plugins/discovery/`. + +A plugin must export a single `discover` async function that returns a standardized graph of `resources` and `edges`. + +### Plugin Skeleton + +```javascript +// nodejs/plugins/discovery/my_custom_plugin.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 + +Plugins 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: { + plugins: { + my_custom_plugin: { + 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 plugin 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 plugin can add CPU info to a host that NMAP previously found). +3. **Source Tracking:** It records your plugin'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. diff --git a/nodejs/app.js b/nodejs/app.js index 6e9a1af..7b856f3 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -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/tos', middleware.auth, require('./routes/tos')); 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. app.use('/api/api-token', middleware.auth, require('./routes/api_token')); @@ -105,7 +106,20 @@ app.use('/oauth', oauthRouter); app.use('/api/oauth', middleware.auth, oauthApiRouter); app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client')); app.get('/.well-known/openid-configuration', discovery); +app.use('/api/webhook', require('./routes/webhook')); +app.use('/api/plugins', middleware.auth, require('./routes/plugins')); +const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware'); +const vaultApiProxy = createProxyMiddleware({ + target: 'http://openbao:8200', + changeOrigin: true, + pathRewrite: { '^/': '/v1/' }, + on: { + proxyReq: fixRequestBody + } +}); + +app.use('/api/vault', middleware.auth, vaultApiProxy); // Catch 404 and forward to error handler. If none of the above routes are // used, this is what will be called. @@ -128,5 +142,18 @@ app.use(function(err, req, res, next) { } 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}); + } }); diff --git a/nodejs/bin/www b/nodejs/bin/www index 7d154e0..6b1d2a9 100755 --- a/nodejs/bin/www +++ b/nodejs/bin/www @@ -31,9 +31,17 @@ const models = require('../models'); * Initialize ORM, then Listen on provided port, on all network interfaces. */ models.initORM().then(() => { + return require('../utils/conf_manager').init(); +}).then(() => { server.listen(port); server.on('error', onError); 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 => { console.error('Failed to initialize ORM:', err); process.exit(1); diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index 9847b23..9554225 100644 Binary files a/nodejs/config/inventory.sqlite and b/nodejs/config/inventory.sqlite differ diff --git a/nodejs/docs/plugins.md b/nodejs/docs/plugins.md new file mode 100644 index 0000000..b240c27 --- /dev/null +++ b/nodejs/docs/plugins.md @@ -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. diff --git a/nodejs/docs/vault.md b/nodejs/docs/vault.md new file mode 100644 index 0000000..c79b7df --- /dev/null +++ b/nodejs/docs/vault.md @@ -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 " http:///api/vault/secret/data/ +``` diff --git a/nodejs/merge_dups.js b/nodejs/merge_dups.js new file mode 100644 index 0000000..179084b --- /dev/null +++ b/nodejs/merge_dups.js @@ -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); diff --git a/nodejs/models/index.js b/nodejs/models/index.js index 18db4b0..aa89de7 100644 --- a/nodejs/models/index.js +++ b/nodejs/models/index.js @@ -15,7 +15,7 @@ require('./api_token'); const { init } = require('@simpleworkjs/orm'); const { Resource, ResourceEdge, ResourceGroup } = require('./resource'); const { AccessRequest } = require('./access_request'); - +const { Webhook } = require('./webhook'); async function initORM() { const ormConf = conf.orm || { dialect: 'sqlite', @@ -29,7 +29,7 @@ async function initORM() { await init({ conf: { orm: ormConf }, models: [ - Resource, ResourceEdge, ResourceGroup, AccessRequest, + Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook, Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken ] }); diff --git a/nodejs/models/webhook.js b/nodejs/models/webhook.js new file mode 100644 index 0000000..bfb746b --- /dev/null +++ b/nodejs/models/webhook.js @@ -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 }; diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 21ef99d..c0c4007 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -1,12 +1,12 @@ { "name": "t42-sso-manager", - "version": "1.11.0", + "version": "1.13.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "t42-sso-manager", - "version": "1.11.0", + "version": "1.13.0", "license": "MIT", "dependencies": { "@fortawesome/fontawesome-free": "^7.3.0", @@ -19,11 +19,14 @@ "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", + "bullmq": "^6.0.3", "compression": "^1.8.1", "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "extend": "^3.0.2", + "http-proxy-middleware": "^4.2.0", + "ioredis": "^6.0.0", "jq-repeat": "^2.2.0", "jquery": "^4.0.0", "jsonwebtoken": "^9.0.3", @@ -33,6 +36,8 @@ "model-redis": "^1.6.0", "moment": "^2.30.1", "mustache": "^4.2.0", + "node-fetch": "^2.7.0", + "node-nmap": "^4.0.0", "nodemailer": "^9.0.0", "p2psub": "^0.2.0", "socket.io": "^4.8.3", @@ -650,6 +655,12 @@ "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": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1098,6 +1109,84 @@ "@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": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", @@ -2244,7 +2333,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2334,6 +2422,54 @@ "dev": true, "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": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -2759,6 +2895,18 @@ "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -2848,6 +2996,15 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3451,7 +3608,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3881,6 +4037,51 @@ "url": "https://opencollective.com/express" } }, + "node_modules/http-proxy-middleware": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-4.2.0.tgz", + "integrity": "sha512-ZA+oNOoM+GLoFTIzhkJptVQov73Srep2LBqhF8hG8CIPKO3nam1jonXVQ/QUH8RbwsmaaVz2SOJdzBNBHNtKbw==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "httpxy": "^0.5.4", + "is-glob": "^4.0.3", + "is-plain-obj": "^4.1.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": "^22.15.0 || ^24.0.0 || >=26.0.0" + } + }, + "node_modules/http-proxy-middleware/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/http-proxy-middleware/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/httpxy": { + "version": "0.5.5", + "resolved": "https://registry.npmjs.org/httpxy/-/httpxy-0.5.5.tgz", + "integrity": "sha512-uDjmnPyp1q4Sgzf3w+J/Fc6UqcCEj0x4Wjp7OqK5dGhNeDgpyrAmnS6ey8QWrX3SWDon2DMKf9sBa5X9+CVyMA==", + "license": "MIT" + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", @@ -3997,6 +4198,59 @@ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", "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": { "version": "10.2.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", @@ -4039,7 +4293,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4069,7 +4322,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4082,12 +4334,23 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" } }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-promise": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", @@ -5082,6 +5345,15 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", @@ -5180,6 +5452,31 @@ "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": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5324,6 +5621,37 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "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": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz", @@ -5395,6 +5723,12 @@ "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": { "version": "8.9.0", "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz", @@ -5404,6 +5738,26 @@ "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": { "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", @@ -5440,6 +5794,21 @@ "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": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", @@ -5486,6 +5855,16 @@ "dev": true, "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": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -6077,6 +6456,12 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", @@ -6201,6 +6586,15 @@ "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": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -6305,6 +6699,15 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "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": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6915,6 +7318,12 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7342,7 +7751,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -7376,13 +7784,17 @@ "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": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, - "license": "0BSD", - "optional": true + "license": "0BSD" }, "node_modules/tunnel-agent": { "version": "0.6.0", @@ -7613,6 +8025,22 @@ "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": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -7774,6 +8202,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": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 79dada2..06e190f 100755 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -1,6 +1,6 @@ { "name": "t42-sso-manager", - "version": "1.11.0", + "version": "1.13.0", "description": "A very simple LDAP management and SSO system", "author": [ { @@ -31,11 +31,14 @@ "@simpleworkjs/orm": "^0.2.8", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", + "bullmq": "^6.0.3", "compression": "^1.8.1", "ejs": "^3.1.10", "express": "^5.2.1", "express-rate-limit": "^8.5.2", "extend": "^3.0.2", + "http-proxy-middleware": "^4.2.0", + "ioredis": "^6.0.0", "jq-repeat": "^2.2.0", "jquery": "^4.0.0", "jsonwebtoken": "^9.0.3", @@ -45,6 +48,8 @@ "model-redis": "^1.6.0", "moment": "^2.30.1", "mustache": "^4.2.0", + "node-fetch": "^2.7.0", + "node-nmap": "^4.0.0", "nodemailer": "^9.0.0", "p2psub": "^0.2.0", "socket.io": "^4.8.3", diff --git a/nodejs/patch.diff b/nodejs/patch.diff new file mode 100644 index 0000000..4b55e83 --- /dev/null +++ b/nodejs/patch.diff @@ -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 @@ +
+
+
+-
++ ++
++
++
+
+
+ Directory Management +@@ -74,6 +93,148 @@ + +
+
++ ++ ++
++
++
++
++ Network Discovery Dashboard ++
++
++ ++ ++
++
++ ++
++ Auto-discovered network resources. Promote unmanaged devices to track them in the Directory. ++ ++
++
++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++
Name / SourceTypeIP AddressStatusActions
++
{{name}}
++
++ {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}} ++
++
++ {{kind}} ++ {{#metadata.subType}} ++ {{metadata.subType}} ++ {{/metadata.subType}} ++ ++ {{#metadata.ip}}
{{metadata.ip}}
{{/metadata.ip}} ++ {{^metadata.ip}}Unknown IP{{/metadata.ip}} ++ {{#metadata.interfaces.length}} ++
++ {{#metadata.interfaces}} ++
{{mac}} {{#ip}}({{ip}}){{/ip}}
++ {{/metadata.interfaces}} ++
++ {{/metadata.interfaces.length}} ++
++ {{#metadata.managed}} ++ Managed ++ {{/metadata.managed}} ++ {{^metadata.managed}} ++ Unmanaged ++ {{/metadata.managed}} ++ ++ {{^metadata.managed}} ++ ++ {{/metadata.managed}} ++ {{#metadata.managed}} ++ ++ {{/metadata.managed}} ++
++
++
++
++ ++ ++
++
++
++
++ Plugins & Scheduler ++
++
++
++ Manage background tasks and schedules. Learn how to make and use custom plugins. ++
++
++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++ ++
Plugin NameCron ScheduleStatusActions
{{name}} ++ {{#enabled}}Enabled{{/enabled}} ++ {{^enabled}}Disabled{{/enabled}} ++ ++ ++ {{#enabled}}{{/enabled}} ++ {{^enabled}}{{/enabled}} ++
++
++
++
++ +
+
+
+@@ -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('
' + (err.message || 'Error loading resources') + '
').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('
Successfully promoted! Created groups: ' + res.groups.join(', ') + '
').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) { diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js new file mode 100644 index 0000000..81f909b --- /dev/null +++ b/nodejs/plugins/discovery/nmap.js @@ -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(); + }); + } +}; diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js new file mode 100644 index 0000000..1d881a1 --- /dev/null +++ b/nodejs/plugins/discovery/proxmox.js @@ -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 }; + } +}; diff --git a/nodejs/plugins/discovery/unifi.js b/nodejs/plugins/discovery/unifi.js new file mode 100644 index 0000000..6b4373e --- /dev/null +++ b/nodejs/plugins/discovery/unifi.js @@ -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 }; + } +}; diff --git a/nodejs/routes/api_conf.js b/nodejs/routes/api_conf.js new file mode 100644 index 0000000..1ba664e --- /dev/null +++ b/nodejs/routes/api_conf.js @@ -0,0 +1,33 @@ +const router = require('express').Router(); +const confManager = require('../utils/conf_manager'); +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); +}); + +router.post('/', async (req, res, next) => { + try { + await confManager.setVaultConf(req.body); + res.json({ success: true }); + } catch(err) { + next(err); + } +}); + +module.exports = router; diff --git a/nodejs/routes/api_directory_admin.js b/nodejs/routes/api_directory_admin.js index d0eb5ed..5b0c5de 100644 --- a/nodejs/routes/api_directory_admin.js +++ b/nodejs/routes/api_directory_admin.js @@ -42,7 +42,12 @@ router.use(async (req, res, next) => { // --- Resources --- router.get('/resources', async (req, res, next) => { 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 // the wire; projectResources strips it unconditionally. res.json({ results: projectResources(resources, { fullMetadata: true }) }); diff --git a/nodejs/routes/discovery.js b/nodejs/routes/discovery.js index 2cb6bfc..09507c0 100644 --- a/nodejs/routes/discovery.js +++ b/nodejs/routes/discovery.js @@ -83,7 +83,12 @@ router.get('/me', async (req, res, next) => { for (const rg of rgs) ids.add(rg.resourceId); } 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 // 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); } }); +// 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; diff --git a/nodejs/routes/docs.js b/nodejs/routes/docs.js index 564181f..8ef2832 100644 --- a/nodejs/routes/docs.js +++ b/nodejs/routes/docs.js @@ -34,6 +34,8 @@ const DOCS = { 'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, 'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')}, directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')}, + plugins: {title: 'Plugins & Scheduler', file: path.join(__dirname, '../../docs/plugins.md')}, + vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')}, diff --git a/nodejs/routes/index.js b/nodejs/routes/index.js index dd6ffc6..0ba5692 100755 --- a/nodejs/routes/index.js +++ b/nodejs/routes/index.js @@ -68,6 +68,18 @@ router.get('/directory', function(req, res) { 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, next) { + res.render('vaultwarden', {...values}); +}); + // 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 // use of :slug at all -- the client reads location.pathname itself and opens @@ -99,6 +111,10 @@ router.get('/users', async function(req, res, next) { res.render('users', {...values}); }); +router.get('/conf', async function(req, res, next) { + res.render('conf', {...values}); +}); + router.get('/login', async function(req, res, next) { res.render('login', {...values, redirect: req.query.redirect}); }); diff --git a/nodejs/routes/plugins.js b/nodejs/routes/plugins.js new file mode 100644 index 0000000..eebedfd --- /dev/null +++ b/nodejs/routes/plugins.js @@ -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; diff --git a/nodejs/routes/webhook.js b/nodejs/routes/webhook.js new file mode 100644 index 0000000..88686d8 --- /dev/null +++ b/nodejs/routes/webhook.js @@ -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; diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js new file mode 100644 index 0000000..cce127c --- /dev/null +++ b/nodejs/services/discovery_reconciler.js @@ -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 }; diff --git a/nodejs/services/scheduler.js b/nodejs/services/scheduler.js new file mode 100644 index 0000000..edc26c5 --- /dev/null +++ b/nodejs/services/scheduler.js @@ -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 }; diff --git a/nodejs/services/webhook_emitter.js b/nodejs/services/webhook_emitter.js new file mode 100644 index 0000000..732dc29 --- /dev/null +++ b/nodejs/services/webhook_emitter.js @@ -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 }; diff --git a/nodejs/sso.db b/nodejs/sso.db new file mode 100644 index 0000000..e69de29 diff --git a/nodejs/test-proxy.js b/nodejs/test-proxy.js new file mode 100644 index 0000000..240bad5 --- /dev/null +++ b/nodejs/test-proxy.js @@ -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); diff --git a/nodejs/test_plugins.js b/nodejs/test_plugins.js new file mode 100644 index 0000000..691968d --- /dev/null +++ b/nodejs/test_plugins.js @@ -0,0 +1,36 @@ +const proxmox = require('./plugins/discovery/proxmox'); +const unifi = require('./plugins/discovery/unifi'); + +async function test() { + console.log("=== Running Proxmox Plugin ==="); + try { + const pveData = await proxmox.discover({ + url: 'https://dl380-0.internal.718it.biz:8006', + tokenId: 'root@pam!agy', + tokenSecret: '1e7c0e31-6767-4295-bcda-d7acf5df1d9a' + }); + console.log(`Found ${pveData.resources.length} resources and ${pveData.edges.length} edges.`); + console.log("Sample resource:"); + console.log(JSON.stringify(pveData.resources[0], null, 2)); + console.log("Sample edge:"); + console.log(JSON.stringify(pveData.edges[0], null, 2)); + } catch (e) { + console.error("Proxmox failed:", e.message); + } + + console.log("\n=== Running Unifi Plugin ==="); + try { + const unifiData = await unifi.discover({ + url: 'https://unifi.718it.biz', + user: 'agy', + password: 'MyPassword!23' + }); + console.log(`Found ${unifiData.resources.length} resources and ${unifiData.edges.length} edges.`); + console.log("Sample resource:"); + console.log(JSON.stringify(unifiData.resources.find(r => r.kind === 'network_device'), null, 2)); + } catch (e) { + console.error("Unifi failed:", e.message); + } +} + +test(); diff --git a/nodejs/tests/reconciler.test.js b/nodejs/tests/reconciler.test.js new file mode 100644 index 0000000..f74976c --- /dev/null +++ b/nodejs/tests/reconciler.test.js @@ -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 + }); +}); diff --git a/nodejs/tests/webhook.test.js b/nodejs/tests/webhook.test.js new file mode 100644 index 0000000..a703021 --- /dev/null +++ b/nodejs/tests/webhook.test.js @@ -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); + }); +}); diff --git a/nodejs/utils/conf_manager.js b/nodejs/utils/conf_manager.js new file mode 100644 index 0000000..b0b3ec1 --- /dev/null +++ b/nodejs/utils/conf_manager.js @@ -0,0 +1,53 @@ +'use strict'; +const conf = require('@simpleworkjs/conf'); +const VAULT_URL = 'http://openbao:8200'; +const VAULT_TOKEN = 'root'; + +async function getVaultConf() { + try { + const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, { + headers: { 'X-Vault-Token': VAULT_TOKEN } + }); + if (res.status === 200) { + const json = await res.json(); + return json.data.data; + } + } catch (err) { + console.error('Error fetching conf from Vault:', err); + } + return null; +} + +async function setVaultConf(newConf) { + const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, { + method: 'POST', + headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' }, + body: JSON.stringify({ data: newConf }) + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`Vault API error: ${res.status} ${text}`); + } + applyConf(newConf); +} + +function applyConf(newConf) { + if (!newConf) return; + // Deep merge into conf + for (const key of Object.keys(newConf)) { + if (typeof newConf[key] === 'object' && newConf[key] !== null && !Array.isArray(newConf[key])) { + conf[key] = { ...conf[key], ...newConf[key] }; + } else { + conf[key] = newConf[key]; + } + } +} + +async function init() { + const vaultConf = await getVaultConf(); + if (vaultConf) { + applyConf(vaultConf); + } +} + +module.exports = { getVaultConf, setVaultConf, init }; diff --git a/nodejs/utils/ui.js b/nodejs/utils/ui.js index d015ef5..4ec5b09 100644 --- a/nodejs/utils/ui.js +++ b/nodejs/utils/ui.js @@ -43,8 +43,10 @@ module.exports = { // non-admin had no signposted destination at all. {href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []}, {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: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []}, {href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']}, ], }; diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs new file mode 100644 index 0000000..6832349 --- /dev/null +++ b/nodejs/views/conf.ejs @@ -0,0 +1,71 @@ +<%- include('top') %> + + +
+
+
+

System Configuration

+

+ Manage runtime configuration such as SMTP settings, discovery plugins, and OAuth parameters. + These secrets are stored securely in OpenBao Vault. +

+
+
+ +
+
+
+
+
Configuration (JSON)
+
+
+
+ Be careful when editing this JSON! Malformed JSON will not save. +
+ +
+ +
+
+
+
+ +<%- include('bottom') %> diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs index c7646a4..72d304d 100644 --- a/nodejs/views/directory.ejs +++ b/nodejs/views/directory.ejs @@ -3,7 +3,26 @@
-
+ +
+
+
Directory Management @@ -74,6 +93,147 @@
+
+ + +
+
+
+
+ Network Discovery Dashboard +
+
+ +
+
+ +
+ Auto-discovered network resources. Promote unmanaged devices to track them in the Directory. + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
Name / SourceTypeIP AddressStatusActions
+
{{name}}
+
+ {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}} +
+
+ {{kind}} + {{#metadata.discovery_sources}} + {{.}} + {{/metadata.discovery_sources}} + {{#metadata.subType}} + {{metadata.subType}} + {{/metadata.subType}} + + {{#metadata.ip}}
{{metadata.ip}}
{{/metadata.ip}} + {{^metadata.ip}}Unknown IP{{/metadata.ip}} + {{#metadata.interfaces.length}} +
+ {{#metadata.interfaces}} +
{{mac}} {{#ip}}({{ip}}){{/ip}}
+ {{/metadata.interfaces}} +
+ {{/metadata.interfaces.length}} +
+ {{#metadata.managed}} + Managed + {{/metadata.managed}} + {{^metadata.managed}} + Unmanaged + {{/metadata.managed}} + + {{^metadata.managed}} + + {{/metadata.managed}} + {{#metadata.managed}} + + {{/metadata.managed}} +
+
+
+
+ + +
+
+
+
+ Plugins & Scheduler +
+
+
+ Manage background tasks and schedules. Learn how to make and use custom plugins. +
+
+ + + + + + + + + + + + + + + + + + + + + + +
Plugin NameCron ScheduleStatusActions
{{name}} + {{#enabled}}Enabled{{/enabled}} + {{^enabled}}Disabled{{/enabled}} + + + {{#enabled}}{{/enabled}} + {{^enabled}}{{/enabled}} +
+
+
+
+
@@ -124,7 +284,7 @@
- +
@@ -1104,6 +1264,110 @@ 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('
' + (err.message || 'Error loading resources') + '
').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) { + 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('
Successfully promoted! Created groups: ' + res.groups.join(', ') + '
').show(); + renderDiscoveryTable(); + loadResources(); // 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); + + $.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(){ + loadDiscoveryResources(); + loadPlugins(); + }); <%- include('bottom') %> diff --git a/nodejs/views/discovery.ejs b/nodejs/views/discovery.ejs new file mode 100644 index 0000000..6768710 --- /dev/null +++ b/nodejs/views/discovery.ejs @@ -0,0 +1,185 @@ +<%- include('top') %> + +
+
+
+ +
+
+
+ Network Discovery Dashboard +
+
+ + +
+
+ +
+ View discovered network resources and promote them to managed SSO groups. + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Name / SourceTypeIP AddressStatusActions
+
{{name}}
+
+ {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}} +
+
+ {{kind}} + {{#metadata.subType}} + {{metadata.subType}} + {{/metadata.subType}} + + {{#metadata.ip}}
{{metadata.ip}}
{{/metadata.ip}} + {{^metadata.ip}}Unknown IP{{/metadata.ip}} + {{#metadata.interfaces.length}} +
+ {{#metadata.interfaces}} +
{{mac}} {{#ip}}({{ip}}){{/ip}}
+ {{/metadata.interfaces}} +
+ {{/metadata.interfaces.length}} +
+ {{#metadata.managed}} + Managed + {{/metadata.managed}} + {{^metadata.managed}} + Unmanaged + {{/metadata.managed}} + + {{^metadata.managed}} + + {{/metadata.managed}} + {{#metadata.managed}} + + {{/metadata.managed}} +
+
+
+
+
+
+ + + +<%- include('bottom') %> diff --git a/nodejs/views/error.ejs b/nodejs/views/error.ejs new file mode 100644 index 0000000..03fdca4 --- /dev/null +++ b/nodejs/views/error.ejs @@ -0,0 +1,25 @@ +<%- include('top') %> + +
+
+
+
+ +
+

<%= error.status || 500 %>

+

<%= error.message || 'Something went wrong' %>

+

+ <% 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. + <% } %> +

+ + Return to Home + +
+
+
+ +<%- include('bottom') %> diff --git a/nodejs/views/landing.ejs b/nodejs/views/landing.ejs index 319ea3f..0b7db71 100644 --- a/nodejs/views/landing.ejs +++ b/nodejs/views/landing.ejs @@ -298,7 +298,7 @@ var mineIds = {}; state.mine.forEach(function(r){ mineIds[r.id] = true; }); 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. diff --git a/nodejs/views/plugins.ejs b/nodejs/views/plugins.ejs new file mode 100644 index 0000000..07990c3 --- /dev/null +++ b/nodejs/views/plugins.ejs @@ -0,0 +1,102 @@ +<%- include('top') %> + +
+
+
+ +
+
+
+ Plugins & Scheduler +
+
+
+ View configured background plugins and scheduler status. Note: Plugins are configured statically in sso-secrets.js. +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
Plugin NameCron ScheduleStatusDetails
{{name}}{{cron}} + {{#enabled}}Enabled{{/enabled}} + {{^enabled}}Disabled{{/enabled}} + + {{details}} +
+
+
+
+
+
+ + + +<%- include('bottom') %> diff --git a/nodejs/views/vaultwarden.ejs b/nodejs/views/vaultwarden.ejs new file mode 100644 index 0000000..03a4003 --- /dev/null +++ b/nodejs/views/vaultwarden.ejs @@ -0,0 +1,233 @@ +<%- include('top') %> + +
+
+

Vault Secrets

+ +
+ +
+
+
+
+
Secrets List
+
+
+
Loading...
+
+
+
+
+ + +
+ +

Select a secret to view its details

+
+
+
+
+ + + + + + +<%- include('bottom') %>