Add network identity diagnostics
Build and Deploy Gateway / build-and-push (push) Successful in 20s
Build and Deploy Gateway / deploy (push) Successful in 13s

This commit is contained in:
2026-08-11 16:19:23 +03:00
parent 3566f4bc0b
commit 6381760b27
8 changed files with 211 additions and 9 deletions
@@ -4,6 +4,7 @@ import net from 'node:net';
import {
assessConnectivity,
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_NETWORK_SOURCE,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../shared/connectivityDiagnostics.js';
@@ -66,6 +67,16 @@ interface IpProbeResult {
error: string | null;
}
interface NetworkProbeResult {
address: string | null;
asn: string | null;
provider: string | null;
city: string | null;
country: string | null;
attempts: number;
error: string | null;
}
interface SiteProbeResult {
id: string;
label: string;
@@ -80,6 +91,7 @@ interface SiteProbeResult {
}
type DiagnosticTarget =
| { kind: 'network' }
| { kind: 'ip'; probe: IpProbe }
| { kind: 'site'; probe: SiteProbe };
@@ -261,6 +273,55 @@ async function publicIps(path: PathKind, proxyPort: number, execute: CurlExecuto
};
}
function text(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function parseNetwork(body: string): Omit<NetworkProbeResult, 'attempts' | 'error'> | null {
try {
const value = record(JSON.parse(body));
const connection = record(value.connection);
const address = text(value.ip);
if (value.success === false || !address || net.isIP(address) !== 4) return null;
const number = Number(connection.asn);
return {
address,
asn: Number.isSafeInteger(number) && number > 0 ? `AS${number}` : null,
provider: text(connection.isp) || text(connection.org),
city: text(value.city),
country: text(value.country_code) || text(value.country),
};
} catch {
return null;
}
}
async function networkProbe(
path: PathKind,
proxyPort: number,
execute: CurlExecutor,
sampleCount = 1,
): Promise<NetworkProbeResult> {
const samples: Array<RequestResult & { network: ReturnType<typeof parseNetwork> }> = [];
for (let attempt = 0; attempt < sampleCount; attempt += 1) {
const result = await request(CONNECTIVITY_NETWORK_SOURCE, path, proxyPort, execute, { body: true, ipv4: true });
samples.push({ ...result, network: result.ok ? parseNetwork(result.body) : null });
}
const selected = mostCommon(samples
.map(({ network }) => network && JSON.stringify(network))
.filter((value): value is string => Boolean(value)));
const network = selected ? JSON.parse(selected) as ReturnType<typeof parseNetwork> : null;
return {
address: network?.address || null,
asn: network?.asn || null,
provider: network?.provider || null,
city: network?.city || null,
country: network?.country || null,
attempts: samples.length,
error: network ? null : samples.at(-1)?.error || 'invalid network response',
};
}
function isPublicAddress(address: string, family: number) {
const type = family === 4 ? 'ipv4' : family === 6 ? 'ipv6' : '';
const blocked = family === 4 ? BLOCKED_IPV4_ADDRESSES : BLOCKED_IPV6_ADDRESSES;
@@ -361,7 +422,8 @@ async function probePath(
execute: CurlExecutor,
sites: SiteProbe[],
): Promise<ConnectivityPathResult> {
const [ip, siteResults] = await Promise.all([
const [network, ip, siteResults] = await Promise.all([
networkProbe(path, proxyPort, execute),
publicIps(path, proxyPort, execute),
Promise.all(sites.map((probe) => siteProbe(probe, path, proxyPort, execute))),
]);
@@ -370,6 +432,7 @@ async function probePath(
internetAvailable: Boolean(
ip.ipv4.addresses.length || ip.ipv6 || siteResults.some((site) => site.status !== 'unavailable'),
),
network,
...ip,
sites: siteResults,
};
@@ -389,6 +452,7 @@ function unavailablePath(): ConnectivityPathResult {
function resolveTarget(targetId: unknown, sites: SiteProbe[]): DiagnosticTarget | null {
if (typeof targetId !== 'string') return null;
if (targetId === CONNECTIVITY_NETWORK_SOURCE.id) return { kind: 'network' };
if (targetId.startsWith('ip:')) {
const probe = IP_PROBES.find(({ id }) => id === targetId.slice(3));
return probe ? { kind: 'ip', probe } : null;
@@ -406,6 +470,9 @@ async function probeTarget(
proxyPort: number,
execute: CurlExecutor,
): Promise<ConnectivityPathResult> {
const network = target.kind === 'network'
? await networkProbe(path, proxyPort, execute, TARGET_SAMPLE_COUNT)
: null;
const ip = target.kind === 'ip'
? await ipProbe(target.probe, path, proxyPort, execute, TARGET_SAMPLE_COUNT)
: null;
@@ -417,7 +484,8 @@ async function probeTarget(
const sites = site ? [site] : [];
return {
available: true,
internetAvailable: Boolean(ip?.address || (site && site.status !== 'unavailable')),
internetAvailable: Boolean(network?.address || ip?.address || (site && site.status !== 'unavailable')),
...(network ? { network } : {}),
ipv4: {
addresses: ipv4Sources.map(({ address }) => address).filter((value): value is string => Boolean(value)),
sources: ipv4Sources,
+7
View File
@@ -8,6 +8,12 @@ export const CONNECTIVITY_IP_SOURCES = Object.freeze([
{ id: 'ipify-v6', label: 'ipify IPv6', family: 6, url: 'https://api6.ipify.org' },
]);
export const CONNECTIVITY_NETWORK_SOURCE = Object.freeze({
id: 'network',
label: 'Сеть',
url: 'https://ipwho.is/',
});
export const CONNECTIVITY_SITES = Object.freeze([
{ id: 'google', label: 'Google', url: 'https://www.google.com/generate_204' },
{ id: 'youtube', label: 'YouTube', url: 'https://www.youtube.com/generate_204' },
@@ -32,6 +38,7 @@ export interface ConnectivityPathResult {
internetAvailable: boolean;
ipv4: { addresses: string[]; [key: string]: unknown };
ipv6: string | null;
network?: unknown;
sites: ConnectivitySiteResult[];
[key: string]: unknown;
}
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.24.2',
gatewayClient: '0.25.2',
gatewayBackend: '0.25.1',
macClient: '0.25.0',
gatewayClient: '0.26.0',
gatewayBackend: '0.26.0',
});
export interface ParsedVersion {
@@ -10,6 +10,7 @@ import { Drawer } from '../../ui/Drawer.js';
import { Tooltip } from '../../ui/Tooltip.js';
import {
CONNECTIVITY_IP_SOURCES,
CONNECTIVITY_NETWORK_SOURCE,
CONNECTIVITY_SITES,
MAX_CUSTOM_DIAGNOSTIC_SERVICES,
} from '../../../shared/connectivityDiagnostics.js';
@@ -134,6 +135,32 @@ function IpCell({
return <code aria-label={`${route}: ${value.address}`}>{value.address}</code>;
}
function NetworkCell({
path,
pending,
route,
}: {
path: DiagnosticPath | undefined;
pending: boolean;
route: string;
}) {
let status: StatusValue | null = null;
if (path?.available === false) status = ['is-muted', '—'];
else if (pending) status = ['is-running', 'Тестируем'];
else if (!path?.available) status = ['is-muted', '—'];
const identity = [path?.network?.asn, path?.network?.provider].filter(Boolean).join(' · ');
const location = [path?.network?.city, path?.network?.country].filter(Boolean).join(', ');
if (!status && !identity && !location) status = ['is-error', 'Нет ответа'];
if (status) return <>
<Status value={status} route={route} /><br />
<span className="client-diagnostics-status is-muted" aria-hidden="true">&nbsp;</span>
</>;
return <span aria-label={`${route}: ${[identity, location].filter(Boolean).join(', ')}`}>
<span className="client-diagnostics-status">{identity || location}</span><br />
<span className="client-diagnostics-status is-muted">{identity && location ? location : <>&nbsp;</>}</span>
</span>;
}
function mergeItems<T>(previous: T[] = [], incoming: T[] = [], key: (item: T) => string) {
const merged = [...previous];
for (const item of incoming) {
@@ -161,6 +188,7 @@ function mergePath(previous: DiagnosticPath | undefined, incoming: DiagnosticPat
ipv4: { addresses, sources },
ipv6,
ipv6Source,
network: incoming.available === false ? null : incoming.network ?? previous?.network ?? null,
sites,
};
}
@@ -242,6 +270,7 @@ export function ConnectivityDiagnosticsPanel({
try {
let next = result;
const targets = [
CONNECTIVITY_NETWORK_SOURCE.id,
...CONNECTIVITY_IP_SOURCES.map(({ id }) => `ip:${id}`),
...sites.map(({ id }) => `site:${id}`),
];
@@ -378,7 +407,24 @@ export function ConnectivityDiagnosticsPanel({
<th>Напрямую</th>
<th>VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''}</th>
</tr></thead>
<tbody>{CONNECTIVITY_IP_SOURCES.map((source) => {
<tbody>
<tr
data-diagnostic-target={CONNECTIVITY_NETWORK_SOURCE.id}
className={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id ? 'is-running' : undefined}
>
<th scope="row">{CONNECTIVITY_NETWORK_SOURCE.label}</th>
<td><NetworkCell
path={result?.direct}
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
route="Напрямую, сеть"
/></td>
<td><NetworkCell
path={result?.vpn}
pending={activeTarget === CONNECTIVITY_NETWORK_SOURCE.id}
route="VPN, сеть"
/></td>
</tr>
{CONNECTIVITY_IP_SOURCES.map((source) => {
const target = `ip:${source.id}`;
const running = activeTarget === target;
return <tr key={source.id} data-diagnostic-target={target} className={running ? 'is-running' : undefined}>
@@ -12,6 +12,14 @@ export interface DiagnosticSiteResult extends Record<string, unknown> {
latencyMs: number | null;
}
export interface DiagnosticNetworkResult extends Record<string, unknown> {
address: string | null;
asn: string | null;
provider: string | null;
city: string | null;
country: string | null;
}
export interface DiagnosticServer extends Record<string, unknown> {
id: string;
label: string;
@@ -26,6 +34,7 @@ export interface DiagnosticPath extends Record<string, unknown> {
};
ipv6: string | null;
ipv6Source: DiagnosticIpResult | null;
network?: DiagnosticNetworkResult | null;
sites: DiagnosticSiteResult[];
server?: DiagnosticServer | null;
}
@@ -59,6 +68,19 @@ function validSiteResult(value: unknown): value is DiagnosticSiteResult {
&& nullableNonnegativeNumber(value.latencyMs);
}
function nullableText(value: unknown): value is string | null {
return value === null || (typeof value === 'string' && value.length > 0);
}
function validNetworkResult(value: unknown): value is DiagnosticNetworkResult {
return record(value)
&& nullableText(value.address)
&& nullableText(value.asn)
&& nullableText(value.provider)
&& nullableText(value.city)
&& nullableText(value.country);
}
function validServer(value: unknown): value is DiagnosticServer | null {
return value === null || (record(value)
&& typeof value.id === 'string'
@@ -76,6 +98,7 @@ function validPath(value: unknown): value is DiagnosticPath {
&& value.ipv4.sources.every(validIpResult)
&& (value.ipv6 === null || (typeof value.ipv6 === 'string' && value.ipv6.length > 0))
&& (value.ipv6Source === null || validIpResult(value.ipv6Source))
&& (!Object.hasOwn(value, 'network') || value.network === null || validNetworkResult(value.network))
&& Array.isArray(value.sites)
&& value.sites.every(validSiteResult);
}