Compare commits

..

1 Commits

Author SHA1 Message Date
wmantly 3ecc153563 chore: bump package.json version to 1.20.0
Pull Request Tests / Run Tests (20.x) (push) Failing after 1m25s
Pull Request Tests / Run Tests (22.x) (push) Failing after 27s
Pull Request Tests / Run Tests (18.x) (push) Failing after 13m44s
Pull Request Tests / Test Summary (push) Failing after 4s
2026-08-03 02:35:04 -04:00
12 changed files with 338 additions and 509 deletions
+67 -71
View File
@@ -1,92 +1,88 @@
---
layout: default
title: Theta Agent & Endpoint Management
title: Discovery Agents
nav_order: 5
---
# Theta Agent & Endpoint Management
# Discovery Agents
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.
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 Agent
## Core Functionality
Agents are simple JavaScript files placed in `nodejs/agents/discovery/`.
### 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`.
A agent must export a single `discover` async function that returns a standardized graph of `resources` and `edges`.
If the agent detects a network IP change, it automatically re-pushes an updated discovery payload to the SSO Manager.
### Agent Skeleton
### 2. Real-Time Telemetry Streaming
Every 30 seconds, the agent streams real-time performance metrics:
- **CPU Load**: System-wide CPU utilization percentage.
- **Memory Utilization**: RAM usage percentage and available memory.
- **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`).
```javascript
// nodejs/agents/discovery/my_custom_agent.js
module.exports = {
discover: async (config) => {
const { url, apiKey } = config; // Provided by your configuration
const resources = [];
const edges = [];
---
// 1. Fetch your data from an API
// const data = await fetch(...);
## Local-First Security & Capability Matrix
// 2. Map data to Resources
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' }
]
}
});
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.
// 3. Map relations to Edges (optional)
edges.push({
parentSlug: 'my-switch-01',
childSlug: 'some-connected-client-slug',
relation: 'connected_to' // 'hosts', 'exposes', 'connected_to'
});
| Capability | Config Key | Risk Level | Description & Impact |
| :--- | :--- | :--- | :--- |
| **Telemetry** | `telemetry` | Safe | Streams read-only system metrics (CPU, RAM, Disk, ZFS, GPU). |
| **Configure LDAP** | `configure_ldap` | Moderate | Writes updated SSSD configuration to `/etc/sssd/sssd.conf` & restarts `sssd`. |
| **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). |
---
## 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>"
return { resources, edges };
}
};
```
### 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:
## Configuration
```bash
curl -fsSL https://<SSO_HOST>/resources/theta-agent/install.sh | sh -s -- "<BASE64_ENCODED_CONFIG>"
Agents are automatically loaded and executed by the internal BullMQ job scheduler. You configure them in your `config/sso-secrets.js`:
```javascript
module.exports = {
// ... existing config ...
discovery: {
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'
}
}
}
};
```
---
## 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
```
## The Reconciliation Engine
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,7 +60,6 @@ backend, that's the niche.
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.
- **[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
Binary file not shown.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.20.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "t42-sso-manager",
"version": "1.20.1",
"version": "1.20.0",
"description": "A very simple LDAP management and SSO system",
"author": [
{
+1 -1
View File
@@ -186,7 +186,7 @@ router.post('/promote/:slug', async (req, res, next) => {
const meta = resource.metadata || {};
meta.managed = true;
await Resource.update(resource.id, { metadata: meta });
await resource.update({ metadata: meta });
res.json(envelope({ success: true, groups: [accessGroup, adminGroup] }));
} catch (err) { next(err); }
+8 -1
View File
@@ -84,7 +84,14 @@ router.get('/discovery', function(req, res, next) {
});
router.get('/plugins', function(req, res, next) {
res.redirect('/directory');
// Plugin instances page — loadable/unloadable, configurable plugin copies
// 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) {
+17 -27
View File
@@ -12,39 +12,32 @@ class DiscoveryReconciler {
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
const allRes = await Resource.list();
// 1. Attempt matching by MAC (highest precision)
// Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
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();
existing = allRes.find(r =>
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
)
r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
);
}
}
// 2. Fallback matching by IP address
// Fallback matching by IP if no MAC match (weaker)
let ipsToMatch = [];
if (res.metadata.interfaces) {
ipsToMatch = res.metadata.interfaces.map(i => i.ip).filter(i => !!i);
}
if (res.metadata.ip) ipsToMatch.push(res.metadata.ip);
if (res.metadata.address) {
res.metadata.address.split(',').forEach(a => ipsToMatch.push(a.trim()));
}
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
const allRes = await Resource.list();
existing = allRes.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
const addrs = r.metadata.address.split(',').map(a => a.trim());
if (addrs.some(a => ipsToMatch.includes(a))) return true;
@@ -53,17 +46,14 @@ class DiscoveryReconciler {
return false;
});
}
// 3. Fallback matching by Slug, Name, or Base Hostname
// Fallback matching by Slug or Name
if (!existing && (res.slug || res.name)) {
const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => {
if (res.slug && r.slug === res.slug) return true;
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;
});
const allRes = await Resource.list();
existing = allRes.find(r =>
(res.slug && r.slug === res.slug) ||
(res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase())
);
}
if (existing) {
+1
View File
@@ -44,6 +44,7 @@ module.exports = {
{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: '/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>/*.
{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']},
+1 -2
View File
@@ -156,12 +156,11 @@ async function mintAppToken(name) {
// client's sso auth headers so OpenBao never sees them.
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';
async function isAdmin(user) {
try {
await permission.byGroup(user, ADMIN_GROUPS);
await permission.byGroup(user, [ADMIN_GROUP]);
return true;
} catch (e) {
return false;
+236 -309
View File
@@ -2,14 +2,10 @@
<script type="text/javascript">
app.auth.forceLogin(['admin', 'app_sso_admin']);
var messagingTypes = {};
var messagingPlugins = [];
$(document).ready(function() {
loadConf();
loadProxyConf();
loadTos();
loadMessagingPlugins();
});
async function loadConf() {
@@ -80,7 +76,7 @@
} catch (error) {
app.messages.toast('Failed to save configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Configuration');
btn.prop('disabled', false).html('<i class="fas fa-save"></i> Save Configuration');
}
}
@@ -91,11 +87,13 @@
return;
}
const btn = $('#btn-test-email');
const $inputGroup = $('#test-email-to').closest('.input-group');
const btn = $inputGroup.find('button');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try {
// First save the SMTP config, then send test email
const payload = {
smtp: {
host: $('#smtp-host').val(),
@@ -106,7 +104,11 @@
secure: $('#smtp-secure').is(':checked')
}
};
// Save config first
await app.api.post('conf', payload);
// Then send test email
const result = await app.api.post('conf/test-email', { to });
app.messages.toast(result.message || 'Test email sent!', 'success');
$('#test-email-to').val('');
@@ -124,11 +126,13 @@
return;
}
const btn = $('#btn-test-sms');
const $inputGroup = $('#test-sms-to').closest('.input-group');
const btn = $inputGroup.find('button');
const originalHtml = btn.html();
btn.prop('disabled', true).html('<i class="fas fa-spinner fa-spin"></i> Sending...');
try {
// First save the VoIP.ms config, then send test SMS
const payload = {
voipms: {
username: $('#voipms-username').val(),
@@ -136,7 +140,11 @@
password: $('#voipms-password').val()
}
};
// Save config first
await app.api.post('conf', payload);
// Then send test SMS
const result = await app.api.post('conf/test-sms', { to });
app.messages.toast(result.message || 'Test SMS sent!', 'success');
$('#test-sms-to').val('');
@@ -193,18 +201,22 @@
} catch (error) {
app.messages.toast('Failed to save Proxy configuration: ' + error.message, 'danger');
} finally {
btn.prop('disabled', false).html('<i class="fas fa-save me-1"></i> Save Proxy Secrets');
btn.prop('disabled', false).html('<i class="fas fa-save"></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() {
try {
const tos = await app.tos.get();
if (tos && tos.content) {
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
}
document.getElementById('tos-content').value = tos.content;
document.getElementById('tos-meta').textContent =
'Last updated ' + moment(tos.updated_on, 'x').fromNow() + ' by ' + tos.updated_by;
} catch(e) {
console.error('Failed to load ToS:', e);
}
@@ -236,332 +248,247 @@
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-3"><i class="fas fa-plug text-secondary mb-2 fs-3"></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>
<div class="container-fluid py-4 px-md-4">
<!-- Page Header -->
<div class="d-flex justify-content-between align-items-center mb-4 pb-3 border-bottom">
<div>
<h3 class="fw-bold mb-1"><i class="fas fa-sliders-h text-primary me-2"></i> System Configuration</h3>
<p class="text-muted mb-0 small">
Manage stack configuration (SMTP, OAuth, VoIP.ms, Proxy, Terms of Service, and Messaging Plugins). Secrets are stored in OpenBao.
</p>
</div>
<div>
<button class="btn btn-outline-secondary me-2" onclick="loadConf()"><i class="fas fa-rotate me-1"></i> Reset</button>
<button id="btn-save" class="btn btn-primary" onclick="saveConf()"><i class="fas fa-save me-1"></i> Save Configuration</button>
<div class="container py-4">
<div class="row mb-4">
<div class="col d-flex justify-content-between align-items-center">
<div>
<h2><i class="fas fa-cogs"></i> System Configuration</h2>
<p class="text-muted mb-0">
Manage runtime configuration such as SMTP, SMS, OAuth, and Terms of Service
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>
<div class="row g-4">
<!-- Sidebar Navigation -->
<div class="col-lg-3 col-md-4">
<div class="card shadow-sm border-0 sticky-top" style="top: var(--sw-content-offset, 70px);">
<div class="list-group list-group-flush rounded-3" id="conf-nav-list" role="tablist">
<a class="list-group-item list-group-item-action active d-flex align-items-center py-3" id="tab-nav-oauth" data-bs-toggle="list" href="#pane-oauth" role="tab">
<i class="fas fa-key text-success me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">OAuth & JWT</div>
<div class="small text-muted">Issuer & Token Lifetimes</div>
<ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
<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>
</li>
</ul>
<div class="tab-content" id="confTabsContent">
<!-- SMTP Tab -->
<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>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-smtp" data-bs-toggle="list" href="#pane-smtp" role="tab">
<i class="fas fa-envelope text-primary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Email (SMTP)</div>
<div class="small text-muted">Mail Delivery & Testing</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>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-sms" data-bs-toggle="list" href="#pane-sms" role="tab">
<i class="fas fa-comment-sms text-info me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">SMS & Messaging</div>
<div class="small text-muted">VoIP.ms & Webhook Plugins</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>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-proxy" data-bs-toggle="list" href="#pane-proxy" role="tab">
<i class="fas fa-shield-alt text-warning me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Proxy Secrets</div>
<div class="small text-muted">OpenBao Integration</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>
</a>
<a class="list-group-item list-group-item-action d-flex align-items-center py-3" id="tab-nav-tos" data-bs-toggle="list" href="#pane-tos" role="tab">
<i class="fas fa-file-contract text-secondary me-3 fs-5" style="width: 24px;"></i>
<div>
<div class="fw-semibold">Terms of Service</div>
<div class="small text-muted">User Agreement & Policy</div>
</div>
</a>
</div>
</div>
</div>
<!-- Main Content Panes -->
<div class="col-lg-9 col-md-8">
<div class="tab-content" id="conf-tab-content">
<!-- OAuth Pane -->
<div class="tab-pane fade show active" id="pane-oauth" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-key text-success me-2"></i> OAuth 2.0 & JWT Settings</h5>
</div>
<div class="card-body p-4">
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="oauth-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label fw-semibold">JWT Secret</label>
<div class="input-group">
<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>
</div>
<div class="form-text">Stored in OpenBao. Leave unchanged to preserve stored value.</div>
</div>
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Access Token Lifetime (seconds)</label>
<input type="number" class="form-control" id="oauth-token-access" placeholder="3600">
</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>
<!-- OAuth Tab -->
<div class="tab-pane fade" id="oauth" 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-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" placeholder="********">
<button class="btn btn-outline-secondary" type="button" onclick="togglePassword('oauth-jwtsecret')"><i class="fas fa-eye"></i></button>
</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>
<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>
<!-- SMTP Pane -->
<div class="tab-pane fade" id="pane-smtp" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-envelope text-primary me-2"></i> SMTP Server Settings</h5>
<!-- SMS Tab -->
<div class="tab-pane fade" id="sms" 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-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
</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">
<label class="form-label">API Username</label>
<input type="text" class="form-control" id="voipms-username">
</div>
<div class="mb-3">
<label class="form-label">DID (sender number)</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
<div class="mb-3">
<label class="form-label">API Password</label>
<div class="input-group">
<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>
</div>
<div class="card-body p-4">
<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 class="mb-3">
<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 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>
</div>
</div>
<!-- SMS & Messaging Pane -->
<div class="tab-pane fade" id="pane-sms" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-comment-sms text-info me-2"></i> VoIP.ms SMS Integration</h5>
</div>
<div class="card-body p-4">
<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">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">DID Sender Number</label>
<input type="text" class="form-control" id="voipms-did" placeholder="15551234567">
</div>
</div>
<div class="mb-3">
<label class="form-label fw-semibold">API Password</label>
<div class="input-group">
<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>
</div>
</div>
<div class="p-3 bg-light rounded border mb-4">
<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">
<input type="tel" class="form-control" id="test-sms-to" placeholder="+15551234567">
<button id="btn-test-sms" class="btn btn-outline-info" type="button" onclick="sendTestSms()">
<i class="fas fa-paper-plane me-1"></i> Send Test SMS
</button>
</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 id="messaging-plugins-list"></div>
<!-- Proxy Secrets Tab -->
<div class="tab-pane fade" id="proxy" 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-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>
<div class="mb-3">
<label class="form-label">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="mb-3">
<label class="form-label">Client ID</label>
<input type="text" class="form-control" id="proxy-client-id">
</div>
<div class="mb-3">
<label class="form-label">Client Secret</label>
<div class="input-group">
<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>
</div>
</div>
</div>
<!-- Proxy Secrets Pane -->
<div class="tab-pane fade" id="pane-proxy" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom">
<h5 class="mb-0 fw-bold"><i class="fas fa-shield-alt text-warning me-2"></i> OpenBao Proxy Integration</h5>
</div>
<div class="card-body p-4">
<p class="text-muted small">Secrets stored directly in OpenBao (<code>secret/proxy/conf</code>) and consumed by Proxy at boot.</p>
<h6 class="fw-bold text-dark mt-3 mb-2">OAuth / OIDC Client</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Issuer URL</label>
<input type="text" class="form-control" id="proxy-issuer" placeholder="https://sso.example.com">
</div>
<div class="row">
<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">
</div>
<div class="col-md-6 mb-3">
<label class="form-label fw-semibold">Client Secret</label>
<div class="input-group">
<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>
</div>
</div>
</div>
<h6 class="fw-bold text-dark mt-4 mb-2">LDAP Bind Account</h6>
<div class="mb-3">
<label class="form-label fw-semibold">Proxy Bind Password</label>
<div class="input-group">
<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>
</div>
</div>
<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>
<h6 class="mt-4 mb-2">LDAP Integration</h6>
<div class="mb-3">
<label class="form-label">Bind Password</label>
<div class="input-group">
<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>
</div>
<div class="form-text">Password for the Proxy's LDAP service account.</div>
</div>
</div>
<!-- Terms of Service Pane -->
<div class="tab-pane fade" id="pane-tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white pt-4 pb-3 border-bottom d-flex justify-content-between align-items-center">
<h5 class="mb-0 fw-bold"><i class="fas fa-file-contract me-2"></i> Terms of Service Editor</h5>
<span class="small text-muted" id="tos-meta"></span>
</div>
<div class="card-body p-4">
<div class="mb-3">
<label class="form-label fw-semibold">Terms Content (Markdown)</label>
<textarea class="form-control font-monospace" id="tos-content" rows="10" placeholder="Enter Terms of Service markdown content..."></textarea>
</div>
<div class="form-check mb-4">
<input class="form-check-input" type="checkbox" id="tos-reset-acceptance">
<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>
<button id="btn-save-proxy" class="btn btn-warning mt-2" onclick="saveProxyConf()"><i class="fas fa-save"></i> Save Proxy Secrets</button>
</div>
</div>
</div>
<!-- ToS Tab -->
<div class="tab-pane fade" id="tos" role="tabpanel">
<div class="card shadow-sm border-0 mb-4">
<div class="card-header bg-white border-bottom-0 pt-4 pb-0 d-flex justify-content-between align-items-center">
<h5 class="mb-0"><i class="fas fa-file-contract me-2"></i> Terms of Service</h5>
<small class="text-muted" id="tos-meta"></small>
</div>
<div class="card-body">
<div class="mb-3">
<label class="form-label">Content <small class="text-muted">(Markdown)</small></label>
<textarea class="form-control" id="tos-content" rows="8"></textarea>
</div>
<div class="form-check mb-3">
<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>
</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>
+5 -95
View File
@@ -13,12 +13,7 @@
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="discovery-tab" data-bs-toggle="tab" data-bs-target="#discovery-tab-pane" type="button" role="tab" aria-controls="discovery-tab-pane" aria-selected="false">
<i class="fa-solid fa-network-wired"></i> 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
<i class="fa-solid fa-network-wired"></i> Discovery
</button>
</li>
</ul>
@@ -192,20 +187,6 @@
</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>
<button class="btn btn-sm btn-outline-primary" onclick="loadDiscoveryPlugins()"><i class="fas fa-rotate me-1"></i> Refresh</button>
</div>
<div id="discovery-plugins-list" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
@@ -1207,7 +1188,6 @@
allEdges.push(res.results);
refreshEdgesUI(resourceId);
$('#new-edge-target').val('');
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to add edge', app.modal.body(), 'danger');
@@ -1219,7 +1199,6 @@
await app.api.delete('directory-admin/edges/' + id);
allEdges = allEdges.filter(e => e.id !== id);
refreshEdgesUI($('#res-id').val());
await loadData();
} catch (err) {
console.error(err);
app.messages.action('Failed to remove edge', app.modal.body(), 'danger');
@@ -1514,7 +1493,7 @@
`;
app.modal.open({
title: 'Install Theta Agent',
title: '<i class="fa-solid fa-shield-halved text-primary me-2"></i> Install Theta Agent',
bodyHtml: bodyHtml,
size: 'lg'
});
@@ -1522,81 +1501,12 @@
updateAgentCommands();
}
var discoveryPlugins = [];
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');
}
}
async function deleteDiscoveryPlugin(id) {
const ok = await app.messages.confirm('Are you sure you want to delete this discovery plugin?');
if (!ok) return;
try {
await app.api.delete(`plugins/${id}`);
app.messages.toast('Discovery plugin deleted', 'success');
loadDiscoveryPlugins();
} catch (e) {
app.messages.toast('Error deleting plugin: ' + e.message, 'danger');
}
}
// Plugin scheduling moved to the dedicated /plugins page (the Agents &
// Scheduler tab here was its old home). Discovery inventory + the discovery
// results table remain on this page.
$(document).ready(function(){
loadDiscoveryResources();
loadDiscoveryPlugins();
});
</script>