Add targeted connectivity diagnostics with sampled results
This commit is contained in:
@@ -91,10 +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') {
|
||||
const { services = [] } = await readJson(req);
|
||||
const { services = [], target = null } = await readJson(req);
|
||||
return sendJson(res, 200, await connectivityDiagnostics.run({
|
||||
vpnAvailable: runtime.running,
|
||||
services,
|
||||
target,
|
||||
}));
|
||||
}
|
||||
if (req.method === 'POST' && req.url === '/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 (services = []) => {
|
||||
runConnectivityDiagnostics: async (services = [], target = null) => {
|
||||
try {
|
||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services }, 15_000);
|
||||
return await send(socketPath, '/diagnostics/connectivity', 'POST', { services, target }, 25_000);
|
||||
} catch (cause) {
|
||||
throw new HarborError('DIAGNOSTICS_FAILED', { cause });
|
||||
}
|
||||
|
||||
+3
-2
@@ -699,15 +699,16 @@ async function handleApi(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
|
||||
const { services = [] } = await readBody(req);
|
||||
const { services = [], target = null } = 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(services)
|
||||
? await singboxRuntime.runConnectivityDiagnostics(services, target)
|
||||
: await localConnectivityDiagnostics.run({
|
||||
vpnAvailable: (await singboxRuntime.refresh()).running,
|
||||
services,
|
||||
target,
|
||||
});
|
||||
return sendJson(res, 200, {
|
||||
...result,
|
||||
|
||||
@@ -2,6 +2,7 @@ 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,
|
||||
@@ -18,6 +19,7 @@ const IP_PROBES = CONNECTIVITY_IP_SOURCES.map((probe) => ({
|
||||
: (body) => body.trim(),
|
||||
}));
|
||||
const SITE_PROBES = CONNECTIVITY_SITES;
|
||||
const TARGET_SAMPLE_COUNT = 3;
|
||||
|
||||
const BLOCKED_IPV4_ADDRESSES = new net.BlockList();
|
||||
for (const [address, prefix] of [
|
||||
@@ -58,6 +60,26 @@ function milliseconds(value) {
|
||||
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
|
||||
}
|
||||
|
||||
function average(values) {
|
||||
const numbers = values.filter(Number.isFinite);
|
||||
return numbers.length ? Math.round(numbers.reduce((sum, value) => sum + value, 0) / numbers.length) : null;
|
||||
}
|
||||
|
||||
function mostCommon(values) {
|
||||
const counts = new Map();
|
||||
let selected = 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, path, proxyPort, execute, {
|
||||
body = false,
|
||||
ipv4 = false,
|
||||
@@ -112,17 +134,26 @@ async function request(probe, path, proxyPort, execute, {
|
||||
};
|
||||
}
|
||||
|
||||
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) === probe.family;
|
||||
async function ipProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
const samples = [];
|
||||
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(Boolean));
|
||||
const matching = samples.filter((sample) => sample.address === address);
|
||||
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',
|
||||
address,
|
||||
attempts: samples.length,
|
||||
latencyMs: average(matching.map((sample) => sample.latencyMs)),
|
||||
error: address ? null : samples.at(-1)?.error || 'invalid IP response',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -182,7 +213,12 @@ async function prepareCustomProbes(services, lookup) {
|
||||
}));
|
||||
}
|
||||
|
||||
async function siteProbe(probe, path, proxyPort, execute) {
|
||||
function siteStatus(result) {
|
||||
if (!result.ok) return 'unavailable';
|
||||
return result.httpStatus >= 200 && result.httpStatus < 400 ? 'available' : 'responded';
|
||||
}
|
||||
|
||||
async function siteProbe(probe, path, proxyPort, execute, sampleCount = 1) {
|
||||
if (probe.validationError) return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
@@ -195,27 +231,26 @@ async function siteProbe(probe, path, proxyPort, execute) {
|
||||
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, options);
|
||||
attempts = 2;
|
||||
const samples = [];
|
||||
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
const status = !result.ok
|
||||
? 'unavailable'
|
||||
: result.httpStatus >= 200 && result.httpStatus < 400
|
||||
? 'available'
|
||||
: 'responded';
|
||||
if (sampleCount === 1 && !samples[0].ok) {
|
||||
samples.push(await request(probe, path, proxyPort, execute, options));
|
||||
}
|
||||
const status = mostCommon(samples.map(siteStatus));
|
||||
const matching = samples.filter((sample) => siteStatus(sample) === status);
|
||||
const representative = matching.at(-1);
|
||||
return {
|
||||
id: probe.id,
|
||||
label: probe.label,
|
||||
status,
|
||||
attempts,
|
||||
httpStatus: result.httpStatus,
|
||||
latencyMs: result.latencyMs,
|
||||
totalMs: result.totalMs,
|
||||
stage: result.stage,
|
||||
error: result.error,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -234,68 +269,78 @@ async function probePath(path, proxyPort, execute, sites) {
|
||||
};
|
||||
}
|
||||
|
||||
export function assessConnectivity(direct, vpn) {
|
||||
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';
|
||||
if (!vpn.available) assessment = 'not-tested';
|
||||
else if (directSite?.status === 'available' && vpnSite?.status === 'available') {
|
||||
assessment = 'available';
|
||||
} else if (
|
||||
directSite?.status === 'responded'
|
||||
&& [403, 451].includes(directSite.httpStatus)
|
||||
&& vpnSite?.status === 'available'
|
||||
) assessment = 'likely-direct-restriction';
|
||||
else if (
|
||||
directSite?.status === 'unavailable'
|
||||
&& vpnSite?.status === 'available'
|
||||
&& direct.internetAvailable
|
||||
) {
|
||||
assessment = 'likely-direct-restriction';
|
||||
} else if (directSite?.status === 'available' && vpnSite?.status !== 'available') {
|
||||
assessment = 'vpn-problem';
|
||||
} else if (directSite?.status === 'unavailable' && vpnSite?.status === 'unavailable') {
|
||||
assessment = 'unavailable';
|
||||
}
|
||||
return { id, label, assessment, direct: directSite, vpn: vpnSite || null };
|
||||
});
|
||||
const directAddresses = [...direct.ipv4.addresses, direct.ipv6].filter(Boolean);
|
||||
const vpnAddresses = [...(vpn.ipv4?.addresses || []), vpn.ipv6].filter(Boolean);
|
||||
const sameEgress = directAddresses.some((address) => vpnAddresses.includes(address));
|
||||
let summary = 'inconclusive';
|
||||
if (!vpn.available) summary = 'vpn-off';
|
||||
else if (!direct.internetAvailable && !vpn.internetAvailable) summary = 'offline';
|
||||
else if (!direct.internetAvailable && vpn.internetAvailable) summary = 'direct-offline';
|
||||
else if (direct.internetAvailable && !vpn.internetAvailable) summary = 'vpn-problem';
|
||||
else if (comparisons.some((item) => item.assessment === 'likely-direct-restriction')) {
|
||||
summary = 'likely-direct-restriction';
|
||||
} else if (sameEgress) summary = 'same-ip';
|
||||
else if (comparisons.every((item) => item.assessment === 'available')) summary = 'available';
|
||||
return { summary, sameEgress, comparisons };
|
||||
function unavailablePath() {
|
||||
return {
|
||||
available: false,
|
||||
reason: 'vpn-off',
|
||||
internetAvailable: false,
|
||||
ipv4: { addresses: [], sources: [] },
|
||||
ipv6: null,
|
||||
ipv6Source: null,
|
||||
sites: [],
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTarget(targetId, sites) {
|
||||
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, path, proxyPort, execute) {
|
||||
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(Boolean),
|
||||
sources: ipv4Sources,
|
||||
},
|
||||
ipv6: ipv6Source?.address || null,
|
||||
ipv6Source,
|
||||
sites,
|
||||
};
|
||||
}
|
||||
|
||||
export { assessConnectivity };
|
||||
|
||||
export function createConnectivityDiagnosticsService({
|
||||
proxyPort,
|
||||
execute = runCurl,
|
||||
lookup = dnsLookup,
|
||||
now = () => new Date().toISOString(),
|
||||
}) {
|
||||
async function runOnce({ vpnAvailable, services = [] }) {
|
||||
const customProbes = await prepareCustomProbes(services, lookup);
|
||||
async function runOnce({ vpnAvailable, services = [], target: targetId = null }) {
|
||||
const requestedServices = targetId?.startsWith('site:custom-')
|
||||
? (Array.isArray(services) ? services : []).filter(({ id }) => `site:${id}` === targetId)
|
||||
: targetId ? [] : services;
|
||||
const customProbes = await prepareCustomProbes(requestedServices, lookup);
|
||||
const siteProbes = [...SITE_PROBES, ...customProbes];
|
||||
const directPromise = probePath('direct', proxyPort, execute, siteProbes);
|
||||
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
|
||||
? probePath('vpn', proxyPort, execute, siteProbes)
|
||||
: Promise.resolve({
|
||||
available: false,
|
||||
reason: 'vpn-off',
|
||||
internetAvailable: false,
|
||||
ipv4: { addresses: [], sources: [] },
|
||||
ipv6: null,
|
||||
ipv6Source: null,
|
||||
sites: [],
|
||||
});
|
||||
? 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(),
|
||||
|
||||
Reference in New Issue
Block a user