Replace TCP DNS ISP block detection with Globalping SNI block detection

Korean ISPs use SNI-based DPI (not DNS hijacking) to block sites.
TCP DNS queries to ISP servers from Cloudflare Workers returned real IPs
even for blocked domains. Now uses Globalping eyeball probes (KT, LG U+)
to detect ECONNRESET on HTTPS — the signature of SNI-based blocking.

Verified: pornhub.com correctly detected as blocked by Korea Telecom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
kappa
2026-02-12 08:45:07 +09:00
parent 8120c42951
commit 8eb89da6c0
3 changed files with 72 additions and 241 deletions

View File

@@ -87,9 +87,10 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
- 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 다이어그램으로 시각화 (충분한 정보 수집 후)`;
}

View File

@@ -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<DnsResolverResult[]> {
);
}
// ============================================================================
// 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<string[]> {
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<IspDnsBlockResult[]> {
const query = buildDnsQuery(domain);
return Promise.all(
ISP_DNS_SERVERS.map(async (server) => {
const start = Date.now();
try {
const timeoutPromise = new Promise<never>((_, 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<GlobalpingRawResult[]>
return [];
}
// SNI 차단 시 나타나는 에러 패턴 (한국 ISP DPI)
const SNI_BLOCK_PATTERNS = ['ECONNRESET', 'connection reset', 'socket hang up'];
async function checkKoreaProbes(domain: string): Promise<GlobalpingProbeResult[]> {
// Create ping and HTTP measurements in parallel
const [pingId, httpId] = await Promise.all([
@@ -540,6 +337,7 @@ async function checkKoreaProbes(domain: string): Promise<GlobalpingProbeResult[]
city: r.probe.city,
network: r.probe.network,
asn: r.probe.asn,
isEyeball: (r.probe.tags || []).includes('eyeball-network'),
ping: r.result.stats ? {
min: r.result.stats.min,
avg: r.result.stats.avg,
@@ -552,21 +350,37 @@ async function checkKoreaProbes(domain: string): Promise<GlobalpingProbeResult[]
for (const r of httpResults) {
const key = `${r.probe.city}:${r.probe.network}`;
const isEyeball = (r.probe.tags || []).includes('eyeball-network');
const existing = probeMap.get(key) || {
city: r.probe.city,
network: r.probe.network,
asn: r.probe.asn,
isEyeball,
};
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;
if (r.result.timings) {
existing.http = {
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,
};
}
// Detect SNI blocking: eyeball probe HTTP failed with ECONNRESET
const rawOutput = r.result.rawOutput || '';
if (r.result.status === 'failed' && isEyeball) {
const isSniBlock = SNI_BLOCK_PATTERNS.some((p) => 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<GlobalpingProbeResult[]
function generateSummary(
dns: DnsResolverResult[],
ispBlocking: IspDnsBlockResult[],
http: HttpCheckResult[],
tcp: TcpCheckResult[],
koreaProbes: GlobalpingProbeResult[]
@@ -596,19 +409,15 @@ function generateSummary(
summary.push(`DNS 정상: ${[...new Set(dnsIps)].join(', ')}로 확인됨`);
}
// ISP blocking check
const blockedIsps = ispBlocking.filter((r) => 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,

View File

@@ -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[];
}