feat: Add messaging plugins, Docker discovery, fix reconciliation

This commit is contained in:
2026-08-02 00:16:17 -04:00
parent f1d52601de
commit 75b133f610
12 changed files with 288 additions and 17 deletions
Binary file not shown.
+15
View File
@@ -10,6 +10,21 @@ function toE164Digits(number) {
} }
async function send(to, message) { async function send(to, message) {
const { PluginInstance } = require('./plugin_instance');
const registry = require('../services/plugin_registry');
const pluginSecrets = require('../utils/plugin_secrets');
const instances = await PluginInstance.find({ category: 'messaging', enabled: true });
if (instances.length > 0) {
const inst = instances[0];
const manifest = registry.getManifest(inst.pluginType);
if (manifest && manifest.sendMessage) {
const secrets = await pluginSecrets.read(inst.id).catch(() => ({}));
const config = { ...inst.config, ...secrets };
return manifest.sendMessage(config, { to, message });
}
}
const params = new URLSearchParams({ const params = new URLSearchParams({
api_username: conf.username, api_username: conf.username,
api_password: conf.password, api_password: conf.password,
+81
View File
@@ -0,0 +1,81 @@
const http = require('http');
module.exports = {
type: 'docker',
category: 'discovery',
name: 'Docker Daemon',
description: 'Discover running containers and networks from a local or remote Docker daemon.',
configSchema: [
{ key: 'socketPath', label: 'Docker Socket Path', type: 'text', required: false, placeholder: '/var/run/docker.sock' },
{ key: 'tcpHost', label: 'TCP Host (e.g., http://10.0.0.1:2375)', type: 'url', required: false, placeholder: '' }
],
validate: async (config) => {
if (!config.socketPath && !config.tcpHost) {
return { ok: false, error: 'Must provide either socketPath or tcpHost' };
}
return { ok: true };
},
discover: async (config) => {
const isTcp = !!config.tcpHost;
const requestOptions = {
path: '/containers/json',
method: 'GET'
};
if (isTcp) {
const url = new URL(config.tcpHost);
requestOptions.host = url.hostname;
requestOptions.port = url.port || (url.protocol === 'https:' ? 443 : 80);
requestOptions.protocol = url.protocol;
} else {
requestOptions.socketPath = config.socketPath || '/var/run/docker.sock';
}
return new Promise((resolve, reject) => {
const req = http.request(requestOptions, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode !== 200) {
return reject(new Error(`Docker API error: ${res.statusCode} ${body}`));
}
try {
const containers = JSON.parse(body);
const resources = [];
const edges = [];
for (const c of containers) {
const name = c.Names && c.Names.length > 0 ? c.Names[0].replace(/^\\//, '') : c.Id.substring(0, 12);
const slug = `docker-cnt-${c.Id.substring(0, 12)}`;
const ports = (c.Ports || []).map(p => p.PublicPort ? `${p.PublicPort}:${p.PrivatePort}` : `${p.PrivatePort}`).join(', ');
resources.push({
kind: 'container',
name: name,
slug: slug,
metadata: {
image: c.Image,
state: c.State,
status: c.Status,
ports: ports
}
});
}
resolve({ resources, edges });
} catch (e) {
reject(new Error(`Failed to parse Docker response: ${e.message}`));
}
});
});
req.on('error', (e) => reject(new Error(`Docker connection error: ${e.message}`)));
req.end();
});
}
};
+1
View File
@@ -32,6 +32,7 @@ module.exports = {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const scan = new nmap.OsAndPortScan(targetRange); const scan = new nmap.OsAndPortScan(targetRange);
scan.command.push('-Pn');
scan.on('complete', function(data) { scan.on('complete', function(data) {
const resources = []; const resources = [];
const edges = []; const edges = [];
+4 -1
View File
@@ -49,7 +49,10 @@ module.exports = {
// 1. Get Nodes // 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent }); const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
if(!resNodes.ok) throw new Error("Proxmox API error on nodes"); if(!resNodes.ok) {
const errText = await resNodes.text();
throw new Error(`Proxmox API error on nodes: ${resNodes.status} ${errText}`);
}
const nodes = (await resNodes.json()).data; const nodes = (await resNodes.json()).data;
for (const node of nodes) { for (const node of nodes) {
+61
View File
@@ -0,0 +1,61 @@
const https = require('https');
module.exports = {
type: 'twilio',
category: 'messaging',
name: 'Twilio SMS',
description: 'Send SMS messages (like 2FA codes) via Twilio.',
configSchema: [
{ key: 'accountSid', label: 'Account SID', type: 'text', required: true },
{ key: 'authToken', label: 'Auth Token', type: 'password', required: true, secret: true },
{ key: 'fromNumber', label: 'From Phone Number', type: 'text', required: true, placeholder: '+15551234567' }
],
validate: async (config) => {
if (!config.accountSid || !config.authToken) return { ok: false, error: 'Missing credentials' };
if (!config.fromNumber) return { ok: false, error: 'Missing fromNumber' };
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
if (!to || !message) throw new Error("Missing 'to' or 'message' in payload");
const data = new URLSearchParams();
data.append('To', to);
data.append('From', config.fromNumber);
data.append('Body', message);
const postData = data.toString();
const options = {
hostname: 'api.twilio.com',
port: 443,
path: `/2010-04-01/Accounts/${config.accountSid}/Messages.json`,
method: 'POST',
headers: {
'Authorization': 'Basic ' + Buffer.from(config.accountSid + ':' + config.authToken).toString('base64'),
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(postData)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(JSON.parse(body));
} else {
reject(new Error(`Twilio API Error: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(postData);
req.end();
});
}
};
+79
View File
@@ -0,0 +1,79 @@
const https = require('https');
const http = require('http');
module.exports = {
type: 'webhook',
category: 'messaging',
name: 'Universal REST Webhook',
description: 'Send a generic HTTP POST request with a custom JSON payload. Variables {{to}} and {{message}} will be replaced.',
configSchema: [
{ key: 'url', label: 'Webhook URL', type: 'url', required: true, placeholder: 'https://api.example.com/send' },
{ key: 'method', label: 'HTTP Method', type: 'text', required: true, placeholder: 'POST' },
{ key: 'headers', label: 'Custom Headers (JSON)', type: 'text', required: false, placeholder: '{"Authorization": "Bearer ...", "Content-Type": "application/json"}' },
{ key: 'payloadTemplate', label: 'Payload Template', type: 'text', required: true, placeholder: '{"recipient": "{{to}}", "text": "{{message}}"}' },
{ key: 'apiSecret', label: 'API Secret / Auth Token', type: 'password', required: false, secret: true }
],
validate: async (config) => {
if (!config.url) return { ok: false, error: 'URL is required' };
if (!config.payloadTemplate) return { ok: false, error: 'Payload template is required' };
try {
if (config.headers) JSON.parse(config.headers);
} catch (e) {
return { ok: false, error: 'Headers must be valid JSON' };
}
return { ok: true };
},
sendMessage: async (config, payload) => {
const { to, message } = payload;
let payloadStr = config.payloadTemplate || '{}';
// Replace template variables safely
payloadStr = payloadStr.replace(/\{\{to\}\}/g, to).replace(/\{\{message\}\}/g, message);
// If there is an API secret, replace {{secret}} in the headers or url
let headersObj = {};
if (config.headers) {
try {
const parsed = JSON.parse(config.headers);
for (const [k, v] of Object.entries(parsed)) {
headersObj[k] = config.apiSecret ? String(v).replace(/\{\{secret\}\}/g, config.apiSecret) : v;
}
} catch(e) {}
}
if (!headersObj['Content-Type']) {
headersObj['Content-Type'] = 'application/json';
}
const urlObj = new URL(config.url);
const options = {
hostname: urlObj.hostname,
port: urlObj.port || (urlObj.protocol === 'https:' ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: config.method || 'POST',
headers: headersObj
};
const client = urlObj.protocol === 'https:' ? https : http;
return new Promise((resolve, reject) => {
const req = client.request(options, (res) => {
let body = '';
res.on('data', chunk => body += chunk);
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve({ status: res.statusCode, body });
} else {
reject(new Error(`Webhook failed: ${res.statusCode} ${body}`));
}
});
});
req.on('error', reject);
req.write(payloadStr);
req.end();
});
}
};
+14 -5
View File
@@ -12,14 +12,14 @@ class DiscoveryReconciler {
let existing = null; let existing = null;
// Attempt matching by MAC if available // Attempt matching by MAC if available (case-insensitive)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) { if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => i.mac).filter(m => !!m); const macs = res.metadata.interfaces.map(i => i.mac ? i.mac.toLowerCase() : null).filter(m => !!m);
if (macs.length > 0) { if (macs.length > 0) {
const allRes = await Resource.list(); const allRes = await Resource.list();
existing = allRes.find(r => existing = allRes.find(r =>
r.metadata && r.metadata.interfaces && r.metadata && r.metadata.interfaces &&
r.metadata.interfaces.some(i => macs.includes(i.mac)) r.metadata.interfaces.some(i => i.mac && macs.includes(i.mac.toLowerCase()))
); );
} }
} }
@@ -65,7 +65,10 @@ class DiscoveryReconciler {
const newIntfs = res.metadata.interfaces; const newIntfs = res.metadata.interfaces;
// Simple union based on mac or ip // Simple union based on mac or ip
for (const ni of newIntfs) { for (const ni of newIntfs) {
const idx = existingIntfs.findIndex(ei => (ni.mac && ei.mac === ni.mac) || (ni.ip && ei.ip === ni.ip)); const idx = existingIntfs.findIndex(ei =>
(ni.mac && ei.mac && ei.mac.toLowerCase() === ni.mac.toLowerCase()) ||
(ni.ip && ei.ip && ei.ip === ni.ip)
);
if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni }; if (idx >= 0) existingIntfs[idx] = { ...existingIntfs[idx], ...ni };
else existingIntfs.push(ni); else existingIntfs.push(ni);
} }
@@ -79,8 +82,14 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now(); mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
}
await existing.update({ await existing.update({
name: res.name || existing.name, name: bestName,
description: res.description || existing.description, description: res.description || existing.description,
metadata: mergedMeta, metadata: mergedMeta,
updated_on: Math.floor(Date.now() / 1000) updated_on: Math.floor(Date.now() / 1000)
+1
View File
@@ -88,6 +88,7 @@ function userPolicyHcl(uid) {
// directory itself, so without it the /vault secrets list 403s. // directory itself, so without it the /vault secrets list 403s.
return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] } return `path "secret/data/users/${uid}/*" { capabilities = ["create", "read", "update", "delete", "list"] }
path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] } path "secret/metadata/users/${uid}" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/" { capabilities = ["list", "read", "delete"] }
path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`; path "secret/metadata/users/${uid}/*" { capabilities = ["list", "read", "delete"] }`;
} }
+28 -11
View File
@@ -152,9 +152,25 @@
</div> </div>
</div> </div>
<div class="row"> <ul class="nav nav-tabs mb-4" id="confTabs" role="tablist">
<div class="col-md-6 mb-4"> <li class="nav-item" role="presentation">
<div class="card shadow-sm border-0 h-100"> <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>
</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"> <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> <h5 class="mb-0"><i class="fas fa-envelope text-primary me-2"></i> SMTP Settings</h5>
</div> </div>
@@ -191,8 +207,9 @@
</div> </div>
</div> </div>
<div class="col-md-6 mb-4"> <!-- OAuth Tab -->
<div class="card shadow-sm border-0 h-100"> <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"> <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> <h5 class="mb-0"><i class="fas fa-key text-success me-2"></i> OAuth & JWT Settings</h5>
</div> </div>
@@ -220,11 +237,10 @@
</div> </div>
</div> </div>
</div> </div>
</div>
<div class="row"> <!-- SMS Tab -->
<div class="col-md-6 mb-4"> <div class="tab-pane fade" id="sms" role="tabpanel">
<div class="card shadow-sm border-0 h-100"> <div class="card shadow-sm border-0 mb-4">
<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"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5> <h5 class="mb-0"><i class="fas fa-comment text-info me-2"></i> SMS (VoIP.ms)</h5>
</div> </div>
@@ -250,8 +266,9 @@
</div> </div>
</div> </div>
<div class="col-md-6 mb-4"> <!-- ToS Tab -->
<div class="card shadow-sm border-0 h-100"> <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"> <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> <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> <small class="text-muted" id="tos-meta"></small>
+3
View File
@@ -136,6 +136,9 @@
const isManaged = !!(r.metadata && r.metadata.managed); const isManaged = !!(r.metadata && r.metadata.managed);
if(managedFilter === 'managed' && !isManaged) return false; if(managedFilter === 'managed' && !isManaged) return false;
if(managedFilter === 'unmanaged' && isManaged) return false; if(managedFilter === 'unmanaged' && isManaged) return false;
const isAuto = r.metadata && r.metadata.discovery_sources && r.metadata.discovery_sources.length > 0 && !r.metadata.discovery_sources.includes('manual');
if(!isAuto) return false;
return true; return true;
}); });
+1
View File
@@ -140,6 +140,7 @@
schema.forEach(function(f) { schema.forEach(function(f) {
if (!includeSecrets && f.secret) return; if (!includeSecrets && f.secret) return;
var val = v[f.key]; var val = v[f.key];
if (f.secret) val = '';
if (val === undefined || val === null) val = ''; if (val === undefined || val === null) val = '';
var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text');
var req = f.required ? ' required' : ''; var req = f.required ? ' required' : '';