153 lines
6.3 KiB
TypeScript
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 };
|
|
}
|