Update VPN client connection flow
This commit is contained in:
@@ -91,7 +91,11 @@ const server = http.createServer(async (req, res) => {
|
||||
return sendJson(res, 200, await devicePolicy.apply(body.devices));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/diagnostics/connectivity') {
|
||||
return sendJson(res, 200, await connectivityDiagnostics.run({ vpnAvailable: runtime.running }));
|
||||
const { services = [] } = await readJson(req);
|
||||
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||
vpnAvailable: runtime.running,
|
||||
services,
|
||||
}));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/apply') {
|
||||
return sendJson(res, 200, await runtime.apply());
|
||||
|
||||
@@ -56,9 +56,9 @@ export function createDataplaneClient(socketPath, send = request) {
|
||||
observeTraffic: () => send(socketPath, '/device-traffic', 'GET'),
|
||||
observeDevicePolicy: () => send(socketPath, '/device-policy', 'GET'),
|
||||
applyDevicePolicies: (devices) => send(socketPath, '/device-policy', 'PUT', { devices }),
|
||||
runConnectivityDiagnostics: async () => {
|
||||
runConnectivityDiagnostics: async (services = []) => {
|
||||
try {
|
||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', null, 15_000);
|
||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000);
|
||||
} catch (cause) {
|
||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||
}
|
||||
|
||||
+6
-2
@@ -700,12 +700,16 @@ async function handleApi(req, res) {
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
||||
if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
|
||||
const { services = [] } = await readBody(req);
|
||||
const state = stateStore.read();
|
||||
const appliedServerId = state.appliedServerId || state.selectedServerId;
|
||||
const selected = (state.servers || []).find((server) => server.id === appliedServerId);
|
||||
const result = remoteDataplane
|
||||
? await singboxRuntime.runConnectivityDiagnostics()
|
||||
: await localConnectivityDiagnostics.run({ vpnAvailable: (await singboxRuntime.refresh()).running });
|
||||
? await singboxRuntime.runConnectivityDiagnostics(services)
|
||||
: await localConnectivityDiagnostics.run({
|
||||
vpnAvailable: (await singboxRuntime.refresh()).running,
|
||||
services,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
...result,
|
||||
vpn: {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user