Update VPN client connection flow
Build and Deploy Gateway / build-and-push (push) Successful in 15s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 21:19:33 +03:00
parent 0c376c72aa
commit 0a4a6d9443
16 changed files with 771 additions and 342 deletions
@@ -1,34 +1,40 @@
import { execFile } from 'node:child_process';
import { lookup as dnsLookup } from 'node:dns/promises';
import net from 'node:net';
import {
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
export const CURL_META_MARKER = '\n__HARBOR_CURL_META__';
const IP_PROBES = [
{
id: 'cloudflare',
url: 'https://www.cloudflare.com/cdn-cgi/trace',
address: (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim(),
},
{ id: 'ipify', url: 'https://api.ipify.org', address: (body) => body.trim() },
];
const IP_FALLBACK = {
id: 'aws',
url: 'https://checkip.amazonaws.com',
address: (body) => body.trim(),
};
const IPV6_PROBE = {
id: 'ipify-v6',
url: 'https://api6.ipify.org',
address: (body) => body.trim(),
};
const SITE_PROBES = [
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' },
];
const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
...probe,
address: probe.id === 'cloudflare'
? (body) => /^ip=(.+)$/m.exec(body)?.[1]?.trim()
: probe.id === 'yandex-internet'
? (body) => /(?:"ipv4"\s*:\s*"|IPv4[^0-9]{0,160})((?:\d{1,3}\.){3}\d{1,3})/i.exec(body)?.[1]
: (body) => body.trim(),
}));
const SITE_PROBES = CONNECTIVITY_SITES;
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],
]) 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],
]) BLOCKED_IPV6_ADDRESSES.addSubnet(address, prefix, 'ipv6');
function runCurl(args) {
return new Promise((resolve) => {
execFile('curl', args, { encoding: 'utf8', maxBuffer: 64 * 1024 }, (error, stdout, stderr) => {
execFile('curl', args, { encoding: 'utf8', maxBuffer: 256 * 1024 }, (error, stdout, stderr) => {
resolve({
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
error: error?.message || '',
@@ -52,11 +58,20 @@ function milliseconds(value) {
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
}
async function request(probe, path, proxyPort, execute, { body = false, ipv4 = false } = {}) {
async function request(probe, path, proxyPort, execute, {
body = false,
ipv4 = false,
follow = true,
resolve = null,
} = {}) {
const args = [
'--silent',
'--show-error',
'--location',
...(follow ? ['--location'] : []),
'--proto',
'=https',
'--proto-redir',
'=https',
'--connect-timeout',
'3',
'--max-time',
@@ -71,6 +86,7 @@ async function request(probe, path, proxyPort, execute, { body = false, ipv4 = f
? ['--proxy', `http://127.0.0.1:${proxyPort}`]
: ['--noproxy', '*']),
...(ipv4 ? ['--ipv4'] : []),
...(resolve ? ['--resolve', resolve] : []),
probe.url,
];
const result = await execute(args);
@@ -96,12 +112,14 @@ async function request(probe, path, proxyPort, execute, { body = false, ipv4 = f
};
}
async function ipProbe(probe, path, proxyPort, execute, version) {
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: version === 4 });
async function ipProbe(probe, path, proxyPort, execute) {
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: probe.family === 4 });
const address = result.ok ? probe.address(result.body) : null;
const valid = typeof address === 'string' && net.isIP(address) === version;
const valid = typeof address === 'string' && net.isIP(address) === probe.family;
return {
source: probe.id,
label: probe.label,
family: probe.family,
address: valid ? address : null,
latencyMs: result.latencyMs,
error: valid ? null : result.error || 'invalid IP response',
@@ -109,29 +127,78 @@ async function ipProbe(probe, path, proxyPort, execute, version) {
}
async function publicIps(path, proxyPort, execute) {
const [primary, ipv6] = await Promise.all([
Promise.all(IP_PROBES.map((probe) => ipProbe(probe, path, proxyPort, execute, 4))),
ipProbe(IPV6_PROBE, path, proxyPort, execute, 6),
]);
const primaryAddresses = new Set(primary.map((probe) => probe.address).filter(Boolean));
const probes = primary.every((probe) => probe.address) && primaryAddresses.size === 1
? primary
: [...primary, await ipProbe(IP_FALLBACK, path, proxyPort, execute, 4)];
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(probes.map((probe) => probe.address).filter(Boolean))],
sources: probes,
addresses: [...new Set(ipv4.map((probe) => probe.address).filter(Boolean))],
sources: ipv4,
},
ipv6: ipv6.address,
ipv6: ipv6?.address || null,
ipv6Source: ipv6,
};
}
function isPublicAddress(address, family) {
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, lookup) {
const requested = Array.isArray(services) ? services.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES) : [];
return Promise.all(requested.map(async (service, index) => {
const requestedId = String(service?.id || '');
const id = /^custom-[a-z0-9-]{1,80}$/i.test(requestedId) ? requestedId : `custom-${index + 1}`;
let parsed;
try {
parsed = new URL(String(service?.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(service?.label || '').trim().slice(0, 40) || hostname,
url: parsed.href,
follow: false,
resolve: `${hostname}:443:${pinned}`,
};
} catch (error) {
return {
id,
label: String(service?.label || '').trim().slice(0, 40) || `Сервис ${index + 1}`,
validationError: error.message || 'Некорректный адрес',
};
}
}));
}
async function siteProbe(probe, path, proxyPort, execute) {
let result = await request(probe, path, proxyPort, execute);
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 };
let result = await request(probe, path, proxyPort, execute, options);
let attempts = 1;
if (!result.ok) {
result = await request(probe, path, proxyPort, execute);
result = await request(probe, path, proxyPort, execute, options);
attempts = 2;
}
const status = !result.ok
@@ -152,23 +219,23 @@ async function siteProbe(probe, path, proxyPort, execute) {
};
}
async function probePath(path, proxyPort, execute) {
const [ip, sites] = await Promise.all([
async function probePath(path, proxyPort, execute, sites) {
const [ip, siteResults] = await Promise.all([
publicIps(path, proxyPort, execute),
Promise.all(SITE_PROBES.map((probe) => siteProbe(probe, path, proxyPort, execute))),
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
]);
return {
available: true,
internetAvailable: Boolean(
ip.ipv4.addresses.length || ip.ipv6 || sites.some((site) => site.status !== 'unavailable'),
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
),
...ip,
sites,
sites: siteResults,
};
}
export function assessConnectivity(direct, vpn) {
const comparisons = SITE_PROBES.map(({ id, label }) => {
const comparisons = direct.sites.map(({ id, label }) => {
const directSite = direct.sites.find((site) => site.id === id);
const vpnSite = vpn.sites?.find((site) => site.id === id);
let assessment = 'inconclusive';
@@ -211,13 +278,15 @@ export function assessConnectivity(direct, vpn) {
export function createConnectivityDiagnosticsService({
proxyPort,
execute = runCurl,
lookup = dnsLookup,
now = () => new Date().toISOString(),
}) {
let inFlight = null;
async function runOnce({ vpnAvailable }) {
const directPromise = probePath('direct', proxyPort, execute);
async function runOnce({ vpnAvailable, services = [] }) {
const customProbes = await prepareCustomProbes(services, lookup);
const siteProbes = [...SITE_PROBES, ...customProbes];
const directPromise = probePath('direct', proxyPort, execute, siteProbes);
const vpnPromise = vpnAvailable
? probePath('vpn', proxyPort, execute)
? probePath('vpn', proxyPort, execute, siteProbes)
: Promise.resolve({
available: false,
reason: 'vpn-off',
@@ -236,9 +305,6 @@ export function createConnectivityDiagnosticsService({
};
}
return {
run(options) {
if (!inFlight) inFlight = runOnce(options).finally(() => { inFlight = null; });
return inFlight;
},
run: runOnce,
};
}