Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ebb5b2c2a7 | |||
| c8c4cad46d | |||
| 59d4b65195 | |||
| 74746e409b | |||
| 70aed035a5 | |||
| 0264a62b22 | |||
| c212537163 | |||
| 391ad12afc | |||
| 622317b6da |
@@ -86,6 +86,11 @@ ops/cookbooks/vendor
|
||||
secrets.json
|
||||
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)
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
[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
|
||||
|
||||
```
|
||||
|
||||
@@ -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,35 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
// Local per-deployment configuration for this Theta42 instance.
|
||||
// This file is gitignored — it contains real secrets and per-deployment
|
||||
// values. The committed conf/base.js now ships generic defaults
|
||||
// (example.com / localhost); the theta42-specific non-secret values that
|
||||
// used to live in base.js have been migrated here so this instance keeps
|
||||
// working. New deployments should put their own values here or in app_* env.
|
||||
module.exports = {
|
||||
port: 3001,
|
||||
name: 'Theta42 SSO',
|
||||
ldap: {
|
||||
url: 'ldap://10.2.0.54',
|
||||
bindDN: 'cn=admin,dc=theta42,dc=com',
|
||||
bindPassword: 'Tomisgaypalm7',
|
||||
userBase: 'ou=people,dc=theta42,dc=com',
|
||||
groupBase: 'ou=groups,dc=theta42,dc=com',
|
||||
},
|
||||
smtp: {
|
||||
host: 'mail.wgnode.com',
|
||||
user: 'noreply@users.theta42.com',
|
||||
// user: '',
|
||||
pass: 'ZxAsQw!2',
|
||||
from: 'Theta42 Accounts <noreply@users.theta42.com>',
|
||||
},
|
||||
voipms: {
|
||||
username: 'wmantly@gmail.com',
|
||||
password: 'EMjQvAuHhD!d5dm',
|
||||
did: '9297353350',
|
||||
},
|
||||
oauth: {
|
||||
issuer: 'https://sso.theta42.com',
|
||||
jwtSecret: '09e2501a1c93aef4d5d713c7db17c800c6d7d6f5f9e9cf2efbdfa37549021bf9',
|
||||
},
|
||||
};
|
||||
@@ -1,23 +1,23 @@
|
||||
---
|
||||
layout: default
|
||||
title: Discovery Plugins
|
||||
title: Discovery Agents
|
||||
nav_order: 5
|
||||
---
|
||||
|
||||
# Discovery Plugins
|
||||
# Discovery Agents
|
||||
|
||||
The SSO Manager supports a robust plugin architecture for auto-discovering devices, hosts, and services across your home lab or data center. Plugins run on a scheduled cron and feed their data into a central **Reconciliation Engine** that smartly merges information based on MAC addresses and IPs.
|
||||
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 Plugin
|
||||
## Writing a Custom Agent
|
||||
|
||||
Plugins are simple JavaScript files placed in `nodejs/plugins/discovery/`.
|
||||
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
|
||||
|
||||
A plugin must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
|
||||
|
||||
### Plugin Skeleton
|
||||
### Agent Skeleton
|
||||
|
||||
```javascript
|
||||
// nodejs/plugins/discovery/my_custom_plugin.js
|
||||
// nodejs/agents/discovery/my_custom_agent.js
|
||||
module.exports = {
|
||||
discover: async (config) => {
|
||||
const { url, apiKey } = config; // Provided by your configuration
|
||||
@@ -56,14 +56,14 @@ module.exports = {
|
||||
|
||||
## Configuration
|
||||
|
||||
Plugins are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
|
||||
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: {
|
||||
plugins: {
|
||||
my_custom_plugin: {
|
||||
agents: {
|
||||
my_custom_agent: {
|
||||
enabled: true,
|
||||
cron: '*/30 * * * *', // Run every 30 minutes
|
||||
url: 'https://api.example.com',
|
||||
@@ -81,8 +81,8 @@ module.exports = {
|
||||
|
||||
## The Reconciliation Engine
|
||||
|
||||
When your plugin returns its graph, the Reconciliation Engine takes over:
|
||||
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 plugin can add CPU info to a host that NMAP previously found).
|
||||
3. **Source Tracking:** It records your plugin's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp.
|
||||
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.
|
||||
+10
-11
@@ -108,18 +108,17 @@ app.use('/api/oauth/client', middleware.auth, require('./routes/oauth_client'));
|
||||
app.get('/.well-known/openid-configuration', discovery);
|
||||
app.use('/api/webhook', require('./routes/webhook'));
|
||||
app.use('/api/plugins', middleware.auth, require('./routes/plugins'));
|
||||
const { createProxyMiddleware, fixRequestBody } = require('http-proxy-middleware');
|
||||
|
||||
const vaultApiProxy = createProxyMiddleware({
|
||||
target: 'http://openbao:8200',
|
||||
changeOrigin: true,
|
||||
pathRewrite: { '^/': '/v1/' },
|
||||
on: {
|
||||
proxyReq: fixRequestBody
|
||||
}
|
||||
});
|
||||
|
||||
app.use('/api/vault', middleware.auth, vaultApiProxy);
|
||||
// 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
|
||||
// used, this is what will be called.
|
||||
|
||||
+6
-1
@@ -31,7 +31,12 @@ const models = require('../models');
|
||||
* Initialize ORM, then Listen on provided port, on all network interfaces.
|
||||
*/
|
||||
models.initORM().then(() => {
|
||||
return require('../utils/conf_manager').init();
|
||||
// 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.on('error', onError);
|
||||
|
||||
Binary file not shown.
Generated
+15
-2
@@ -1,17 +1,18 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.16.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.16.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
@@ -1347,6 +1348,18 @@
|
||||
"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": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@simpleworkjs/conf/-/conf-1.2.0.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "t42-sso-manager",
|
||||
"version": "1.13.0",
|
||||
"version": "1.16.0",
|
||||
"description": "A very simple LDAP management and SSO system",
|
||||
"author": [
|
||||
{
|
||||
@@ -24,6 +24,7 @@
|
||||
"@fortawesome/fontawesome-free": "^7.3.0",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"@simpleworkjs/app-stack": "^1.0.0",
|
||||
"@simpleworkjs/bao-conf": "^1.0.0",
|
||||
"@simpleworkjs/conf": "^1.2.0",
|
||||
"@simpleworkjs/directory-schema": "^1.1.0",
|
||||
"@simpleworkjs/frontend": "^0.2.7",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const router = require('express').Router();
|
||||
const confManager = require('../utils/conf_manager');
|
||||
const baoConf = require('@simpleworkjs/bao-conf');
|
||||
const permission = require('../utils/permission');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
|
||||
@@ -21,13 +21,40 @@ router.get('/', async (req, res) => {
|
||||
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 {
|
||||
await confManager.setVaultConf(req.body);
|
||||
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;
|
||||
module.exports = router;
|
||||
@@ -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;
|
||||
@@ -34,7 +34,7 @@ const DOCS = {
|
||||
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
|
||||
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
|
||||
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
|
||||
plugins: {title: 'Plugins & Scheduler', file: path.join(__dirname, '../../docs/plugins.md')},
|
||||
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')},
|
||||
|
||||
+28
-2
@@ -11,6 +11,8 @@ const {Tos} = require('../models/tos');
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const buildInfo = require('../utils/build_info');
|
||||
const { mountStaticModules } = require('@simpleworkjs/app-stack');
|
||||
const middleware = require('../middleware/auth');
|
||||
const permission = require('../utils/permission');
|
||||
|
||||
const values ={
|
||||
title: conf.environment !== 'production' ? `dev` : '',
|
||||
@@ -64,6 +66,15 @@ router.get('/notifications', (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('/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) {
|
||||
res.render('directory', {...values});
|
||||
});
|
||||
@@ -76,8 +87,23 @@ router.get('/plugins', function(req, res, next) {
|
||||
res.redirect('/directory');
|
||||
});
|
||||
|
||||
router.get('/vault', function(req, res, next) {
|
||||
res.render('vaultwarden', {...values});
|
||||
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
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
const proxmox = require('./plugins/discovery/proxmox');
|
||||
const unifi = require('./plugins/discovery/unifi');
|
||||
|
||||
async function test() {
|
||||
console.log("=== Running Proxmox Plugin ===");
|
||||
try {
|
||||
const pveData = await proxmox.discover({
|
||||
url: 'https://dl380-0.internal.718it.biz:8006',
|
||||
tokenId: 'root@pam!agy',
|
||||
tokenSecret: '1e7c0e31-6767-4295-bcda-d7acf5df1d9a'
|
||||
});
|
||||
console.log(`Found ${pveData.resources.length} resources and ${pveData.edges.length} edges.`);
|
||||
console.log("Sample resource:");
|
||||
console.log(JSON.stringify(pveData.resources[0], null, 2));
|
||||
console.log("Sample edge:");
|
||||
console.log(JSON.stringify(pveData.edges[0], null, 2));
|
||||
} catch (e) {
|
||||
console.error("Proxmox failed:", e.message);
|
||||
}
|
||||
|
||||
console.log("\n=== Running Unifi Plugin ===");
|
||||
try {
|
||||
const unifiData = await unifi.discover({
|
||||
url: 'https://unifi.718it.biz',
|
||||
user: 'agy',
|
||||
password: 'MyPassword!23'
|
||||
});
|
||||
console.log(`Found ${unifiData.resources.length} resources and ${unifiData.edges.length} edges.`);
|
||||
console.log("Sample resource:");
|
||||
console.log(JSON.stringify(unifiData.resources.find(r => r.kind === 'network_device'), null, 2));
|
||||
} catch (e) {
|
||||
console.error("Unifi failed:", e.message);
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
@@ -1,53 +0,0 @@
|
||||
'use strict';
|
||||
const conf = require('@simpleworkjs/conf');
|
||||
const VAULT_URL = process.env.VAULT_ADDR || 'http://openbao:8200';
|
||||
const VAULT_TOKEN = process.env.VAULT_TOKEN || ('ro' + 'ot');
|
||||
|
||||
async function getVaultConf() {
|
||||
try {
|
||||
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
||||
headers: { 'X-Vault-Token': VAULT_TOKEN }
|
||||
});
|
||||
if (res.status === 200) {
|
||||
const json = await res.json();
|
||||
return json.data.data;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching conf from Vault:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function setVaultConf(newConf) {
|
||||
const res = await fetch(`${VAULT_URL}/v1/secret/data/sso-manager/conf`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-Vault-Token': VAULT_TOKEN, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ data: newConf })
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Vault API error: ${res.status} ${text}`);
|
||||
}
|
||||
applyConf(newConf);
|
||||
}
|
||||
|
||||
function applyConf(newConf) {
|
||||
if (!newConf) return;
|
||||
// Deep merge into conf
|
||||
for (const key of Object.keys(newConf)) {
|
||||
if (typeof newConf[key] === 'object' && newConf[key] !== null && !Array.isArray(newConf[key])) {
|
||||
conf[key] = { ...conf[key], ...newConf[key] };
|
||||
} else {
|
||||
conf[key] = newConf[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
const vaultConf = await getVaultConf();
|
||||
if (vaultConf) {
|
||||
applyConf(vaultConf);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { getVaultConf, setVaultConf, init };
|
||||
@@ -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,
|
||||
};
|
||||
+118
-24
@@ -9,7 +9,25 @@
|
||||
async function loadConf() {
|
||||
try {
|
||||
const data = await app.api.get('conf');
|
||||
$('#conf-json').val(JSON.stringify(data, null, 4));
|
||||
// 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');
|
||||
}
|
||||
@@ -18,50 +36,126 @@
|
||||
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 {
|
||||
const text = $('#conf-json').val();
|
||||
const payload = JSON.parse(text);
|
||||
|
||||
await app.api.post('conf', payload);
|
||||
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success');
|
||||
} catch (error) {
|
||||
let msg = error.message;
|
||||
if (error instanceof SyntaxError) {
|
||||
msg = 'Invalid JSON format. Please check your syntax.';
|
||||
}
|
||||
app.messages.toast('Failed to save configuration: ' + msg, 'danger');
|
||||
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">
|
||||
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
|
||||
<p class="text-muted">
|
||||
Manage runtime configuration such as SMTP settings, discovery plugins, and OAuth parameters.
|
||||
These secrets are stored securely in OpenBao Vault.
|
||||
</p>
|
||||
<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-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<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">Configuration (JSON)</h5>
|
||||
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="alert alert-info">
|
||||
<i class="fas fa-info-circle"></i> Be careful when editing this JSON! Malformed JSON will not save.
|
||||
<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>
|
||||
<textarea id="conf-json" class="form-control text-monospace" rows="25" style="font-family: monospace; font-size: 14px; background-color: #f8f9fa;" spellcheck="false"></textarea>
|
||||
</div>
|
||||
<div class="card-footer bg-white border-top-0 pb-4 text-end">
|
||||
<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 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>
|
||||
|
||||
+45
-33
@@ -3,26 +3,30 @@
|
||||
<div class="container mt-4">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<ul class="nav nav-tabs mb-3 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-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 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>
|
||||
<i class="fa-solid fa-server"></i> Directory Management
|
||||
@@ -97,7 +101,7 @@
|
||||
|
||||
<!-- 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="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
|
||||
@@ -186,22 +190,22 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins Tab Pane -->
|
||||
<!-- Agents 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="border-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
|
||||
<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/plugins">Learn how to make and use custom plugins</a>.
|
||||
<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">Plugin Name</th>
|
||||
<th class="ps-3">Agent Name</th>
|
||||
<th>Cron Schedule</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
@@ -225,7 +229,7 @@
|
||||
<tbody id="plugins-empty-state" style="display: none;">
|
||||
<tr>
|
||||
<td colspan="4" class="text-center py-4 text-muted">
|
||||
No plugins configured.
|
||||
No agents configured.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
@@ -233,6 +237,8 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1319,11 +1325,11 @@
|
||||
});
|
||||
}
|
||||
|
||||
// --- PLUGINS SCRIPTS ---
|
||||
// --- AGENT SCRIPTS ---
|
||||
function loadPlugins() {
|
||||
app.api.get('plugins', function(err, res) {
|
||||
if(err) {
|
||||
app.messages.toast("Error loading plugins: " + (err.message || err), 'danger');
|
||||
app.messages.toast("Error loading agents: " + (err.message || err), 'danger');
|
||||
return;
|
||||
}
|
||||
const plugins = res.results || {};
|
||||
@@ -1339,7 +1345,7 @@
|
||||
$.scope.plugins.push({
|
||||
name: name,
|
||||
cron: config.cron || '',
|
||||
enabled: config.enabled
|
||||
enabled: !!config.enabled
|
||||
});
|
||||
});
|
||||
$('#plugins-list').show();
|
||||
@@ -1351,14 +1357,20 @@
|
||||
function updatePlugin(name) {
|
||||
const cron = $('#cron-' + name).val();
|
||||
app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
|
||||
if(err) { app.messages.toast("Failed to save: " + err.message, 'danger'); return; }
|
||||
app.messages.toast("Saved schedule successfully.", 'success');
|
||||
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("Failed to toggle: " + err.message, 'danger'); return; }
|
||||
if(err) {
|
||||
app.messages.toast("Error toggling agent: " + (err.message || err), 'danger');
|
||||
return;
|
||||
}
|
||||
loadPlugins();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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') %>
|
||||
@@ -1,230 +0,0 @@
|
||||
<%- include('top') %>
|
||||
|
||||
<div class="container-fluid py-4">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2><i class="fas fa-lock"></i> Vault Secrets</h2>
|
||||
<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>
|
||||
|
||||
<!-- 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">Secret Path (Name)</label>
|
||||
<input type="text" class="form-control" id="secret-path-input" placeholder="e.g. database-creds">
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Secret Data (JSON)</label>
|
||||
<textarea class="form-control" id="secret-data-input" rows="8" style="font-family: monospace;">{
|
||||
"username": "",
|
||||
"password": ""
|
||||
}</textarea>
|
||||
</div>
|
||||
<div class="alert alert-danger d-none" id="secret-error"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-primary" onclick="saveSecret()">Save Secret</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
app.auth.forceLogin();
|
||||
|
||||
let currentSecretPath = null;
|
||||
const secretModal = new bootstrap.Modal(document.getElementById('secretModal'));
|
||||
|
||||
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 {
|
||||
// In dev mode, v2 kv engine is mounted at secret/
|
||||
const res = await apiCall('GET', 'secret/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 => {
|
||||
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();
|
||||
|
||||
// Update active state
|
||||
document.querySelectorAll('#secrets-list .active').forEach(el => el.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
|
||||
loadSecretDetails(key);
|
||||
};
|
||||
listEl.appendChild(item);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(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', `secret/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) {
|
||||
console.error(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', `secret/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', `secret/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');
|
||||
}
|
||||
}
|
||||
|
||||
// Init
|
||||
loadSecrets();
|
||||
</script>
|
||||
|
||||
<%- include('bottom') %>
|
||||
Reference in New Issue
Block a user