diff --git a/src/agents/troubleshoot-agent.ts b/src/agents/troubleshoot-agent.ts index 75803ff..988754f 100644 --- a/src/agents/troubleshoot-agent.ts +++ b/src/agents/troubleshoot-agent.ts @@ -87,9 +87,10 @@ export class TroubleshootAgent extends BaseAgent { - check_server_status: 서버 상태 확인 (server_id 없으면 전체 목록) - check_domain_status: 도메인 상태 확인 (domain 없으면 전체 목록) - check_service_status: DDoS/VPN 서비스 상태 확인 -- diagnose_domain: 도메인 네트워크 진단 (DNS 조회, ISP 차단 감지, HTTP/TCP 연결 테스트) - - "접속이 안 돼요", "사이트가 안 열려요", "차단된 것 같아요" 등의 문의 시 즉시 호출 - - DNS, HTTP, ISP 차단 여부를 한번에 확인하여 원인을 빠르게 파악 +- diagnose_domain: 도메인 네트워크 종합 진단 + - DNS 조회, HTTP/HTTPS 연결 테스트, 한국 내 Ping/HTTP 응답 시간 측정 + - 한국 ISP(KT, LG U+) eyeball 프로브에서 SNI 차단 여부 자동 감지 + - "접속이 안 돼요", "사이트가 안 열려요", "차단된 것 같아요", "느려요" 등의 문의 시 즉시 호출 - generate_diagram: 문제 상황을 Mermaid 다이어그램으로 시각화 (충분한 정보 수집 후)`; } diff --git a/src/services/network-diagnostic.ts b/src/services/network-diagnostic.ts index 90a6b5a..8f97c1d 100644 --- a/src/services/network-diagnostic.ts +++ b/src/services/network-diagnostic.ts @@ -3,32 +3,19 @@ * * 도메인 접속 문제 진단을 위한 네트워크 체크: * - DoH DNS 조회 (Cloudflare, Google) - * - 한국 ISP DNS 차단 감지 (KT, LG, SK) — TCP DNS 쿼리 * - HTTP/HTTPS 연결 테스트 * - TCP 포트 연결 테스트 + * - Globalping 한국 프로브 (Ping + HTTP 응답 시간 + ISP SNI 차단 감지) */ import { connect } from 'cloudflare:sockets'; -import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult, GlobalpingProbeResult } from '../types'; +import type { NetworkDiagnosticReport, DnsResolverResult, HttpCheckResult, TcpCheckResult, GlobalpingProbeResult } from '../types'; import { createLogger } from '../utils/logger'; const logger = createLogger('network-diagnostic'); const CHECK_TIMEOUT_MS = 5000; -// warning.or.kr 및 알려진 ISP 차단 페이지 IP -const KNOWN_BLOCK_IPS = new Set([ - '121.189.57.82', // warning.or.kr (KT) - '121.189.57.83', // warning.or.kr - '211.234.62.24', // KCSC block page - '211.234.62.25', - '175.158.50.11', // block redirect - '175.158.50.12', - '210.91.57.228', // LGU+ block - '61.97.65.4', // SKT block - '121.167.36.1', // common block redirect -]); - // ============================================================================ // DoH DNS Resolution (Cloudflare & Google) // ============================================================================ @@ -114,200 +101,6 @@ async function resolveDoh(domain: string): Promise { ); } -// ============================================================================ -// Korean ISP DNS Block Detection (TCP DNS Query) -// ============================================================================ - -const ISP_DNS_SERVERS = [ - { isp: 'KT', ip: '168.126.63.1' }, - { isp: 'LG U+', ip: '164.124.101.2' }, - { isp: 'SK Broadband', ip: '210.220.163.82' }, -]; - -/** - * DNS 와이어 포맷 패킷 생성 (TCP용: 2바이트 길이 프리픽스 포함) - */ -function buildDnsQuery(domain: string): Uint8Array { - // DNS 헤더 (12 bytes) - const header = new Uint8Array([ - 0xAB, 0xCD, // Transaction ID - 0x01, 0x00, // Flags: standard query, recursion desired - 0x00, 0x01, // Questions: 1 - 0x00, 0x00, // Answer RRs: 0 - 0x00, 0x00, // Authority RRs: 0 - 0x00, 0x00, // Additional RRs: 0 - ]); - - // Question section: encode domain labels - const labels = domain.split('.'); - const questionParts: number[] = []; - for (const label of labels) { - questionParts.push(label.length); - for (let i = 0; i < label.length; i++) { - questionParts.push(label.charCodeAt(i)); - } - } - questionParts.push(0); // Root label - questionParts.push(0x00, 0x01); // Type A - questionParts.push(0x00, 0x01); // Class IN - - const question = new Uint8Array(questionParts); - const dnsMessage = new Uint8Array(header.length + question.length); - dnsMessage.set(header, 0); - dnsMessage.set(question, header.length); - - // TCP DNS: 2-byte length prefix - const tcpMessage = new Uint8Array(2 + dnsMessage.length); - tcpMessage[0] = (dnsMessage.length >> 8) & 0xFF; - tcpMessage[1] = dnsMessage.length & 0xFF; - tcpMessage.set(dnsMessage, 2); - - return tcpMessage; -} - -/** - * TCP DNS 응답에서 A 레코드 IP 파싱 - */ -function parseDnsResponse(raw: Uint8Array): string[] { - const ips: string[] = []; - - // Skip 2-byte TCP length prefix - const offset = 2; - if (raw.length < offset + 12) return ips; - - // Read answer count from header - const answerCount = (raw[offset + 6] << 8) | raw[offset + 7]; - if (answerCount === 0) return ips; - - // Skip header (12 bytes) + question section - let pos = offset + 12; - - // Skip question section - while (pos < raw.length && raw[pos] !== 0) { - if ((raw[pos] & 0xC0) === 0xC0) { - pos += 2; - break; - } - pos += raw[pos] + 1; - } - if (pos < raw.length && raw[pos] === 0) pos++; // null terminator - pos += 4; // skip QTYPE + QCLASS - - // Parse answer records - for (let i = 0; i < answerCount && pos < raw.length; i++) { - // Skip name (may be pointer) - if ((raw[pos] & 0xC0) === 0xC0) { - pos += 2; - } else { - while (pos < raw.length && raw[pos] !== 0) { - pos += raw[pos] + 1; - } - pos++; // null terminator - } - - if (pos + 10 > raw.length) break; - - const rtype = (raw[pos] << 8) | raw[pos + 1]; - const rdlength = (raw[pos + 8] << 8) | raw[pos + 9]; - pos += 10; - - if (rtype === 1 && rdlength === 4 && pos + 4 <= raw.length) { - ips.push(`${raw[pos]}.${raw[pos + 1]}.${raw[pos + 2]}.${raw[pos + 3]}`); - } - pos += rdlength; - } - - return ips; -} - -/** - * TCP 소켓으로 DNS 서버에 질의 - */ -async function queryDnsTcp(serverIp: string, query: Uint8Array): Promise { - const socket = connect({ hostname: serverIp, port: 53 }); - - // Wait for connection to establish - await socket.opened; - - // readable/writable are on the socket object directly - const writer = socket.writable.getWriter(); - await writer.write(query); - writer.releaseLock(); - - const reader = socket.readable.getReader(); - const chunks: Uint8Array[] = []; - let totalLength = 0; - - try { - while (totalLength < 512) { // DNS response shouldn't exceed 512 bytes for simple queries - const { value, done } = await reader.read(); - if (done) break; - chunks.push(value as Uint8Array); - totalLength += (value as Uint8Array).length; - - // Check if we have enough data (2-byte length + message) - if (totalLength >= 2) { - const firstChunk = chunks[0]; - const expectedLen = (firstChunk[0] << 8) | firstChunk[1]; - if (totalLength >= expectedLen + 2) break; - } - } - } finally { - reader.releaseLock(); - await socket.close(); - } - - // Combine chunks - const raw = new Uint8Array(totalLength); - let offset = 0; - for (const chunk of chunks) { - raw.set(chunk, offset); - offset += chunk.length; - } - - return parseDnsResponse(raw); -} - -async function checkIspBlocking(domain: string): Promise { - const query = buildDnsQuery(domain); - - return Promise.all( - ISP_DNS_SERVERS.map(async (server) => { - const start = Date.now(); - try { - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('timeout')), CHECK_TIMEOUT_MS) - ); - - const resolvedIps = await Promise.race([ - queryDnsTcp(server.ip, query), - timeoutPromise, - ]); - - const blocked = resolvedIps.some((ip) => KNOWN_BLOCK_IPS.has(ip)); - - return { - isp: server.isp, - serverIp: server.ip, - resolvedIps, - blocked, - blockReason: blocked ? 'warning.or.kr 차단 페이지 IP 감지' : undefined, - responseTimeMs: Date.now() - start, - }; - } catch (error) { - return { - isp: server.isp, - serverIp: server.ip, - resolvedIps: [], - blocked: false, - responseTimeMs: Date.now() - start, - error: (error as Error).message, - }; - } - }) - ); -} - // ============================================================================ // HTTP/HTTPS Connection Test // ============================================================================ @@ -429,6 +222,7 @@ interface GlobalpingRawResult { city: string; network: string; asn: number; + tags: string[]; }; result: { status: string; @@ -516,6 +310,9 @@ async function pollGlobalpingResult(id: string): Promise return []; } +// SNI 차단 시 나타나는 에러 패턴 (한국 ISP DPI) +const SNI_BLOCK_PATTERNS = ['ECONNRESET', 'connection reset', 'socket hang up']; + async function checkKoreaProbes(domain: string): Promise { // Create ping and HTTP measurements in parallel const [pingId, httpId] = await Promise.all([ @@ -540,6 +337,7 @@ async function checkKoreaProbes(domain: string): Promise rawOutput.includes(p)); + if (isSniBlock) { + existing.blocked = true; + existing.blockReason = 'SNI 기반 차단 (ECONNRESET)'; + } + } + + if (!existing.error && r.result.status === 'failed' && !existing.blocked) { + existing.error = rawOutput; } probeMap.set(key, existing); } @@ -580,7 +394,6 @@ async function checkKoreaProbes(domain: string): Promise r.blocked); - if (blockedIsps.length > 0) { - const names = blockedIsps.map((r) => r.isp).join(', '); - summary.push(`ISP 차단 감지: ${names}에서 차단 페이지(warning.or.kr)로 리다이렉트됩니다. 방통위/KCSC 차단일 가능성이 높습니다.`); + // ISP SNI blocking detection (from Globalping eyeball probes) + const blockedProbes = koreaProbes.filter((p) => p.blocked); + if (blockedProbes.length > 0) { + const names = blockedProbes.map((p) => `${p.network}`).join(', '); + summary.push(`ISP 차단 감지: ${names}에서 SNI 기반 차단(ECONNRESET)이 확인되었습니다. 방통위/KCSC 차단일 가능성이 높습니다.`); } else { - const testedIsps = ispBlocking.filter((r) => !r.error); - if (testedIsps.length > 0) { - summary.push(`ISP 차단 없음: 테스트한 통신사(${testedIsps.map((r) => r.isp).join(', ')})에서 차단이 감지되지 않았습니다.`); - } - const failedIsps = ispBlocking.filter((r) => r.error); - if (failedIsps.length > 0) { - summary.push(`ISP DNS 테스트 실패: ${failedIsps.map((r) => `${r.isp}(${r.error})`).join(', ')}`); + const eyeballProbes = koreaProbes.filter((p) => p.isEyeball && p.http); + if (eyeballProbes.length > 0) { + summary.push(`ISP 차단 없음: ${eyeballProbes.map((p) => p.network).join(', ')} 프로브에서 정상 접속 확인`); } } @@ -677,7 +486,6 @@ function generateSummary( export interface NetworkDiagnosticOptions { ports?: number[]; - skipIspCheck?: boolean; skipHttpCheck?: boolean; skipTcpCheck?: boolean; skipKoreaProbes?: boolean; @@ -691,15 +499,14 @@ export async function runNetworkDiagnostic( const startTime = Date.now(); // Run all checks in parallel - const [dns, ispBlocking, http, tcp, koreaProbes] = await Promise.all([ + const [dns, 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, koreaProbes); + const summary = generateSummary(dns, http, tcp, koreaProbes); logger.info('네트워크 진단 완료', { domain, @@ -712,7 +519,6 @@ export async function runNetworkDiagnostic( domain, timestamp: new Date().toISOString(), dns, - ispBlocking, http, tcp, koreaProbes, diff --git a/src/types.ts b/src/types.ts index 758e28c..cc9976b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -464,13 +464,37 @@ export interface TcpCheckResult { error?: string; } +export interface GlobalpingProbeResult { + city: string; + network: string; + asn: number; + isEyeball?: boolean; + blocked?: boolean; + blockReason?: string; + ping?: { + min: number; + avg: number; + max: number; + loss: number; + }; + http?: { + statusCode: number; + totalMs: number; + dnsMs: number; + tcpMs: number; + tlsMs: number; + firstByteMs: number; + }; + error?: string; +} + export interface NetworkDiagnosticReport { domain: string; timestamp: string; dns: DnsResolverResult[]; - ispBlocking: IspDnsBlockResult[]; http: HttpCheckResult[]; tcp: TcpCheckResult[]; + koreaProbes: GlobalpingProbeResult[]; summary: string[]; }