Add per-device VPN and direct routing policies
This commit is contained in:
@@ -10,6 +10,7 @@ import {
|
||||
DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
migrateDeviceInventoryState,
|
||||
} from '../../src/server/services/deviceInventoryService.js';
|
||||
import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.js';
|
||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||
|
||||
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
||||
@@ -196,6 +197,143 @@ test('device traffic totals persist exact deltas across polls and process epochs
|
||||
assert.equal(snapshot.source.traffic.error, 'traffic unavailable');
|
||||
});
|
||||
|
||||
test('pinned device policy persists, reconciles the full set, and keeps the last applied mode on failure', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const store = createJsonStore({
|
||||
filePath: path.join(directory, 'devices.json'),
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
initializeMissing: true,
|
||||
backupWhen: () => true,
|
||||
});
|
||||
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||
const mac = '00:11:22:33:44:55';
|
||||
let observations = [{
|
||||
ip: '192.168.50.7',
|
||||
mac,
|
||||
interface: 'eth0',
|
||||
observedAt,
|
||||
active: true,
|
||||
}];
|
||||
let activeDevices = [];
|
||||
let generation = 0;
|
||||
let policyEpoch = 'policy-epoch-a';
|
||||
let failApply = false;
|
||||
let malformedAck = false;
|
||||
const appliedSets = [];
|
||||
const policySnapshot = () => ({
|
||||
epoch: policyEpoch,
|
||||
generation: `policy-rules-${generation}`,
|
||||
fingerprint: fingerprintDirectDevices(activeDevices),
|
||||
observedAt,
|
||||
appliedIds: activeDevices.map(({ id }) => id),
|
||||
});
|
||||
const createService = () => createDeviceInventoryService({
|
||||
store,
|
||||
observe: () => ({ observedAt, observations, error: null }),
|
||||
observePolicy: policySnapshot,
|
||||
applyPolicies: async (devices) => {
|
||||
appliedSets.push(structuredClone(devices));
|
||||
if (failApply) throw new Error('iptables unavailable');
|
||||
if (malformedAck) return { ...policySnapshot(), fingerprint: 'invalid' };
|
||||
activeDevices = structuredClone(devices);
|
||||
generation += 1;
|
||||
return policySnapshot();
|
||||
},
|
||||
});
|
||||
|
||||
let service = createService();
|
||||
let snapshot = await service.refresh();
|
||||
const id = snapshot.devices[0].id;
|
||||
assert.equal(snapshot.devices[0].desiredPolicy, 'vpn');
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||
|
||||
snapshot = service.update(id, { pinned: true }, snapshot.revision);
|
||||
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
||||
assert.deepEqual(appliedSets.at(-1), [{ id, ip: '192.168.50.7', mac, interface: 'eth0' }]);
|
||||
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||
assert.equal(store.read().schemaVersion, 2);
|
||||
assert.equal(store.read().policy.schemaVersion, 1);
|
||||
|
||||
service = createService();
|
||||
snapshot = await service.reconcilePolicies();
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||
assert.throws(
|
||||
() => service.update(id, { pinned: false }, snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
|
||||
policyEpoch = 'policy-epoch-b';
|
||||
activeDevices = [];
|
||||
failApply = true;
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'failed');
|
||||
failApply = false;
|
||||
snapshot = await service.refresh();
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||
|
||||
observations = [
|
||||
{ ...observations[0], interface: 'eth0' },
|
||||
{ ...observations[0], interface: 'eth1' },
|
||||
];
|
||||
snapshot = await service.refresh();
|
||||
assert.deepEqual(appliedSets.at(-1), []);
|
||||
assert.equal(snapshot.devices[0].confidence, 'ambiguous');
|
||||
assert.equal(snapshot.devices[0].desiredPolicy, 'direct');
|
||||
assert.equal(snapshot.devices[0].appliedPolicy, 'vpn');
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'pending');
|
||||
|
||||
snapshot = await service.setPolicy(id, 'vpn', snapshot.revision);
|
||||
assert.equal(snapshot.devices[0].policyStatus, 'applied');
|
||||
observations = [{ ...observations[0], interface: 'eth0' }];
|
||||
snapshot = await service.refresh();
|
||||
snapshot = await service.setPolicy(id, 'direct', snapshot.revision);
|
||||
const secondMac = '00:11:22:33:44:66';
|
||||
observations.push({
|
||||
ip: '192.168.50.8',
|
||||
mac: secondMac,
|
||||
interface: 'eth0',
|
||||
observedAt,
|
||||
active: true,
|
||||
});
|
||||
snapshot = await service.refresh();
|
||||
const secondId = snapshot.devices.find((device) => device.mac === secondMac).id;
|
||||
snapshot = service.update(secondId, { pinned: true }, snapshot.revision);
|
||||
failApply = true;
|
||||
await assert.rejects(
|
||||
service.setPolicy(secondId, 'direct', snapshot.revision),
|
||||
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
||||
);
|
||||
snapshot = service.snapshot();
|
||||
const byId = new Map(snapshot.devices.map((device) => [device.id, device]));
|
||||
assert.equal(byId.get(id).desiredPolicy, 'direct');
|
||||
assert.equal(byId.get(id).appliedPolicy, 'direct');
|
||||
assert.equal(byId.get(id).policyStatus, 'applied');
|
||||
assert.equal(byId.get(secondId).desiredPolicy, 'direct');
|
||||
assert.equal(byId.get(secondId).appliedPolicy, 'vpn');
|
||||
assert.equal(byId.get(secondId).policyStatus, 'failed');
|
||||
assert.equal(snapshot.source.policy.error, 'iptables unavailable');
|
||||
failApply = false;
|
||||
malformedAck = true;
|
||||
await assert.rejects(
|
||||
service.setPolicy(secondId, 'direct', snapshot.revision),
|
||||
(error) => error.code === 'DEVICE_POLICY_APPLY_FAILED',
|
||||
);
|
||||
snapshot = service.snapshot();
|
||||
assert.equal(snapshot.devices.find((device) => device.id === secondId).appliedPolicy, 'vpn');
|
||||
assert.match(snapshot.source.policy.error, /acknowledgement/);
|
||||
assert.throws(
|
||||
() => service.setPolicy(secondId, 'block', snapshot.revision),
|
||||
(error) => error.code === 'REQUEST_INVALID',
|
||||
);
|
||||
});
|
||||
|
||||
test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-migration-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
@@ -210,11 +348,47 @@ test('device inventory v1 migration creates a versioned backup', (t) => {
|
||||
|
||||
const migrated = store.read();
|
||||
assert.equal(migrated.schemaVersion, DEVICE_INVENTORY_SCHEMA_VERSION);
|
||||
assert.equal(migrated.policy.schemaVersion, 1);
|
||||
assert.deepEqual(migrated.traffic.baselinesByMac, {});
|
||||
assert.match(store.migration?.backupPath || '', /\.backup-v1-/);
|
||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||
});
|
||||
|
||||
test('device inventory backs up and normalizes a malformed additive policy checkpoint', (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-policy-migration-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
const filePath = path.join(directory, 'devices.json');
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
revision: 3,
|
||||
devices: [],
|
||||
traffic: { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [] },
|
||||
policy: {
|
||||
schemaVersion: 1,
|
||||
defaultMode: 'vpn',
|
||||
dataplaneEpoch: 42,
|
||||
byMac: {
|
||||
broken: { desired: 'direct', applied: 'vpn', status: 'failed' },
|
||||
},
|
||||
},
|
||||
}));
|
||||
const store = createJsonStore({
|
||||
filePath,
|
||||
defaultValue: {},
|
||||
migrate: migrateDeviceInventoryState,
|
||||
backupWhen: () => true,
|
||||
});
|
||||
|
||||
const migrated = store.read();
|
||||
assert.equal(migrated.schemaVersion, 2);
|
||||
assert.equal(migrated.policy.schemaVersion, 1);
|
||||
assert.equal(migrated.policy.dataplaneEpoch, null);
|
||||
assert.deepEqual(migrated.policy.byMac, {});
|
||||
assert.match(migrated.policy.lastError, /device policy checkpoint/);
|
||||
assert.match(store.migration?.backupPath || '', /\.backup-v2-/);
|
||||
assert.ok(fs.existsSync(store.migration.backupPath));
|
||||
});
|
||||
|
||||
test('device inventory backs up malformed v2 traffic and re-baselines without double counting', async (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-device-corrupt-traffic-'));
|
||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||
|
||||
Reference in New Issue
Block a user