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
+78
View File
@@ -0,0 +1,78 @@
'use strict';
const { _Interfaces: Interfaces } = require('../plugins/discovery/proxmox');
// Regression coverage for the MAC/IP mismatch: the plugin used to collect MACs
// and IPs into two flat lists and zip them by index, so on a multi-NIC guest
// -- or any guest where one NIC had no address -- the directory recorded an IP
// against the wrong MAC. Interfaces keys by MAC so a pairing can only come from
// the source that observed both together.
describe('proxmox Interfaces', () => {
test('keeps each IP on the NIC it was observed on', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', ['10.0.0.5'], 'eth0');
i.add('AA:BB:CC:00:00:02', ['192.168.9.7'], 'eth1');
expect(i.toArray()).toEqual([
{ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5', ips: ['10.0.0.5'], name: 'eth0' },
{ mac: 'aa:bb:cc:00:00:02', ip: '192.168.9.7', ips: ['192.168.9.7'], name: 'eth1' },
]);
});
test('a NIC with no address does not steal the next NIC\'s IP', () => {
const i = new Interfaces();
i.add('AA:BB:CC:00:00:01', [], 'eth0'); // stopped/unconfigured
i.add('AA:BB:CC:00:00:02', ['10.0.0.9'], 'eth1');
const byMac = Object.fromEntries(i.toArray().map(x => [x.mac, x.ip]));
expect(byMac['aa:bb:cc:00:00:01']).toBeNull();
expect(byMac['aa:bb:cc:00:00:02']).toBe('10.0.0.9');
});
test('merges the config MAC with the agent-reported address for the same NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5'], 'eth0'); // guest agent
i.add('AA:BB:CC:00:00:01', [], 'net0'); // VM config, same NIC
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0]).toMatchObject({ mac: 'aa:bb:cc:00:00:01', ip: '10.0.0.5' });
});
test('collects multiple addresses on one NIC without inventing a second NIC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', ['10.0.0.5', '10.0.0.6'], 'eth0');
expect(i.toArray()).toHaveLength(1);
expect(i.toArray()[0].ips).toEqual(['10.0.0.5', '10.0.0.6']);
expect(i.primaryIp()).toBe('10.0.0.5');
});
test('ignores placeholder and malformed MACs', () => {
const i = new Interfaces();
i.add('00:00:00:00:00:00', [], 'eth0');
i.add('not-a-mac', [], 'eth1');
i.add('', [], 'eth2');
expect(i.toArray()).toEqual([]);
expect(i.primaryMac()).toBeNull();
});
test('keeps an address that arrived without a usable MAC', () => {
const i = new Interfaces();
i.add(null, ['10.0.0.5'], 'eth0');
expect(i.primaryIp()).toBe('10.0.0.5');
expect(i.primaryMac()).toBeNull();
});
test('primary values prefer a NIC that actually has an address', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'eth0');
i.add('aa:bb:cc:00:00:02', ['10.0.0.9'], 'eth1');
expect(i.primaryIp()).toBe('10.0.0.9');
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:02');
});
test('a fully unaddressed guest still reports its MAC', () => {
const i = new Interfaces();
i.add('aa:bb:cc:00:00:01', [], 'net0');
expect(i.primaryIp()).toBeNull();
expect(i.primaryMac()).toBe('aa:bb:cc:00:00:01');
});
});