Add Gateway connectivity diagnostics and traffic chart improvements
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-07 20:48:43 +03:00
parent e6666558b7
commit 12375006d3
18 changed files with 1133 additions and 206 deletions
+1
View File
@@ -14,6 +14,7 @@ export const settings = {
appMode: process.env.APP_MODE === "client" ? "client" : "gateway",
port: parsePort(process.env.PORT, 3456),
proxyPort,
diagnosticsProxyPort: parsePort(process.env.DIAGNOSTICS_PROXY_PORT, 18080),
tproxyPort: parsePort(process.env.TPROXY_PORT, 7895),
tproxyMark: process.env.TPROXY_MARK || "1",
tproxyChain: process.env.TPROXY_CHAIN || "VPN_PROXY_TPROXY",
+7
View File
@@ -7,6 +7,7 @@ import { buildVersionInfo } from './version.js';
import { readNeighborSnapshot } from './adapters/neighbors.js';
import { createDeviceTrafficService } from './services/deviceTrafficService.js';
import { createDevicePolicyService } from './services/devicePolicyService.js';
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
const socketPath = settings.dataplaneSocket;
const runtime = createSingboxRuntime({
@@ -27,6 +28,9 @@ const devicePolicy = createDevicePolicyService({
tproxyPort: settings.tproxyPort,
tproxyMark: settings.tproxyMark,
});
const connectivityDiagnostics = createConnectivityDiagnosticsService({
proxyPort: settings.diagnosticsProxyPort,
});
let ready = false;
let trafficTimer = null;
const MAX_POLICY_BODY_BYTES = 256 * 1024;
@@ -86,6 +90,9 @@ const server = http.createServer(async (req, res) => {
const body = await readJson(req);
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 }));
}
if (req.method === 'POST' && req.url === '/apply') {
return sendJson(res, 200, await runtime.apply());
}
+1
View File
@@ -56,6 +56,7 @@ 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: () => send(socketPath, '/diagnostics/connectivity', 'POST'),
apply: () => update('/apply', 'POST'),
restart: () => update('/restart', 'POST'),
stop: () => update('/stop', 'POST'),
+21
View File
@@ -47,6 +47,7 @@ import {
migrateDeviceInventoryState,
} from './services/deviceInventoryService.js';
import { buildGatewayVersionInfo, buildVersionInfo } from './version.js';
import { createConnectivityDiagnosticsService } from './services/connectivityDiagnosticsService.js';
const MAX_BODY_BYTES = 1_000_000;
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
@@ -134,6 +135,9 @@ const deviceInventory = settings.appMode === 'gateway'
vendor: createVendorLookup(),
})
: null;
const localConnectivityDiagnostics = settings.appMode === 'gateway' && !remoteDataplane
? createConnectivityDiagnosticsService({ proxyPort: settings.diagnosticsProxyPort })
: null;
let subscriptionRefreshPromise = null;
let subscriptionRefreshTimer = null;
let gatewayDiscoveryPromise = null;
@@ -694,6 +698,23 @@ async function handleApi(req, res) {
return sendState(res, { results });
}
if (req.method === 'POST' && req.url === '/api/diagnostics/connectivity') {
if (settings.appMode !== 'gateway') throw new HarborError('ENDPOINT_NOT_FOUND');
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 });
return sendJson(res, 200, {
...result,
vpn: {
...result.vpn,
server: selected ? { id: selected.id, label: selected.label } : null,
},
});
}
if (req.method === 'POST' && req.url === '/api/subscription/fetch') {
const { url = '' } = await readBody(req);
const normalizedUrl = String(url).trim();
@@ -0,0 +1,244 @@
import { execFile } from 'node:child_process';
import net from 'node:net';
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' },
];
function runCurl(args) {
return new Promise((resolve) => {
execFile('curl', args, { encoding: 'utf8', maxBuffer: 64 * 1024 }, (error, stdout, stderr) => {
resolve({
exitCode: Number.isInteger(error?.code) ? error.code : error ? null : 0,
error: error?.message || '',
stderr: stderr || '',
stdout: stdout || '',
});
});
});
}
function stageFor(exitCode) {
if (exitCode === 6) return 'dns';
if (exitCode === 7) return 'tcp';
if ([35, 51, 58, 60].includes(exitCode)) return 'tls';
if (exitCode === 28) return 'timeout';
return 'request';
}
function milliseconds(value) {
const seconds = Number(value);
return Number.isFinite(seconds) ? Math.round(seconds * 1000) : null;
}
async function request(probe, path, proxyPort, execute, { body = false, ipv4 = false } = {}) {
const args = [
'--silent',
'--show-error',
'--location',
'--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'] : []),
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 = {};
try {
meta = 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 = 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, path, proxyPort, execute, version) {
const result = await request(probe, path, proxyPort, execute, { body: true, ipv4: version === 4 });
const address = result.ok ? probe.address(result.body) : null;
const valid = typeof address === 'string' && net.isIP(address) === version;
return {
source: probe.id,
address: valid ? address : null,
latencyMs: result.latencyMs,
error: valid ? null : result.error || 'invalid IP response',
};
}
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)];
return {
ipv4: {
addresses: [...new Set(probes.map((probe) => probe.address).filter(Boolean))],
sources: probes,
},
ipv6: ipv6.address,
ipv6Source: ipv6,
};
}
async function siteProbe(probe, path, proxyPort, execute) {
let result = await request(probe, path, proxyPort, execute);
let attempts = 1;
if (!result.ok) {
result = await request(probe, path, proxyPort, execute);
attempts = 2;
}
const status = !result.ok
? 'unavailable'
: result.httpStatus >= 200 && result.httpStatus < 400
? 'available'
: 'responded';
return {
id: probe.id,
label: probe.label,
status,
attempts,
httpStatus: result.httpStatus,
latencyMs: result.latencyMs,
totalMs: result.totalMs,
stage: result.stage,
error: result.error,
};
}
async function probePath(path, proxyPort, execute) {
const [ip, sites] = await Promise.all([
publicIps(path, proxyPort, execute),
Promise.all(SITE_PROBES.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,
sites,
};
}
export function assessConnectivity(direct, vpn) {
const comparisons = SITE_PROBES.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 };
}
export function createConnectivityDiagnosticsService({
proxyPort,
execute = runCurl,
now = () => new Date().toISOString(),
}) {
let inFlight = null;
async function runOnce({ vpnAvailable }) {
const directPromise = probePath('direct', proxyPort, execute);
const vpnPromise = vpnAvailable
? probePath('vpn', proxyPort, execute)
: Promise.resolve({
available: false,
reason: 'vpn-off',
internetAvailable: false,
ipv4: { addresses: [], sources: [] },
ipv6: null,
ipv6Source: null,
sites: [],
});
const [direct, vpn] = await Promise.all([directPromise, vpnPromise]);
return {
checkedAt: now(),
direct,
vpn,
assessment: assessConnectivity(direct, vpn),
};
}
return {
run(options) {
if (!inFlight) inFlight = runOnce(options).finally(() => { inFlight = null; });
return inFlight;
},
};
}
+10
View File
@@ -7,6 +7,7 @@ import { atomicWriteFile, atomicWriteJson } from './services/stateStore.js';
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
const MIXED_INBOUND = 'mixed-in';
const TPROXY_INBOUND = 'tproxy-in';
const DIAGNOSTICS_INBOUND = 'diagnostics-vpn-in';
function findOutbound(subscriptionConfig, selectedTag) {
const outbounds = Array.isArray(subscriptionConfig?.outbounds)
@@ -51,6 +52,14 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
sniff: true,
set_system_proxy: false,
},
...(!clientMode ? [{
type: 'mixed',
tag: DIAGNOSTICS_INBOUND,
listen: '127.0.0.1',
listen_port: settings.diagnosticsProxyPort,
sniff: true,
set_system_proxy: false,
}] : []),
];
const directRules = normalizeRouteRules(routeRules)
.filter((rule) => rule.enabled)
@@ -58,6 +67,7 @@ export function buildGatewayConfig(subscriptionConfig, selectedTag, {
const rules = clientMode
? [...directRules, { inbound: [MIXED_INBOUND], outbound: outboundTag }]
: [
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag },