installer: 외부 포트체크 서비스 기반 점검으로 교체
기존 방식은 자기 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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
import { app, BrowserWindow, dialog, ipcMain, shell } from 'electron'
|
||||||
import http from 'node:http'
|
import http from 'node:http'
|
||||||
import https from 'node:https'
|
import https from 'node:https'
|
||||||
|
import net from 'node:net'
|
||||||
import os from 'node:os'
|
import os from 'node:os'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
@@ -477,7 +478,7 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortF
|
|||||||
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
const targetPort = Number.isFinite(port) && port > 0 ? port : 25565
|
||||||
sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`)
|
sendLog(`포트포워딩 점검 시작: 포트 ${targetPort}`)
|
||||||
|
|
||||||
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백
|
// 외부 IP 확보: 공용 API → 실패 시 UPnP 게이트웨이의 외부 IP로 폴백.
|
||||||
let externalIp = await detectExternalIpHttp()
|
let externalIp = await detectExternalIpHttp()
|
||||||
if (externalIp) {
|
if (externalIp) {
|
||||||
sendLog(`외부 IP 확인(HTTP): ${externalIp}`)
|
sendLog(`외부 IP 확인(HTTP): ${externalIp}`)
|
||||||
@@ -488,32 +489,25 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortF
|
|||||||
else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.')
|
else sendLog('UPnP 게이트웨이에서도 외부 IP를 얻지 못했습니다.')
|
||||||
}
|
}
|
||||||
|
|
||||||
if (externalIp && (await testPortReachable(externalIp, targetPort))) {
|
// 1차 점검: 외부에서 이미 접근 가능한지 (서버가 떠 있거나, 우리가 임시 리스너 띄워서 검증).
|
||||||
sendLog(`외부에서 ${externalIp}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
sendLog('외부 포트체크 서비스(ifconfig.co)로 1차 점검합니다...')
|
||||||
|
let probe = await probePortFromOutside(targetPort, externalIp)
|
||||||
|
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||||
|
sendLog(`1차 점검 결과: ${probe.reachable === true ? '성공' : probe.reachable === false ? '실패' : '확인 불가'} (${probe.detail})`)
|
||||||
|
|
||||||
|
if (probe.reachable === true) {
|
||||||
|
sendLog(`외부에서 ${externalIp || '(IP 미상)'}:${targetPort} 접근 확인됨. 포트포워딩 됨.`)
|
||||||
|
// 이미 라우터 사용자 규칙으로 포워딩 중이라면 우리가 이전에 만든 UPnP 매핑은 불필요.
|
||||||
|
// 남아 있으면 중복/충돌 소지가 있어 제거 시도.
|
||||||
|
await removeUpnpMapping(targetPort)
|
||||||
return { status: 'preForwarded', externalIp, port: targetPort }
|
return { status: 'preForwarded', externalIp, port: targetPort }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UPnP 시도.
|
||||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`)
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 시도(TCP)...`)
|
||||||
try {
|
try {
|
||||||
await openPortViaUpnp(targetPort)
|
await openPortViaUpnp(targetPort)
|
||||||
sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.')
|
sendLog('UPnP portMapping 요청 성공. 외부 접근을 재확인합니다.')
|
||||||
// 라우터의 NAT 상태 반영에 약간의 지연이 있을 수 있어 짧게 재시도
|
|
||||||
let reachable = false
|
|
||||||
if (externalIp) {
|
|
||||||
for (let attempt = 1; attempt <= 3; attempt++) {
|
|
||||||
if (await testPortReachable(externalIp, targetPort)) { reachable = true; break }
|
|
||||||
await sleep(1500)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (reachable) {
|
|
||||||
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
|
||||||
return { status: 'upnpOk', externalIp, port: targetPort }
|
|
||||||
}
|
|
||||||
const reason = externalIp
|
|
||||||
? 'UPnP 매핑은 등록됐지만 외부 접근이 확인되지 않았습니다(ISP 차단 또는 이중 NAT 가능).'
|
|
||||||
: '외부 IP를 확인할 수 없어 접근 검증을 건너뛰었습니다. UPnP 매핑은 등록됐을 수 있습니다.'
|
|
||||||
sendLog(reason)
|
|
||||||
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason + ' 직접 포트포워딩을 해주세요.' }
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const msg = (error as Error).message || String(error)
|
const msg = (error as Error).message || String(error)
|
||||||
sendLog(`UPnP 시도 실패: ${msg}`)
|
sendLog(`UPnP 시도 실패: ${msg}`)
|
||||||
@@ -524,6 +518,24 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise<PortF
|
|||||||
message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.`
|
message: `UPnP 실패: ${msg}. 라우터에서 UPnP가 꺼져 있을 수 있습니다. 직접 포트포워딩을 해주세요.`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NAT 반영 지연을 고려해 최대 3회 재점검.
|
||||||
|
for (let attempt = 1; attempt <= 3; attempt++) {
|
||||||
|
await sleep(1500)
|
||||||
|
sendLog(`UPnP 적용 후 재점검 ${attempt}/3...`)
|
||||||
|
probe = await probePortFromOutside(targetPort, externalIp)
|
||||||
|
if (!externalIp && probe.detectedIp) externalIp = probe.detectedIp
|
||||||
|
if (probe.reachable === true) {
|
||||||
|
sendLog(`UPnP로 포트 ${targetPort} 자동 개방 완료.`)
|
||||||
|
return { status: 'upnpOk', externalIp, port: targetPort }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reason = probe.reachable === false
|
||||||
|
? 'UPnP 매핑은 등록됐지만 외부 포트체크 서비스에서 연결이 닿지 않았습니다. ISP 차단, 이중 NAT, 또는 방화벽 설정을 확인하세요.'
|
||||||
|
: `외부 포트체크 결과를 받지 못했습니다(${probe.detail}). UPnP 매핑은 등록됐을 수 있습니다.`
|
||||||
|
sendLog(reason)
|
||||||
|
return { status: 'upnpFailed', externalIp, port: targetPort, message: reason }
|
||||||
})
|
})
|
||||||
|
|
||||||
async function detectExternalIpHttp(): Promise<string> {
|
async function detectExternalIpHttp(): Promise<string> {
|
||||||
@@ -570,22 +582,159 @@ function detectExternalIpUpnp(): Promise<string> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function testPortReachable(host: string, port: number): Promise<boolean> {
|
/**
|
||||||
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<void>((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<void>((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) => {
|
return new Promise((resolve) => {
|
||||||
import('node:net').then((net) => {
|
const target = new URL(`https://ifconfig.co/port/${port}`)
|
||||||
const socket = net.createConnection({ host, port })
|
const req = https.get(target, {
|
||||||
socket.setTimeout(3000)
|
timeout: 15000,
|
||||||
socket.once('connect', () => {
|
headers: { 'Accept': 'application/json', 'User-Agent': 'MusicQuiz-Installer' }
|
||||||
socket.end()
|
}, (res) => {
|
||||||
resolve(true)
|
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', () => {
|
req.on('error', (err) => resolve({ ok: false, error: err.message }))
|
||||||
socket.destroy()
|
req.on('timeout', () => req.destroy(new Error('요청 시간 초과(15s)')))
|
||||||
resolve(false)
|
})
|
||||||
})
|
}
|
||||||
}).catch(() => resolve(false))
|
|
||||||
|
function removeUpnpMapping(port: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let settled = false
|
||||||
|
const done = () => { if (!settled) { settled = true; resolve() } }
|
||||||
|
let client: ReturnType<typeof natUpnp.createClient> | 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user