Files
sso-manager-node/nodejs/plugins/discovery/proxmox.js
T

401 lines
17 KiB
JavaScript

const fetch = require('node-fetch');
const https = require('https');
// Custom agent to bypass self-signed certs typical in Proxmox
const agent = new https.Agent({
rejectUnauthorized: false
});
// Accumulates a guest's NICs, keyed by MAC, merging what several Proxmox
// endpoints each know a piece of: the guest agent knows MAC+IP together, the
// VM/LXC config knows the MAC even while the guest is stopped, and the LXC
// interfaces endpoint knows the DHCP-assigned IP. Keying by MAC is what keeps
// the pairing honest -- the previous code collected MACs and IPs into two flat
// lists and zipped them by index, which mismatched them on any multi-NIC guest.
class Interfaces {
constructor() { this.byMac = new Map(); this.anonymous = []; }
// Interfaces that belong to something running INSIDE the guest -- container
// engines, overlay networks, VPNs -- rather than to the guest itself. A
// Home Assistant VM reported 16 of these (docker0, hassio, 14x veth*)
// alongside its one real NIC, which is noise in the directory and, worse,
// gives the reconciler a pile of 172.x addresses to match unrelated hosts on.
// Only applied to guests; a hypervisor's own bridges are how you reach it.
static VIRTUAL_IFACE_RE = /^(lo|docker\d*|hassio|veth|br-|virbr|tap|fwbr|fwln|fwpr|cni|flannel|cali|kube|weave|zt|tailscale|wg|tun|utun)/i;
static isVirtualName(name) {
return !!name && Interfaces.VIRTUAL_IFACE_RE.test(name);
}
// A udev "predictable" name of the form enx<12 hex> encodes the MAC. It is
// the only place the Proxmox node network API exposes a physical NIC's MAC
// (/nodes/{node}/network carries no hwaddr field at all), so parse it out
// rather than leaving every hypervisor MAC-less.
static macFromIfaceName(name) {
const m = /^enx([0-9a-f]{12})$/i.exec(name || '');
if (!m) return null;
return m[1].toLowerCase().match(/.{2}/g).join(':');
}
static normalizeMac(mac) {
const m = (mac || '').toLowerCase().trim();
if (!/^([0-9a-f]{2}:){5}[0-9a-f]{2}$/.test(m)) return null;
if (m === '00:00:00:00:00:00') return null;
return m;
}
// `ips` are the addresses observed on this one NIC (may be empty for a
// stopped guest, where only the MAC is known).
add(mac, ips, name) {
const key = Interfaces.normalizeMac(mac);
const addrs = (ips || []).filter(Boolean);
if (!key) {
// An IP with no usable MAC is still worth keeping; a NIC with neither is not.
if (addrs.length) this.anonymous.push({ mac: null, ip: addrs[0], ips: addrs, name: name || null });
return;
}
const existing = this.byMac.get(key);
if (existing) {
for (const ip of addrs) if (!existing.ips.includes(ip)) existing.ips.push(ip);
existing.ip = existing.ips[0] || null;
if (!existing.name && name) existing.name = name;
return;
}
this.byMac.set(key, { mac: key, ip: addrs[0] || null, ips: addrs, name: name || null });
}
toArray() { return [...this.byMac.values(), ...this.anonymous]; }
// The address/MAC the directory shows in its single-value columns, and what
// the reconciler matches on. Prefer a NIC that actually has an address.
primaryIp() {
const withIp = this.toArray().find(i => i.ip);
return withIp ? withIp.ip : null;
}
primaryMac() {
const withIp = this.toArray().find(i => i.ip && i.mac);
if (withIp) return withIp.mac;
const first = this.toArray().find(i => i.mac);
return first ? first.mac : null;
}
}
module.exports = {
// Plugin manifest — see nodejs/services/plugin_registry.js. `configSchema`
// drives the admin UI form and validation; fields flagged `secret:true` are
// stored in OpenBao (secret/plugins/<instance-id>/conf), never in the DB.
type: 'proxmox',
category: 'discovery',
name: 'Proxmox VE',
description: 'Discover VMs, containers, and hypervisor nodes from a Proxmox VE API endpoint.',
configSchema: [
{ key: 'url', label: 'API URL', type: 'url', required: true, placeholder: 'https://pve.example:8006' },
{ key: 'tokenId', label: 'Token ID', type: 'text', required: true, placeholder: 'user@pam!token' },
{ key: 'tokenSecret', label: 'Token Secret', type: 'password', required: true, secret: true },
{ key: 'location', label: 'Location / Site (optional)', type: 'site_select', required: false, placeholder: 'Default Site' },
{ key: 'autoPromote', label: 'Auto-promote to Directory', type: 'boolean', required: false, default: false }
],
// "Test" button in the UI: hit the unauthenticated version endpoint with the
// API token to confirm the URL + token are valid before scheduling runs.
validate: async (config) => {
const { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) return { ok: false, error: 'Missing url, tokenId, or tokenSecret' };
try {
const res = await fetch(`${url}/api2/json/version`, { headers: { 'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}` }, agent });
if (!res.ok) return { ok: false, error: `Proxmox API rejected the token (${res.status})` };
return { ok: true };
} catch (err) {
return { ok: false, error: err.message };
}
},
discover: async (config) => {
let { url, tokenId, tokenSecret } = config;
if (!url || !tokenId || !tokenSecret) {
throw new Error("Missing Proxmox config");
}
const headers = {
'Authorization': `PVEAPIToken=${tokenId}=${tokenSecret}`
};
// Ensure URL has no trailing slash
url = url.endsWith('/') ? url.slice(0, -1) : url;
const resources = [];
const edges = [];
// 0. The Proxmox endpoint itself. Without it a multi-node cluster produces
// several unrelated roots in the Directory tree and nothing says where any
// of them came from. Every node discovered below is parented to this, so
// one endpoint == one subtree.
const endpointHost = (() => {
try { return new URL(url).hostname; } catch (e) { return url.replace(/^https?:\/\//, '').split('/')[0]; }
})();
const clusterName = await (async () => {
// /cluster/status names the cluster when one exists; a standalone node
// has no cluster entry, in which case the endpoint hostname is the name.
try {
const res = await fetch(`${url}/api2/json/cluster/status`, { headers, agent });
if (!res.ok) return null;
const entry = ((await res.json()).data || []).find(d => d.type === 'cluster');
return entry ? entry.name : null;
} catch (e) { return null; }
})();
const endpointSlug = `pve-${endpointHost.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')}`;
resources.push({
kind: 'host',
name: clusterName || `Proxmox (${endpointHost})`,
slug: endpointSlug,
metadata: {
subType: 'proxmox',
address: url,
os: 'Proxmox VE',
isProduction: true,
sourceId: url,
// Deliberately NO `ip`/`interfaces`: this resource stands for the
// cluster (the API endpoint), not for a machine. Giving it the address
// it is reached at made the reconciler match it to the very node that
// answers on that address -- the endpoint and the node collapsed into
// one row, which then became its own parent. The cluster is identified
// by slug + sourceId instead, which nothing else can collide with.
interfaces: []
}
});
// 1. Get Nodes
const resNodes = await fetch(`${url}/api2/json/nodes`, { headers, agent });
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) {
// An offline node is still a real hypervisor that belongs in the
// directory -- skipping it entirely used to make it look decommissioned
// and let the reconciler's garbage collector archive it after a week of
// downtime. Record it, mark it down, and skip only the guest enumeration
// (which needs the node to answer).
const online = node.status === 'online';
const nodeSlug = `pve-node-${node.node}`;
// A hypervisor with no address is not actionable. Read its bridges/NICs
// so the node lands in the directory reachable and MAC-identified like
// any other host. Unlike a guest, a node's bridges are kept: vmbrN is
// normally the address you actually reach the hypervisor on.
const nodeIfaces = new Interfaces();
try {
const netRes = online
? await fetch(`${url}/api2/json/nodes/${node.node}/network`, { headers, agent })
: { ok: false };
if (netRes.ok) {
const ifaceList = (await netRes.json()).data || [];
for (const iface of ifaceList) {
if (iface.iface === 'lo') continue;
const ip = iface.address || iface.cidr;
// This endpoint has no hwaddr field, so the MAC has to be recovered
// from a predictable interface name -- either this interface's own
// or, for a bridge, one of the physical ports beneath it.
let mac = Interfaces.macFromIfaceName(iface.iface);
if (!mac) {
for (const alt of (iface.altnames || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
}
if (!mac && iface.bridge_ports) {
for (const port of String(iface.bridge_ports).split(/\s+/).filter(Boolean)) {
mac = Interfaces.macFromIfaceName(port);
if (mac) break;
// The port may itself only carry the MAC in an altname.
const portDef = ifaceList.find(i => i.iface === port);
for (const alt of ((portDef && portDef.altnames) || [])) {
mac = Interfaces.macFromIfaceName(alt);
if (mac) break;
}
if (mac) break;
}
}
nodeIfaces.add(mac || iface.hwaddr, ip ? [String(ip).split('/')[0]] : [], iface.iface);
}
}
} catch (e) {}
resources.push({
kind: 'host',
name: node.node,
slug: nodeSlug,
metadata: {
subType: 'hypervisor',
os: 'Proxmox VE',
isProduction: true,
status: node.status,
sourceId: `${node.node}`,
node: node.node,
interfaces: nodeIfaces.toArray(),
macAddress: nodeIfaces.primaryMac(),
ip: nodeIfaces.primaryIp()
}
});
edges.push({ parentSlug: endpointSlug, childSlug: nodeSlug, relation: 'hosts' });
// Everything below asks the node itself; an offline node answers none of
// it, and its guests are already recorded from previous runs.
if (!online) continue;
// 2. Get VMs for this node
const resVms = await fetch(`${url}/api2/json/nodes/${node.node}/qemu`, { headers, agent });
const vms = resVms.ok ? ((await resVms.json()).data || []) : [];
for (const vm of vms) {
const vmSlug = `vm-${vm.vmid}`;
const isTemplate = vm.template === 1;
const ifaces = new Interfaces();
// Enrich from QEMU guest agent if running. The agent is the only source
// that knows which IP sits on which NIC, so pair them here rather than
// accumulating two flat lists (zipping those by index attributed IPs to
// the wrong MAC on any guest with more than one NIC).
if (vm.status === 'running') {
try {
const agentRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/agent/network-get-interfaces`, { headers, agent });
if (agentRes.ok) {
const agentData = (await agentRes.json()).data;
if (agentData && agentData.result) {
for (const iface of agentData.result) {
// Docker bridges, veth pairs and VPN tunnels are the
// guest's own plumbing, not NICs of the guest.
if (Interfaces.isVirtualName(iface.name)) continue;
const ips = (iface['ip-addresses'] || [])
.filter(ip => ip['ip-address-type'] === 'ipv4' && ip['ip-address'] !== '127.0.0.1')
.map(ip => ip['ip-address']);
ifaces.add(iface['hardware-address'], ips, iface.name);
}
}
}
} catch(e) {}
}
// Enrich from VM config: the MAC is declared there whether or not the
// guest agent answered, so a stopped VM still gets a stable identity.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/qemu/${vm.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
if (confData[`net${i}`]) {
const m = confData[`net${i}`].match(/(?:virtio|e1000e?|rtl8139|vmxnet3)=([0-9a-fA-F:]{17})/);
if(m) ifaces.add(m[1], [], `net${i}`);
}
}
}
} catch(e) {}
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
name: vm.name || `VM ${vm.vmid}`,
slug: vmSlug,
metadata: {
subType: isTemplate ? 'template' : 'vm',
vmid: vm.vmid,
// The Proxmox-side identity, so a resource can be traced back to the
// exact guest on the exact node it was discovered from.
sourceId: `${node.node}/qemu/${vm.vmid}`,
node: node.node,
isProduction: vm.status === 'running',
interfaces,
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: vmSlug, relation: 'hosts' });
}
// 3. Get LXCs for this node
const resLxcs = await fetch(`${url}/api2/json/nodes/${node.node}/lxc`, { headers, agent });
const lxcs = resLxcs.ok ? ((await resLxcs.json()).data || []) : [];
for (const lxc of lxcs) {
const lxcSlug = `lxc-${lxc.vmid}`;
const isTemplate = lxc.template === 1;
const ifaces = new Interfaces();
// Enrich from LXC config. Each netN line carries its own hwaddr and ip,
// so read them off the same line instead of into parallel lists.
try {
const configRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/config`, { headers, agent });
if (configRes.ok) {
const confData = (await configRes.json()).data;
for (let i = 0; i < 10; i++) {
const line = confData[`net${i}`];
if (!line) continue;
const hwMatch = line.match(/hwaddr=([0-9a-fA-F:]{17})/);
// `ip=` is either a CIDR address or the literal `dhcp`/`manual`.
const ipMatch = line.match(/\bip=(\d+\.\d+\.\d+\.\d+)/);
const nameMatch = line.match(/\bname=([^,]+)/);
if (hwMatch || ipMatch) {
ifaces.add(hwMatch && hwMatch[1], ipMatch ? [ipMatch[1]] : [], nameMatch ? nameMatch[1] : `net${i}`);
}
}
}
} catch(e) {}
// A DHCP-configured container has no IP in its config. Ask the running
// container's interface list so it lands in the directory addressable
// instead of as an IP-less row.
if (lxc.status === 'running' && !ifaces.primaryIp()) {
try {
const ifRes = await fetch(`${url}/api2/json/nodes/${node.node}/lxc/${lxc.vmid}/interfaces`, { headers, agent });
if (ifRes.ok) {
for (const iface of ((await ifRes.json()).data || [])) {
if (Interfaces.isVirtualName(iface.name)) continue;
const ip = (iface.inet || '').split('/')[0];
ifaces.add(iface.hwaddr, ip ? [ip] : [], iface.name);
}
}
} catch(e) {}
}
const interfaces = ifaces.toArray();
resources.push({
kind: isTemplate ? 'template' : 'host',
name: lxc.name || `LXC ${lxc.vmid}`,
slug: lxcSlug,
metadata: {
subType: isTemplate ? 'template' : 'lxc',
vmid: lxc.vmid,
sourceId: `${node.node}/lxc/${lxc.vmid}`,
node: node.node,
isProduction: lxc.status === 'running',
interfaces,
macAddress: ifaces.primaryMac(),
ip: ifaces.primaryIp()
}
});
edges.push({ parentSlug: nodeSlug, childSlug: lxcSlug, relation: 'hosts' });
}
}
return { resources, edges };
},
// The generalized plugin contract calls `run`; the discovery plugins keep
// `discover` as their implementation name for back-compat, and `run` is just
// an alias. Referenced via module.exports (not `this`) so it survives being
// detached and called as a bare function reference.
run: async (config) => module.exports.discover(config),
// Exported for unit tests only -- not part of the plugin contract.
_Interfaces: Interfaces
};