Refactor launcher profiles and port automation
This commit is contained in:
299
app/assets/js/portmanager.js
Normal file
299
app/assets/js/portmanager.js
Normal file
@@ -0,0 +1,299 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user