diff --git a/src/server/dataplane.js b/src/server/dataplane.js
index 593e669..5329701 100644
--- a/src/server/dataplane.js
+++ b/src/server/dataplane.js
@@ -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') {
diff --git a/src/server/dataplaneClient.js b/src/server/dataplaneClient.js
index 22d6bb0..940d8aa 100644
--- a/src/server/dataplaneClient.js
+++ b/src/server/dataplaneClient.js
@@ -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 });
}
diff --git a/src/server/index.js b/src/server/index.js
index 143fe67..925fae1 100644
--- a/src/server/index.js
+++ b/src/server/index.js
@@ -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,
diff --git a/src/server/services/connectivityDiagnosticsService.js b/src/server/services/connectivityDiagnosticsService.js
index aa0c57b..f072282 100644
--- a/src/server/services/connectivityDiagnosticsService.js
+++ b/src/server/services/connectivityDiagnosticsService.js
@@ -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(),
diff --git a/src/shared/connectivityDiagnostics.js b/src/shared/connectivityDiagnostics.js
index b28aea3..16de8a5 100644
--- a/src/shared/connectivityDiagnostics.js
+++ b/src/shared/connectivityDiagnostics.js
@@ -18,3 +18,44 @@ export const CONNECTIVITY_SITES = Object.freeze([
]);
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
+
+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 };
+}
diff --git a/src/shared/versions.js b/src/shared/versions.js
index 305702d..22ac138 100644
--- a/src/shared/versions.js
+++ b/src/shared/versions.js
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
- macClient: '0.17.5',
- gatewayClient: '0.18.5',
- gatewayBackend: '0.18.0',
+ macClient: '0.17.6',
+ gatewayClient: '0.18.6',
+ gatewayBackend: '0.18.1',
});
export function parseVersion(value) {
diff --git a/src/web/api.js b/src/web/api.js
index a6c882c..2b5de00 100644
--- a/src/web/api.js
+++ b/src/web/api.js
@@ -92,9 +92,9 @@ export const api = {
}),
},
diagnostics: {
- connectivity: (services = []) => request('/api/diagnostics/connectivity', {
+ connectivity: (services = [], target = null) => request('/api/diagnostics/connectivity', {
method: 'POST',
- body: JSON.stringify({ services }),
+ body: JSON.stringify({ services, target }),
}),
},
singbox: {
diff --git a/src/web/components/ConnectivityDiagnosticsPanel.jsx b/src/web/components/ConnectivityDiagnosticsPanel.jsx
index 8a54ba1..b3173c8 100644
--- a/src/web/components/ConnectivityDiagnosticsPanel.jsx
+++ b/src/web/components/ConnectivityDiagnosticsPanel.jsx
@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react';
import { api } from '../api.js';
import {
+ assessConnectivity,
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
@@ -36,8 +37,9 @@ function readCustomServices() {
}
function resultStatus(site, pending, available = true) {
- if (pending && !site) return ['is-running', 'Проверяем'];
- if (!available || !site) return ['is-muted', '—'];
+ if (!available) return ['is-muted', '—'];
+ if (pending) return ['is-running', 'Проверяем'];
+ if (!site) return ['is-muted', '—'];
if (site.status === 'unavailable') return ['is-error', 'Нет доступа'];
if (site.status === 'responded') return ['is-warning', `HTTP ${site.httpStatus}`];
return ['is-good', site.latencyMs === null ? 'Доступен' : `${site.latencyMs} мс`];
@@ -57,15 +59,52 @@ function ipResult(path, source) {
function IpCell({ path, source, pending, route }) {
const value = ipResult(path, source);
- if (pending && !value) return {value.address};
}
+function mergeItems(previous = [], incoming = [], key) {
+ const merged = [...previous];
+ for (const item of incoming) {
+ const index = merged.findIndex((value) => value[key] === item[key]);
+ if (index >= 0) merged[index] = item;
+ else merged.push(item);
+ }
+ return merged;
+}
+
+function mergePath(previous, incoming) {
+ const sources = mergeItems(previous?.ipv4?.sources, incoming.ipv4?.sources, 'source');
+ const sites = mergeItems(previous?.sites, incoming.sites, 'id');
+ const ipv6Source = incoming.ipv6Source || previous?.ipv6Source || null;
+ const ipv6 = ipv6Source?.address || null;
+ const addresses = [...new Set(sources.map(({ address }) => address).filter(Boolean))];
+ return {
+ ...previous,
+ ...incoming,
+ internetAvailable: Boolean(
+ addresses.length || ipv6 || sites.some(({ status }) => status !== 'unavailable'),
+ ),
+ ipv4: { addresses, sources },
+ ipv6,
+ ipv6Source,
+ sites,
+ };
+}
+
+function mergeResult(previous, incoming) {
+ const direct = mergePath(previous?.direct, incoming.direct);
+ const vpn = mergePath(previous?.vpn, incoming.vpn);
+ return { ...incoming, direct, vpn, assessment: assessConnectivity(direct, vpn) };
+}
+
export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeRef, onClose }) {
const [result, setResult] = useState(null);
const [status, setStatus] = useState('idle');
+ const [activeTarget, setActiveTarget] = useState(null);
const [error, setError] = useState(null);
const [customServices, setCustomServices] = useState(readCustomServices);
const [adding, setAdding] = useState(false);
@@ -85,11 +124,25 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR
setStatus('running');
setError(null);
try {
- setResult(await api.diagnostics.connectivity(customServices));
+ let next = result;
+ const targets = [
+ ...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
+ ...[...CONNECTIVITY_SITES, ...customServices].map(({ id }) => `site:${id}`),
+ ];
+ for (const target of targets) {
+ setActiveTarget(target);
+ const partial = await api.diagnostics.connectivity(customServices, target);
+ const legacyFullResult = partial.direct.ipv4.sources.length > 1 || partial.direct.sites.length > 1;
+ next = legacyFullResult ? partial : mergeResult(next, partial);
+ setResult(next);
+ if (legacyFullResult) break;
+ }
setStatus('ready');
} catch (requestError) {
setError(requestError);
setStatus('error');
+ } finally {
+ setActiveTarget(null);
}
}
@@ -179,11 +232,14 @@ export function ConnectivityDiagnosticsPanel({ isGateway, open, panelRef, closeR