1 Commits

Author SHA1 Message Date
jmadmin 119bb443d9 Add Node.js manager service and updated docker-compose 2026-05-10 20:43:48 -03:00
12 changed files with 514 additions and 125 deletions
+277 -125
View File
@@ -1,248 +1,400 @@
# Windrose Dedicated Server — TrueNAS SCALE Deployment Guide Windrose Dedicated Server — TrueNAS SCALE Deployment Guide
A fully working configuration for running the Windrose Dedicated Server (Windows build) inside a TrueNAS SCALE container using Wine + DXVK, with persistent saves, explicit world selection, and fixed hostname resolution for ICE/P2P networking. A fully working configuration for running the Windrose Dedicated Server (Windows build) inside a TrueNAS SCALE container using Wine + DXVK, with persistent saves, explicit world selection, and fixed hostname resolution for ICE/P2P networking.
This README documents the exact setup currently running in production. This README documents the exact setup currently running in production.
---
## Table of Contents
- [Overview](#overview) Table of Contents
- [Directory Layout](#directory-layout)
- [Environment Variables](#environment-variables)
- [Hostname Resolution (ICE/P2P Fix)](#hostname-resolution-icep2p-fix)
- [DXVK Behavior](#dxvk-behavior)
- [Server Launch Command](#server-launch-command)
- [Entrypoint Behavior Summary](#entrypoint-behavior-summary)
- [Networking Requirements](#networking-requirements)
- [Troubleshooting](#troubleshooting)
- [Future Enhancements](#future-enhancements)
--- Overview
## Overview
Directory Layout
Environment Variables
Hostname Resolution (ICE/P2P Fix)
DXVK Behavior
Server Launch Command
Entrypoint Behavior Summary
Networking Requirements
Troubleshooting
Future Enhancements
Overview
This deployment runs: This deployment runs:
- Windrose Dedicated Server (Windows build)
- Inside a TrueNAS SCALE App container
- Using Wine + DXVK Windrose Dedicated Server (Windows build)
- With persistent world saves
- With explicit WORLD_ID selection
- With hostname resolution fixes for ICE/P2P
- With nonfatal DXVK installation fallback Inside a TrueNAS SCALE App container
- With stable LAN/WAN connectivity
Using Wine + DXVK
With persistent world saves
With explicit WORLD\_ID selection
With hostname resolution fixes for ICE/P2P
With nonfatal DXVK installation fallback
With stable LAN/WAN connectivity
This configuration is confirmed working as of: This configuration is confirmed working as of:
**May 10, 2026**
---
## Directory Layout May 10, 2026
### Persistent Storage
Directory Layout
Persistent Storage
Mounted into the container: Mounted into the container:
```
Code
/data/Saved /data/Saved
```
Windrose stores all world data here: Windrose stores all world data here:
```
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD_ID>/
```
### Game Installation Directory
``` Code
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD\_ID>/
Game Installation Directory
Code
/server /server
/server/R5 /server/R5
```
This is where SteamCMD installs the Windrose server files. This is where SteamCMD installs the Windrose server files.
---
## Environment Variables
Environment Variables
Set these in the TrueNAS SCALE App → Environment Variables. Set these in the TrueNAS SCALE App → Environment Variables.
### Required
| Variable | Description |
|----------|-------------|
| WORLD_ID | Folder name of the world to load. Example: `352359B474804C2A4D83D84AADC7579D` |
### Optional Required
| Variable | Default | Description | Variable Description
|--------------|---------------------------|-------------|
| WINEPREFIX | /data/wine/wineprefix | Wine prefix location |
| SAVED_DIR | /data/Saved | Persistent save directory |
--- WORLD\_ID Folder name of the world to load. Example: 352359B474804C2A4D83D84AADC7579D
## Hostname Resolution (ICE/P2P Fix)
Optional
Variable Default Description
WINEPREFIX /data/wine/wineprefix Wine prefix location
SAVED\_DIR /data/Saved Persistent save directory
Hostname Resolution (ICE/P2P Fix)
Windrose internally tries to resolve the hostname: Windrose internally tries to resolve the hostname:
```
Code
windrose windrose
```
Your deployment requires: Your deployment requires:
```
Code
windrose.theminisip.ca windrose.theminisip.ca
```
Both must resolve to the LAN IP of the TrueNAS host: Both must resolve to the LAN IP of the TrueNAS host:
```
Code
192.168.3.41 192.168.3.41
```
The entrypoint injects this automatically: The entrypoint injects this automatically:
```
bash
192.168.3.41 windrose windrose.theminisip.ca 192.168.3.41 windrose windrose.theminisip.ca
```
This prevents ICE errors such as: This prevents ICE errors such as:
```
Code
Cannot resolve addresses for host windrose. Cannot resolve addresses for host windrose.
Error on getting local ICE candidates Error on getting local ICE candidates
P2pClient is broken P2pClient is broken
```
--- DXVK Behavior
## DXVK Behavior
DXVK is required even in headless mode. DXVK is required even in headless mode.
Your container includes: Your container includes:
```
/opt/dxvk/x32/*.dll
/opt/dxvk/x64/*.dll Code
```
/opt/dxvk/x32/\*.dll
/opt/dxvk/x64/\*.dll
The entrypoint: The entrypoint:
- Runs setup-dxvk.sh if present
- Falls back to manual DLL copy
- Logs exit codes Runs setup-dxvk.sh if present
- Never crashes if DXVK is missing or incomplete
Falls back to manual DLL copy
Logs exit codes
Never crashes if DXVK is missing or incomplete
This ensures Wine initializes correctly. This ensures Wine initializes correctly.
---
## Server Launch Command
Server Launch Command
The server is launched with: The server is launched with:
```
wine WindroseServer-Win64-Shipping.exe \
-log \
-unattended \
-nosteam \
-savedir "Saved" \
-saveprofile Default \
-world "$WORLD_ID"
```
### Notes
- `"Saved"` resolves to `/server/R5/Saved` bash
- World data still loads from `/data/Saved` via RocksDB
- `-nosteam` avoids Steam API issues
- `-unattended` prevents UI prompts
--- wine WindroseServer-Win64-Shipping.exe \\
## Entrypoint Behavior Summary &#x20; -log \\
&#x20; -unattended \\
&#x20; -nosteam \\
&#x20; -savedir "Saved" \\
&#x20; -saveprofile Default \\
&#x20; -world "$WORLD\_ID"
Notes:
"Saved" resolves to /server/R5/Saved
World data still loads from /data/Saved via RocksDB
\-nosteam avoids Steam API issues
\-unattended prevents UI prompts
Entrypoint Behavior Summary
The entrypoint: The entrypoint:
1. Initializes Wine prefix
2. Installs DXVK (nonfatal)
3. Injects hostname resolution Initializes Wine prefix
4. Validates WORLD_ID
5. Launches the server
Installs DXVK (nonfatal)
Injects hostname resolution
Validates WORLD\_ID
Launches the server
This is the exact working version currently deployed. This is the exact working version currently deployed.
---
## Networking Requirements
### LAN Networking Requirements
LAN
Works out of the box. Works out of the box.
### WAN
WAN
Forward these ports: Forward these ports:
| Port | Protocol | Purpose |
|------|----------|----------|
| 7777 | UDP | Game traffic | Port Protocol Purpose
| 15000 | UDP | ICE / P2P |
| 27015 | UDP | Query port | 7777 UDP Game traffic
15000 UDP ICE / P2P
27015 UDP Query port
Ensure Cloudflare DNS for: Ensure Cloudflare DNS for:
```
Code
windrose.theminisip.ca windrose.theminisip.ca
```
points to your public IP. points to your public IP.
---
## Troubleshooting
### ICE Errors Troubleshooting
ICE Errors
If you see: If you see:
```
Code
Cannot resolve addresses for host windrose Cannot resolve addresses for host windrose
```
Check: Check:
- /etc/hosts mapping
- DNS for windrose.theminisip.ca
- Correct WORLD_ID
### World Not Loading
/etc/hosts mapping
DNS for windrose.theminisip.ca
Correct WORLD\_ID
World Not Loading
Ensure: Ensure:
```
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD_ID>/
``` Code
/data/Saved/SaveProfiles/Default/RocksDB/0.10.0/Worlds/<WORLD\_ID>/
exists. exists.
### DXVK Errors
DXVK Errors
Nonfatal — server will still run. Nonfatal — server will still run.
---
## Future Enhancements
- Node.js web manager (deployment, config editing, restarts)
- Multiinstance orchestration
- World backups & restores
- Metrics dashboard
- Log streaming
+16
View File
@@ -37,3 +37,19 @@ services:
tty: true tty: true
stdin_open: true stdin_open: true
manager:
build: ./manager
container_name: windrose-manager
environment:
- ADMIN_PASSWORD=changeme
ports:
- "3000:3000"
volumes:
# Manager needs access to real config files
- ./data:/data:rw
# Manager needs access to Docker to restart windrose container
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
+14
View File
@@ -0,0 +1,14 @@
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
ENV PORT=3000
CMD ["npm", "start"]
+21
View File
@@ -0,0 +1,21 @@
{
"name": "windrose-manager",
"version": "1.0.0",
"main": "dist/server.js",
"scripts": {
"dev": "ts-node src/server.ts",
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"dockerode": "^4.0.0",
"express": "^4.19.0",
"yaml": "^2.4.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.6.0"
}
}
+37
View File
@@ -0,0 +1,37 @@
import { Router } from "express";
import { getInstance } from "../core/config";
import { readJson, writeJson, listWorldDescriptions } from "../core/files";
const router = Router();
router.get("/:id/server", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
res.json(readJson(inst.configPaths.serverDescription));
});
router.put("/:id/server", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
writeJson(inst.configPaths.serverDescription, req.body);
res.json({ status: "updated" });
});
router.post("/:id/worlds/update-all", (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
const files = listWorldDescriptions(inst.configPaths.worldsRoot);
for (const file of files) {
const data = readJson(file);
const updated = { ...data, ...req.body };
writeJson(file, updated);
}
res.json({ status: "worlds-updated", count: files.length });
});
export default router;
+23
View File
@@ -0,0 +1,23 @@
import { Router } from "express";
import { getInstance } from "../core/config";
import { restartInstance, getInstanceStatus } from "../core/docker";
const router = Router();
router.post("/:id/restart", async (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
await restartInstance(inst);
res.json({ status: "restarted" });
});
router.get("/:id/status", async (req, res) => {
const inst = getInstance(req.params.id);
if (!inst) return res.status(404).json({ error: "not found" });
const status = await getInstanceStatus(inst);
res.json(status);
});
export default router;
+21
View File
@@ -0,0 +1,21 @@
import { Request, Response, NextFunction } from "express";
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD;
export function requireAuth(req: Request, res: Response, next: NextFunction) {
const header = req.headers.authorization;
if (!header || !header.startsWith("Basic ")) {
res.setHeader("WWW-Authenticate", "Basic");
return res.status(401).send("Authentication required");
}
const decoded = Buffer.from(header.replace("Basic ", ""), "base64").toString();
const [user, pass] = decoded.split(":");
if (pass !== ADMIN_PASSWORD) {
return res.status(403).send("Forbidden");
}
next();
}
+30
View File
@@ -0,0 +1,30 @@
import fs from "fs";
import path from "path";
import YAML from "yaml";
export interface InstanceConfig {
id: string;
containerName: string;
image: string;
savedDir: string;
configPaths: {
serverDescription: string;
worldsRoot: string;
};
}
const CONFIG_PATH = path.join(__dirname, "..", "..", "config", "instances.yml");
let cache: { instances: InstanceConfig[] };
export function loadConfig() {
if (!cache) {
const raw = fs.readFileSync(CONFIG_PATH, "utf8");
cache = YAML.parse(raw);
}
return cache;
}
export function getInstance(id: string) {
return loadConfig().instances.find(i => i.id === id);
}
+22
View File
@@ -0,0 +1,22 @@
import Docker from "dockerode";
import { InstanceConfig } from "./config";
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
export async function restartInstance(inst: InstanceConfig) {
const container = docker.getContainer(inst.containerName);
await container.restart();
}
export async function getInstanceStatus(inst: InstanceConfig) {
try {
const container = docker.getContainer(inst.containerName);
const info = await container.inspect();
return {
running: info.State.Running,
status: info.State.Status
};
} catch {
return { running: false, status: "not found" };
}
}
+27
View File
@@ -0,0 +1,27 @@
import fs from "fs";
import path from "path";
export function readJson(file: string) {
return JSON.parse(fs.readFileSync(file, "utf8"));
}
export function writeJson(file: string, data: any) {
fs.writeFileSync(file, JSON.stringify(data, null, 2));
}
export function listWorldDescriptions(worldsRoot: string) {
const versions = fs.readdirSync(worldsRoot);
const files: string[] = [];
for (const v of versions) {
const worldsDir = path.join(worldsRoot, v, "Worlds");
if (!fs.existsSync(worldsDir)) continue;
for (const worldId of fs.readdirSync(worldsDir)) {
const file = path.join(worldsDir, worldId, "WorldDescription.json");
if (fs.existsSync(file)) files.push(file);
}
}
return files;
}
+16
View File
@@ -0,0 +1,16 @@
import express from "express";
import { requireAuth } from "./auth";
import instanceApi from "./api/instance";
import configApi from "./api/config";
const app = express();
app.use(express.json());
// Protect everything
app.use(requireAuth);
app.use("/instance", instanceApi);
app.use("/config", configApi);
const port = process.env.PORT || 3000;
app.listen(port, () => console.log(`Windrose Manager running on port ${port}`));
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "CommonJS",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"strict": false
}
}