diff --git a/nodejs/config/inventory.sqlite b/nodejs/config/inventory.sqlite index c643427..7ca8a6b 100644 Binary files a/nodejs/config/inventory.sqlite and b/nodejs/config/inventory.sqlite differ diff --git a/nodejs/models/sms.js b/nodejs/models/sms.js index 0ccd691..8ee62d7 100644 --- a/nodejs/models/sms.js +++ b/nodejs/models/sms.js @@ -10,6 +10,21 @@ function toE164Digits(number) { } 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({ 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 @@ -