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() + }) }) }