476 lines
15 KiB
TypeScript
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,
|
|
};
|
|
}
|