Add network diagnostic tool for domain connectivity troubleshooting
DNS lookup (DoH via Cloudflare/Google), Korean ISP block detection (KT/LG/SK via TCP DNS), HTTP/HTTPS check, and TCP port test — all run in parallel with per-check timeouts. Integrated as diagnose_domain tool in troubleshoot-agent with updated patterns for network keywords. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,8 @@
|
|||||||
import type { ToolDefinition, TroubleshootSession } from '../types';
|
import type { ToolDefinition, TroubleshootSession } from '../types';
|
||||||
import type { AgentToolContext } from './base-agent';
|
import type { AgentToolContext } from './base-agent';
|
||||||
import { BaseAgent } from './base-agent';
|
import { BaseAgent } from './base-agent';
|
||||||
|
import { DiagramAgent } from './diagram-agent';
|
||||||
|
import { runNetworkDiagnostic } from '../services/network-diagnostic';
|
||||||
import { SessionManager } from '../utils/session-manager';
|
import { SessionManager } from '../utils/session-manager';
|
||||||
import { getSessionConfig } from '../constants/agent-config';
|
import { getSessionConfig } from '../constants/agent-config';
|
||||||
import { createLogger } from '../utils/logger';
|
import { createLogger } from '../utils/logger';
|
||||||
@@ -51,19 +53,20 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
|
|||||||
|
|
||||||
protected getSystemPrompt(session: TroubleshootSession): string {
|
protected getSystemPrompt(session: TroubleshootSession): string {
|
||||||
return `당신은 호스팅/인프라 서비스의 기술 문제 해결 전문가입니다.
|
return `당신은 호스팅/인프라 서비스의 기술 문제 해결 전문가입니다.
|
||||||
고객의 문제를 체계적으로 진단하고 해결책을 제시합니다.
|
고객의 문제를 신속하게 진단하고 해결책을 제시합니다.
|
||||||
|
|
||||||
|
## 핵심 원칙: 도구 먼저, 질문은 나중에
|
||||||
|
고객이 문제를 말하면 **먼저 도구를 호출하여 상태를 확인**하세요. 질문부터 하지 마세요.
|
||||||
|
- "서버 접속이 안 돼요" → 바로 check_server_status 호출
|
||||||
|
- "도메인이 안 열려요" → 바로 check_domain_status 호출
|
||||||
|
- "VPN 연결이 안 돼요" → 바로 check_service_status 호출
|
||||||
|
도구 결과를 확인한 후, 추가 정보가 필요하면 그때 질문하세요.
|
||||||
|
|
||||||
## 문제 해결 프로세스
|
## 문제 해결 프로세스
|
||||||
1. **상태 파악**: 현재 상태 확인 (서버 상태, 도메인 상태, 서비스 상태)
|
1. **즉시 상태 조회**: 고객 메시지에서 키워드를 파악하여 관련 도구 즉시 호출
|
||||||
2. **원인 분석**: 수집된 정보를 기반으로 근본 원인 분석
|
2. **결과 기반 진단**: 조회 결과를 분석하여 원인 파악
|
||||||
3. **해결책 제시**: 단계별 해결 방법 안내
|
3. **해결책 제시**: 단계별 해결 방법 안내
|
||||||
4. **예측**: 재발 방지 및 모니터링 권고
|
4. **추가 확인**: 필요 시 추가 질문 또는 에스컬레이션
|
||||||
|
|
||||||
## 정보 수집 항목
|
|
||||||
- **카테고리**: 서버, 도메인, DDoS, VPN, 네트워크, 기타
|
|
||||||
- **증상**: 구체적 증상 (접속 불가, 느림, 오류 메시지 등)
|
|
||||||
- **환경**: OS, 브라우저, 리전, 사용 중인 서비스
|
|
||||||
- **에러 메시지**: 정확한 에러 메시지 또는 코드
|
|
||||||
|
|
||||||
## 현재 세션 상태: ${session.status}
|
## 현재 세션 상태: ${session.status}
|
||||||
## 에스컬레이션 카운트: ${session.escalation_count || 0}
|
## 에스컬레이션 카운트: ${session.escalation_count || 0}
|
||||||
@@ -71,15 +74,23 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
|
|||||||
## 대화 원칙
|
## 대화 원칙
|
||||||
- 항상 한국어로 응답하세요.
|
- 항상 한국어로 응답하세요.
|
||||||
- 전문적이지만 이해하기 쉽게 설명하세요.
|
- 전문적이지만 이해하기 쉽게 설명하세요.
|
||||||
|
- 진단 결과를 단정 짓지 말고 추정형으로 이야기하세요.
|
||||||
|
좋은 예: "DB 서버가 중지 상태로 보이는데, 이 부분이 원인일 가능성이 있습니다"
|
||||||
|
나쁜 예: "DB 서버가 중지 상태입니다. 이것이 원인입니다"
|
||||||
|
조회 결과는 시스템 기록이므로 실제 상황과 다를 수 있음을 인지하세요.
|
||||||
- 문제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
|
- 문제와 무관한 메시지가 오면 __PASSTHROUGH__를 응답하세요.
|
||||||
- 상담이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
|
- 상담이 완료되면 __SESSION_END__를 응답 끝에 추가하세요.
|
||||||
- 3라운드 이내에 해결이 어려운 경우 __ESCALATE__를 응답에 포함하세요.
|
- 3라운드 이내에 해결이 어려운 경우 __ESCALATE__를 응답에 포함하세요.
|
||||||
이 경우 고객에게 "전문 엔지니어에게 전달하겠습니다"라고 안내하세요.
|
이 경우 고객에게 "전문 엔지니어에게 전달하겠습니다"라고 안내하세요.
|
||||||
|
|
||||||
## 도구 사용
|
## 도구 사용
|
||||||
- check_server_status: 서버 상태 확인
|
- check_server_status: 서버 상태 확인 (server_id 없으면 전체 목록)
|
||||||
- check_domain_status: 도메인 상태 확인
|
- check_domain_status: 도메인 상태 확인 (domain 없으면 전체 목록)
|
||||||
- check_service_status: DDoS/VPN 서비스 상태 확인`;
|
- check_service_status: DDoS/VPN 서비스 상태 확인
|
||||||
|
- diagnose_domain: 도메인 네트워크 진단 (DNS 조회, ISP 차단 감지, HTTP/TCP 연결 테스트)
|
||||||
|
- "접속이 안 돼요", "사이트가 안 열려요", "차단된 것 같아요" 등의 문의 시 즉시 호출
|
||||||
|
- DNS, HTTP, ISP 차단 여부를 한번에 확인하여 원인을 빠르게 파악
|
||||||
|
- generate_diagram: 문제 상황을 Mermaid 다이어그램으로 시각화 (충분한 정보 수집 후)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected getTools(): ToolDefinition[] {
|
protected getTools(): ToolDefinition[] {
|
||||||
@@ -137,6 +148,50 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'diagnose_domain',
|
||||||
|
description: '도메인의 네트워크 상태를 종합 진단합니다. DNS 조회, 한국 ISP(KT/LG/SK) 차단 감지, HTTP/HTTPS 연결, TCP 포트 연결을 테스트합니다.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
domain: {
|
||||||
|
type: 'string',
|
||||||
|
description: '진단할 도메인 (예: example.com)',
|
||||||
|
},
|
||||||
|
ports: {
|
||||||
|
type: 'array',
|
||||||
|
items: { type: 'number' },
|
||||||
|
description: '추가로 테스트할 TCP 포트 목록 (기본: 80, 443)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['domain'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'generate_diagram',
|
||||||
|
description: '문제 상황을 Mermaid 다이어그램으로 시각화하여 고객에게 전송합니다. 충분한 정보를 수집한 후에 호출하세요.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
description: {
|
||||||
|
type: 'string',
|
||||||
|
description: '다이어그램에 표현할 문제 상황 설명',
|
||||||
|
},
|
||||||
|
diagram_type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['flow', 'sequence', 'architecture'],
|
||||||
|
description: '다이어그램 유형 (선택, 기본값: flow)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['description'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +215,17 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
|
|||||||
args.service_id as number | undefined,
|
args.service_id as number | undefined,
|
||||||
db
|
db
|
||||||
);
|
);
|
||||||
|
case 'diagnose_domain':
|
||||||
|
return this.handleDiagnoseDomain(
|
||||||
|
args.domain as string,
|
||||||
|
args.ports as number[] | undefined
|
||||||
|
);
|
||||||
|
case 'generate_diagram':
|
||||||
|
return this.handleGenerateDiagram(
|
||||||
|
args.description as string,
|
||||||
|
args.diagram_type as string | undefined,
|
||||||
|
context
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
|
return JSON.stringify({ error: `알 수 없는 도구: ${name}` });
|
||||||
}
|
}
|
||||||
@@ -292,4 +358,47 @@ export class TroubleshootAgent extends BaseAgent<TroubleshootSession> {
|
|||||||
return JSON.stringify({ error: '서비스 상태 조회에 실패했습니다.' });
|
return JSON.stringify({ error: '서비스 상태 조회에 실패했습니다.' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async handleDiagnoseDomain(
|
||||||
|
domain: string,
|
||||||
|
ports?: number[]
|
||||||
|
): Promise<string> {
|
||||||
|
// Validate domain format
|
||||||
|
const domainRegex = /^[a-zA-Z0-9][a-zA-Z0-9.-]{0,251}[a-zA-Z0-9]?\.[a-zA-Z]{2,}$/;
|
||||||
|
if (!domainRegex.test(domain)) {
|
||||||
|
return JSON.stringify({ error: '올바른 도메인 형식이 아닙니다. 예: example.com' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await runNetworkDiagnostic(domain, { ports });
|
||||||
|
return JSON.stringify(report);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('네트워크 진단 오류', error as Error, { domain });
|
||||||
|
return JSON.stringify({ error: '네트워크 진단 중 오류가 발생했습니다.' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGenerateDiagram(
|
||||||
|
description: string,
|
||||||
|
diagramType: string | undefined,
|
||||||
|
context: AgentToolContext
|
||||||
|
): Promise<string> {
|
||||||
|
if (!context.chatId) {
|
||||||
|
return JSON.stringify({ error: '채팅 정보가 없어 다이어그램을 전송할 수 없습니다.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const diagramAgent = new DiagramAgent();
|
||||||
|
const result = await diagramAgent.generateAndSend(
|
||||||
|
{
|
||||||
|
env: context.env,
|
||||||
|
chatId: context.chatId,
|
||||||
|
telegramUserId: context.userId,
|
||||||
|
messageId: context.messageId,
|
||||||
|
},
|
||||||
|
description,
|
||||||
|
diagramType
|
||||||
|
);
|
||||||
|
|
||||||
|
return JSON.stringify(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
529
src/services/network-diagnostic.ts
Normal file
529
src/services/network-diagnostic.ts
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
/**
|
||||||
|
* Network Diagnostic Service
|
||||||
|
*
|
||||||
|
* 도메인 접속 문제 진단을 위한 네트워크 체크:
|
||||||
|
* - DoH DNS 조회 (Cloudflare, Google)
|
||||||
|
* - 한국 ISP DNS 차단 감지 (KT, LG, SK) — TCP DNS 쿼리
|
||||||
|
* - HTTP/HTTPS 연결 테스트
|
||||||
|
* - TCP 포트 연결 테스트
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { NetworkDiagnosticReport, DnsResolverResult, IspDnsBlockResult, HttpCheckResult, TcpCheckResult } 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)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
interface DohAnswer {
|
||||||
|
name: string;
|
||||||
|
type: number;
|
||||||
|
TTL: number;
|
||||||
|
data: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DohResponse {
|
||||||
|
Status: number;
|
||||||
|
Answer?: DohAnswer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOH_RESOLVERS = [
|
||||||
|
{ name: 'Cloudflare', url: 'https://1.1.1.1/dns-query', ip: '1.1.1.1' },
|
||||||
|
{ name: 'Google', url: 'https://8.8.8.8/resolve', ip: '8.8.8.8' },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function resolveDoh(domain: string): Promise<DnsResolverResult[]> {
|
||||||
|
return Promise.all(
|
||||||
|
DOH_RESOLVERS.map(async (resolver) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
const isCloudflare = resolver.name === 'Cloudflare';
|
||||||
|
const url = isCloudflare
|
||||||
|
? `${resolver.url}?name=${encodeURIComponent(domain)}&type=A`
|
||||||
|
: `${resolver.url}?name=${encodeURIComponent(domain)}&type=A`;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = isCloudflare
|
||||||
|
? { Accept: 'application/dns-json' }
|
||||||
|
: {};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
headers,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return {
|
||||||
|
resolver: resolver.name,
|
||||||
|
ip: resolver.ip,
|
||||||
|
records: [],
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
error: `HTTP ${response.status}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as DohResponse;
|
||||||
|
const records = (data.Answer || [])
|
||||||
|
.filter((a) => a.type === 1) // A records
|
||||||
|
.map((a) => ({
|
||||||
|
type: 'A',
|
||||||
|
value: a.data,
|
||||||
|
ttl: a.TTL,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
resolver: resolver.name,
|
||||||
|
ip: resolver.ip,
|
||||||
|
records,
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
resolver: resolver.name,
|
||||||
|
ip: resolver.ip,
|
||||||
|
records: [],
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// 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 = (globalThis as unknown as {
|
||||||
|
connect: (opts: { hostname: string; port: number }) => {
|
||||||
|
opened: Promise<{ readable: ReadableStream; writable: WritableStream }>;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
}).connect({ hostname: serverIp, port: 53 });
|
||||||
|
|
||||||
|
const { readable, writable } = await socket.opened;
|
||||||
|
|
||||||
|
const writer = writable.getWriter();
|
||||||
|
await writer.write(query);
|
||||||
|
writer.releaseLock();
|
||||||
|
|
||||||
|
const reader = 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();
|
||||||
|
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
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
async function checkHttp(domain: string): Promise<HttpCheckResult[]> {
|
||||||
|
const urls = [`https://${domain}`, `http://${domain}`];
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
urls.map(async (url) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), CHECK_TIMEOUT_MS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'HEAD',
|
||||||
|
redirect: 'manual',
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const redirectUrl = response.headers.get('location') || undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
statusCode: response.status,
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
redirectUrl,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// TCP Port Test (connect API — ping substitute)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
const DEFAULT_PORTS = [80, 443];
|
||||||
|
|
||||||
|
async function checkTcpPorts(domain: string, ports?: number[]): Promise<TcpCheckResult[]> {
|
||||||
|
// Resolve domain first to get IP for connect()
|
||||||
|
const dohResult = await resolveDoh(domain);
|
||||||
|
const ip = dohResult[0]?.records[0]?.value;
|
||||||
|
|
||||||
|
if (!ip) {
|
||||||
|
return (ports || DEFAULT_PORTS).map((port) => ({
|
||||||
|
host: domain,
|
||||||
|
port,
|
||||||
|
connected: false,
|
||||||
|
responseTimeMs: 0,
|
||||||
|
error: 'DNS 조회 실패로 TCP 테스트를 수행할 수 없습니다',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.all(
|
||||||
|
(ports || DEFAULT_PORTS).map(async (port) => {
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
const timeoutPromise = new Promise<never>((_, reject) =>
|
||||||
|
setTimeout(() => reject(new Error('timeout')), CHECK_TIMEOUT_MS)
|
||||||
|
);
|
||||||
|
|
||||||
|
const socket = (globalThis as unknown as {
|
||||||
|
connect: (opts: { hostname: string; port: number }) => {
|
||||||
|
opened: Promise<unknown>;
|
||||||
|
close: () => void;
|
||||||
|
};
|
||||||
|
}).connect({ hostname: ip, port });
|
||||||
|
|
||||||
|
await Promise.race([socket.opened, timeoutPromise]);
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
return {
|
||||||
|
host: domain,
|
||||||
|
port,
|
||||||
|
connected: true,
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
host: domain,
|
||||||
|
port,
|
||||||
|
connected: false,
|
||||||
|
responseTimeMs: Date.now() - start,
|
||||||
|
error: (error as Error).message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Summary Generation
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function generateSummary(
|
||||||
|
dns: DnsResolverResult[],
|
||||||
|
ispBlocking: IspDnsBlockResult[],
|
||||||
|
http: HttpCheckResult[],
|
||||||
|
tcp: TcpCheckResult[]
|
||||||
|
): string[] {
|
||||||
|
const summary: string[] = [];
|
||||||
|
|
||||||
|
// DNS resolution check
|
||||||
|
const dnsOk = dns.some((r) => r.records.length > 0);
|
||||||
|
const dnsIps = dns.flatMap((r) => r.records.map((rec) => rec.value));
|
||||||
|
if (!dnsOk) {
|
||||||
|
summary.push('DNS 조회 실패: 도메인이 어떤 DNS 서버에서도 조회되지 않습니다. 도메인 등록 또는 DNS 설정을 확인하세요.');
|
||||||
|
} else {
|
||||||
|
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 차단일 가능성이 높습니다.`);
|
||||||
|
} 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(', ')}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HTTP check
|
||||||
|
const httpsResult = http.find((r) => r.url.startsWith('https://'));
|
||||||
|
const httpResult = http.find((r) => r.url.startsWith('http://'));
|
||||||
|
|
||||||
|
if (httpsResult?.statusCode) {
|
||||||
|
if (httpsResult.statusCode >= 200 && httpsResult.statusCode < 400) {
|
||||||
|
summary.push(`HTTPS 정상: ${httpsResult.statusCode} (${httpsResult.responseTimeMs}ms)`);
|
||||||
|
} else if (httpsResult.statusCode >= 500) {
|
||||||
|
summary.push(`HTTPS 서버 오류: ${httpsResult.statusCode} (서버 내부 문제)`);
|
||||||
|
} else {
|
||||||
|
summary.push(`HTTPS 응답: ${httpsResult.statusCode} (${httpsResult.responseTimeMs}ms)`);
|
||||||
|
}
|
||||||
|
} else if (httpsResult?.error) {
|
||||||
|
summary.push(`HTTPS 연결 실패: ${httpsResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (httpResult?.statusCode) {
|
||||||
|
if (httpResult.statusCode >= 300 && httpResult.statusCode < 400 && httpResult.redirectUrl?.startsWith('https')) {
|
||||||
|
summary.push('HTTP→HTTPS 리다이렉트 설정됨');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TCP check
|
||||||
|
const tcpFailed = tcp.filter((r) => !r.connected);
|
||||||
|
if (tcpFailed.length > 0) {
|
||||||
|
summary.push(`TCP 연결 실패 포트: ${tcpFailed.map((r) => `${r.port}(${r.error || '연결 불가'})`).join(', ')}`);
|
||||||
|
}
|
||||||
|
const tcpOk = tcp.filter((r) => r.connected);
|
||||||
|
if (tcpOk.length > 0) {
|
||||||
|
summary.push(`TCP 연결 성공: ${tcpOk.map((r) => `포트 ${r.port} (${r.responseTimeMs}ms)`).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Main Entry Point
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export interface NetworkDiagnosticOptions {
|
||||||
|
ports?: number[];
|
||||||
|
skipIspCheck?: boolean;
|
||||||
|
skipHttpCheck?: boolean;
|
||||||
|
skipTcpCheck?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runNetworkDiagnostic(
|
||||||
|
domain: string,
|
||||||
|
options?: NetworkDiagnosticOptions
|
||||||
|
): Promise<NetworkDiagnosticReport> {
|
||||||
|
logger.info('네트워크 진단 시작', { domain });
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
// Run all checks in parallel
|
||||||
|
const [dns, ispBlocking, http, tcp] = 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),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const summary = generateSummary(dns, ispBlocking, http, tcp);
|
||||||
|
|
||||||
|
logger.info('네트워크 진단 완료', {
|
||||||
|
domain,
|
||||||
|
durationMs: Date.now() - startTime,
|
||||||
|
summaryCount: summary.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
domain,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
dns,
|
||||||
|
ispBlocking,
|
||||||
|
http,
|
||||||
|
tcp,
|
||||||
|
summary,
|
||||||
|
};
|
||||||
|
}
|
||||||
78
src/types.ts
78
src/types.ts
@@ -16,11 +16,14 @@ export interface Env {
|
|||||||
DEPOSIT_BANK_HOLDER?: string;
|
DEPOSIT_BANK_HOLDER?: string;
|
||||||
// API URLs
|
// API URLs
|
||||||
OPENAI_API_BASE?: string;
|
OPENAI_API_BASE?: string;
|
||||||
D2_RENDER_URL?: string;
|
|
||||||
NAMECHEAP_API_URL?: string;
|
NAMECHEAP_API_URL?: string;
|
||||||
WHOIS_API_URL?: string;
|
WHOIS_API_URL?: string;
|
||||||
CLOUD_ORCHESTRATOR_URL?: string;
|
CLOUD_ORCHESTRATOR_URL?: string;
|
||||||
CLOUD_ORCHESTRATOR?: Fetcher;
|
CLOUD_ORCHESTRATOR?: Fetcher;
|
||||||
|
// Kroki
|
||||||
|
KROKI_URL?: string;
|
||||||
|
// Queues
|
||||||
|
WORK_QUEUE?: Queue;
|
||||||
// KV Namespaces
|
// KV Namespaces
|
||||||
RATE_LIMIT_KV: KVNamespace;
|
RATE_LIMIT_KV: KVNamespace;
|
||||||
SESSION_KV: KVNamespace;
|
SESSION_KV: KVNamespace;
|
||||||
@@ -401,10 +404,6 @@ export interface CheckServiceArgs {
|
|||||||
service_id?: number;
|
service_id?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RenderD2Args {
|
|
||||||
source: string;
|
|
||||||
format?: 'svg' | 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface AdminArgs {
|
export interface AdminArgs {
|
||||||
action: 'block_user' | 'unblock_user' | 'set_role' | 'broadcast' | 'confirm_deposit' | 'reject_deposit' | 'list_pending';
|
action: 'block_user' | 'unblock_user' | 'set_role' | 'broadcast' | 'confirm_deposit' | 'reject_deposit' | 'list_pending';
|
||||||
@@ -421,6 +420,60 @@ export interface ApproveActionArgs {
|
|||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Network Diagnostic Types
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export interface DnsRecord {
|
||||||
|
type: string;
|
||||||
|
value: string;
|
||||||
|
ttl?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DnsResolverResult {
|
||||||
|
resolver: string;
|
||||||
|
ip: string;
|
||||||
|
records: DnsRecord[];
|
||||||
|
responseTimeMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IspDnsBlockResult {
|
||||||
|
isp: string;
|
||||||
|
serverIp: string;
|
||||||
|
resolvedIps: string[];
|
||||||
|
blocked: boolean;
|
||||||
|
blockReason?: string;
|
||||||
|
responseTimeMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HttpCheckResult {
|
||||||
|
url: string;
|
||||||
|
statusCode?: number;
|
||||||
|
responseTimeMs: number;
|
||||||
|
redirectUrl?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TcpCheckResult {
|
||||||
|
host: string;
|
||||||
|
port: number;
|
||||||
|
connected: boolean;
|
||||||
|
responseTimeMs: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NetworkDiagnosticReport {
|
||||||
|
domain: string;
|
||||||
|
timestamp: string;
|
||||||
|
dns: DnsResolverResult[];
|
||||||
|
ispBlocking: IspDnsBlockResult[];
|
||||||
|
http: HttpCheckResult[];
|
||||||
|
tcp: TcpCheckResult[];
|
||||||
|
summary: string[];
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Inline Keyboard Data
|
// Inline Keyboard Data
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -448,21 +501,6 @@ export type KeyboardCallbackData =
|
|||||||
| ActionApprovalKeyboardData
|
| ActionApprovalKeyboardData
|
||||||
| EscalationKeyboardData;
|
| EscalationKeyboardData;
|
||||||
|
|
||||||
// ============================================
|
|
||||||
// D2 Rendering
|
|
||||||
// ============================================
|
|
||||||
|
|
||||||
export interface D2RenderRequest {
|
|
||||||
source: string;
|
|
||||||
format: 'svg' | 'png';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface D2RenderResponse {
|
|
||||||
success: boolean;
|
|
||||||
image?: ArrayBuffer;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Request Context
|
// Request Context
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const BILLING_PATTERNS = /입금|충전|잔액|계좌|예치금|송금|
|
|||||||
|
|
||||||
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot|server/i;
|
export const SERVER_PATTERNS = /서버|VPS|클라우드|호스팅|인스턴스|linode|vultr|\d+번\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|#\d+\s*(?:시작|중지|정지|재시작|리셋|리부팅|삭제|해지)|reboot|server/i;
|
||||||
|
|
||||||
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨|error|problem|down|slow|timeout|crash|접속.*안|연결.*안|불안정/i;
|
export const TROUBLESHOOT_PATTERNS = /문제|에러|오류|안[돼되]|느려|트러블|장애|버그|실패|안\s*됨|error|problem|down|slow|timeout|crash|접속.*안|연결.*안|불안정|사이트.*안.*열|안.*열려|차단|block|ISP|통신사|포트.*안|포트.*막|ping|핑/i;
|
||||||
|
|
||||||
export const ONBOARDING_PATTERNS = /신규|가입|서비스\s*소개|뭐하는|어떤\s*서비스|요금|플랜|가격|plan|pricing|시작하려|처음|어떻게\s*이용|회원가입|시작/i;
|
export const ONBOARDING_PATTERNS = /신규|가입|서비스\s*소개|뭐하는|어떤\s*서비스|요금|플랜|가격|plan|pricing|시작하려|처음|어떻게\s*이용|회원가입|시작/i;
|
||||||
|
|
||||||
@@ -24,6 +24,8 @@ export const SECURITY_PATTERNS = /ddos|DDoS|디도스|vpn|VPN|보안|방어|공
|
|||||||
|
|
||||||
export const ASSET_PATTERNS = /자산|현황|대시보드|내\s*서버|내\s*도메인|보유|목록|리스트|내\s*서비스|내\s*계정|asset|my\s*server|my\s*domain/i;
|
export const ASSET_PATTERNS = /자산|현황|대시보드|내\s*서버|내\s*도메인|보유|목록|리스트|내\s*서비스|내\s*계정|asset|my\s*server|my\s*domain/i;
|
||||||
|
|
||||||
|
export const DIAGRAM_PATTERNS = /다이어그램|diagram|구조.*그려|그림.*그려|시각화|visuali[sz]e|흐름도|flow.*chart|아키텍처.*보여|구성도/i;
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Pattern Matching Functions
|
// Pattern Matching Functions
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user