add standalone port-forwarding tool (마인크래프트 간편 포트포워딩)

포트포워딩만 단독으로 수행하는 세 번째 Electron 앱 추가.
- 포트 입력(기본 25565) → UPnP 개방 시도 → 외부에서 닿는지 점검(ifconfig.co
  + 임시 리스너) → 외부 접속 주소/성공·실패·확인불가 안내. '포트 닫기'로 매핑 제거.
- 점검 로직은 메인 설치기의 v0.3.14 오탐 수정(ifconfig 실패→확인불가)을 반영해
  자체 포함. sharp 미사용이라 빌드에서 제외(exe 약 73MB).
- 신규: src/installer-pf/{main,preload}.ts, installer-pf/{index.html,renderer.js},
  locales/installer-pf/ko-kr.json, tsconfig.installer-pf.json, electron-builder-pf.yml.
- package.json 에 installer:pf / dist:win:pf 스크립트, i18n 컴포넌트 유니온에
  'installer-pf' 추가. v0.3.15.

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
2026-06-24 21:43:06 +09:00
parent f554559be9
commit 86883f56e4
9 changed files with 686 additions and 3 deletions

366
src/installer-pf/main.ts Normal file
View File

@@ -0,0 +1,366 @@
import { app, BrowserWindow, ipcMain } from 'electron'
import http from 'node:http'
import https from 'node:https'
import net from 'node:net'
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
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)
}
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms))
function fetchBuffer(url: string): Promise<Buffer> {
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<string> {
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<string> {
return new Promise((resolve) => {
let settled = false
const finish = (ip: string) => { if (!settled) { settled = true; resolve(ip) } }
let client: ReturnType<typeof natUpnp.createClient> | 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('')
}, 8000)
client.externalIp((err: Error | null, ip?: string) => {
clearTimeout(timer)
try { client && client.close() } catch {}
finish(err || !ip ? '' : ip)
})
})
}
function openPortViaUpnp(port: number): Promise<void> {
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<typeof natUpnp.createClient> | 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<void> {
return new Promise((resolve) => {
let settled = false
const fin = () => { if (!settled) { settled = true; resolve() } }
let client: ReturnType<typeof natUpnp.createClient> | 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<IfconfigPortResult> {
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<IfconfigPortResult> {
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<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(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<void>((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
port: number
reachable: boolean | null
preForwarded: boolean
detail: string
}
ipcMain.handle('pf:i18n:dict', () => localeDict)
ipcMain.handle('pf:open', async (_event, portInput: number): Promise<PortForwardOutcome> => {
const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565
sendLog(t('log.start', { port }))
// 이전에 남은 매핑을 먼저 제거해 "사용자 라우터 규칙으로 이미 열린 상태" 와 구별.
sendLog(t('log.cleanup'))
await removeUpnpMapping(port)
let externalIp = await detectExternalIpHttp()
if (externalIp) sendLog(t('log.externalIpHttp', { ip: externalIp }))
else {
sendLog(t('log.externalIpHttpFail'))
externalIp = await detectExternalIpUpnp()
if (externalIp) sendLog(t('log.externalIpUpnp', { ip: externalIp }))
else sendLog(t('log.externalIpFail'))
}
// 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 { externalIp, port, reachable: true, preForwarded: true, detail: probe.detail }
}
// UPnP 개방 시도.
sendLog(t('log.upnpTry', { port }))
try {
await openPortViaUpnp(port)
sendLog(t('log.upnpReqOk'))
} catch (error) {
sendLog(t('log.upnpTryFail', { message: (error as Error).message }))
return { externalIp, port, reachable: probe.reachable, preForwarded: false, detail: 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 { externalIp, port, reachable: true, preForwarded: false, detail: probe.detail }
}
}
sendLog(t('log.upnpUnconfirmed'))
return { externalIp, port, reachable: probe.reachable, preForwarded: false, detail: probe.detail }
})
ipcMain.handle('pf:close', async (_event, portInput: number): Promise<void> => {
const port = Number.isFinite(portInput) && portInput > 0 && portInput < 65536 ? Math.floor(portInput) : 25565
sendLog(t('log.closeTry', { port }))
await removeUpnpMapping(port)
})
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()
})
})
app.on('window-all-closed', () => {
app.quit()
})

View File

@@ -0,0 +1,38 @@
import { contextBridge, ipcRenderer } from 'electron'
interface PortForwardOutcome {
externalIp: string
port: number
reachable: boolean | null
preForwarded: boolean
detail: string
}
const api = {
/** i18n 사전을 렌더러에 전달. */
loadLocale: (): Promise<Record<string, unknown>> => ipcRenderer.invoke('pf:i18n:dict'),
/** 지정 포트를 UPnP 로 개방 시도하고 외부에서 닿는지 점검. */
openPort: (port: number): Promise<PortForwardOutcome> => ipcRenderer.invoke('pf:open', port),
/** UPnP 포트 매핑 제거. */
closePort: (port: number): Promise<void> => ipcRenderer.invoke('pf:close', port),
/** 프로그램 종료. */
quit: (): Promise<void> => ipcRenderer.invoke('pf:quit'),
/** 로그 스트림 구독. */
onLog: (handler: (line: string) => void): (() => void) => {
const listener = (_event: unknown, line: string) => handler(line)
ipcRenderer.on('log', listener)
return () => ipcRenderer.removeListener('log', listener)
}
}
contextBridge.exposeInMainWorld('pfTool', api)
declare global {
interface Window {
pfTool: typeof api
}
}

View File

@@ -73,7 +73,7 @@ export function createI18n(filePath: string): I18n {
* 1. 패키징된 Electron 앱이면 `process.resourcesPath/locales/<component>/ko-kr.json`
* 2. `<프로젝트 루트>/locales/<component>/ko-kr.json`
*/
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp'): I18n {
export function loadComponentI18n(component: 'server' | 'installer' | 'installer-rp' | 'installer-pf'): I18n {
// 컴파일된 dist/shared/i18n.js 기준으로 프로젝트 루트는 2단계 위.
const projectRoot = path.resolve(__dirname, '..', '..')