Compare commits

...

8 Commits

Author SHA1 Message Date
wmantly 2612b0e3ab Merge pull request #159 from theta42/fix/sync-version-v1.20.2
fix: sync package version to v1.20.2 tag
2026-08-03 21:35:14 -04:00
wmantly bf471c2e19 fix: sync package version to v1.20.2 tag
The v1.20.2 release tag was created but nodejs/package.json (and the
lockfile) were left at 1.20.1, so the deployed app's buildVersion lagged
its own release tag and the update-check banner falsely reported a newer
version. Bump the version fields to match the tag.
2026-08-03 21:26:55 -04:00
wmantly d802c399a3 Merge pull request #157 from theta42/fix/sso-vault-conf-directory-v1.20.2
fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
2026-08-03 15:30:49 -04:00
wmantly d8242b1d53 fix(sso): align conf page design, fix directory inventory filter & plugin modal, fix vault 403 & add shared secrets v1.20.2
Pull Request Tests / Run Tests (18.x) (push) Failing after 56s
Pull Request Tests / Run Tests (20.x) (push) Failing after 28s
Pull Request Tests / Run Tests (22.x) (push) Failing after 28s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 15:26:59 -04:00
wmantly 0c5159c49b Merge pull request #155 from theta42/fix/sso-directory-conf-vault-v1.20.1
fix(sso): Directory, Configuration, Vault Broker & Plugins migration v1.20.1
2026-08-03 14:01:55 -04:00
wmantly 70b76c6ed5 fix(sso): Directory graph live refresh, discovery reconciler, conf layout, vault broker admin roles, and move plugins to directory/conf
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m23s
Pull Request Tests / Run Tests (20.x) (push) Failing after 30s
Pull Request Tests / Run Tests (22.x) (push) Failing after 31s
Pull Request Tests / Test Summary (push) Failing after 5s
2026-08-03 13:54:53 -04:00
wmantly 8143ef8ca8 Merge pull request #154 from theta42/feature/v1.20.0-package-version-and-docs
release: v1.20.0 package version & docs update
2026-08-03 02:41:47 -04:00
wmantly 2b8b7a96e0 release: v1.20.0 - bump package.json version and update docs 2026-08-03 02:37:52 -04:00
14 changed files with 597 additions and 394 deletions
+70 -66
View File
@@ -1,88 +1,92 @@
--- ---
layout: default layout: default
title: Discovery Agents title: Theta Agent & Endpoint Management
nav_order: 5 nav_order: 5
--- ---
# Discovery Agents # Theta Agent & Endpoint Management
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. The **Theta Agent** (`theta-agent`) is a unified, 2-way Command & Control (C2) endpoint management daemon written in Go for Linux hosts across your home lab, infrastructure, or data center. It connects outbound via a long-lived WebSocket connection to the central **SSO Manager** (`wss://<sso-host>/api/agent/ws`), enabling real-time host telemetry, automated host discovery, and local-first administrative management.
## Writing a Custom Agent ---
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`. ## Core Functionality
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`. ### 1. Host Discovery & Inventory
Upon establishing a WebSocket connection, the agent immediately pushes a comprehensive discovery payload:
- **Hostname & Network Interfaces**: Hostname and all non-loopback IPv4 addresses and MACs.
- **Operating System & Kernel**: Linux distribution, platform, and kernel version.
- **Hardware Specs**: CPU model, total RAM (GB), and total root disk capacity (GB).
- **Physical Location**: Location identifier string (e.g. `dc-01-rack-12`) configured in `agent.yml`.
### Agent Skeleton If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
```javascript ### 2. Real-Time Telemetry Streaming
// nodejs/agents/discovery/my_custom_agent.js Every 30 seconds, the agent streams real-time performance metrics:
module.exports = { - **CPU Load**: System-wide CPU utilization percentage.
discover: async (config) => { - **Memory Utilization**: RAM usage percentage and available memory.
const { url, apiKey } = config; // Provided by your configuration - **Disk Utilization**: Root filesystem usage percentage.
- **ZFS Storage Health**: Health status of ZFS pools (e.g., `ONLINE`).
- **NVIDIA GPU Load**: GPU compute utilization percentage (via `nvidia-smi`).
const resources = []; ---
const edges = [];
// 1. Fetch your data from an API ## Local-First Security & Capability Matrix
// const data = await fetch(...);
// 2. Map data to Resources To protect hosts against unauthorized control, `theta-agent` enforces a **strict, local-first capability matrix** defined in `/etc/theta42/agent.yml`. Central SSO Manager requests are checked against local configuration before execution; permissions cannot be overridden remotely.
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) | Capability | Config Key | Risk Level | Description & Impact |
edges.push({ | :--- | :--- | :--- | :--- |
parentSlug: 'my-switch-01', | **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
childSlug: 'some-connected-client-slug', | **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to' | **Service Control** | `service_control` | High | Restarts systemd services listed in an explicit allowlist (e.g., `["nginx", "docker", "sssd"]`). |
}); | **Reboot** | `reboot` | High | Triggers an immediate system reboot (`systemctl reboot`). |
| **Arbitrary Bash** | `arbitrary_bash` | Critical | Executes raw bash scripts sent from the SSO Manager as `root` (used for automated GitOps). |
return { resources, edges }; ---
}
}; ## High-Risk Command Verification (Protocol v1.1.0)
High-risk management commands (`reboot`, `service_restart`, `configure_ldap`, `arbitrary_bash`, `update_binary`) are cryptographically verified using **Ed25519 signatures**:
1. The SSO Manager canonicalizes the command payload (sorted keys, no whitespace).
2. The payload is signed with the SSO Manager's Ed25519 private key.
3. The Base64 signature is appended to the message payload.
4. The agent verifies the signature against the configured `public_key` in `/etc/theta42/agent.yml` before executing the action.
---
## Installation & Deployment
### Quick One-Liner Install
Run the following command as `root` on the target Linux host:
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- --url "https://<SSO_HOST>" --token "<HOST_TOKEN>"
``` ```
## Configuration ### Custom Config Wizard
You can generate a Base64-encoded custom configuration using the **Install Agent** button on the **Directory Management** page in the SSO Manager UI:
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`: ```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
```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 ---
## Configuration File Example (`/etc/theta42/agent.yml`)
```yaml
# /etc/theta42/agent.yml
server_url: "wss://sso.example.com"
auth_token: "your-unique-host-token"
location: "dc-01-rack-12"
public_key: "MCowBQYDK2VwAyEA..."
capabilities:
telemetry: true
configure_ldap: true
reboot: false
service_control: ["nginx", "docker", "sssd"]
arbitrary_bash: false
```
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.
+1
View File
@@ -60,6 +60,7 @@ backend, that's the niche.
run the pieces separately via `app_*` env config. run the pieces separately via `app_*` env config.
- **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites. - **Geo-Location Scaling** — built-in support for N-Way Multi-Master OpenLDAP [replication](replication.html) across physical sites.
- **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/). - **[Directory & Inventory](directory.html)** — map sites, hosts, and services as a graph with rich metadata (IP/MAC, OS/kernel, ports, git repos), auto-provisioned access groups, and automatic registration from theta-env and ldap-client. Drives directory-aware tools like the [SSH jump host](https://theta42.github.io/jump-host/).
- **[Theta Agent & Endpoint C2](agents.html)** — 2-way Go daemon (`theta-agent`) for real-time telemetry (CPU, RAM, Disk, ZFS, GPU), automated host discovery, SSSD/LDAP configuration, and local capability-controlled management operations.
## Get it ## Get it
Binary file not shown.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.19.6", "version": "1.20.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.19.6", "version": "1.20.2",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-free": "^7.3.0", "@fortawesome/fontawesome-free": "^7.3.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "t42-sso-manager", "name": "t42-sso-manager",
"version": "1.19.6", "version": "1.20.2",
"description": "A very simple LDAP management and SSO system", "description": "A very simple LDAP management and SSO system",
"author": [ "author": [
{ {
+1 -1
View File
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {}; const meta = resource.metadata || {};
meta.managed = true; meta.managed = true;
await resource.update({ metadata: meta }); await Resource.update(resource.id, { metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] })); res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); } } catch (err) { next(err); }
+1 -8
View File
@@ -84,14 +84,7 @@ router.get('/discovery', function(req, res, next) {
}); });
router.get('/plugins', function(req, res, next) { router.get('/plugins', function(req, res, next) {
// Plugin instances page — loadable/unloadable, configurable plugin copies res.redirect('/directory');
// with per-instance secrets in OpenBao. Renders the shell for anyone; the
// client gates with app.auth.forceLogin(['app_sso_admin',
// 'app_sso_directory_admin','admin']) and the /api/plugins endpoints enforce
// the same server-side. Same header-vs-navigation auth model as /conf and
// /vault (auth-token is a client-set header, not a cookie).
const registry = require('../services/plugin_registry');
res.render('plugins', {...values, pluginTypes: registry.types });
}); });
router.get('/vault', function(req, res) { router.get('/vault', function(req, res) {
+24 -14
View File
@@ -12,32 +12,39 @@ class DiscoveryReconciler {
res._originalSlug = res.slug; // Keep track for edge mapping res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null; let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
if (macs.length > 0) {
const allRes = await Resource.list(); const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r => existing = allRes.find(r =>
r.metadata && r.metadata.interfaces && r.metadata && (
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase())) (r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
); );
} }
} }
// Fallback matching by IP if no MAC match (weaker) // 2. Fallback matching by IP address
let ipsToMatch = []; let ipsToMatch = [];
if (res.metadata.interfaces) { if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i); ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
} }
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) { if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim())); res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
} }
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) { if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => { existing = allRes.find(r => {
if (!r.metadata) return false; if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) { if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim()); const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true; if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -47,13 +54,16 @@ class DiscoveryReconciler {
}); });
} }
// Fallback matching by Slug or Name // 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) { if (!existing && (res.slug || res.name)) {
const allRes = await Resource.list(); const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => existing = allRes.find(r => {
(res.slug && r.slug === res.slug) || if (res.slug && r.slug === res.slug) return true;
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
); if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
if (inputName && r.slug && normalizeHost(r.slug) === inputName) return true;
return false;
});
} }
if (existing) { if (existing) {
+2 -2
View File
@@ -29,8 +29,8 @@ describe('vault_broker admin policy', () => {
return { status: 404, text: async () => '' }; return { status: 404, text: async () => '' };
} }
if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') { if (method === 'PUT' && path === 'sys/policies/acl/sso-admin') {
expect(body.policy).toContain('path "secret/metadata" { capabilities = ["list", "read", "delete"] }'); expect(body.policy).toContain('path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }');
expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["list", "read", "delete"] }'); expect(body.policy).toContain('path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }');
return { status: 204, ok: true }; return { status: 204, ok: true };
} }
if (method === 'POST' && path === 'auth/token/create/sso-broker') { if (method === 'POST' && path === 'auth/token/create/sso-broker') {
-1
View File
@@ -44,7 +44,6 @@ module.exports = {
{href: '/groups', icon: 'fas fa-users-cog', label: 'Groups', groups: ['app_sso_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: '/conf', icon: 'fas fa-cogs', label: 'Configuration', groups: ['app_sso_admin']},
{href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']}, {href: '/directory', icon: 'fa-solid fa-server', label: 'Directory', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
{href: '/plugins', icon: 'fa-solid fa-plug', label: 'Plugins', groups: ['app_sso_admin', 'app_sso_directory_admin', 'admin']},
// Vault requires login - per-user secrets at secret/users/<uid>/*. // Vault requires login - per-user secrets at secret/users/<uid>/*.
{href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']}, {href: '/vault', icon: 'fa-solid fa-vault', label: 'Vault', groups: ['login']},
{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']},
+33 -29
View File
@@ -79,17 +79,19 @@ async function mintToken(policies) {
return { token, ttl }; return { token, ttl };
} }
// ── Per-user token ──────────────────────────────────────────────────────────
// ── Per-user token ────────────────────────────────────────────────────────── // ── Per-user token ──────────────────────────────────────────────────────────
function userPolicyHcl(uid) { function userPolicyHcl(uid) {
// uid is an LDAP uid (alphanumeric + a few separators); it is interpolated return `path "secret/data/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
// into a policy path, so reject anything but a safe charset. path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
// The bare `secret/metadata/users/<uid>` grant is required to LIST the path "secret/metadata/users/${uid}" { capabilities = ["create", "read", "update", "delete", "list"] }
// contents of the namespace: `.../*` covers nested paths but NOT the path "secret/metadata/users/${uid}/" { capabilities = ["create", "read", "update", "delete", "list"] }
// directory itself, so without it the /vault secrets list 403s. path "secret/metadata/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/data/shared" { capabilities = ["read", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] } path "secret/data/shared/*" { capabilities = ["read", "list"] }
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] } path "secret/metadata/shared" { capabilities = ["read", "list"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`; path "secret/metadata/shared/" { capabilities = ["read", "list"] }
path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`;
} }
// Mint (or return the cached) per-user token confined to secret/users/<uid>/*. // Mint (or return the cached) per-user token confined to secret/users/<uid>/*.
@@ -107,13 +109,13 @@ async function getOrCreateUserToken(uid) {
// ── Admin token (read/write all of secret/) ───────────────────────────────── // ── Admin token (read/write all of secret/) ─────────────────────────────────
function adminPolicyHcl() { function adminPolicyHcl() {
// The bare `secret/metadata` / `secret/metadata/` grants let an admin LIST return `path "secret/*" { capabilities = ["create", "read", "update", "delete", "list"] }
// the KV mount root (the top-level dirs); `secret/metadata/*` covers nested path "secret" { capabilities = ["create", "read", "update", "delete", "list"] }
// paths but NOT the root itself, so without it the /vault secrets list 403s. path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] }
return `path "secret/data/*" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/data" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata" { capabilities = ["list", "read", "delete"] } path "secret/metadata" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/" { capabilities = ["list", "read", "delete"] } path "secret/metadata/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/*" { capabilities = ["list", "read", "delete"] }`; path "secret/metadata/*" { capabilities = ["create", "read", "update", "delete", "list"] }`;
} }
async function getOrCreateAdminToken(uid) { async function getOrCreateAdminToken(uid) {
@@ -128,11 +130,16 @@ async function getOrCreateAdminToken(uid) {
// ── Per-app token (minted ONCE, returned to the caller, never cached) ─────── // ── Per-app token (minted ONCE, returned to the caller, never cached) ───────
function appPolicyHcl(name) { function appPolicyHcl(name) {
// The bare `secret/metadata/apps/<name>` grant lets an app LIST its own return `path "secret/data/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
// namespace root (see userPolicyHcl for why `/*` alone isn't enough). path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
return `path "secret/data/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] } path "secret/metadata/apps/${name}" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}" { capabilities = ["list", "read", "delete"] } path "secret/metadata/apps/${name}/" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/apps/${name}/*" { capabilities = ["list", "read", "delete"] }`; path "secret/metadata/apps/${name}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/data/shared" { capabilities = ["read", "list"] }
path "secret/data/shared/*" { capabilities = ["read", "list"] }
path "secret/metadata/shared" { capabilities = ["read", "list"] }
path "secret/metadata/shared/" { capabilities = ["read", "list"] }
path "secret/metadata/shared/*" { capabilities = ["read", "list"] }`;
} }
// Create the app-<name> policy + mint a token for it. Returns the token ONCE // Create the app-<name> policy + mint a token for it. Returns the token ONCE
@@ -156,11 +163,12 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them. // client's sso auth headers so OpenBao never sees them.
const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200'; const VAULT_ADDR = process.env.VAULT_ADDR || 'http://openbao:8200';
const ADMIN_GROUPS = ['app_sso_admin', 'app_super_admin', 'app_sso_directory_admin'];
const ADMIN_GROUP = 'app_sso_admin'; const ADMIN_GROUP = 'app_sso_admin';
async function isAdmin(user) { async function isAdmin(user) {
try { try {
await permission.byGroup(user, [ADMIN_GROUP]); await permission.byGroup(user, ADMIN_GROUPS);
return true; return true;
} catch (e) { } catch (e) {
return false; return false;
@@ -189,17 +197,13 @@ async function scopeGuard(req, res, next) {
return res.status(503).json({ error: 'vault broker unavailable', detail: e.message }); 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); const norm = normalizeVaultPath(req.path);
if (norm === null) { if (norm === null) {
return res.status(403).json({ error: 'vault paths must be under /secret/' }); return res.status(403).json({ error: 'vault paths must be under /secret/' });
} }
const base = `/secret/users/${uid}`; const userBase = `/secret/users/${uid}`;
const allowed = admin || norm === base || norm.startsWith(base + '/'); const sharedBase = `/secret/shared`;
const allowed = admin || norm === userBase || norm.startsWith(userBase + '/') || norm === sharedBase || norm.startsWith(sharedBase + '/');
if (!allowed) { if (!allowed) {
return res.status(403).json({ error: 'path outside your vault namespace' }); return res.status(403).json({ error: 'path outside your vault namespace' });
} }
+222 -192
View File
@@ -1,11 +1,16 @@
<%- include('top') %> <%- include('top') %>
<script type="text/javascript"> <script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']); app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() { $(document).ready(function() {
loadConf(); loadConf();
loadProxyConf(); loadProxyConf();
loadTos(); loadTos();
loadMessagingPlugins();
}); });
async function loadConf() { async function loadConf() {
@@ -44,7 +49,7 @@
async function saveConf() { async function saveConf() {
const btn = $('#btn-save'); const btn = $('#btn-save');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = { const payload = {
smtp: { smtp: {
@@ -72,11 +77,11 @@
try { try {
await app.api.post('conf', payload); await app.api.post('conf', payload);
app.messages.toast('Configuration saved successfully! It will take effect immediately.', 'success'); app.messages.toast('Configuration saved successfully!', 'success');
} catch (error) { } catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
} }
} }
@@ -87,13 +92,11 @@
return; return;
} }
const $inputGroup = $('#test-email-to').closest('.input-group'); const btn = $('#btn-test-email');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try { try {
// First save the SMTP config, then send test email
const payload = { const payload = {
smtp: { smtp: {
host: $('#smtp-host').val(), host: $('#smtp-host').val(),
@@ -104,11 +107,7 @@
secure: $('#smtp-secure').is(':checked') secure: $('#smtp-secure').is(':checked')
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test email
const result = await app.api.post('conf/test-email', { to }); const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success'); app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val(''); $('#test-email-to').val('');
@@ -126,13 +125,11 @@
return; return;
} }
const $inputGroup = $('#test-sms-to').closest('.input-group'); const btn = $('#btn-test-sms');
const btn = $inputGroup.find('button');
const originalHtml = btn.html(); const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Sending...');
try { try {
// First save the VoIP.ms config, then send test SMS
const payload = { const payload = {
voipms: { voipms: {
username: $('#voipms-username').val(), username: $('#voipms-username').val(),
@@ -140,11 +137,7 @@
password: $('#voipms-password').val() password: $('#voipms-password').val()
} }
}; };
// Save config first
await app.api.post('conf', payload); await app.api.post('conf', payload);
// Then send test SMS
const result = await app.api.post('conf/test-sms', { to }); const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success'); app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val(''); $('#test-sms-to').val('');
@@ -182,7 +175,7 @@
async function saveProxyConf() { async function saveProxyConf() {
const btn = $('#btn-save-proxy'); const btn = $('#btn-save-proxy');
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Saving...'); btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin me-1"></i> Saving...');
const payload = { const payload = {
oidc: { oidc: {
@@ -201,22 +194,18 @@
} catch (error) { } catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger'); app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally { } finally {
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Proxy Secrets'); btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
} }
} }
// ── Terms of Service editor ──────────────────────────────────────────
// Moved here from the admin Overview dashboard — it's a configuration
// control, so it belongs on the System Configuration page. The API is
// routes/tos.js (GET to read, PUT to save; PUT is app_sso_admin-gated, which
// matches this page's gate). app.tos.get/update are the shared frontend
// helpers (@simpleworkjs/frontend).
async function loadTos() { async function loadTos() {
try { try {
const tos = await app.tos.get(); const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content; document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent = document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by; 'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
} catch(e) { } catch(e) {
console.error('Failed to load ToS:', e); console.error('Failed to load ToS:', e);
} }
@@ -248,246 +237,287 @@
loadTos(); loadTos();
}); });
} }
// ── Messaging Plugins ──────────────────────────────────────────────
function loadMessagingPlugins() {
app.api.get('plugins/types', function(err, res) {
if (!err && res && res.results) {
(res.results || []).forEach(t => { messagingTypes[t.type] = t; });
}
app.api.get('plugins', function(err, res) {
if (err) return;
messagingPlugins = (res.results || []).filter(p => p.category === 'messaging');
renderMessagingPlugins();
});
});
}
function renderMessagingPlugins() {
const $list = $('#messaging-plugins-list').empty();
if (messagingPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fas fa-plug text-black-50 fs-2 mb-2"></i><br>No messaging plugins configured.</div>');
return;
}
messagingPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="togglePlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-outline-danger" onclick="deletePlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function togglePlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Plugin ${state ? 'loaded' : 'unloaded'} successfully`, 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function deletePlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this plugin instance?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Plugin deleted', 'success');
loadMessagingPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
</script> </script>
<div class="container py-4"> <div class="container mt-4">
<div class="row mb-4"> <div class="row">
<div class="col d-flex justify-content-between align-items-center"> <div class="col-12">
<div> <div class="card shadow">
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <!-- Header with Sub-Nav Tabs matching directory.ejs -->
<p class="text-muted mb-0"> <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service <ul class="nav nav-tabs card-header-tabs" id="confTabs" role="tablist">
settings. These are stored securely in OpenBao and take effect immediately.
Secret fields (the SMTP password, OAuth JWT secret, and VoIP.ms API password)
are masked — leave them unchanged to keep the stored value.
</p>
</div>
<div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
</div>
</div>
</div>
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#smtp" type="button" role="tab">SMTP Settings</button> <button class="nav-link active" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#pane-oauth" type="button" role="tab">
<i class="fas fa-key text-success me-1"></i> OAuth & JWT
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="oauth-tab" data-bs-toggle="tab" data-bs-target="#oauth" type="button" role="tab">OAuth & JWT</button> <button class="nav-link" id="smtp-tab" data-bs-toggle="tab" data-bs-target="#pane-smtp" type="button" role="tab">
<i class="fas fa-envelope text-primary me-1"></i> Email (SMTP)
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#sms" type="button" role="tab">SMS (VoIP.ms)</button> <button class="nav-link" id="sms-tab" data-bs-toggle="tab" data-bs-target="#pane-sms" type="button" role="tab">
<i class="fas fa-comment-sms text-info me-1"></i> SMS & Messaging
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#tos" type="button" role="tab">Terms of Service</button> <button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#pane-proxy" type="button" role="tab">
<i class="fas fa-shield-alt text-warning me-1"></i> Proxy Secrets
</button>
</li> </li>
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link" id="proxy-tab" data-bs-toggle="tab" data-bs-target="#proxy" type="button" role="tab">Proxy Secrets</button> <button class="nav-link" id="tos-tab" data-bs-toggle="tab" data-bs-target="#pane-tos" type="button" role="tab">
<i class="fas fa-file-contract text-secondary me-1"></i> Terms of Service
</button>
</li> </li>
</ul> </ul>
<div>
<div class="tab-content" id="confTabsContent"> <button class="btn btn-sm btn-outline-secondary me-1" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<!-- SMTP Tab --> <button id="btn-save" class="btn btn-sm btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
<div class="tab-pane fade show active" id="smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Host</label>
<input type="text" class="form-control" id="smtp-host">
</div>
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test SMS</label>
<div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS
</button>
</div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</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>
<hr class="my-4">
<div class="mb-3">
<label class="form-label">Send Test Email</label>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane"></i> Send Test Email
</button>
</div>
<div class="form-text">Send a test email to verify your SMTP configuration is working.</div>
</div>
</div>
</div> </div>
</div> </div>
<!-- OAuth Tab --> <div class="card-body p-4">
<div class="tab-pane fade" id="oauth" role="tabpanel"> <div class="tab-content" id="confTabContent">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <!-- OAuth & JWT Tab -->
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5> <div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
</div> <h5 class="fw-bold mb-3"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
<div class="card-body"> <p class="text-muted small">Configure OIDC issuer URLs, token lifetimes, and JWT signing keys. Stored in OpenBao.</p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Issuer URL</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer"> <input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">JWT Secret</label> <label class="form-label fw-semibold">JWT Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********"> <input type="password" class="form-control" id="oauth-jwtsecret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current secret stored in OpenBao. Clear and type a new value to replace it.</div> <div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Access Token Lifetime (seconds)</label> <div class="col-md-6 mb-3">
<input type="number" class="form-control" id="oauth-token-access"> <label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
</div> <input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
<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 class="col-md-6 mb-3">
<label class="form-label fw-semibold">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh" placeholder="2592000">
</div> </div>
</div> </div>
</div> </div>
<!-- SMS Tab --> <!-- SMTP Tab -->
<div class="tab-pane fade" id="sms" role="tabpanel"> <div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <h5 class="fw-bold mb-3"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <p class="text-muted small">System mail server credentials for password resets, notifications, and verification emails.</p>
<h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5> <div class="row">
<div class="col-md-8 mb-3">
<label class="form-label fw-semibold">SMTP Host</label>
<input type="text" class="form-control" id="smtp-host" placeholder="smtp.example.com">
</div>
<div class="col-md-4 mb-3">
<label class="form-label fw-semibold">Port</label>
<input type="number" class="form-control" id="smtp-port" placeholder="587">
</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
</div> </div>
<div class="card-body">
<p class="form-text">Used to deliver SMS 2FA login codes. The API password is stored in OpenBao and masked below.</p>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Username</label> <label class="form-label fw-semibold">From Address</label>
<input type="text" class="form-control" id="smtp-from" placeholder="noreply@example.com">
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label fw-semibold" for="smtp-secure">Use Secure TLS Connection</label>
</div>
<div class="p-3 bg-light rounded border">
<h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-primary me-2"></i> Send Test Email</h6>
<div class="input-group">
<input type="email" class="form-control" id="test-email-to" placeholder="recipient@example.com">
<button id="btn-test-email" class="btn btn-outline-primary" type="button" onclick="sendTestEmail()">
<i class="fas fa-paper-plane me-1"></i> Send Test Email
</button>
</div>
<div class="form-text">Saves current SMTP config and sends a test message.</div>
</div>
</div>
<!-- SMS & Messaging Tab -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<h5 class="fw-bold mb-3"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
<p class="text-muted small">Configure VoIP.ms API credentials for delivering SMS 2FA codes.</p>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">API Username</label>
<input type="text" class="form-control" id="voipms-username"> <input type="text" class="form-control" id="voipms-username">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">DID (sender number)</label> <label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567"> <input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div> </div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">API Password</label> <label class="form-label fw-semibold">API Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="voipms-password" placeholder="********"> <input type="password" class="form-control" id="voipms-password" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('voipms-password')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Leave unchanged to keep the current password stored in OpenBao. Clear and type a new value to replace it.</div> </div>
<hr class="my-4">
<div class="mb-3"> <div class="p-3 bg-light rounded border mb-4">
<label class="form-label">Send Test SMS</label> <h6 class="fw-bold mb-2"><i class="fas fa-paper-plane text-info me-2"></i> Send Test SMS</h6>
<div class="input-group"> <div class="input-group">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567"> <input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button class="btn btn-outline-primary" type="button" onclick="sendTestSms()"> <button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane"></i> Send Test SMS <i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button> </button>
</div> </div>
<div class="form-text">Send a test SMS to verify your VoIP.ms configuration is working.</div>
</div>
</div> </div>
<hr class="my-4">
<div class="d-flex justify-content-between align-items-center mb-3">
<h5 class="mb-0 fw-bold"><i class="fas fa-plug text-primary me-2"></i> Messaging Plugins & Webhooks</h5>
<button class="btn btn-sm btn-outline-primary" onclick="loadMessagingPlugins()"><i class="fas fa-rotate"></i> Refresh</button>
</div> </div>
<div id="messaging-plugins-list"></div>
</div> </div>
<!-- Proxy Secrets Tab --> <!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="proxy" role="tabpanel"> <div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <h5 class="fw-bold mb-3"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h5 class="mb-0"><i class="fas fa-shield-alt text-warning me-2"></i> Proxy Secrets (OpenBao)</h5>
</div>
<div class="card-body">
<p class="form-text">These secrets are stored directly in OpenBao (`secret/proxy/conf`) and read by the Proxy at boot.</p>
<h6 class="mt-3 mb-2">OAuth / OIDC Integration</h6> <h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Issuer URL</label> <label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com"> <input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div> </div>
<div class="mb-3"> <div class="row">
<label class="form-label">Client ID</label> <div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id"> <input type="text" class="form-control" id="proxy-client-id">
</div> </div>
<div class="mb-3"> <div class="col-md-6 mb-3">
<label class="form-label">Client Secret</label> <label class="form-label fw-semibold">Client Secret</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-client-secret" placeholder="********"> <input type="password" class="form-control" id="proxy-client-secret" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-client-secret')"><i class="fas fa-eye"></i></button>
</div> </div>
</div> </div>
</div>
<h6 class="mt-4 mb-2">LDAP Integration</h6> <h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Bind Password</label> <label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group"> <div class="input-group">
<input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********"> <input type="password" class="form-control" id="proxy-ldap-bindpass" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button> <button class="btn btn-outline-secondary" type="button" onclick="togglePassword('proxy-ldap-bindpass')"><i class="fas fa-eye"></i></button>
</div> </div>
<div class="form-text">Password for the Proxy's LDAP service account.</div>
</div> </div>
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button> <button id="btn-save-proxy" class="btn btn-warning mt-2 text-dark fw-semibold" onclick="saveProxyConf()"><i class="fas fa-save me-1"></i> Save Proxy Secrets</button>
</div>
</div>
</div> </div>
<!-- ToS Tab --> <!-- Terms of Service Tab -->
<div class="tab-pane fade" id="tos" role="tabpanel"> <div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4"> <div class="d-flex justify-content-between align-items-center mb-3">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center"> <h5 class="fw-bold mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5> <span class="small text-muted" id="tos-meta"></span>
<small class="text-muted" id="tos-meta"></small>
</div> </div>
<div class="card-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label> <label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control" id="tos-content" rows="8"></textarea> <textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div> </div>
<div class="form-check mb-3"> <div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance"> <input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<label class="form-check-label" for="tos-reset-acceptance">Require all users to re-accept these terms</label> <label class="form-check-label fw-semibold" for="tos-reset-acceptance">Require all users to re-accept these terms upon next login</label>
</div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk me-1"></i> Save Terms of Service</button>
<div id="tos-result" style="display:none" class="mt-3"></div>
</div>
</div> </div>
<button class="btn btn-primary" onclick="saveTos()"><i class="fas fa-floppy-disk"></i> Save Terms</button>
<div id="tos-result" style="display:none" class="mt-2"></div>
</div> </div>
</div> </div>
</div> </div>
+163 -7
View File
@@ -13,7 +13,12 @@
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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 <i class="fa-solid fa-network-wired"></i> Discovered Inventory
</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> Discovery Plugins
</button> </button>
</li> </li>
</ul> </ul>
@@ -187,6 +192,23 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Discovery Plugins Tab Pane -->
<div class="tab-pane fade" id="plugins-tab-pane" role="tabpanel" aria-labelledby="plugins-tab">
<div class="p-4 bg-white border-top">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
<h5 class="fw-bold mb-1"><i class="fa-solid fa-plug text-primary me-2"></i> Discovery Plugins</h5>
<p class="text-muted small mb-0">Manage background discovery agents (Nmap, Docker, Proxmox, UniFi). Per-instance secrets are stored in OpenBao.</p>
</div>
<div>
<button class="btn btn-sm btn-outline-primary me-2" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
<button class="btn btn-sm btn-primary shadow-sm" onclick="openNewDiscoveryPluginModal()"><i class="fas fa-plus me-1"></i> New Plugin</button>
</div>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1188,6 +1210,7 @@
allEdges.push(res.results); allEdges.push(res.results);
refreshEdgesUI(resourceId); refreshEdgesUI(resourceId);
$('#new-edge-target').val(''); $('#new-edge-target').val('');
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger'); app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1199,6 +1222,7 @@
await app.api.delete('directory-admin/edges/' + id); await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id); allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val()); refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) { } catch (err) {
console.error(err); console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger'); app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1239,8 +1263,9 @@
const search = $('#discovery-search-filter').val().toLowerCase(); const search = $('#discovery-search-filter').val().toLowerCase();
const filtered = allDiscoveryResources.filter(r => { const filtered = allDiscoveryResources.filter(r => {
if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false; if (search && !r.name.toLowerCase().includes(search) && !r.slug.toLowerCase().includes(search)) return false;
const isManaged = !!(r.metadata && r.metadata.managed); // Directory contains managed items; Discovered Inventory only shows unmanaged/pending items awaiting promotion
if(isManaged) return false; const isExplicitManaged = r.metadata && (r.metadata.managed === true || r.metadata.managed === 'true');
if (isExplicitManaged || r.kind === 'site' || r.kind === 'service') return false;
return true; return true;
}); });
@@ -1493,7 +1518,7 @@
`; `;
app.modal.open({ app.modal.open({
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent', title: 'Install Theta Agent',
bodyHtml: bodyHtml, bodyHtml: bodyHtml,
size: 'lg' size: 'lg'
}); });
@@ -1501,12 +1526,143 @@
updateAgentCommands(); updateAgentCommands();
} }
// Plugin scheduling moved to the dedicated /plugins page (the Agents & var discoveryPlugins = [];
// Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page. function loadDiscoveryPlugins() {
app.api.get('plugins', function(err, res) {
if (err) return;
discoveryPlugins = (res.results || []).filter(p => p.category === 'discovery');
renderDiscoveryPlugins();
});
}
function renderDiscoveryPlugins() {
const $list = $('#discovery-plugins-list').empty();
if (discoveryPlugins.length === 0) {
$list.append('<div class="text-muted text-center py-4"><i class="fa-solid fa-plug fs-2 mb-2 text-black-50"></i><br>No discovery plugins configured.</div>');
return;
}
discoveryPlugins.forEach(p => {
const badgeClass = p.enabled ? 'bg-success' : 'bg-secondary';
const statusText = p.enabled ? 'Loaded' : 'Unloaded';
const card = `
<div class="card mb-3 border shadow-sm">
<div class="card-body d-flex align-items-center justify-content-between">
<div>
<h6 class="mb-1"><strong>${p.name}</strong> <span class="badge bg-secondary ms-2">${p.pluginType}</span></h6>
<div class="small text-muted font-monospace">${p.slug} | Schedule: ${p.cron}</div>
</div>
<div class="d-flex align-items-center gap-2">
<span class="badge ${badgeClass} me-2">${statusText}</span>
<button class="btn btn-sm btn-outline-primary" onclick="toggleDiscoveryPlugin('${p.id}', ${!p.enabled})">${p.enabled ? 'Unload' : 'Load'}</button>
<button class="btn btn-sm btn-success" title="Run now" onclick="runDiscoveryPluginNow('${p.id}')"><i class="fa-solid fa-play"></i> Run</button>
<button class="btn btn-sm btn-outline-danger" onclick="deleteDiscoveryPlugin('${p.id}')"><i class="fas fa-trash"></i></button>
</div>
</div>
</div>
`;
$list.append(card);
});
}
async function toggleDiscoveryPlugin(id, state) {
const endpoint = state ? 'load' : 'unload';
try {
await app.api.post(`plugins/${id}/${endpoint}`, {});
app.messages.toast(`Discovery plugin ${state ? 'loaded' : 'unloaded'}`, 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error toggling plugin: ' + e.message, 'danger');
}
}
async function runDiscoveryPluginNow(id) {
try {
await app.api.post(`plugins/${id}/run`, {});
app.messages.toast('Enqueued discovery plugin run', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error running plugin: ' + e.message, 'danger');
}
}
var discoveryPluginTypes = [];
function openNewDiscoveryPluginModal() {
app.api.get('plugins/types', function(err, res) {
if (err) { app.messages.toast('Error loading plugin types: ' + err.message, 'danger'); return; }
discoveryPluginTypes = (res.results || []).filter(t => t.category === 'discovery');
if (discoveryPluginTypes.length === 0) {
app.messages.toast('No discovery plugin types available', 'warning');
return;
}
const options = discoveryPluginTypes.map(t => `<option value="${t.type}">${t.name} (${t.type})</option>`).join('');
const bodyHtml = `
<div class="mb-3">
<label class="form-label fw-bold">Plugin Type</label>
<select id="new-plugin-type" class="form-select shadow-sm">${options}</select>
</div>
<div class="mb-3">
<label class="form-label fw-bold">Instance Name</label>
<input type="text" id="new-plugin-name" class="form-control shadow-sm" placeholder="e.g. Local Subnet Scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Slug</label>
<input type="text" id="new-plugin-slug" class="form-control shadow-sm font-monospace" placeholder="e.g. local-subnet-scanner">
</div>
<div class="mb-3">
<label class="form-label fw-bold">Cron Schedule</label>
<input type="text" id="new-plugin-cron" class="form-control shadow-sm font-monospace" value="*/15 * * * *">
<div class="form-text">Standard 5-field cron expression (e.g. */15 * * * * for every 15 mins)</div>
</div>
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="new-plugin-enabled" checked>
<label class="form-check-label fw-semibold" for="new-plugin-enabled">Enable (load on create)</label>
</div>
<div class="d-flex justify-content-end gap-2">
<button class="btn btn-secondary" onclick="app.modal.close()">Cancel</button>
<button class="btn btn-primary" onclick="saveNewDiscoveryPlugin()">Create Plugin</button>
</div>
`;
app.modal.open({
title: 'Configure New Discovery Plugin',
bodyHtml: bodyHtml,
size: 'md'
});
});
}
async function saveNewDiscoveryPlugin() {
const type = $('#new-plugin-type').val();
const name = $('#new-plugin-name').val().trim();
const slug = $('#new-plugin-slug').val().trim() || name.toLowerCase().replace(/[^a-z0-9]/g, '-');
const cron = $('#new-plugin-cron').val().trim() || '*/15 * * * *';
const enabled = $('#new-plugin-enabled').is(':checked');
if (!name) return app.messages.action('Name is required', app.modal.body(), 'danger');
try {
await app.api.post('plugins', {
pluginType: type,
name,
slug,
cron,
enabled,
config: {}
});
app.messages.toast('Discovery plugin created successfully!', 'success');
app.modal.close();
loadDiscoveryPlugins();
} catch (e) {
app.messages.action('Error creating plugin: ' + e.message, app.modal.body(), 'danger');
}
}
$(document).ready(function(){ $(document).ready(function(){
loadDiscoveryResources(); loadDiscoveryResources();
loadDiscoveryPlugins();
}); });
</script> </script>
+8 -2
View File
@@ -134,7 +134,12 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
// key relative to the subject's namespace (so 'foo' for a user means // key relative to the subject's namespace (so 'foo' for a user means
// secret/data/users/<uid>/foo). // secret/data/users/<uid>/foo).
function vpath(kind, key) { function vpath(kind, key) {
return `secret/${kind}/${VAULT_BASE}${key}`; let cleanKey = key || '';
if (cleanKey.startsWith('/')) cleanKey = cleanKey.slice(1);
if (VAULT_BASE) {
return `secret/${kind}/${VAULT_BASE}${cleanKey}`;
}
return `secret/${kind}/${cleanKey}`;
} }
function apiCall(method, path, body = null) { function apiCall(method, path, body = null) {
@@ -156,7 +161,8 @@ curl "$VAULT_ADDR/v1/secret/data/apps/<span id="app-name-display"></span>/conf"
async function loadSecrets() { async function loadSecrets() {
try { try {
const res = await apiCall('GET', vpath('metadata', '?list=true')); const listPath = vpath('metadata', '').replace(/\/$/, '') + '?list=true';
const res = await apiCall('GET', listPath);
const listEl = document.getElementById('secrets-list'); const listEl = document.getElementById('secrets-list');
listEl.innerHTML = ''; listEl.innerHTML = '';
if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) { if (!res || !res.data || !res.data.keys || res.data.keys.length === 0) {