Add per-device VPN and direct routing policies
This commit is contained in:
@@ -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