From 05a5dcedc04305e9bbb477d16901ac7a6f0380c8 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 5 Jul 2026 21:13:19 +0900 Subject: [PATCH] installer-pf: guide manual forwarding + detect CGNAT on failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 포트포워딩 도구가 실패/확인불가일 때 다음 조치를 바로 안내하도록 보강. - 이 PC LAN IPv4 자동 감지 → "외부 TCP {port} → {localIp}:{port} 수동 포워딩 + Windows 방화벽 인바운드 허용" 안내. - 외부 IP 가 CGNAT(100.64/10) 대역이면 경고(공유기 포워딩만으론 불가). - UPnP 가 ECONNREFUSED/타임아웃이면 "라우터 UPnP 거부/미지원" 로그 힌트. 실제 사용자 로그(라우터 UPnP ECONNREFUSED, 공인 IP, ifconfig false)에 기반한 개선. v0.3.16. Co-Authored-By: Claude Opus 4 --- installer-pf/renderer.js | 15 +++++++++++ locales/installer-pf/ko-kr.json | 6 +++++ package.json | 2 +- src/installer-pf/main.ts | 46 +++++++++++++++++++++++++++++---- 4 files changed, 63 insertions(+), 6 deletions(-) diff --git a/installer-pf/renderer.js b/installer-pf/renderer.js index 1020cda..5ce4838 100644 --- a/installer-pf/renderer.js +++ b/installer-pf/renderer.js @@ -108,11 +108,26 @@ function renderMain() { cls = 'warn'; badge = tt('verdict.unknown') msg = tt('main.unknownMsg') } + + // 실패/확인불가일 때 다음 조치 안내: CGNAT 경고 + 수동 포워딩 방법. + var extra = '' + if (outcome.reachable !== true) { + if (outcome.cgnat) { + extra += '

' + + escapeHtml(tt('main.cgnatWarn', { ip: outcome.externalIp || '?' })) + '

' + } + var manual = outcome.localIp + ? tt('main.manualForward', { port: outcome.port, localIp: outcome.localIp }) + : tt('main.manualForwardNoIp', { port: outcome.port }) + extra += '

' + escapeHtml(manual) + '

' + } + resultEl.innerHTML = '
' + '
' + escapeHtml(badge) + ' ' + ' ' + escapeHtml(addr) + '
' + '

' + escapeHtml(msg) + '

' + + extra + '

' + escapeHtml(tt('main.detailLabel', { detail: outcome.detail || '' })) + '

' + '
' } diff --git a/locales/installer-pf/ko-kr.json b/locales/installer-pf/ko-kr.json index 011f6a2..544ed0a 100644 --- a/locales/installer-pf/ko-kr.json +++ b/locales/installer-pf/ko-kr.json @@ -25,12 +25,18 @@ "alreadyOpen": "(이미 열려 있던 상태입니다)", "failMsg": "외부에서 포트에 닿지 않습니다. 라우터 UPnP 설정, 이중 NAT(CGNAT), Windows 방화벽, 또는 포워딩 대상 IP 를 확인하세요.", "unknownMsg": "외부 도달 여부를 확정하지 못했습니다(외부 점검 서비스 응답 없음). 포트 매핑은 등록되었을 수 있습니다. 잠시 후 다시 시도하거나, 실제 접속으로 확인하세요.", + "manualForward": "자동(UPnP) 개방이 안 되면 라우터 관리페이지에서 수동 포워딩: 외부 TCP {{port}} → 이 PC({{localIp}}) : {{port}}. 그리고 Windows 방화벽에서 TCP {{port}} 인바운드를 허용하세요.", + "manualForwardNoIp": "이 PC 의 LAN IP 를 확인하지 못했습니다. 라우터에서 이 PC 로 외부 TCP {{port}} 포워딩과 Windows 방화벽 인바운드 허용을 설정하세요.", + "cgnatWarn": "주의: 외부 IP({{ip}})가 CGNAT(이중 NAT) 대역입니다. 이 경우 공유기 포트포워딩만으로는 외부 접속이 불가능하며, ISP 에 공인 IP 를 요청하거나 별도 터널링이 필요합니다.", "detailLabel": "점검 상세: {{detail}}", "error": "오류: {{message}}", "closed": "포트 {{port}} 매핑을 제거했습니다." }, "log": { "start": "포트포워딩 시작: TCP {{port}}", + "localIp": "이 PC LAN IP: {{ip}}", + "cgnatDetected": "CGNAT(이중 NAT) 감지: 외부 IP {{ip}} 는 100.64~100.127 대역. 공유기 포워딩만으론 외부 접속 불가.", + "upnpUnavailable": "라우터가 UPnP 제어 연결을 거부/미지원(ECONNREFUSED/타임아웃). 라우터에서 UPnP 를 켜거나 수동 포워딩이 필요합니다.", "cleanup": "이전 UPnP 매핑 정리 중…", "externalIpHttp": "외부 IP(HTTP): {{ip}}", "externalIpHttpFail": "외부 IP HTTP 조회 실패 → UPnP 게이트웨이로 폴백", diff --git a/package.json b/package.json index 1b060ec..ad8e58a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "minecraft-music-quiz-installer", - "version": "0.3.15", + "version": "0.3.16", "description": "마인크래프트 음악퀴즈 간편설치기 + 관리 사이트", "main": "dist/installer/main.js", "scripts": { diff --git a/src/installer-pf/main.ts b/src/installer-pf/main.ts index 57b6030..f459d1d 100644 --- a/src/installer-pf/main.ts +++ b/src/installer-pf/main.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, ipcMain } from 'electron' import http from 'node:http' import https from 'node:https' import net from 'node:net' +import os from 'node:os' import path from 'node:path' import { URL } from 'node:url' import natUpnp from 'nat-upnp' @@ -39,6 +40,29 @@ function sendLog(line: string): void { const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) +/** 이 PC 의 LAN IPv4(사설 대역 우선). 수동 포워딩 대상 IP 안내에 쓴다. */ +function detectLocalIpv4(): string { + const ifaces = os.networkInterfaces() + const candidates: string[] = [] + for (const name of Object.keys(ifaces)) { + for (const info of ifaces[name] || []) { + if (info.family === 'IPv4' && !info.internal && !info.address.startsWith('169.254.')) { + candidates.push(info.address) + } + } + } + const priv = candidates.find((a) => /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(a)) + return priv || candidates[0] || '' +} + +/** CGNAT(이중 NAT) 대역 100.64.0.0/10 인지. 이 경우 공유기 포워딩만으론 외부 접속 불가. */ +function isCgnat(ip: string): boolean { + const m = ip.match(/^100\.(\d+)\./) + if (!m) return false + const octet = Number(m[1]) + return octet >= 64 && octet <= 127 +} + function fetchBuffer(url: string): Promise { return new Promise((resolve, reject) => { const target = new URL(url) @@ -280,6 +304,8 @@ async function probePortFromOutside( interface PortForwardOutcome { externalIp: string + localIp: string + cgnat: boolean port: number reachable: boolean | null preForwarded: boolean @@ -292,6 +318,9 @@ ipcMain.handle('pf:open', async (_event, portInput: number): Promise 0 && portInput < 65536 ? Math.floor(portInput) : 25565 sendLog(t('log.start', { port })) + const localIp = detectLocalIpv4() + if (localIp) sendLog(t('log.localIp', { ip: localIp })) + // 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별. sendLog(t('log.cleanup')) await removeUpnpMapping(port) @@ -304,6 +333,10 @@ ipcMain.handle('pf:open', async (_event, portInput: number): Promise + ({ externalIp, localIp, cgnat, port, reachable, preForwarded, detail }) // 1차 점검: 이미 외부에서 닿는지. sendLog(t('log.probeStart')) @@ -312,7 +345,7 @@ ipcMain.handle('pf:open', async (_event, portInput: number): Promise => {