Compare commits

...

6 Commits

Author SHA1 Message Date
wmantly 74746e409b Merge pull request #135 from theta42/release-v1.15.2
Add vault.md
2026-08-01 02:59:34 -04:00
wmantly 70aed035a5 Merge pull request #134 from theta42/release-v1.15.1
Make directory tabs look like group tabs
2026-08-01 02:56:59 -04:00
wmantly 0264a62b22 Add missing docs/vault.md 2026-08-01 02:55:49 -04:00
wmantly c212537163 Make directory tabs look like group tabs 2026-08-01 02:52:57 -04:00
wmantly 391ad12afc Merge pull request #133 from theta42/release-v1.15.0
Release v1.15.0
2026-08-01 02:44:15 -04:00
wmantly 622317b6da UI/UX improvements: structured conf page and rename plugin to agent 2026-08-01 02:39:28 -04:00
9 changed files with 257 additions and 73 deletions
+14 -14
View File
@@ -1,23 +1,23 @@
--- ---
layout: default layout: default
title: Discovery Plugins title: Discovery Agents
nav_order: 5 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 ```javascript
// nodejs/plugins/discovery/my_custom_plugin.js // nodejs/agents/discovery/my_custom_agent.js
module.exports = { module.exports = {
discover: async (config) => { discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration const { url, apiKey } = config; // Provided by your configuration
@@ -56,14 +56,14 @@ module.exports = {
## Configuration ## 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 ```javascript
module.exports = { module.exports = {
// ... existing config ... // ... existing config ...
discovery: { discovery: {
plugins: { agents: {
my_custom_plugin: { my_custom_agent: {
enabled: true, enabled: true,
cron: '*/30 * * * *', // Run every 30 minutes cron: '*/30 * * * *', // Run every 30 minutes
url: 'https://api.example.com', url: 'https://api.example.com',
@@ -81,8 +81,8 @@ module.exports = {
## The Reconciliation Engine ## 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`. 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). 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 plugin's filename in the `discovery_sources` array on the resource, and updates the `last_seen` timestamp. 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. 4. **LDAP Spam Prevention:** Brand new devices are marked as `managed: false`. They will not pollute your LDAP directory until an admin explicitly promotes them.
+39
View File
@@ -0,0 +1,39 @@
---
layout: default
title: Secrets Vault
nav_order: 6
---
# Secrets Vault
SSO Manager integrates natively with **OpenBao** (a Vault fork) to securely manage and store sensitive data, configuration, and API keys.
The Vault proxy endpoint is exposed directly through SSO Manager at `/api/vault/v1/`, which safely authenticates and authorizes requests before forwarding them to the internal OpenBao container.
## Architecture
The secrets engine uses a persistent file backend (`/var/lib/docker/volumes/theta-env_openbao-data/_data`) to ensure high availability and durability.
When the environment is initialized via `setup.sh`, OpenBao is automatically unsealed and seeded with a root token that the application uses for authentication. The root token is kept securely inside the container environment.
## Accessing the Vault
The SSO Manager Vault can be accessed in two ways:
1. **Via the SSO Manager UI**: Go to the **Admin Configuration** page (`/conf`) to edit the application's configuration secrets directly.
2. **Via the REST API**: Send requests to `/api/vault/v1/...` with your SSO Manager session or API Token.
### API Example
To read secrets from the default key-value store, issue a `GET` request to:
`/api/vault/v1/secret/data/sso-manager/conf`
Only administrators with `app_sso_admin` or `admin` permissions can query the vault endpoints.
## Namespaces and Paths
Currently, secrets are maintained at `/v1/secret/data/sso-manager/conf` using the `kv-v2` backend. When configurations are edited via the admin UI, SSO Manager performs a deep-merge so that partial updates don't overwrite unrelated keys (such as SMTP vs OAuth configurations).
## Plugin Integration
When building custom Agents or integrations, they can utilize the local Vault to retrieve API tokens instead of hardcoding them. Always use the `/api/vault` proxy to ensure permissions are consistently enforced.
Binary file not shown.
+10 -1
View File
@@ -23,7 +23,16 @@ router.get('/', async (req, res) => {
router.post('/', async (req, res, next) => { router.post('/', async (req, res, next) => {
try { try {
await confManager.setVaultConf(req.body); const existing = await confManager.getVaultConf() || {};
// 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 confManager.setVaultConf(existing);
res.json({ success: true }); res.json({ success: true });
} catch(err) { } catch(err) {
next(err); next(err);
+20
View File
@@ -0,0 +1,20 @@
const router = require('express').Router();
const permission = require('../utils/permission');
router.use(async (req, res, next) => {
try {
await permission.byGroup(req.user, ['app_sso_admin']);
next();
} catch(err) {
next(err);
}
});
router.get('/', (req, res) => {
res.render('conf', {
title: 'Configuration',
user: req.user
});
});
module.exports = router;
+1 -1
View File
@@ -34,7 +34,7 @@ const DOCS = {
'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')}, 'oauth-apps': {title: 'Connecting Apps (SSO)', file: path.join(__dirname, '../../docs/concepts-oauth-apps.md')},
'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')}, 'api-tokens': {title: 'API Tokens', file: path.join(__dirname, '../../docs/concepts-api-tokens.md')},
directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')}, directory: {title: 'Directory & Inventory', file: path.join(__dirname, '../../docs/directory.md')},
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')}, vault: {title: 'Vault Secrets', file: path.join(__dirname, '../../docs/vault.md')},
overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')}, overview: {title: 'Overview', file: path.join(__dirname, '../../README.md')},
+10
View File
@@ -64,6 +64,16 @@ router.get('/notifications', (req, res) => res.redirect(301, '/overview'));
router.get('/dashboard', (req, res) => res.redirect(301, '/overview')); router.get('/dashboard', (req, res) => res.redirect(301, '/overview'));
router.get('/executive', (req, res) => res.redirect(301, '/overview')); router.get('/executive', (req, res) => res.redirect(301, '/overview'));
router.get('/conf', async function(req, res, next) {
const permission = require('../utils/permission');
try {
await permission.byGroup(req.user, ['app_sso_admin']);
res.render('conf', {...values});
} catch(err) {
next(err);
}
});
router.get('/directory', function(req, res) { router.get('/directory', function(req, res) {
res.render('directory', {...values}); res.render('directory', {...values});
}); });
+118 -24
View File
@@ -9,7 +9,25 @@
async function loadConf() { async function loadConf() {
try { try {
const data = await app.api.get('conf'); 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) { } catch (error) {
app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger'); app.messages.toast('Failed to load configuration: ' + (error.message || 'Unknown error'), 'danger');
} }
@@ -18,50 +36,126 @@
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"></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 { try {
const text = $('#conf-json').val();
const payload = JSON.parse(text);
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! It will take effect immediately.', 'success');
} catch (error) { } catch (error) {
let msg = error.message; app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
if (error instanceof SyntaxError) {
msg = 'Invalid JSON format. Please check your syntax.';
}
app.messages.toast('Failed to save configuration: ' + msg, '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"></i> Save Configuration');
} }
} }
function togglePassword(id) {
const el = document.getElementById(id);
if (el.type === 'password') {
el.type = 'text';
} else {
el.type = 'password';
}
}
</script> </script>
<div class="container py-4"> <div class="container py-4">
<div class="row mb-4"> <div class="row mb-4">
<div class="col"> <div class="col d-flex justify-content-between align-items-center">
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <div>
<p class="text-muted"> <h2><i class="fas fa-cogs"></i> System Configuration</h2>
Manage runtime configuration such as SMTP settings, discovery plugins, and OAuth parameters. <p class="text-muted mb-0">
These secrets are stored securely in OpenBao Vault. Manage runtime configuration such as SMTP settings and OAuth parameters.
</p> 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> </div>
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-md-6 mb-4">
<div class="card shadow-sm border-0"> <div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0"> <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>
<div class="card-body"> <div class="card-body">
<div class="alert alert-info"> <div class="mb-3">
<i class="fas fa-info-circle"></i> Be careful when editing this JSON! Malformed JSON will not save. <label class="form-label">Host</label>
<input type="text" class="form-control" id="smtp-host">
</div>
<div class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div> </div>
<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>
<div class="card-footer bg-white border-top-0 pb-4 text-end"> </div>
<button class="btn btn-secondary me-2" onclick="loadConf()"><i class="fas fa-undo"></i> Reset</button> </div>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save"></i> Save Configuration</button>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access">
</div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div>
</div> </div>
</div> </div>
</div> </div>
+45 -33
View File
@@ -3,26 +3,30 @@
<div class="container mt-4"> <div class="container mt-4">
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<ul class="nav nav-tabs mb-3 card-header-tabs" id="directoryTabs" role="tablist"> <div class="card shadow">
<li class="nav-item" role="presentation"> <div class="card-header">
<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"> <ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
<i class="fa-solid fa-server"></i> Directory <li class="nav-item" role="presentation">
</button> <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">
</li> <i class="fa-solid fa-server"></i> Directory
<li class="nav-item" role="presentation"> </button>
<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"> </li>
<i class="fa-solid fa-network-wired"></i> Discovery <li class="nav-item" role="presentation">
</button> <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">
</li> <i class="fa-solid fa-network-wired"></i> Discovery
<li class="nav-item" role="presentation"> </button>
<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"> </li>
<i class="fa-solid fa-plug"></i> Plugins & Scheduler <li class="nav-item" role="presentation">
</button> <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">
</li> <i class="fa-solid fa-robot"></i> Agents & Scheduler
</ul> </button>
<div class="tab-content" id="directoryTabsContent"> </li>
<div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab"> </ul>
<div class="card shadow border-top-0"> </div>
<div class="card-body p-0">
<div class="tab-content" id="directoryTabsContent">
<div class="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-tab">
<div class="border-0">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2"> <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<i class="fa-solid fa-server"></i> Directory Management <i class="fa-solid fa-server"></i> Directory Management
@@ -97,7 +101,7 @@
<!-- Discovery Tab Pane --> <!-- Discovery Tab Pane -->
<div class="tab-pane fade" id="discovery-tab-pane" role="tabpanel" aria-labelledby="discovery-tab"> <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 class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard <i class="fa-solid fa-network-wired"></i> Network Discovery Dashboard
@@ -186,22 +190,22 @@
</div> </div>
</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="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 class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div> <div>
<i class="fa-solid fa-plug"></i> Plugins & Scheduler <i class="fa-solid fa-robot"></i> Agents & Scheduler
</div> </div>
</div> </div>
<div class="p-3 pb-0 text-muted small border-bottom"> <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>
<div class="table-responsive"> <div class="table-responsive">
<table class="card-body table table-hover mb-0 align-middle"> <table class="card-body table table-hover mb-0 align-middle">
<thead class="table-light"> <thead class="table-light">
<tr> <tr>
<th class="ps-3">Plugin Name</th> <th class="ps-3">Agent Name</th>
<th>Cron Schedule</th> <th>Cron Schedule</th>
<th>Status</th> <th>Status</th>
<th>Actions</th> <th>Actions</th>
@@ -225,7 +229,7 @@
<tbody id="plugins-empty-state" style="display: none;"> <tbody id="plugins-empty-state" style="display: none;">
<tr> <tr>
<td colspan="4" class="text-center py-4 text-muted"> <td colspan="4" class="text-center py-4 text-muted">
No plugins configured. No agents configured.
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -233,6 +237,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -1319,11 +1325,11 @@
}); });
} }
// --- PLUGINS SCRIPTS --- // --- AGENT SCRIPTS ---
function loadPlugins() { function loadPlugins() {
app.api.get('plugins', function(err, res) { app.api.get('plugins', function(err, res) {
if(err) { if(err) {
app.messages.toast("Error loading plugins: " + (err.message || err), 'danger'); app.messages.toast("Error loading agents: " + (err.message || err), 'danger');
return; return;
} }
const plugins = res.results || {}; const plugins = res.results || {};
@@ -1339,7 +1345,7 @@
$.scope.plugins.push({ $.scope.plugins.push({
name: name, name: name,
cron: config.cron || '', cron: config.cron || '',
enabled: config.enabled enabled: !!config.enabled
}); });
}); });
$('#plugins-list').show(); $('#plugins-list').show();
@@ -1351,14 +1357,20 @@
function updatePlugin(name) { function updatePlugin(name) {
const cron = $('#cron-' + name).val(); const cron = $('#cron-' + name).val();
app.api.put('plugins/' + name, {cron: cron}, function(err, res) { app.api.put('plugins/' + name, {cron: cron}, function(err, res) {
if(err) { app.messages.toast("Failed to save: " + err.message, 'danger'); return; } if(err) {
app.messages.toast("Saved schedule successfully.", 'success'); app.messages.toast("Error saving agent schedule: " + (err.message || err), 'danger');
return;
}
app.messages.toast("Agent schedule saved successfully.", 'success');
}); });
} }
function togglePlugin(name, enable) { function togglePlugin(name, enable) {
app.api.put('plugins/' + name, {enabled: enable}, function(err, res) { 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(); loadPlugins();
}); });
} }