Files
harbor-net/src/server/services/connectivityDiagnosticsService.ts
T
dokril 34d8b681ad
Build and Deploy Gateway / build-and-push (push) Failing after 2s
Build and Deploy Gateway / deploy (push) Has been skipped
Refactor VPN proxy client implementation
2026-08-09 00:41:52 +03:00

476 lines
15 KiB
TypeScript

import { execFile } from 'node:child_process';
import { lookup as dnsLookup } from 'node:dns/promises';
import net from 'node:net';
import {
assessConnectivity,
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
import type { ConnectivityPathResult } from '../../shared/connectivityDiagnostics.js';
type PathKind = 'direct' | 'vpn';
interface CurlExecution {
exitCode: number | null;
error: string;
stderr: string;
stdout: string;
}
type CurlExecutor = (args: string[]) => Promise<CurlExecution>;
type DnsLookup = typeof dnsLookup;
interface BaseProbe {
id: string;
label: string;
url: string;
}
interface IpProbe extends BaseProbe {
family: 4 | 6;
address: (body: string) => string | undefined;
}
interface SiteProbe extends BaseProbe {
follow?: boolean;
resolve?: string;
validationError?: string;
}
interface RequestOptions {
body?: boolean;
ipv4?: boolean;
follow?: boolean;
resolve?: string | null;
}
interface RequestResult {
ok: boolean;
body: string;
exitCode: number | null;
httpStatus: number | null;
latencyMs: number | null;
totalMs: number | null;
stage: string;
error: string | null;
}
interface IpProbeResult {
source: string;
label: string;
family: 4 | 6;
address: string | null;
attempts: number;
latencyMs: number | null;
error: string | null;
}
interface SiteProbeResult {
id: string;
label: string;
status: string;
attempts: number;
httpStatus: number | null;
latencyMs: number | null;
totalMs: number | null;
stage: string;
error: string | null;
[key: string]: unknown;
}
type DiagnosticTarget =
| { kind: 'ip'; probe: IpProbe }
| { kind: 'site'; probe: SiteProbe };
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
const IP_PROBES: IpProbe[] = CONNECTIVITY_IP_SOURCES.map((probe) => ({
...probe,
family: probe.family === 6 ? 6 : 4,
address: probe.id === 'cloudflare'
? (body: string) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
: probe.id === 'yandex-internet'
? (body: string) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
: (body: string) => body.trim(),
}));
const SITE_PROBES: SiteProbe[] = [...CONNECTIVITY_SITES];
const TARGET_SAMPLE_COUNT = 3;
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
for (const [address, prefix] of [
['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.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24],
['224.0.0.0', 4], ['240.0.0.0', 4],
] as Array<[string, number]>) BLOCKED_IPV4_ADDRESSES.addSubnet(address, prefix, 'ipv4');
const BLOCKED_IPV6_ADDRESSES = new net.BlockList();
for (const [address, prefix] of [
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['fc00::', 7],
['fe80::', 10], ['ff00::', 8], ['2001:db8::', 32],
] as Array<[string, number]>) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
function runCurl(args: string[]): Promise<CurlExecution> {
return new Promise((resolve) => {
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
resolve({
exitCode: typeof error?.code === 'number' && Number.isInteger(error.code) ? error.code : error ? null : 0,
error: error?.message || '',
stderr: stderr || '',
stdout: stdout || '',
});
});
});
}
function stageFor(exitCode: number | null) {
if (exitCode === 6) return 'dns';
if (exitCode === 7) return 'tcp';
if (exitCode !== null && [35, 51, 58, 60].includes(exitCode)) return 'tls';
if (exitCode === 28) return 'timeout';
return 'request';
}
function milliseconds(value: unknown) {
const seconds = Number(value);
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
}
function average(values: Array<number | null>) {
const numbers = values.filter((value): value is number => Number.isFinite(value));
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
}
function mostCommon<T>(values: T[]): T | null {
const counts = new Map<T, number>();
let selected: T | null = null;
let selectedCount = 0;
for (const value of values) {
const count = (counts.get(value) || 0) + 1;
counts.set(value, count);
if (count >= selectedCount) {
selected = value;
selectedCount = count;
}
}
return selected;
}
async function request(probe: BaseProbe, path: PathKind, proxyPort: number, execute: CurlExecutor, {
body = false,
ipv4 = false,
follow = true,
resolve = null,
}: RequestOptions = {}): Promise<RequestResult> {
const args = [
'--silent',
'--show-error',
...(follow ? ['--location'] : []),
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'3',
'--max-time',
'6',
'--user-agent',
'Harbor-Diagnostics/1',
'--output',
body ? '-' : '/dev/null',
'--write-out',
`${CURL_META_MARKER}%{json}`,
...(path === 'vpn'
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
: ['--noproxy', '*']),
...(ipv4 ? ['--ipv4'] : []),
...(resolve ? ['--resolve', resolve] : []),
probe.url,
];
const result = await execute(args);
const marker = result.stdout.lastIndexOf(CURL_META_MARKER);
const responseBody = marker >= 0 ? result.stdout.slice(0, marker) : '';
let meta: Record<string, unknown> = {};
try {
meta = record(JSON.parse(marker >= 0 ? result.stdout.slice(marker + CURL_META_MARKER.length) : '{}'));
} catch {
// Curl diagnostics remain useful even when an old curl cannot emit JSON metadata.
}
const exitCode = typeof meta.exitcode === 'number' && Number.isInteger(meta.exitcode)
? meta.exitcode
: result.exitCode;
const ok = exitCode === 0;
return {
ok,
body: responseBody,
exitCode,
httpStatus: Number(meta.http_code) || null,
latencyMs: milliseconds(meta.time_starttransfer),
totalMs: milliseconds(meta.time_total),
stage: ok ? 'complete' : stageFor(exitCode),
error: ok ? null : String(meta.errormsg || result.stderr || result.error || 'request failed').trim(),
};
}
async function ipProbe(
probe: IpProbe,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sampleCount = 1,
): Promise<IpProbeResult> {
const samples: Array<RequestResult & { address: string | null }> = [];
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
const parsed = result.ok ? probe.address(result.body) : null;
samples.push({
...result,
address: typeof parsed === 'string' && net.isIP(parsed) === probe.family ? parsed : null,
});
}
const address = mostCommon(samples.map((sample) => sample.address).filter((value): value is string => Boolean(value)));
const matching = samples.filter((sample) => sample.address === address);
return {
source: probe.id,
label: probe.label,
family: probe.family,
address,
attempts: samples.length,
latencyMs: average(matching.map((sample) => sample.latencyMs)),
error: address ? null : samples.at(-1)?.error || 'invalid IP response',
};
}
async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecutor) {
const probes = await Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute)));
const ipv4 = probes.filter((probe) => probe.family === 4);
const ipv6 = probes.find((probe) => probe.family === 6);
return {
ipv4: {
addresses: [...new Set(ipv4.map((probe) => probe.address).filter((value): value is string => Boolean(value)))],
sources: ipv4,
},
ipv6: ipv6?.address || null,
ipv6Source: ipv6,
};
}
function isPublicAddress(address: string, family: number) {
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
return Boolean(type && net.isIP(address) === family && !blocked.check(address, type));
}
async function prepareCustomProbes(services: unknown, lookup: DnsLookup): Promise<SiteProbe[]> {
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
return Promise.all(requested.map(async (service, index) => {
const value = record(service);
const requestedId = String(value.id || '');
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
let parsed;
try {
parsed = new URL(String(value.url || '').trim());
if (parsed.protocol !== 'https:' || parsed.username || parsed.password || (parsed.port && parsed.port !== '443')) {
throw new Error('Разрешены только публичные HTTPS-адреса');
}
const hostname = parsed.hostname.replace(/^\[|\]$/g, '');
const resolved = await lookup(hostname, { all: true, verbatim: true });
const addresses = Array.isArray(resolved) ? resolved : [resolved];
if (!addresses.length || addresses.some(({ address, family }) => !isPublicAddress(address, family))) {
throw new Error('Адрес ведёт во внутреннюю или служебную сеть');
}
const target = addresses.find(({ family }) => family === 4) || addresses[0];
const pinned = target.family === 6 ? `[${target.address}]` : target.address;
return {
id,
label: String(value.label || '').trim().slice(0, 40) || hostname,
url: parsed.href,
follow: false,
resolve: `${hostname}:443:${pinned}`,
};
} catch (error) {
return {
id,
label: String(value.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
url: '',
validationError: error instanceof Error ? error.message : 'Некорректный адрес',
};
}
}));
}
function siteStatus(result: RequestResult) {
if (!result.ok) return 'unavailable';
return result.httpStatus !== null && result.httpStatus >= 200 && result.httpStatus < 400
? 'available'
: 'responded';
}
async function siteProbe(
probe: SiteProbe,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sampleCount = 1,
): Promise<SiteProbeResult> {
if (probe.validationError) return {
id: probe.id,
label: probe.label,
status: 'unavailable',
attempts: 0,
httpStatus: null,
latencyMs: null,
totalMs: null,
stage: 'validation',
error: probe.validationError,
};
const options = { follow: probe.follow !== false, resolve: probe.resolve };
const samples = [];
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
samples.push(await request(probe, path, proxyPort, execute, options));
}
if (sampleCount === 1 && samples[0] && !samples[0].ok) {
samples.push(await request(probe, path, proxyPort, execute, options));
}
const status = mostCommon(samples.map(siteStatus)) || 'unavailable';
const matching = samples.filter((sample) => siteStatus(sample) === status);
const representative = matching.at(-1) || samples.at(-1);
if (!representative) throw new Error('Diagnostic probe produced no samples');
return {
id: probe.id,
label: probe.label,
status,
attempts: samples.length,
httpStatus: mostCommon(matching.map((sample) => sample.httpStatus)),
latencyMs: average(matching.map((sample) => sample.latencyMs)),
totalMs: average(matching.map((sample) => sample.totalMs)),
stage: representative.stage,
error: representative.error,
};
}
async function probePath(
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sites: SiteProbe[],
): Promise<ConnectivityPathResult> {
const [ip, siteResults] = await Promise.all([
publicIps(path, proxyPort, execute),
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
]);
return {
available: true,
internetAvailable: Boolean(
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
),
...ip,
sites: siteResults,
};
}
function unavailablePath(): ConnectivityPathResult {
return {
available: false,
reason: 'vpn-off',
internetAvailable: false,
ipv4: { addresses: [], sources: [] },
ipv6: null,
ipv6Source: null,
sites: [],
};
}
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
if (typeof targetId !== 'string') return null;
if (targetId.startsWith('ip:')) {
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
return probe ? { kind: 'ip', probe } : null;
}
if (targetId.startsWith('site:')) {
const probe = sites.find(({ id }) => id === targetId.slice(5));
return probe ? { kind: 'site', probe } : null;
}
return null;
}
async function probeTarget(
target: DiagnosticTarget,
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
): Promise<ConnectivityPathResult> {
const ip = target.kind === 'ip'
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
: null;
const site = target.kind === 'site'
? await siteProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
: null;
const ipv4Sources = ip?.family === 4 ? [ip] : [];
const ipv6Source = ip?.family === 6 ? ip : null;
const sites = site ? [site] : [];
return {
available: true,
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
ipv4: {
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
sources: ipv4Sources,
},
ipv6: ipv6Source?.address || null,
ipv6Source,
sites,
};
}
export { assessConnectivity };
export function createConnectivityDiagnosticsService({
proxyPort,
execute = runCurl,
lookup = dnsLookup,
now = () => new Date().toISOString(),
}: {
proxyPort: number;
execute?: CurlExecutor;
lookup?: DnsLookup;
now?: () => string;
}) {
async function runOnce({ vpnAvailable, services = [], target: targetId = null }: {
vpnAvailable: boolean;
services?: unknown;
target?: unknown;
}) {
const requestedServices = typeof targetId === 'string' && targetId.startsWith('site:custom-')
? (Array.isArray(services) ? services : []).filter((service) => `site:${String(record(service).id || '')}` === targetId)
: targetId ? [] : services;
const customProbes = await prepareCustomProbes(requestedServices, lookup);
const siteProbes = [...SITE_PROBES, ...customProbes];
const target = resolveTarget(targetId, siteProbes);
if (targetId && !target) throw new Error('Unknown diagnostic target');
const directPromise = target
? probeTarget(target, 'direct', proxyPort, execute)
: probePath('direct', proxyPort, execute, siteProbes);
const vpnPromise = vpnAvailable
? target
? probeTarget(target, 'vpn', proxyPort, execute)
: probePath('vpn', proxyPort, execute, siteProbes)
: Promise.resolve(unavailablePath());
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
return {
checkedAt: now(),
direct,
vpn,
assessment: assessConnectivity(direct, vpn),
};
}
return {
run: runOnce,
};
}