sec: authenticate theta-agent enrollment; directory + discovery fixes (v1.29.0)
Pull Request Tests / Run Tests (18.x) (push) Failing after 1m30s
Pull Request Tests / Run Tests (20.x) (push) Successful in 23s
Pull Request Tests / Run Tests (22.x) (push) Failing after 37s
Pull Request Tests / Test Summary (push) Failing after 4s

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>
This commit is contained in:
2026-08-05 18:44:37 -04:00
parent 49100c9b68
commit 87339da1b2
19 changed files with 1810 additions and 286 deletions
+102 -14
View File
@@ -2,26 +2,64 @@ const { Resource, ResourceEdge, ResourceGroup } = require('../models/resource');
const { WebhookEmitter } = require('./webhook_emitter');
const crypto = require('crypto');
// Is `candidateId` at or below `rootId` in the edge graph? Used to refuse an
// edge that would close a loop. Carries its own visited set so it terminates
// even if the stored graph already contains a cycle from an older release.
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;
}
class DiscoveryReconciler {
static async reconcile(sourceName, payload) {
const { resources = [], edges = [] } = payload;
let newDevices = 0;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
// Read the inventory ONCE, not once per incoming resource. A Proxmox
// cluster reports ~55 resources against an inventory of similar size, so
// the per-iteration Resource.list() was doing quadratic full-table reads
// every discovery run. Newly created rows are pushed onto this list as we
// go, so later resources in the same payload still match against them.
const allRes = await Resource.list();
for (const res of resources) {
if (!res.metadata) res.metadata = {};
res._originalSlug = res.slug; // Keep track for edge mapping
let existing = null;
const normalizeMac = (m) => (m || '').toLowerCase().replace(/[^a-f0-9]/g, '');
const normalizeHost = (h) => (h || '').toLowerCase().split('.')[0].trim();
const allRes = await Resource.list();
let existing = null;
// A discovered device may only merge into a resource of the same kind
// (or into a placeholder from an earlier, kind-less discovery). Without
// this a VM called "gitea-runner" matches a hand-created *service* of
// the same name on rule 3 and silently overwrites it -- the discovered
// host's metadata lands on a service row, and the operator's entry is
// gone. `template` counts as `host`: a VM converted to a template is the
// same device, and it should update in place rather than fork a row.
const kindClass = (k) => (k === 'template' ? 'host' : k);
const incomingKind = kindClass(res.kind || 'unmanaged_device');
const kindCompatible = (r) => {
const k = kindClass(r.kind);
if (k === 'unmanaged_device' || incomingKind === 'unmanaged_device') return true;
return k === incomingKind;
};
const candidates = allRes.filter(kindCompatible);
// 1. Attempt matching by MAC (highest precision)
if (res.metadata.interfaces && res.metadata.interfaces.length > 0) {
const macs = res.metadata.interfaces.map(i => normalizeMac(i.mac)).filter(m => m.length === 12);
if (macs.length > 0) {
existing = allRes.find(r =>
existing = candidates.find(r =>
r.metadata && (
(r.metadata.macAddress && macs.includes(normalizeMac(r.metadata.macAddress))) ||
(r.metadata.interfaces && r.metadata.interfaces.some(i => macs.includes(normalizeMac(i.mac))))
@@ -42,7 +80,7 @@ class DiscoveryReconciler {
ipsToMatch = [...new Set(ipsToMatch.filter(Boolean))];
if (!existing && ipsToMatch.length > 0) {
existing = allRes.find(r => {
existing = candidates.find(r => {
if (!r.metadata) return false;
if (r.metadata.ip && ipsToMatch.includes(r.metadata.ip)) return true;
if (r.metadata.address) {
@@ -57,7 +95,7 @@ class DiscoveryReconciler {
// 3. Fallback matching by Slug, Name, or Base Hostname
if (!existing && (res.slug || res.name)) {
const inputName = normalizeHost(res.name || res.slug);
existing = allRes.find(r => {
existing = candidates.find(r => {
if (res.slug && r.slug === res.slug) return true;
if (res.name && r.name && r.name.toLowerCase() === res.name.toLowerCase()) return true;
if (inputName && r.name && normalizeHost(r.name) === inputName) return true;
@@ -93,10 +131,33 @@ class DiscoveryReconciler {
mergedMeta.last_seen = Date.now();
const isIp = (str) => /^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$/.test(str || '');
// Pick the most human name across sources. Rank first, length only as
// a tie-break within a rank -- comparing lengths alone let a UniFi
// client named after its MAC ("ac:16:2d:b3:da:80", 17 chars) beat the
// hypervisor's real hostname from Proxmox ("dl380-0", 7), so the
// Directory listed MAC addresses where host names belong.
//
// NB: `\\.` inside a regex LITERAL matches a backslash, not a dot, so
// the old isIp returned false for every input and IP-shaped names were
// never replaced either. It is `\.` here.
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());
// 2 = a real name, 1 = an IP (at least routable/recognizable), 0 = a
// MAC or nothing (pure machine identifier, the worst thing to show).
const nameRank = (str) => {
if (!str || !String(str).trim()) return 0;
if (isMac(str)) return 0;
if (isIp(str)) return 1;
return 2;
};
let bestName = existing.name;
if (res.name && (!bestName || isIp(bestName) || res.name.length > bestName.length && !isIp(res.name))) {
bestName = res.name;
if (res.name) {
const incoming = nameRank(res.name);
const current = nameRank(bestName);
if (incoming > current || (incoming === current && res.name.length > (bestName || '').length)) {
bestName = res.name;
}
}
await existing.update({
@@ -125,12 +186,17 @@ class DiscoveryReconciler {
newDevices++;
res._actualId = created.id; // Map original slug to actual ID
// Make it visible to the rest of THIS payload: a Proxmox run reports
// the endpoint, then its nodes, then their guests, and two of them can
// legitimately share a MAC/IP. Without this the same device could be
// created twice in a single run.
allRes.push(created);
WebhookEmitter.emit('discovery.new_device', created.toJSON());
}
}
// Now process edges
const allRes = await Resource.list();
// Now process edges. `allRes` above is already current -- rows created in
// the loop were pushed onto it -- so no second full read is needed.
const existingEdges = await ResourceEdge.list();
for (const edge of edges) {
@@ -154,15 +220,37 @@ class DiscoveryReconciler {
if (childResInDb) childId = childResInDb.id;
}
// Two slugs in one payload can resolve to the SAME resource once the
// matcher has merged them -- a Proxmox endpoint reached at the address
// of the node that answers for it is the case that produced this. The
// edge would then make a resource its own parent, which renders as an
// infinitely nested tree and defeats every ancestor walk in the app
// (findAncestorSiteSlug, withResolvedAddress) that relies on a cycle
// guard to terminate rather than to be correct.
if (parentId && childId && parentId === childId) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping self-edge on ${edge.parentSlug} -> ${edge.childSlug} (both resolved to the same resource)`);
continue;
}
// Likewise refuse an edge that closes a loop: if the proposed parent is
// already a descendant of the proposed child, adding this makes a cycle.
if (parentId && childId && isDescendant(parentId, childId, existingEdges)) {
console.warn(`[DiscoveryReconciler] ${sourceName}: dropping ${edge.parentSlug} -> ${edge.childSlug} (would create a cycle)`);
continue;
}
if (parentId && childId) {
const edgeExists = existingEdges.find(e => e.parentId === parentId && e.childId === childId && e.relation === edge.relation);
if (!edgeExists) {
await ResourceEdge.create({
const created = await ResourceEdge.create({
id: crypto.randomUUID(),
parentId,
childId,
relation: edge.relation
});
// Keep the in-memory edge list current so the cycle check above sees
// edges added earlier in this same payload.
existingEdges.push(created);
}
}
}