300 lines
8.3 KiB
JavaScript
300 lines
8.3 KiB
JavaScript
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)
|
|
}
|
|
}
|
|
})
|