Improve VPN client connection management
This commit is contained in:
@@ -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]));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user