Track proxy traffic separately in device inventory
This commit is contained in:
@@ -20,6 +20,7 @@ const traffic = createDeviceTrafficService({
|
||||
uploadChain: settings.trafficUploadChain,
|
||||
downloadChain: settings.trafficDownloadChain,
|
||||
bypassCidrs: settings.bypassCidrs,
|
||||
proxyPort: settings.proxyPort,
|
||||
});
|
||||
const devicePolicy = createDevicePolicyService({
|
||||
chain: settings.devicePolicyChain,
|
||||
|
||||
@@ -15,6 +15,7 @@ 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 восстановлен из корректных данных';
|
||||
|
||||
const DEFAULT_DEVICE_POLICY = Object.freeze({
|
||||
desired: 'vpn',
|
||||
@@ -36,6 +37,15 @@ const DEFAULT_POLICY_STATE = {
|
||||
byMac: {},
|
||||
};
|
||||
|
||||
const DEFAULT_PROXY_TRAFFIC = {
|
||||
schemaVersion: 1,
|
||||
lastObservedAt: null,
|
||||
lastError: null,
|
||||
baselinesByMac: {},
|
||||
totalsByMac: {},
|
||||
rebaselineMacs: [],
|
||||
};
|
||||
|
||||
const DEFAULT_STATE = {
|
||||
schemaVersion: DEVICE_INVENTORY_SCHEMA_VERSION,
|
||||
revision: 0,
|
||||
@@ -50,6 +60,7 @@ const DEFAULT_STATE = {
|
||||
baselinesByMac: {},
|
||||
totalsByMac: {},
|
||||
rebaselineMacs: [],
|
||||
proxy: DEFAULT_PROXY_TRAFFIC,
|
||||
},
|
||||
devices: [],
|
||||
};
|
||||
@@ -65,6 +76,68 @@ const parseStoredCounter = (value) => {
|
||||
return COUNTER_PATTERN.test(counter) ? BigInt(counter).toString() : null;
|
||||
};
|
||||
|
||||
function normalizeProxyTraffic(value, devices) {
|
||||
const proxy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
if (Number.isSafeInteger(proxy.schemaVersion) && proxy.schemaVersion > 1) {
|
||||
throw new Error(`Unsupported proxy traffic schemaVersion: ${proxy.schemaVersion}`);
|
||||
}
|
||||
const rebaselineMacs = new Set((Array.isArray(proxy.rebaselineMacs) ? proxy.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
let recovered = value !== undefined && (
|
||||
proxy !== value || proxy.schemaVersion !== 1
|
||||
|| !proxy.baselinesByMac || typeof proxy.baselinesByMac !== 'object' || Array.isArray(proxy.baselinesByMac)
|
||||
|| !proxy.totalsByMac || typeof proxy.totalsByMac !== 'object' || Array.isArray(proxy.totalsByMac)
|
||||
);
|
||||
if (recovered) {
|
||||
for (const device of devices) rebaselineMacs.add(normalizeMac(device.mac));
|
||||
}
|
||||
const baselinesByMac = {};
|
||||
for (const [rawMac, baseline] of recordEntries(proxy.baselinesByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(baseline?.uploadBytes);
|
||||
const downloadBytes = parseStoredCounter(baseline?.downloadBytes);
|
||||
if (!MAC_PATTERN.test(mac) || typeof baseline?.epoch !== 'string' || !baseline.epoch
|
||||
|| uploadBytes == null || downloadBytes == null) {
|
||||
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
|
||||
recovered = true;
|
||||
continue;
|
||||
}
|
||||
baselinesByMac[mac] = { epoch: baseline.epoch, uploadBytes, downloadBytes };
|
||||
}
|
||||
const totalsByMac = {};
|
||||
for (const [rawMac, total] of recordEntries(proxy.totalsByMac)) {
|
||||
const mac = normalizeMac(rawMac);
|
||||
const uploadBytes = parseStoredCounter(total?.uploadBytes);
|
||||
const downloadBytes = parseStoredCounter(total?.downloadBytes);
|
||||
if (!MAC_PATTERN.test(mac) || uploadBytes == null || downloadBytes == null) {
|
||||
if (MAC_PATTERN.test(mac)) rebaselineMacs.add(mac);
|
||||
recovered = true;
|
||||
continue;
|
||||
}
|
||||
totalsByMac[mac] = {
|
||||
uploadBytes,
|
||||
downloadBytes,
|
||||
observedAt: typeof total?.observedAt === 'string' ? total.observedAt : null,
|
||||
};
|
||||
}
|
||||
for (const mac of new Set([...Object.keys(baselinesByMac), ...Object.keys(totalsByMac)])) {
|
||||
if (!Object.hasOwn(baselinesByMac, mac) || !Object.hasOwn(totalsByMac, mac)) {
|
||||
rebaselineMacs.add(mac);
|
||||
recovered = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
lastObservedAt: typeof proxy.lastObservedAt === 'string' ? proxy.lastObservedAt : null,
|
||||
lastError: recovered
|
||||
? PROXY_RECOVERY_ERROR
|
||||
: typeof proxy.lastError === 'string' ? proxy.lastError : null,
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePolicyState(value) {
|
||||
const policy = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const byMac = {};
|
||||
@@ -142,6 +215,7 @@ export function migrateDeviceInventoryState(value) {
|
||||
? state.traffic
|
||||
: {};
|
||||
const devices = Array.isArray(state.devices) ? state.devices : [];
|
||||
const proxyTraffic = normalizeProxyTraffic(traffic.proxy, devices);
|
||||
const rebaselineMacs = new Set((Array.isArray(traffic.rebaselineMacs) ? traffic.rebaselineMacs : [])
|
||||
.map(normalizeMac).filter((mac) => MAC_PATTERN.test(mac)));
|
||||
let recoveredTraffic = version >= 2 && (
|
||||
@@ -202,6 +276,7 @@ export function migrateDeviceInventoryState(value) {
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs].filter((mac) => MAC_PATTERN.test(mac)),
|
||||
proxy: proxyTraffic,
|
||||
},
|
||||
devices,
|
||||
};
|
||||
@@ -276,6 +351,7 @@ 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 proxyTraffic = state.traffic.proxy.totalsByMac[device.mac];
|
||||
const policy = policyFor(state, device.mac);
|
||||
return {
|
||||
...device,
|
||||
@@ -283,6 +359,9 @@ export function createDeviceInventoryService({
|
||||
uploadBytes: traffic?.uploadBytes || '0',
|
||||
downloadBytes: traffic?.downloadBytes || '0',
|
||||
trafficObservedAt: traffic?.observedAt || null,
|
||||
proxyUploadBytes: proxyTraffic?.uploadBytes || '0',
|
||||
proxyDownloadBytes: proxyTraffic?.downloadBytes || '0',
|
||||
proxyTrafficObservedAt: proxyTraffic?.observedAt || null,
|
||||
desiredPolicy: policy.desired,
|
||||
appliedPolicy: policy.applied,
|
||||
policyStatus: policy.status,
|
||||
@@ -303,6 +382,10 @@ export function createDeviceInventoryService({
|
||||
traffic: {
|
||||
lastObservedAt: state.traffic.lastObservedAt,
|
||||
error: state.traffic.lastError,
|
||||
proxy: {
|
||||
lastObservedAt: state.traffic.proxy.lastObservedAt,
|
||||
error: state.traffic.proxy.lastError,
|
||||
},
|
||||
},
|
||||
policy: {
|
||||
lastAppliedAt: state.policy.lastAppliedAt,
|
||||
@@ -489,8 +572,13 @@ export function createDeviceInventoryService({
|
||||
if (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch) {
|
||||
throw new Error('Dataplane не вернул traffic epoch');
|
||||
}
|
||||
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices : [];
|
||||
const processByMac = new Map();
|
||||
for (const row of Array.isArray(trafficResult.devices) ? trafficResult.devices : []) {
|
||||
const proxyByMac = new Map();
|
||||
let proxyRows = 0;
|
||||
let legacyRows = 0;
|
||||
let proxySampleError = null;
|
||||
for (const row of rows) {
|
||||
const mac = normalizeMac(row?.mac);
|
||||
const upload = String(row?.uploadBytes ?? '');
|
||||
const download = String(row?.downloadBytes ?? '');
|
||||
@@ -502,7 +590,30 @@ export function createDeviceInventoryService({
|
||||
upload: previous.upload + BigInt(upload),
|
||||
download: previous.download + BigInt(download),
|
||||
});
|
||||
const hasProxyUpload = Object.hasOwn(row || {}, 'proxyUploadBytes');
|
||||
const hasProxyDownload = Object.hasOwn(row || {}, 'proxyDownloadBytes');
|
||||
if (!hasProxyUpload && !hasProxyDownload) {
|
||||
legacyRows += 1;
|
||||
continue;
|
||||
}
|
||||
if (!hasProxyUpload || !hasProxyDownload) {
|
||||
proxySampleError = 'Dataplane вернул неполный proxy traffic counter';
|
||||
continue;
|
||||
}
|
||||
const proxyUpload = String(row.proxyUploadBytes);
|
||||
const proxyDownload = String(row.proxyDownloadBytes);
|
||||
if (!COUNTER_PATTERN.test(proxyUpload) || !COUNTER_PATTERN.test(proxyDownload)) {
|
||||
proxySampleError = 'Dataplane вернул невалидный proxy traffic counter';
|
||||
continue;
|
||||
}
|
||||
proxyRows += 1;
|
||||
const previousProxy = proxyByMac.get(mac) || { upload: 0n, download: 0n };
|
||||
proxyByMac.set(mac, {
|
||||
upload: previousProxy.upload + BigInt(proxyUpload),
|
||||
download: previousProxy.download + BigInt(proxyDownload),
|
||||
});
|
||||
}
|
||||
if (proxyRows && legacyRows) proxySampleError = 'Dataplane смешал старый и новый proxy traffic contract';
|
||||
const knownMacs = new Set(devices.map((device) => device.mac));
|
||||
const baselinesByMac = { ...traffic.baselinesByMac };
|
||||
const totalsByMac = { ...traffic.totalsByMac };
|
||||
@@ -546,6 +657,73 @@ export function createDeviceInventoryService({
|
||||
rebaselineMacs.delete(mac);
|
||||
}
|
||||
}
|
||||
const proxyBaselines = { ...traffic.proxy.baselinesByMac };
|
||||
const proxyTotals = { ...traffic.proxy.totalsByMac };
|
||||
const proxyRebaseline = new Set(traffic.proxy.rebaselineMacs);
|
||||
for (const mac of new Set([
|
||||
...Object.keys(proxyTotals),
|
||||
...Object.keys(proxyBaselines),
|
||||
...proxyRebaseline,
|
||||
])) {
|
||||
if (knownMacs.has(mac)) continue;
|
||||
delete proxyTotals[mac];
|
||||
delete proxyBaselines[mac];
|
||||
proxyRebaseline.delete(mac);
|
||||
}
|
||||
let proxy = {
|
||||
...traffic.proxy,
|
||||
lastError: !proxyRebaseline.size && traffic.proxy.lastError === PROXY_RECOVERY_ERROR
|
||||
? null
|
||||
: traffic.proxy.lastError,
|
||||
baselinesByMac: proxyBaselines,
|
||||
totalsByMac: proxyTotals,
|
||||
rebaselineMacs: [...proxyRebaseline],
|
||||
};
|
||||
if (proxySampleError) {
|
||||
proxy = { ...proxy, lastError: proxySampleError };
|
||||
} else if (proxyRows) {
|
||||
try {
|
||||
const nextProxyBaselines = { ...proxyBaselines };
|
||||
const nextProxyTotals = { ...proxyTotals };
|
||||
const nextProxyRebaseline = new Set(proxyRebaseline);
|
||||
for (const [mac, processTotal] of proxyByMac) {
|
||||
if (!knownMacs.has(mac)) continue;
|
||||
const baseline = nextProxyBaselines[mac];
|
||||
const recovering = nextProxyRebaseline.has(mac);
|
||||
const sameEpoch = !recovering && baseline?.epoch === trafficResult.epoch;
|
||||
const baselineUpload = sameEpoch ? BigInt(baseline.uploadBytes) : 0n;
|
||||
const baselineDownload = sameEpoch ? BigInt(baseline.downloadBytes) : 0n;
|
||||
if (processTotal.upload < baselineUpload || processTotal.download < baselineDownload) {
|
||||
throw new Error('Dataplane proxy traffic counter уменьшился внутри одного epoch');
|
||||
}
|
||||
const total = nextProxyTotals[mac] || { uploadBytes: '0', downloadBytes: '0' };
|
||||
nextProxyTotals[mac] = {
|
||||
uploadBytes: (BigInt(total.uploadBytes)
|
||||
+ (recovering ? 0n : processTotal.upload - baselineUpload)).toString(),
|
||||
downloadBytes: (BigInt(total.downloadBytes)
|
||||
+ (recovering ? 0n : processTotal.download - baselineDownload)).toString(),
|
||||
observedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
||||
};
|
||||
nextProxyBaselines[mac] = {
|
||||
epoch: trafficResult.epoch,
|
||||
uploadBytes: processTotal.upload.toString(),
|
||||
downloadBytes: processTotal.download.toString(),
|
||||
};
|
||||
nextProxyRebaseline.delete(mac);
|
||||
}
|
||||
proxy = {
|
||||
...proxy,
|
||||
lastObservedAt: trafficResult.observedAt || proxy.lastObservedAt,
|
||||
lastError: trafficResult.source?.error
|
||||
|| (nextProxyRebaseline.size ? proxy.lastError : null),
|
||||
baselinesByMac: nextProxyBaselines,
|
||||
totalsByMac: nextProxyTotals,
|
||||
rebaselineMacs: [...nextProxyRebaseline],
|
||||
};
|
||||
} catch (error) {
|
||||
proxy = { ...proxy, lastError: error.message || String(error) };
|
||||
}
|
||||
}
|
||||
traffic = {
|
||||
...traffic,
|
||||
epoch: trafficResult.epoch,
|
||||
@@ -556,6 +734,7 @@ export function createDeviceInventoryService({
|
||||
baselinesByMac,
|
||||
totalsByMac,
|
||||
rebaselineMacs: [...rebaselineMacs],
|
||||
proxy,
|
||||
};
|
||||
} catch (error) {
|
||||
traffic = { ...traffic, lastError: error.message || String(error) };
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
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 INTERFACE_PATTERN = /^[a-z0-9_.:-]{1,15}$/i;
|
||||
const CHAIN_PATTERN = /^[a-z0-9_]{1,24}$/i;
|
||||
const COUNTERS = [
|
||||
['upload', 'upload', 'uploadBytes'],
|
||||
['download', 'download', 'downloadBytes'],
|
||||
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
|
||||
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
|
||||
];
|
||||
|
||||
const childChain = (chain, slot) => `${chain}_${slot}`;
|
||||
const proxyChildChain = (chain, slot) => `${childChain(chain, slot)}_P`;
|
||||
const counterKey = ({ ip, mac, interface: deviceInterface }) => crypto
|
||||
.createHash('sha256')
|
||||
.update(`${ip}|${mac}|${deviceInterface}`)
|
||||
@@ -19,6 +27,55 @@ function commandError(command, result) {
|
||||
).trim());
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = COMMAND_OPTIONS) {
|
||||
return new Promise((resolve) => {
|
||||
let child;
|
||||
try {
|
||||
child = spawn(command, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch (error) {
|
||||
resolve({ status: null, stdout: '', stderr: '', error });
|
||||
return;
|
||||
}
|
||||
const stdout = [];
|
||||
const stderr = [];
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let timer;
|
||||
const finish = (result) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
stdout: Buffer.concat(stdout).toString(options.encoding || 'utf8'),
|
||||
stderr: Buffer.concat(stderr).toString(options.encoding || 'utf8'),
|
||||
...result,
|
||||
});
|
||||
};
|
||||
child.stdout.on('data', (chunk) => stdout.push(chunk));
|
||||
child.stderr.on('data', (chunk) => stderr.push(chunk));
|
||||
child.on('error', (error) => finish({ status: null, error }));
|
||||
child.on('close', (status) => finish({
|
||||
status,
|
||||
error: timedOut ? new Error(`${command} превысил ${options.timeout} мс`) : null,
|
||||
}));
|
||||
timer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
child.kill(options.killSignal || 'SIGKILL');
|
||||
}, options.timeout);
|
||||
child.stdin.on('error', () => {});
|
||||
child.stdin.end(options.input == null ? undefined : options.input);
|
||||
});
|
||||
}
|
||||
|
||||
function isIpv4Cidr(value) {
|
||||
const [address, prefix, extra] = String(value).split('/');
|
||||
const size = Number(prefix);
|
||||
return extra === undefined && net.isIPv4(address)
|
||||
&& Number.isInteger(size) && size >= 0 && size <= 32;
|
||||
}
|
||||
|
||||
const zeroCounters = () => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
|
||||
|
||||
export function selectTrafficDevices(observations) {
|
||||
const candidates = new Map();
|
||||
const ipsByMac = new Map();
|
||||
@@ -49,57 +106,72 @@ export function selectTrafficDevices(observations) {
|
||||
));
|
||||
}
|
||||
|
||||
export function buildTrafficRuleCommands({
|
||||
export function buildTrafficRestore({
|
||||
devices,
|
||||
bypassCidrs,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
slot,
|
||||
proxyPort,
|
||||
}) {
|
||||
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
|
||||
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|
||||
|| proxyPort < 1 || proxyPort > 65_535
|
||||
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) {
|
||||
throw new Error('Некорректная конфигурация traffic accounting');
|
||||
}
|
||||
const uploadChild = childChain(uploadChain, slot);
|
||||
const downloadChild = childChain(downloadChain, slot);
|
||||
const commands = [
|
||||
['iptables', ['-w', '1', '-t', 'raw', '-F', uploadChild]],
|
||||
['iptables', ['-w', '1', '-t', 'mangle', '-F', downloadChild]],
|
||||
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-i', 'br-+', '-j', 'RETURN']],
|
||||
const proxyUploadChild = proxyChildChain(uploadChain, slot);
|
||||
const proxyDownloadChild = proxyChildChain(downloadChain, slot);
|
||||
const raw = [
|
||||
'*raw',
|
||||
`-F ${uploadChild}`,
|
||||
`-F ${proxyUploadChild}`,
|
||||
`-A ${uploadChild} -i br-+ -j RETURN`,
|
||||
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
|
||||
`-A ${uploadChild} -p tcp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
|
||||
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j ${proxyUploadChild}`,
|
||||
`-A ${uploadChild} -p udp --dport ${proxyPort} -m addrtype --dst-type LOCAL -j RETURN`,
|
||||
];
|
||||
const mangle = [
|
||||
'*mangle',
|
||||
`-F ${downloadChild}`,
|
||||
`-F ${proxyDownloadChild}`,
|
||||
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||
`-A ${downloadChild} -p tcp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
|
||||
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j ${proxyDownloadChild}`,
|
||||
`-A ${downloadChild} -p udp --sport ${proxyPort} -m addrtype --src-type LOCAL -j RETURN`,
|
||||
];
|
||||
|
||||
for (const device of devices) {
|
||||
for (const protocol of ['tcp', 'udp']) {
|
||||
raw.push(`-A ${proxyUploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-upload -j RETURN`);
|
||||
mangle.push(`-A ${proxyDownloadChild} -o ${device.interface} -d ${device.ip} -p ${protocol} -m comment --comment harbor-traffic:${device.key}:proxy-download -j RETURN`);
|
||||
}
|
||||
}
|
||||
for (const cidr of bypassCidrs) {
|
||||
commands.push(
|
||||
['iptables', ['-w', '1', '-t', 'raw', '-A', uploadChild, '-d', cidr, '-j', 'RETURN']],
|
||||
['iptables', ['-w', '1', '-t', 'mangle', '-A', downloadChild, '-s', cidr, '-j', 'RETURN']],
|
||||
);
|
||||
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
|
||||
mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`);
|
||||
}
|
||||
for (const device of devices) {
|
||||
commands.push(
|
||||
['iptables', [
|
||||
'-w', '1', '-t', 'raw', '-A', uploadChild,
|
||||
'-i', device.interface, '-s', device.ip,
|
||||
'-m', 'mac', '--mac-source', device.mac,
|
||||
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:upload`,
|
||||
'-j', 'RETURN',
|
||||
]],
|
||||
['iptables', [
|
||||
'-w', '1', '-t', 'mangle', '-A', downloadChild,
|
||||
'-o', device.interface, '-d', device.ip,
|
||||
'-m', 'comment', '--comment', `harbor-traffic:${device.key}:download`,
|
||||
'-j', 'RETURN',
|
||||
]],
|
||||
);
|
||||
raw.push(`-A ${uploadChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m comment --comment harbor-traffic:${device.key}:upload -j RETURN`);
|
||||
mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m comment --comment harbor-traffic:${device.key}:download -j RETURN`);
|
||||
}
|
||||
return commands;
|
||||
return [...raw, 'COMMIT', ...mangle, 'COMMIT', ''].join('\n');
|
||||
}
|
||||
|
||||
export function parseTrafficCounters(text, chain) {
|
||||
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const linePattern = new RegExp(
|
||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download)"?`,
|
||||
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
|
||||
);
|
||||
const counters = new Map();
|
||||
for (const line of String(text || '').split(/\r?\n/)) {
|
||||
const match = line.match(linePattern);
|
||||
if (!match) continue;
|
||||
counters.set(`${match[3]}:${match[4]}`, match[2]);
|
||||
const key = `${match[3]}:${match[4]}`;
|
||||
counters.set(key, (BigInt(counters.get(key) || '0') + BigInt(match[2])).toString());
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
@@ -109,7 +181,8 @@ export function createDeviceTrafficService({
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
bypassCidrs,
|
||||
run = spawnSync,
|
||||
proxyPort,
|
||||
run = runCommand,
|
||||
nextGeneration = () => crypto.randomUUID(),
|
||||
}) {
|
||||
const epoch = nextGeneration();
|
||||
@@ -129,23 +202,25 @@ export function createDeviceTrafficService({
|
||||
devices: [],
|
||||
};
|
||||
|
||||
function execute(command, args) {
|
||||
const result = run(command, args, COMMAND_OPTIONS);
|
||||
async function execute(command, args, options = COMMAND_OPTIONS) {
|
||||
const result = await run(command, args, options);
|
||||
if (result.error || result.status !== 0) throw commandError(command, result);
|
||||
return String(result.stdout || '');
|
||||
}
|
||||
|
||||
function prepare(slot, devices) {
|
||||
for (const [command, args] of buildTrafficRuleCommands({
|
||||
async function prepare(slot, devices) {
|
||||
const input = buildTrafficRestore({
|
||||
devices,
|
||||
bypassCidrs,
|
||||
uploadChain,
|
||||
downloadChain,
|
||||
slot,
|
||||
})) execute(command, args);
|
||||
proxyPort,
|
||||
});
|
||||
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input });
|
||||
}
|
||||
|
||||
function switchTo(slot) {
|
||||
async function switchTo(slot) {
|
||||
const uploadChild = childChain(uploadChain, slot);
|
||||
const downloadChild = childChain(downloadChain, slot);
|
||||
const replace = activeSlot ? '-R' : '-A';
|
||||
@@ -156,32 +231,35 @@ export function createDeviceTrafficService({
|
||||
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
|
||||
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
|
||||
|
||||
execute('iptables', uploadArgs);
|
||||
await execute('iptables', uploadArgs);
|
||||
try {
|
||||
execute('iptables', downloadArgs);
|
||||
await execute('iptables', downloadArgs);
|
||||
} catch (error) {
|
||||
const rollbackArgs = activeSlot
|
||||
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
|
||||
: ['-w', '1', '-t', 'raw', '-F', uploadChain];
|
||||
execute('iptables', rollbackArgs);
|
||||
await execute('iptables', rollbackArgs);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readCounters(devices, slot) {
|
||||
async function readCounters(devices, slot) {
|
||||
if (!slot) return new Map();
|
||||
const upload = parseTrafficCounters(
|
||||
const [raw, mangle] = await Promise.all([
|
||||
execute('iptables-save', ['-c', '-t', 'raw']),
|
||||
childChain(uploadChain, slot),
|
||||
);
|
||||
const download = parseTrafficCounters(
|
||||
execute('iptables-save', ['-c', '-t', 'mangle']),
|
||||
childChain(downloadChain, slot),
|
||||
);
|
||||
]);
|
||||
const parsed = {
|
||||
upload: parseTrafficCounters(raw, childChain(uploadChain, slot)),
|
||||
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
|
||||
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
|
||||
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
|
||||
};
|
||||
const counters = new Map();
|
||||
for (const { key } of devices) {
|
||||
counters.set(`${key}:upload`, upload.get(`${key}:upload`) || '0');
|
||||
counters.set(`${key}:download`, download.get(`${key}:download`) || '0');
|
||||
for (const [kind, field] of COUNTERS) {
|
||||
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
|
||||
}
|
||||
}
|
||||
return counters;
|
||||
}
|
||||
@@ -194,15 +272,14 @@ export function createDeviceTrafficService({
|
||||
for (const device of devices) devicesByKey.set(device.key, device);
|
||||
}
|
||||
|
||||
function finalizeRetired() {
|
||||
async function finalizeRetired() {
|
||||
if (!pendingRetired) return false;
|
||||
const counters = readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||
const counters = await readCounters(pendingRetired.devices, pendingRetired.slot);
|
||||
for (const { key } of pendingRetired.devices) {
|
||||
const previous = finalized.get(key) || { upload: 0n, download: 0n };
|
||||
finalized.set(key, {
|
||||
upload: previous.upload + counter(counters, key, 'upload'),
|
||||
download: previous.download + counter(counters, key, 'download'),
|
||||
});
|
||||
const previous = finalized.get(key) || zeroCounters();
|
||||
const next = { ...previous };
|
||||
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
|
||||
finalized.set(key, next);
|
||||
}
|
||||
pendingRetired = null;
|
||||
return true;
|
||||
@@ -212,27 +289,21 @@ export function createDeviceTrafficService({
|
||||
const activeByMac = new Map(activeDevices.map((device) => [device.mac, device]));
|
||||
const totalsByMac = new Map();
|
||||
for (const [key, remembered] of devicesByKey) {
|
||||
const base = finalized.get(key) || { upload: 0n, download: 0n };
|
||||
const base = finalized.get(key) || zeroCounters();
|
||||
const pending = pendingRetired?.counters || new Map();
|
||||
const upload = base.upload
|
||||
+ counter(pending, key, 'upload')
|
||||
+ counter(activeCounters, key, 'upload');
|
||||
const download = base.download
|
||||
+ counter(pending, key, 'download')
|
||||
+ counter(activeCounters, key, 'download');
|
||||
const previous = totalsByMac.get(remembered.mac) || { upload: 0n, download: 0n };
|
||||
totalsByMac.set(remembered.mac, {
|
||||
...(activeByMac.get(remembered.mac) || remembered),
|
||||
upload: previous.upload + upload,
|
||||
download: previous.download + download,
|
||||
});
|
||||
const previous = totalsByMac.get(remembered.mac) || zeroCounters();
|
||||
const total = { ...(activeByMac.get(remembered.mac) || remembered) };
|
||||
for (const [kind, field] of COUNTERS) {
|
||||
total[field] = previous[field] + base[field]
|
||||
+ counter(pending, key, kind) + counter(activeCounters, key, kind);
|
||||
}
|
||||
totalsByMac.set(remembered.mac, total);
|
||||
}
|
||||
return [...totalsByMac.values()]
|
||||
.map(({ key: _key, upload, download, ...device }) => ({
|
||||
...device,
|
||||
uploadBytes: upload.toString(),
|
||||
downloadBytes: download.toString(),
|
||||
}))
|
||||
.map((total) => Object.fromEntries([
|
||||
...Object.entries(total).filter(([key]) => key !== 'key' && !COUNTERS.some(([, field]) => field === key)),
|
||||
...COUNTERS.map(([, field, output]) => [output, total[field].toString()]),
|
||||
]))
|
||||
.sort((left, right) => left.mac.localeCompare(right.mac));
|
||||
}
|
||||
|
||||
@@ -253,7 +324,7 @@ export function createDeviceTrafficService({
|
||||
|
||||
if (pendingRetired) {
|
||||
try {
|
||||
countersRead = finalizeRetired() || countersRead;
|
||||
countersRead = await finalizeRetired() || countersRead;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || error.message || String(error);
|
||||
}
|
||||
@@ -262,8 +333,8 @@ export function createDeviceTrafficService({
|
||||
if (!pendingRetired && !sourceError && nextSignature !== activeSignature) {
|
||||
const nextSlot = activeSlot === 'A' ? 'B' : 'A';
|
||||
try {
|
||||
prepare(nextSlot, nextDevices);
|
||||
switchTo(nextSlot);
|
||||
await prepare(nextSlot, nextDevices);
|
||||
await switchTo(nextSlot);
|
||||
const retired = activeSlot ? {
|
||||
slot: activeSlot,
|
||||
devices: activeDevices,
|
||||
@@ -276,14 +347,14 @@ export function createDeviceTrafficService({
|
||||
pendingRetired = retired;
|
||||
remember(nextDevices);
|
||||
current.generation = nextGeneration();
|
||||
if (pendingRetired) countersRead = finalizeRetired() || countersRead;
|
||||
if (pendingRetired) countersRead = await finalizeRetired() || countersRead;
|
||||
} catch (error) {
|
||||
sourceError = error.message || String(error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
activeCounters = readCounters(activeDevices, activeSlot);
|
||||
activeCounters = await readCounters(activeDevices, activeSlot);
|
||||
countersRead = true;
|
||||
} catch (error) {
|
||||
sourceError = sourceError || error.message || String(error);
|
||||
|
||||
Reference in New Issue
Block a user