Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb5b2c2a7 | |||
| c8c4cad46d | |||
| 59d4b65195 | |||
| 74746e409b | |||
| 70aed035a5 | |||
| 0264a62b22 | |||
| c212537163 | |||
| 391ad12afc | |||
| 622317b6da | |||
| aa17981c15 | |||
| 99fc0d2819 | |||
| 276629a587 | |||
| a26d54ec6f | |||
| 011d4b2975 | |||
| ecc9b62842 |
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
|||||||
secrets.json
|
secrets.json
|
||||||
secrets.js
|
secrets.js
|
||||||
|
|
||||||
|
# Per-deployment secret files (real LDAP/SMTP/jwtSecret + generated OAuth
|
||||||
|
# creds). theta-env bind-mounts ./config and generates/fills these at setup;
|
||||||
|
# they must never be committed. The empty *.example templates ARE tracked.
|
||||||
|
config/*-secrets.js
|
||||||
|
|
||||||
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
# Jekyll build artifact (GitHub Pages builds remotely; ignore locally)
|
||||||
docs/_site
|
docs/_site
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,58 @@ All notable changes to this project are documented here. Format loosely
|
|||||||
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versions
|
||||||
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
correspond to git tags (`vX.Y.Z`) and `nodejs/package.json`'s `version`.
|
||||||
|
|
||||||
|
## [1.16.0] - 2026-08-01
|
||||||
|
|
||||||
|
OpenBao becomes the central secrets store for the theta42 stack, and the SSO
|
||||||
|
Manager becomes its broker. This is the SSO's half of the move: it loads its
|
||||||
|
own secrets from OpenBao, mints scoped tokens for users and external apps,
|
||||||
|
and exposes a fixed, role-scoped personal-secrets UI.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Secrets now load from OpenBao at boot** via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config
|
||||||
|
(replacing the old `utils/conf_manager.js`, which did a shallow-per-key
|
||||||
|
merge). `bin/www` runs `bao-conf.init()` after `models.initORM()` and
|
||||||
|
before `listen`. Fail-soft: if OpenBao is unreachable, boot continues from
|
||||||
|
`CONF_SECRETS`. The SSO authenticates with a scoped `VAULT_TOKEN` (policy
|
||||||
|
`sso-broker`), never the root token. The admin **Configuration** UI
|
||||||
|
(`/api/conf`) now writes through `bao-conf.set('sso-manager', …)`.
|
||||||
|
- **`/api/vault` proxy reworked** — the old endpoint was an ungated
|
||||||
|
pass-through that never injected an `X-Vault-Token` (so the UI was both
|
||||||
|
ungated *and* broken). It is now `middleware.auth` → `scopeGuard` → a
|
||||||
|
token-injecting proxy. `scopeGuard` resolves a per-user (`user-<uid>`) or
|
||||||
|
per-admin (`sso-admin`) token via the new `utils/vault_broker.js`
|
||||||
|
(Redis-cached, minted through the `sso-broker` token role) and enforces a
|
||||||
|
path prefix as a second layer on top of the OpenBao policy. The client
|
||||||
|
`auth-token` is stripped; only the server-minted token reaches OpenBao.
|
||||||
|
- **Vault UI reworked and renamed** (`views/vaultwarden.ejs` →
|
||||||
|
`views/vault.ejs`; the `/vault` route is now `middleware.auth`-gated).
|
||||||
|
Non-admin users see only their `secret/users/<uid>/` namespace; admins get
|
||||||
|
free-form path entry across `secret/` plus an **Apps** tab to mint scoped
|
||||||
|
tokens for external apps (`secret/apps/<name>/*`, shown once with copy +
|
||||||
|
`curl` convention).
|
||||||
|
- Bumped package version to track the release tag.
|
||||||
|
|
||||||
|
### Removed
|
||||||
|
- `nodejs/utils/conf_manager.js` (replaced by `@simpleworkjs/bao-conf`).
|
||||||
|
- `nodejs/views/vaultwarden.ejs` (renamed `vault.ejs`).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
- **Committed-secrets remediation.** `config/sso-secrets.js` (LDAP bind
|
||||||
|
password, SMTP, `oauth.jwtSecret`) and `nodejs/test_plugins.js` (a
|
||||||
|
hardcoded Proxmox root API token and a UniFi password) were tracked on
|
||||||
|
master. They are now untracked + gitignored (`config/*-secrets.js`), and
|
||||||
|
`test_plugins.js` is deleted; `config/proxy-secrets.js.example` added as a
|
||||||
|
placeholder template. **The secrets remain in git history — rotation at
|
||||||
|
the providers is the real remediation and is the operator's to perform.**
|
||||||
|
OpenBao is now the authoritative store; the local files are seed artifacts
|
||||||
|
only.
|
||||||
|
|
||||||
|
> Note: releases v1.12.0–v1.15.2 were tagged from merge PRs without
|
||||||
|
> corresponding `CHANGELOG.md` entries or GitHub releases; this entry
|
||||||
|
> resumes the changelog at v1.16.0.
|
||||||
|
|
||||||
## [1.11.0] - 2026-07-31
|
## [1.11.0] - 2026-07-31
|
||||||
|
|
||||||
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
Closes the end-user half of the directory. The admin side could describe the lab; the user side could not tell anyone what they had or how to use it, and several of the paths meant to do so were silently returning nothing.
|
||||||
@@ -379,6 +431,15 @@ First tagged release. Establishes the `vX.Y.Z` tag convention that the in-app up
|
|||||||
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
- Unix/POSIX and LDAP bind-only service account support, distinct from real-person accounts.
|
||||||
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
- Merged OAuth Apps + LDAP Info into a single Integrations page.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [1.14.0] - 2026-08-01
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Added Configuration page in the UI to manage SSO configurations stored securely in OpenBao Vault.
|
||||||
|
- Added Discovery plugin and Scheduler integration within the Directory.
|
||||||
|
- Re-routed Vault proxy under `/api/vault` and implemented Vault authentication headers.
|
||||||
|
|
||||||
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
[Unreleased]: https://github.com/theta42/sso-manager-node/compare/v1.1.16...HEAD
|
||||||
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
[1.1.15]: https://github.com/theta42/sso-manager-node/compare/v1.1.14...v1.1.15
|
||||||
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
[1.1.14]: https://github.com/theta42/sso-manager-node/compare/v1.1.13...v1.1.14
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ COPY nodejs/services ./services
|
|||||||
COPY nodejs/utils ./utils
|
COPY nodejs/utils ./utils
|
||||||
COPY nodejs/views ./views
|
COPY nodejs/views ./views
|
||||||
COPY nodejs/public ./public
|
COPY nodejs/public ./public
|
||||||
|
COPY nodejs/plugins ./plugins
|
||||||
|
|
||||||
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
# routes/index.js reads path.join(__dirname, '../../tos.md') at boot. With the
|
||||||
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
# app flattened into /app, __dirname is /app/routes and ../../ resolves to /,
|
||||||
|
|||||||
@@ -132,6 +132,29 @@ v1.1.13 -> v1.1.14`), or `Already up to date` if there's nothing new. Full
|
|||||||
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
details, including env var overrides (`LDAP_BASE_DN`, `SKIP_LDAP`, ...), in
|
||||||
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
[DEPLOYMENT.md](DEPLOYMENT.md) under *Method 2: Bare metal*.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Secrets are loaded from **OpenBao** at boot via
|
||||||
|
[@simpleworkjs/bao-conf](https://simpleworkjs.github.io/bao-conf/), which
|
||||||
|
deep-merges `secret/sso-manager/conf` over the file-loaded config (fail-soft:
|
||||||
|
if OpenBao is unreachable, boot continues from `CONF_SECRETS`). The SSO
|
||||||
|
authenticates to OpenBao with the scoped `VAULT_TOKEN` (env, policy
|
||||||
|
`sso-broker`) — never the root token.
|
||||||
|
|
||||||
|
The SSO also acts as the **vault broker** for the whole stack: it mints
|
||||||
|
per-user (`user-<uid>`) and per-admin (`sso-admin`) tokens through the
|
||||||
|
`sso-broker` token role and exposes the personal-secrets UI at **Vault → My
|
||||||
|
Secrets** (`secret/users/<uid>/*`, server-side token injection + path-scope
|
||||||
|
guard) and an admin **Apps** tab to mint scoped tokens for external apps
|
||||||
|
(`secret/apps/<name>/*`). The old `utils/conf_manager.js` was replaced by
|
||||||
|
`@simpleworkjs/bao-conf`; the admin **Configuration** UI (`/api/conf`) now
|
||||||
|
writes `secret/sso-manager/conf` through `bao-conf.set`.
|
||||||
|
|
||||||
|
The `config/*-secrets.js` files are operator-edit seed artifacts (gitignored),
|
||||||
|
not the authoritative store. For the full architecture, policies, token model,
|
||||||
|
and rotation procedure, see theta-env's
|
||||||
|
**[Secrets docs](https://theta42.github.io/theta-env/secrets/)**.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
oidc: {
|
|
||||||
clientId: '',
|
|
||||||
clientSecret: '',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Example proxy secrets file. theta-env generates a real ./config/proxy-secrets.js
|
||||||
|
// from this shape at setup (with empty clientId/clientSecret), then bootstrap.js
|
||||||
|
// writes the SSO-generated OAuth client creds into it AND into OpenBao
|
||||||
|
// (secret/proxy/conf). The proxy loads it via @simpleworkjs/conf, then overlays
|
||||||
|
// secret/proxy/conf from OpenBao via @simpleworkjs/bao-conf at boot.
|
||||||
|
//
|
||||||
|
// The real file is gitignored (config/*-secrets.js) — never commit live creds.
|
||||||
|
// This .example is tracked to document the expected shape only.
|
||||||
|
module.exports = {
|
||||||
|
oidc: {
|
||||||
|
// The SSO registers the proxy as an OAuth client and writes the real
|
||||||
|
// values here (and into OpenBao). "set-me" is the bootstrap placeholder.
|
||||||
|
clientId: 'set-me',
|
||||||
|
clientSecret: 'set-me',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
|
|
||||||
// Example secrets configuration file (file-based config).
|
|
||||||
//
|
|
||||||
// Bare-metal: install.sh seeds a filled-in version of this file at
|
|
||||||
// /etc/sso-manager/secrets.js on first run (LDAP + JWT already live; only
|
|
||||||
// SMTP is left as a placeholder). Only write this one by hand if you're
|
|
||||||
// skipping install.sh's LDAP bootstrap (SKIP_LDAP=true) or setting up
|
|
||||||
// manually.
|
|
||||||
// Docker / unified stack: place at ./config/sso-secrets.js and bind-mount
|
|
||||||
// ./config at /config (see docker-compose.yml); docker-entrypoint.sh points
|
|
||||||
// the CONF_SECRETS env var at it so @simpleworkjs/conf reads it.
|
|
||||||
//
|
|
||||||
// Values here override conf/base.js and win over <environment>.js. `app_*` env
|
|
||||||
// vars (if any are set) override this file too — so the Docker stack passes NO
|
|
||||||
// app_* env, keeping this file authoritative.
|
|
||||||
//
|
|
||||||
// The app only reads the keys it knows (port, name, ldap, smtp, voipms, oauth).
|
|
||||||
// The extra `stack`, `bootstrap`, and `serviceAccountPass` keys below are read
|
|
||||||
// by the orchestrator (docker-entrypoint.sh, the bootstrap script, setup.sh)
|
|
||||||
// and ignored by the app — safe to leave them out for bare-metal use.
|
|
||||||
|
|
||||||
module.exports = {
|
|
||||||
port: 3001,
|
|
||||||
name: 'SSO Manager', // shown in UI and outbound email
|
|
||||||
logo: '/static/img/theta42.svg', // nav/favicon image; point at your own file under public/ to white-label
|
|
||||||
ldap: {
|
|
||||||
url: 'ldap://localhost', // or ldaps://host:636 for TLS
|
|
||||||
bindDN: 'cn=admin,dc=example,dc=com',
|
|
||||||
bindPassword: 'ldap-admin-pass',
|
|
||||||
userBase: 'ou=people,dc=example,dc=com',
|
|
||||||
groupBase: 'ou=groups,dc=example,dc=com',
|
|
||||||
// ldapsHost: 'ldap.internal.example.com', // optional: hostname shown for
|
|
||||||
// direct LDAPS binds on /integrations. Leave empty to derive from the
|
|
||||||
// OAuth issuer. Set an internal-only name to avoid port-forwarding 636.
|
|
||||||
// ldapsPort: 636,
|
|
||||||
},
|
|
||||||
smtp: {
|
|
||||||
host: 'smtp.example.com',
|
|
||||||
port: 587,
|
|
||||||
secure: false, // true for 465, false for other ports
|
|
||||||
user: 'noreply@example.com',
|
|
||||||
pass: 'your-smtp-password',
|
|
||||||
from: 'SSO Manager <noreply@example.com>',
|
|
||||||
},
|
|
||||||
voipms: {
|
|
||||||
username: '', // VoIP.ms username (optional)
|
|
||||||
password: '', // VoIP.ms password (optional)
|
|
||||||
did: '', // VoIP.ms DID (optional)
|
|
||||||
},
|
|
||||||
oauth: {
|
|
||||||
issuer: 'https://sso.example.com', // falls back to the request host at runtime
|
|
||||||
jwtSecret: 'a-long-random-development-jwt-secret-value-1234567890',
|
|
||||||
token_lifetime: {
|
|
||||||
access_token: 3600, // 1 hour in seconds
|
|
||||||
refresh_token: 2592000 // 30 days in seconds
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
// ── Orchestrator-only keys (ignored by the app) ──────────────────────────
|
|
||||||
// Read by docker-entrypoint.sh (server-side slapd config + validation), the
|
|
||||||
// superproject bootstrap script, and setup.sh. Omit for bare-metal use.
|
|
||||||
stack: {
|
|
||||||
ldapBaseDn: 'dc=example,dc=com', // slapd suffix (also drives seed OUs).
|
|
||||||
// The base DN also appears in ldap.bindDN/userBase/groupBase above and
|
|
||||||
// in oauth.issuer — keep them consistent with this value
|
|
||||||
// (cn=admin,<dn>, ou=people,<dn>, ou=groups,<dn>, https://<ssoHost>).
|
|
||||||
ldapDomain: 'example.com', // default cert CN + OAuth issuer host
|
|
||||||
ldapCertCn: '', // cert CN; empty -> defaults to ldapDomain
|
|
||||||
ssoHost: 'sso.example.com', // public SSO hostname (OAuth issuer URL)
|
|
||||||
proxyHost: 'proxy.example.com', // public proxy hostname
|
|
||||||
},
|
|
||||||
bootstrap: {
|
|
||||||
adminUid: 'admin', // initial SSO admin username
|
|
||||||
adminPass: 'AdminPass123!', // initial SSO admin password
|
|
||||||
adminEmail: 'admin@example.com', // initial SSO admin email
|
|
||||||
},
|
|
||||||
serviceAccountPass: 'proxy-service-pass', // LDAP password the proxy binds with
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Discovery Agents
|
||||||
|
nav_order: 5
|
||||||
|
---
|
||||||
|
|
||||||
|
# Discovery Agents
|
||||||
|
|
||||||
|
The SSO Manager supports a robust agent architecture for auto-discovering devices, hosts, and services across your home lab or data center. Agents run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||||
|
|
||||||
|
## Writing a Custom Agent
|
||||||
|
|
||||||
|
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
||||||
|
|
||||||
|
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||||
|
|
||||||
|
### Agent Skeleton
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// nodejs/agents/discovery/my_custom_agent.js
|
||||||
|
module.exports = {
|
||||||
|
discover: async (config) => {
|
||||||
|
const { url, apiKey } = config; // Provided by your configuration
|
||||||
|
|
||||||
|
const resources = [];
|
||||||
|
const edges = [];
|
||||||
|
|
||||||
|
// 1. Fetch your data from an API
|
||||||
|
// const data = await fetch(...);
|
||||||
|
|
||||||
|
// 2. Map data to Resources
|
||||||
|
resources.push({
|
||||||
|
kind: 'network_device', // 'host', 'service', 'network_device', 'unmanaged_device'
|
||||||
|
name: 'My Switch',
|
||||||
|
slug: 'my-switch-01',
|
||||||
|
metadata: {
|
||||||
|
make: 'Vendor',
|
||||||
|
model: 'Model X',
|
||||||
|
interfaces: [
|
||||||
|
{ mac: '00:1A:2B:3C:4D:5E', ip: '10.0.0.5' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. Map relations to Edges (optional)
|
||||||
|
edges.push({
|
||||||
|
parentSlug: 'my-switch-01',
|
||||||
|
childSlug: 'some-connected-client-slug',
|
||||||
|
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resources, edges };
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
// ... existing config ...
|
||||||
|
discovery: {
|
||||||
|
agents: {
|
||||||
|
my_custom_agent: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '*/30 * * * *', // Run every 30 minutes
|
||||||
|
url: 'https://api.example.com',
|
||||||
|
apiKey: 'secret-key'
|
||||||
|
},
|
||||||
|
nmap: {
|
||||||
|
enabled: true,
|
||||||
|
cron: '0 * * * *',
|
||||||
|
targetRange: '192.168.1.0/24'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## The Reconciliation Engine
|
||||||
|
|
||||||
|
When your agent returns its graph, the Reconciliation Engine takes over:
|
||||||
|
1. **Matching:** It tries to find an existing device in the database matching any MAC address provided in the `interfaces` array. If no MAC matches, it falls back to IP address, and then to `slug`.
|
||||||
|
2. **Merging:** If it finds a match, it gracefully merges the metadata (so your agent can add CPU info to a host that NMAP previously found).
|
||||||
|
3. **Source Tracking:** It records your agent's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
||||||
|
4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
layout: default
|
||||||
|
title: Secrets Vault
|
||||||
|
nav_order: 6
|
||||||
|
---
|
||||||
|
|
||||||
|
# Secrets Vault
|
||||||
|
|
||||||
|
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
|
||||||
|
|
||||||
|
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
|
||||||
|
|
||||||
|
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
|
||||||
|
|
||||||
|
## Accessing the Vault
|
||||||
|
|
||||||
|
The SSO Manager Vault can be accessed in two ways:
|
||||||
|
|
||||||
|
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
|
||||||
|
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
|
||||||
|
|
||||||
|
### API Example
|
||||||
|
|
||||||
|
To read secrets from the default key-value store, issue a `GET` request to:
|
||||||
|
`/api/vault/v1/secret/data/sso-manager/conf`
|
||||||
|
|
||||||
|
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
|
||||||
|
|
||||||
|
## Namespaces and Paths
|
||||||
|
|
||||||
|
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
|
||||||
|
|
||||||
|
## Plugin Integration
|
||||||
|
|
||||||
|
When building custom Agents or integrations, they can utilize the local Vault to retrieve API tokens instead of hardcoding them. Always use the `/api/vault` proxy to ensure permissions are consistently enforced.
|
||||||
+27
-1
@@ -97,6 +97,7 @@ app.use('/api/access-requests', middleware.auth, require('./routes/access_reques
|
|||||||
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
app.use('/api/update-check', middleware.auth, require('./routes/update_check'));
|
||||||
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
app.use('/api/tos', middleware.auth, require('./routes/tos'));
|
||||||
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
app.use('/api/metrics', middleware.auth, require('./routes/api_metrics'));
|
||||||
|
app.use('/api/conf', middleware.auth, require('./routes/api_conf'));
|
||||||
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
// Self-service API tokens (PATs) — owner-scoped, no admin group required.
|
||||||
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
app.use('/api/api-token', middleware.auth, require('./routes/api_token'));
|
||||||
|
|
||||||
@@ -105,7 +106,19 @@ app.use('/oauth', oauthRouter);
|
|||||||
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
app.use('/api/oauth', middleware.auth, oauthApiRouter);
|
||||||
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||||
app.get('/.well-known/openid-configuration', discovery);
|
app.get('/.well-known/openid-configuration', discovery);
|
||||||
|
app.use('/api/webhook', require('./routes/webhook'));
|
||||||
|
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||||
|
|
||||||
|
// OpenBao vault API. The broker mints a server-side scoped token per user
|
||||||
|
// (per-user user-<uid> or, for admins, sso-admin), enforces the path prefix
|
||||||
|
// (scopeGuard), and injects ONLY that token into the proxied request — the
|
||||||
|
// client's sso auth headers are stripped and never reach OpenBao. Non-admins
|
||||||
|
// are confined to secret/users/<uid>/*; admins roam all of secret/. The
|
||||||
|
// admin-only app-token mint route is mounted BEFORE the proxy so it isn't
|
||||||
|
// shadowed by the catch-all /api/vault proxy.
|
||||||
|
const vaultBroker = require('./utils/vault_broker');
|
||||||
|
app.use('/api/vault/apps', middleware.auth, vaultBroker.mintAppRouter);
|
||||||
|
app.use('/api/vault', middleware.auth, vaultBroker.scopeGuard, vaultBroker.vaultProxy());
|
||||||
|
|
||||||
// Catch 404 and forward to error handler. If none of the above routes are
|
// Catch 404 and forward to error handler. If none of the above routes are
|
||||||
// used, this is what will be called.
|
// used, this is what will be called.
|
||||||
@@ -128,5 +141,18 @@ app.use(function(err, req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
res.status(err.status || 500);
|
res.status(err.status || 500);
|
||||||
res.json({name: err.name, message: err.message});
|
if (req.accepts('html') && !req.originalUrl.startsWith('/api/')) {
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const buildInfo = require('./utils/build_info');
|
||||||
|
res.render('error', {
|
||||||
|
name: conf.name,
|
||||||
|
title: 'Error',
|
||||||
|
titleIcon: '',
|
||||||
|
logo: conf.logo,
|
||||||
|
error: err,
|
||||||
|
...buildInfo
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({name: err.name, message: err.message});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -31,9 +31,22 @@ const models = require('../models');
|
|||||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||||
*/
|
*/
|
||||||
models.initORM().then(() => {
|
models.initORM().then(() => {
|
||||||
|
// Overlay secret/sso-manager/conf from OpenBao over the file-loaded conf.
|
||||||
|
// Fail-soft: if OpenBao is unreachable, conf keeps the ./config/sso-secrets.js
|
||||||
|
// values and boot continues. (Same position the old conf_manager held, so
|
||||||
|
// call-time conf readers — which is how sso consumes its secrets — are
|
||||||
|
// unaffected; nothing in sso captures a secret at require time.)
|
||||||
|
return require('@simpleworkjs/bao-conf').init({ path: 'sso-manager', conf });
|
||||||
|
}).then(() => {
|
||||||
server.listen(port);
|
server.listen(port);
|
||||||
server.on('error', onError);
|
server.on('error', onError);
|
||||||
server.on('listening', onListening);
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
// Initialize scheduler
|
||||||
|
const { initScheduler } = require('../services/scheduler');
|
||||||
|
initScheduler(conf.discovery).catch(err => {
|
||||||
|
console.error('Failed to initialize scheduler:', err);
|
||||||
|
});
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
console.error('Failed to initialize ORM:', err);
|
console.error('Failed to initialize ORM:', err);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# Vault Secrets Management
|
||||||
|
|
||||||
|
The Vault Secrets feature integrates with OpenBao to provide a secure key-value store for your environment. It allows you to store sensitive information like passwords, API keys, and credentials, ensuring they are encrypted and access-controlled.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
You can access the Vault UI from the application's top navigation bar.
|
||||||
|
|
||||||
|
### Creating Secrets
|
||||||
|
|
||||||
|
1. Click on the **New Secret** button.
|
||||||
|
2. Enter a **Secret Path**. This acts as the name/identifier of your secret (e.g., `db-credentials`).
|
||||||
|
3. Enter the **Secret Data** in JSON format. For example:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"username": "admin",
|
||||||
|
"password": "supersecretpassword123"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
4. Click **Save Secret**.
|
||||||
|
|
||||||
|
### Reading and Editing Secrets
|
||||||
|
|
||||||
|
* To view a secret, click on its name in the **Secrets List**.
|
||||||
|
* To update an existing secret, select it and click the **Edit** button. You can then modify the JSON data and save your changes.
|
||||||
|
|
||||||
|
### OpenBao Integration
|
||||||
|
|
||||||
|
The secrets are stored in an OpenBao backend configured in development mode. The default KV (Key-Value) version 2 engine is mounted at `secret/`. The built-in UI uses the `/api/vault/secret/` API endpoints to interact with OpenBao.
|
||||||
|
|
||||||
|
## API Access
|
||||||
|
|
||||||
|
If you need to programmatically access the secrets, you can interact directly with the OpenBao API using the root token (in dev mode):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Example: Read a secret via the API
|
||||||
|
curl -H "X-Vault-Token: root" -H "Authorization: Bearer <your-sso-token>" http://<your-sso-host>/api/vault/secret/data/<your-secret-path>
|
||||||
|
```
|
||||||
@@ -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);
|
||||||
@@ -15,7 +15,7 @@ require('./api_token');
|
|||||||
const { init } = require('@simpleworkjs/orm');
|
const { init } = require('@simpleworkjs/orm');
|
||||||
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
const { Resource, ResourceEdge, ResourceGroup } = require('./resource');
|
||||||
const { AccessRequest } = require('./access_request');
|
const { AccessRequest } = require('./access_request');
|
||||||
|
const { Webhook } = require('./webhook');
|
||||||
async function initORM() {
|
async function initORM() {
|
||||||
const ormConf = conf.orm || {
|
const ormConf = conf.orm || {
|
||||||
dialect: 'sqlite',
|
dialect: 'sqlite',
|
||||||
@@ -29,7 +29,7 @@ async function initORM() {
|
|||||||
await init({
|
await init({
|
||||||
conf: { orm: ormConf },
|
conf: { orm: ormConf },
|
||||||
models: [
|
models: [
|
||||||
Resource, ResourceEdge, ResourceGroup, AccessRequest,
|
Resource, ResourceEdge, ResourceGroup, AccessRequest, Webhook,
|
||||||
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
Token, AuthToken, InviteToken, ImpersonationToken, PasswordResetToken, OtpToken, ServiceToken
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -295,6 +295,9 @@ User.listDetail = async function(){
|
|||||||
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
obj.onboardingRequired = obj.onboardingNeeds.length > 0 ? 'yes' : '';
|
||||||
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
obj.isServiceAccount = serviceAccountDNs.has(String(obj.dn).toLowerCase()) ? 'yes' : '';
|
||||||
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
obj.managerUids = obj.manager.map(dn => dnToUid.get(String(dn).toLowerCase()) || dn);
|
||||||
|
// hasSshKey is a boolean flag for the UI -- sshPublicKey may be an array,
|
||||||
|
// and Mustache's {{#sshPublicKey}}...{{/sshPublicKey}} iterates over each item.
|
||||||
|
obj.hasSshKey = obj.sshPublicKey ? 'yes' : '';
|
||||||
|
|
||||||
return obj;
|
return obj;
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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 };
|
||||||
Generated
+508
-11
@@ -1,17 +1,18 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.11.0",
|
"version": "1.16.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.11.0",
|
"version": "1.16.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
@@ -19,11 +20,14 @@
|
|||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -33,6 +37,8 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -650,6 +656,12 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@ioredis/commands": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@isaacs/cliui": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
||||||
@@ -1098,6 +1110,84 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "1.1.6",
|
"version": "1.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||||
@@ -1258,6 +1348,18 @@
|
|||||||
"node": ">=18.0.0"
|
"node": ">=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@simpleworkjs/bao-conf": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/bao-conf/-/bao-conf-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-mcay5NQ/w9ShpIAolMP/3f9TfXSLE+d5jrA4dTPOUHDjTkdsP7pe4hMmQUmwnniR59U1bGoRIVdXjvDbX3I5nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"extend": "^3.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@simpleworkjs/conf": {
|
"node_modules/@simpleworkjs/conf": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||||
@@ -1423,6 +1525,15 @@
|
|||||||
"@types/ms": "*"
|
"@types/ms": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/http-proxy": {
|
||||||
|
"version": "1.17.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-proxy/-/http-proxy-1.17.17.tgz",
|
||||||
|
"integrity": "sha512-ED6LB+Z1AVylNTu7hdzuBqOgMnvG/ld6wGCG8wFnAzKX5uyW2K3WD52v0gnLCTK/VLpXtKckgWuyScYK6cSPaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/istanbul-lib-coverage": {
|
"node_modules/@types/istanbul-lib-coverage": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||||
@@ -2244,7 +2355,6 @@
|
|||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fill-range": "^7.1.1"
|
"fill-range": "^7.1.1"
|
||||||
@@ -2334,6 +2444,54 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/bullmq": {
|
||||||
|
"version": "6.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-6.0.3.tgz",
|
||||||
|
"integrity": "sha512-ri/ugcNf4G/knwnMd2LVuwIdyzI9A2a2CipYvvfG6H4I1X23DhNrDtd8yuj46dqeE8kdoUSPlTJ9rEtfs4W/cg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cron-parser": "5.6.1",
|
||||||
|
"msgpackr": "2.0.5",
|
||||||
|
"node-abort-controller": "3.1.1",
|
||||||
|
"semver": "7.8.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bullmq-otel": ">=2.0.0",
|
||||||
|
"ioredis": ">=5.0.0",
|
||||||
|
"pg": ">=8.0.0",
|
||||||
|
"redis": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bullmq-otel": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"ioredis": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/bullmq/node_modules/semver": {
|
||||||
|
"version": "7.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
|
||||||
|
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"semver": "bin/semver.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bytes": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -2759,6 +2917,18 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/cron-parser": {
|
||||||
|
"version": "5.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-5.6.1.tgz",
|
||||||
|
"integrity": "sha512-QBm4o1PwZiuY7KFbVvW7FLC8bozy7YWzv+Fz6KRS7sQghzcbDZCGxr/Bc5b6TQreAoSwuWVP491dIcK0THCX6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"luxon": "^3.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -2848,6 +3018,15 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/denque": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/depd": {
|
"node_modules/depd": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||||
@@ -3201,6 +3380,12 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "4.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||||
|
"integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/execa": {
|
"node_modules/execa": {
|
||||||
"version": "5.1.1",
|
"version": "5.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz",
|
||||||
@@ -3451,7 +3636,6 @@
|
|||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"to-regex-range": "^5.0.1"
|
"to-regex-range": "^5.0.1"
|
||||||
@@ -3518,6 +3702,26 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
|
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/foreground-child": {
|
"node_modules/foreground-child": {
|
||||||
"version": "3.3.1",
|
"version": "3.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
|
||||||
@@ -3881,6 +4085,44 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/http-proxy": {
|
||||||
|
"version": "1.18.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy/-/http-proxy-1.18.1.tgz",
|
||||||
|
"integrity": "sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^4.0.0",
|
||||||
|
"follow-redirects": "^1.0.0",
|
||||||
|
"requires-port": "^1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/http-proxy-middleware": {
|
||||||
|
"version": "2.0.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/http-proxy-middleware/-/http-proxy-middleware-2.0.10.tgz",
|
||||||
|
"integrity": "sha512-RKzRWNPxUZqbuk3BC5mGVJbBnWgr+diEnjJexIOytFbBzDy88Fbh/YvBr3DsNrl1jYAfjWfpATEv0NO35FDuPQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-proxy": "^1.17.8",
|
||||||
|
"http-proxy": "^1.18.1",
|
||||||
|
"is-glob": "^4.0.1",
|
||||||
|
"is-plain-obj": "^3.0.0",
|
||||||
|
"micromatch": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/express": "^4.17.13"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/express": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz",
|
||||||
@@ -3997,6 +4239,59 @@
|
|||||||
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/ioredis": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@ioredis/commands": "2.0.0",
|
||||||
|
"cluster-key-slot": "1.1.1",
|
||||||
|
"debug": "4.4.3",
|
||||||
|
"denque": "2.1.0",
|
||||||
|
"redis-errors": "1.2.0",
|
||||||
|
"standard-as-callback": "2.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/ioredis"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/cluster-key-slot": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ioredis/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.2.0",
|
"version": "10.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||||
@@ -4039,7 +4334,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -4069,7 +4363,6 @@
|
|||||||
"version": "4.0.3",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-extglob": "^2.1.1"
|
"is-extglob": "^2.1.1"
|
||||||
@@ -4082,12 +4375,23 @@
|
|||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-plain-obj": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-gwsOE28k+23GP1B6vFl1oVh/WOzmawBrKwo5Ev6wMKzPkaXaCDIQKzLnvsA42DRlbVTWorkgTKIviAKCWkfUwA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-promise": {
|
"node_modules/is-promise": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
|
||||||
@@ -5082,6 +5386,15 @@
|
|||||||
"node": "20 || >=22"
|
"node": "20 || >=22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/luxon": {
|
||||||
|
"version": "3.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
|
||||||
|
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/make-dir": {
|
"node_modules/make-dir": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||||
@@ -5180,6 +5493,31 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime": {
|
"node_modules/mime": {
|
||||||
"version": "2.6.0",
|
"version": "2.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz",
|
||||||
@@ -5324,6 +5662,37 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/msgpackr": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-cef05H/dSYpLpqp3sj/qyZh5vhUYCalnaLO7j1yOmpsR0y/XwLVtK7r5gn+U/F7CTEfMowcGhlUQJDLcLf7jcA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"msgpackr-extract": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/msgpackr-extract": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"node-gyp-build-optional-packages": "5.2.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"download-msgpackr-prebuilds": "bin/download-prebuilds.js"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4",
|
||||||
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mustache": {
|
"node_modules/mustache": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/mustache/-/mustache-4.2.0.tgz",
|
||||||
@@ -5395,6 +5764,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-abort-controller": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "8.9.0",
|
"version": "8.9.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.0.tgz",
|
||||||
@@ -5404,6 +5779,26 @@
|
|||||||
"node": "^18 || ^20 || >= 21"
|
"node": "^18 || ^20 || >= 21"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-fetch": {
|
||||||
|
"version": "2.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
|
||||||
|
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-url": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "4.x || >=6.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"encoding": "^0.1.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"encoding": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp": {
|
"node_modules/node-gyp": {
|
||||||
"version": "12.4.0",
|
"version": "12.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
|
||||||
@@ -5440,6 +5835,21 @@
|
|||||||
"node-gyp-build-test": "build-test.js"
|
"node-gyp-build-test": "build-test.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/node-gyp-build-optional-packages": {
|
||||||
|
"version": "5.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
|
||||||
|
"integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"detect-libc": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"node-gyp-build-optional-packages": "bin.js",
|
||||||
|
"node-gyp-build-optional-packages-optional": "optional.js",
|
||||||
|
"node-gyp-build-optional-packages-test": "build-test.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-gyp/node_modules/isexe": {
|
"node_modules/node-gyp/node_modules/isexe": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
|
||||||
@@ -5486,6 +5896,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/node-nmap": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/node-nmap/-/node-nmap-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-VJGebpYsfqmUm46+Fq0qp1Y9VXGXZ7/WL03tHGy1oJHHxaJ2DvYLMjuYWYHDV0pgUL+e5/9rCN/QEsx3+fU9TA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queued-up": "^2.0.2",
|
||||||
|
"xml2js": "^0.4.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/node-releases": {
|
"node_modules/node-releases": {
|
||||||
"version": "2.0.51",
|
"version": "2.0.51",
|
||||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
|
||||||
@@ -6077,6 +6497,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queued-up": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/queued-up/-/queued-up-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-6ToqVyUPHRoIcxLKyUz7TCph2NULzoc41TAjdX/Fv7wsvj+E7tAAgqOab1cIFe0uTLJNWOswbLG4eDd2j3Y8AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/range-parser": {
|
"node_modules/range-parser": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz",
|
||||||
@@ -6201,6 +6627,15 @@
|
|||||||
"node": ">= 20.0.0"
|
"node": ">= 20.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redis-errors": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/require-directory": {
|
"node_modules/require-directory": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
@@ -6211,6 +6646,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/requires-port": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/resolve-cwd": {
|
"node_modules/resolve-cwd": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz",
|
||||||
@@ -6305,6 +6746,15 @@
|
|||||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/sax": {
|
||||||
|
"version": "1.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/semver": {
|
"node_modules/semver": {
|
||||||
"version": "6.3.1",
|
"version": "6.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
|
||||||
@@ -6915,6 +7365,12 @@
|
|||||||
"node": ">=10"
|
"node": ">=10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standard-as-callback": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@@ -7342,7 +7798,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"is-number": "^7.0.0"
|
"is-number": "^7.0.0"
|
||||||
@@ -7376,13 +7831,17 @@
|
|||||||
"nodetouch": "bin/nodetouch.js"
|
"nodetouch": "bin/nodetouch.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "0.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
|
||||||
|
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||||
"dev": true,
|
"license": "0BSD"
|
||||||
"license": "0BSD",
|
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/tunnel-agent": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
@@ -7613,6 +8072,22 @@
|
|||||||
"makeerror": "1.0.12"
|
"makeerror": "1.0.12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tr46": "~0.0.3",
|
||||||
|
"webidl-conversions": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@@ -7774,6 +8249,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml2js": {
|
||||||
|
"version": "0.4.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
|
||||||
|
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"sax": ">=0.6.0",
|
||||||
|
"xmlbuilder": "~11.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlbuilder": {
|
||||||
|
"version": "11.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/xss": {
|
"node_modules/xss": {
|
||||||
"version": "1.0.15",
|
"version": "1.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/xss/-/xss-1.0.15.tgz",
|
||||||
|
|||||||
+7
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "t42-sso-manager",
|
"name": "t42-sso-manager",
|
||||||
"version": "1.11.0",
|
"version": "1.16.0",
|
||||||
"description": "A very simple LDAP management and SSO system",
|
"description": "A very simple LDAP management and SSO system",
|
||||||
"author": [
|
"author": [
|
||||||
{
|
{
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"@simpleworkjs/app-stack": "^1.0.0",
|
"@simpleworkjs/app-stack": "^1.0.0",
|
||||||
|
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||||
"@simpleworkjs/conf": "^1.2.0",
|
"@simpleworkjs/conf": "^1.2.0",
|
||||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||||
"@simpleworkjs/frontend": "^0.2.7",
|
"@simpleworkjs/frontend": "^0.2.7",
|
||||||
@@ -31,11 +32,14 @@
|
|||||||
"@simpleworkjs/orm": "^0.2.8",
|
"@simpleworkjs/orm": "^0.2.8",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bullmq": "^6.0.3",
|
||||||
"compression": "^1.8.1",
|
"compression": "^1.8.1",
|
||||||
"ejs": "^3.1.10",
|
"ejs": "^3.1.10",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"extend": "^3.0.2",
|
"extend": "^3.0.2",
|
||||||
|
"http-proxy-middleware": "^2.0.10",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"jq-repeat": "^2.2.0",
|
"jq-repeat": "^2.2.0",
|
||||||
"jquery": "^4.0.0",
|
"jquery": "^4.0.0",
|
||||||
"jsonwebtoken": "^9.0.3",
|
"jsonwebtoken": "^9.0.3",
|
||||||
@@ -45,6 +49,8 @@
|
|||||||
"model-redis": "^1.6.0",
|
"model-redis": "^1.6.0",
|
||||||
"moment": "^2.30.1",
|
"moment": "^2.30.1",
|
||||||
"mustache": "^4.2.0",
|
"mustache": "^4.2.0",
|
||||||
|
"node-fetch": "^2.7.0",
|
||||||
|
"node-nmap": "^4.0.0",
|
||||||
"nodemailer": "^9.0.0",
|
"nodemailer": "^9.0.0",
|
||||||
"p2psub": "^0.2.0",
|
"p2psub": "^0.2.0",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
diff --git a/nodejs/views/directory.ejs b/nodejs/views/directory.ejs
|
||||||
|
index c7646a4..411b56f 100644
|
||||||
|
--- a/nodejs/views/directory.ejs
|
||||||
|
+++ b/nodejs/views/directory.ejs
|
||||||
|
@@ -3,7 +3,26 @@
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
- <div class="card shadow">
|
||||||
|
+ <ul class="nav nav-tabs mb-3" id="directoryTabs" role="tablist">
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
|
||||||
|
+ <i class="fa-solid fa-server"></i> Directory
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Discovery
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ <li class="nav-item" role="presentation">
|
||||||
|
+ <button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </button>
|
||||||
|
+ </li>
|
||||||
|
+ </ul>
|
||||||
|
+ <div class="tab-content" id="directoryTabsContent">
|
||||||
|
+ <div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-server"></i> Directory Management
|
||||||
|
@@ -74,6 +93,148 @@
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
+
|
||||||
|
+ <!-- Discovery Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
+ </div>
|
||||||
|
+ <div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
+ <input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
|
||||||
|
+ <select id="discovery-filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderDiscoveryTable()" style="width: 150px;">
|
||||||
|
+ <option value="unmanaged">Unmanaged Only</option>
|
||||||
|
+ <option value="managed">Managed Only</option>
|
||||||
|
+ <option value="all">All Resources</option>
|
||||||
|
+ </select>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
|
||||||
|
+ <a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Name / Source</th>
|
||||||
|
+ <th>Type</th>
|
||||||
|
+ <th>IP Address</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th class="text-end pe-3">Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="discovery-list" jq-repeat="discoveryResources">
|
||||||
|
+ <tr id="discovery-row-{{slug}}">
|
||||||
|
+ <td class="ps-3">
|
||||||
|
+ <div class="fw-bold">{{name}}</div>
|
||||||
|
+ <div class="text-muted small">
|
||||||
|
+ <i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
+ </div>
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <span class="badge bg-secondary">{{kind}}</span>
|
||||||
|
+ {{#metadata.subType}}
|
||||||
|
+ <span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
+ {{/metadata.subType}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
+ {{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
+ {{#metadata.interfaces.length}}
|
||||||
|
+ <div class="mt-1 small text-muted">
|
||||||
|
+ {{#metadata.interfaces}}
|
||||||
|
+ <div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
+ {{/metadata.interfaces}}
|
||||||
|
+ </div>
|
||||||
|
+ {{/metadata.interfaces.length}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ <td class="text-end pe-3">
|
||||||
|
+ {{^metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
+ <i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ {{#metadata.managed}}
|
||||||
|
+ <button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
+ Promoted
|
||||||
|
+ </button>
|
||||||
|
+ {{/metadata.managed}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="discovery-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
+ <i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
+ <h5>No resources found</h5>
|
||||||
|
+ <p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
+ <!-- Plugins Tab Pane -->
|
||||||
|
+ <div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||||
|
+ <div class="card shadow border-top-0">
|
||||||
|
+ <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
+ <div>
|
||||||
|
+ <i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ <div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
+ <i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/plugins">Learn how to make and use custom plugins</a>.
|
||||||
|
+ </div>
|
||||||
|
+ <div class="table-responsive">
|
||||||
|
+ <table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
+ <thead class="table-light">
|
||||||
|
+ <tr>
|
||||||
|
+ <th class="ps-3">Plugin Name</th>
|
||||||
|
+ <th>Cron Schedule</th>
|
||||||
|
+ <th>Status</th>
|
||||||
|
+ <th>Actions</th>
|
||||||
|
+ </tr>
|
||||||
|
+ </thead>
|
||||||
|
+ <tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
+ <tr>
|
||||||
|
+ <td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
+ <td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
|
||||||
|
+ <td>
|
||||||
|
+ {{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
+ {{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ <td>
|
||||||
|
+ <button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
|
||||||
|
+ {{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
|
||||||
|
+ {{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ <tbody id="plugins-empty-state" style="display: none;">
|
||||||
|
+ <tr>
|
||||||
|
+ <td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
+ No plugins configured.
|
||||||
|
+ </td>
|
||||||
|
+ </tr>
|
||||||
|
+ </tbody>
|
||||||
|
+ </table>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+ </div>
|
||||||
|
+
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@@ -413,14 +574,144 @@
|
||||||
|
const parentEdge = allEdges.find(e => e.childId === r.id);
|
||||||
|
if (parentEdge) {
|
||||||
|
r.parentId = parentEdge.parentId;
|
||||||
|
- const parent = resourcesById[parentEdge.parentId];
|
||||||
|
+ const parent = resourcesById[parentEdge.parentId];
|
||||||
|
if (parent) r.hostName = parent.name;
|
||||||
|
}
|
||||||
|
rawResources.push(r);
|
||||||
|
}
|
||||||
|
+ function openAddModal(parent_id, kind) {
|
||||||
|
+ if(parent_id){
|
||||||
|
+ $('#newResourceParent').val(parent_id);
|
||||||
|
+ $('#newResourceKind').val(kind);
|
||||||
|
+ var currentLabel = "Resource";
|
||||||
|
+ if(kind === 'Host'){ currentLabel = 'Host'; }
|
||||||
|
+ else if(kind === 'Site'){ currentLabel = 'Site'; }
|
||||||
|
+
|
||||||
|
+ $('#newResourceLabel').text('Add Child ' + currentLabel);
|
||||||
|
+ }else{
|
||||||
|
+ $('#newResourceParent').val('');
|
||||||
|
+ $('#newResourceKind').val('Host');
|
||||||
|
+ $('#newResourceLabel').text('Add Resource');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // Clear input
|
||||||
|
+ $('#newResourceName').val('');
|
||||||
|
+ $('#addResourceModal').modal('show');
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- DISCOVERY SCRIPTS ---
|
||||||
|
+ let allDiscoveryResources = [];
|
||||||
|
+
|
||||||
|
+ function loadDiscoveryResources() {
|
||||||
|
+ app.api.get('discovery/resources', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ allDiscoveryResources = res.results || [];
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function renderDiscoveryTable() {
|
||||||
|
+ const search = $('#discovery-search-filter').val().toLowerCase();
|
||||||
|
+ const managedFilter = $('#discovery-filter-managed').val();
|
||||||
|
+
|
||||||
|
+ const filtered = allDiscoveryResources.filter(r => {
|
||||||
|
+ if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
+ const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
+ if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
|
+ if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
+ return true;
|
||||||
|
+ });
|
||||||
|
+
|
||||||
|
+ $.scope.discoveryResources.empty();
|
||||||
|
+ for(const r of filtered) {
|
||||||
|
+ $.scope.discoveryResources.push(r);
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ if(filtered.length === 0) {
|
||||||
|
+ $('#discovery-list').hide();
|
||||||
|
+ $('#discovery-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ $('#discovery-list').show();
|
||||||
|
+ $('#discovery-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function promoteResource(slug) {
|
||||||
|
+ if(!confirm("Are you sure you want to promote this resource? This will generate SSO LDAP groups for it.")) return;
|
||||||
|
+ app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error promoting resource: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const resource = allDiscoveryResources.find(r => r.slug === slug);
|
||||||
|
+ if(resource) {
|
||||||
|
+ resource.metadata = resource.metadata || {};
|
||||||
|
+ resource.metadata.managed = true;
|
||||||
|
+ }
|
||||||
|
+ $('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||||
|
+ renderDiscoveryTable();
|
||||||
|
+ renderTable(); // Also update directory tab
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ // --- PLUGINS SCRIPTS ---
|
||||||
|
+ function loadPlugins() {
|
||||||
|
+ app.api.get('plugins', function(err, res) {
|
||||||
|
+ if(err) {
|
||||||
|
+ alert("Error loading plugins: " + (err.message || err));
|
||||||
|
+ return;
|
||||||
|
+ }
|
||||||
|
+ const plugins = res.results || {};
|
||||||
|
+ const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
- renderTable();
|
||||||
|
+ $.scope.plugins.empty();
|
||||||
|
+ if(pluginNames.length === 0) {
|
||||||
|
+ $('#plugins-list').hide();
|
||||||
|
+ $('#plugins-empty-state').show();
|
||||||
|
+ } else {
|
||||||
|
+ pluginNames.forEach(name => {
|
||||||
|
+ const config = plugins[name];
|
||||||
|
+ $.scope.plugins.push({
|
||||||
|
+ name: name,
|
||||||
|
+ cron: config.cron || '',
|
||||||
|
+ enabled: config.enabled
|
||||||
|
+ });
|
||||||
|
+ });
|
||||||
|
+ $('#plugins-list').show();
|
||||||
|
+ $('#plugins-empty-state').hide();
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
|
||||||
|
+ function updatePlugin(name) {
|
||||||
|
+ const cron = $('#cron-' + name).val();
|
||||||
|
+ app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to save: " + err.message); return; }
|
||||||
|
+ alert("Saved schedule successfully.");
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ function togglePlugin(name, enable) {
|
||||||
|
+ app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
|
||||||
|
+ if(err) { alert("Failed to toggle: " + err.message); return; }
|
||||||
|
+ loadPlugins();
|
||||||
|
+ });
|
||||||
|
+ }
|
||||||
|
+
|
||||||
|
+ $(document).ready(function(){
|
||||||
|
+ renderTable();
|
||||||
|
+ loadDiscoveryResources();
|
||||||
|
+ loadPlugins();
|
||||||
|
+
|
||||||
|
+ // Auto-open modal if hash is present
|
||||||
|
+ if(window.location.hash && window.location.hash.startsWith('#modal-')) {
|
||||||
|
+ const slug = window.location.hash.replace('#modal-', '');
|
||||||
|
+ setTimeout(() => openEditModal(slug), 500);
|
||||||
|
+ }
|
||||||
|
+ });
|
||||||
|
// Type-ahead for the "what can this user reach" lookup. Non-blocking: the
|
||||||
|
// input accepts a free-typed uid whether or not the list ever arrives.
|
||||||
|
loadDirectoryUsers().then(function(users) {
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', async (req, res) => {
|
||||||
|
const editable = {
|
||||||
|
smtp: conf.smtp || {},
|
||||||
|
discovery: conf.discovery || {},
|
||||||
|
oauth: conf.oauth || {}
|
||||||
|
};
|
||||||
|
res.json(editable);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shallow-per-key merge of `src` into the live conf object (matches the old
|
||||||
|
// conf_manager.applyConf behaviour: nested objects are spread, not deep-merged,
|
||||||
|
// so call-time conf readers see saved values without a restart).
|
||||||
|
function applyToLiveConf(src) {
|
||||||
|
if (!src) return;
|
||||||
|
for (const key of Object.keys(src)) {
|
||||||
|
if (typeof src[key] === 'object' && src[key] !== null && !Array.isArray(src[key])) {
|
||||||
|
conf[key] = { ...(conf[key] || {}), ...src[key] };
|
||||||
|
} else {
|
||||||
|
conf[key] = src[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
router.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const existing = await baoConf.get('sso-manager/conf') || {};
|
||||||
|
// Deep merge req.body into existing
|
||||||
|
for (const key of Object.keys(req.body)) {
|
||||||
|
if (typeof req.body[key] === 'object' && req.body[key] !== null && !Array.isArray(req.body[key])) {
|
||||||
|
existing[key] = { ...(existing[key] || {}), ...req.body[key] };
|
||||||
|
} else {
|
||||||
|
existing[key] = req.body[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await baoConf.set('sso-manager/conf', existing);
|
||||||
|
// Reflect the saved values in the live conf immediately (the next boot's
|
||||||
|
// bao-conf.init() would pick them up too, but this keeps running readers
|
||||||
|
// current without a restart, as the old conf_manager did).
|
||||||
|
applyToLiveConf(existing);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -42,7 +42,12 @@ router.use(async (req, res, next) => {
|
|||||||
// --- Resources ---
|
// --- Resources ---
|
||||||
router.get('/resources', async (req, res, next) => {
|
router.get('/resources', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const resources = await Resource.list();
|
let resources = await Resource.list();
|
||||||
|
resources = resources.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
return !isAuto || isManaged;
|
||||||
|
});
|
||||||
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
// Even admins never receive secret metadata (e.g. client_secret_hash) over
|
||||||
// the wire; projectResources strips it unconditionally.
|
// the wire; projectResources strips it unconditionally.
|
||||||
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
res.json({ results: projectResources(resources, { fullMetadata: true }) });
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const router = require('express').Router();
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
|
router.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
next();
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/', (req, res) => {
|
||||||
|
res.render('conf', {
|
||||||
|
title: 'Configuration',
|
||||||
|
user: req.user
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -83,7 +83,12 @@ router.get('/me', async (req, res, next) => {
|
|||||||
for (const rg of rgs) ids.add(rg.resourceId);
|
for (const rg of rgs) ids.add(rg.resourceId);
|
||||||
}
|
}
|
||||||
const all = await Resource.list();
|
const all = await Resource.list();
|
||||||
accessible = all.filter(r => ids.has(r.id) || (r.metadata && r.metadata.isPublic));
|
accessible = all.filter(r => {
|
||||||
|
const isAuto = r.metadata?.discovery_sources?.length > 0 && !r.metadata.discovery_sources.includes('manual');
|
||||||
|
const isManaged = r.metadata?.managed === true;
|
||||||
|
if (isAuto && !isManaged) return false;
|
||||||
|
return ids.has(r.id) || (r.metadata && r.metadata.isPublic);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
// resolvedAddress is the whole point of /me ("how do I reach it") and a
|
||||||
// service inherits it from its host, so it must be computed here rather
|
// service inherits it from its host, so it must be computed here rather
|
||||||
@@ -93,4 +98,61 @@ router.get('/me', async (req, res, next) => {
|
|||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/sync
|
||||||
|
// Used by external agents (e.g. ldap-client) to push discovery data.
|
||||||
|
router.post('/sync', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const { DiscoveryReconciler } = require('../services/discovery_reconciler');
|
||||||
|
// Assuming the caller provides a source name and payload
|
||||||
|
const source = req.body.source || 'agent';
|
||||||
|
await DiscoveryReconciler.reconcile(source, req.body.payload || req.body);
|
||||||
|
res.json(envelope({ success: true }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/discovery/promote/:slug
|
||||||
|
// Promotes an unmanaged device to managed by creating its LDAP groups.
|
||||||
|
router.post('/promote/:slug', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
const resource = await Resource.getBySlug(req.params.slug);
|
||||||
|
if (!resource) return res.status(404).json(envelope({ error: 'Not found' }));
|
||||||
|
|
||||||
|
const { Group } = require('../models/group_ldap');
|
||||||
|
|
||||||
|
const accessGroup = `${resource.slug}_access`;
|
||||||
|
const adminGroup = `${resource.slug}_admin`;
|
||||||
|
|
||||||
|
// Create groups if they don't exist
|
||||||
|
try { await Group.get(accessGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: accessGroup, description: `Access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
try { await Group.get(adminGroup); } catch (e) {
|
||||||
|
if (e.status === 404) await Group.add({ name: adminGroup, description: `Admin access to ${resource.name}`, owner: req.user.dn });
|
||||||
|
else throw e;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Link them
|
||||||
|
const crypto = require('crypto');
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: accessGroup,
|
||||||
|
accessLevel: 'user'
|
||||||
|
});
|
||||||
|
await ResourceGroup.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
resourceId: resource.id,
|
||||||
|
groupCn: adminGroup,
|
||||||
|
accessLevel: 'admin'
|
||||||
|
});
|
||||||
|
|
||||||
|
const meta = resource.metadata || {};
|
||||||
|
meta.managed = true;
|
||||||
|
await resource.update({ metadata: meta });
|
||||||
|
|
||||||
|
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ const DOCS = {
|
|||||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||||
|
agents: {title: 'Agents & Scheduler', file: path.join(__dirname, '../../docs/agents.md')},
|
||||||
|
vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
|
||||||
|
|
||||||
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
|
||||||
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
changelog: {title: 'Changelog', file: path.join(__dirname, '../../CHANGELOG.md')},
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const {Tos} = require('../models/tos');
|
|||||||
const conf = require('@simpleworkjs/conf');
|
const conf = require('@simpleworkjs/conf');
|
||||||
const buildInfo = require('../utils/build_info');
|
const buildInfo = require('../utils/build_info');
|
||||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||||
|
const middleware = require('../middleware/auth');
|
||||||
|
const permission = require('../utils/permission');
|
||||||
|
|
||||||
const values ={
|
const values ={
|
||||||
title: conf.environment !== 'production' ? `dev` : '',
|
title: conf.environment !== 'production' ? `dev` : '',
|
||||||
@@ -64,10 +66,46 @@ router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
|
|||||||
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
|
||||||
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
router.get('/executive', (req, res) => res.redirect(301, '/overview'));
|
||||||
|
|
||||||
|
router.get('/conf', async function(req, res, next) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
res.render('conf', {...values});
|
||||||
|
} catch(err) {
|
||||||
|
next(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/directory', function(req, res) {
|
router.get('/directory', function(req, res) {
|
||||||
res.render('directory', {...values});
|
res.render('directory', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/discovery', function(req, res, next) {
|
||||||
|
res.redirect('/directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/plugins', function(req, res, next) {
|
||||||
|
res.redirect('/directory');
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/vault', middleware.auth, async function(req, res, next) {
|
||||||
|
// Personal per-user secrets (secret/users/<uid>/*) for everyone; admins get
|
||||||
|
// free-form access across all of secret/ plus an Apps tab to mint scoped
|
||||||
|
// tokens for external apps. The /api/vault proxy enforces the same scoping
|
||||||
|
// server-side (scopeGuard + the token's own OpenBao policy).
|
||||||
|
let isAdmin = false;
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, ['app_sso_admin']);
|
||||||
|
isAdmin = true;
|
||||||
|
} catch (e) { /* non-admin: personal namespace only */ }
|
||||||
|
res.render('vault', {
|
||||||
|
...values,
|
||||||
|
vaultUid: req.user.uid,
|
||||||
|
vaultIsAdmin: isAdmin,
|
||||||
|
vaultBase: isAdmin ? '' : `users/${req.user.uid}/`,
|
||||||
|
vaultAddr: process.env.VAULT_ADDR || 'http://openbao:8200',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
// Linkable deep-link to a single resource's modal, e.g. from the resource
|
||||||
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
// modal's app.modal `url` option. Mirrors /users/:uid below: no server-side
|
||||||
// use of :slug at all -- the client reads location.pathname itself and opens
|
// use of :slug at all -- the client reads location.pathname itself and opens
|
||||||
@@ -99,6 +137,10 @@ router.get('/users', async function(req, res, next) {
|
|||||||
res.render('users', {...values});
|
res.render('users', {...values});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/conf', async function(req, res, next) {
|
||||||
|
res.render('conf', {...values});
|
||||||
|
});
|
||||||
|
|
||||||
router.get('/login', async function(req, res, next) {
|
router.get('/login', async function(req, res, next) {
|
||||||
res.render('login', {...values, redirect: req.query.redirect});
|
res.render('login', {...values, redirect: req.query.redirect});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
require('./setup');
|
||||||
|
const { Webhook } = require('../models/webhook');
|
||||||
|
const { WebhookEmitter } = require('../services/webhook_emitter');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
describe('WebhookEmitter', () => {
|
||||||
|
let webhook;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Clear webhooks before each test
|
||||||
|
const all = await Webhook.list();
|
||||||
|
for (const w of all) {
|
||||||
|
await w.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
webhook = await Webhook.create({
|
||||||
|
id: crypto.randomUUID(),
|
||||||
|
name: 'Test Webhook',
|
||||||
|
url: 'http://localhost:9999/dummy',
|
||||||
|
events: ['discovery.new_device'],
|
||||||
|
secret: 'mysecret',
|
||||||
|
created_on: Math.floor(Date.now() / 1000)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not throw when emitting an event', async () => {
|
||||||
|
// We expect this to fail network connection but be caught gracefully by the emitter
|
||||||
|
await WebhookEmitter.emit('discovery.new_device', { name: 'Device1' });
|
||||||
|
// If it doesn't throw, test passes
|
||||||
|
expect(true).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-1
@@ -43,8 +43,10 @@ module.exports = {
|
|||||||
// non-admin had no signposted destination at all.
|
// non-admin had no signposted destination at all.
|
||||||
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
{href: '/', icon: 'fa-solid fa-compass', label: 'Catalog', groups: []},
|
||||||
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
{href: '/users', icon: 'fa-solid fa-users', label: 'Users', groups: ['app_sso_admin', 'admin']},
|
||||||
{href: '/groups', icon: 'fa-solid fa-users-viewfinder', label: 'Groups', groups: ['app_sso_admin', 'admin']},
|
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_admin']},
|
||||||
|
{href: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
|
||||||
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
|
||||||
|
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: []},
|
||||||
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
{href: '/overview', icon: 'fa-solid fa-gauge-high', label: 'Overview', groups: ['app_sso_admin', 'admin']},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Vault broker — mints scoped OpenBao tokens for end users, admins, and
|
||||||
|
// external apps, using the SSO_VAULT_TOKEN (policy `sso-broker`) and the
|
||||||
|
// `sso-broker` token role created by theta-env/setup.sh.
|
||||||
|
//
|
||||||
|
// secret/users/<uid>/* per-user personal KV (user-<uid> policy)
|
||||||
|
// secret/apps/<name>/* per-external-app namespace (app-<name> policy)
|
||||||
|
// secret/* admin UI sessions (sso-admin policy)
|
||||||
|
//
|
||||||
|
// The sso-broker policy grants update on auth/token/create/sso-broker and on
|
||||||
|
// sys/policies/acl/user-*, app-*, sso-admin — exactly what this module needs to
|
||||||
|
// create the per-subject policies and mint their tokens. Per-user/admin tokens
|
||||||
|
// are cached in Redis for the token's lifetime and re-minted on miss; per-app
|
||||||
|
// tokens are returned ONCE (displayed in the UI, never stored retrievably).
|
||||||
|
|
||||||
|
const baoConf = require('@simpleworkjs/bao-conf');
|
||||||
|
const { createClient } = require('redis');
|
||||||
|
const express = require('express');
|
||||||
|
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||||
|
const conf = require('@simpleworkjs/conf');
|
||||||
|
const permission = require('./permission');
|
||||||
|
|
||||||
|
const ROLE = 'sso-broker';
|
||||||
|
const DEFAULT_TTL = 24 * 60 * 60; // matches the role's token_period (24h)
|
||||||
|
|
||||||
|
let redisClient;
|
||||||
|
async function getRedis() {
|
||||||
|
if (!redisClient) {
|
||||||
|
const url = (conf.redis && typeof conf.redis === 'string') ? conf.redis
|
||||||
|
: (conf.redis && conf.redis.url) ? conf.redis.url : undefined;
|
||||||
|
redisClient = createClient({ url });
|
||||||
|
redisClient.on('error', (err) => console.error('Redis vault_broker error', err));
|
||||||
|
await redisClient.connect();
|
||||||
|
}
|
||||||
|
return redisClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cacheGet(key) {
|
||||||
|
try { return await (await getRedis()).get(key); } catch (e) { return null; }
|
||||||
|
}
|
||||||
|
async function cacheSet(key, value, ttl) {
|
||||||
|
try { await (await getRedis()).set(key, value, { EX: ttl }); } catch (e) { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Low-level OpenBao call via @simpleworkjs/bao-conf.request (authenticates with
|
||||||
|
// SSO_VAULT_TOKEN). Throws on non-2xx.
|
||||||
|
async function bao(method, path, body) {
|
||||||
|
const res = await baoConf.request(method, path, body);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao ${method} ${path} failed (${res.status}) ${text}`);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure an ACL policy exists (idempotent). 200 = exists, 404 = create.
|
||||||
|
async function ensurePolicy(name, hcl) {
|
||||||
|
const existing = await baoConf.request('GET', `sys/policies/acl/${name}`);
|
||||||
|
if (existing.status === 200) return;
|
||||||
|
if (existing.status !== 404) {
|
||||||
|
const t = await existing.text().catch(() => '');
|
||||||
|
throw new Error(`OpenBao policy read ${name} failed (${existing.status}) ${t}`);
|
||||||
|
}
|
||||||
|
await bao('PUT', `sys/policies/acl/${name}`, { policy: hcl });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint a token through the sso-broker role with the given policies. Returns
|
||||||
|
// { token, ttl } (ttl = lease_duration seconds, falls back to DEFAULT_TTL).
|
||||||
|
async function mintToken(policies) {
|
||||||
|
const res = await bao('POST', 'auth/token/create/sso-broker', { policies });
|
||||||
|
const json = await res.json();
|
||||||
|
const token = json && json.auth && json.auth.client_token;
|
||||||
|
if (!token) throw new Error(`OpenBao token mint returned no client_token: ${JSON.stringify(json)}`);
|
||||||
|
const ttl = (json.auth && json.auth.lease_duration) || DEFAULT_TTL;
|
||||||
|
return { token, ttl };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-user token ──────────────────────────────────────────────────────────
|
||||||
|
function userPolicyHcl(uid) {
|
||||||
|
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated
|
||||||
|
// into a policy path, so reject anything but a safe charset.
|
||||||
|
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
|
||||||
|
// Re-minted when the cache entry expires (a little before the token's own TTL).
|
||||||
|
async function getOrCreateUserToken(uid) {
|
||||||
|
if (!/^[A-Za-z0-9._-]{1,64}$/.test(uid)) throw new Error(`invalid uid for vault token: ${uid}`);
|
||||||
|
const cacheKey = `vault_token:${uid}`;
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
await ensurePolicy(`user-${uid}`, userPolicyHcl(uid));
|
||||||
|
const { token, ttl } = await mintToken([`user-${uid}`]);
|
||||||
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Admin token (read/write all of secret/) ─────────────────────────────────
|
||||||
|
async function getOrCreateAdminToken(uid) {
|
||||||
|
const cacheKey = `vault_token:admin:${uid || 'global'}`;
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) return cached;
|
||||||
|
const { token, ttl } = await mintToken(['sso-admin']);
|
||||||
|
await cacheSet(cacheKey, token, Math.max(ttl - 60, 60));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
|
||||||
|
function appPolicyHcl(name) {
|
||||||
|
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
|
||||||
|
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the app-<name> policy + mint a token for it. Returns the token ONCE
|
||||||
|
// (the admin UI shows it with a copy button); it is not stored retrievably, so
|
||||||
|
// a later compromise of an admin session cannot recover previously-minted app
|
||||||
|
// tokens. The caller must record it in the external app immediately.
|
||||||
|
async function mintAppToken(name) {
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,62}$/.test(name)) {
|
||||||
|
throw new Error('invalid app name (lowercase letters, digits, hyphens; max 63 chars)');
|
||||||
|
}
|
||||||
|
await ensurePolicy(`app-${name}`, appPolicyHcl(name));
|
||||||
|
const { token, ttl } = await mintToken([`app-${name}`]);
|
||||||
|
return { token, ttl, policy: `app-${name}`, path: `secret/apps/${name}/` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── /api/vault proxy: scope guard + token-injecting proxy ───────────────────
|
||||||
|
// Replaces the old bare pass-through (which sent no X-Vault-Token and gated
|
||||||
|
// nothing). The guard mints a server-side token for the user (per-user or
|
||||||
|
// admin) and enforces the path prefix as defense-in-depth on top of the
|
||||||
|
// token's own policy; the proxy injects ONLY that token and strips the
|
||||||
|
// client's sso auth headers so OpenBao never sees them.
|
||||||
|
|
||||||
|
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||||
|
const ADMIN_GROUP = 'app_sso_admin';
|
||||||
|
|
||||||
|
async function isAdmin(user) {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(user, [ADMIN_GROUP]);
|
||||||
|
return true;
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize a KV-v2 request path by stripping the data/metadata segment so the
|
||||||
|
// prefix check works on the logical path: /secret/data/users/alice/foo ->
|
||||||
|
// /secret/users/alice/foo. Returns null if the path isn't under /secret/.
|
||||||
|
function normalizeVaultPath(p) {
|
||||||
|
const norm = p.replace(/^\/secret\/(data|metadata)\//, '/secret/');
|
||||||
|
if (norm !== '/secret' && !norm.startsWith('/secret/')) return null;
|
||||||
|
return norm;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scopeGuard(req, res, next) {
|
||||||
|
if (!req.user || req.user.isMachine) {
|
||||||
|
return res.status(403).json({ error: 'machine tokens cannot use the vault API' });
|
||||||
|
}
|
||||||
|
const uid = req.user.uid;
|
||||||
|
const admin = await isAdmin(req.user);
|
||||||
|
let token;
|
||||||
|
try {
|
||||||
|
token = admin ? await getOrCreateAdminToken(uid) : await getOrCreateUserToken(uid);
|
||||||
|
} catch (e) {
|
||||||
|
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defense-in-depth: confirm the requested path is within the subject's
|
||||||
|
// namespace. Admins roam all of secret/; users are confined to
|
||||||
|
// secret/users/<uid>/. (The token's own policy enforces the same at the
|
||||||
|
// OpenBao layer; this catches a buggy/malicious client early with a clear
|
||||||
|
// 403 instead of an opaque OpenBao denial.)
|
||||||
|
const norm = normalizeVaultPath(req.path);
|
||||||
|
if (norm === null) {
|
||||||
|
return res.status(403).json({ error: 'vault paths must be under /secret/' });
|
||||||
|
}
|
||||||
|
const base = `/secret/users/${uid}`;
|
||||||
|
const allowed = admin || norm === base || norm.startsWith(base + '/');
|
||||||
|
if (!allowed) {
|
||||||
|
return res.status(403).json({ error: 'path outside your vault namespace' });
|
||||||
|
}
|
||||||
|
|
||||||
|
req.vaultToken = token;
|
||||||
|
req.vaultIsAdmin = admin;
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vaultProxy() {
|
||||||
|
return createProxyMiddleware({
|
||||||
|
target: VAULT_ADDR,
|
||||||
|
changeOrigin: true,
|
||||||
|
pathRewrite: { '^/': '/v1/' },
|
||||||
|
on: {
|
||||||
|
proxyReq(proxyReq, req, res, options) {
|
||||||
|
fixRequestBody(proxyReq, req, res, options);
|
||||||
|
// Inject ONLY the server-minted scoped token; strip the client's
|
||||||
|
// sso session/api auth so it never reaches OpenBao.
|
||||||
|
proxyReq.setHeader('X-Vault-Token', req.vaultToken);
|
||||||
|
proxyReq.removeHeader('auth-token');
|
||||||
|
proxyReq.removeHeader('authorization');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Admin-only: mint a one-time token for an external app. POST /api/vault/apps
|
||||||
|
// { name } -> { token, ttl, policy, path }. The token is returned ONCE and is
|
||||||
|
// not cached/stored retrievably. Mount BEFORE the /api/vault proxy.
|
||||||
|
const mintAppRouter = express.Router();
|
||||||
|
mintAppRouter.post('/', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
await permission.byGroup(req.user, [ADMIN_GROUP]);
|
||||||
|
const name = (req.body && req.body.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'name is required' });
|
||||||
|
const result = await mintAppToken(name);
|
||||||
|
res.json(result);
|
||||||
|
} catch (e) {
|
||||||
|
if (e.status === 401) return res.status(403).json({ error: 'admin only' });
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getOrCreateUserToken,
|
||||||
|
getOrCreateAdminToken,
|
||||||
|
mintAppToken,
|
||||||
|
ensurePolicy,
|
||||||
|
scopeGuard,
|
||||||
|
vaultProxy,
|
||||||
|
mintAppRouter,
|
||||||
|
};
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
<script type="text/javascript">
|
||||||
|
app.auth.forceLogin(['admin', 'app_sso_admin']);
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadConf();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadConf() {
|
||||||
|
try {
|
||||||
|
const data = await app.api.get('conf');
|
||||||
|
// Populate SMTP
|
||||||
|
if (data.smtp) {
|
||||||
|
$('#smtp-host').val(data.smtp.host || '');
|
||||||
|
$('#smtp-port').val(data.smtp.port || 587);
|
||||||
|
$('#smtp-user').val(data.smtp.user || '');
|
||||||
|
$('#smtp-pass').val(data.smtp.pass || '');
|
||||||
|
$('#smtp-from').val(data.smtp.from || '');
|
||||||
|
$('#smtp-secure').prop('checked', !!data.smtp.secure);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Populate OAuth
|
||||||
|
if (data.oauth) {
|
||||||
|
$('#oauth-issuer').val(data.oauth.issuer || '');
|
||||||
|
$('#oauth-jwtsecret').val(data.oauth.jwtSecret || '');
|
||||||
|
if (data.oauth.token_lifetime) {
|
||||||
|
$('#oauth-token-access').val(data.oauth.token_lifetime.access_token || 3600);
|
||||||
|
$('#oauth-token-refresh').val(data.oauth.token_lifetime.refresh_token || 2592000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveConf() {
|
||||||
|
const btn = $('#btn-save');
|
||||||
|
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...');
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
smtp: {
|
||||||
|
host: $('#smtp-host').val(),
|
||||||
|
port: parseInt($('#smtp-port').val(), 10) || 587,
|
||||||
|
user: $('#smtp-user').val(),
|
||||||
|
pass: $('#smtp-pass').val(),
|
||||||
|
from: $('#smtp-from').val(),
|
||||||
|
secure: $('#smtp-secure').is(':checked')
|
||||||
|
},
|
||||||
|
oauth: {
|
||||||
|
issuer: $('#oauth-issuer').val(),
|
||||||
|
jwtSecret: $('#oauth-jwtsecret').val(),
|
||||||
|
token_lifetime: {
|
||||||
|
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
|
||||||
|
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await app.api.post('conf', payload);
|
||||||
|
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
|
||||||
|
} finally {
|
||||||
|
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePassword(id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el.type === 'password') {
|
||||||
|
el.type = 'text';
|
||||||
|
} else {
|
||||||
|
el.type = 'password';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="container py-4">
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
Manage runtime configuration such as SMTP settings and OAuth parameters.
|
||||||
|
These secrets are stored securely in OpenBao Vault.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
|
||||||
|
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm border-0 h-100">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Host</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-host">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Port</label>
|
||||||
|
<input type="number" class="form-control" id="smtp-port">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">User</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-user">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Password</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="smtp-pass">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">From Address</label>
|
||||||
|
<input type="text" class="form-control" id="smtp-from">
|
||||||
|
</div>
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="smtp-secure">
|
||||||
|
<label class="form-check-label">Use Secure (TLS)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6 mb-4">
|
||||||
|
<div class="card shadow-sm border-0 h-100">
|
||||||
|
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
|
||||||
|
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Issuer URL</label>
|
||||||
|
<input type="text" class="form-control" id="oauth-issuer">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">JWT Secret</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="password" class="form-control" id="oauth-jwtsecret">
|
||||||
|
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Access Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-access">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Refresh Token Lifetime (seconds)</label>
|
||||||
|
<input type="number" class="form-control" id="oauth-token-refresh">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
+278
-6
@@ -3,7 +3,30 @@
|
|||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card shadow">
|
<div class="card shadow">
|
||||||
|
<div class="card-header">
|
||||||
|
<ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="directory-tab" data-bs-toggle="tab" data-bs-target="#directory-tab-pane" type="button" role="tab" aria-controls="directory-tab-pane" aria-selected="true">
|
||||||
|
<i class="fa-solid fa-server"></i> Directory
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
|
||||||
|
<i class="fa-solid fa-network-wired"></i> Discovery
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="plugins-tab" data-bs-toggle="tab" data-bs-target="#plugins-tab-pane" type="button" role="tab" aria-controls="plugins-tab-pane" aria-selected="false">
|
||||||
|
<i class="fa-solid fa-robot"></i> Agents & Scheduler
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<div class="tab-content" id="directoryTabsContent">
|
||||||
|
<div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
|
||||||
|
<div class="border-0">
|
||||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
<div>
|
<div>
|
||||||
<i class="fa-solid fa-server"></i> Directory Management
|
<i class="fa-solid fa-server"></i> Directory Management
|
||||||
@@ -49,12 +72,9 @@
|
|||||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
{{#metadata.isProduction}}<span class="badge bg-danger">Prod</span>{{/metadata.isProduction}}
|
||||||
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
{{^metadata.isProduction}}<span class="badge bg-info">Dev</span>{{/metadata.isProduction}}
|
||||||
<span class="badge bg-light text-dark border">{{hostName}}</span>
|
<a href="#" class="text-reset text-decoration-none ms-2" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
||||||
<br>
|
|
||||||
<a href="#" class="text-reset text-decoration-none" onclick="openEditModal('{{id}}'); return false;" title="View details">
|
|
||||||
<strong>{{name}}</strong>
|
<strong>{{name}}</strong>
|
||||||
</a>
|
</a>
|
||||||
<small class="text-muted">{{slug}}</small>
|
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
{{#metadata.ip}}<div><small>IP:</small> {{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
@@ -77,6 +97,149 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Discovery Tab Pane -->
|
||||||
|
<div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab">
|
||||||
|
<div class="border-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<input type="text" id="discovery-search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderDiscoveryTable()" style="width: 250px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Auto-discovered network resources. Promote unmanaged devices to track them in the Directory.
|
||||||
|
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Name / Source</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end pe-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="discovery-list" jq-repeat="discoveryResources">
|
||||||
|
<tr id="discovery-row-{{slug}}">
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold">{{name}}</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary me-1">{{kind}}</span>
|
||||||
|
{{#metadata.discovery_sources}}
|
||||||
|
<span class="badge bg-info text-dark me-1" style="font-size: 0.7em;">{{.}}</span>
|
||||||
|
{{/metadata.discovery_sources}}
|
||||||
|
{{#metadata.subType}}
|
||||||
|
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
{{/metadata.subType}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
{{#metadata.interfaces.length}}
|
||||||
|
<div class="mt-1 small text-muted">
|
||||||
|
{{#metadata.interfaces}}
|
||||||
|
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
{{/metadata.interfaces}}
|
||||||
|
</div>
|
||||||
|
{{/metadata.interfaces.length}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
Promoted
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="discovery-empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
<h5>No resources found</h5>
|
||||||
|
<p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Agents Tab Pane -->
|
||||||
|
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
|
||||||
|
<div class="border-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-robot"></i> Agents & Scheduler
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> Manage background tasks and schedules. <a href="/docs/agents">Learn how to make and use custom agents</a>.
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Agent Name</th>
|
||||||
|
<th>Cron Schedule</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
<td><input type="text" class="form-control form-control-sm font-monospace" id="cron-{{name}}" value="{{cron}}" style="max-width: 150px;"></td>
|
||||||
|
<td>
|
||||||
|
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="updatePlugin('{{name}}')" title="Save Schedule">Save</button>
|
||||||
|
{{#enabled}}<button class="btn btn-sm btn-outline-danger" onclick="togglePlugin('{{name}}', false)">Disable</button>{{/enabled}}
|
||||||
|
{{^enabled}}<button class="btn btn-sm btn-outline-success" onclick="togglePlugin('{{name}}', true)">Enable</button>{{/enabled}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="plugins-empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
No agents configured.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,7 +290,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label class="form-label">Host / URI Address</label>
|
<label class="form-label">Host / URI Address</label>
|
||||||
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://...">
|
<input type="text" id="res-address" class="form-control shadow-sm font-monospace" placeholder="https://... or comma-separated IPs">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -1107,6 +1270,115 @@
|
|||||||
app.messages.action('Failed to delete', $target, 'danger');
|
app.messages.action('Failed to delete', $target, 'danger');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- DISCOVERY SCRIPTS ---
|
||||||
|
let allDiscoveryResources = [];
|
||||||
|
|
||||||
|
function loadDiscoveryResources() {
|
||||||
|
app.api.get('discovery/resources', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allDiscoveryResources = res.results || [];
|
||||||
|
renderDiscoveryTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDiscoveryTable() {
|
||||||
|
const search = $('#discovery-search-filter').val().toLowerCase();
|
||||||
|
const filtered = allDiscoveryResources.filter(r => {
|
||||||
|
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
if(isManaged) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$.scope.discoveryResources.empty();
|
||||||
|
for(const r of filtered) {
|
||||||
|
$.scope.discoveryResources.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(filtered.length === 0) {
|
||||||
|
$('#discovery-list').hide();
|
||||||
|
$('#discovery-empty-state').show();
|
||||||
|
} else {
|
||||||
|
$('#discovery-list').show();
|
||||||
|
$('#discovery-empty-state').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteResource(slug) {
|
||||||
|
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resource = allDiscoveryResources.find(r => r.slug === slug);
|
||||||
|
if(resource) {
|
||||||
|
resource.metadata = resource.metadata || {};
|
||||||
|
resource.metadata.managed = true;
|
||||||
|
}
|
||||||
|
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||||
|
renderDiscoveryTable();
|
||||||
|
loadResources(); // Also update directory tab
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- AGENT SCRIPTS ---
|
||||||
|
function loadPlugins() {
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error loading agents: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plugins = res.results || {};
|
||||||
|
const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
$.scope.plugins.empty();
|
||||||
|
if(pluginNames.length === 0) {
|
||||||
|
$('#plugins-list').hide();
|
||||||
|
$('#plugins-empty-state').show();
|
||||||
|
} else {
|
||||||
|
pluginNames.forEach(name => {
|
||||||
|
const config = plugins[name];
|
||||||
|
$.scope.plugins.push({
|
||||||
|
name: name,
|
||||||
|
cron: config.cron || '',
|
||||||
|
enabled: !!config.enabled
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#plugins-list').show();
|
||||||
|
$('#plugins-empty-state').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePlugin(name) {
|
||||||
|
const cron = $('#cron-' + name).val();
|
||||||
|
app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error saving agent schedule: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
app.messages.toast("Agent schedule saved successfully.", 'success');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePlugin(name, enable) {
|
||||||
|
app.api.put('plugins/' + name, {enabled: enable}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error toggling agent: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadPlugins();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function(){
|
||||||
|
loadDiscoveryResources();
|
||||||
|
loadPlugins();
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<%- include('bottom') %>
|
<%- include('bottom') %>
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||||
|
<input type="text" id="search-filter" class="form-control form-control-sm shadow-sm" placeholder="Search resources..." onkeyup="renderTable()" style="width: 250px;">
|
||||||
|
<select id="filter-managed" class="form-select form-select-sm shadow-sm" onchange="renderTable()" style="width: 150px;">
|
||||||
|
<option value="all">All Resources</option>
|
||||||
|
<option value="unmanaged" selected>Unmanaged Only</option>
|
||||||
|
<option value="managed">Managed Only</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-header actionMessage" style="display:none"></div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> View discovered network resources and promote them to managed SSO groups.
|
||||||
|
<a href="/docs/discovery" class="text-reset float-end" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Name / Source</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th class="text-end pe-3">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="discovery-list" jq-repeat="resources">
|
||||||
|
<tr id="resource-row-{{slug}}">
|
||||||
|
<td class="ps-3">
|
||||||
|
<div class="fw-bold">{{name}}</div>
|
||||||
|
<div class="text-muted small">
|
||||||
|
<i class="fa-solid fa-plug pe-1"></i> {{#metadata.source}}{{metadata.source}}{{/metadata.source}}{{^metadata.source}}Manual{{/metadata.source}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-secondary">{{kind}}</span>
|
||||||
|
{{#metadata.subType}}
|
||||||
|
<span class="badge bg-light text-dark border">{{metadata.subType}}</span>
|
||||||
|
{{/metadata.subType}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.ip}}<div class="font-monospace small"><i class="fa-solid fa-network-wired pe-1"></i>{{metadata.ip}}</div>{{/metadata.ip}}
|
||||||
|
{{^metadata.ip}}<span class="text-muted small fst-italic">Unknown IP</span>{{/metadata.ip}}
|
||||||
|
{{#metadata.interfaces.length}}
|
||||||
|
<div class="mt-1 small text-muted">
|
||||||
|
{{#metadata.interfaces}}
|
||||||
|
<div><i class="fa-solid fa-microchip pe-1"></i> {{mac}} {{#ip}}<span class="text-black-50">({{ip}})</span>{{/ip}}</div>
|
||||||
|
{{/metadata.interfaces}}
|
||||||
|
</div>
|
||||||
|
{{/metadata.interfaces.length}}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<span class="badge bg-success rounded-pill px-2"><i class="fa-solid fa-check"></i> Managed</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<span class="badge bg-warning text-dark rounded-pill px-2"><i class="fa-solid fa-ghost"></i> Unmanaged</span>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
<td class="text-end pe-3">
|
||||||
|
{{^metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="promoteResource('{{slug}}')" title="Promote to Managed">
|
||||||
|
<i class="fa-solid fa-arrow-up-right-dots"></i> Promote
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
{{#metadata.managed}}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" disabled title="Already Managed">
|
||||||
|
Promoted
|
||||||
|
</button>
|
||||||
|
{{/metadata.managed}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="text-center py-5 text-muted">
|
||||||
|
<i class="fa-solid fa-magnifying-glass fs-2 mb-3 text-black-50"></i>
|
||||||
|
<h5>No resources found</h5>
|
||||||
|
<p>Check your filters or ensure the discovery agents are running.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'admin']);
|
||||||
|
|
||||||
|
let allResources = [];
|
||||||
|
|
||||||
|
function loadResources() {
|
||||||
|
app.api.get('discovery/resources', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
$('.actionMessage').html('<div class="alert alert-danger">' + (err.message || 'Error loading resources') + '</div>').show();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
allResources = res.results || [];
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const search = $('#search-filter').val().toLowerCase();
|
||||||
|
const managedFilter = $('#filter-managed').val();
|
||||||
|
|
||||||
|
const filtered = allResources.filter(r => {
|
||||||
|
// Name search
|
||||||
|
if(search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
|
||||||
|
|
||||||
|
// Managed filter
|
||||||
|
const isManaged = !!(r.metadata && r.metadata.managed);
|
||||||
|
if(managedFilter === 'managed' && !isManaged) return false;
|
||||||
|
if(managedFilter === 'unmanaged' && isManaged) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
$.scope.resources.empty();
|
||||||
|
for(const r of filtered) {
|
||||||
|
$.scope.resources.push(r);
|
||||||
|
}
|
||||||
|
|
||||||
|
if(filtered.length === 0) {
|
||||||
|
$('#discovery-list').hide();
|
||||||
|
$('#empty-state').show();
|
||||||
|
} else {
|
||||||
|
$('#discovery-list').show();
|
||||||
|
$('#empty-state').hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function promoteResource(slug) {
|
||||||
|
app.api.post('discovery/promote/' + slug, {}, function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error promoting resource: " + (err.message || err), 'danger');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('.actionMessage').html('<div class="alert alert-success alert-dismissible"><button type="button" class="btn-close" data-bs-dismiss="alert"></button>Successfully promoted! Created groups: ' + res.groups.join(', ') + '</div>').show();
|
||||||
|
renderTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
loadResources();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-6 text-center">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fa-solid fa-triangle-exclamation text-warning" style="font-size: 4rem;"></i>
|
||||||
|
</div>
|
||||||
|
<h1 class="display-4 fw-bold text-dark"><%= error.status || 500 %></h1>
|
||||||
|
<h3 class="mb-3 text-secondary"><%= error.message || 'Something went wrong' %></h3>
|
||||||
|
<p class="text-muted mb-4">
|
||||||
|
<% if (error.status === 404) { %>
|
||||||
|
The page you are looking for doesn't exist or has been moved.
|
||||||
|
<% } else { %>
|
||||||
|
An unexpected error occurred. Please try again later.
|
||||||
|
<% } %>
|
||||||
|
</p>
|
||||||
|
<a href="/" class="btn btn-primary shadow-sm px-4 py-2">
|
||||||
|
<i class="fa-solid fa-house me-2"></i>Return to Home
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
+64
-22
@@ -1,15 +1,6 @@
|
|||||||
<%- include('top') %>
|
<%- include('top') %>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.portal-banner {
|
|
||||||
background-color: var(--bs-primary);
|
|
||||||
color: white;
|
|
||||||
padding: 2.5rem 1rem;
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
border-radius: .5rem;
|
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
|
||||||
}
|
|
||||||
.portal-banner h1 { font-weight: 700; }
|
|
||||||
.catalog-grid {
|
.catalog-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
@@ -23,7 +14,19 @@
|
|||||||
}
|
}
|
||||||
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
.service-card:hover { transform: translateY(-3px); box-shadow: 0 .5rem 1rem rgba(0,0,0,.15)!important; }
|
||||||
.service-card .card-body { flex: 1; }
|
.service-card .card-body { flex: 1; }
|
||||||
.card-icon { font-size: 1.4rem; width: 1.8rem; text-align: center; }
|
.card-icon {
|
||||||
|
font-size: 1.4rem;
|
||||||
|
width: 1.8rem;
|
||||||
|
text-align: center;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.card-icon-img {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
.howto code {
|
.howto code {
|
||||||
display: block;
|
display: block;
|
||||||
background: var(--bs-tertiary-bg, #f1f3f5);
|
background: var(--bs-tertiary-bg, #f1f3f5);
|
||||||
@@ -37,12 +40,6 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="container mt-4">
|
<div class="container mt-4">
|
||||||
<div class="portal-banner text-center">
|
|
||||||
<h1><%- name %> Portal</h1>
|
|
||||||
<p class="lead mb-3">Everything the lab offers — what you can reach, and how to reach it.</p>
|
|
||||||
<a href="/profile" class="btn btn-light shadow-sm"><i class="fa-solid fa-user"></i> My Profile</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
<input type="text" id="catalog-search" class="form-control shadow-sm"
|
||||||
@@ -68,8 +65,36 @@
|
|||||||
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
<ul class="list-group mb-5 shadow-sm" id="approvals"></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-layer-group text-success"></i> My Access</h3>
|
<!-- My Access section with tabs -->
|
||||||
<div class="catalog-grid mb-5" id="my-services"></div>
|
<div class="card shadow mb-4">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<span><i class="fa-solid fa-layer-group text-success"></i> My Access</span>
|
||||||
|
</div>
|
||||||
|
<div class="px-3 pt-3 border-bottom">
|
||||||
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#my-services-tab" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-cube"></i> Services
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#my-hosts-tab" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-server"></i> Hosts
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="tab-content">
|
||||||
|
<div class="tab-pane fade show active" id="my-services-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-services"></div>
|
||||||
|
</div>
|
||||||
|
<div class="tab-pane fade" id="my-hosts-tab" role="tabpanel">
|
||||||
|
<div class="catalog-grid" id="my-hosts"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
<h3 class="mb-3"><i class="fa-solid fa-compass text-secondary"></i> Discover More</h3>
|
||||||
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
<p class="text-muted small">Things you don't have access to yet. Request what you need.</p>
|
||||||
@@ -99,6 +124,17 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render icon as either Font Awesome class or <img> for URL
|
||||||
|
function renderIcon(icon, kind) {
|
||||||
|
if (!icon) icon = KIND_ICONS[kind] || 'fa-solid fa-cube';
|
||||||
|
// If it starts with http, treat it as an image URL
|
||||||
|
if (/^https?:\/\//i.test(icon)) {
|
||||||
|
return '<img src="' + esc(icon) + '" alt="" class="card-icon-img">';
|
||||||
|
}
|
||||||
|
// Otherwise it's a Font Awesome class
|
||||||
|
return '<i class="' + esc(icon) + ' card-icon"></i>';
|
||||||
|
}
|
||||||
|
|
||||||
// "How do I actually use this?" — the question the directory exists to
|
// "How do I actually use this?" — the question the directory exists to
|
||||||
// answer and the one the old portal never did. Everything here is derived
|
// answer and the one the old portal never did. Everything here is derived
|
||||||
// from directory metadata; nothing is hardcoded per-service.
|
// from directory metadata; nothing is hardcoded per-service.
|
||||||
@@ -142,7 +178,6 @@
|
|||||||
|
|
||||||
function cardHtml(r, accessible) {
|
function cardHtml(r, accessible) {
|
||||||
var md = r.metadata || {};
|
var md = r.metadata || {};
|
||||||
var icon = md.icon || KIND_ICONS[r.kind] || 'fa-solid fa-cube';
|
|
||||||
var blurb = md.tagline || r.description || 'No description provided';
|
var blurb = md.tagline || r.description || 'No description provided';
|
||||||
var href = accessible ? linkFor(r) : null;
|
var href = accessible ? linkFor(r) : null;
|
||||||
var lines = accessible ? howTo(r) : [];
|
var lines = accessible ? howTo(r) : [];
|
||||||
@@ -166,10 +201,12 @@
|
|||||||
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
+ '<i class="fa-solid fa-hand"></i> Request access</button>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var iconHtml = renderIcon(md.icon, r.kind);
|
||||||
|
|
||||||
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
return '<div class="card shadow-sm service-card ' + (accessible ? 'border-success' : '') + '">'
|
||||||
+ '<div class="card-body">'
|
+ '<div class="card-body">'
|
||||||
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
+ '<h5 class="card-title d-flex align-items-start gap-2">'
|
||||||
+ '<i class="' + esc(icon) + ' card-icon ' + (accessible ? 'text-success' : 'text-secondary') + '"></i>'
|
+ iconHtml
|
||||||
+ '<span>' + esc(r.name) + '</span>'
|
+ '<span>' + esc(r.name) + '</span>'
|
||||||
+ '</h5>'
|
+ '</h5>'
|
||||||
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
+ '<div class="mb-2"><span class="badge bg-secondary">' + esc(r.kind)
|
||||||
@@ -235,7 +272,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderAll() {
|
function renderAll() {
|
||||||
renderGrid('my-services', state.mine, true);
|
// Split mine into services and hosts
|
||||||
|
var myServices = state.mine.filter(function(r) { return r.kind === 'service' || r.kind === 'oauth'; });
|
||||||
|
var myHosts = state.mine.filter(function(r) { return r.kind === 'host'; });
|
||||||
|
|
||||||
|
renderGrid('my-services', myServices, true);
|
||||||
|
renderGrid('my-hosts', myHosts, true);
|
||||||
renderGrid('other-services', state.others, false);
|
renderGrid('other-services', state.others, false);
|
||||||
renderRequests();
|
renderRequests();
|
||||||
renderApprovals();
|
renderApprovals();
|
||||||
@@ -256,7 +298,7 @@
|
|||||||
var mineIds = {};
|
var mineIds = {};
|
||||||
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
state.mine.forEach(function(r){ mineIds[r.id] = true; });
|
||||||
state.others = (allRes.results || []).filter(function(r){
|
state.others = (allRes.results || []).filter(function(r){
|
||||||
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth';
|
return !mineIds[r.id] && r.kind !== 'site' && r.kind !== 'oauth' && (r.metadata && r.metadata.managed);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Requests are best-effort: a failure here must not blank the catalog.
|
// Requests are best-effort: a failure here must not blank the catalog.
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<ul class="nav nav-tabs mb-3">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/directory"><i class="fa-solid fa-server"></i> Directory</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="/discovery"><i class="fa-solid fa-network-wired"></i> Discovery</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link active" href="/plugins"><i class="fa-solid fa-plug"></i> Plugins</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="card shadow border-top-0">
|
||||||
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||||
|
<div>
|
||||||
|
<i class="fa-solid fa-plug"></i> Plugins & Scheduler
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-3 pb-0 text-muted small border-bottom">
|
||||||
|
<i class="fa-solid fa-circle-info"></i> View configured background plugins and scheduler status. Note: Plugins are configured statically in <code>sso-secrets.js</code>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="card-body table table-hover mb-0 align-middle">
|
||||||
|
<thead class="table-light">
|
||||||
|
<tr>
|
||||||
|
<th class="ps-3">Plugin Name</th>
|
||||||
|
<th>Cron Schedule</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Details</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="plugins-list" jq-repeat="plugins">
|
||||||
|
<tr>
|
||||||
|
<td class="ps-3 fw-bold">{{name}}</td>
|
||||||
|
<td><code class="text-dark">{{cron}}</code></td>
|
||||||
|
<td>
|
||||||
|
{{#enabled}}<span class="badge bg-success">Enabled</span>{{/enabled}}
|
||||||
|
{{^enabled}}<span class="badge bg-secondary">Disabled</span>{{/enabled}}
|
||||||
|
</td>
|
||||||
|
<td class="small text-muted font-monospace">
|
||||||
|
{{details}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<tbody id="empty-state" style="display: none;">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="text-center py-4 text-muted">
|
||||||
|
No plugins configured in sso-secrets.js
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
app.api.get('plugins', function(err, res) {
|
||||||
|
if(err) {
|
||||||
|
app.messages.toast("Error loading plugins: " + (err.message || err));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const plugins = res.results || {};
|
||||||
|
const pluginNames = Object.keys(plugins);
|
||||||
|
|
||||||
|
$.scope.plugins.empty();
|
||||||
|
if(pluginNames.length === 0) {
|
||||||
|
$('#plugins-list').hide();
|
||||||
|
$('#empty-state').show();
|
||||||
|
} else {
|
||||||
|
pluginNames.forEach(name => {
|
||||||
|
const config = plugins[name];
|
||||||
|
const details = Object.entries(config)
|
||||||
|
.filter(([k, v]) => k !== 'enabled' && k !== 'cron')
|
||||||
|
.map(([k, v]) => `${k}: ${v}`)
|
||||||
|
.join(', ');
|
||||||
|
|
||||||
|
$.scope.plugins.push({
|
||||||
|
name: name,
|
||||||
|
cron: config.cron || 'N/A',
|
||||||
|
enabled: config.enabled,
|
||||||
|
details: details
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$('#plugins-list').show();
|
||||||
|
$('#empty-state').hide();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
+292
-354
@@ -6,13 +6,10 @@
|
|||||||
var isOwnProfile = !location.pathname.includes('/users/');
|
var isOwnProfile = !location.pathname.includes('/users/');
|
||||||
|
|
||||||
function renderProfile(user){
|
function renderProfile(user){
|
||||||
// data.photo = unescape(encodeURIComponent(data.jpegPhoto));
|
|
||||||
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.createTimestamp = moment(user.createTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
user.modifyTimestamp = moment(user.modifyTimestamp, "YYYYMMDDHHmmssZ").fromNow();
|
||||||
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
user.managerUids = (user.manager || []).map(app.user.dnToUid);
|
||||||
|
|
||||||
$.scope.user.update(user);
|
$.scope.user.update(user);
|
||||||
$.scope.passwordReset.update(user);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function renderUserGroups(user){
|
async function renderUserGroups(user){
|
||||||
@@ -92,9 +89,6 @@
|
|||||||
async function renderMyServices(){
|
async function renderMyServices(){
|
||||||
try{
|
try{
|
||||||
let res = await app.api.get('discovery/me');
|
let res = await app.api.get('discovery/me');
|
||||||
// The server already resolves this by walking the graph, so a service
|
|
||||||
// with no address of its own inherits its host's. Only fall back
|
|
||||||
// locally when it genuinely resolved to nothing.
|
|
||||||
res.results.forEach(r => {
|
res.results.forEach(r => {
|
||||||
r.resolvedAddress = r.resolvedAddress
|
r.resolvedAddress = r.resolvedAddress
|
||||||
|| (r.metadata && r.metadata.address)
|
|| (r.metadata && r.metadata.address)
|
||||||
@@ -112,34 +106,32 @@
|
|||||||
try{
|
try{
|
||||||
let uid = currentUser.uid;
|
let uid = currentUser.uid;
|
||||||
let res = await app.api.get(`metrics/user/${uid}`);
|
let res = await app.api.get(`metrics/user/${uid}`);
|
||||||
if (res && res.results) {
|
if (res && res.results && res.results.services) {
|
||||||
const renderList = (items, id) => {
|
const el = document.getElementById('metrics-user-services');
|
||||||
const el = document.getElementById(id);
|
if(!el) return;
|
||||||
if(!el) return;
|
el.innerHTML = '';
|
||||||
el.innerHTML = '';
|
const items = res.results.services;
|
||||||
if (!items || items.length === 0) {
|
if (!items || items.length === 0) {
|
||||||
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
el.innerHTML = '<li class="list-group-item text-muted">No data available</li>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.forEach(item => {
|
items.forEach(item => {
|
||||||
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
el.innerHTML += `<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
${item.value}
|
${item.value}
|
||||||
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
<span class="badge bg-primary rounded-pill">${item.score}</span>
|
||||||
</li>`;
|
</li>`;
|
||||||
});
|
});
|
||||||
};
|
|
||||||
renderList(res.results.services, 'metrics-user-services');
|
|
||||||
}
|
}
|
||||||
}catch(error){
|
}catch(error){
|
||||||
console.error('renderMyMetrics error:', error)
|
console.error('renderMyMetrics error:', error);
|
||||||
|
const el = document.getElementById('metrics-user-services');
|
||||||
|
if(el) el.innerHTML = '<li class="list-group-item text-danger">Failed to load metrics</li>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function determinUser(){
|
async function determinUser(){
|
||||||
if(location.pathname.includes('/users/')){
|
if(location.pathname.includes('/users/')){
|
||||||
let uid = location.pathname.replace('/users/', '');
|
let uid = location.pathname.replace('/users/', '');
|
||||||
|
|
||||||
return (await app.api.get('user/'+uid)).results;
|
return (await app.api.get('user/'+uid)).results;
|
||||||
}else{
|
}else{
|
||||||
return await app.auth.asyncUser;
|
return await app.auth.asyncUser;
|
||||||
@@ -148,16 +140,9 @@
|
|||||||
|
|
||||||
function editUser(user){
|
function editUser(user){
|
||||||
user = user || currentUser;
|
user = user || currentUser;
|
||||||
|
|
||||||
var $profileCard = $('#userProfile');
|
var $profileCard = $('#userProfile');
|
||||||
var $editCard = $('#editProfile');
|
var $editCard = $('#editProfile');
|
||||||
|
|
||||||
$.scope.editProfile.update(user);
|
$.scope.editProfile.update(user);
|
||||||
// jq-repeat's update() is trailing-edge throttled (~50ms) as of 2.1.0 --
|
|
||||||
// wait for the throttle tick to land before sliding the updated card
|
|
||||||
// into view, or it can briefly show stale/empty data. The manager
|
|
||||||
// picker is a JS widget, not a mustache-bound input, so it also has to
|
|
||||||
// wait for update() to (re-)render its empty mount div before attaching.
|
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
app.ui.userSelect('#edit-manager', {
|
app.ui.userSelect('#edit-manager', {
|
||||||
name: 'manager',
|
name: 'manager',
|
||||||
@@ -172,8 +157,6 @@
|
|||||||
function editUserSeccess(data){
|
function editUserSeccess(data){
|
||||||
currentUser = data.results;
|
currentUser = data.results;
|
||||||
renderProfile(currentUser);
|
renderProfile(currentUser);
|
||||||
// Same throttle-tick wait as editUser() above -- renderProfile() calls
|
|
||||||
// $.scope.user.update()/passwordReset.update() internally.
|
|
||||||
setTimeout(function(){
|
setTimeout(function(){
|
||||||
$('#editProfile').slideUp();
|
$('#editProfile').slideUp();
|
||||||
$('#userProfile').slideDown()
|
$('#userProfile').slideDown()
|
||||||
@@ -201,6 +184,29 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Password reset modal
|
||||||
|
function openPasswordResetModal(){
|
||||||
|
app.modal.open({
|
||||||
|
title: 'Reset Password for ' + currentUser.uid,
|
||||||
|
bodyHtml:
|
||||||
|
'<div class="actionMessage mb-3" style="display:none"></div>'
|
||||||
|
+ '<form id="passwordResetForm" action="user/' + currentUser.uid + '/password" method="put" onsubmit="formAJAX(this)" evalAJAX="app.modal.close(); app.messages.toast(\'Password updated\', \'success\');">'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">New Password</label>'
|
||||||
|
+ '<input type="password" class="form-control shadow" name="userPassword" placeholder="8+ chars; mix upper/lower/number/symbol" validate="password">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '<div class="mb-3">'
|
||||||
|
+ '<label class="form-label">Confirm Password</label>'
|
||||||
|
+ '<input type="password" class="form-control shadow" name="password" placeholder="Retype password" validate="eq:userPassword">'
|
||||||
|
+ '</div>'
|
||||||
|
+ '</form>',
|
||||||
|
footer: {
|
||||||
|
buttonsHtml: '<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>'
|
||||||
|
+ '<button type="submit" form="passwordResetForm" class="btn btn-warning"><i class="fa-solid fa-key"></i> Reset Password</button>',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(async function(){
|
$(document).ready(async function(){
|
||||||
currentUser = await determinUser();
|
currentUser = await determinUser();
|
||||||
|
|
||||||
@@ -210,7 +216,7 @@
|
|||||||
renderMyServices();
|
renderMyServices();
|
||||||
if(!isOwnProfile) {
|
if(!isOwnProfile) {
|
||||||
renderMyMetrics();
|
renderMyMetrics();
|
||||||
$('#user-metrics-card').show();
|
$('#tab-metrics').parent().show();
|
||||||
}
|
}
|
||||||
$('#personal-group-uid-label').text(currentUser.uid);
|
$('#personal-group-uid-label').text(currentUser.uid);
|
||||||
|
|
||||||
@@ -221,8 +227,6 @@
|
|||||||
name: 'members', values: [], placeholder: 'Type a username…',
|
name: 'members', values: [], placeholder: 'Type a username…',
|
||||||
});
|
});
|
||||||
|
|
||||||
// API Tokens are self-service only — never shown when an admin is
|
|
||||||
// viewing someone else's profile via /users/:uid.
|
|
||||||
if(isOwnProfile){
|
if(isOwnProfile){
|
||||||
$('#own-api-tokens-section').show();
|
$('#own-api-tokens-section').show();
|
||||||
tableAJAX();
|
tableAJAX();
|
||||||
@@ -230,318 +234,275 @@
|
|||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="row mb-3" style="display:none">
|
<div class="container mt-4">
|
||||||
<div class="col-md-4">
|
<div class="row" style="display:none">
|
||||||
<div class="shadow-lg card">
|
<div class="col-12">
|
||||||
<div class="card-header shadow">
|
<div id="userProfile" class="shadow-lg card card-default mb-4">
|
||||||
<i class="fa-solid fa-arrow-rotate-left"></i>
|
<div class="card-header shadow d-flex justify-content-between align-items-center">
|
||||||
Password Reset
|
<div>
|
||||||
<div class="float-end">
|
<i class="fa-regular fa-id-card"></i>
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
Profile: <strong>{{user.uid}}</strong>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<button type="button" onclick="openPasswordResetModal()" class="btn btn-outline-warning btn-sm">
|
||||||
|
<i class="fa-solid fa-key"></i> Reset Password
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="editUser()" class="btn btn-warning btn-sm">
|
||||||
|
<i class="fa-solid fa-user-pen"></i> Edit
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
<div class="card-header shadow actionMessage" style="display:none"></div>
|
||||||
|
|
||||||
|
<div class="px-3 pt-3 border-bottom">
|
||||||
|
<ul class="nav nav-tabs border-bottom-0" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#tab-profile" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-user"></i> Profile
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-groups" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-users"></i> My Groups
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-services" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-layer-group"></i> My Services
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation" id="tab-metrics-parent" style="display:none">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-metrics" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-chart-line"></i> Security & Usage
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#tab-members" type="button" role="tab">
|
||||||
|
<i class="fa-solid fa-people-group"></i> Members of {{user.uid}}'s Group
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div jq-repeat="passwordReset" class="card-body">
|
|
||||||
<h3>
|
<div class="card-body">
|
||||||
Reset Password for {{uid}}
|
<div class="tab-content">
|
||||||
</h3>
|
<!-- Profile Tab -->
|
||||||
<form action="user/{{uid}}/password" method="put" onsubmit="formAJAX(this)" class="mb-3">
|
<div class="tab-pane fade show active" id="tab-profile" role="tabpanel">
|
||||||
<div class="mb-3">
|
<div jq-repeat="user">
|
||||||
<label class="form-label">Password</label>
|
<div class="row">
|
||||||
<div class="input-group shadow">
|
<div class="col-md-6">
|
||||||
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
|
<p><i>Name:</i> <b>{{givenName}} {{sn}}</b></p>
|
||||||
<input type="password" name="userPassword" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
|
<p><i>Email:</i> <b>{{mail}}</b>
|
||||||
|
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
|
||||||
|
</p>
|
||||||
|
<p><i>Phone:</i> <b>{{mobile}}</b>
|
||||||
|
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
|
||||||
|
</p>
|
||||||
|
<p><i>Location (Site):</i> <b>{{location}}</b></p>
|
||||||
|
<p><i>LDAP DN:</i> <b>{{dn}}</b></p>
|
||||||
|
<p><i>Home Directory:</i> <b>{{homeDirectory}}</b></p>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<p><i>Login Shell:</i> <b>{{loginShell}}</b></p>
|
||||||
|
<p><i>Manager(s):</i>
|
||||||
|
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
||||||
|
</p>
|
||||||
|
<p><i>Status:</i>
|
||||||
|
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
||||||
|
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
||||||
|
</p>
|
||||||
|
<p><i>Date of Birth:</i> <b>{{dateOfBirth}}</b></p>
|
||||||
|
<p><i>TOS:</i>
|
||||||
|
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
|
||||||
|
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
|
||||||
|
</p>
|
||||||
|
<p><i>SSH Public Key:</i> <b>{{sshPublicKey}}</b></p>
|
||||||
|
<p><i>Unix User ID:</i> <b>{{uidNumber}}</b></p>
|
||||||
|
<p><i>Unix Group ID:</i> <b>{{gidNumber}}</b></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{#description}}
|
||||||
|
<hr>
|
||||||
|
<p><i>Description:</i><br>{{description}}</p>
|
||||||
|
{{/description}}
|
||||||
|
<hr>
|
||||||
|
<p class="text-muted small mb-0">
|
||||||
|
<i>Joined:</i> <b>{{createTimestamp}}</b> | <i>Edited:</i> <b>{{modifyTimestamp}}</b>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-3 border-top pt-3">
|
||||||
|
<h6 class="text-muted">Admin Actions</h6>
|
||||||
|
<div class="d-flex gap-2 flex-wrap group-required group-required-app_sso_admin">
|
||||||
|
{{#isActive}}
|
||||||
|
<button type="button" class="btn btn-outline-warning" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
||||||
|
<i class="fa-solid fa-lock"></i> Deactivate
|
||||||
|
</button>
|
||||||
|
{{/isActive}}
|
||||||
|
{{#isInactive}}
|
||||||
|
<button type="button" class="btn btn-warning" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
||||||
|
<i class="fa-solid fa-lock-open"></i> Activate
|
||||||
|
</button>
|
||||||
|
{{/isInactive}}
|
||||||
|
<button type="button" class="btn btn-secondary" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
||||||
|
<i class="fa-solid fa-user-secret"></i> Impersonate
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="deleteUser('{{uid}}', this)">
|
||||||
|
<i class="fa-solid fa-user-slash"></i> Delete User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Again</label>
|
<!-- My Groups Tab -->
|
||||||
<div class="input-group shadow">
|
<div class="tab-pane fade" id="tab-groups" role="tabpanel">
|
||||||
<span class="input-group-text" id="addon-wrapping"><i class="fa-solid fa-key"></i></span>
|
<div class="table-responsive">
|
||||||
<input type="password" name="password" class="form-control" placeholder="huunteR!23" aria-label="Username" aria-describedby="addon-wrapping">
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Description</th>
|
||||||
|
<th class="group-required group-required-app_sso_admin text-end">Actions</th>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="mygroups">
|
||||||
|
<tr>
|
||||||
|
<td>{{cn}}</td>
|
||||||
|
<td>{{description}}</td>
|
||||||
|
<td class="text-end group-required group-required-app_sso_admin">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="group-required group-required-app_sso_admin mt-3">
|
||||||
|
<label class="form-label small">Add to group</label>
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<div id="add-group-select" class="flex-grow-1"></div>
|
||||||
|
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-secondary shadow">Change</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
</div>
|
<!-- My Services Tab -->
|
||||||
</div>
|
<div class="tab-pane fade" id="tab-services" role="tabpanel">
|
||||||
</div>
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
<div class="col-md-8">
|
<thead>
|
||||||
<div id="userProfile" class="shadow-lg card card-default mb-8">
|
<th>Name</th>
|
||||||
<div class="card-header shadow">
|
<th>Address / IP</th>
|
||||||
<i class="fa-regular fa-id-card"></i>
|
<th>Description</th>
|
||||||
Profile
|
<th>Kind</th>
|
||||||
<div class="float-end">
|
</thead>
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
<tbody jq-repeat="myservices">
|
||||||
</div>
|
<tr>
|
||||||
</div>
|
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
|
||||||
</div>
|
<td>{{description}}</td>
|
||||||
<div class="profile-body" jq-repeat="user">
|
<td>
|
||||||
<div class="card-body profile-body-{{uid}}">
|
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
|
||||||
<h2><i>User Name:</i> <b>{{uid}}</b></h2>
|
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
||||||
{{^isServiceAccount}}<i>Name:</i> <b>{{givenName}} {{sn}}</b><br />{{/isServiceAccount}}
|
</td>
|
||||||
<i>Email:</i> <b>{{mail}}</b>
|
</tr>
|
||||||
{{#emailVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/emailVerified}}
|
</tbody>
|
||||||
<br />
|
</table>
|
||||||
<i>Phone:</i> <b>{{mobile}}</b>
|
</div>
|
||||||
{{#phoneVerified}}<span class="badge bg-success ms-1"><i class="fa-solid fa-circle-check"></i> Verified</span>{{/phoneVerified}}
|
|
||||||
<br />
|
|
||||||
<i>Location (Site):</i> <b>{{location}} </b><br />
|
|
||||||
<i>LDAP DN:</i> <b>{{dn}} </b><br />
|
|
||||||
<i>Home Directory:</i> <b>{{homeDirectory}} </b><br />
|
|
||||||
<i>Login Shell:</i> <b>{{loginShell}} </b><br />
|
|
||||||
<i>Manager(s):</i>
|
|
||||||
{{#managerUids}}<span class="badge bg-secondary me-1">{{.}}</span>{{/managerUids}}
|
|
||||||
<br />
|
|
||||||
<i>Status:</i>
|
|
||||||
{{#isActive}}<span class="badge bg-success">Active</span>{{/isActive}}
|
|
||||||
{{#isInactive}}<span class="badge bg-danger">Inactive</span>{{/isInactive}}
|
|
||||||
<br />
|
|
||||||
<i>Date of Birth:</i> <b>{{dateOfBirth}}</b><br />
|
|
||||||
<i>TOS:</i>
|
|
||||||
{{#tosAccepted}}<span class="badge bg-success">Accepted</span>{{/tosAccepted}}
|
|
||||||
{{#tosNotAccepted}}<span class="badge bg-warning text-dark">Pending</span>{{/tosNotAccepted}}
|
|
||||||
<br />
|
|
||||||
<i>SSH Public Key:</i> <b>{{sshPublicKey}}</b><br />
|
|
||||||
<i>Unix User ID:</i> <b>{{uidNumber}} </b><br />
|
|
||||||
<i>Unix Group ID:</i> <b>{{gidNumber}} </b><br />
|
|
||||||
<i>Description:</i><br>
|
|
||||||
<p>
|
|
||||||
{{description}}
|
|
||||||
</p>
|
|
||||||
<!-- <img id="profile_photo" /> -->
|
|
||||||
</div>
|
|
||||||
<div class="card-footer profile-body-{{uid}}">
|
|
||||||
<div class="float-end">
|
|
||||||
<button type="button" onclick="editUser()" class="btn btn-warning btn shadow ">
|
|
||||||
<i class="fa-solid fa-user-pen"></i>
|
|
||||||
</button>
|
|
||||||
{{#isActive}}
|
|
||||||
<button type="button" class="btn btn-outline-warning shadow group-required group-required-app_sso_admin" title="Deactivate user" onclick="toggleActive('{{uid}}', false)">
|
|
||||||
<i class="fa-solid fa-lock"></i>
|
|
||||||
</button>
|
|
||||||
{{/isActive}}
|
|
||||||
{{#isInactive}}
|
|
||||||
<button type="button" class="btn btn-warning shadow group-required group-required-app_sso_admin" title="Activate user" onclick="toggleActive('{{uid}}', true)">
|
|
||||||
<i class="fa-solid fa-lock-open"></i>
|
|
||||||
</button>
|
|
||||||
{{/isInactive}}
|
|
||||||
<button type="button" class="btn btn-secondary shadow group-required group-required-app_sso_admin" title="Impersonate this user" onclick="startImpersonate('{{uid}}')">
|
|
||||||
<i class="fa-solid fa-user-secret"></i>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-danger shadow group-required group-required-app_sso_admin" onclick="deleteUser('{{uid}}', this)">
|
|
||||||
<i class="fa-solid fa-user-slash"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="float-left">
|
|
||||||
<i>Joined:</i> <b>{{createTimestamp}} </b> <br/>
|
|
||||||
<i>Edited:</i> <b>{{modifyTimestamp}} </b>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Security & Usage Tab -->
|
||||||
|
<div class="tab-pane fade" id="tab-metrics" role="tabpanel">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12">
|
||||||
|
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used (Last 7 Days)</h6>
|
||||||
|
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
|
||||||
|
<li class="list-group-item text-muted">Loading...</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Members Tab -->
|
||||||
|
<div class="tab-pane fade" id="tab-members" role="tabpanel">
|
||||||
|
<p class="text-muted small">
|
||||||
|
Every account gets a personal Unix group (its primary GID) — add
|
||||||
|
other accounts here as supplementary members.
|
||||||
|
</p>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-striped">
|
||||||
|
<thead>
|
||||||
|
<th>Username</th>
|
||||||
|
<th class="text-end">Actions</th>
|
||||||
|
</thead>
|
||||||
|
<tbody jq-repeat="personalGroupMembers">
|
||||||
|
<tr>
|
||||||
|
<td>{{uid}}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
|
||||||
|
<i class="fa-solid fa-xmark"></i>
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="mt-3">
|
||||||
|
<label class="form-label small">Add member</label>
|
||||||
|
<div class="d-flex gap-2 align-items-start">
|
||||||
|
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
|
||||||
|
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="editProfile" class="shadow-lg card card-default mb-8" style="display:none">
|
<!-- Edit Profile Card (hidden by default) -->
|
||||||
|
<div id="editProfile" class="shadow-lg card card-default mb-4" style="display:none">
|
||||||
<div class="card-header shadow">
|
<div class="card-header shadow">
|
||||||
<i class="fad fa-id-card"></i>
|
<i class="fad fa-id-card"></i>
|
||||||
Edit Profile
|
Edit Profile
|
||||||
<div class="float-end">
|
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="card-header shadow actionMessage" style="display:none"></div>
|
||||||
<div class="card-body" jq-repeat="editProfile">
|
<div class="card-body" jq-repeat="editProfile">
|
||||||
<div id="tableAJAX">
|
<h3>Editing {{uid}}</h3>
|
||||||
<h3>Editing {{uid}}</h3>
|
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)">
|
||||||
<form action="user/{{uid}}" method="put" onsubmit="formAJAX(this)" evalAJAX="editUserSeccess(data)">
|
<div class="mb-3">
|
||||||
<div class="mb-3">
|
<label class="form-label">Date of Birth</label>
|
||||||
<label class="form-label">Date of Birth</label>
|
<input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
|
||||||
<input type="date" class="form-control shadow" name="dateOfBirth" value="{{dateOfBirth}}" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">SSH Public Key</label>
|
|
||||||
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Mobile Phone</label>
|
|
||||||
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Location (Site)</label>
|
|
||||||
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Home Directory</label>
|
|
||||||
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Login Shell</label>
|
|
||||||
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">Manager(s)</label>
|
|
||||||
<div id="edit-manager"></div>
|
|
||||||
</div>
|
|
||||||
<div class="mb-3">
|
|
||||||
<label class="form-label">User Description (Optional)</label>
|
|
||||||
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
|
|
||||||
</div>
|
|
||||||
<button type="submit" class="btn btn-outline-dark btn-warning">Change</button>
|
|
||||||
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shadow-lg card card-default mb-8">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-users-viewfinder"></i>
|
|
||||||
My groups
|
|
||||||
<div class="float-end">
|
|
||||||
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<th>
|
|
||||||
Name
|
|
||||||
</th>
|
|
||||||
<th>
|
|
||||||
Description
|
|
||||||
</th>
|
|
||||||
<th class="group-required group-required-app_sso_admin"></th>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="mygroups">
|
|
||||||
<tr>
|
|
||||||
<td>{{cn}}</td>
|
|
||||||
<td>{{description}}</td>
|
|
||||||
<td class="text-end group-required group-required-app_sso_admin">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removeFromGroup('{{cn}}', this)">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="group-required group-required-app_sso_admin">
|
|
||||||
<label class="form-label small">Add to group</label>
|
|
||||||
<div class="d-flex gap-2 align-items-start">
|
|
||||||
<div id="add-group-select" class="flex-grow-1"></div>
|
|
||||||
<button type="button" class="btn btn-outline-dark" onclick="addToGroups(this)">Add</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
</div>
|
<label class="form-label">SSH Public Key</label>
|
||||||
</div>
|
<input type="text" class="form-control" name="sshPublicKey" placeholder="ssh-rsa AAAAB3NzaC1yc2EAAAADAQ..." value="{{sshPublicKey}}" />
|
||||||
|
|
||||||
<div class="shadow-lg card card-default mb-8">
|
|
||||||
<div class="card-header shadow">
|
|
||||||
<i class="fa-solid fa-layer-group"></i>
|
|
||||||
My Services
|
|
||||||
<div class="float-end">
|
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table">
|
|
||||||
<thead>
|
|
||||||
<th>Name</th>
|
|
||||||
<th>Address / IP</th>
|
|
||||||
<th>Description</th>
|
|
||||||
<th>Kind</th>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="myservices">
|
|
||||||
<tr>
|
|
||||||
<td>{{name}} <small class="text-muted">({{slug}})</small></td>
|
|
||||||
<td><a href="{{resolvedAddress}}" target="_blank"><code>{{resolvedAddress}}</code></a></td>
|
|
||||||
<td>{{description}}</td>
|
|
||||||
<td>
|
|
||||||
{{#metadata.isProduction}}<span class="badge bg-danger mb-1">Prod</span><br>{{/metadata.isProduction}}
|
|
||||||
<span class="badge bg-secondary">{{kind}}{{#metadata.subType}} ({{metadata.subType}}){{/metadata.subType}}</span>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin" id="user-metrics-card" style="display:none">
|
|
||||||
<div class="card-header shadow bg-success text-white">
|
|
||||||
<i class="fa-solid fa-chart-line"></i>
|
|
||||||
Security & Usage Stats (Last 7 Days)
|
|
||||||
<div class="float-end">
|
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body p-0">
|
|
||||||
<div class="row m-0">
|
|
||||||
<div class="col-12 p-3">
|
|
||||||
<h6 class="text-success"><i class="fa-solid fa-plug"></i> Top Services Used</h6>
|
|
||||||
<ul class="list-group list-group-flush border rounded" id="metrics-user-services">
|
|
||||||
<li class="list-group-item text-muted">Loading...</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
</div>
|
<label class="form-label">Mobile Phone</label>
|
||||||
</div>
|
<input type="text" class="form-control" name="mobile" placeholder="9175551234" value="{{mobile}}" />
|
||||||
|
</div>
|
||||||
<div class="shadow-lg card card-default mb-8 group-required group-required-app_sso_admin">
|
<div class="mb-3">
|
||||||
<div class="card-header shadow">
|
<label class="form-label">Location (Site)</label>
|
||||||
<i class="fa-solid fa-people-group"></i>
|
<input type="text" class="form-control" name="location" placeholder="Site One" value="{{location}}" />
|
||||||
Members of <span id="personal-group-uid-label"></span>'s group
|
</div>
|
||||||
<div class="float-end">
|
<div class="mb-3">
|
||||||
<a href="/docs/accounts" class="text-reset me-2" title="Help"><i class="fa-solid fa-circle-question"></i></a>
|
<label class="form-label">Home Directory</label>
|
||||||
<i class="fa-solid fa-arrows-up-down"></i>
|
<input type="text" class="form-control" name="homeDirectory" placeholder="/home/jsmith" value="{{homeDirectory}}" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="mb-3">
|
||||||
<div class="card-header shadow actionMessage" style="display:none">
|
<label class="form-label">Login Shell</label>
|
||||||
</div>
|
<input type="text" class="form-control" name="loginShell" placeholder="/bin/bash" value="{{loginShell}}" />
|
||||||
<div class="card-body">
|
</div>
|
||||||
<p class="text-muted small">
|
<div class="mb-3">
|
||||||
Every account gets a personal Unix group (its primary GID) — add
|
<label class="form-label">Manager(s)</label>
|
||||||
other accounts here as supplementary members (e.g. to share write
|
<div id="edit-manager"></div>
|
||||||
access to files owned by this group).
|
</div>
|
||||||
</p>
|
<div class="mb-3">
|
||||||
<div class="table-responsive">
|
<label class="form-label">User Description (optional)</label>
|
||||||
<table class="table">
|
<textarea class="form-control" name="description" placeholder="Admin group for gitea app">{{description}}</textarea>
|
||||||
<thead>
|
</div>
|
||||||
<th>
|
<button type="submit" class="btn btn-outline-dark btn-warning">Save Changes</button>
|
||||||
Username
|
<button type="button" class="btn btn-secondary" onclick="$('#editProfile').slideUp(); $('#userProfile').slideDown();">Cancel</button>
|
||||||
</th>
|
</form>
|
||||||
<th class="text-end"></th>
|
|
||||||
</thead>
|
|
||||||
<tbody jq-repeat="personalGroupMembers">
|
|
||||||
<tr>
|
|
||||||
<td>{{uid}}</td>
|
|
||||||
<td class="text-end">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" title="Remove from group" onclick="removePersonalGroupMember('{{uid}}', this)">
|
|
||||||
<i class="fa-solid fa-xmark"></i>
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<label class="form-label small">Add member</label>
|
|
||||||
<div class="d-flex gap-2 align-items-start">
|
|
||||||
<div id="add-personal-group-member-select" class="flex-grow-1"></div>
|
|
||||||
<button type="button" class="btn btn-outline-dark" onclick="addPersonalGroupMembers(this)">Add</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -549,13 +510,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script type="text/javascript">
|
<script type="text/javascript">
|
||||||
// Any logged-in user can manage their own API tokens (self-service).
|
// API Tokens section (same as before)
|
||||||
// Section is only revealed (see $(document).ready above) when isOwnProfile.
|
|
||||||
|
|
||||||
var tokensById = {};
|
var tokensById = {};
|
||||||
|
|
||||||
// Shared "reveal secret once" display -- same pattern as jump-host's and
|
|
||||||
// proxy's showToken().
|
|
||||||
function showToken(title, token){
|
function showToken(title, token){
|
||||||
app.modal.open({title: title, bodyHtml:
|
app.modal.open({title: title, bodyHtml:
|
||||||
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
'<p class="text-danger"><i class="fa-solid fa-triangle-exclamation"></i> Save this token now — it will <strong>not</strong> be shown again.</p>'
|
||||||
@@ -564,10 +521,7 @@
|
|||||||
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
|
+ '<p class="mt-3 mb-0 text-muted small">Use it as a bearer token:<br><code>Authorization: Bearer ' + app.util.escapeHtml(token) + '</code></p>'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// Not the checkmark-flash technique this file used to use for its copy
|
|
||||||
// buttons -- FontAwesome replaces <i> icons with inline <svg>, so
|
|
||||||
// swapping the <i>'s class silently no-ops. A toast doesn't have that
|
|
||||||
// problem.
|
|
||||||
function copyFieldValue(sel){
|
function copyFieldValue(sel){
|
||||||
var $el = $(sel);
|
var $el = $(sel);
|
||||||
var text = $el.val();
|
var text = $el.val();
|
||||||
@@ -580,17 +534,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(ms){
|
function fmtTime(ms){
|
||||||
// created_on/last_used_on come back from Redis as strings (model-redis
|
|
||||||
// only coerces fields with an explicit `type`); moment(value, "x") parses
|
|
||||||
// a numeric string-or-number as a Unix-ms timestamp, unlike new Date(str).
|
|
||||||
if(!ms || Number(ms) === 0) return '—';
|
if(!ms || Number(ms) === 0) return '—';
|
||||||
var t = moment(ms, "x");
|
var t = moment(ms, "x");
|
||||||
if(!t.isValid()) return '—';
|
if(!t.isValid()) return '—';
|
||||||
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
|
return t.fromNow() + ' <span class="text-muted">(' + t.format('YYYY-MM-DD HH:mm') + ')</span>';
|
||||||
}
|
}
|
||||||
function fmtExpiry(token){
|
function fmtExpiry(token){
|
||||||
// expires_at is type:number (a real number); isExpired is a class getter
|
|
||||||
// that is NOT serialized to the client, so compute expiry here.
|
|
||||||
var exp = Number(token.expires_at);
|
var exp = Number(token.expires_at);
|
||||||
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
if(!exp) return '<span class="badge text-bg-secondary">never</span>';
|
||||||
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
if(Date.now() > exp) return '<span class="badge text-bg-danger">expired</span>';
|
||||||
@@ -639,14 +588,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create is a native <form>+formAJAX submission (matching this app's own
|
|
||||||
// hostModal-style convention) rather than a JS-built payload. Deliberately
|
|
||||||
// does NOT call app.modal.close() before showToken() -- app.modal is a
|
|
||||||
// singleton, and close() immediately followed by open() in the same tick
|
|
||||||
// collides with Bootstrap's hide-transition guard (show() silently
|
|
||||||
// no-ops while _isTransitioning is still true from the just-started
|
|
||||||
// hide()). open() alone already overwrites the (already-visible) modal's
|
|
||||||
// content in place.
|
|
||||||
function createApiToken(){
|
function createApiToken(){
|
||||||
var $body = app.modal.open({
|
var $body = app.modal.open({
|
||||||
title: 'New API Token',
|
title: 'New API Token',
|
||||||
@@ -714,9 +655,6 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Not class="row": app-base.js's `$('div.row').fadeIn('slow')` page-reveal
|
|
||||||
runs before this page's own ready handler and would unhide any div.row
|
|
||||||
unconditionally, defeating the isOwnProfile check below. -->
|
|
||||||
<div id="own-api-tokens-section" style="display:none">
|
<div id="own-api-tokens-section" style="display:none">
|
||||||
<div class="row mt-3 justify-content-center">
|
<div class="row mt-3 justify-content-center">
|
||||||
<div class="col-md-8">
|
<div class="col-md-8">
|
||||||
|
|||||||
@@ -301,7 +301,7 @@
|
|||||||
{{mail}}
|
{{mail}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#sshPublicKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/sshPublicKey}}
|
{{#hasSshKey}}<i class="fa-regular fa-circle-check text-success"></i>{{/hasSshKey}}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
{{#isActive}}<i class="fa-regular fa-circle-check text-success"></i>{{/isActive}}
|
||||||
|
|||||||
@@ -0,0 +1,315 @@
|
|||||||
|
<%- include('top') %>
|
||||||
|
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2><i class="fas fa-lock"></i>
|
||||||
|
<% if (vaultIsAdmin) { %> Vault Secrets <small class="text-muted">(admin — all of secret/)</small>
|
||||||
|
<% } else { %> My Secrets <small class="text-muted">(personal namespace)</small><% } %>
|
||||||
|
</h2>
|
||||||
|
<ul class="nav nav-pills" id="vault-tabs">
|
||||||
|
<li class="nav-item"><button class="nav-link active" data-bs-toggle="pill" data-bs-target="#tab-secrets" type="button">Secrets</button></li>
|
||||||
|
<% if (vaultIsAdmin) { %>
|
||||||
|
<li class="nav-item"><button class="nav-link" data-bs-toggle="pill" data-bs-target="#tab-apps" type="button">Apps</button></li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tab-content">
|
||||||
|
<!-- ── Secrets tab ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="tab-pane fade show active" id="tab-secrets">
|
||||||
|
<div class="d-flex justify-content-end mb-3">
|
||||||
|
<button class="btn btn-primary" onclick="showCreateModal()">
|
||||||
|
<i class="fas fa-plus"></i> New Secret
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light"><h5 class="card-title mb-0">Secrets List</h5></div>
|
||||||
|
<div class="list-group list-group-flush" id="secrets-list">
|
||||||
|
<div class="list-group-item text-center text-muted">Loading...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-8">
|
||||||
|
<div class="card shadow-sm" id="secret-details-card" style="display: none;">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0" id="secret-title">Secret Details</h5>
|
||||||
|
<div>
|
||||||
|
<button class="btn btn-sm btn-outline-primary me-2" onclick="editCurrentSecret()"><i class="fas fa-edit"></i> Edit</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" onclick="deleteCurrentSecret()"><i class="fas fa-trash"></i> Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<pre id="secret-content" class="bg-dark text-light p-3 rounded" style="min-height: 200px;"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="no-secret-selected" class="text-center text-muted mt-5">
|
||||||
|
<i class="fas fa-key fa-4x mb-3 text-secondary"></i>
|
||||||
|
<h4>Select a secret to view its details</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Apps tab (admin only) ───────────────────────────────────────── -->
|
||||||
|
<% if (vaultIsAdmin) { %>
|
||||||
|
<div class="tab-pane fade" id="tab-apps">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-5">
|
||||||
|
<div class="card shadow-sm">
|
||||||
|
<div class="card-header bg-light"><h5 class="card-title mb-0">Mint an app token</h5></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">Mints a scoped OpenBao token confined to <code>secret/apps/<name>/*</code> for an external app. The token is shown <strong>once</strong> — record it in the app immediately; it cannot be recovered later.</p>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">App name (lowercase letters, digits, hyphens)</label>
|
||||||
|
<input type="text" class="form-control" id="app-name-input" placeholder="e.g. my-service">
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="mintApp()"><i class="fas fa-key"></i> Mint token</button>
|
||||||
|
<div class="alert alert-danger d-none mt-3" id="app-error"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-7">
|
||||||
|
<div class="card shadow-sm d-none" id="app-result-card">
|
||||||
|
<div class="card-header bg-light d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="card-title mb-0">App token</h5>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="copyText(document.getElementById('app-token').textContent)"><i class="fas fa-copy"></i> Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="small text-muted">Give the external app this token (header <code>X-Vault-Token</code>) and the path convention below.</p>
|
||||||
|
<pre id="app-token" class="bg-dark text-light p-3 rounded"></pre>
|
||||||
|
<h6 class="mt-3">Connection convention</h6>
|
||||||
|
<pre class="bg-light p-2 rounded small">VAULT_ADDR=<%- vaultAddr %>
|
||||||
|
path=secret/apps/<name>/conf
|
||||||
|
curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf" \
|
||||||
|
-H "X-Vault-Token: <token above>"</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create/Edit Secret Modal -->
|
||||||
|
<div class="modal fade" id="secretModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="secretModalTitle">Create Secret</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">
|
||||||
|
<% if (vaultIsAdmin) { %>Secret path (under secret/)<% } else { %>Secret name (in your personal namespace)<% } %>
|
||||||
|
</label>
|
||||||
|
<input type="text" class="form-control" id="secret-path-input" placeholder="<% if (vaultIsAdmin) { %>e.g. apps/my-service/conf<% } else { %>e.g. database-creds<% } %>">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Secret Data (JSON)</label>
|
||||||
|
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
||||||
|
"username": "",
|
||||||
|
"password": ""
|
||||||
|
}</textarea>
|
||||||
|
</div>
|
||||||
|
<div class="alert alert-danger d-none" id="secret-error"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
app.auth.forceLogin();
|
||||||
|
|
||||||
|
// Server-derived scoping. VAULT_BASE is '' for admins (free-form under
|
||||||
|
// secret/) or 'users/<uid>/' for everyone else (confined to their personal
|
||||||
|
// namespace). The /api/vault proxy enforces the same server-side; these only
|
||||||
|
// drive the UI.
|
||||||
|
const VAULT_BASE = <%- JSON.stringify(vaultBase) %>;
|
||||||
|
const IS_ADMIN = <%- JSON.stringify(vaultIsAdmin) %>;
|
||||||
|
|
||||||
|
let currentSecretPath = null;
|
||||||
|
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||||
|
|
||||||
|
// Build a vault API path. kind is 'data' or 'metadata'; key is the logical
|
||||||
|
// key relative to the subject's namespace (so 'foo' for a user means
|
||||||
|
// secret/data/users/<uid>/foo).
|
||||||
|
function vpath(kind, key) {
|
||||||
|
return `secret/${kind}/${VAULT_BASE}${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function apiCall(method, path, body = null) {
|
||||||
|
const opts = {
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() }
|
||||||
|
};
|
||||||
|
if (body) opts.body = JSON.stringify(body);
|
||||||
|
return fetch('/api/vault/' + path, opts).then(async res => {
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Vault API error: ${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecrets() {
|
||||||
|
try {
|
||||||
|
const res = await apiCall('GET', vpath('metadata', '?list=true'));
|
||||||
|
const listEl = document.getElementById('secrets-list');
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="list-group-item text-center text-muted">No secrets found</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.data.keys.forEach(key => {
|
||||||
|
// KV list returns dir entries with a trailing slash; admins can still
|
||||||
|
// open them by typing the full path in the modal. Skip dirs in the list
|
||||||
|
// for non-admins (their namespace is flat).
|
||||||
|
if (!IS_ADMIN && key.endsWith('/')) return;
|
||||||
|
const item = document.createElement('a');
|
||||||
|
item.href = '#';
|
||||||
|
item.className = 'list-group-item list-group-item-action d-flex align-items-center';
|
||||||
|
item.innerHTML = `<i class="fas fa-file-alt text-secondary me-3"></i> <span>${key}</span>`;
|
||||||
|
item.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
||||||
|
item.classList.add('active');
|
||||||
|
loadSecretDetails(key);
|
||||||
|
};
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('secrets-list').innerHTML =
|
||||||
|
`<div class="list-group-item text-danger"><i class="fas fa-exclamation-triangle"></i> Error loading secrets: ${err.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSecretDetails(key) {
|
||||||
|
try {
|
||||||
|
currentSecretPath = key;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'none';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'block';
|
||||||
|
document.getElementById('secret-title').textContent = key;
|
||||||
|
document.getElementById('secret-content').textContent = 'Loading...';
|
||||||
|
const res = await apiCall('GET', vpath('data', key));
|
||||||
|
if (!res || !res.data || !res.data.data) {
|
||||||
|
document.getElementById('secret-content').textContent = 'No data found.';
|
||||||
|
} else {
|
||||||
|
document.getElementById('secret-content').textContent = JSON.stringify(res.data.data, null, 2);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('secret-content').textContent = `Error: ${err.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showCreateModal() {
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Create Secret';
|
||||||
|
document.getElementById('secret-path-input').value = '';
|
||||||
|
document.getElementById('secret-path-input').disabled = false;
|
||||||
|
document.getElementById('secret-data-input').value = '{\n "key": "value"\n}';
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
document.getElementById('secretModalTitle').textContent = 'Edit Secret';
|
||||||
|
document.getElementById('secret-path-input').value = currentSecretPath;
|
||||||
|
document.getElementById('secret-path-input').disabled = true;
|
||||||
|
document.getElementById('secret-data-input').value = document.getElementById('secret-content').textContent;
|
||||||
|
document.getElementById('secret-error').classList.add('d-none');
|
||||||
|
secretModal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSecret() {
|
||||||
|
const errorEl = document.getElementById('secret-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
const path = document.getElementById('secret-path-input').value.trim();
|
||||||
|
if (!path) {
|
||||||
|
errorEl.textContent = 'Secret path is required';
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(document.getElementById('secret-data-input').value);
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = 'Invalid JSON: ' + err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await apiCall('POST', vpath('data', path), { data });
|
||||||
|
secretModal.hide();
|
||||||
|
await loadSecrets();
|
||||||
|
if (currentSecretPath === path || !currentSecretPath) {
|
||||||
|
await loadSecretDetails(path);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCurrentSecret() {
|
||||||
|
if (!currentSecretPath) return;
|
||||||
|
try {
|
||||||
|
await apiCall('DELETE', vpath('metadata', currentSecretPath));
|
||||||
|
currentSecretPath = null;
|
||||||
|
document.getElementById('no-secret-selected').style.display = 'block';
|
||||||
|
document.getElementById('secret-details-card').style.display = 'none';
|
||||||
|
await loadSecrets();
|
||||||
|
} catch (err) {
|
||||||
|
app.messages.toast('Error deleting secret: ' + err.message, 'danger');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Apps tab (admin) ───────────────────────────────────────────────────
|
||||||
|
async function mintApp() {
|
||||||
|
const errorEl = document.getElementById('app-error');
|
||||||
|
errorEl.classList.add('d-none');
|
||||||
|
const name = document.getElementById('app-name-input').value.trim();
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = 'App name is required';
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/vault/apps', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'auth-token': app.auth.getToken() },
|
||||||
|
body: JSON.stringify({ name })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`${res.status} ${text}`);
|
||||||
|
}
|
||||||
|
const result = await res.json();
|
||||||
|
document.getElementById('app-token').textContent = result.token;
|
||||||
|
document.getElementById('app-name-display').textContent = name;
|
||||||
|
document.getElementById('app-result-card').classList.remove('d-none');
|
||||||
|
} catch (err) {
|
||||||
|
errorEl.textContent = err.message;
|
||||||
|
errorEl.classList.remove('d-none');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text) {
|
||||||
|
navigator.clipboard.writeText(text).then(() => app.messages.toast('Copied', 'success'));
|
||||||
|
}
|
||||||
|
|
||||||
|
loadSecrets();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<%- include('bottom') %>
|
||||||
Reference in New Issue
Block a user