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
@@ -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;
},
};
}