diff --git a/src/server/adapters/neighbors.js b/src/server/adapters/neighbors.js index f9a18f5..0f303d9 100644 --- a/src/server/adapters/neighbors.js +++ b/src/server/adapters/neighbors.js @@ -3,6 +3,13 @@ import { spawnSync } from 'node:child_process'; const ACTIVE_STATES = new Set(['REACHABLE', 'DELAY', 'PROBE', 'PERMANENT', 'NOARP']); const IGNORED_STATES = new Set(['FAILED', 'INCOMPLETE']); 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()) { if (!Array.isArray(value)) return []; @@ -11,13 +18,15 @@ export function parseNeighborSnapshot(value, observedAt = new Date().toISOString .filter(Boolean) .map((state) => String(state).toUpperCase()); 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 [{ ip: String(entry.dst), mac, - interface: String(entry.dev), + interface: deviceInterface, active: states.some((state) => ACTIVE_STATES.has(state)), observedAt, source: 'neighbor', diff --git a/src/server/services/deviceInventoryService.js b/src/server/services/deviceInventoryService.js index fed7eea..2013ed7 100644 --- a/src/server/services/deviceInventoryService.js +++ b/src/server/services/deviceInventoryService.js @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import net from 'node:net'; import { HarborError } from '../../shared/errors.js'; +import { isDeviceInterface } from '../adapters/neighbors.js'; import { fingerprintDirectDevices } from './devicePolicyService.js'; 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 MAC_PATTERN = /^[0-9a-f]{2}(?::[0-9a-f]{2}){5}$/; 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_STATUSES = new Set(['applied', 'applying', 'pending', 'failed']); 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) ? 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 rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : []) .map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac))); @@ -314,8 +316,7 @@ export function createDeviceInventoryService({ function policyIdentity(device) { return device?.pinned && device.confidence !== 'ambiguous' && net.isIPv4(String(device.ip || '')) && MAC_PATTERN.test(device.mac) - && INTERFACE_PATTERN.test(String(device.interface || '')) - && !String(device.interface).startsWith('br-'); + && isDeviceInterface(device.interface); } function directDevices(state) { @@ -523,7 +524,8 @@ export function createDeviceInventoryService({ : null, ]); 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(); for (const observation of observations) { const mac = normalizeMac(observation.mac); diff --git a/src/server/services/devicePolicyService.js b/src/server/services/devicePolicyService.js index f2150f1..7e26fe6 100644 --- a/src/server/services/devicePolicyService.js +++ b/src/server/services/devicePolicyService.js @@ -1,11 +1,11 @@ import crypto from 'node:crypto'; import net from 'node:net'; import { spawnSync } from 'node:child_process'; +import { isDeviceInterface } from '../adapters/neighbors.js'; const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' }; const DEVICE_ID_PATTERN = /^dev_[a-f0-9]{16}$/; 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 MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i; const MAX_DEVICES = 512; @@ -36,8 +36,8 @@ export function normalizeDirectDevices(value) { }; const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`; if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip) - || !MAC_PATTERN.test(normalized.mac) || !INTERFACE_PATTERN.test(normalized.interface) - || normalized.interface.startsWith('br-') || ids.has(normalized.id) || tuples.has(tuple)) { + || !MAC_PATTERN.test(normalized.mac) || !isDeviceInterface(normalized.interface) + || ids.has(normalized.id) || tuples.has(tuple)) { throw new Error('Некорректная или повторяющаяся device policy identity'); } ids.add(normalized.id); diff --git a/src/server/services/deviceTrafficService.js b/src/server/services/deviceTrafficService.js index dc287e3..d5ba3bf 100644 --- a/src/server/services/deviceTrafficService.js +++ b/src/server/services/deviceTrafficService.js @@ -1,10 +1,10 @@ import crypto from 'node:crypto'; import net from 'node:net'; import { spawn } from 'node:child_process'; +import { isDeviceInterface } from '../adapters/neighbors.js'; const COMMAND_OPTIONS = { encoding: 'utf8', timeout: 2_000, killSignal: 'SIGKILL' }; 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 COUNTERS = [ ['upload', 'upload', 'uploadBytes'], @@ -85,8 +85,7 @@ export function selectTrafficDevices(observations) { const ip = String(observation?.ip || ''); const mac = String(observation?.mac || '').toLowerCase(); const deviceInterface = String(observation?.interface || ''); - if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !INTERFACE_PATTERN.test(deviceInterface) - || deviceInterface.startsWith('br-')) continue; + if (!net.isIPv4(ip) || !MAC_PATTERN.test(mac) || !isDeviceInterface(deviceInterface)) continue; const location = `${mac}|${deviceInterface}`; candidates.set(`${ip}|${location}`, { ip, mac, interface: deviceInterface }); diff --git a/src/shared/versions.js b/src/shared/versions.js index 2423f56..11bf082 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.12.2', + macClient: '0.12.3', gatewayClient: '0.13.0', - gatewayBackend: '0.13.0', + gatewayBackend: '0.13.1', }); export function parseVersion(value) { diff --git a/test/server/device-inventory.test.js b/test/server/device-inventory.test.js index ae144d2..b29c743 100644 --- a/test/server/device-inventory.test.js +++ b/test/server/device-inventory.test.js @@ -13,6 +13,40 @@ import { import { fingerprintDirectDevices } from '../../src/server/services/devicePolicyService.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) => { const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-devices-')); 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 = { observedAt, 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 = { mac, @@ -270,6 +307,7 @@ test('legacy dataplane samples preserve saved proxy totals while Gateway totals }); let snapshot = await service.refresh(); + assert.equal(snapshot.devices.length, 1); assert.equal(snapshot.devices[0].proxyUploadBytes, '30'); row = { mac, uploadBytes: '15', downloadBytes: '27' }; snapshot = await service.refresh(); diff --git a/test/server/device-policy.test.js b/test/server/device-policy.test.js index f6cf35f..07a42e7 100644 --- a/test/server/device-policy.test.js +++ b/test/server/device-policy.test.js @@ -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.throws(() => normalizeDirectDevices([directDevice, directDevice]), /повторяющаяся/); 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({ devices: [directDevice], diff --git a/test/server/device-traffic.test.js b/test/server/device-traffic.test.js index 208d9ce..8746a4b 100644 --- a/test/server/device-traffic.test.js +++ b/test/server/device-traffic.test.js @@ -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.11', '00:11:22:33:44:88', 'br-docker0'), 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'), ]);