From 75b133f6103da55d8456b0dd925ae0adc8c88188 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Sun, 2 Aug 2026 00:16:17 -0400 Subject: [PATCH] feat: Add messaging plugins, Docker discovery, fix reconciliation --- nodejs/config/inventory.sqlite | Bin 49152 -> 49152 bytes nodejs/models/sms.js | 15 +++++ nodejs/plugins/discovery/docker.js | 81 ++++++++++++++++++++++++ nodejs/plugins/discovery/nmap.js | 1 + nodejs/plugins/discovery/proxmox.js | 5 +- nodejs/plugins/messaging/twilio.js | 61 ++++++++++++++++++ nodejs/plugins/messaging/webhook.js | 79 +++++++++++++++++++++++ nodejs/services/discovery_reconciler.js | 19 ++++-- nodejs/utils/vault_broker.js | 1 + nodejs/views/conf.ejs | 39 ++++++++---- nodejs/views/discovery.ejs | 3 + nodejs/views/plugins.ejs | 1 + 12 files changed, 288 insertions(+), 17 deletions(-) create mode 100644 nodejs/plugins/discovery/docker.js create mode 100644 nodejs/plugins/messaging/twilio.js create mode 100644 nodejs/plugins/messaging/webhook.js diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index c643427575e198ee260d95acf1e0017de5c79731..7ca8a6b2b28b7a2f81e4c9968fb23a16d4297fc9 100644 GIT binary patch delta 3867 zcma)9TWl0n7@qAGO1n3W5NW$@x3mP%oaWr-T$%_f2rL(aXn7F=GiT-?xU^AtK|&TD z^oa*&In>`ju9W3kDTcVu6yn_TkR~=jZfGPCI55gp(Wm`my9Jht zDZ$vmw`Pu?K<1>WgvdX2nqn#tRtS?4QIiBuGBu$TEUYEo-45k43|vPs5~&%+n|nKTDj`z0xIDqQ8iG<>0UOqYV3gXbh+%rFRC<^ zG!%&V9!DGtg?u70qSCsquYE#wbYo>Jd1jg>|jwmUtFN7YEn3sQz`Z7&eHdk_p63)N#7Lo+mi8fziymCr12+@li;Lks_2D zTonCVTvKi2^x*L$CqgtG9zPKV?cF5Zq!c$%$4R|0PqEZe1bzTwL*GYSV2qUJF7h~A zp*-TsGatW^*pnX^7m=i7mR;rKfs3 z$?OmKvwVwxzS_99<#c=ADZOdD1;d-xwVV#k!_Lh+UEef*bU~d4N8Sz1iLOj~sCT9k zpm-GIx?XllsasCrqr!Opr~HoGz1(#6YPOvDBXg|pQQw8WvECnhx2^bn#oImq^?cYf zy1cghm1TF99qE49eXe^XeI>oQ>vrl^*Wn8nwp7pZH9b?$PED1|g1AO#LXcD(YS;Jz z8i^vv2=F{+%J-Ox*^hVPx=suiPpay|L8i&lC88#p=vh8me{Q0*uR~8ENd-n+NsAPN zRw#{UDEBn+O!)Y@+f2Xe!tvKu+}WpMG6yaxfvhqc<0Qt+e;~Mk>f%YRlRA-VpkVK{ zkT~E=NL(dOu9FZ`U6kE+`$O~ElhI14PDewZLlOd{L;&qrTMF%%Q50Bd8BxmPLd@o* z+U^GxO&WYnv@%ks^L?Q$3{|9sf(dUzhI|Tim{4l1rkV*kOSg!v8t+b+Ck&k?S{bg> zd7cfJ3Sg|U5EdC?3`{u-bC+9%sp|?%S$jHQlk>za@_!Sq49(GL=30(Dn5Wz#4t7S` zh3fP*7CfZXmE2uKCnl+!bom5v+tpJ8tv29Faqcofn&cutLA<(frY!QguMiOgh8?Fw z`gCbJ)^30|=o)Bc@G;wqWn-CTu=ye@15jt6d^PnX!j>|N4O}e8OVVK$eO`Q~O6CYK z6ZKS92I_KP0TLvz1bIFMD|*n;JXdiP2GXTI6GqFW+{uk(_Gd2@GWqjaC%-Yf zx4>(p^l@KxeR?XD(1sfGV@({G&_f&jaWoF>q>Gf|z-lKAU?h(0q;!X813M_Xp*Rr! z#0AwS)k`ss?8NDVabPEw9f$+x%r=_jw*ELW+OuW6_Qn3%-ZARo&{_tw9rjpQlwr!d na3s_Z;-;p2>PIIpZ%Lxi)WU6Q1cg;d{27!Xt{S+@x)^ixJC2GGi|gT z>@#c-3vlkrxlCKq=Dq_cl+HR9CfpPs54O_&ey~X!K`BZ0m_$c#R}|%H8@lD&y6YKS zn#nLgGHJj@o&zjwQEL0lg;*C)6qlYMdS&9{RqC6gD?wiRtV zvnJ2GduL-{_~p!Um{v+kh@mj;(IqFl7bfQkIq;?@1H1fxiS3jlFNa7UkIRAeFxl#m z15f-NjM{%BgT~~@!(mL09*k9!VI2G%zL#m~zfZ_0lkJ9Ex=m!jRV!Q}j`2ZF6o?fg;MAHSq%8(J}QEi5jNno=YT)%BWm`SST07+46j&T$zk) zOwA>_uQi{)HCYhrlk*?drlLXb%-&zDHZzb~-Iv(GlWW&zva;NfLfMfbmz)hMrCAll zxzcg5HgQ|rnXZZX=~`pz$?FlgKV4U}SJj2|O>JBKRy(C_W}a7n$=p@T>3f;$>ALz( zrdJ(GU)H*1tBz@?p$|`_8}*Yvofx4I5{xOZT+0OraUW3K^MHey{ENc`xuzk`Z 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({ api_username: conf.username, api_password: conf.password, diff --git a/nodejs/plugins/discovery/docker.js b/nodejs/plugins/discovery/docker.js new file mode 100644 index 0000000..978d518 --- /dev/null +++ b/nodejs/plugins/discovery/docker.js @@ -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(); + }); + } +}; diff --git a/nodejs/plugins/discovery/nmap.js b/nodejs/plugins/discovery/nmap.js index 1f6f5fd..6f5b90b 100644 --- a/nodejs/plugins/discovery/nmap.js +++ b/nodejs/plugins/discovery/nmap.js @@ -32,6 +32,7 @@ module.exports = { return new Promise((resolve, reject) => { const scan = new nmap.OsAndPortScan(targetRange); + scan.command.push('-Pn'); scan.on('complete', function(data) { const resources = []; const edges = []; diff --git a/nodejs/plugins/discovery/proxmox.js b/nodejs/plugins/discovery/proxmox.js index 231457e..25c691d 100644 --- a/nodejs/plugins/discovery/proxmox.js +++ b/nodejs/plugins/discovery/proxmox.js @@ -49,7 +49,10 @@ module.exports = { // 1. Get Nodes 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; for (const node of nodes) { diff --git a/nodejs/plugins/messaging/twilio.js b/nodejs/plugins/messaging/twilio.js new file mode 100644 index 0000000..bda6119 --- /dev/null +++ b/nodejs/plugins/messaging/twilio.js @@ -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(); + }); + } +}; diff --git a/nodejs/plugins/messaging/webhook.js b/nodejs/plugins/messaging/webhook.js new file mode 100644 index 0000000..3cbcc03 --- /dev/null +++ b/nodejs/plugins/messaging/webhook.js @@ -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(); + }); + } +}; diff --git a/nodejs/services/discovery_reconciler.js b/nodejs/services/discovery_reconciler.js index cce127c..5decacf 100644 --- a/nodejs/services/discovery_reconciler.js +++ b/nodejs/services/discovery_reconciler.js @@ -12,14 +12,14 @@ class DiscoveryReconciler { 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) { - 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) { const allRes = await Resource.list(); existing = allRes.find(r => 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; // Simple union based on mac or ip 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 }; else existingIntfs.push(ni); } @@ -79,8 +82,14 @@ class DiscoveryReconciler { 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({ - name: res.name || existing.name, + name: bestName, description: res.description || existing.description, metadata: mergedMeta, updated_on: Math.floor(Date.now() / 1000) diff --git a/nodejs/utils/vault_broker.js b/nodejs/utils/vault_broker.js index 80f160b..430c054 100644 --- a/nodejs/utils/vault_broker.js +++ b/nodejs/utils/vault_broker.js @@ -88,6 +88,7 @@ function userPolicyHcl(uid) { // directory itself, so without it the /vault secrets list 403s. 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"] }`; } diff --git a/nodejs/views/conf.ejs b/nodejs/views/conf.ejs index 472e1c3..a3563d5 100644 --- a/nodejs/views/conf.ejs +++ b/nodejs/views/conf.ejs @@ -152,9 +152,25 @@ -
-
-
+ + +
+ +
+
SMTP Settings
@@ -191,8 +207,9 @@
-
-
+ +
+
OAuth & JWT Settings
@@ -220,11 +237,10 @@
-
-
-
-
+ +
+
SMS (VoIP.ms)
@@ -250,8 +266,9 @@
-
-
+ +
+
Terms of Service
diff --git a/nodejs/views/discovery.ejs b/nodejs/views/discovery.ejs index 0456340..05e225b 100644 --- a/nodejs/views/discovery.ejs +++ b/nodejs/views/discovery.ejs @@ -136,6 +136,9 @@ const isManaged = !!(r.metadata && r.metadata.managed); if(managedFilter === 'managed' && !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; }); diff --git a/nodejs/views/plugins.ejs b/nodejs/views/plugins.ejs index 92e15ea..3ce1724 100644 --- a/nodejs/views/plugins.ejs +++ b/nodejs/views/plugins.ejs @@ -140,6 +140,7 @@ schema.forEach(function(f) { if (!includeSecrets && f.secret) return; var val = v[f.key]; + if (f.secret) val = ''; if (val === undefined || val === null) val = ''; var inputType = f.type === 'password' ? 'password' : (f.type === 'url' ? 'url' : 'text'); var req = f.required ? ' required' : '';