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