From d440514fdc58ffe50262ad6240d6ad33ba30f0fa Mon Sep 17 00:00:00 2001 From: claude-bot Date: Wed, 13 May 2026 01:08:50 +0900 Subject: [PATCH] =?UTF-8?q?installer:=20=EC=99=B8=EB=B6=80=20=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EC=B2=B4=ED=81=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=A0=90=EA=B2=80=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B5=90=EC=B2=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 기존 방식은 자기 PC에서 자기 외부 IP로 TCP 연결을 시도해 도달성을 판정했는데, 가정용 라우터의 헤어핀(hairpin) NAT 미지원으로 실제 외부 접근은 가능해도 내부 검증은 실패하던 문제가 있었다. - probePortFromOutside: 임시 TCP 리스너를 대상 포트에 띄우고 ifconfig.co/port/PORT를 호출해 외부에서 해당 포트로 TCP 연결을 시도하게 한 뒤, 리스너에 연결이 도달했는지 + ifconfig.co의 JSON reachable 값으로 종합 판정. - 포트가 이미 사용 중(서버 동작 중)이면 임시 리스너를 띄우지 않고 외부 서비스 응답만으로 판정. - ifconfig.co 응답에서 IP도 같이 얻어 외부 IP 폴백 경로 추가. 또한 1차 점검에서 이미 외부 접근이 가능한 상태(사용자가 라우터에 수동 포워딩 규칙을 등록한 경우)에는 UPnP로 추가 매핑을 만들지 않고, 우리가 이전 실행에서 만들어 둔 UPnP 매핑이 남아 있으면 portUnmapping으로 제거하여 중복/충돌 가능성을 줄인다. UPnP 매핑 후 재점검도 외부 서비스 기반 probePortFromOutside로 1.5초 간격 3회 재시도해 NAT 상태 전파 지연을 흡수. Co-Authored-By: Claude Opus 4.7 --- src/installer/main.ts | 217 +++++++++++++++++++++++++++++++++++------- 1 file changed, 183 insertions(+), 34 deletions(-) diff --git a/src/installer/main.ts b/src/installer/main.ts index bea014a..3aa5c79 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, shell } 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 fs from 'node:fs' @@ -477,7 +478,7 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise 0 ? port : 25565 sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`) - // 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백 + // 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백. let externalIp = await detectExternalIpHttp() if (externalIp) { sendLog(`외부 IP 확인(HTTP): ${externalIp}`) @@ -488,32 +489,25 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise { @@ -570,22 +582,159 @@ function detectExternalIpUpnp(): Promise { }) } -function testPortReachable(host: string, port: number): Promise { - if (!host) return Promise.resolve(false) +/** + * 외부에서 우리 PC의 지정 포트가 닿는지 확인한다. + * + * 헤어핀(hairpin) NAT 미지원 가정용 라우터에서는 내부에서 자기 외부 IP로 직접 TCP 연결을 + * 시도해도 실패하므로, 외부 포트체크 서비스(ifconfig.co)에게 검사를 위임한다. + * + * 1) 가능하면 임시 TCP 리스너를 해당 포트에 띄운다(서버가 아직 안 떠 있는 상태도 검증 가능). + * 포트가 이미 사용 중이면 외부 서비스 응답만으로 판정한다. + * 2) ifconfig.co/port/PORT를 호출해 외부에서 TCP 연결을 시도하게 한다. + * 3) 임시 리스너에 연결이 도달했거나 ifconfig.co가 reachable=true를 반환하면 성공. + */ +async function probePortFromOutside( + port: number, + hintIp: string +): Promise<{ reachable: boolean | null; detail: string; detectedIp: string }> { + // 1) 임시 리스너 바인딩 시도. + 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(`포트 ${port}이(가) 이미 사용 중. 임시 리스너 없이 외부 서비스 응답만으로 판정합니다.`) + } else { + sendLog(`임시 리스너 바인딩 실패: ${(err as Error).message}`) + } + try { server && server.close() } catch {} + server = null + } + + let gotInboundConnection = false + const inboundPromise = new Promise((resolve) => { + if (!server) { resolve(); return } + const onConn = (sock: net.Socket) => { + gotInboundConnection = true + try { sock.end() } catch {} + try { sock.destroy() } catch {} + resolve() + } + server.on('connection', onConn) + }) + + // 2) 외부 서비스 트리거. + const externalProbe = fetchIfconfigCoPort(port).catch((err) => ({ + ok: false as const, + error: (err as Error).message + })) + + // 외부 연결 도달 또는 12초 타임아웃 중 빠른 것을 기다린다. + await Promise.race([ + inboundPromise, + sleep(12000) + ]) + + const externalResult = await externalProbe + + try { server && server.close() } catch {} + + // 3) 판정. + let reachable: boolean | null = null + const details: string[] = [] + if (listenerBound) { + details.push(`임시 리스너 도달=${gotInboundConnection ? 'yes' : 'no'}`) + if (gotInboundConnection) reachable = true + } else { + details.push('임시 리스너=skip(포트 사용중)') + } + + let detectedIp = '' + if ('ok' in externalResult && externalResult.ok) { + details.push(`ifconfig.co reachable=${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(`ifconfig.co 실패=${(externalResult as { error: string }).error}`) + } + + // 임시 리스너가 떴고 외부 서비스도 닿지 않았다면 명확한 false. + if (reachable === null && listenerBound && !gotInboundConnection) reachable = false + + return { + reachable, + detail: details.join(', ') || '결과 없음', + detectedIp: detectedIp || hintIp || '' + } +} + +function fetchIfconfigCoPort(port: number): Promise<{ ok: true; reachable: boolean | null; ip: string } | { ok: false; error: string }> { return new Promise((resolve) => { - import('node:net').then((net) => { - const socket = net.createConnection({ host, port }) - socket.setTimeout(3000) - socket.once('connect', () => { - socket.end() - resolve(true) + const target = new URL(`https://ifconfig.co/port/${port}`) + const req = https.get(target, { + timeout: 15000, + headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-Installer' } + }, (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 (err) { + resolve({ ok: false, error: `응답 파싱 실패: ${text.slice(0, 80)}` }) + } }) - socket.once('error', () => resolve(false)) - socket.once('timeout', () => { - socket.destroy() - resolve(false) - }) - }).catch(() => resolve(false)) + }) + req.on('error', (err) => resolve({ ok: false, error: err.message })) + req.on('timeout', () => req.destroy(new Error('요청 시간 초과(15s)'))) + }) +} + +function removeUpnpMapping(port: number): Promise { + return new Promise((resolve) => { + let settled = false + const done = () => { if (!settled) { settled = true; resolve() } } + let client: ReturnType | null = null + try { + client = natUpnp.createClient() + } catch (err) { + sendLog(`UPnP 클라이언트 생성 실패(매핑 제거 단계): ${(err as Error).message}`) + done() + return + } + const timer = setTimeout(() => { + try { client && client.close() } catch {} + sendLog(`UPnP 매핑 제거 응답 없음(타임아웃 8s). 라우터에 우리가 만든 규칙이 없을 수 있습니다.`) + done() + }, 8000) + client.portUnmapping({ public: port, protocol: 'tcp' }, (err: Error | null) => { + clearTimeout(timer) + try { client && client.close() } catch {} + if (err) sendLog(`UPnP 매핑 제거 시도 결과: ${err.message} (없으면 정상)`) + else sendLog(`UPnP 매핑 제거 완료(포트 ${port}).`) + done() + }) }) }