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});
}); });
+115 -21
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...');
try {
const text = $('#conf-json').val();
const payload = JSON.parse(text);
const payload = {
smtp: {
host: $('#smtp-host').val(),
port: parseInt($('#smtp-port').val(), 10) || 587,
user: $('#smtp-user').val(),
pass: $('#smtp-pass').val(),
from: $('#smtp-from').val(),
secure: $('#smtp-secure').is(':checked')
},
oauth: {
issuer: $('#oauth-issuer').val(),
jwtSecret: $('#oauth-jwtsecret').val(),
token_lifetime: {
access_token: parseInt($('#oauth-token-access').val(), 10) || 3600,
refresh_token: parseInt($('#oauth-token-refresh').val(), 10) || 2592000
}
}
};
try {
await app.api.post('conf', payload); 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">
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2> <h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted"> <p class="text-muted mb-0">
Manage runtime configuration such as SMTP settings, discovery plugins, and OAuth parameters. Manage runtime configuration such as SMTP settings and OAuth parameters.
These secrets are stored securely in OpenBao Vault. These secrets are stored securely in OpenBao Vault.
</p> </p>
</div> </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 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>
<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 class="mb-3">
<label class="form-label">Port</label>
<input type="number" class="form-control" id="smtp-port">
</div>
<div class="mb-3">
<label class="form-label">User</label>
<input type="text" class="form-control" id="smtp-user">
</div>
<div class="mb-3">
<label class="form-label">Password</label>
<div class="input-group">
<input type="password" class="form-control" id="smtp-pass">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('smtp-pass')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="mb-3">
<label class="form-label">From Address</label>
<input type="text" class="form-control" id="smtp-from">
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="smtp-secure">
<label class="form-check-label">Use Secure (TLS)</label>
</div>
</div>
</div>
</div>
<div class="col-md-6 mb-4">
<div class="card shadow-sm border-0 h-100">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0">
<h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer">
</div>
<div class="mb-3">
<label class="form-label">JWT Secret</label>
<div class="input-group">
<input type="password" class="form-control" id="oauth-jwtsecret">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</div>
</div>
<div class="mb-3">
<label class="form-label">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access">
</div>
<div class="mb-3">
<label class="form-label">Refresh Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-refresh">
</div> </div>
<div 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> </div>
</div> </div>
+28 -16
View File
@@ -3,7 +3,9 @@
<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">
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs" id="directoryTabs" role="tablist">
<li class="nav-item" role="presentation"> <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"> <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 <i class="fa-solid fa-server"></i> Directory
@@ -16,13 +18,15 @@
</li> </li>
<li class="nav-item" role="presentation"> <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"> <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 <i class="fa-solid fa-robot"></i> Agents & Scheduler
</button> </button>
</li> </li>
</ul> </ul>
</div>
<div class="card-body p-0">
<div class="tab-content" id="directoryTabsContent"> <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="tab-pane fade show active" id="directory-tab-pane" role="tabpanel" aria-labelledby="directory-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-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>
@@ -237,6 +241,8 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<script> <script>
app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']); app.auth.forceLogin(['app_sso_admin', 'app_sso_directory_admin', 'admin']);
@@ -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();
}); });
} }