From 0d24350445255f747696bbf241be9d3d02005299 Mon Sep 17 00:00:00 2001 From: kappa Date: Thu, 12 Feb 2026 03:33:23 +0900 Subject: [PATCH] Add Globalping API for Korea-based latency measurement in domain diagnostics Integrates Globalping API to measure ping and HTTP response times from Korean probes (Seoul, Chuncheon, Daejeon). Results include per-probe ping stats and HTTP timing breakdown (DNS, TCP, TLS, TTFB). Co-Authored-By: Claude Opus 4.6 --- src/services/network-diagnostic.ts | 201 ++++++++++++++++++++++++++++- 1 file changed, 197 insertions(+), 4 deletions(-) diff --git a/src/services/network-diagnostic.ts b/src/services/network-diagnostic.ts index 27b4b0e..90a6b5a 100644 --- a/src/services/network-diagnostic.ts +++ b/src/services/network-diagnostic.ts @@ -9,7 +9,7 @@ */ import { connect } from 'cloudflare:sockets'; -import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult } from '../types'; +import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult, GlobalpingProbeResult } from '../types'; import { createLogger } from '../utils/logger'; const logger = createLogger('network-diagnostic'); @@ -410,6 +410,170 @@ async function checkTcpPorts(domain: string, ports?: number[]): Promise { + try { + const body: Record = { + type, + target, + locations: [{ country: 'KR' }], + limit: 5, + }; + + if (type === 'ping') { + body.measurementOptions = { packets: 3 }; + } else { + body.measurementOptions = { + protocol: 'HTTPS', + request: { method: 'HEAD' }, + }; + } + + const response = await fetch(GLOBALPING_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + logger.warn('Globalping measurement creation failed', { + type, + status: response.status, + }); + return null; + } + + const data = (await response.json()) as { id: string }; + return data.id; + } catch (error) { + logger.error('Globalping API error', error as Error); + return null; + } +} + +async function pollGlobalpingResult(id: string): Promise { + const deadline = Date.now() + GLOBALPING_MAX_WAIT_MS; + + while (Date.now() < deadline) { + try { + const response = await fetch(`${GLOBALPING_API}/${id}`); + if (!response.ok) return []; + + const data = (await response.json()) as GlobalpingMeasurementResponse; + if (data.status === 'finished') { + return data.results || []; + } + + // Wait before polling again + await new Promise((r) => setTimeout(r, GLOBALPING_POLL_INTERVAL_MS)); + } catch { + return []; + } + } + + return []; +} + +async function checkKoreaProbes(domain: string): Promise { + // Create ping and HTTP measurements in parallel + const [pingId, httpId] = await Promise.all([ + createGlobalpingMeasurement('ping', domain), + createGlobalpingMeasurement('http', domain), + ]); + + if (!pingId && !httpId) return []; + + // Poll both results in parallel + const [pingResults, httpResults] = await Promise.all([ + pingId ? pollGlobalpingResult(pingId) : Promise.resolve([]), + httpId ? pollGlobalpingResult(httpId) : Promise.resolve([]), + ]); + + // Merge ping and HTTP results by probe city+network + const probeMap = new Map(); + + for (const r of pingResults) { + const key = `${r.probe.city}:${r.probe.network}`; + probeMap.set(key, { + city: r.probe.city, + network: r.probe.network, + asn: r.probe.asn, + ping: r.result.stats ? { + min: r.result.stats.min, + avg: r.result.stats.avg, + max: r.result.stats.max, + loss: r.result.stats.loss, + } : undefined, + error: r.result.status !== 'finished' ? r.result.rawOutput : undefined, + }); + } + + for (const r of httpResults) { + const key = `${r.probe.city}:${r.probe.network}`; + const existing = probeMap.get(key) || { + city: r.probe.city, + network: r.probe.network, + asn: r.probe.asn, + }; + existing.http = r.result.timings ? { + statusCode: r.result.statusCode || 0, + totalMs: r.result.timings.total, + dnsMs: r.result.timings.dns, + tcpMs: r.result.timings.tcp, + tlsMs: r.result.timings.tls, + firstByteMs: r.result.timings.firstByte, + } : undefined; + if (!existing.error && r.result.status !== 'finished') { + existing.error = r.result.rawOutput; + } + probeMap.set(key, existing); + } + + return [...probeMap.values()]; +} + // ============================================================================ // Summary Generation // ============================================================================ @@ -418,7 +582,8 @@ function generateSummary( dns: DnsResolverResult[], ispBlocking: IspDnsBlockResult[], http: HttpCheckResult[], - tcp: TcpCheckResult[] + tcp: TcpCheckResult[], + koreaProbes: GlobalpingProbeResult[] ): string[] { const summary: string[] = []; @@ -479,6 +644,30 @@ function generateSummary( summary.push(`TCP 연결 성공: ${tcpOk.map((r) => `포트 ${r.port} (${r.responseTimeMs}ms)`).join(', ')}`); } + // Korea probe results + if (koreaProbes.length > 0) { + const pingProbes = koreaProbes.filter((p) => p.ping); + if (pingProbes.length > 0) { + const avgPing = pingProbes.reduce((sum, p) => sum + (p.ping?.avg || 0), 0) / pingProbes.length; + const details = pingProbes.map((p) => `${p.city}/${p.network}: ${p.ping!.avg.toFixed(1)}ms`).join(', '); + summary.push(`한국 Ping: 평균 ${avgPing.toFixed(1)}ms (${details})`); + if (pingProbes.some((p) => (p.ping?.loss || 0) > 0)) { + const lossy = pingProbes.filter((p) => (p.ping?.loss || 0) > 0); + summary.push(`패킷 손실 감지: ${lossy.map((p) => `${p.city}: ${p.ping!.loss}%`).join(', ')}`); + } + } + const httpProbes = koreaProbes.filter((p) => p.http); + if (httpProbes.length > 0) { + const avgTotal = httpProbes.reduce((sum, p) => sum + (p.http?.totalMs || 0), 0) / httpProbes.length; + const details = httpProbes.map((p) => `${p.city}: ${p.http!.totalMs}ms(TTFB ${p.http!.firstByteMs}ms)`).join(', '); + summary.push(`한국 HTTP 응답: 평균 ${avgTotal.toFixed(0)}ms (${details})`); + } + const errorProbes = koreaProbes.filter((p) => p.error); + if (errorProbes.length > 0) { + summary.push(`한국 프로브 오류: ${errorProbes.map((p) => `${p.city}: ${p.error}`).join(', ')}`); + } + } + return summary; } @@ -491,6 +680,7 @@ export interface NetworkDiagnosticOptions { skipIspCheck?: boolean; skipHttpCheck?: boolean; skipTcpCheck?: boolean; + skipKoreaProbes?: boolean; } export async function runNetworkDiagnostic( @@ -501,19 +691,21 @@ export async function runNetworkDiagnostic( const startTime = Date.now(); // Run all checks in parallel - const [dns, ispBlocking, http, tcp] = await Promise.all([ + const [dns, ispBlocking, http, tcp, koreaProbes] = await Promise.all([ resolveDoh(domain), options?.skipIspCheck ? Promise.resolve([]) : checkIspBlocking(domain), options?.skipHttpCheck ? Promise.resolve([]) : checkHttp(domain), options?.skipTcpCheck ? Promise.resolve([]) : checkTcpPorts(domain, options?.ports), + options?.skipKoreaProbes ? Promise.resolve([]) : checkKoreaProbes(domain), ]); - const summary = generateSummary(dns, ispBlocking, http, tcp); + const summary = generateSummary(dns, ispBlocking, http, tcp, koreaProbes); logger.info('네트워크 진단 완료', { domain, durationMs: Date.now() - startTime, summaryCount: summary.length, + koreaProbeCount: koreaProbes.length, }); return { @@ -523,6 +715,7 @@ export async function runNetworkDiagnostic( ispBlocking, http, tcp, + koreaProbes, summary, }; }