diff --git a/src/installer/main.ts b/src/installer/main.ts index d9069e3..a603695 100644 --- a/src/installer/main.ts +++ b/src/installer/main.ts @@ -298,8 +298,82 @@ ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) = await downloadServerZip(pack.pack, installPath) // 다운로드한 zip에 들어있을 수 있는 eula.txt를 그대로 보존한다. // 동의 흐름은 renderer가 별도 IPC로 읽고 동의 시 덮어쓴다. + + // run.bat 에 서버 기동/종료시 UPnP 자동 등록/해제 로직 주입. + // 이렇게 해야 서버가 안 떠 있는 동안에는 포트가 닫혀 있게 된다. + await injectUpnpToRunBat(installPath) }) +/** + * 추출된 서버 zip 의 run.bat 에 UPnP 자동 등록(서버 시작 시) / 자동 해제(서버 종료 후) + * 스크립트를 끼워 넣는다. 이미 우리가 주입했던 마커가 있으면 다시 건드리지 않는다. + * + * 동작: + * 1) 서버 시작 직전: server.properties 의 server-port 값(없으면 25565) 으로 PowerShell + * 을 통해 HNetCfg.NATUPnP.1 COM 객체를 이용해 정적 포트 매핑 추가. + * 2) 서버 프로세스 종료 후(=pause 직전 또는 파일 끝): 동일한 포트의 매핑 제거. + * + * 제한: 사용자가 콘솔 창을 X 버튼으로 강제 종료하면 teardown 이 실행되지 않는다. + * 이 경우 라우터의 UPnP TTL 에 의해 자동 만료되며, 다음 실행 시 Add 전에 Remove 를 + * 시도하므로 idempotent. + */ +async function injectUpnpToRunBat(installPath: string): Promise { + const runBat = path.join(installPath, 'run.bat') + if (!fs.existsSync(runBat)) { + sendLog('run.bat 이 없어 UPnP 자동 등록 스크립트 주입을 건너뜁니다.') + return + } + const MARKER = 'REM === UPNP MANAGED BY MUSICQUIZ INSTALLER ===' + const original = await fsp.readFile(runBat, 'utf8') + if (original.includes(MARKER)) { + sendLog('run.bat 에 이미 UPnP 자동 등록 스크립트가 들어 있어 건너뜁니다.') + return + } + + const lines = original.split(/\r?\n/) + const javaIdx = lines.findIndex((line) => /^\s*java(\.exe)?[\s"]/i.test(line)) + if (javaIdx === -1) { + sendLog('run.bat 에서 java 호출 라인을 찾지 못해 UPnP 자동 등록 주입을 건너뜁니다.') + return + } + let pauseIdx = -1 + for (let i = javaIdx + 1; i < lines.length; i++) { + if (/^\s*pause\b/i.test(lines[i])) { pauseIdx = i; break } + } + if (pauseIdx === -1) pauseIdx = lines.length + + // PowerShell 한 줄로 처리: server.properties 의 server-port 우선, 없으면 25565. + // Add 전에 같은 포트의 매핑이 남아 있으면 먼저 Remove 하여 idempotent 하게 만든다. + const addBlock = [ + MARKER, + 'REM 서버 시작 직전: server-port 추출 후 UPnP 매핑 등록.', + 'set "_MQ_PORT=25565"', + 'for /f "tokens=2 delims==" %%a in (\'findstr /b /c:"server-port=" server.properties 2^>nul\') do set "_MQ_PORT=%%a"', + 'set "_MQ_PORT=%_MQ_PORT: =%"', + 'echo [MusicQuiz] UPnP 등록 시도: TCP %_MQ_PORT%', + 'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; $ip=(Get-NetIPAddress -AddressFamily IPv4 -PrefixOrigin Dhcp,Manual -ErrorAction SilentlyContinue ^| Where-Object {$_.IPAddress -notlike \'169.254.*\' -and $_.IPAddress -ne \'127.0.0.1\'} ^| Select-Object -First 1).IPAddress; if (-not $ip) { Write-Host \'[MusicQuiz] 로컬 IPv4 검색 실패\'; exit 1 }; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { try { $c.Remove($port,\'TCP\') ^| Out-Null } catch {}; $c.Add($port,\'TCP\',$port,$ip,$true,\'MusicQuiz Minecraft Server\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 등록 성공: \' + $ip + \':\' + $port + \' TCP\') } else { Write-Host \'[MusicQuiz] UPnP 컬렉션 사용 불가(라우터 UPnP 꺼짐?)\' } } catch { Write-Host (\'[MusicQuiz] UPnP 등록 실패: \' + $_.Exception.Message) }"' + ] + + const removeBlock = [ + 'REM 서버 종료 후: UPnP 매핑 해제.', + 'echo [MusicQuiz] UPnP 해제 시도: TCP %_MQ_PORT%', + 'powershell -NoProfile -Command "$port=[int]$env:_MQ_PORT; try { $u = New-Object -ComObject HNetCfg.NATUPnP.1; $c=$u.StaticPortMappingCollection; if ($c) { $c.Remove($port,\'TCP\') ^| Out-Null; Write-Host (\'[MusicQuiz] UPnP 해제 완료: TCP \' + $port) } } catch { Write-Host (\'[MusicQuiz] UPnP 해제 실패: \' + $_.Exception.Message) }"' + ] + + const merged: string[] = [] + merged.push(...lines.slice(0, javaIdx)) + merged.push(...addBlock) + merged.push(lines[javaIdx]) + merged.push(...lines.slice(javaIdx + 1, pauseIdx)) + merged.push(...removeBlock) + merged.push(...lines.slice(pauseIdx)) + + // bat 파일은 CRLF 가 안전. + const output = merged.join('\r\n') + await fsp.writeFile(runBat, output, 'utf8') + sendLog('run.bat 에 서버 기동/종료 시 UPnP 자동 등록·해제 스크립트를 추가했습니다.') +} + ipcMain.handle('server:readEula', async (_event, installPath: string): Promise<{ exists: boolean; content: string }> => { if (!installPath) return { exists: false, content: '' } const target = path.join(path.resolve(installPath), 'eula.txt') @@ -529,11 +603,16 @@ ipcMain.handle('server:portForward', async (_event, port: number): Promise