Compare commits

..

2 Commits

Author SHA1 Message Date
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
7 changed files with 191 additions and 52 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.
+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>
+18 -12
View File
@@ -16,7 +16,7 @@
</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>
@@ -186,22 +186,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="card shadow border-top-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 +225,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>
@@ -1319,11 +1319,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 +1339,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 +1351,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();
}); });
} }