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 CHAIN_PATTERN = /^[a-z0-9_-]{1,26}$/i; const MARK_PATTERN = /^(?:0x)?[0-9a-f]+$/i; const MAX_DEVICES = 512; const childChain = (chain, slot) => `${chain}_${slot}`; const fingerprint = (devices) => crypto.createHash('sha256') .update(JSON.stringify(devices)) .digest('hex'); function commandError(command, result) { return new Error(String( result.stderr || result.stdout || result.error?.message || `${command} завершился с ошибкой`, ).trim()); } export function normalizeDirectDevices(value) { if (!Array.isArray(value) || value.length > MAX_DEVICES) { throw new Error('Некорректный набор device policy'); } const ids = new Set(); const tuples = new Set(); const devices = value.map((device) => { const normalized = { id: String(device?.id || ''), ip: String(device?.ip || ''), mac: String(device?.mac || '').toLowerCase(), interface: String(device?.interface || ''), }; const tuple = `${normalized.ip}|${normalized.mac}|${normalized.interface}`; if (!DEVICE_ID_PATTERN.test(normalized.id) || !net.isIPv4(normalized.ip) || !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); tuples.add(tuple); return normalized; }); return devices.sort((left, right) => left.id.localeCompare(right.id)); } export const fingerprintDirectDevices = (value) => fingerprint(normalizeDirectDevices(value)); export function buildDevicePolicyRestore({ devices, chain, slot, tproxyPort, tproxyMark }) { const child = childChain(chain, slot); const rules = ['*mangle', `-F ${child}`]; for (const device of devices) { rules.push(`-A ${child} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-policy:${device.id}:direct -j RETURN`); } rules.push( `-A ${child} -p tcp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`, `-A ${child} -p udp -j TPROXY --on-port ${tproxyPort} --tproxy-mark ${tproxyMark}/${tproxyMark}`, 'COMMIT', '', ); return rules.join('\n'); } export function createDevicePolicyService({ chain, tproxyPort, tproxyMark, run = spawnSync, now = () => new Date(), nextGeneration = () => crypto.randomUUID(), }) { if (!CHAIN_PATTERN.test(String(chain || '')) || !Number.isInteger(tproxyPort) || tproxyPort < 1 || tproxyPort > 65_535 || !MARK_PATTERN.test(String(tproxyMark || ''))) { throw new Error('Некорректная конфигурация device policy'); } const epoch = nextGeneration(); let activeSlot = 'A'; let activeSignature = JSON.stringify([]); let generation = epoch; let appliedDevices = []; let observedAt = now().toISOString(); let queue = Promise.resolve(); function execute(command, args, input) { const result = run(command, args, input == null ? COMMAND_OPTIONS : { ...COMMAND_OPTIONS, input }); if (result.error || result.status !== 0) throw commandError(command, result); } function snapshot(changed = false) { return { epoch, generation, fingerprint: fingerprint(appliedDevices), observedAt, appliedIds: appliedDevices.map(({ id }) => id), changed, }; } function performApply(value) { const devices = normalizeDirectDevices(value); const signature = JSON.stringify(devices); if (signature === activeSignature) return snapshot(false); const nextSlot = activeSlot === 'A' ? 'B' : 'A'; execute('iptables-restore', ['-w', '1', '--noflush'], buildDevicePolicyRestore({ devices, chain, slot: nextSlot, tproxyPort, tproxyMark, })); execute('iptables', [ '-w', '1', '-t', 'mangle', '-R', chain, '1', '-j', childChain(chain, nextSlot), ]); activeSlot = nextSlot; activeSignature = signature; appliedDevices = devices; generation = nextGeneration(); observedAt = now().toISOString(); return snapshot(true); } function apply(devices) { const result = queue.then(() => performApply(devices)); queue = result.catch(() => {}); return result; } return { apply, snapshot: () => snapshot(false) }; }