diff --git a/src/server/services/connectivityDiagnosticsService.ts b/src/server/services/connectivityDiagnosticsService.ts index de96118..b407771 100644 --- a/src/server/services/connectivityDiagnosticsService.ts +++ b/src/server/services/connectivityDiagnosticsService.ts @@ -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 | 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 { + const samples: Array }> = []; + 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 : 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 { - 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 { + 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, diff --git a/src/shared/connectivityDiagnostics.ts b/src/shared/connectivityDiagnostics.ts index 167ef36..e425461 100644 --- a/src/shared/connectivityDiagnostics.ts +++ b/src/shared/connectivityDiagnostics.ts @@ -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; } diff --git a/src/shared/versions.ts b/src/shared/versions.ts index b1636f8..4d1ef78 100644 --- a/src/shared/versions.ts +++ b/src/shared/versions.ts @@ -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 { diff --git a/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx b/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx index 9d7a8fd..4b8a676 100644 --- a/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx +++ b/src/web/features/diagnostics/ConnectivityDiagnosticsPanel.tsx @@ -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 {value.address}; } +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 <> +
+ + ; + return + {identity || location}
+ {identity && location ? location : <> } +
; +} + function mergeItems(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({ Напрямую VPN{result?.vpn?.server?.label ? ` · ${result.vpn.server.label}` : ''} - {CONNECTIVITY_IP_SOURCES.map((source) => { + + + {CONNECTIVITY_NETWORK_SOURCE.label} + + + + {CONNECTIVITY_IP_SOURCES.map((source) => { const target = `ip:${source.id}`; const running = activeTarget === target; return diff --git a/src/web/features/diagnostics/connectivityResult.ts b/src/web/features/diagnostics/connectivityResult.ts index 24497fe..04d0a6b 100644 --- a/src/web/features/diagnostics/connectivityResult.ts +++ b/src/web/features/diagnostics/connectivityResult.ts @@ -12,6 +12,14 @@ export interface DiagnosticSiteResult extends Record { latencyMs: number | null; } +export interface DiagnosticNetworkResult extends Record { + address: string | null; + asn: string | null; + provider: string | null; + city: string | null; + country: string | null; +} + export interface DiagnosticServer extends Record { id: string; label: string; @@ -26,6 +34,7 @@ export interface DiagnosticPath extends Record { }; 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); } diff --git a/test/server/connectivity-diagnostics.test.js b/test/server/connectivity-diagnostics.test.js index 497a357..dcd84b3 100644 --- a/test/server/connectivity-diagnostics.test.js +++ b/test/server/connectivity-diagnostics.test.js @@ -49,6 +49,7 @@ test('connectivity diagnostics force separate direct and VPN paths', async () => assert.deepEqual(result.vpn.ipv4.addresses, ['203.0.113.20']); assert.equal(result.direct.ipv6, '2001:db8::10'); assert.equal(result.vpn.ipv6, '2001:db8::20'); + assert.equal(result.direct.network.error, 'invalid network response'); assert.equal(result.assessment.summary, 'available'); assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*'))); assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080'))); @@ -242,6 +243,48 @@ test('a targeted IP row uses three samples and keeps the majority address', asyn assert.equal(result.vpn.ipv4.sources[0].latencyMs, 250); }); +test('a targeted network row reports provider, ASN and location through both forced paths', async () => { + const attempts = { direct: 0, vpn: 0 }; + const calls = []; + const execute = async (args) => { + calls.push(args); + const route = args.includes('--proxy') ? 'vpn' : 'direct'; + attempts[route] += 1; + if (route === 'direct' && attempts.direct === 1) return response('{invalid'); + return response(JSON.stringify(route === 'vpn' ? { + success: true, + ip: '203.0.113.20', + city: 'Amsterdam', + country_code: 'NL', + connection: { asn: 9009, isp: 'M247 Europe' }, + } : { + success: true, + ip: '198.51.100.10', + city: 'Moscow', + country_code: 'RU', + connection: { asn: 12389, isp: 'Rostelecom' }, + })); + }; + const result = await createConnectivityDiagnosticsService({ proxyPort: 18080, execute }) + .run({ vpnAvailable: true, target: 'network' }); + + assert.deepEqual(attempts, { direct: 3, vpn: 3 }); + assert.deepEqual(result.direct.network, { + address: '198.51.100.10', + asn: 'AS12389', + provider: 'Rostelecom', + city: 'Moscow', + country: 'RU', + attempts: 3, + error: null, + }); + assert.equal(result.vpn.network.asn, 'AS9009'); + assert.equal(result.vpn.network.provider, 'M247 Europe'); + assert.ok(calls.every((args) => args.at(-1) === 'https://ipwho.is/' && args.includes('--ipv4'))); + assert.ok(calls.some((args) => args.includes('--noproxy') && args.includes('*'))); + assert.ok(calls.some((args) => args.includes('--proxy') && args.includes('http://127.0.0.1:18080'))); +}); + test('a targeted service row averages three measurements and ignores one transient failure', async () => { const attempts = { direct: 0, vpn: 0 }; const execute = async (args) => { diff --git a/test/web/diagnostics-feature-contract.test.js b/test/web/diagnostics-feature-contract.test.js index fc23908..90e092c 100644 --- a/test/web/diagnostics-feature-contract.test.js +++ b/test/web/diagnostics-feature-contract.test.js @@ -20,6 +20,13 @@ const pathResult = { ipv4: { addresses: ['198.51.100.10'], sources: [ip] }, ipv6: null, ipv6Source: null, + network: { + address: '198.51.100.10', + asn: 'AS12389', + provider: 'Rostelecom', + city: 'Moscow', + country: 'RU', + }, sites: [site], }; const valid = { @@ -62,6 +69,7 @@ test('unknown target and legacy-full results pass one identity-preserving parser { ...valid, direct: { ...valid.direct, available: 'yes' } }, { ...valid, direct: { ...valid.direct, ipv4: { addresses: [], sources: [{}] } } }, { ...valid, direct: { ...valid.direct, ipv6: 6 } }, + { ...valid, direct: { ...valid.direct, network: { ...valid.direct.network, asn: 12389 } } }, { ...valid, direct: { ...valid.direct, sites: [{ ...site, id: '' }] } }, { ...valid, direct: { ...valid.direct, sites: [{ ...site, status: 'blocked' }] } }, { ...valid, direct: { ...valid.direct, sites: [{ ...site, latencyMs: -1 }] } }, @@ -75,7 +83,7 @@ test('unknown target and legacy-full results pass one identity-preserving parser }); test('serial probes, storage and editor behavior stay panel-owned', () => { - assert.match(panel, /const targets = \[[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/); + assert.match(panel, /const targets = \[[\s\S]*CONNECTIVITY_NETWORK_SOURCE\.id[\s\S]*CONNECTIVITY_IP_SOURCES\.map[\s\S]*sites\.map/); assert.match(panel, /for \(const target of targets\) \{[\s\S]*setActiveTarget\(target\)[\s\S]*await runConnectivityDiagnostics\(customServices, target\)[\s\S]*if \(legacyFullResult\) break/); assert.match(panel, /CUSTOM_SERVICES_KEY = 'harbor-diagnostic-services'/); assert.match(panel, /HIDDEN_SERVICES_KEY = 'harbor-hidden-diagnostic-services'/); @@ -85,3 +93,10 @@ test('serial probes, storage and editor behavior stay panel-owned', () => { assert.match(panel, /retryable: Boolean\(Reflect\.get\(value, 'retryable'\)\)/); assert.match(panel, /requestDetails\(error\)[\s\S]*requestError\.retryable/); }); + +test('network identity is one stable compact row for Direct and VPN', () => { + assert.match(panel, /data-diagnostic-target=\{CONNECTIVITY_NETWORK_SOURCE\.id\}[\s\S]*
[\s\S]*client-diagnostics-status is-muted[\s\S]* /); + assert.doesNotMatch(panel, /traceroute|tracepath/); +}); diff --git a/test/web/style-boundaries.test.js b/test/web/style-boundaries.test.js index 2f0f401..a7b68fc 100644 --- a/test/web/style-boundaries.test.js +++ b/test/web/style-boundaries.test.js @@ -56,7 +56,7 @@ const acceptedLedger = { ruleDeclarationSequences: '9dacb5d7407717ae041cabf6e8e75af7cfccfb7b5e4c387880468db409e93fbd', selectors: '8456f64c608e0411bbc56c0c955d25383391f019fa5af0648a72d40564cfee1a', variableReferences: '42661286e6150115a367068908f79c79951e24fb3fcf8775c2b083bb2d520e38', - witnesses: 'd992876195d2c68c00d687c5a174e5657e3480ad8496251afe52d20c9571e82f', + witnesses: '7f44af307fd67fc88e274c1dcc6a21a5e064a8be3895d80fd640715e1af42419', }, }; @@ -209,7 +209,7 @@ test('client typography uses the shared semantic scale outside the token owner', test('accepted stylesheet has pinned declaration, selector, keyframe, variable, and cascade ledgers', () => { const witnesses = readStyleWitnesses(root); - assert.equal(witnesses.length, 737); + assert.equal(witnesses.length, 757); assert.equal(witnesses.filter((witness) => witness.unknown || witness.ancestorUnknown).length, 0); const ledger = createStyleLedger(readStyleSource(root), { witnesses }); assert.deepEqual(ledger.counts, acceptedLedger.counts);