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' import { loadComponentI18n } from '../shared/i18n.js' // 포트포워딩 전용 독립 도구. 메인 설치기의 UPnP 개방 + 외부 포트 점검 로직만 // 떼어내 단독 실행한다. (음악퀴즈 설치/리소스팩과 무관) const i18n = loadComponentI18n('installer-pf') const t = i18n.t const localeDict = i18n.dict let mainWindow: BrowserWindow | null = null // 이 도구가 UPnP 로 직접 열어둔 포트. 앱이 살아 있는 동안 매핑을 유지하고, // 창을 닫거나 종료할 때 이 포트의 매핑을 제거한다(사용자가 라우터에 직접 만든 // 영구 규칙으로 이미 열려 있던 preForwarded 포트는 우리 것이 아니므로 추적하지 않음). let activePort: number | null = null // before-quit 재진입 가드. 매핑 정리를 마친 뒤에만 실제 종료로 넘어가게 한다. let cleanupDone = false function createMainWindow(): void { const iconPath = path.join(__dirname, '..', '..', 'build', process.platform === 'win32' ? 'icon.ico' : 'icon.png') mainWindow = new BrowserWindow({ width: 760, height: 620, icon: iconPath, webPreferences: { preload: path.join(__dirname, 'preload.js'), contextIsolation: true, nodeIntegration: false } }) mainWindow.removeMenu() void mainWindow.loadFile(path.join(__dirname, '..', '..', 'installer-pf', 'index.html')) } function sendLog(line: string): void { if (!mainWindow || mainWindow.isDestroyed()) return const stamped = `[${new Date().toLocaleTimeString('ko-KR', { hour12: false })}] ${line}` mainWindow.webContents.send('log', stamped) } // nat-upnp 는 라우터가 UPnP 를 거부/미지원할 때 콜백 밖에서 비동기 소켓 오류를 // 낼 수 있다. 처리되지 않으면 Electron 메인 프로세스가 그대로 종료되어 창이 갑자기 // 닫힌다("오류나면서 끝남"). 전역 가드로 잡아 로그만 남기고 앱은 계속 살려 둔다. process.on('uncaughtException', (err) => { try { sendLog(t('log.internalError', { message: (err as Error)?.message || String(err) })) } catch {} }) process.on('unhandledRejection', (reason) => { try { sendLog(t('log.internalError', { message: reason instanceof Error ? reason.message : String(reason) })) } catch {} }) 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 } /** 사설(RFC1918) IPv4 인지. 라우터 WAN 이 사설이면 앞단에 NAT 이 또 있다는 뜻(이중 NAT). */ function isPrivateIpv4(ip: string): boolean { return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(ip) } /** * CGNAT/이중 NAT 여부 판별. HTTP 로 본 공인 egress IP 하나만으로는 배제할 수 없다 * (전형적 CGNAT 은 egress 는 ISP 공인 IP, 라우터 WAN 만 100.64/10 이라 egress 검사로는 놓침). * 그래서 라우터 WAN(IGD 외부) IP 를 함께 보고 비교한다. * - 'yes' : 공인 IP 가 CGNAT 대역이거나, 라우터 WAN 이 사설/CGNAT 이거나 공인 IP 와 다름. * - 'no' : 라우터 WAN 이 HTTP 공인 IP 와 동일 → 단일 NAT 확정. * - 'unknown' : 라우터 WAN 을 못 읽음(UPnP 미응답/거부) → 외부 IP 만으로는 확정/배제 불가. */ function classifyCgnat(publicIp: string, wanIp: string): 'yes' | 'no' | 'unknown' { if (publicIp && isCgnat(publicIp)) return 'yes' if (wanIp) { if (isCgnat(wanIp) || isPrivateIpv4(wanIp)) return 'yes' if (publicIp && wanIp !== publicIp) return 'yes' if (publicIp && wanIp === publicIp) return 'no' } return 'unknown' } function fetchBuffer(url: string): Promise { return new Promise((resolve, reject) => { const target = new URL(url) const transport = target.protocol === 'https:' ? https : http const request = transport.get(target, { timeout: 15000 }, (response) => { const code = response.statusCode ?? 0 if ((code === 301 || code === 302) && response.headers.location) { response.resume() fetchBuffer(new URL(response.headers.location, target).toString()).then(resolve, reject) return } if (code >= 400) { response.resume() reject(new Error(`HTTP ${code}`)) return } const chunks: Buffer[] = [] response.on('data', (c: Buffer) => chunks.push(c)) response.on('end', () => resolve(Buffer.concat(chunks))) }) request.on('error', reject) request.on('timeout', () => request.destroy(new Error(t('errors.requestTimeout')))) }) } async function detectExternalIpHttp(): Promise { const endpoints = ['https://api.ipify.org', 'https://ifconfig.me/ip', 'https://icanhazip.com'] for (const url of endpoints) { try { const buffer = await fetchBuffer(url) const ip = buffer.toString('utf8').trim() if (/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) return ip } catch { // try next } } return '' } function detectExternalIpUpnp(): Promise { return new Promise((resolve) => { let settled = false const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } } let client: ReturnType | null = null try { client = natUpnp.createClient() } catch (err) { sendLog(t('log.upnpClientFail', { message: (err as Error).message })) finish('') return } const timer = setTimeout(() => { try { client && client.close() } catch {} finish('') }, 6000) client.externalIp((err: Error | null, ip?: string) => { clearTimeout(timer) try { client && client.close() } catch {} finish(err || !ip ? '' : ip) }) }) } function openPortViaUpnp(port: number): Promise { return new Promise((resolve, reject) => { let settled = false const done = (err?: Error) => { if (settled) return settled = true if (err) reject(err) else resolve() } let client: ReturnType | null = null try { client = natUpnp.createClient() } catch (err) { done(err as Error) return } const timer = setTimeout(() => { try { client && client.close() } catch {} done(new Error(t('errors.upnpTimeout'))) }, 15000) client.portMapping( { public: port, private: port, ttl: 0, description: 'MusicQuiz PortForward', protocol: 'tcp' }, (error: Error | null) => { clearTimeout(timer) try { client && client.close() } catch {} done(error || undefined) } ) }) } function removeUpnpMapping(port: number): Promise { return new Promise((resolve) => { let settled = false const fin = () => { if (!settled) { settled = true; resolve() } } let client: ReturnType | null = null try { client = natUpnp.createClient() } catch (err) { sendLog(t('log.upnpClientFail', { message: (err as Error).message })) fin() return } const timer = setTimeout(() => { try { client && client.close() } catch {} fin() }, 8000) client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => { clearTimeout(timer) try { client && client.close() } catch {} if (err) sendLog(t('log.upnpRemoveAttempt', { message: err.message })) else sendLog(t('log.upnpRemoveDone', { port })) fin() }) }) } type IfconfigPortResult = { ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string } // ifconfig.co 는 간헐적으로 타임아웃/레이트리밋을 낸다. 1회 재시도로 일시적 실패를 줄인다. async function fetchIfconfigCoPort(port: number): Promise { let last: IfconfigPortResult = { ok: false, error: 'no attempt' } for (let attempt = 0; attempt < 2; attempt++) { last = await fetchIfconfigCoPortOnce(port) if (last.ok) return last if (attempt === 0) await sleep(1500) } return last } function fetchIfconfigCoPortOnce(port: number): Promise { return new Promise((resolve) => { const target = new URL(`https://ifconfig.co/port/${port}`) const req = https.get(target, { timeout: 15000, headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-PortForward' } }, (res) => { if ((res.statusCode ?? 0) >= 400) { res.resume() resolve({ ok: false, error: `HTTP ${res.statusCode}` }) return } const chunks: Buffer[] = [] res.on('data', (c: Buffer) => chunks.push(c)) res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8').trim() try { const json = JSON.parse(text) const reachable = typeof json.reachable === 'boolean' ? json.reachable : null const ip = typeof json.ip === 'string' ? json.ip : '' resolve({ ok: true, reachable, ip }) } catch { resolve({ ok: false, error: text.slice(0, 80) }) } }) }) req.on('error', (err) => resolve({ ok: false, error: err.message })) req.on('timeout', () => req.destroy(new Error(t('errors.requestTimeout')))) }) } /** * 외부에서 지정 포트가 닿는지 검사한다. * 1) 임시 TCP 리스너를 0.0.0.0:port 에 띄운다(서버가 안 떠 있어도 검증 가능). * 2) ifconfig.co 에게 외부 IP:port 로 접속을 시킨다. * 3) 리스너에 인바운드가 오거나 ifconfig.co 가 reachable=true 면 성공. * '닫힘(false)' 은 ifconfig.co 가 명시적으로 false 를 줄 때만. 외부 판정이 없으면 null(확인 불가). */ async function probePortFromOutside( port: number, hintIp: string ): Promise<{ reachable: boolean | null; detail: string; detectedIp: string }> { let server: net.Server | null = null let listenerBound = false try { server = net.createServer() await new Promise((resolve, reject) => { const onError = (err: Error) => { server!.removeListener('error', onError); reject(err) } server!.once('error', onError) server!.listen(port, '0.0.0.0', () => { server!.removeListener('error', onError) listenerBound = true resolve() }) }) } catch (err) { const code = (err as NodeJS.ErrnoException).code if (code === 'EADDRINUSE') sendLog(t('log.portInUse', { port })) else sendLog(t('log.listenerBindFail', { message: (err as Error).message })) try { server && server.close() } catch {} server = null } let gotInbound = false const inboundPromise = new Promise((resolve) => { if (!server) { resolve(); return } server.on('connection', (sock: net.Socket) => { gotInbound = true try { sock.destroy() } catch {} resolve() }) }) const externalProbe = fetchIfconfigCoPort(port).catch((err) => ({ ok: false as const, error: (err as Error).message })) await Promise.race([inboundPromise, sleep(12000)]) const externalResult = await externalProbe try { server && server.close() } catch {} let reachable: boolean | null = null const details: string[] = [] if (listenerBound) { details.push(t('log.detailListenerHit', { value: gotInbound ? 'yes' : 'no' })) if (gotInbound) reachable = true } else { details.push(t('log.detailListenerSkip')) } let detectedIp = '' if ('ok' in externalResult && externalResult.ok) { details.push(t('log.detailIfconfig', { reachable: String(externalResult.reachable), ip: externalResult.ip || '?' })) detectedIp = externalResult.ip || '' if (externalResult.reachable === true) reachable = true else if (reachable !== true && externalResult.reachable === false) reachable = false } else if ('ok' in externalResult && !externalResult.ok) { details.push(t('log.detailIfconfigFail', { error: (externalResult as { error: string }).error })) } // 외부 점검 서비스가 명시적 false 를 준 경우에만 닫힘. 리스너 미도달만으로는 단정하지 않는다 // (ifconfig.co 실패 시 외부 시도 자체가 없었고, 리스너 수신 주체가 이 도구라 방화벽 영향도 받음). return { reachable, detail: details.join(', ') || t('log.detailNone'), detectedIp: detectedIp || hintIp || '' } } interface PortForwardOutcome { externalIp: string localIp: string wanIp: string cgnat: 'yes' | 'no' | 'unknown' port: number reachable: boolean | null preForwarded: boolean detail: string } ipcMain.handle('pf:i18n:dict', () => localeDict) ipcMain.handle('pf:open', async (_event, portInput: number): Promise => { const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565 sendLog(t('log.start', { port })) const localIp = detectLocalIpv4() if (localIp) sendLog(t('log.localIp', { ip: localIp })) // 다른 포트를 이미 우리가 열어둔 상태에서 새 포트를 열면, 이전 포트 매핑을 먼저 닫아 // 매핑이 새는 것을 막는다. if (activePort !== null && activePort !== port) { await removeUpnpMapping(activePort) activePort = null } // 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별. sendLog(t('log.cleanup')) await removeUpnpMapping(port) let externalIp = await detectExternalIpHttp() if (externalIp) sendLog(t('log.externalIpHttp', { ip: externalIp })) else sendLog(t('log.externalIpHttpFail')) // 라우터 WAN(IGD 외부) IP 를 UPnP 로 조회해 HTTP 공인 IP 와 비교 → CGNAT/이중 NAT 판별. const wanIp = await detectExternalIpUpnp() if (wanIp) sendLog(t('log.routerWan', { ip: wanIp })) else sendLog(t('log.routerWanUnknown')) if (!externalIp && wanIp) externalIp = wanIp const cgnat = classifyCgnat(externalIp, wanIp) if (cgnat === 'yes') sendLog(t('log.cgnatDetected', { public: externalIp || '?', wan: wanIp || '?' })) else if (cgnat === 'unknown') sendLog(t('log.cgnatUnknown')) const wrap = (reachable: boolean | null, preForwarded: boolean, detail: string): PortForwardOutcome => ({ externalIp, localIp, wanIp, cgnat, port, reachable, preForwarded, detail }) // 1차 점검: 이미 외부에서 닿는지. sendLog(t('log.probeStart')) let probe = await probePortFromOutside(port, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp sendLog(t('log.probeResult', { verdict: verdictText(probe.reachable), detail: probe.detail })) if (probe.reachable === true) { sendLog(t('log.preForwarded')) return wrap(true, true, probe.detail) } // UPnP 개방 시도. sendLog(t('log.upnpTry', { port })) try { await openPortViaUpnp(port) // 우리가 연 포트로 기록 → 앱 종료/창 닫힘 시 자동으로 매핑 제거. activePort = port sendLog(t('log.upnpReqOk')) } catch (error) { const msg = (error as Error).message sendLog(t('log.upnpTryFail', { message: msg })) // ECONNREFUSED/타임아웃 = 라우터가 UPnP 제어를 거부/미지원. 수동 포워딩 안내로 유도. if (/ECONNREFUSED|ETIMEDOUT|timed out|시간 초과/i.test(msg)) sendLog(t('log.upnpUnavailable')) return wrap(probe.reachable, false, probe.detail) } // NAT 반영 지연 고려 재점검. for (let attempt = 1; attempt <= 3; attempt++) { await sleep(1500) sendLog(t('log.recheck', { attempt })) probe = await probePortFromOutside(port, externalIp) if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp if (probe.reachable === true) { sendLog(t('log.upnpDone', { port })) return wrap(true, false, probe.detail) } } sendLog(t('log.upnpUnconfirmed')) return wrap(probe.reachable, false, probe.detail) }) ipcMain.handle('pf:close', async (_event, portInput: number): Promise => { const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565 sendLog(t('log.closeTry', { port })) await removeUpnpMapping(port) if (activePort === port) activePort = null }) ipcMain.handle('pf:quit', async () => { app.quit() }) function verdictText(reachable: boolean | null): string { return reachable === true ? t('verdict.success') : reachable === false ? t('verdict.fail') : t('verdict.unknown') } app.whenReady().then(() => { createMainWindow() app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createMainWindow() }) }) // 창을 닫거나 종료할 때, 이 도구가 열어둔 UPnP 매핑을 제거한 뒤 실제 종료로 넘어간다. // removeUpnpMapping 은 비동기라 before-quit 을 한 번 막고(cleanup) 끝나면 다시 quit 한다. app.on('before-quit', (event) => { if (cleanupDone || activePort === null) return event.preventDefault() const port = activePort activePort = null sendLog(t('log.closeTry', { port })) void removeUpnpMapping(port).finally(() => { cleanupDone = true app.quit() }) }) app.on('window-all-closed', () => { app.quit() })