Phase 2 references for the host-agent Dune adapter, moved out of volatile /tmp
into docs/reference-repos/ (per Commander). Three upstream projects, .git +
node_modules + compiled binaries stripped (16MB source). Nested AI-instruction
files (.claude/, CLAUDE.md) removed so they don't pollute Corrosion sessions.
- icehunter/ dune-admin (Go+React) — 4 control planes; SETUP_DOCKER.md is the
closest analog to our agent's Dune docker control plane (compose
lifecycle, docker logs, RabbitMQ-via-exec, dune Postgres schema)
- adainrivers/ Rust/Tauri desktop — SSH+k8s BattleGroup control, maintenance
daemon, in-game admin console (Rust idiom reference)
- the4rchangel/ Node web UI replacing battlegroup.bat — matches the Commander's
Hyper-V self-host path + game-config schema
See docs/reference-repos/README.md for the full index + how we use each.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1931 lines
71 KiB
JavaScript
1931 lines
71 KiB
JavaScript
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 >/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}`);
|
|
});
|