Add per-device VPN and direct routing policies
This commit is contained in:
@@ -2,19 +2,46 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import net from 'node:net';
|
||||
import { HarborError } from '../../shared/errors.js';
|
||||
import { fingerprintDirectDevices } from './devicePolicyService.js';
|
||||
|
||||
export const DEVICE_INVENTORY_SCHEMA_VERSION = 2;
|
||||
const ONLINE_MS = 2 * 60 * 1000;
|
||||
const RECENT_MS = 24 * 60 * 60 * 1000;
|
||||
const RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
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 DEFAULT_DEVICE_POLICY = Object.freeze({
|
||||
desired: 'vpn',
|
||||
applied: 'vpn',
|
||||
status: 'applied',
|
||||
appliedAt: null,
|
||||
error: null,
|
||||
operationId: null,
|
||||
});
|
||||
|
||||
const DEFAULT_POLICY_STATE = {
|
||||
schemaVersion: 1,
|
||||
defaultMode: 'vpn',
|
||||
dataplaneEpoch: null,
|
||||
generation: null,
|
||||
fingerprint: null,
|
||||
lastAppliedAt: null,
|
||||
lastError: null,
|
||||
byMac: {},
|
||||
};
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
lastObservedAt: null,
|
||||
lastError: null,
|
||||
policy: DEFAULT_POLICY_STATE,
|
||||
traffic: {
|
||||
epoch: null,
|
||||
generation: null,
|
||||
@@ -38,6 +65,49 @@ const parseStoredCounter = (value) => {
|
||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||
};
|
||||
|
||||
function normalizePolicyState(value) {
|
||||
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const byMac = {};
|
||||
let recovered = value !== undefined && (
|
||||
policy.schemaVersion !== 1
|
||||
|| policy.defaultMode !== 'vpn'
|
||||
|| !policy.byMac || typeof policy.byMac !== 'object' || Array.isArray(policy.byMac)
|
||||
|| (policy.dataplaneEpoch != null && typeof policy.dataplaneEpoch !== 'string')
|
||||
|| (policy.generation != null && typeof policy.generation !== 'string')
|
||||
|| (policy.fingerprint != null && typeof policy.fingerprint !== 'string')
|
||||
|| (policy.lastAppliedAt != null && typeof policy.lastAppliedAt !== 'string')
|
||||
);
|
||||
for (const [rawMac, entry] of recordEntries(policy.byMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
if (!MAC_PATTERN.test(mac) || !POLICY_MODES.has(entry?.desired)
|
||||
|| !POLICY_MODES.has(entry?.applied) || !POLICY_STATUSES.has(entry?.status)) {
|
||||
recovered = true;
|
||||
continue;
|
||||
}
|
||||
byMac[mac] = {
|
||||
desired: entry.desired,
|
||||
applied: entry.applied,
|
||||
status: entry.status,
|
||||
appliedAt: typeof entry.appliedAt === 'string' ? entry.appliedAt : null,
|
||||
error: typeof entry.error === 'string' ? entry.error : null,
|
||||
operationId: typeof entry.operationId === 'string' ? entry.operationId : null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...DEFAULT_POLICY_STATE,
|
||||
dataplaneEpoch: typeof policy.dataplaneEpoch === 'string' ? policy.dataplaneEpoch : null,
|
||||
generation: typeof policy.generation === 'string' ? policy.generation : null,
|
||||
fingerprint: typeof policy.fingerprint === 'string' ? policy.fingerprint : null,
|
||||
lastAppliedAt: typeof policy.lastAppliedAt === 'string' ? policy.lastAppliedAt : null,
|
||||
schemaVersion: 1,
|
||||
defaultMode: 'vpn',
|
||||
lastError: recovered
|
||||
? 'Повреждённый device policy checkpoint восстановлен из корректных данных'
|
||||
: typeof policy.lastError === 'string' ? policy.lastError : null,
|
||||
byMac,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseOuiVendors(text) {
|
||||
const vendors = new Map();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
@@ -122,6 +192,7 @@ export function migrateDeviceInventoryState(value) {
|
||||
...state,
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: Number.isSafeInteger(state.revision) ? state.revision : 0,
|
||||
policy: normalizePolicyState(state.policy),
|
||||
traffic: {
|
||||
...DEFAULT_STATE.traffic,
|
||||
...traffic,
|
||||
@@ -147,10 +218,57 @@ export function createDeviceInventoryService({
|
||||
store,
|
||||
observe,
|
||||
observeTraffic = null,
|
||||
observePolicy = null,
|
||||
applyPolicies = null,
|
||||
vendor = () => null,
|
||||
now = () => new Date(),
|
||||
}) {
|
||||
let refreshPromise = null;
|
||||
let policyQueue = Promise.resolve();
|
||||
|
||||
function serializePolicy(action) {
|
||||
const result = policyQueue.then(action, action);
|
||||
policyQueue = result.catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
function policyFor(state, mac) {
|
||||
return state.policy.byMac[mac] || DEFAULT_DEVICE_POLICY;
|
||||
}
|
||||
|
||||
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-');
|
||||
}
|
||||
|
||||
function directDevices(state) {
|
||||
return state.devices
|
||||
.filter((device) => policyFor(state, device.mac).desired === 'direct' && policyIdentity(device))
|
||||
.map(({ id, ip, mac, interface: deviceInterface }) => ({
|
||||
id,
|
||||
ip,
|
||||
mac,
|
||||
interface: deviceInterface,
|
||||
}));
|
||||
}
|
||||
|
||||
function validatePolicyAck(result, requested) {
|
||||
const appliedIds = Array.isArray(result?.appliedIds) ? result.appliedIds : [];
|
||||
const expectedIds = new Set(requested.map(({ id }) => id));
|
||||
if (typeof result?.epoch !== 'string' || !result.epoch
|
||||
|| typeof result.generation !== 'string' || !result.generation
|
||||
|| !FINGERPRINT_PATTERN.test(result.fingerprint)
|
||||
|| typeof result.observedAt !== 'string' || !result.observedAt
|
||||
|| result.fingerprint !== fingerprintDirectDevices(requested)
|
||||
|| appliedIds.length !== expectedIds.size
|
||||
|| new Set(appliedIds).size !== appliedIds.length
|
||||
|| appliedIds.some((id) => !DEVICE_ID_PATTERN.test(id) || !expectedIds.has(id))) {
|
||||
throw new Error('Dataplane вернул невалидный device policy acknowledgement');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function snapshot() {
|
||||
const state = migrateDeviceInventoryState(store.read());
|
||||
@@ -158,12 +276,18 @@ export function createDeviceInventoryService({
|
||||
const rank = { online: 0, recent: 1, offline: 2 };
|
||||
const devices = state.devices.map((device) => {
|
||||
const traffic = state.traffic.totalsByMac[device.mac];
|
||||
const policy = policyFor(state, device.mac);
|
||||
return {
|
||||
...device,
|
||||
status: deviceStatus(device.lastSeenAt, current),
|
||||
uploadBytes: traffic?.uploadBytes || '0',
|
||||
downloadBytes: traffic?.downloadBytes || '0',
|
||||
trafficObservedAt: traffic?.observedAt || null,
|
||||
desiredPolicy: policy.desired,
|
||||
appliedPolicy: policy.applied,
|
||||
policyStatus: policy.status,
|
||||
policyAppliedAt: policy.appliedAt,
|
||||
policyError: policy.error,
|
||||
};
|
||||
}).sort((left, right) => (
|
||||
Number(right.pinned) - Number(left.pinned)
|
||||
@@ -180,13 +304,127 @@ export function createDeviceInventoryService({
|
||||
lastObservedAt: state.traffic.lastObservedAt,
|
||||
error: state.traffic.lastError,
|
||||
},
|
||||
policy: {
|
||||
lastAppliedAt: state.policy.lastAppliedAt,
|
||||
error: state.policy.lastError,
|
||||
},
|
||||
},
|
||||
devices,
|
||||
};
|
||||
}
|
||||
|
||||
function markPolicyEpoch(observed) {
|
||||
if (typeof observed?.epoch !== 'string' || !observed.epoch || !Array.isArray(observed.appliedIds)) return;
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (!state.policy.dataplaneEpoch || state.policy.dataplaneEpoch === observed.epoch) return state;
|
||||
const appliedIds = new Set(observed.appliedIds);
|
||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
const byMac = {};
|
||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||
const device = devicesByMac.get(mac);
|
||||
if (!device) continue;
|
||||
const applied = appliedIds.has(device.id) ? 'direct' : 'vpn';
|
||||
if (entry.desired === 'vpn' && applied === 'vpn') continue;
|
||||
byMac[mac] = {
|
||||
...entry,
|
||||
applied,
|
||||
status: entry.desired === applied ? 'applied' : 'pending',
|
||||
appliedAt: observed.observedAt || entry.appliedAt,
|
||||
error: entry.desired === applied
|
||||
? null
|
||||
: 'Dataplane перезапущен, маршрут ожидает повторного применения',
|
||||
operationId: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
policy: {
|
||||
...state.policy,
|
||||
dataplaneEpoch: observed.epoch,
|
||||
generation: typeof observed.generation === 'string' ? observed.generation : null,
|
||||
fingerprint: FINGERPRINT_PATTERN.test(observed.fingerprint) ? observed.fingerprint : null,
|
||||
lastAppliedAt: observed.observedAt || state.policy.lastAppliedAt,
|
||||
lastError: null,
|
||||
byMac,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function commitPolicySuccess(result) {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const appliedIds = new Set(Array.isArray(result.appliedIds) ? result.appliedIds : []);
|
||||
const devicesByMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
const byMac = {};
|
||||
for (const [mac, entry] of Object.entries(state.policy.byMac)) {
|
||||
const device = devicesByMac.get(mac);
|
||||
if (!device) continue;
|
||||
if (entry.desired === 'vpn' && !appliedIds.has(device?.id)) continue;
|
||||
const applied = appliedIds.has(device?.id) ? 'direct' : 'vpn';
|
||||
byMac[mac] = {
|
||||
...entry,
|
||||
applied,
|
||||
status: entry.desired === applied ? 'applied' : 'pending',
|
||||
appliedAt: result.observedAt || state.policy.lastAppliedAt,
|
||||
error: entry.desired === applied
|
||||
? null
|
||||
: 'Gateway должен однозначно распознать устройство',
|
||||
operationId: null,
|
||||
};
|
||||
}
|
||||
const policy = {
|
||||
...state.policy,
|
||||
dataplaneEpoch: result.epoch,
|
||||
generation: result.generation,
|
||||
fingerprint: result.fingerprint,
|
||||
lastAppliedAt: result.observedAt || state.policy.lastAppliedAt,
|
||||
lastError: null,
|
||||
byMac,
|
||||
};
|
||||
if (JSON.stringify(policy) === JSON.stringify(state.policy)) return state;
|
||||
return { ...state, revision: state.revision + 1, policy };
|
||||
});
|
||||
}
|
||||
|
||||
function commitPolicyFailure(error) {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const message = error.message || String(error);
|
||||
const byMac = Object.fromEntries(Object.entries(state.policy.byMac).map(([mac, entry]) => [mac, {
|
||||
...entry,
|
||||
status: entry.status === 'applied' && entry.desired === entry.applied ? 'applied' : 'failed',
|
||||
error: entry.status === 'applied' && entry.desired === entry.applied ? null : message,
|
||||
operationId: null,
|
||||
}]));
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
policy: { ...state.policy, lastError: message, byMac },
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcileLocked(observedPolicy, throwOnError) {
|
||||
if (!applyPolicies) return snapshot();
|
||||
markPolicyEpoch(observedPolicy);
|
||||
const state = migrateDeviceInventoryState(store.read());
|
||||
try {
|
||||
const requested = directDevices(state);
|
||||
const result = validatePolicyAck(await applyPolicies(requested), requested);
|
||||
commitPolicySuccess(result);
|
||||
return snapshot();
|
||||
} catch (cause) {
|
||||
commitPolicyFailure(cause);
|
||||
if (throwOnError) throw new HarborError('DEVICE_POLICY_APPLY_FAILED', { cause });
|
||||
return snapshot();
|
||||
}
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
const [result, trafficResult] = await Promise.all([
|
||||
const [result, trafficResult, policyResult] = await Promise.all([
|
||||
Promise.resolve().then(() => observe()).catch((error) => ({
|
||||
observedAt: now().toISOString(),
|
||||
observations: [],
|
||||
@@ -196,49 +434,54 @@ export function createDeviceInventoryService({
|
||||
? Promise.resolve().then(() => observeTraffic())
|
||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||
: null,
|
||||
observePolicy
|
||||
? Promise.resolve().then(() => observePolicy())
|
||||
.catch((error) => ({ transportError: error.message || String(error) }))
|
||||
: null,
|
||||
]);
|
||||
const observedAt = result?.observedAt || now().toISOString();
|
||||
const observations = Array.isArray(result?.observations) ? result.observations : [];
|
||||
const ipsByMac = new Map();
|
||||
const identitiesByMac = new Map();
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac || !net.isIPv4(String(observation.ip || ''))) continue;
|
||||
if (!ipsByMac.has(mac)) ipsByMac.set(mac, new Set());
|
||||
ipsByMac.get(mac).add(String(observation.ip));
|
||||
if (!identitiesByMac.has(mac)) identitiesByMac.set(mac, new Set());
|
||||
identitiesByMac.get(mac).add(`${observation.ip}|${observation.interface || ''}`);
|
||||
}
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac) continue;
|
||||
const previous = byMac.get(mac);
|
||||
const lastSeenAt = observation.active || !previous
|
||||
? observation.observedAt || observedAt
|
||||
: previous.lastSeenAt;
|
||||
byMac.set(mac, {
|
||||
id: previous?.id || deviceId(mac),
|
||||
alias: previous?.alias || '',
|
||||
pinned: previous?.pinned === true,
|
||||
hostname: previous?.hostname || null,
|
||||
manufacturer: previous?.manufacturer || vendor(mac),
|
||||
mac,
|
||||
ip: String(observation.ip || previous?.ip || ''),
|
||||
interface: String(observation.interface || previous?.interface || ''),
|
||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
||||
lastSeenAt,
|
||||
source: 'neighbor',
|
||||
confidence: ipsByMac.get(mac)?.size > 1
|
||||
? 'ambiguous'
|
||||
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||
});
|
||||
}
|
||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||
const devices = [...byMac.values()].filter((device) => (
|
||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
));
|
||||
let traffic = state.traffic;
|
||||
if (trafficResult) {
|
||||
return serializePolicy(async () => {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
|
||||
for (const observation of observations) {
|
||||
const mac = normalizeMac(observation.mac);
|
||||
if (!mac) continue;
|
||||
const previous = byMac.get(mac);
|
||||
const lastSeenAt = observation.active || !previous
|
||||
? observation.observedAt || observedAt
|
||||
: previous.lastSeenAt;
|
||||
byMac.set(mac, {
|
||||
id: previous?.id || deviceId(mac),
|
||||
alias: previous?.alias || '',
|
||||
pinned: previous?.pinned === true,
|
||||
hostname: previous?.hostname || null,
|
||||
manufacturer: previous?.manufacturer || vendor(mac),
|
||||
mac,
|
||||
ip: String(observation.ip || previous?.ip || ''),
|
||||
interface: String(observation.interface || previous?.interface || ''),
|
||||
firstSeenAt: previous?.firstSeenAt || observation.observedAt || observedAt,
|
||||
lastSeenAt,
|
||||
source: 'neighbor',
|
||||
confidence: identitiesByMac.get(mac)?.size > 1
|
||||
? 'ambiguous'
|
||||
: isPrivateMac(mac) ? 'medium' : 'high',
|
||||
});
|
||||
}
|
||||
const cutoff = new Date(observedAt).getTime() - RETENTION_MS;
|
||||
const devices = [...byMac.values()].filter((device) => (
|
||||
device.pinned || device.alias || new Date(device.lastSeenAt).getTime() >= cutoff
|
||||
));
|
||||
let traffic = state.traffic;
|
||||
if (trafficResult) {
|
||||
if (trafficResult.transportError) {
|
||||
traffic = { ...traffic, lastError: trafficResult.transportError };
|
||||
} else {
|
||||
@@ -318,17 +561,19 @@ export function createDeviceInventoryService({
|
||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: result?.error || null,
|
||||
traffic,
|
||||
devices,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
lastObservedAt: observedAt,
|
||||
lastError: result?.error || null,
|
||||
traffic,
|
||||
devices,
|
||||
};
|
||||
});
|
||||
if (policyResult?.transportError) commitPolicyFailure(new Error(policyResult.transportError));
|
||||
return reconcileLocked(policyResult, false);
|
||||
});
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
@@ -357,6 +602,11 @@ export function createDeviceInventoryService({
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
const index = state.devices.findIndex((device) => device.id === id);
|
||||
if (index < 0) throw new HarborError('DEVICE_NOT_FOUND');
|
||||
const currentPolicy = policyFor(state, state.devices[index].mac);
|
||||
if (pinProvided && patch.pinned === false
|
||||
&& (currentPolicy.desired === 'direct' || currentPolicy.applied === 'direct')) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
const devices = [...state.devices];
|
||||
devices[index] = {
|
||||
...devices[index],
|
||||
@@ -368,5 +618,46 @@ export function createDeviceInventoryService({
|
||||
return snapshot();
|
||||
}
|
||||
|
||||
return { snapshot, refresh, update };
|
||||
function setPolicy(id, mode, expectedRevision) {
|
||||
if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0 || !POLICY_MODES.has(mode)) {
|
||||
throw new HarborError('REQUEST_INVALID');
|
||||
}
|
||||
return serializePolicy(async () => {
|
||||
store.update((stored) => {
|
||||
const state = migrateDeviceInventoryState(stored);
|
||||
if (state.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT');
|
||||
const device = state.devices.find((candidate) => candidate.id === id);
|
||||
if (!device) throw new HarborError('DEVICE_NOT_FOUND');
|
||||
if (mode === 'direct' && !device.pinned) throw new HarborError('DEVICE_POLICY_REQUIRES_PIN');
|
||||
if (mode === 'direct' && !policyIdentity(device)) throw new HarborError('DEVICE_IDENTITY_AMBIGUOUS');
|
||||
const current = policyFor(state, device.mac);
|
||||
if (current.desired === mode && current.status === 'applied') return state;
|
||||
const byMac = {
|
||||
...state.policy.byMac,
|
||||
[device.mac]: {
|
||||
...current,
|
||||
desired: mode,
|
||||
status: 'applying',
|
||||
error: null,
|
||||
operationId: crypto.randomUUID(),
|
||||
},
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
revision: state.revision + 1,
|
||||
policy: { ...state.policy, lastError: null, byMac },
|
||||
};
|
||||
});
|
||||
return reconcileLocked(null, true);
|
||||
});
|
||||
}
|
||||
|
||||
async function reconcilePolicies() {
|
||||
const observed = observePolicy
|
||||
? await Promise.resolve().then(() => observePolicy()).catch(() => null)
|
||||
: null;
|
||||
return serializePolicy(() => reconcileLocked(observed, true));
|
||||
}
|
||||
|
||||
return { snapshot, refresh, update, setPolicy, reconcilePolicies };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
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;
|
||||
|
||||
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) || !INTERFACE_PATTERN.test(normalized.interface)
|
||||
|| normalized.interface.startsWith('br-') || 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) };
|
||||
}
|
||||
Reference in New Issue
Block a user