Improve VPN client connection management
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-12 21:48:37 +03:00
parent 068a7f9890
commit 9e52ccc24d
22 changed files with 962 additions and 76 deletions
+6
View File
@@ -22,6 +22,12 @@ export const settings = {
devicePolicyChain: process.env.DEVICE_POLICY_CHAIN || "VPN_PROXY_DEVICE_POLICY",
trafficUploadChain: process.env.TRAFFIC_UPLOAD_CHAIN || "VPN_PROXY_TRAFFIC_UP",
trafficDownloadChain: process.env.TRAFFIC_DOWNLOAD_CHAIN || "VPN_PROXY_TRAFFIC_DOWN",
deviceTrafficAccountingEnabled: process.env.DEVICE_TRAFFIC_ACCOUNTING_ENABLED !== "false",
directTrafficChain: process.env.DIRECT_TRAFFIC_CHAIN || "VPN_PROXY_DIRECT",
directTrafficMark: process.env.DIRECT_TRAFFIC_MARK || "0x40000000",
gatewayClientCidrs: (process.env.GATEWAY_CLIENT_CIDRS
|| "10.0.0.0/8 172.16.0.0/12 192.168.0.0/16")
.trim().split(/\s+/).filter(Boolean),
bypassCidrs: (process.env.BYPASS_CIDRS
|| "0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16 172.16.0.0/12 192.168.0.0/16 224.0.0.0/4 240.0.0.0/4")
.trim().split(/\s+/).filter(Boolean),
+14 -8
View File
@@ -25,6 +25,10 @@ const traffic = createDeviceTrafficService({
observe: () => readNeighborSnapshot(),
uploadChain: settings.trafficUploadChain,
downloadChain: settings.trafficDownloadChain,
directChain: settings.directTrafficChain,
directMark: settings.directTrafficMark,
tproxyMark: settings.tproxyMark,
gatewayClientCidrs: settings.gatewayClientCidrs,
bypassCidrs: settings.bypassCidrs,
proxyPort: settings.proxyPort,
});
@@ -147,14 +151,16 @@ server.listen(socketPath, async () => {
console.warn(`[dataplane] sing-box не запущен: ${errorMessage(error)}`);
} finally {
ready = true;
setImmediate(() => {
traffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
});
trafficTimer = setInterval(() => {
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
}, 15_000);
trafficTimer.unref();
if (settings.deviceTrafficAccountingEnabled) {
setImmediate(() => {
traffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] traffic counters не запущены: ${errorMessage(error)}`));
});
trafficTimer = setInterval(() => {
traffic.refresh().catch((error: unknown) => console.warn(`[dataplane] traffic counters не обновлены: ${errorMessage(error)}`));
}, 15_000);
trafficTimer.unref();
}
setImmediate(() => {
domainTraffic.refresh()
.catch((error: unknown) => console.warn(`[dataplane] domain traffic не запущен: ${errorMessage(error)}`));
+11 -1
View File
@@ -1,8 +1,10 @@
import { spawnSync } from 'node:child_process';
const options = { encoding: 'utf8' as const };
const CHAIN_PATTERN = /^[a-z0-9_-]{1,28}$/i;
export function setGatewayInterception(enabled: boolean, chain: string, run: typeof spawnSync = spawnSync) {
if (!CHAIN_PATTERN.test(chain)) throw new Error('Некорректная TProxy chain');
const rule = ['-w', '-t', 'mangle', 'PREROUTING', '-j', chain];
const exists = run('iptables', [...rule.slice(0, 3), '-C', ...rule.slice(3)], options).status === 0;
@@ -10,7 +12,15 @@ export function setGatewayInterception(enabled: boolean, chain: string, run: typ
if (exists) run('iptables', [...rule.slice(0, 3), '-D', ...rule.slice(3)], options);
return;
}
if (exists) return;
if (exists) {
const input = `*mangle\n-D PREROUTING -j ${chain}\n-I PREROUTING 1 -j ${chain}\nCOMMIT\n`;
const result = run('iptables-restore', ['-w', '--noflush'], { ...options, input });
if (result.status !== 0) {
// The transaction keeps the already-working jump intact; leave routing up.
return;
}
return;
}
const result = run(
'iptables',
+86
View File
@@ -114,7 +114,88 @@ export function renderPrometheusMetrics(value: unknown) {
}
}
const directTraffic = record(snapshot.directTraffic);
const directSeries = Array.isArray(directTraffic.series) ? directTraffic.series.map(record) : [];
const directObservedAt = timestamp(directTraffic.observedAt);
if (directObservedAt) {
lines.push(
'# HELP harbor_direct_ipv4_packet_bytes_total IPv4 L3 packet bytes forwarded directly instead of entering sing-box; includes IP headers and retransmissions.',
'# TYPE harbor_direct_ipv4_packet_bytes_total counter',
);
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'download' }, counter(directTraffic.downloadBytes));
metric(lines, 'harbor_direct_ipv4_packet_bytes_total', { direction: 'upload' }, counter(directTraffic.uploadBytes));
}
if (directSeries.length) {
lines.push(
'# HELP harbor_device_direct_ipv4_packet_bytes_total Attributed IPv4 L3 packet bytes forwarded directly instead of entering sing-box.',
'# TYPE harbor_device_direct_ipv4_packet_bytes_total counter',
);
for (const series of directSeries) {
for (const [direction, amount] of [
['download', series.downloadBytes],
['upload', series.uploadBytes],
]) {
metric(lines, 'harbor_device_direct_ipv4_packet_bytes_total', {
device_id: series.deviceId,
direction,
}, counter(amount));
}
}
}
if (directObservedAt) {
lines.push(
'# HELP harbor_direct_ipv4_packet_last_observed_timestamp_seconds Unix timestamp of the last successful direct IPv4 packet observation.',
'# TYPE harbor_direct_ipv4_packet_last_observed_timestamp_seconds gauge',
);
lines.push(`harbor_direct_ipv4_packet_last_observed_timestamp_seconds ${directObservedAt}`);
}
const domainTraffic = record(snapshot.domainTraffic);
const trackedSeries = Array.isArray(domainTraffic.tracked) ? domainTraffic.tracked.map(record) : [];
if (trackedSeries.length) {
lines.push(
'# HELP harbor_singbox_tracked_bytes_total Bytes observed by the sing-box TCP/UDP tracker; excludes IP and tunnel overhead and may miss short connections.',
'# TYPE harbor_singbox_tracked_bytes_total counter',
);
for (const series of trackedSeries) {
const source = String(series.source || '');
const outbound = String(series.outbound || '');
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
throw new Error('Invalid sing-box outbound labels');
}
for (const [direction, amount] of [
['download', series.downloadBytes],
['upload', series.uploadBytes],
]) {
metric(lines, 'harbor_singbox_tracked_bytes_total', { source, outbound, direction }, counter(amount));
}
}
}
const routeSeries = Array.isArray(domainTraffic.routes) ? domainTraffic.routes.map(record) : [];
if (routeSeries.length) {
lines.push(
'# HELP harbor_device_singbox_tracked_bytes_total Attributed bytes observed by the sing-box TCP/UDP tracker for a selected outbound.',
'# TYPE harbor_device_singbox_tracked_bytes_total counter',
);
for (const series of routeSeries) {
const source = String(series.source || '');
const outbound = String(series.outbound || '');
if (!['gateway', 'proxy'].includes(source) || !['vpn', 'direct', 'unknown'].includes(outbound)) {
throw new Error('Invalid sing-box outbound labels');
}
for (const [direction, amount] of [
['download', series.downloadBytes],
['upload', series.uploadBytes],
]) {
metric(lines, 'harbor_device_singbox_tracked_bytes_total', {
device_id: series.deviceId,
source,
outbound,
direction,
}, counter(amount));
}
}
}
const domainSeries = Array.isArray(domainTraffic.series) ? domainTraffic.series.map(record) : [];
if (domainSeries.length) {
lines.push(
@@ -143,6 +224,11 @@ export function renderPrometheusMetrics(value: unknown) {
'# TYPE harbor_domain_traffic_last_observed_timestamp_seconds gauge',
);
lines.push(`harbor_domain_traffic_last_observed_timestamp_seconds ${domainObservedAt}`);
lines.push(
'# HELP harbor_singbox_traffic_last_observed_timestamp_seconds Unix timestamp of the last successful sing-box traffic observation.',
'# TYPE harbor_singbox_traffic_last_observed_timestamp_seconds gauge',
`harbor_singbox_traffic_last_observed_timestamp_seconds ${domainObservedAt}`,
);
}
if (domainTraffic.overflowConnections != null) {
lines.push(
+62 -1
View File
@@ -704,6 +704,14 @@ export function createDeviceInventoryService({
},
series: [],
};
let directTrafficSnapshot: Record<string, unknown> = {
epoch: null,
observedAt: null,
source: { error: null },
uploadBytes: '0',
downloadBytes: '0',
series: [],
};
function captureTrafficHistory(state: InventoryState) {
const knownMacs = new Set(state.devices.map(({ mac }) => mac));
@@ -877,7 +885,7 @@ export function createDeviceInventoryService({
}
function metricsSnapshot() {
return { ...snapshot(), domainTraffic: domainTrafficSnapshot };
return { ...snapshot(), domainTraffic: domainTrafficSnapshot, directTraffic: directTrafficSnapshot };
}
function markPolicyEpoch(observed: unknown) {
@@ -1064,6 +1072,59 @@ export function createDeviceInventoryService({
} else if (domainTrafficResult) {
domainTrafficSnapshot = record(domainTrafficResult);
}
if (typeof trafficResult?.transportError === 'string') {
directTrafficSnapshot = {
...directTrafficSnapshot,
source: { error: trafficResult.transportError },
};
} else if (trafficResult) {
try {
const rows = Array.isArray(trafficResult.devices) ? trafficResult.devices.map(record) : [];
const hasDirect = rows.some((row) => Object.hasOwn(row, 'directUploadBytes') || Object.hasOwn(row, 'directDownloadBytes'));
const direct = record(trafficResult.direct);
const hasDirectTotal = Object.hasOwn(direct, 'uploadBytes') || Object.hasOwn(direct, 'downloadBytes');
if ((hasDirectTotal && rows.some((row) => !Object.hasOwn(row, 'directUploadBytes') || !Object.hasOwn(row, 'directDownloadBytes')))
|| (!hasDirectTotal && hasDirect)
|| (hasDirectTotal && (!Object.hasOwn(direct, 'uploadBytes') || !Object.hasOwn(direct, 'downloadBytes')))) {
throw new Error('Dataplane вернул неполный direct traffic counter');
}
const uploadBytes = String(direct.uploadBytes ?? '0');
const downloadBytes = String(direct.downloadBytes ?? '0');
if (hasDirectTotal && (!COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes))) {
throw new Error('Dataplane вернул невалидный global direct traffic counter');
}
if (hasDirectTotal && (typeof trafficResult.epoch !== 'string' || !trafficResult.epoch
|| !validTimestamp(trafficResult.observedAt))) {
throw new Error('Dataplane вернул невалидную direct traffic identity');
}
const series = hasDirectTotal ? rows.map((row) => {
const mac = normalizeMac(row.mac);
const uploadBytes = String(row.directUploadBytes ?? '');
const downloadBytes = String(row.directDownloadBytes ?? '');
if (!MAC_PATTERN.test(mac) || !COUNTER_PATTERN.test(uploadBytes) || !COUNTER_PATTERN.test(downloadBytes)) {
throw new Error('Dataplane вернул невалидный direct traffic counter');
}
return { deviceId: deviceId(mac), uploadBytes, downloadBytes };
}) : [];
directTrafficSnapshot = {
epoch: typeof trafficResult.epoch === 'string' ? trafficResult.epoch : null,
observedAt: hasDirectTotal && typeof trafficResult.observedAt === 'string' ? trafficResult.observedAt : null,
source: {
error: typeof record(trafficResult.source).error === 'string'
? String(record(trafficResult.source).error)
: null,
},
uploadBytes,
downloadBytes,
series,
};
} catch (error) {
directTrafficSnapshot = {
...directTrafficSnapshot,
source: { error: errorMessage(error) },
};
}
}
const nextState = store.update((stored) => {
const state = migrateDeviceInventoryState(stored);
const byMac = new Map(state.devices.map((device) => [device.mac, device]));
+139 -19
View File
@@ -24,9 +24,9 @@ interface TrafficDevice {
key: string;
}
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download';
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload';
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes';
type CounterKind = 'upload' | 'download' | 'proxy-upload' | 'proxy-download' | 'direct-upload' | 'direct-download';
type CounterField = 'upload' | 'download' | 'proxyUpload' | 'proxyDownload' | 'directUpload' | 'directDownload';
type CounterOutput = 'uploadBytes' | 'downloadBytes' | 'proxyUploadBytes' | 'proxyDownloadBytes' | 'directUploadBytes' | 'directDownloadBytes';
type CounterValues = Record<CounterField, bigint>;
interface RetiredCounters {
@@ -40,6 +40,7 @@ interface TrafficSnapshot {
generation: string;
observedAt: string | null;
source: { error: string | null };
direct: { uploadBytes: string; downloadBytes: string };
devices: Record<string, unknown>[];
}
@@ -53,6 +54,8 @@ const COUNTERS = [
['download', 'download', 'downloadBytes'],
['proxy-upload', 'proxyUpload', 'proxyUploadBytes'],
['proxy-download', 'proxyDownload', 'proxyDownloadBytes'],
['direct-upload', 'directUpload', 'directUploadBytes'],
['direct-download', 'directDownload', 'directDownloadBytes'],
] as const satisfies readonly (readonly [CounterKind, CounterField, CounterOutput])[];
const childChain = (chain: string, slot: string) => `${chain}_${slot}`;
@@ -117,7 +120,37 @@ function isIpv4Cidr(value: unknown) {
&& Number.isInteger(size) && size >= 0 && size <= 32;
}
const zeroCounters = (): CounterValues => ({ upload: 0n, download: 0n, proxyUpload: 0n, proxyDownload: 0n });
function cidrRange(cidr: string) {
const [address, prefix] = cidr.split('/');
const value = address.split('.').reduce((result, octet) => result * 256n + BigInt(octet), 0n);
const bits = BigInt(Number(prefix));
const mask = bits === 0n ? 0n : (0xffff_ffffn << (32n - bits)) & 0xffff_ffffn;
const first = value & mask;
return [first, first | (0xffff_ffffn ^ mask)] as const;
}
function hasOverlappingCidrs(cidrs: readonly string[]) {
const ranges = cidrs.map(cidrRange).sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
return ranges.some(([start], index) => index > 0 && start <= ranges[index - 1][1]);
}
const zeroCounters = (): CounterValues => ({
upload: 0n,
download: 0n,
proxyUpload: 0n,
proxyDownload: 0n,
directUpload: 0n,
directDownload: 0n,
});
function markValue(value: unknown) {
try {
const parsed = BigInt(String(value));
return parsed > 0n && parsed <= 0xffff_ffffn ? parsed : null;
} catch {
return null;
}
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
@@ -160,6 +193,10 @@ export function buildTrafficRestore({
bypassCidrs,
uploadChain,
downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot,
proxyPort,
}: {
@@ -167,17 +204,28 @@ export function buildTrafficRestore({
bypassCidrs: readonly string[];
uploadChain: string;
downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: readonly string[];
slot: string;
proxyPort: number;
}) {
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain)
const parsedDirectMark = markValue(directMark);
const parsedTproxyMark = markValue(tproxyMark);
if (!CHAIN_PATTERN.test(uploadChain) || !CHAIN_PATTERN.test(downloadChain) || !CHAIN_PATTERN.test(directChain)
|| !['A', 'B'].includes(slot) || !Number.isInteger(proxyPort)
|| proxyPort < 1 || proxyPort > 65_535
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))) {
|| parsedDirectMark == null || (parsedDirectMark & (parsedDirectMark - 1n)) !== 0n
|| parsedTproxyMark == null || (parsedDirectMark & parsedTproxyMark) !== 0n
|| !Array.isArray(bypassCidrs) || bypassCidrs.some((cidr) => !isIpv4Cidr(cidr))
|| !Array.isArray(gatewayClientCidrs) || gatewayClientCidrs.some((cidr) => !isIpv4Cidr(cidr))
|| hasOverlappingCidrs(gatewayClientCidrs)) {
throw new Error('Некорректная конфигурация traffic accounting');
}
const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const proxyUploadChild = proxyChildChain(uploadChain, slot);
const proxyDownloadChild = proxyChildChain(downloadChain, slot);
const raw = [
@@ -192,8 +240,12 @@ export function buildTrafficRestore({
];
const mangle = [
'*mangle',
`-F ${directChild}`,
`-F ${downloadChild}`,
`-F ${proxyDownloadChild}`,
`-A ${directChild} -m addrtype --dst-type LOCAL -j RETURN`,
`-A ${directChild} -m mark --mark ${tproxyMark}/${tproxyMark} -j RETURN`,
`-A ${directChild} -i br-+ -j RETURN`,
`-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}`,
@@ -208,9 +260,16 @@ export function buildTrafficRestore({
}
for (const cidr of bypassCidrs) {
raw.push(`-A ${uploadChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${directChild} -d ${cidr} -j RETURN`);
mangle.push(`-A ${downloadChild} -s ${cidr} -j RETURN`);
}
for (const cidr of gatewayClientCidrs) {
mangle.push(`-A ${directChild} -s ${cidr} -m comment --comment harbor-traffic:global:direct-upload -j CONNMARK --set-xmark ${directMark}/${directMark}`);
mangle.push(`-A ${downloadChild} -d ${cidr} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:global:direct-download`);
}
for (const device of devices) {
mangle.push(`-A ${directChild} -i ${device.interface} -s ${device.ip} -m mac --mac-source ${device.mac} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-upload`);
mangle.push(`-A ${downloadChild} -o ${device.interface} -d ${device.ip} -m connmark --mark ${directMark}/${directMark} -m comment --comment harbor-traffic:${device.key}:direct-download`);
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`);
}
@@ -220,7 +279,7 @@ export function buildTrafficRestore({
export function parseTrafficCounters(text: unknown, chain: string): Map<string, string> {
const escapedChain = chain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const linePattern = new RegExp(
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}):(upload|download|proxy-upload|proxy-download)"?`,
`^\\[(\\d+):(\\d+)\\] -A ${escapedChain} .*--comment "?harbor-traffic:([a-f0-9]{16}|global):(upload|download|proxy-upload|proxy-download|direct-upload|direct-download)"?`,
);
const counters = new Map<string, string>();
for (const line of String(text || '').split(/\r?\n/)) {
@@ -236,6 +295,10 @@ export function createDeviceTrafficService({
observe,
uploadChain,
downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
bypassCidrs,
proxyPort,
run = runCommand,
@@ -244,6 +307,10 @@ export function createDeviceTrafficService({
observe: () => Promise<unknown> | unknown;
uploadChain: string;
downloadChain: string;
directChain: string;
directMark: string;
tproxyMark: string;
gatewayClientCidrs: string[];
bypassCidrs: string[];
proxyPort: number;
run?: RunCommand;
@@ -257,12 +324,14 @@ export function createDeviceTrafficService({
let pendingRetired: RetiredCounters | null = null;
let refreshPromise: Promise<TrafficSnapshot> | null = null;
const finalized = new Map<string, CounterValues>();
const finalizedDirect = { upload: 0n, download: 0n };
const devicesByKey = new Map<string, TrafficDevice>();
let current: TrafficSnapshot = {
epoch,
generation: epoch,
observedAt: null,
source: { error: null },
direct: { uploadBytes: '0', downloadBytes: '0' },
devices: [],
};
@@ -278,6 +347,10 @@ export function createDeviceTrafficService({
bypassCidrs,
uploadChain,
downloadChain,
directChain,
directMark,
tproxyMark,
gatewayClientCidrs,
slot,
proxyPort,
});
@@ -287,22 +360,37 @@ export function createDeviceTrafficService({
async function switchTo(slot: 'A' | 'B') {
const uploadChild = childChain(uploadChain, slot);
const downloadChild = childChain(downloadChain, slot);
const directChild = childChain(directChain, slot);
const replace = activeSlot ? '-R' : '-A';
const uploadArgs = activeSlot
? ['-w', '1', '-t', 'raw', replace, uploadChain, '1', '-j', uploadChild]
: ['-w', '1', '-t', 'raw', replace, uploadChain, '-j', uploadChild];
const downloadArgs = activeSlot
? ['-w', '1', '-t', 'mangle', replace, downloadChain, '1', '-j', downloadChild]
: ['-w', '1', '-t', 'mangle', replace, downloadChain, '-j', downloadChild];
const mangleInput = [
'*mangle',
activeSlot
? `-R ${downloadChain} 1 -j ${downloadChild}`
: `-A ${downloadChain} -j ${downloadChild}`,
activeSlot
? `-R ${directChain} 1 -j ${directChild}`
: `-A ${directChain} -j ${directChild}`,
'COMMIT',
'',
].join('\n');
await execute('iptables', uploadArgs);
try {
await execute('iptables', downloadArgs);
await execute('iptables-restore', ['-w', '1', '--noflush'], { ...COMMAND_OPTIONS, input: mangleInput });
} catch (error) {
const rollbackArgs = activeSlot
const uploadRollback = activeSlot
? ['-w', '1', '-t', 'raw', '-R', uploadChain, '1', '-j', childChain(uploadChain, activeSlot)]
: ['-w', '1', '-t', 'raw', '-F', uploadChain];
await execute('iptables', rollbackArgs);
try {
await execute('iptables', uploadRollback);
} catch (rollbackError) {
const original = error instanceof Error ? error.message : String(error);
const rollback = rollbackError instanceof Error ? rollbackError.message : String(rollbackError);
throw new Error(`${original}; rollback: ${rollback}`, { cause: error });
}
throw error;
}
}
@@ -318,6 +406,8 @@ export function createDeviceTrafficService({
download: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
proxyUpload: parseTrafficCounters(raw, proxyChildChain(uploadChain, slot)),
proxyDownload: parseTrafficCounters(mangle, proxyChildChain(downloadChain, slot)),
directUpload: parseTrafficCounters(mangle, childChain(directChain, slot)),
directDownload: parseTrafficCounters(mangle, childChain(downloadChain, slot)),
};
const counters = new Map<string, string>();
for (const { key } of devices) {
@@ -325,6 +415,8 @@ export function createDeviceTrafficService({
counters.set(`${key}:${kind}`, parsed[field].get(`${key}:${kind}`) || '0');
}
}
counters.set('global:direct-upload', parsed.directUpload.get('global:direct-upload') || '0');
counters.set('global:direct-download', parsed.directDownload.get('global:direct-download') || '0');
return counters;
}
@@ -345,6 +437,8 @@ export function createDeviceTrafficService({
for (const [kind, field] of COUNTERS) next[field] += counter(counters, key, kind);
finalized.set(key, next);
}
finalizedDirect.upload += counter(counters, 'global', 'direct-upload');
finalizedDirect.download += counter(counters, 'global', 'direct-download');
pendingRetired = null;
return true;
}
@@ -368,18 +462,41 @@ export function createDeviceTrafficService({
}
return [...totalsByMac.values()]
.map((total) => {
const { key: _key, upload, download, proxyUpload, proxyDownload, ...device } = total;
const {
key: _key,
upload,
download,
proxyUpload,
proxyDownload,
directUpload,
directDownload,
...device
} = total;
return {
...device,
uploadBytes: upload.toString(),
downloadBytes: download.toString(),
proxyUploadBytes: proxyUpload.toString(),
proxyDownloadBytes: proxyDownload.toString(),
directUploadBytes: directUpload.toString(),
directDownloadBytes: directDownload.toString(),
};
})
.sort((left, right) => left.mac.localeCompare(right.mac));
}
function directTotals() {
const pending = pendingRetired?.counters || new Map();
return {
uploadBytes: (finalizedDirect.upload
+ counter(pending, 'global', 'direct-upload')
+ counter(activeCounters, 'global', 'direct-upload')).toString(),
downloadBytes: (finalizedDirect.download
+ counter(pending, 'global', 'direct-download')
+ counter(activeCounters, 'global', 'direct-download')).toString(),
};
}
async function performRefresh() {
let observed: Record<string, unknown>;
try {
@@ -426,11 +543,13 @@ export function createDeviceTrafficService({
}
}
try {
activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true;
} catch (error) {
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
if (activeSlot) {
try {
activeCounters = await readCounters(activeDevices, activeSlot);
countersRead = true;
} catch (error) {
sourceError = sourceError || (error instanceof Error ? error.message : String(error));
}
}
current = {
epoch,
@@ -439,6 +558,7 @@ export function createDeviceTrafficService({
? observed.observedAt
: current.observedAt,
source: { error: sourceError },
direct: countersRead ? directTotals() : current.direct,
devices: countersRead ? processTotals() : current.devices,
};
return structuredClone(current);
+99 -4
View File
@@ -9,6 +9,7 @@ const DEFAULT_MAX_SERIES = 4096;
const UNKNOWN_DOMAIN = { domain: '_unknown', service: 'Не распознано' };
const ATTRIBUTION_OUTCOMES = ['unresolved_host', 'unknown_device', 'unsupported_source'] as const;
type AttributionOutcome = typeof ATTRIBUTION_OUTCOMES[number];
type TrafficRoute = 'vpn' | 'direct' | 'unknown';
const SERVICE_DOMAINS = [
['YouTube', ['youtube.com', 'youtube-nocookie.com', 'youtu.be', 'googlevideo.com', 'ytimg.com']],
['OpenAI / ChatGPT', ['chatgpt.com', 'openai.com', 'oaistatic.com', 'oaiusercontent.com']],
@@ -21,13 +22,19 @@ interface ParsedBaseConnection {
}
type ParsedConnection =
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
| (ParsedBaseConnection & { outcome: 'unsupported_source' })
| (ParsedBaseConnection & {
outcome: 'unknown_device';
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
})
| (ParsedBaseConnection & {
outcome: 'classified' | 'unresolved_host';
deviceId: string;
domain: string;
service: string;
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
});
interface PreviousConnection {
@@ -36,6 +43,8 @@ interface PreviousConnection {
requestedKey?: string;
countedUpload: bigint | null;
countedDownload: bigint | null;
trackedUpload: bigint | null;
trackedDownload: bigint | null;
}
interface DomainSeriesTotal {
@@ -47,12 +56,28 @@ interface DomainSeriesTotal {
downloadBytes: bigint;
}
interface RouteSeriesTotal {
deviceId?: string;
source: 'gateway' | 'proxy';
outbound: TrafficRoute;
uploadBytes: bigint;
downloadBytes: bigint;
}
interface DomainTrafficSnapshot {
epoch: string;
observedAt: string | null;
source: { error: string | null };
overflowConnections: string;
attributionEvents: Record<AttributionOutcome, string>;
tracked: Array<Omit<RouteSeriesTotal, 'deviceId' | 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
}>;
routes: Array<Omit<RouteSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
}>;
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
uploadBytes: string;
downloadBytes: string;
@@ -86,6 +111,12 @@ function sourceFor(type: string): 'gateway' | 'proxy' | null {
return null;
}
function routeFor(value: unknown): TrafficRoute {
if (!Array.isArray(value) || !value.length
|| value.some((entry) => typeof entry !== 'string' || !entry.trim())) return 'unknown';
return value[0].trim() === 'direct' ? 'direct' : 'vpn';
}
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
const connection = record(value);
const id = String(connection.id || '');
@@ -103,8 +134,9 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
};
const source = sourceFor(String(metadata.type || ''));
if (!source) return { ...parsed, outcome: 'unsupported_source' };
const outbound = routeFor(connection.chains);
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device', source, outbound };
const classifiedDomain = classifyDomain(metadata.host);
const domain = classifiedDomain || UNKNOWN_DOMAIN;
return {
@@ -113,6 +145,7 @@ function parseConnection(value: unknown, devicesByIp: Map<string, string | null>
deviceId: currentDeviceId,
...domain,
source,
outbound,
};
}
@@ -164,6 +197,8 @@ export function createDomainTrafficService({
if (!Number.isInteger(maxSeries) || maxSeries < 2) throw new Error('Domain traffic series limit должен быть не меньше 2');
const epoch = crypto.randomUUID();
const totals = new Map<string, DomainSeriesTotal>();
const routeTotals = new Map<string, RouteSeriesTotal>();
const trackedTotals = new Map<string, RouteSeriesTotal>();
const normalSeriesLimit = maxSeries - 2;
let normalSeries = 0;
let previousConnections = new Map<string, PreviousConnection>();
@@ -180,6 +215,8 @@ export function createDomainTrafficService({
source: { error: null },
overflowConnections: '0',
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
tracked: [],
routes: [],
series: [],
};
@@ -192,6 +229,27 @@ export function createDomainTrafficService({
attributionEvents: Object.fromEntries(
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
) as Record<AttributionOutcome, string>,
tracked: [...trackedTotals.values()]
.map((entry) => ({
source: entry.source,
outbound: entry.outbound,
uploadBytes: entry.uploadBytes.toString(),
downloadBytes: entry.downloadBytes.toString(),
}))
.sort((left, right) => (
left.source.localeCompare(right.source) || left.outbound.localeCompare(right.outbound)
)),
routes: [...routeTotals.values()]
.map((entry) => ({
...entry,
uploadBytes: entry.uploadBytes.toString(),
downloadBytes: entry.downloadBytes.toString(),
}))
.sort((left, right) => (
String(left.deviceId).localeCompare(String(right.deviceId))
|| left.source.localeCompare(right.source)
|| left.outbound.localeCompare(right.outbound)
)),
series: [...totals.values()]
.map((entry) => ({
...entry,
@@ -220,18 +278,42 @@ export function createDomainTrafficService({
if (!net.isIPv4(ip) || !id) continue;
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
}
const connections = response.connections.map((connection) => parseConnection(connection, devicesByIp));
const activeConnections = new Map<string, PreviousConnection>();
for (const rawConnection of response.connections) {
const connection = parseConnection(rawConnection, devicesByIp);
for (const connection of connections) {
const previous = previousConnections.get(connection.id);
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
attributionEvents[connection.outcome] += 1n;
}
if (connection.outcome !== 'unsupported_source') {
const uploadDelta = previous?.trackedUpload != null && connection.upload >= previous.trackedUpload
? connection.upload - previous.trackedUpload
: connection.upload;
const downloadDelta = previous?.trackedDownload != null && connection.download >= previous.trackedDownload
? connection.download - previous.trackedDownload
: connection.download;
const trackedKey = `${connection.source}\0${connection.outbound}`;
const tracked = trackedTotals.get(trackedKey) || {
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
tracked.uploadBytes += uploadDelta;
tracked.downloadBytes += downloadDelta;
trackedTotals.set(trackedKey, tracked);
}
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
activeConnections.set(connection.id, {
outcome: connection.outcome,
countedUpload: previous?.countedUpload ?? null,
countedDownload: previous?.countedDownload ?? null,
trackedUpload: connection.outcome === 'unknown_device'
? connection.upload
: previous?.trackedUpload ?? null,
trackedDownload: connection.outcome === 'unknown_device'
? connection.download
: previous?.trackedDownload ?? null,
});
continue;
}
@@ -257,6 +339,17 @@ export function createDomainTrafficService({
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
? connection.download - previous.countedDownload
: connection.download;
const routeKey = `${connection.deviceId}\0${connection.source}\0${connection.outbound}`;
const routeTotal = routeTotals.get(routeKey) || {
deviceId: connection.deviceId,
source: connection.source,
outbound: connection.outbound,
uploadBytes: 0n,
downloadBytes: 0n,
};
routeTotal.uploadBytes += uploadDelta;
routeTotal.downloadBytes += downloadDelta;
routeTotals.set(routeKey, routeTotal);
const total = totals.get(key) || {
deviceId: key === requestedKey ? connection.deviceId : '_other',
domain,
@@ -274,6 +367,8 @@ export function createDomainTrafficService({
requestedKey,
countedUpload: connection.upload,
countedDownload: connection.download,
trackedUpload: connection.upload,
trackedDownload: connection.download,
});
}
previousConnections = activeConnections;
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.1',
gatewayClient: '0.26.0',
gatewayBackend: '0.26.1',
macClient: '0.25.2',
gatewayClient: '0.26.1',
gatewayBackend: '0.26.2',
});
export interface ParsedVersion {
@@ -110,7 +110,7 @@ export function instructionBlocks({ isGateway, host, port, controlHost }: {
{ id: 'prometheus-config', label: 'prometheus.yml', text: prometheusScrapeConfig(controlHost) },
{ id: 'grafana-dashboard', label: 'Grafana dashboard', text: grafanaDashboardJson },
],
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками, устройства с policy Direct и трафик без распознанного домена в domain series не входят.',
note: 'Domain counters снимаются с активных соединений sing-box раз в 2 секунды. Историю хранит Prometheus; соединения между снимками могут быть пропущены, неизвестный домен записывается как _unknown, а policy Direct виден только в отдельной Direct IPv4 metric.',
}] : []),
];
}