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
+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);