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; 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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } 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 { 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) { 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(values: T[]): T | null { const counts = new Map(); 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 { 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 = {}; 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 { const samples: Array = []; 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 { 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 { 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 { 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 { 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, }; }