Refactor VPN proxy client implementation
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
import crypto from 'node:crypto';
|
||||
import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { domainToASCII } from 'node:url';
|
||||
import { deviceId } from './deviceInventoryService.js';
|
||||
|
||||
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
||||
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];
|
||||
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']],
|
||||
] as const;
|
||||
|
||||
interface ParsedBaseConnection {
|
||||
id: string;
|
||||
upload: bigint;
|
||||
download: bigint;
|
||||
}
|
||||
|
||||
type ParsedConnection =
|
||||
| (ParsedBaseConnection & { outcome: 'unknown_device' | 'unsupported_source' })
|
||||
| (ParsedBaseConnection & {
|
||||
outcome: 'classified' | 'unresolved_host';
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source: 'gateway' | 'proxy';
|
||||
});
|
||||
|
||||
interface PreviousConnection {
|
||||
outcome: AttributionOutcome | 'classified';
|
||||
key?: string;
|
||||
requestedKey?: string;
|
||||
countedUpload: bigint | null;
|
||||
countedDownload: bigint | null;
|
||||
}
|
||||
|
||||
interface DomainSeriesTotal {
|
||||
deviceId: string;
|
||||
domain: string;
|
||||
service: string;
|
||||
source: string;
|
||||
uploadBytes: bigint;
|
||||
downloadBytes: bigint;
|
||||
}
|
||||
|
||||
interface DomainTrafficSnapshot {
|
||||
epoch: string;
|
||||
observedAt: string | null;
|
||||
source: { error: string | null };
|
||||
overflowConnections: string;
|
||||
attributionEvents: Record<AttributionOutcome, string>;
|
||||
series: Array<Omit<DomainSeriesTotal, 'uploadBytes' | 'downloadBytes'> & {
|
||||
uploadBytes: string;
|
||||
downloadBytes: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
}
|
||||
|
||||
const matchesDomain = (domain: string, suffix: string) => domain === suffix || domain.endsWith(`.${suffix}`);
|
||||
|
||||
export function classifyDomain(value: unknown): { domain: string; service: string } | null {
|
||||
let domain = domainToASCII(String(value || '').trim().replace(/\.$/, '')).toLowerCase();
|
||||
if (domain.startsWith('www.')) domain = domain.slice(4);
|
||||
const labels = domain.split('.');
|
||||
if (!domain || domain.length > 253 || net.isIP(domain) || labels.length < 2
|
||||
|| labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) return null;
|
||||
for (const [service, suffixes] of SERVICE_DOMAINS) {
|
||||
const suffix = suffixes.find((candidate) => matchesDomain(domain, candidate));
|
||||
if (suffix) return { domain: suffix, service };
|
||||
}
|
||||
return { domain, service: domain };
|
||||
}
|
||||
|
||||
function sourceFor(type: string): 'gateway' | 'proxy' | null {
|
||||
if (type === 'tproxy/tproxy-in') return 'gateway';
|
||||
if (type === 'mixed/mixed-in') return 'proxy';
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseConnection(value: unknown, devicesByIp: Map<string, string | null>): ParsedConnection {
|
||||
const connection = record(value);
|
||||
const id = String(connection.id || '');
|
||||
const metadata = record(connection.metadata);
|
||||
const upload = connection.upload;
|
||||
const download = connection.download;
|
||||
if (!id || typeof upload !== 'number' || !Number.isSafeInteger(upload) || upload < 0
|
||||
|| typeof download !== 'number' || !Number.isSafeInteger(download) || download < 0) {
|
||||
throw new Error('Sing-box вернул невалидный domain traffic counter');
|
||||
}
|
||||
const parsed = {
|
||||
id,
|
||||
upload: BigInt(upload),
|
||||
download: BigInt(download),
|
||||
};
|
||||
const source = sourceFor(String(metadata.type || ''));
|
||||
if (!source) return { ...parsed, outcome: 'unsupported_source' };
|
||||
const currentDeviceId = devicesByIp.get(String(metadata.sourceIP || ''));
|
||||
if (!currentDeviceId) return { ...parsed, outcome: 'unknown_device' };
|
||||
const classifiedDomain = classifyDomain(metadata.host);
|
||||
const domain = classifiedDomain || UNKNOWN_DOMAIN;
|
||||
return {
|
||||
...parsed,
|
||||
outcome: classifiedDomain ? 'classified' : 'unresolved_host',
|
||||
deviceId: currentDeviceId,
|
||||
...domain,
|
||||
source,
|
||||
};
|
||||
}
|
||||
|
||||
export function readSingboxConnections(port: number, timeoutMs = 1500): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = http.get({ host: '127.0.0.1', port, path: '/connections' }, (response) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
let tooLarge = false;
|
||||
response.on('data', (chunk: Buffer) => {
|
||||
if (tooLarge) return;
|
||||
size += chunk.length;
|
||||
if (size > MAX_RESPONSE_BYTES) {
|
||||
tooLarge = true;
|
||||
request.destroy(new Error('Sing-box connections response слишком большой'));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
response.on('end', () => {
|
||||
if (tooLarge) return;
|
||||
if ((response.statusCode || 500) >= 400) {
|
||||
reject(new Error(`Sing-box connections HTTP ${response.statusCode}`));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
resolve(JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}'));
|
||||
} catch (cause) {
|
||||
reject(new Error('Sing-box вернул невалидный connections JSON', { cause }));
|
||||
}
|
||||
});
|
||||
});
|
||||
request.setTimeout(timeoutMs, () => request.destroy(new Error('Sing-box connections timeout')));
|
||||
request.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
export function createDomainTrafficService({
|
||||
observe,
|
||||
devices,
|
||||
now = () => new Date(),
|
||||
maxSeries = DEFAULT_MAX_SERIES,
|
||||
}: {
|
||||
observe: () => Promise<unknown> | unknown;
|
||||
devices: () => unknown;
|
||||
now?: () => Date;
|
||||
maxSeries?: number;
|
||||
}) {
|
||||
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 normalSeriesLimit = maxSeries - 2;
|
||||
let normalSeries = 0;
|
||||
let previousConnections = new Map<string, PreviousConnection>();
|
||||
let overflowConnections = 0n;
|
||||
const attributionEvents: Record<AttributionOutcome, bigint> = {
|
||||
unresolved_host: 0n,
|
||||
unknown_device: 0n,
|
||||
unsupported_source: 0n,
|
||||
};
|
||||
let refreshPromise: Promise<DomainTrafficSnapshot> | null = null;
|
||||
let current: DomainTrafficSnapshot = {
|
||||
epoch,
|
||||
observedAt: null,
|
||||
source: { error: null },
|
||||
overflowConnections: '0',
|
||||
attributionEvents: { unresolved_host: '0', unknown_device: '0', unsupported_source: '0' },
|
||||
series: [],
|
||||
};
|
||||
|
||||
function buildSnapshot(error: string | null = null): DomainTrafficSnapshot {
|
||||
return {
|
||||
epoch,
|
||||
observedAt: current.observedAt,
|
||||
source: { error },
|
||||
overflowConnections: overflowConnections.toString(),
|
||||
attributionEvents: Object.fromEntries(
|
||||
ATTRIBUTION_OUTCOMES.map((outcome) => [outcome, attributionEvents[outcome].toString()]),
|
||||
) as Record<AttributionOutcome, string>,
|
||||
series: [...totals.values()]
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
uploadBytes: entry.uploadBytes.toString(),
|
||||
downloadBytes: entry.downloadBytes.toString(),
|
||||
}))
|
||||
.sort((left, right) => (
|
||||
left.deviceId.localeCompare(right.deviceId)
|
||||
|| left.service.localeCompare(right.service)
|
||||
|| left.domain.localeCompare(right.domain)
|
||||
|| left.source.localeCompare(right.source)
|
||||
)),
|
||||
};
|
||||
}
|
||||
|
||||
async function performRefresh() {
|
||||
try {
|
||||
const response = record(await observe());
|
||||
if (!Array.isArray(response.connections)) throw new Error('Sing-box не вернул connections array');
|
||||
const devicesByIp = new Map<string, string | null>();
|
||||
const observedDevices = devices();
|
||||
for (const value of Array.isArray(observedDevices) ? observedDevices : []) {
|
||||
const device = record(value);
|
||||
const ip = String(device.ip || '');
|
||||
const id = typeof device.mac === 'string' ? deviceId(device.mac.toLowerCase()) : null;
|
||||
if (!net.isIPv4(ip) || !id) continue;
|
||||
devicesByIp.set(ip, devicesByIp.has(ip) ? null : id);
|
||||
}
|
||||
const activeConnections = new Map<string, PreviousConnection>();
|
||||
for (const rawConnection of response.connections) {
|
||||
const connection = parseConnection(rawConnection, devicesByIp);
|
||||
const previous = previousConnections.get(connection.id);
|
||||
if (connection.outcome !== 'classified' && previous?.outcome !== connection.outcome) {
|
||||
attributionEvents[connection.outcome] += 1n;
|
||||
}
|
||||
if (connection.outcome === 'unknown_device' || connection.outcome === 'unsupported_source') {
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
countedUpload: previous?.countedUpload ?? null,
|
||||
countedDownload: previous?.countedDownload ?? null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!('deviceId' in connection)) throw new Error('Sing-box вернул невалидную attribution запись');
|
||||
const requestedKey = `${connection.deviceId}\0${connection.domain}\0${connection.source}`;
|
||||
let key = previous?.requestedKey === requestedKey && previous.key ? previous.key : requestedKey;
|
||||
let domain = connection.domain;
|
||||
let service = connection.service;
|
||||
if (key !== requestedKey) {
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
} else if (!totals.has(key) && normalSeries >= normalSeriesLimit) {
|
||||
overflowConnections += 1n;
|
||||
domain = '_other';
|
||||
service = 'Другие домены';
|
||||
key = `_other\0${domain}\0${connection.source}`;
|
||||
} else if (!totals.has(key)) {
|
||||
normalSeries += 1;
|
||||
}
|
||||
const uploadDelta = previous?.countedUpload != null && connection.upload >= previous.countedUpload
|
||||
? connection.upload - previous.countedUpload
|
||||
: connection.upload;
|
||||
const downloadDelta = previous?.countedDownload != null && connection.download >= previous.countedDownload
|
||||
? connection.download - previous.countedDownload
|
||||
: connection.download;
|
||||
const total = totals.get(key) || {
|
||||
deviceId: key === requestedKey ? connection.deviceId : '_other',
|
||||
domain,
|
||||
service,
|
||||
source: connection.source,
|
||||
uploadBytes: 0n,
|
||||
downloadBytes: 0n,
|
||||
};
|
||||
total.uploadBytes += uploadDelta;
|
||||
total.downloadBytes += downloadDelta;
|
||||
totals.set(key, total);
|
||||
activeConnections.set(connection.id, {
|
||||
outcome: connection.outcome,
|
||||
key,
|
||||
requestedKey,
|
||||
countedUpload: connection.upload,
|
||||
countedDownload: connection.download,
|
||||
});
|
||||
}
|
||||
previousConnections = activeConnections;
|
||||
current = { ...current, observedAt: now().toISOString() };
|
||||
current = buildSnapshot();
|
||||
return current;
|
||||
} catch (error) {
|
||||
current = buildSnapshot(error instanceof Error ? error.message : String(error));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = performRefresh().finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
return { snapshot: () => current, refresh };
|
||||
}
|
||||
Reference in New Issue
Block a user