Add Gateway connectivity diagnostics and traffic chart improvements
This commit is contained in:
@@ -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,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());
|
||||
}
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user