Files
harbor-net/src/shared/connectivityDiagnostics.ts
T
dokril 416b2b294a
Build and Deploy Gateway / build-and-push (push) Successful in 21s
Build and Deploy Gateway / deploy (push) Successful in 14s
Persist configurable connectivity diagnostics
2026-08-19 13:38:19 +03:00

153 lines
6.3 KiB
TypeScript

export const CONNECTIVITY_IP_SOURCES = Object.freeze([
{ id: 'cloudflare', label: 'Cloudflare', family: 4, url: 'https://www.cloudflare.com/cdn-cgi/trace' },
{ id: 'ipify', label: 'ipify', family: 4, url: 'https://api.ipify.org' },
{ id: 'aws', label: 'AWS', family: 4, url: 'https://checkip.amazonaws.com' },
{ id: 'icanhazip', label: 'icanhazip', family: 4, url: 'https://icanhazip.com' },
{ id: 'ifconfig-me', label: 'ifconfig.me', family: 4, url: 'https://ifconfig.me/ip' },
{ id: 'yandex-internet', label: 'Яндекс Интернетометр', family: 4, url: 'https://yandex.ru/internet/' },
{ 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' },
{ id: 'instagram', label: 'Instagram', url: 'https://www.instagram.com/' },
{ id: 'speedtest', label: 'Speedtest', url: 'https://www.speedtest.net/' },
{ id: 'yandex', label: 'Яндекс Интернетометр', url: 'https://yandex.ru/internet/' },
{ id: '2ip', label: '2IP', url: 'https://2ip.ru/' },
]);
export const MAX_CUSTOM_DIAGNOSTIC_SERVICES = 5;
export interface DiagnosticService {
id: string;
label: string;
url: string;
}
export interface DiagnosticSettings {
configured: boolean;
customServices: DiagnosticService[];
hiddenServiceIds: string[];
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: {};
}
function diagnosticService(value: unknown): DiagnosticService | null {
const candidate = record(value);
const id = typeof candidate.id === 'string' ? candidate.id.trim() : '';
const label = typeof candidate.label === 'string' ? candidate.label.trim() : '';
if (!/^custom-[a-z0-9-]{1,80}$/i.test(id) || !label || label.length > 40) return null;
try {
const url = new URL(typeof candidate.url === 'string' ? candidate.url.trim() : '');
if (
url.protocol !== 'https:'
|| url.username
|| url.password
|| (url.port && url.port !== '443')
) return null;
return { id, label, url: url.href };
} catch {
return null;
}
}
export function normalizeDiagnosticSettings(
value: unknown,
{ strict = false }: { strict?: boolean } = {},
): DiagnosticSettings {
const candidate = record(value);
const requestedServices = Array.isArray(candidate.customServices) ? candidate.customServices : [];
const customServices = requestedServices
.map(diagnosticService)
.filter((service): service is DiagnosticService => Boolean(service))
.filter((service, index, services) => services.findIndex(({ id }) => id === service.id) === index)
.slice(0, MAX_CUSTOM_DIAGNOSTIC_SERVICES);
const builtInIds = new Set(CONNECTIVITY_SITES.map(({ id }) => id));
const requestedHiddenIds = Array.isArray(candidate.hiddenServiceIds) ? candidate.hiddenServiceIds : [];
const hiddenServiceIds = requestedHiddenIds
.filter((id): id is string => typeof id === 'string' && builtInIds.has(id))
.filter((id, index, ids) => ids.indexOf(id) === index);
if (strict && (
typeof candidate.configured !== 'boolean'
|| !Array.isArray(candidate.customServices)
|| !Array.isArray(candidate.hiddenServiceIds)
|| customServices.length !== requestedServices.length
|| hiddenServiceIds.length !== requestedHiddenIds.length
)) throw new TypeError('Invalid diagnostic settings');
return {
configured: candidate.configured === true,
customServices,
hiddenServiceIds,
};
}
export interface ConnectivitySiteResult {
id: string;
label: string;
status: string;
httpStatus?: number | null;
[key: string]: unknown;
}
export interface ConnectivityPathResult {
available?: boolean;
internetAvailable: boolean;
ipv4: { addresses: string[]; [key: string]: unknown };
ipv6: string | null;
network?: unknown;
sites: ConnectivitySiteResult[];
[key: string]: unknown;
}
export function assessConnectivity(direct: ConnectivityPathResult, vpn: ConnectivityPathResult) {
const comparisons = direct.sites.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(Number(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 };
}