const childProcess = require('child_process') const natUpnp = require('nat-upnp') const ConfigManager = require('./configmanager') const RULE_PREFIX = 'MRS Launcher Auto Port' const UPnP_DESCRIPTION_PREFIX = 'MRS Launcher' const UPnP_TTL_SECONDS = 3600 const states = new Map() function buildState(profileId, port){ return { profileId, port, statusCode: 'checking', summary: '포트 확인 중', message: '포트 개방 가능 여부를 확인하고 있습니다.', tone: 'info', firewallCreated: false, ruleName: null, upnpCreated: false, checkedAt: Date.now() } } function toPublicState(state){ return { port: state.port, statusCode: state.statusCode, summary: state.summary, message: state.message, tone: state.tone, checkedAt: state.checkedAt } } function createClient(){ return natUpnp.createClient() } function clientGetMappings(client){ return new Promise((resolve, reject) => { client.getMappings((error, results) => { if(error){ reject(error) return } resolve(Array.isArray(results) ? results : []) }) }) } function clientPortMapping(client, options){ return new Promise((resolve, reject) => { client.portMapping(options, (error) => { if(error){ reject(error) return } resolve() }) }) } function clientPortUnmapping(client, options){ return new Promise((resolve, reject) => { client.portUnmapping(options, (error) => { if(error){ reject(error) return } resolve() }) }) } function extractPort(mapping){ if(mapping == null){ return null } if(typeof mapping.public === 'number'){ return mapping.public } if(typeof mapping.publicPort === 'number'){ return mapping.publicPort } if(typeof mapping.port === 'number'){ return mapping.port } if(mapping.public != null && typeof mapping.public === 'object' && typeof mapping.public.port === 'number'){ return mapping.public.port } return null } function extractProtocol(mapping){ if(mapping == null){ return '' } const protocol = mapping.protocol ?? mapping.type ?? mapping.public?.protocol ?? '' return String(protocol).toUpperCase() } async function findExistingTcpMapping(port){ const client = createClient() const mappings = await clientGetMappings(client) return mappings.find((mapping) => extractPort(mapping) === port && (extractProtocol(mapping) === '' || extractProtocol(mapping) === 'TCP')) ?? null } function getFirewallRuleName(profileId, port){ return `${RULE_PREFIX} ${profileId} ${port}` } function ensureWindowsFirewallRule(profileId, port){ if(process.platform !== 'win32'){ return { created: false, ruleName: null } } const ruleName = getFirewallRuleName(profileId, port) const result = childProcess.spawnSync('netsh', [ 'advfirewall', 'firewall', 'add', 'rule', `name=${ruleName}`, 'dir=in', 'action=allow', 'protocol=TCP', `localport=${port}` ], { encoding: 'utf8' }) return { created: result.status === 0, ruleName } } function deleteWindowsFirewallRule(ruleName){ if(process.platform !== 'win32' || !ruleName){ return } childProcess.spawnSync('netsh', [ 'advfirewall', 'firewall', 'delete', 'rule', `name=${ruleName}` ], { encoding: 'utf8' }) } function buildPortFailureMessage(error){ const baseMessage = '자동 포트 개방 실패. 직접 포트포워딩 해주세요.' if(error == null){ return baseMessage } const errorMessage = typeof error === 'string' ? error : (error.message ?? String(error)) return `${baseMessage} ${errorMessage}` } exports.ensurePortAvailability = async function(profile){ if(profile?.serverEnabled !== true){ return { port: null, statusCode: 'not-needed', summary: '포트 개방 필요 없음', message: '이 프로필은 서버를 사용하지 않습니다.', tone: 'info', checkedAt: Date.now() } } const manualAddress = ConfigManager.getLibraryServerAddressOverride(profile.id) if(manualAddress != null && manualAddress.length > 0){ await exports.releaseProfilePort(profile.id) return { port: profile.serverPort ?? 25565, statusCode: 'manual-address', summary: '접속 주소 사용 중', message: `직접 입력한 접속 주소가 설정되어 있어 자동 포트 개방을 건너뜁니다. (${manualAddress})`, tone: 'info', checkedAt: Date.now() } } const port = profile.serverPort ?? 25565 const currentState = states.get(profile.id) if(currentState != null && currentState.port === port && currentState.statusCode !== 'failed'){ currentState.checkedAt = Date.now() return toPublicState(currentState) } const state = buildState(profile.id, port) states.set(profile.id, state) const firewallState = ensureWindowsFirewallRule(profile.id, port) state.firewallCreated = firewallState.created state.ruleName = firewallState.ruleName try { const existingMapping = await findExistingTcpMapping(port) if(existingMapping != null){ state.statusCode = 'already-open' state.summary = '이미 포트가 열려있습니다.' state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.` state.tone = 'success' state.checkedAt = Date.now() return toPublicState(state) } const client = createClient() await clientPortMapping(client, { public: port, private: port, protocol: 'TCP', ttl: UPnP_TTL_SECONDS, description: `${UPnP_DESCRIPTION_PREFIX} ${profile.id}` }) state.upnpCreated = true state.statusCode = 'opened' state.summary = '자동 포트 개방 성공' state.message = `UPnP로 TCP ${port} 포트를 열었습니다.${state.firewallCreated ? ' 윈도우 방화벽 허용도 같이 적용했습니다.' : ''}` state.tone = 'success' } catch (error) { const message = typeof error?.message === 'string' ? error.message : String(error) if(/ConflictInMappingEntry|Already/i.test(message)){ state.statusCode = 'already-open' state.summary = '이미 포트가 열려있습니다.' state.message = `TCP ${port} 포트에 기존 포트포워딩이 있어 그대로 사용합니다.` state.tone = 'success' } else { state.statusCode = 'failed' state.summary = '자동 포트 개방 실패' state.message = buildPortFailureMessage(error) state.tone = 'error' } } state.checkedAt = Date.now() return toPublicState(state) } exports.getPortAvailabilityState = function(profileId){ const state = states.get(profileId) return state != null ? toPublicState(state) : null } exports.releaseProfilePort = async function(profileId){ const state = states.get(profileId) if(state == null){ return } if(state.upnpCreated){ try { const client = createClient() await clientPortUnmapping(client, { public: state.port, protocol: 'TCP' }) } catch (error) { void error } } if(state.firewallCreated && state.ruleName){ deleteWindowsFirewallRule(state.ruleName) } states.delete(profileId) } exports.cleanupAll = async function(){ await Promise.all(Array.from(states.keys()).map((profileId) => exports.releaseProfilePort(profileId))) } process.once('exit', () => { for(const state of states.values()){ if(state.firewallCreated && state.ruleName){ deleteWindowsFirewallRule(state.ruleName) } } })