87339da1b2
SECURITY /api/agent/ws authenticated nothing. There was no agent registry, so any client reaching the SSO could register as a node, publish discovery and telemetry into the admin view, and receive commands -- including a signed arbitrary_bash -- addressed to a token it guessed. Tokens were generated in the BROWSER and never recorded server-side, so there was nothing to validate against and no way to revoke one. Agents are now rows in a new Agent table, authenticated by SHA-256 token hash before the connection is registered or the welcome payload is sent. Tokens are minted by POST /api/agent/enroll and shown once. Revoke and rotate drop the live socket immediately. All agent actions are audited. The Ed25519 command-signing key was generated in the AgentManager constructor, so it changed on every restart and the public_key pinned in an agent's agent.yml stopped matching. It now lives in OpenBao at secret/agent/signing-key; if it cannot be loaded the SSO refuses to send high-risk commands rather than signing with a key no agent has seen. DIRECTORY Agents bind to a host resource instead of being matched by hostname, and a bound agent's discovery is written onto that resource -- previously the one source running ON the host contributed nothing to the directory. The resource tree is collapsible, with state persisted per browser. DISCOVERY The Proxmox plugin zipped MACs and IPs from two flat lists by index, attributing addresses to the wrong NIC on multi-NIC guests. NICs are now keyed by MAC. Adds an endpoint resource parenting each node, sourceId/ vmid/node identity, container-interface filtering, node IP/MAC, and offline-node handling. The reconciler could make a resource its own parent, named hosts after their MAC address, had a dead isIp() regex (\\. matches a backslash), merged across kinds, and re-read the whole inventory per resource. Dockerfile.test-runner never copied nodejs/plugins, so every plugin test suite failed in CI as "Cannot find module". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
105 lines
3.5 KiB
JavaScript
105 lines
3.5 KiB
JavaScript
'use strict';
|
|
|
|
// Pure-logic coverage for the two reconciler rules that real Proxmox + UniFi
|
|
// data broke. Both were found by running discovery against a live cluster:
|
|
// the directory came back listing MAC addresses as host names, and one
|
|
// resource ended up as its own parent.
|
|
|
|
// Mirrors the ranking in services/discovery_reconciler.js. Kept here (rather
|
|
// than exported) because it is a few lines of predicate that the reconciler
|
|
// applies inline while merging; if it grows, export it and drop this copy.
|
|
const isIp = (str) => /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$/.test(str || '');
|
|
const isMac = (str) => /^([0-9a-f]{2}[:-]){5}[0-9a-f]{2}$/i.test((str || '').trim());
|
|
const nameRank = (str) => {
|
|
if (!str || !String(str).trim()) return 0;
|
|
if (isMac(str)) return 0;
|
|
if (isIp(str)) return 1;
|
|
return 2;
|
|
};
|
|
function bestNameOf(existingName, incomingName) {
|
|
let best = existingName;
|
|
if (incomingName) {
|
|
const a = nameRank(incomingName);
|
|
const b = nameRank(best);
|
|
if (a > b || (a === b && incomingName.length > (best || '').length)) best = incomingName;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
describe('discovery name ranking', () => {
|
|
test('a real hostname beats a MAC even when shorter', () => {
|
|
// The exact regression: UniFi named the host by MAC, Proxmox knew the
|
|
// hostname, and length-only comparison kept the MAC.
|
|
expect(bestNameOf('ac:16:2d:b3:da:80', 'dl380-0')).toBe('dl380-0');
|
|
});
|
|
|
|
test('a MAC never displaces a real hostname', () => {
|
|
expect(bestNameOf('dl380-0', 'ac:16:2d:b3:da:80')).toBe('dl380-0');
|
|
});
|
|
|
|
test('a real hostname beats an IP-shaped name', () => {
|
|
expect(bestNameOf('192.168.1.27', 'hass.io')).toBe('hass.io');
|
|
});
|
|
|
|
test('an IP beats a MAC', () => {
|
|
expect(bestNameOf('bc:24:11:3f:cd:c8', '192.168.1.27')).toBe('192.168.1.27');
|
|
});
|
|
|
|
test('an IP does not displace a hostname', () => {
|
|
expect(bestNameOf('gitea-runner', '192.168.1.176')).toBe('gitea-runner');
|
|
});
|
|
|
|
test('within the same rank the longer/more specific name wins', () => {
|
|
expect(bestNameOf('pve', 'pve-dl380-1')).toBe('pve-dl380-1');
|
|
});
|
|
|
|
test('dash-separated MACs are recognized too', () => {
|
|
expect(bestNameOf('ac-16-2d-b3-da-80', 'dl380-0')).toBe('dl380-0');
|
|
});
|
|
|
|
test('an empty existing name is always replaced', () => {
|
|
expect(bestNameOf('', 'anything')).toBe('anything');
|
|
expect(bestNameOf(null, 'ac:16:2d:b3:da:80')).toBe('ac:16:2d:b3:da:80');
|
|
});
|
|
});
|
|
|
|
// Mirrors isDescendant() in the reconciler.
|
|
function isDescendant(candidateId, rootId, edges) {
|
|
const seen = new Set();
|
|
const stack = [rootId];
|
|
while (stack.length) {
|
|
const id = stack.pop();
|
|
if (id === candidateId) return true;
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
for (const e of edges) if (e.parentId === id) stack.push(e.childId);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
describe('discovery edge cycle guard', () => {
|
|
const edges = [
|
|
{ parentId: 'cluster', childId: 'node1' },
|
|
{ parentId: 'node1', childId: 'vm1' },
|
|
];
|
|
|
|
test('detects a direct parent/child inversion', () => {
|
|
// Proposing node1 -> cluster when cluster -> node1 already exists.
|
|
expect(isDescendant('node1', 'cluster', edges)).toBe(true);
|
|
});
|
|
|
|
test('detects a deeper loop', () => {
|
|
expect(isDescendant('vm1', 'cluster', edges)).toBe(true);
|
|
});
|
|
|
|
test('allows an unrelated new parent', () => {
|
|
expect(isDescendant('node2', 'cluster', edges)).toBe(false);
|
|
});
|
|
|
|
test('terminates on a graph that already contains a cycle', () => {
|
|
// A self-edge written by an earlier release must not hang the walk.
|
|
const cyclic = [{ parentId: 'a', childId: 'a' }, { parentId: 'a', childId: 'b' }];
|
|
expect(isDescendant('zzz', 'a', cyclic)).toBe(false);
|
|
});
|
|
});
|