const express = require('express'); const http = require('http'); const fs = require('fs'); const { WebSocketServer } = require('ws'); const path = require('path'); const ps = require('./lib/powershell'); const ssh = require('./lib/ssh'); const vmCtl = require('./lib/vm'); const dunePaths = require('./lib/paths'); const app = express(); const server = http.createServer(app); const wss = new WebSocketServer({ server }); const PORT = process.env.PORT || 3000; const VM_NAME = vmCtl.VM_NAME; const DEFAULT_SERVER_PATH = path.join( 'C:', 'Program Files (x86)', 'Steam', 'steamapps', 'common', 'Dune Awakening Self-Hosted Server' ); app.use(express.json()); // --------------------------------------------------------------------------- // WebSocket — broadcast helper // --------------------------------------------------------------------------- function broadcast(type, data) { const msg = JSON.stringify({ type, data }); wss.clients.forEach((c) => { if (c.readyState === 1) c.send(msg); }); } function log(text) { broadcast('output', text); } // --------------------------------------------------------------------------- // VM helpers // --------------------------------------------------------------------------- const VM_STATUS_CMD = ` $vm = Get-VM -Name '${VM_NAME}' -ErrorAction SilentlyContinue if ($vm) { $ip = $null if ($vm.State -eq 'Running') { $ip = (Get-VMNetworkAdapter -VMName '${VM_NAME}').IPAddresses | Where-Object { $_ -match '^\\d+\\.\\d+\\.\\d+\\.\\d+$' } | Select-Object -First 1 } [PSCustomObject]@{ exists = $true state = $vm.State.ToString() ip = $ip memoryMB = [math]::Round($vm.MemoryAssigned / 1MB) startupMemoryMB = [math]::Round($vm.MemoryStartup / 1MB) uptime = $vm.Uptime.ToString() } | ConvertTo-Json -Compress } else { '{"exists":false}' }`.trim(); let cachedVmStatus = null; async function getVmStatus() { try { cachedVmStatus = await ps.runJson(VM_STATUS_CMD); } catch { cachedVmStatus = { exists: false, error: 'Failed to query Hyper-V' }; } return cachedVmStatus; } async function getVmIp() { const st = cachedVmStatus || (await getVmStatus()); return st && st.ip ? st.ip : null; } // Auto-sync is disabled once the user explicitly sets an IP via the dashboard. // It only runs on first boot to seed settings.conf when it's empty. let lastKnownVmIp = null; let visibilityManuallySet = false; // Funcom reads line 4 of settings.conf at gateway startup as GameRmqAddress. const PORT_FORWARD_INFO = { rmqTcp: 31982, gameUdpStart: 7777, gameUdpEnd: 7810, }; async function readSettingsConfIp(vmIp) { return (await ssh.run(vmIp, "sed -n '4p' /home/dune/.dune/settings.conf 2>/dev/null", null, { timeout: 10000 })).trim(); } async function writeSettingsConfIp(vmIp, advertisedIp) { if (!/^\d+\.\d+\.\d+\.\d+$/.test(advertisedIp)) { throw new Error(`Invalid IP address: ${advertisedIp}`); } const content = `\n\n\n${advertisedIp}\n`; const b64 = Buffer.from(content).toString('base64'); await ssh.run(vmIp, `echo ${b64} | base64 -d > /home/dune/.dune/settings.conf`, null, { timeout: 10000 }); } async function syncSettingsConfIp(ip) { if (!ip || ip === lastKnownVmIp || visibilityManuallySet) return; try { const currentIpInConf = await readSettingsConfIp(ip); if (!currentIpInConf) { // settings.conf has no IP yet — seed it with the VM's private IP log(`Seeding settings.conf with VM IP ${ip}...\n`); await writeSettingsConfIp(ip, ip); } lastKnownVmIp = ip; } catch { /* non-critical */ } } async function getDirectorPort(ip) { try { const raw = await ssh.run(ip, "sudo kubectl get svc -A -o jsonpath='{.items[*].spec.ports[?(@.port==11717)].nodePort}' 2>/dev/null" ); const port = raw.replace(/'/g, '').trim(); return /^\d+$/.test(port) ? port : null; } catch { return null; } } function battlegroupOutputNeedsBootstrap(output) { return /No resources found/i.test(output || ''); } async function cleanOrphanBattlegroupNamespaces(ip) { const bgCount = (await ssh.run(ip, "sudo kubectl get battlegroups -A --no-headers 2>/dev/null | wc -l", null, { timeout: 15000 })).trim(); if (parseInt(bgCount, 10) > 0) return false; log('No battlegroup CR found — removing empty seabass namespace(s)...\n'); await ssh.run(ip, [ "for ns in $(sudo kubectl get ns -o jsonpath='{.items[*].metadata.name}' 2>/dev/null | tr ' ' '\\n' | grep '^funcom-seabass-'); do", ' sudo kubectl delete ns "$ns" --wait=false 2>/dev/null || true', 'done', 'echo CLEAN_OK', ].join(' '), log, { timeout: 120000 }); await new Promise((r) => setTimeout(r, 8000)); return true; } async function runBootstrapSetup(ip, { token, worldName, region, enableSwap }) { log('Uploading bootstrap files...\n'); const psUpload = ` $scriptDir = '${DEFAULT_SERVER_PATH}\\battlegroup-management' $sshKey = "$env:LOCALAPPDATA\\DuneAwakeningServer\\sshKey" $bootstrapSetup = Join-Path $scriptDir 'bootstrap\\setup' $setupText = (Get-Content $bootstrapSetup -Raw) -replace "\`r\`n", "\`n" $b64Setup = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($setupText)) $uploadScript = @" #!/bin/sh set -e echo $b64Setup | base64 -d | sudo -n tee /home/dune/.dune/bin/setup > /dev/null sudo -n chmod +x /home/dune/.dune/bin/setup echo UPLOAD_OK "@ $uploadScript = $uploadScript -replace "\`r\`n", "\`n" $b64Upload = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($uploadScript)) $uploadCmd = "echo $b64Upload | base64 -d | sh" $out = & ssh -o StrictHostKeyChecking=no -o LogLevel=QUIET -i "$sshKey" "dune@${ip}" $uploadCmd 2>&1 $out | Out-String `; const uploadOut = await ps.run(psUpload, log); if (!uploadOut.includes('UPLOAD_OK')) { throw new Error('Bootstrap upload failed'); } const stdinLines = [ worldName || 'Dune Server', region || '3', token || '', ].join('\n') + '\n'; log('\nRunning first-time battlegroup setup (this takes a while)...\n'); await ssh.run(ip, '/home/dune/.dune/bin/setup 2>&1', log, { timeout: 900000, stdin: stdinLines, }); log('\nBattlegroup setup complete.\n'); if (enableSwap) { log('\nEnabling experimental swap memory...\n'); await ssh.run( ip, 'echo yes | /home/dune/.dune/bin/battlegroup enable-experimental-swap 2>&1', log, { timeout: 600000 } ); log('Swap memory enabled.\n'); } } // --------------------------------------------------------------------------- // REST API // --------------------------------------------------------------------------- // --- Status (with in-flight guard to prevent stacking from fast polls) --- let statusInFlight = false; let lastStatusResult = null; app.get('/api/status', async (_req, res) => { if (statusInFlight && lastStatusResult) return res.json(lastStatusResult); statusInFlight = true; try { const vm = await getVmStatus(); let bg = null; let directorPort = null; if (vm.exists && vm.state === 'Running' && vm.ip) { try { const raw = await ssh.run(vm.ip, '/home/dune/.dune/bin/battlegroup status 2>&1', null, { timeout: 10000 }); const gameServersSection = raw.split(/Game Servers/i)[1] || ''; const hasRunningServers = /\bRunning\b/i.test(gameServersSection); bg = { running: hasRunningServers, output: raw, needsBootstrap: battlegroupOutputNeedsBootstrap(raw), }; } catch (e) { const out = e.stdout || e.message; bg = { running: false, output: out, needsBootstrap: battlegroupOutputNeedsBootstrap(out), }; } directorPort = await getDirectorPort(vm.ip); syncSettingsConfIp(vm.ip); } lastStatusResult = { vm, battlegroup: bg, ssh: { keyPresent: dunePaths.sshKeyExists(), keyPath: dunePaths.sshKeyExists() ? dunePaths.getKeyPath() : null, wsl: dunePaths.isWsl(), }, links: vm.ip ? { fileBrowser: `http://${vm.ip}:18888/`, director: directorPort ? `http://${vm.ip}:${directorPort}/` : null, } : null, }; res.json(lastStatusResult); } catch (e) { if (lastStatusResult) return res.json(lastStatusResult); res.status(500).json({ error: e.message }); } finally { statusInFlight = false; } }); // --- VM controls --- app.post('/api/vm/start', async (req, res) => { const memoryGB = req.body && req.body.memoryGB ? parseInt(req.body.memoryGB, 10) : null; try { const result = await vmCtl.startVm({ memoryGB: Number.isFinite(memoryGB) ? memoryGB : null, log, autoStepDown: !memoryGB, }); if (result.ip) await syncSettingsConfIp(result.ip); cachedVmStatus = null; res.json({ success: true, ip: result.ip, memoryGB: result.memoryGB, }); } catch (e) { const message = vmCtl.shortVmError(e.message); log(`Error: ${message}\n`); cachedVmStatus = null; res.status(e.code === 'OUT_OF_MEMORY' ? 507 : 500).json({ success: false, error: message, code: e.code || 'START_FAILED', startFailed: true, attemptedGB: e.attemptedGB || null, }); } }); app.post('/api/vm/stop', async (_req, res) => { try { log('Stopping VM...\n'); await ps.run(`Stop-VM -Name '${VM_NAME}' -Force`, log); log('VM stopped.\n'); cachedVmStatus = null; res.json({ success: true }); } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // --- VM settings --- app.post('/api/vm/password', async (req, res) => { const { password } = req.body; if (!password) return res.status(400).json({ error: 'Password required' }); const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const b64 = Buffer.from(`dune:${password}\n`).toString('base64'); const out = await ssh.run(ip, `echo ${b64} | base64 -d | sudo -n chpasswd && echo PWOK`); if (out.includes('PWOK')) { log('Password changed successfully.\n'); res.json({ success: true }); } else { throw new Error('Unexpected output'); } } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); app.post('/api/vm/rotate-key', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { log('Rotating SSH key...\n'); const psCmd = ` $scriptDir = '${DEFAULT_SERVER_PATH}\\battlegroup-management' . "$scriptDir\\vm-utilities.ps1" Update-SshKey -Ip '${ip}' `; await ps.run(psCmd, log); log('SSH key rotated.\n'); res.json({ success: true }); } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // --- Battlegroup commands --- async function fixImageTagsIfNeeded(ip) { try { const ns = (await ssh.run(ip, "sudo kubectl get battlegroups -A --no-headers -o custom-columns=':metadata.namespace' 2>/dev/null | head -1", null, { timeout: 15000 })).trim(); const bgName = (await ssh.run(ip, "sudo kubectl get battlegroups -A --no-headers -o custom-columns=':metadata.name' 2>/dev/null | head -1", null, { timeout: 15000 })).trim(); if (!ns || !bgName) return; const raw = await ssh.run(ip, `sudo kubectl get battlegroup ${bgName} -n ${ns} -o json 2>/dev/null`, null, { timeout: 30000 }); const bg = JSON.parse(raw); const serverImage = bg.spec?.serverGroup?.template?.spec?.sets?.[0]?.image || ''; if (!/:0-0-shipping$/.test(serverImage)) return; log('Detected placeholder image tags (0-0-shipping). Looking up correct version...\n'); const imgLine = (await ssh.run(ip, "sudo crictl images 2>/dev/null | grep 'seabass-server ' | head -1", null, { timeout: 15000 })).trim(); const parts = imgLine.split(/\s+/); const correctTag = parts[1]; if (!correctTag || correctTag === '0-0-shipping') { log('Could not determine correct image tag from local images.\n'); return; } log(`Patching image tags from 0-0-shipping to ${correctTag}...\n`); await ssh.run(ip, `sudo kubectl get battlegroup ${bgName} -n ${ns} -o json 2>/dev/null | ` + `sed 's|:0-0-shipping|:${correctTag}|g' | ` + `sudo kubectl apply -f - 2>&1`, null, { timeout: 30000 }); log('Image tags corrected.\n'); // Clean up any pods stuck from the bad tags const stuckPods = (await ssh.run(ip, `sudo kubectl get pods -n ${ns} --no-headers 2>/dev/null | grep -E 'ImagePullBackOff|ErrImagePull|Init:ImagePullBackOff' | awk '{print $1}'`, null, { timeout: 15000 })).trim(); if (stuckPods) { const podList = stuckPods.split('\n').filter(Boolean); log(`Cleaning up ${podList.length} stuck pod(s)...\n`); await ssh.run(ip, `sudo kubectl delete pods -n ${ns} ${podList.join(' ')} 2>&1`, null, { timeout: 15000 }); } // Clean up Error pods from failed DB init jobs so the operator can retry const errorPods = (await ssh.run(ip, `sudo kubectl get pods -n ${ns} --no-headers 2>/dev/null | grep -E 'Error' | grep 'db-dbdepl-util' | awk '{print $1}'`, null, { timeout: 15000 })).trim(); if (errorPods) { const podList = errorPods.split('\n').filter(Boolean); log(`Cleaning up ${podList.length} failed DB init pod(s)...\n`); await ssh.run(ip, `sudo kubectl delete pods -n ${ns} ${podList.join(' ')} 2>&1`, null, { timeout: 15000 }); } log('Pre-start cleanup complete.\n'); } catch (e) { log(`Image tag check warning: ${e.message}\n`); } } function bgRoute(action, label, timeoutMs) { app.post(`/api/bg/${action}`, async (_req, res) => { const ip = await getVmIp(); if (!ip) { log(`Cannot ${action}: VM is not running.\n`); return res.status(400).json({ error: 'VM not running' }); } if (!dunePaths.sshKeyExists()) { const msg = `SSH key not found at ${dunePaths.getKeyPath()}. Use Settings → Rotate SSH Key.`; log(`Cannot ${action}: ${msg}\n`); return res.status(500).json({ success: false, error: msg, code: 'SSH_KEY_MISSING' }); } try { if (action === 'start' || action === 'restart') { // Fix placeholder image tags before starting await fixImageTagsIfNeeded(ip); // Re-apply the visibility IP so the gateway registers GameRmqAddress on startup try { const currentIp = await readSettingsConfIp(ip); if (currentIp && /^\d+\.\d+\.\d+\.\d+$/.test(currentIp)) { await writeSettingsConfIp(ip, currentIp); log(`Confirmed visibility IP: ${currentIp}\n`); if (currentIp !== ip) { log(`WAN mode: ensure TCP ${PORT_FORWARD_INFO.rmqTcp}, Director NodePort, and UDP ${PORT_FORWARD_INFO.gameUdpStart}-${PORT_FORWARD_INFO.gameUdpEnd} are forwarded to VM ${ip}\n`); } } } catch { /* non-critical */ } } log(`${label}...\n`); const out = await ssh.run( ip, `/home/dune/.dune/bin/battlegroup ${action} 2>&1`, log, { timeout: timeoutMs || 300000 } ); log(`\n${label} complete.\n`); res.json({ success: true, output: out }); } catch (e) { const hint = /timed out/i.test(e.message) ? `\nThe ${action} command timed out. The battlegroup may still be processing — check status in a minute.\n` : /ECONNREFUSED|connect/i.test(e.message) ? `\nCould not connect to the VM at ${ip}. Make sure the VM is running and SSH is reachable.\n` : /permission|denied/i.test(e.message) ? `\nSSH authentication failed. The VM may need its SSH key reconfigured.\n` : ''; log(`Error: ${e.message}${hint}\n`); if (e.stdout) log(`Output before error:\n${e.stdout}\n`); res.status(500).json({ success: false, error: e.message }); } }); } bgRoute('start', 'Starting battlegroup', 600000); bgRoute('stop', 'Stopping battlegroup'); bgRoute('restart', 'Restarting battlegroup', 600000); bgRoute('update', 'Updating battlegroup', 600000); bgRoute('backup', 'Creating database backup', 600000); bgRoute('import', 'Importing database backup', 600000); bgRoute('enable-experimental-swap', 'Enabling swap memory', 600000); // --- Logs --- app.post('/api/logs/export', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { log('Exporting battlegroup logs...\n'); const out = await ssh.run(ip, '/home/dune/.dune/bin/battlegroup logs-export 2>&1', log, { timeout: 300000 }); log('\nLog export complete.\n'); res.json({ success: true, output: out }); } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); app.post('/api/logs/operators', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { log('Exporting operator logs...\n'); const out = await ssh.run(ip, '/home/dune/.dune/bin/battlegroup operator-logs-export 2>&1', log, { timeout: 300000 }); log('\nOperator log export complete.\n'); res.json({ success: true, output: out }); } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // --------------------------------------------------------------------------- // Setup wizard // --------------------------------------------------------------------------- // Step 1 — Pre-flight: check Hyper-V, existing VM, available drives app.get('/api/setup/preflight', async (_req, res) => { try { const out = await ps.run(` $result = @{ hyperv = $false; vmExists = $false; vmState = $null; drives = @() } if (Get-Module -ListAvailable -Name Hyper-V) { $svc = Get-Service -Name vmms -ErrorAction SilentlyContinue if ($svc -and $svc.Status -eq 'Running') { $result.hyperv = $true } } $vm = Get-VM -Name '${VM_NAME}' -ErrorAction SilentlyContinue if ($vm) { $result.vmExists = $true $result.vmState = $vm.State.ToString() } $result.drives = @(Get-PSDrive -PSProvider FileSystem | Where-Object { $_.Free -gt 100GB } | ForEach-Object { @{ name = $_.Name; freeGB = [math]::Round($_.Free / 1GB, 1) } }) $vmcx = Get-Item '${DEFAULT_SERVER_PATH}\\Virtual Machines\\*.vmcx' -ErrorAction SilentlyContinue | Select-Object -First 1 $result.vmcxFound = [bool]$vmcx $nics = @(Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -notmatch 'Hyper-V|Virtual' } | ForEach-Object { @{ name = $_.Name; desc = $_.InterfaceDescription } }) $result.nics = $nics $result | ConvertTo-Json -Depth 3 -Compress `); res.json(JSON.parse(out)); } catch (e) { res.status(500).json({ error: e.message }); } }); // Reset — remove VM, install folders, SSH keys (fresh setup) app.post('/api/setup/reset', async (_req, res) => { const fs = require('fs'); const os = require('os'); try { log('=== Resetting Dune server installation ===\n'); const vm = await getVmStatus(); if (vm.exists && vm.state === 'Running' && vm.ip) { log('Stopping battlegroup (if running)...\n'); try { await ssh.run(vm.ip, '/home/dune/.dune/bin/battlegroup stop 2>&1', log, { timeout: 180000 }); log('Battlegroup stop sent.\n'); } catch (e) { log(`Battlegroup stop skipped: ${e.message}\n`); } } log('Removing Hyper-V VM and install folders...\n'); const psOut = await ps.run(` $removedVm = $false $vm = Get-VM -Name '${VM_NAME}' -ErrorAction SilentlyContinue if ($vm) { if ($vm.State -eq 'Running') { Stop-VM -Name '${VM_NAME}' -TurnOff -Force } Remove-VM -Name '${VM_NAME}' -Force $removedVm = $true Write-Output 'Removed VM dune-awakening.' } $cleared = @() Get-PSDrive -PSProvider FileSystem | ForEach-Object { $dest = "$($_.Name):\\DuneAwakeningServer" if (Test-Path $dest) { Remove-Item $dest -Recurse -Force -ErrorAction SilentlyContinue if (-not (Test-Path $dest)) { $cleared += $dest } } } if ($cleared.Count -gt 0) { Write-Output ("Cleared: " + ($cleared -join ', ')) } $sw = Get-VMSwitch -Name 'DuneAwakeningServerSwitch' -ErrorAction SilentlyContinue if ($sw) { $used = @(Get-VMNetworkAdapter -All | Where-Object { $_.SwitchName -eq 'DuneAwakeningServerSwitch' }) if ($used.Count -eq 0) { Remove-VMSwitch -Name 'DuneAwakeningServerSwitch' -Force -ErrorAction SilentlyContinue Write-Output 'Removed unused DuneAwakeningServerSwitch.' } } @{ removedVm = $removedVm; vmExists = [bool](Get-VM -Name '${VM_NAME}' -ErrorAction SilentlyContinue) } | ConvertTo-Json -Compress `, log); let resetMeta = {}; try { resetMeta = JSON.parse(psOut.trim().split('\n').pop()); } catch { /* ignore */ } const keyDir = dunePaths.getDuneKeyDir(); if (fs.existsSync(keyDir)) { fs.rmSync(keyDir, { recursive: true, force: true }); log(`Removed SSH keys at ${keyDir}\n`); } dunePaths.removeWslKeyMirror(); visibilityManuallySet = false; lastKnownVmIp = null; cachedVmStatus = null; lastStatusResult = null; log('Reset complete. Run the setup wizard from step 1.\n'); res.json({ success: true, removedVm: !!resetMeta.removedVm, vmExists: !!resetMeta.vmExists, }); } catch (e) { log(`Reset error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // Step 2 — Import VM: remove old if needed, import, configure network+memory, start app.post('/api/setup/import', async (req, res) => { const { drive, memoryGB, networkMode, nicName } = req.body; if (!drive || !memoryGB) return res.status(400).json({ error: 'drive and memoryGB required' }); const dest = `${drive}:\\DuneAwakeningServer`; const memBytes = memoryGB * 1073741824; // 1GB in bytes const switchMode = networkMode === 'default' ? 'default' : 'external'; try { log('=== Starting VM import ===\n'); // Remove existing VM if present log('Checking for existing VM...\n'); await ps.run(` $vm = Get-VM -Name '${VM_NAME}' -ErrorAction SilentlyContinue if ($vm) { if ($vm.State -eq 'Running') { Stop-VM -Name '${VM_NAME}' -TurnOff -Force } Remove-VM -Name '${VM_NAME}' -Force Write-Output 'Removed existing VM.' } if (Test-Path '${dest}') { Remove-Item '${dest}' -Recurse -Force -ErrorAction SilentlyContinue Write-Output 'Cleared destination folder.' } `, log); // Import log('\nImporting VM (this may take a few minutes)...\n'); await ps.run(` $vmcx = Get-Item '${DEFAULT_SERVER_PATH}\\Virtual Machines\\*.vmcx' -ErrorAction Stop | Select-Object -First 1 $compat = Compare-VM -Path $vmcx.FullName -Copy -VirtualMachinePath '${dest}' -VhdDestinationPath '${dest}\\Virtual Hard Disks' -ErrorAction Stop Import-VM -CompatibilityReport $compat -ErrorAction Stop | Out-Null Write-Output 'VM imported.' `, log); // Network switch log('\nConfiguring network...\n'); if (switchMode === 'default') { await ps.run(` Connect-VMNetworkAdapter -VMName '${VM_NAME}' -SwitchName 'Default Switch' -ErrorAction Stop Write-Output 'Connected to Default Switch.' `, log); } else { const nicArg = nicName ? nicName.replace(/'/g, "''") : ''; await ps.run(` $nicName = '${nicArg}' if (-not $nicName) { $nic = Get-NetAdapter | Where-Object { $_.Status -eq 'Up' -and $_.InterfaceDescription -notmatch 'Hyper-V|Virtual' } | Select-Object -First 1 $nicName = $nic.Name } $existing = Get-VMSwitch -SwitchType External -ErrorAction SilentlyContinue | Where-Object { $_.NetAdapterInterfaceDescription -eq (Get-NetAdapter -Name $nicName).InterfaceDescription } if ($existing) { $switchName = $existing.Name } else { $switchName = 'DuneAwakeningServerSwitch' New-VMSwitch -Name $switchName -NetAdapterName $nicName -AllowManagementOS $true -ErrorAction Stop | Out-Null Write-Output "Created external switch '$switchName'." } Connect-VMNetworkAdapter -VMName '${VM_NAME}' -SwitchName $switchName -ErrorAction Stop Write-Output "Connected to switch '$switchName'." `, log); } // Resize disk log('\nInitializing virtual disk...\n'); await ps.run(` $vhdx = Get-Item '${dest}\\Virtual Hard Disks\\*.vhdx' -ErrorAction SilentlyContinue | Select-Object -First 1 if ($vhdx) { Resize-VHD -Path $vhdx.FullName -SizeBytes 100GB -ErrorAction Stop; Write-Output 'Disk resized to 100GB.' } $boot = Get-VMHardDiskDrive -VMName '${VM_NAME}' | Select-Object -First 1 if ($boot) { Set-VMFirmware -VMName '${VM_NAME}' -FirstBootDevice $boot } `, log); // Memory log('\nSetting memory to ' + memoryGB + 'GB...\n'); await ps.run(` Set-VMMemory -VMName '${VM_NAME}' -StartupBytes ${memBytes} Write-Output 'Memory configured.' `, log); // Start VM (auto step-down if host is low on RAM) log('\nStarting VM...\n'); let ip; try { const result = await vmCtl.startVm({ log, autoStepDown: true }); ip = result.ip; } catch (startErr) { const message = vmCtl.shortVmError(startErr.message); log(`\nVM imported successfully but failed to start: ${message}\n`); log('You can adjust memory below and retry.\n'); cachedVmStatus = null; return res.json({ success: false, imported: true, startFailed: true, error: message }); } if (!ip) { log('Could not detect VM IP after 2 minutes.\n'); return res.status(500).json({ success: false, error: 'VM started but no IP detected' }); } log(`VM ready at ${ip}\n`); cachedVmStatus = null; res.json({ success: true, ip }); } catch (e) { log(`\nError: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // Retry start with different memory (VM already imported) app.post('/api/setup/retry-start', async (req, res) => { const memoryGB = parseInt(req.body && req.body.memoryGB, 10); if (!Number.isFinite(memoryGB) || memoryGB < 1) { return res.status(400).json({ error: 'memoryGB required' }); } try { const result = await vmCtl.startVm({ memoryGB, log, autoStepDown: false }); cachedVmStatus = null; res.json({ success: true, ip: result.ip, memoryGB: result.memoryGB }); } catch (e) { const message = vmCtl.shortVmError(e.message); log(`\nError: ${message}\n`); cachedVmStatus = null; res.status(e.code === 'OUT_OF_MEMORY' ? 507 : 500).json({ success: false, error: message, code: e.code || 'START_FAILED', startFailed: true, attemptedGB: e.attemptedGB || memoryGB, }); } }); // Step 3 — SSH key + password (combined — uses ASKPASS for first-time key install) app.post('/api/setup/security', async (req, res) => { const { ip, currentPassword, newPassword } = req.body; if (!ip || !newPassword) return res.status(400).json({ error: 'ip and newPassword required' }); const curPw = currentPassword || 'dune'; const fs = require('fs'); const os = require('os'); const { spawn: spawnProc } = require('child_process'); const keyDir = dunePaths.getDuneKeyDir(); const keyPath = dunePaths.getKeyPath(); const tmpDir = os.tmpdir(); const tempKey = path.join(tmpDir, `dunekey-${Date.now()}`); const askpassFile = path.join(tmpDir, `dune_askpass_${Date.now()}.bat`); try { fs.mkdirSync(keyDir, { recursive: true }); // 1. Generate key pair log('Generating SSH key pair...\n'); await new Promise((resolve, reject) => { const kg = spawnProc('ssh-keygen', ['-t', 'ed25519', '-f', tempKey, '-N', '', '-q', '-C', 'dune-server-manager'], { windowsHide: true, stdio: 'ignore', }); kg.on('error', reject); kg.on('close', (code) => code === 0 ? resolve() : reject(new Error(`ssh-keygen exited ${code}`))); }); // 2. Build the remote install command const pubKey = fs.readFileSync(tempKey + '.pub', 'utf8').trim(); const b64Pub = Buffer.from(pubKey + '\n').toString('base64'); const installSh = [ 'mkdir -p $HOME/.ssh', 'chmod 700 $HOME/.ssh', `echo ${b64Pub} | base64 -d > $HOME/.ssh/authorized_keys`, 'chmod 600 $HOME/.ssh/authorized_keys', 'echo KEY_INSTALLED', ].join(' && '); // 3. Create askpass script that echoes the current password fs.writeFileSync(askpassFile, `@echo ${curPw}`); // 4. SSH with ASKPASS to install the public key log('Installing SSH key on VM (using current password)...\n'); await new Promise((resolve, reject) => { const sshProc = spawnProc('ssh', [ '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=QUIET', '-o', 'PubkeyAuthentication=no', '-o', 'PreferredAuthentications=keyboard-interactive,password', '-o', 'ConnectTimeout=15', `dune@${ip}`, installSh, ], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, SSH_ASKPASS: askpassFile, SSH_ASKPASS_REQUIRE: 'force', DISPLAY: 'dummy', }, }); let out = ''; sshProc.stdout.on('data', (d) => { out += d.toString(); log(d.toString()); }); sshProc.stderr.on('data', (d) => { out += d.toString(); log(d.toString()); }); const timer = setTimeout(() => { sshProc.kill(); reject(new Error('SSH key install timed out')); }, 60000); sshProc.on('error', (e) => { clearTimeout(timer); reject(e); }); sshProc.on('close', (code) => { clearTimeout(timer); if (out.includes('KEY_INSTALLED')) resolve(); else reject(new Error(`Key install failed (exit ${code}): ${out.slice(-200)}`)); }); }); // Cleanup askpass try { fs.unlinkSync(askpassFile); } catch {} // 5. Verify the new key works log('Verifying key...\n'); await new Promise((resolve, reject) => { const v = spawnProc('ssh', [ '-o', 'StrictHostKeyChecking=no', '-o', 'LogLevel=QUIET', '-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', '-i', tempKey, `dune@${ip}`, 'echo VERIFY_OK', ], { windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] }); let out = ''; v.stdout.on('data', (d) => { out += d.toString(); }); v.on('close', () => out.includes('VERIFY_OK') ? resolve() : reject(new Error('Key verification failed'))); v.on('error', reject); }); // 6. Move key into place try { fs.unlinkSync(keyPath); } catch {} try { fs.unlinkSync(keyPath + '.pub'); } catch {} fs.renameSync(tempKey, keyPath); fs.renameSync(tempKey + '.pub', keyPath + '.pub'); log('SSH key installed.\n'); // 7. Change password using the new key log('Changing password...\n'); const b64Pw = Buffer.from(`dune:${newPassword}\n`).toString('base64'); const pwOut = await ssh.run(ip, `echo ${b64Pw} | base64 -d | sudo -n chpasswd && echo PWOK`, null, { timeout: 30000 }); if (!pwOut.includes('PWOK')) throw new Error('Password change failed'); log('Password changed.\n'); res.json({ success: true }); } catch (e) { log(`Error: ${e.message}\n`); try { fs.unlinkSync(askpassFile); } catch {} try { fs.unlinkSync(tempKey); } catch {} try { fs.unlinkSync(tempKey + '.pub'); } catch {} res.status(500).json({ success: false, error: e.message }); } }); // Step 5 — Detect public IP from VM app.post('/api/setup/detect-ip', async (req, res) => { const { ip } = req.body; if (!ip) return res.status(400).json({ error: 'ip required' }); let publicIp = null; for (const method of [ () => ssh.run(ip, "wget -qO- --timeout=5 'https://api.ipify.org' 2>/dev/null"), () => ssh.run(ip, 'curl -s --max-time 5 https://api.ipify.org 2>/dev/null'), () => ps.run("(Invoke-WebRequest -Uri 'https://api.ipify.org' -UseBasicParsing -TimeoutSec 5).Content"), ]) { try { const out = await method(); if (out && /^\d+\.\d+\.\d+\.\d+$/.test(out.trim())) { publicIp = out.trim(); break; } } catch { /* try next */ } } res.json({ privateIp: ip, publicIp }); }); // Step 6 — Configure networking (DHCP or static) + set player IP + write token app.post('/api/setup/network', async (req, res) => { const { ip, mode, staticIp, staticCidr, staticGw, staticDns, playerIp, token } = req.body; if (!ip) return res.status(400).json({ error: 'ip required' }); let finalIp = ip; try { if (mode === 'static') { log('Applying static network config...\n'); const iface = 'eth0'; const cidr = staticCidr || '/24'; const gw = staticGw; const dns = staticDns || '1.1.1.1'; const ifContent = `auto lo\\niface lo inet loopback\\n\\nauto ${iface}\\niface ${iface} inet static\\n address ${staticIp}${cidr}\\n gateway ${gw}\\n`; const resolvContent = `nameserver ${dns}\\n`; const b64If = Buffer.from(ifContent.replace(/\\n/g, '\n')).toString('base64'); const b64Resolv = Buffer.from(resolvContent.replace(/\\n/g, '\n')).toString('base64'); const script = [ `echo ${b64If} | base64 -d | sudo -n tee /etc/network/interfaces > /dev/null`, `echo ${b64Resolv} | base64 -d | sudo -n tee /etc/resolv.conf > /dev/null`, `echo APPLY_OK`, `nohup sudo -n sh -c 'sleep 2; rc-service networking restart' /dev/null 2>&1 &`, ].join(' && '); const out = await ssh.run(ip, script); if (!out.includes('APPLY_OK')) throw new Error('Failed to apply static config'); log('Waiting for VM on new IP...\n'); await new Promise((r) => setTimeout(r, 6000)); let reachable = false; for (let i = 0; i < 30 && !reachable; i++) { try { await ssh.run(staticIp, 'true'); reachable = true; } catch { await new Promise((r) => setTimeout(r, 2000)); } } if (!reachable) throw new Error(`VM not reachable on ${staticIp}`); finalIp = staticIp; log(`VM now at ${finalIp}\n`); } // Write player IP to VM settings const pIp = playerIp || finalIp; log(`Setting player-facing IP to ${pIp}...\n`); await writeSettingsConfIp(finalIp, pIp); log('Player IP configured.\n'); res.json({ success: true, vmIp: finalIp }); } catch (e) { log(`Error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // Step 7 — Upload bootstrap + run first-time setup on VM app.post('/api/setup/bootstrap', async (req, res) => { const { ip, enableSwap, token, worldName, region } = req.body; if (!ip) return res.status(400).json({ error: 'ip required' }); try { await runBootstrapSetup(ip, { token, worldName, region, enableSwap }); cachedVmStatus = null; lastStatusResult = null; res.json({ success: true }); } catch (e) { log(`\nError: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // Repair incomplete install (empty namespace / no battlegroup CR) app.post('/api/setup/repair', async (req, res) => { const { token, worldName, region, enableSwap } = req.body; if (!token) return res.status(400).json({ error: 'token required' }); const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { log('=== Repairing incomplete battlegroup setup ===\n'); await cleanOrphanBattlegroupNamespaces(ip); await runBootstrapSetup(ip, { token, worldName, region, enableSwap }); cachedVmStatus = null; lastStatusResult = null; res.json({ success: true }); } catch (e) { log(`\nRepair error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // --------------------------------------------------------------------------- // Game config (UserGame.ini + UserEngine.ini) // --------------------------------------------------------------------------- const CONFIG_PATHS = { game: '/home/dune/.dune/download/scripts/setup/config/UserGame.ini', engine: '/home/dune/.dune/download/scripts/setup/config/UserEngine.ini', }; function parseIni(raw) { const result = {}; for (const line of raw.split('\n')) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('[')) continue; // Parse both active and commented-out key=value lines let active = true; let content = trimmed; if (trimmed.startsWith(';')) { // Only parse as a commented key=value if it looks like one (no spaces before =) const rest = trimmed.slice(1).trim(); if (!/^[A-Za-z]/.test(rest)) continue; // pure comment const eq = rest.indexOf('='); if (eq === -1) continue; active = false; content = rest; } const eq = content.indexOf('='); if (eq === -1) continue; const key = content.slice(0, eq).trim(); if (active) { result[key] = content.slice(eq + 1).trim(); } else if (!(key in result)) { // Commented-out values shown as empty so UI knows the key exists but is off result[key] = ''; } } return result; } function applyToIni(raw, updates) { const lines = raw.split('\n'); const applied = new Set(); const quotedKeys = new Set(['Bgd.ServerDisplayName', 'Bgd.ServerLoginPassword']); const result = lines.map((line) => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('[')) return line; let content = trimmed; if (trimmed.startsWith(';')) { content = trimmed.slice(1).trim(); if (!/^[A-Za-z]/.test(content)) return line; } const eq = content.indexOf('='); if (eq === -1) return line; const key = content.slice(0, eq).trim(); if (key in updates) { applied.add(key); const val = updates[key]; if (!val && val !== '0' && val !== 0) { // Empty value → comment out the line const defaultVal = content.slice(eq + 1).trim(); return `;${key}=${defaultVal || '""'}`; } // Wrap in quotes if this key expects quoted values const formatted = quotedKeys.has(key) && !String(val).startsWith('"') ? `"${val}"` : String(val); return `${key}=${formatted}`; } return line; }); // Append any keys that weren't found in the file for (const [key, val] of Object.entries(updates)) { if (applied.has(key) || (!val && val !== '0' && val !== 0)) continue; const formatted = quotedKeys.has(key) && !String(val).startsWith('"') ? `"${val}"` : String(val); result.push(`${key}=${formatted}`); } return result.join('\n'); } app.get('/api/config', async (_req, res) => { const vmIp = await getVmIp(); if (!vmIp) return res.status(400).json({ error: 'VM not running' }); try { const [gameRaw, engineRaw] = await Promise.all([ ssh.run(vmIp, `cat ${CONFIG_PATHS.game} 2>/dev/null`, null, { timeout: 15000 }), ssh.run(vmIp, `cat ${CONFIG_PATHS.engine} 2>/dev/null`, null, { timeout: 15000 }), ]); res.json({ game: parseIni(gameRaw), engine: parseIni(engineRaw), rawGame: gameRaw, rawEngine: engineRaw, }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/config', async (req, res) => { const { game, engine } = req.body; const vmIp = await getVmIp(); if (!vmIp) return res.status(400).json({ error: 'VM not running' }); try { // Read current files, apply changes, write back const [gameRaw, engineRaw] = await Promise.all([ ssh.run(vmIp, `cat ${CONFIG_PATHS.game} 2>/dev/null`, null, { timeout: 15000 }), ssh.run(vmIp, `cat ${CONFIG_PATHS.engine} 2>/dev/null`, null, { timeout: 15000 }), ]); if (game && Object.keys(game).length) { const updated = applyToIni(gameRaw, game); const b64 = Buffer.from(updated).toString('base64'); await ssh.run(vmIp, `echo '${b64}' | base64 -d > ${CONFIG_PATHS.game}`, null, { timeout: 15000 }); } if (engine && Object.keys(engine).length) { const updated = applyToIni(engineRaw, engine); const b64 = Buffer.from(updated).toString('base64'); await ssh.run(vmIp, `echo '${b64}' | base64 -d > ${CONFIG_PATHS.engine}`, null, { timeout: 15000 }); } // Deploy INI files to the Kubernetes pods so game servers pick them up log('Deploying settings to battlegroup pods…\n'); const applyOut = await ssh.run(vmIp, '/home/dune/.dune/bin/battlegroup apply-default-usersettings 2>&1', null, { timeout: 30000 }); log(applyOut + '\n'); log('Config saved and deployed. Stop & start the battlegroup to apply changes.\n'); res.json({ success: true }); } catch (e) { log(`Config save error: ${e.message}\n`); res.status(500).json({ success: false, error: e.message }); } }); // --------------------------------------------------------------------------- // Character Editor // --------------------------------------------------------------------------- let dbPodCache = null; let dbPodCacheTime = 0; async function getDbPod(vmIp) { if (dbPodCache && Date.now() - dbPodCacheTime < 120000) return dbPodCache; const raw = await ssh.run(vmIp, "sudo kubectl get pods --all-namespaces --no-headers 2>/dev/null | grep 'db-dbdepl-sts.*Running'", null, { timeout: 15000 } ); const line = raw.trim().split('\n')[0]; if (!line) throw new Error('Database pod not found — is the VM fully booted?'); const parts = line.trim().split(/\s+/); dbPodCache = { ns: parts[0], name: parts[1] }; dbPodCacheTime = Date.now(); return dbPodCache; } async function runPsql(vmIp, sql, opts = {}) { const { ns, name } = await getDbPod(vmIp); const remoteCmd = `sudo kubectl exec -i -n ${ns} ${name} -- psql -U dune -d dune -p 15432 -t -A 2>&1`; // Pipe SQL via SSH stdin — embedding large queries in the command line hits // Windows ENAMETOOLONG (e.g. unlock-all cosmetics with 600+ IDs). return ssh.run(vmIp, remoteCmd, null, { timeout: opts.timeout || 60000, stdin: sql, }); } app.get('/api/characters', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const raw = await runPsql(ip, "SELECT json_agg(row_to_json(t)) FROM (" + "SELECT eps.player_pawn_id as id, decrypt_user_data(eps.encrypted_character_name) as name " + "FROM encrypted_player_state eps " + "WHERE eps.player_pawn_id IS NOT NULL " + "ORDER BY eps.player_pawn_id) t" ); res.json({ characters: JSON.parse(raw.trim()) || [] }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.get('/api/characters/:id', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); if (isNaN(id)) return res.status(400).json({ error: 'Invalid ID' }); try { const propsRaw = await runPsql(ip, `SELECT properties::text FROM actors WHERE id = ${id}`); const gasRaw = await runPsql(ip, `SELECT gas_attributes::text FROM actors WHERE id = ${id}`); const invRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (` + `SELECT id, inventory_type, max_item_count FROM inventories WHERE actor_id = ${id} AND inventory_type IS NOT NULL ORDER BY id) t` ); const itemsRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (` + `SELECT i.id, i.inventory_id, i.template_id, i.stack_size, i.position_index, inv.inventory_type ` + `FROM items i JOIN inventories inv ON i.inventory_id = inv.id ` + `WHERE inv.actor_id = ${id} ORDER BY inv.inventory_type, i.position_index) t` ); res.json({ actorId: id, properties: JSON.parse(propsRaw.trim() || '{}'), gasAttributes: JSON.parse(gasRaw.trim() || '{}'), inventories: JSON.parse(invRaw.trim()) || [], items: JSON.parse(itemsRaw.trim()) || [], }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/characters/:id/stats', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { updates } = req.body; if (!updates || !updates.length) return res.status(400).json({ error: 'No updates' }); try { const propUpdates = updates.filter(u => u.field === 'properties'); const gasUpdates = updates.filter(u => u.field === 'gas_attributes'); if (propUpdates.length) { let expr = 'properties'; for (const u of propUpdates) { const pathStr = '{' + u.path.join(',') + '}'; expr = `jsonb_set(${expr}, '${pathStr}', '${JSON.stringify(u.value)}'::jsonb)`; } await runPsql(ip, `UPDATE actors SET properties = ${expr} WHERE id = ${id}`); } if (gasUpdates.length) { let expr = 'gas_attributes'; for (const u of gasUpdates) { const pathStr = '{' + u.path.join(',') + '}'; expr = `jsonb_set(${expr}, '${pathStr}', '${JSON.stringify(u.value)}'::jsonb)`; } await runPsql(ip, `UPDATE actors SET gas_attributes = ${expr} WHERE id = ${id}`); } log(`Character ${id} stats updated.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/characters/:id/inventory/add', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const actorId = parseInt(req.params.id); const { templateId, stackSize, inventoryId, isEquipment } = req.body; if (!templateId || !stackSize || !inventoryId) { return res.status(400).json({ error: 'templateId, stackSize, and inventoryId required' }); } const safeId = templateId.replace(/'/g, "''"); const stats = isEquipment ? '{"FCustomizationStats": [[], {}], "FItemStackAndDurabilityStats": [[], {}]}' : '{"FItemStackAndDurabilityStats": [[], {"DecayedMaxDurability": 0.0}]}'; try { const posRaw = await runPsql(ip, `SELECT COALESCE(MAX(position_index) + 1, 0) FROM items WHERE inventory_id = ${inventoryId}` ); const nextPos = parseInt(posRaw.trim()) || 0; await runPsql(ip, `INSERT INTO items (inventory_id, template_id, stack_size, position_index, stats) ` + `VALUES (${inventoryId}, '${safeId}', ${parseInt(stackSize)}, ${nextPos}, '${stats}'::jsonb)` ); log(`Added ${stackSize}x ${templateId} to inventory.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Unlock all tech tree recipes app.post('/api/characters/:id/tech/unlock-all', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { const catalogIds = loadTechRecipeCatalogIds(); const raw = await runPsql(ip, `SELECT COALESCE(properties->'TechKnowledgePlayerComponent'->'m_TechKnowledge'->'m_TechKnowledgeData', '[]'::jsonb) ` + `FROM actors WHERE id = ${id}` ); const current = JSON.parse(raw.trim()) || []; const byKey = new Map(); for (const entry of current) { const key = entry?.ItemKey; if (!key) continue; byKey.set(key, { ...entry, ItemKey: key, bIsNewEntry: false, UnlockedState: 'Purchased', }); } for (const itemKey of catalogIds) { if (byKey.has(itemKey)) { const entry = byKey.get(itemKey); entry.UnlockedState = 'Purchased'; entry.bIsNewEntry = false; } else { byKey.set(itemKey, { ItemKey: itemKey, bIsNewEntry: false, UnlockedState: 'Purchased', }); } } const merged = [...byKey.values()].sort((a, b) => String(a.ItemKey).localeCompare(String(b.ItemKey)) ); const payload = JSON.stringify(merged).replace(/'/g, "''"); await runPsql(ip, `UPDATE actors SET properties = jsonb_set(` + `jsonb_set(properties, '{TechKnowledgePlayerComponent,m_TechKnowledgePoints}', '99999'), ` + `'{TechKnowledgePlayerComponent,m_TechKnowledge,m_TechKnowledgeData}', '${payload}'::jsonb` + `) WHERE id = ${id}`, { timeout: 120000 } ); log(`All ${merged.length} tech tree recipes unlocked for character ${id} (+${merged.length - current.length} added).\n`); res.json({ success: true, total: merged.length, added: merged.length - current.length, previous: current.length, catalogTotal: catalogIds.length, }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Lock all tech tree recipes (reset) app.post('/api/characters/:id/tech/lock-all', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { await runPsql(ip, `UPDATE actors SET properties = jsonb_set(` + `properties, '{TechKnowledgePlayerComponent,m_TechKnowledge,m_TechKnowledgeData}', ` + `(SELECT jsonb_agg(jsonb_set(elem, '{UnlockedState}', '"NotPurchased"')) ` + `FROM jsonb_array_elements(properties->'TechKnowledgePlayerComponent'->'m_TechKnowledge'->'m_TechKnowledgeData') as elem)` + `) WHERE id = ${id}` ); log(`All tech tree recipes locked for character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Get cosmetics list app.get('/api/characters/:id/cosmetics', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { const raw = await runPsql(ip, `SELECT COALESCE(json_agg(elem->>'m_CustomizationId' ORDER BY elem->>'m_CustomizationId'), '[]') ` + `FROM (SELECT jsonb_array_elements(properties->'CustomizationLibraryActorComponent'` + `->'m_UnlockedCustomizationSerializableList'->'m_UnlockedCustomizationIds') as elem ` + `FROM actors WHERE id = ${id}) sub` ); res.json({ cosmetics: JSON.parse(raw.trim()) || [] }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Add cosmetic app.post('/api/characters/:id/cosmetics/add', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { cosmeticId } = req.body; if (!cosmeticId) return res.status(400).json({ error: 'cosmeticId required' }); const safe = cosmeticId.replace(/[^a-zA-Z0-9_ ]/g, ''); try { await runPsql(ip, `UPDATE actors SET properties = jsonb_set(properties, ` + `'{CustomizationLibraryActorComponent,m_UnlockedCustomizationSerializableList,m_UnlockedCustomizationIds}', ` + `(properties->'CustomizationLibraryActorComponent'->'m_UnlockedCustomizationSerializableList'->'m_UnlockedCustomizationIds') ` + `|| '[{"m_CustomizationId": "${safe.replace(/'/g, "''")}"}]'::jsonb` + `) WHERE id = ${id}` ); log(`Cosmetic "${safe}" added to character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Remove cosmetic app.post('/api/characters/:id/cosmetics/remove', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { cosmeticId } = req.body; if (!cosmeticId) return res.status(400).json({ error: 'cosmeticId required' }); const safe = cosmeticId.replace(/[^a-zA-Z0-9_ ]/g, ''); try { await runPsql(ip, `UPDATE actors SET properties = jsonb_set(properties, ` + `'{CustomizationLibraryActorComponent,m_UnlockedCustomizationSerializableList,m_UnlockedCustomizationIds}', ` + `(SELECT COALESCE(jsonb_agg(elem), '[]'::jsonb) FROM jsonb_array_elements(` + `properties->'CustomizationLibraryActorComponent'->'m_UnlockedCustomizationSerializableList'->'m_UnlockedCustomizationIds'` + `) as elem WHERE elem->>'m_CustomizationId' != '${safe.replace(/'/g, "''")}')` + `) WHERE id = ${id}` ); log(`Cosmetic "${safe}" removed from character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); function loadCosmeticCatalogIds() { const catalogPath = path.join(__dirname, 'public', 'data', 'cosmetic-catalog.json'); const data = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); return Object.entries(data.cosmetics || {}) .filter(([id, info]) => info.unlock !== 'inventory' && !id.startsWith('Swatch_')) .map(([id]) => id) .sort(); } function loadTechRecipeCatalogIds() { const catalogPath = path.join(__dirname, 'public', 'data', 'tech-recipe-catalog.json'); const data = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); return Object.keys(data.recipes || {}).sort(); } function loadTechRecipeCatalogMeta() { const catalogPath = path.join(__dirname, 'public', 'data', 'tech-recipe-catalog.json'); const data = JSON.parse(fs.readFileSync(catalogPath, 'utf8')); return { total: data.total || Object.keys(data.recipes || {}).length }; } // Unlock all cosmetics from catalog (merge with existing) app.post('/api/characters/:id/cosmetics/unlock-all', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { const catalogIds = loadCosmeticCatalogIds(); const raw = await runPsql(ip, `SELECT COALESCE(json_agg(elem->>'m_CustomizationId' ORDER BY elem->>'m_CustomizationId'), '[]') ` + `FROM (SELECT jsonb_array_elements(properties->'CustomizationLibraryActorComponent'` + `->'m_UnlockedCustomizationSerializableList'->'m_UnlockedCustomizationIds') as elem ` + `FROM actors WHERE id = ${id}) sub` ); const current = JSON.parse(raw.trim()) || []; const merged = [...new Set([...current, ...catalogIds])].sort(); const payload = JSON.stringify(merged.map((cid) => ({ m_CustomizationId: cid }))); const escaped = payload.replace(/'/g, "''"); await runPsql(ip, `UPDATE actors SET properties = jsonb_set(properties, ` + `'{CustomizationLibraryActorComponent,m_UnlockedCustomizationSerializableList,m_UnlockedCustomizationIds}', ` + `'${escaped}'::jsonb` + `) WHERE id = ${id}`, { timeout: 120000 } ); log(`All ${merged.length} cosmetics unlocked for character ${id}.\n`); res.json({ success: true, total: merged.length, added: merged.length - current.length }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Get specialization data app.get('/api/characters/:id/specializations', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { const pawnRow = await runPsql(ip, `SELECT player_controller_id FROM encrypted_player_state WHERE player_pawn_id = ${id}` ); const controllerId = parseInt(pawnRow.trim()); const tracksRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (` + `SELECT track_type, xp_amount, level FROM specialization_tracks WHERE player_id = ${id} ORDER BY track_type) t` ); const keystonesRaw = await runPsql(ip, `SELECT COALESCE(json_agg(km.name ORDER BY km.id), '[]') FROM purchased_specialization_keystones pk ` + `JOIN specialization_keystones_map km ON pk.keystone_id = km.id WHERE pk.player_id = ${id}` ); const allKeystonesRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t) ORDER BY t.id), '[]') FROM (SELECT id, name FROM specialization_keystones_map ORDER BY id) t` ); res.json({ controllerId, tracks: JSON.parse(tracksRaw.trim()) || [], purchasedKeystones: JSON.parse(keystonesRaw.trim()) || [], allKeystones: JSON.parse(allKeystonesRaw.trim()) || [], }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Set specialization track app.post('/api/characters/:id/specializations/track', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { trackType, xp, level } = req.body; const validTracks = ['Combat', 'Crafting', 'Gathering', 'Exploration', 'Sabotage']; if (!validTracks.includes(trackType)) return res.status(400).json({ error: 'Invalid track type' }); try { await runPsql(ip, `INSERT INTO specialization_tracks (player_id, track_type, xp_amount, level) ` + `VALUES (${id}, '${trackType}', ${parseInt(xp)}, ${parseFloat(level)}) ` + `ON CONFLICT (player_id, track_type) DO UPDATE SET xp_amount = EXCLUDED.xp_amount, level = EXCLUDED.level` ); log(`Specialization ${trackType} set to level ${level} for character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Unlock all keystones for a track app.post('/api/characters/:id/specializations/unlock-keystones', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { trackPrefix } = req.body; const validPrefixes = ['Combat_', 'Crafting_', 'Exploration_', 'Gathering_', 'Sabotage_']; if (!validPrefixes.some(p => trackPrefix === p)) return res.status(400).json({ error: 'Invalid track prefix' }); try { await runPsql(ip, `INSERT INTO purchased_specialization_keystones (player_id, keystone_id) ` + `SELECT ${id}, id FROM specialization_keystones_map WHERE name LIKE '${trackPrefix}%' ` + `ON CONFLICT DO NOTHING` ); log(`All ${trackPrefix.replace('_', '')} keystones unlocked for character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Get currency and faction data app.get('/api/characters/:id/economy', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); try { const pawnRow = await runPsql(ip, `SELECT player_controller_id FROM encrypted_player_state WHERE player_pawn_id = ${id}` ); const controllerId = parseInt(pawnRow.trim()); const currencyRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (` + `SELECT currency_id, balance FROM player_virtual_currency_balances WHERE player_controller_id = ${controllerId} ORDER BY currency_id) t` ); const factionRepRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (` + `SELECT fr.faction_id, f.name as faction_name, fr.reputation_amount ` + `FROM player_faction_reputation fr JOIN factions f ON fr.faction_id = f.id ` + `WHERE fr.actor_id = ${id} ORDER BY fr.faction_id) t` ); const factionsRaw = await runPsql(ip, `SELECT COALESCE(json_agg(row_to_json(t)), '[]') FROM (SELECT id, name FROM factions ORDER BY id) t` ); res.json({ controllerId, currency: JSON.parse(currencyRaw.trim()) || [], factionRep: JSON.parse(factionRepRaw.trim()) || [], factions: JSON.parse(factionsRaw.trim()) || [], }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Set currency app.post('/api/characters/:id/economy/currency', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { currencyId, balance } = req.body; try { const pawnRow = await runPsql(ip, `SELECT player_controller_id FROM encrypted_player_state WHERE player_pawn_id = ${id}` ); const controllerId = parseInt(pawnRow.trim()); await runPsql(ip, `INSERT INTO player_virtual_currency_balances (player_controller_id, currency_id, balance) ` + `VALUES (${controllerId}, ${parseInt(currencyId)}, ${parseInt(balance)}) ` + `ON CONFLICT (player_controller_id, currency_id) DO UPDATE SET balance = EXCLUDED.balance` ); log(`Currency ${currencyId} set to ${balance} for character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // Set faction reputation app.post('/api/characters/:id/economy/reputation', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const id = parseInt(req.params.id); const { factionId, amount } = req.body; try { await runPsql(ip, `INSERT INTO player_faction_reputation (actor_id, faction_id, reputation_amount) ` + `VALUES (${id}, ${parseInt(factionId)}, ${parseInt(amount)}) ` + `ON CONFLICT (actor_id, faction_id) DO UPDATE SET reputation_amount = EXCLUDED.reputation_amount` ); log(`Faction ${factionId} reputation set to ${amount} for character ${id}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.delete('/api/characters/:id/inventory/:itemId', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const itemId = parseInt(req.params.itemId); try { await runPsql(ip, `DELETE FROM items WHERE id = ${itemId}`); log(`Removed item ${itemId}.\n`); res.json({ success: true }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --------------------------------------------------------------------------- // Server Visibility (LAN / Public) // --------------------------------------------------------------------------- app.get('/api/server-visibility', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const advertisedIp = await readSettingsConfIp(ip); const directorPort = await getDirectorPort(ip); // Detect public IP — try VM first, fall back to Windows host let publicIp = null; for (const method of [ () => ssh.run(ip, 'curl -s --max-time 5 https://api.ipify.org 2>/dev/null', null, { timeout: 10000 }), () => ssh.run(ip, "wget -qO- --timeout=5 'https://api.ipify.org' 2>/dev/null", null, { timeout: 10000 }), () => ps.run("(Invoke-WebRequest -Uri 'https://api.ipify.org' -UseBasicParsing -TimeoutSec 5).Content"), ]) { try { const out = await method(); if (out && /^\d+\.\d+\.\d+\.\d+$/.test(out.trim())) { publicIp = out.trim(); break; } } catch { /* try next */ } } const isWan = advertisedIp && advertisedIp !== ip; res.json({ advertisedIp, vmIp: ip, publicIp, directorPort, isWan, portForward: { targetIp: ip, ...PORT_FORWARD_INFO, directorTcp: directorPort, }, }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/server-visibility', async (req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); const { advertisedIp } = req.body; if (!advertisedIp) return res.status(400).json({ error: 'advertisedIp required' }); try { await writeSettingsConfIp(ip, advertisedIp.trim()); visibilityManuallySet = true; const isWan = advertisedIp.trim() !== ip; log(`Server visibility IP set to ${advertisedIp}.\n`); if (isWan) { log(`WAN: forward TCP ${PORT_FORWARD_INFO.rmqTcp}, Director NodePort, and UDP ${PORT_FORWARD_INFO.gameUdpStart}-${PORT_FORWARD_INFO.gameUdpEnd} to VM ${ip}, then stop and start the battlegroup.\n`); } else { log('LAN mode: players on your local network can join. Stop and start the battlegroup to apply.\n'); } log('Self-hosted worlds appear in-game under Servers → Experimental (not Official or Private).\n'); res.json({ success: true, isWan, requiresBattlegroupRestart: true, message: 'Stop the battlegroup completely, then start it again for the gateway to register the new address with Funcom.', }); } catch (e) { res.status(500).json({ error: e.message }); } }); // --------------------------------------------------------------------------- // Experimental: Multi-Sietch Management // --------------------------------------------------------------------------- async function getBattlegroupJson(ip) { const ns = await ssh.run(ip, "sudo kubectl get battlegroups -A --no-headers -o custom-columns=':metadata.namespace' 2>/dev/null | head -1", null, { timeout: 15000 }); const name = await ssh.run(ip, "sudo kubectl get battlegroups -A --no-headers -o custom-columns=':metadata.name' 2>/dev/null | head -1", null, { timeout: 15000 }); if (!ns || !name) throw new Error('Battlegroup not found'); const raw = await ssh.run(ip, `sudo kubectl get battlegroups -n ${ns.trim()} ${name.trim()} -o json 2>/dev/null`, null, { timeout: 30000 }); return { ns: ns.trim(), name: name.trim(), bg: JSON.parse(raw) }; } app.get('/api/sietches', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const { bg } = await getBattlegroupJson(ip); const sets = bg.spec.serverGroup.template.spec.sets; const worldPartitions = bg.spec.database.template.spec.deployment.spec.worldPartitions; const survivalSets = sets .map((s, i) => ({ index: i, map: s.map, partitions: s.partitions, replicas: s.replicas, memory: s.resources?.limits?.memory || '?', dedicatedScaling: s.dedicatedScaling })) .filter(s => s.map === 'Survival_1' && !s.dedicatedScaling); const maxPartitionId = Math.max(...worldPartitions.flatMap(w => w.partitions.map(p => p.id))); res.json({ sietches: survivalSets, sietchCount: survivalSets.length, maxPartitionId, totalSets: sets.length, totalWorldPartitions: worldPartitions.length, }); } catch (e) { res.status(500).json({ error: e.message }); } }); app.post('/api/sietches/add', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const { ns, name, bg } = await getBattlegroupJson(ip); const sets = bg.spec.serverGroup.template.spec.sets; const worldPartitions = bg.spec.database.template.spec.deployment.spec.worldPartitions; const grid = (bg.metadata.annotations.grid || '').split(','); const survivalSets = sets.filter(s => s.map === 'Survival_1' && !s.dedicatedScaling); const currentCount = survivalSets.length; const maxPartitionId = Math.max(...worldPartitions.flatMap(w => w.partitions.map(p => p.id))); const newPartitionId = maxPartitionId + 1; const newSietchNum = currentCount + 1; log(`Adding sietch ${newSietchNum} (partition ${newPartitionId})...\n`); // Clone the first Survival_1 set as template const template = JSON.parse(JSON.stringify(sets.find(s => s.map === 'Survival_1' && !s.dedicatedScaling))); template.partitions = [newPartitionId]; // Build patches const patches = [ { op: 'add', path: '/spec/serverGroup/template/spec/sets/-', value: template }, { op: 'add', path: '/spec/database/template/spec/deployment/spec/worldPartitions/-', value: { map: 'Survival_1', partitions: [{ dimension: 0, disable: false, id: newPartitionId, maxX: 1, maxY: 1, minX: 0, minY: 0 }] }}, { op: 'replace', path: '/metadata/annotations/grid', value: [...grid, '1x1'].join(',') }, ]; const patchJson = JSON.stringify(patches); const b64 = Buffer.from(patchJson).toString('base64'); await ssh.run(ip, `echo '${b64}' | base64 -d | sudo kubectl patch battlegroup ${name} -n ${ns} --type=json -p "$(echo '${b64}' | base64 -d)" 2>&1`, log, { timeout: 30000 }); log(`\nSietch ${newSietchNum} added (partition ${newPartitionId}). Restart the battlegroup to apply.\n`); res.json({ success: true, sietchNumber: newSietchNum, partitionId: newPartitionId }); } catch (e) { log(`Error adding sietch: ${e.message}\n`); res.status(500).json({ error: e.message }); } }); app.post('/api/sietches/remove', async (_req, res) => { const ip = await getVmIp(); if (!ip) return res.status(400).json({ error: 'VM not running' }); try { const { ns, name, bg } = await getBattlegroupJson(ip); const sets = bg.spec.serverGroup.template.spec.sets; const worldPartitions = bg.spec.database.template.spec.deployment.spec.worldPartitions; const grid = (bg.metadata.annotations.grid || '').split(','); // Find all Survival_1 sets (non-dedicatedScaling) const survivalIndices = sets .map((s, i) => ({ ...s, _idx: i })) .filter(s => s.map === 'Survival_1' && !s.dedicatedScaling); if (survivalIndices.length <= 1) { return res.status(400).json({ error: 'Cannot remove the last sietch' }); } const lastSurvival = survivalIndices[survivalIndices.length - 1]; const lastPartitionId = lastSurvival.partitions[0]; // Find matching worldPartition entry const wpIdx = worldPartitions.findIndex(w => w.map === 'Survival_1' && w.partitions.some(p => p.id === lastPartitionId)); log(`Removing sietch ${survivalIndices.length} (partition ${lastPartitionId})...\n`); // Build patches (remove in reverse index order to avoid shifting) const patches = []; patches.push({ op: 'remove', path: `/spec/serverGroup/template/spec/sets/${lastSurvival._idx}` }); if (wpIdx >= 0) { patches.push({ op: 'remove', path: `/spec/database/template/spec/deployment/spec/worldPartitions/${wpIdx}` }); } if (grid.length > 1) { patches.push({ op: 'replace', path: '/metadata/annotations/grid', value: grid.slice(0, -1).join(',') }); } // Sort patches so higher indices are removed first patches.sort((a, b) => (b.path > a.path ? 1 : -1)); const patchJson = JSON.stringify(patches); const b64 = Buffer.from(patchJson).toString('base64'); await ssh.run(ip, `echo '${b64}' | base64 -d | sudo kubectl patch battlegroup ${name} -n ${ns} --type=json -p "$(echo '${b64}' | base64 -d)" 2>&1`, log, { timeout: 30000 }); log(`\nSietch removed. Restart the battlegroup to apply.\n`); res.json({ success: true, removedPartition: lastPartitionId, remainingSietches: survivalIndices.length - 1 }); } catch (e) { log(`Error removing sietch: ${e.message}\n`); res.status(500).json({ error: e.message }); } }); // --- Static UI (after all API routes) --- app.use('/api', (_req, res) => { res.status(404).json({ error: 'API endpoint not found. Stop and restart the Server Manager (start_as_admin.bat) to load updates.', }); }); app.use(express.static(path.join(__dirname, 'public'))); // --- SPA fallback --- app.get('*', (_req, res) => { res.sendFile(path.join(__dirname, 'public', 'index.html')); }); // --------------------------------------------------------------------------- // Start // --------------------------------------------------------------------------- server.listen(PORT, () => { console.log(`Dune Server Manager running at http://localhost:${PORT}`); });