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 => {