Filter container interfaces from device discovery and policies
This commit is contained in:
@@ -3,6 +3,13 @@ import { spawnSync } from 'node:child_process';
|
|||||||
const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']);
|
const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']);
|
||||||
const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']);
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
|
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||||
|
|
||||||
|
export function isDeviceInterface(value) {
|
||||||
|
const name = String(value || '');
|
||||||
|
return INTERFACE_PATTERN.test(name)
|
||||||
|
&& name !== 'docker0' && !name.startsWith('br-') && !name.startsWith('veth');
|
||||||
|
}
|
||||||
|
|
||||||
export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) {
|
export function parseNeighborSnapshot(value, observedAt = new Date().toISOString()) {
|
||||||
if (!Array.isArray(value)) return [];
|
if (!Array.isArray(value)) return [];
|
||||||
@@ -11,13 +18,15 @@ export function parseNeighborSnapshot(value, observedAt = new Date().toISOString
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((state) => String(state).toUpperCase());
|
.map((state) => String(state).toUpperCase());
|
||||||
const mac = String(entry?.lladdr || '').toLowerCase();
|
const mac = String(entry?.lladdr || '').toLowerCase();
|
||||||
if (!entry?.dst || !entry?.dev || !MAC_PATTERN.test(mac) || states.some((state) => IGNORED_STATES.has(state))) {
|
const deviceInterface = String(entry?.dev || '');
|
||||||
|
if (!entry?.dst || !isDeviceInterface(deviceInterface) || !MAC_PATTERN.test(mac)
|
||||||
|
|| states.some((state) => IGNORED_STATES.has(state))) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return [{
|
return [{
|
||||||
ip: String(entry.dst),
|
ip: String(entry.dst),
|
||||||
mac,
|
mac,
|
||||||
interface: String(entry.dev),
|
interface: deviceInterface,
|
||||||
active: states.some((state) => ACTIVE_STATES.has(state)),
|
active: states.some((state) => ACTIVE_STATES.has(state)),
|
||||||
observedAt,
|
observedAt,
|
||||||
source: 'neighbor',
|
source: 'neighbor',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import crypto from 'node:crypto';
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { HarborError } from '../../shared/errors.js';
|
import { HarborError } from '../../shared/errors.js';
|
||||||
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||||
|
|
||||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
||||||
@@ -12,7 +13,6 @@ const COUNTER_PATTERN = /^\d+$/;
|
|||||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
const FINGERPRINT_PATTERN = /^[a-f0-9]{64}$/;
|
||||||
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
|
||||||
const POLICY_MODES = new Set(['vpn', 'direct']);
|
const POLICY_MODES = new Set(['vpn', 'direct']);
|
||||||
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
|
const POLICY_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']);
|
||||||
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
const PROXY_RECOVERY_ERROR = 'Повреждённый proxy traffic checkpoint восстановлен из корректных данных';
|
||||||
@@ -214,7 +214,9 @@ export function migrateDeviceInventoryState(value) {
|
|||||||
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
|
const traffic = state.traffic && typeof state.traffic === 'object' && !Array.isArray(state.traffic)
|
||||||
? state.traffic
|
? state.traffic
|
||||||
: {};
|
: {};
|
||||||
const devices = Array.isArray(state.devices) ? state.devices : [];
|
const devices = Array.isArray(state.devices)
|
||||||
|
? state.devices.filter((device) => isDeviceInterface(device?.interface))
|
||||||
|
: [];
|
||||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||||
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||||
@@ -314,8 +316,7 @@ export function createDeviceInventoryService({
|
|||||||
function policyIdentity(device) {
|
function policyIdentity(device) {
|
||||||
return device?.pinned && device.confidence !== 'ambiguous'
|
return device?.pinned && device.confidence !== 'ambiguous'
|
||||||
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
|
&& net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac)
|
||||||
&& INTERFACE_PATTERN.test(String(device.interface || ''))
|
&& isDeviceInterface(device.interface);
|
||||||
&& !String(device.interface).startsWith('br-');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function directDevices(state) {
|
function directDevices(state) {
|
||||||
@@ -523,7 +524,8 @@ export function createDeviceInventoryService({
|
|||||||
: null,
|
: null,
|
||||||
]);
|
]);
|
||||||
const observedAt = result?.observedAt || now().toISOString();
|
const observedAt = result?.observedAt || now().toISOString();
|
||||||
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
const observations = (Array.isArray(result?.observations) ? result.observations : [])
|
||||||
|
.filter((observation) => isDeviceInterface(observation?.interface));
|
||||||
const identitiesByMac = new Map();
|
const identitiesByMac = new Map();
|
||||||
for (const observation of observations) {
|
for (const observation of observations) {
|
||||||
const mac = normalizeMac(observation.mac);
|
const mac = normalizeMac(observation.mac);
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawnSync } from 'node:child_process';
|
import { spawnSync } from 'node:child_process';
|
||||||
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||||
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/;
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/;
|
||||||
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
|
||||||
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
const CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i;
|
||||||
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i;
|
||||||
const MAX_DEVICES = 512;
|
const MAX_DEVICES = 512;
|
||||||
@@ -36,8 +36,8 @@ export function normalizeDirectDevices(value) {
|
|||||||
};
|
};
|
||||||
const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`;
|
const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`;
|
||||||
if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip)
|
if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip)
|
||||||
|| !MAC_PATTERN.test(normalized.mac) || !INTERFACE_PATTERN.test(normalized.interface)
|
|| !MAC_PATTERN.test(normalized.mac) || !isDeviceInterface(normalized.interface)
|
||||||
|| normalized.interface.startsWith('br-') || ids.has(normalized.id) || tuples.has(tuple)) {
|
|| ids.has(normalized.id) || tuples.has(tuple)) {
|
||||||
throw new Error('Некорректная или повторяющаяся device policy identity');
|
throw new Error('Некорректная или повторяющаяся device policy identity');
|
||||||
}
|
}
|
||||||
ids.add(normalized.id);
|
ids.add(normalized.id);
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
import { isDeviceInterface } from '../adapters/neighbors.js';
|
||||||
|
|
||||||
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' };
|
||||||
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
const MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/i;
|
||||||
const INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
|
||||||
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
||||||
const COUNTERS = [
|
const COUNTERS = [
|
||||||
['upload', 'upload', 'uploadBytes'],
|
['upload', 'upload', 'uploadBytes'],
|
||||||
@@ -85,8 +85,7 @@ export function selectTrafficDevices(observations) {
|
|||||||
const ip = String(observation?.ip || '');
|
const ip = String(observation?.ip || '');
|
||||||
const mac = String(observation?.mac || '').toLowerCase();
|
const mac = String(observation?.mac || '').toLowerCase();
|
||||||
const deviceInterface = String(observation?.interface || '');
|
const deviceInterface = String(observation?.interface || '');
|
||||||
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !INTERFACE_PATTERN.test(deviceInterface)
|
if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue;
|
||||||
|| deviceInterface.startsWith('br-')) continue;
|
|
||||||
|
|
||||||
const location = `${mac}|${deviceInterface}`;
|
const location = `${mac}|${deviceInterface}`;
|
||||||
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.12.2',
|
macClient: '0.12.3',
|
||||||
gatewayClient: '0.13.0',
|
gatewayClient: '0.13.0',
|
||||||
gatewayBackend: '0.13.0',
|
gatewayBackend: '0.13.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -13,6 +13,40 @@ import {
|
|||||||
import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.js';
|
import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.js';
|
||||||
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
import { createJsonStore } from '../../src/server/services/stateStore.js';
|
||||||
|
|
||||||
|
test('container neighbors are hidden while a real 172 LAN device remains valid', () => {
|
||||||
|
const observedAt = '2026-08-07T12:00:00.000Z';
|
||||||
|
const parsed = parseNeighborSnapshot([
|
||||||
|
{ dst: '172.17.0.2', dev: 'docker0', lladdr: '00:11:22:33:44:51', state: ['REACHABLE'] },
|
||||||
|
{ dst: '172.18.0.2', dev: 'br-1234', lladdr: '00:11:22:33:44:52', state: ['REACHABLE'] },
|
||||||
|
{ dst: '172.19.0.2', dev: 'veth1234', lladdr: '00:11:22:33:44:53', state: ['REACHABLE'] },
|
||||||
|
{ dst: '172.20.0.7', dev: 'eth0', lladdr: '00:11:22:33:44:54', state: ['REACHABLE'] },
|
||||||
|
], observedAt);
|
||||||
|
assert.deepEqual(parsed.map(({ ip, interface: deviceInterface }) => [ip, deviceInterface]), [
|
||||||
|
['172.20.0.7', 'eth0'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const storedDevice = (ip, deviceInterface) => ({
|
||||||
|
id: `dev_${ip.replaceAll('.', '').padEnd(16, '0').slice(0, 16)}`,
|
||||||
|
alias: '',
|
||||||
|
pinned: false,
|
||||||
|
hostname: null,
|
||||||
|
manufacturer: null,
|
||||||
|
mac: ip === '172.17.0.2' ? '00:11:22:33:44:51' : '00:11:22:33:44:54',
|
||||||
|
ip,
|
||||||
|
interface: deviceInterface,
|
||||||
|
firstSeenAt: observedAt,
|
||||||
|
lastSeenAt: observedAt,
|
||||||
|
source: 'neighbor',
|
||||||
|
confidence: 'high',
|
||||||
|
});
|
||||||
|
const migrated = migrateDeviceInventoryState({
|
||||||
|
schemaVersion: 2,
|
||||||
|
devices: [storedDevice('172.17.0.2', 'docker0'), storedDevice('172.20.0.7', 'eth0')],
|
||||||
|
traffic: { baselinesByMac: {}, totalsByMac: {}, rebaselineMacs: [] },
|
||||||
|
});
|
||||||
|
assert.deepEqual(migrated.devices.map(({ ip }) => ip), ['172.20.0.7']);
|
||||||
|
});
|
||||||
|
|
||||||
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
test('device inventory discovers, merges, persists metadata and expires anonymous devices', async (t) => {
|
||||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
|
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-'));
|
||||||
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
|
||||||
@@ -252,7 +286,10 @@ test('legacy dataplane samples preserve saved proxy totals while Gateway totals
|
|||||||
const neighbor = {
|
const neighbor = {
|
||||||
observedAt,
|
observedAt,
|
||||||
error: null,
|
error: null,
|
||||||
observations: [{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true }],
|
observations: [
|
||||||
|
{ ip: '192.168.50.7', mac, interface: 'eth0', observedAt, active: true },
|
||||||
|
{ ip: '172.17.0.2', mac: '00:11:22:33:44:66', interface: 'docker0', observedAt, active: true },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
let row = {
|
let row = {
|
||||||
mac,
|
mac,
|
||||||
@@ -270,6 +307,7 @@ test('legacy dataplane samples preserve saved proxy totals while Gateway totals
|
|||||||
});
|
});
|
||||||
|
|
||||||
let snapshot = await service.refresh();
|
let snapshot = await service.refresh();
|
||||||
|
assert.equal(snapshot.devices.length, 1);
|
||||||
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
assert.equal(snapshot.devices[0].proxyUploadBytes, '30');
|
||||||
row = { mac, uploadBytes: '15', downloadBytes: '27' };
|
row = { mac, uploadBytes: '15', downloadBytes: '27' };
|
||||||
snapshot = await service.refresh();
|
snapshot = await service.refresh();
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ test('device policy rules match the full identity before the TPROXY fallback', (
|
|||||||
assert.deepEqual(normalizeDirectDevices([{ ...directDevice, mac: directDevice.mac.toUpperCase() }]), [directDevice]);
|
assert.deepEqual(normalizeDirectDevices([{ ...directDevice, mac: directDevice.mac.toUpperCase() }]), [directDevice]);
|
||||||
assert.throws(() => normalizeDirectDevices([directDevice, directDevice]), /повторяющаяся/);
|
assert.throws(() => normalizeDirectDevices([directDevice, directDevice]), /повторяющаяся/);
|
||||||
assert.throws(() => normalizeDirectDevices([{ ...directDevice, interface: 'br-user' }]), /identity/);
|
assert.throws(() => normalizeDirectDevices([{ ...directDevice, interface: 'br-user' }]), /identity/);
|
||||||
|
assert.throws(() => normalizeDirectDevices([{ ...directDevice, interface: 'docker0' }]), /identity/);
|
||||||
|
assert.throws(() => normalizeDirectDevices([{ ...directDevice, interface: 'veth1234' }]), /identity/);
|
||||||
|
|
||||||
const restore = buildDevicePolicyRestore({
|
const restore = buildDevicePolicyRestore({
|
||||||
devices: [directDevice],
|
devices: [directDevice],
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ test('traffic selection keeps only unambiguous IPv4 neighbors', () => {
|
|||||||
observation('192.168.50.10', '00:11:22:33:44:77', 'bad interface'),
|
observation('192.168.50.10', '00:11:22:33:44:77', 'bad interface'),
|
||||||
observation('192.168.50.11', '00:11:22:33:44:88', 'br-docker0'),
|
observation('192.168.50.11', '00:11:22:33:44:88', 'br-docker0'),
|
||||||
observation('192.168.50.12', '00:11:22:33:44:99', 'eth+'),
|
observation('192.168.50.12', '00:11:22:33:44:99', 'eth+'),
|
||||||
|
observation('172.17.0.2', '00:11:22:33:44:aa', 'docker0'),
|
||||||
|
observation('172.18.0.2', '00:11:22:33:44:bb', 'veth1234'),
|
||||||
observation('2001:db8::7', '00:11:22:33:44:88'),
|
observation('2001:db8::7', '00:11:22:33:44:88'),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user