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:
366
src/installer-pf/main.ts
Normal file
366
src/installer-pf/main.ts
Normal 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()
|
||||
})
|
||||
38
src/installer-pf/preload.ts
Normal file
38
src/installer-pf/preload.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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, '..', '..')
|
||||
|
||||
|
||||
Reference in New Issue
Block a user