From 119bb443d90fcf5443722ee266701a3480d563b8 Mon Sep 17 00:00:00 2001 From: Joel Maxwell Date: Sun, 10 May 2026 20:43:48 -0300 Subject: [PATCH] Add Node.js manager service and updated docker-compose --- docker-compose.yml | 16 ++++++++++++++++ manager/Dockerfile | 14 ++++++++++++++ manager/package.json | 21 +++++++++++++++++++++ manager/src/api/config.ts | 37 +++++++++++++++++++++++++++++++++++++ manager/src/api/instance.ts | 23 +++++++++++++++++++++++ manager/src/auth.ts | 21 +++++++++++++++++++++ manager/src/core/config.ts | 30 ++++++++++++++++++++++++++++++ manager/src/core/docker.ts | 22 ++++++++++++++++++++++ manager/src/core/files.ts | 27 +++++++++++++++++++++++++++ manager/src/server.ts | 16 ++++++++++++++++ manager/tsconfig.json | 10 ++++++++++ 11 files changed, 237 insertions(+) create mode 100644 manager/Dockerfile create mode 100644 manager/package.json create mode 100644 manager/src/api/config.ts create mode 100644 manager/src/api/instance.ts create mode 100644 manager/src/auth.ts create mode 100644 manager/src/core/config.ts create mode 100644 manager/src/core/docker.ts create mode 100644 manager/src/core/files.ts create mode 100644 manager/src/server.ts create mode 100644 manager/tsconfig.json diff --git a/docker-compose.yml b/docker-compose.yml index 43b4acc..34c73d6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,3 +37,19 @@ services: tty: 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 diff --git a/manager/Dockerfile b/manager/Dockerfile new file mode 100644 index 0000000..911e170 --- /dev/null +++ b/manager/Dockerfile @@ -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"] diff --git a/manager/package.json b/manager/package.json new file mode 100644 index 0000000..652cd3a --- /dev/null +++ b/manager/package.json @@ -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" + } +} diff --git a/manager/src/api/config.ts b/manager/src/api/config.ts new file mode 100644 index 0000000..577758b --- /dev/null +++ b/manager/src/api/config.ts @@ -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; diff --git a/manager/src/api/instance.ts b/manager/src/api/instance.ts new file mode 100644 index 0000000..5aaf658 --- /dev/null +++ b/manager/src/api/instance.ts @@ -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; diff --git a/manager/src/auth.ts b/manager/src/auth.ts new file mode 100644 index 0000000..4ada979 --- /dev/null +++ b/manager/src/auth.ts @@ -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(); +} diff --git a/manager/src/core/config.ts b/manager/src/core/config.ts new file mode 100644 index 0000000..419b83f --- /dev/null +++ b/manager/src/core/config.ts @@ -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); +} diff --git a/manager/src/core/docker.ts b/manager/src/core/docker.ts new file mode 100644 index 0000000..5c0758b --- /dev/null +++ b/manager/src/core/docker.ts @@ -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" }; + } +} diff --git a/manager/src/core/files.ts b/manager/src/core/files.ts new file mode 100644 index 0000000..059d6a4 --- /dev/null +++ b/manager/src/core/files.ts @@ -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; +} diff --git a/manager/src/server.ts b/manager/src/server.ts new file mode 100644 index 0000000..7b478a9 --- /dev/null +++ b/manager/src/server.ts @@ -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}`)); diff --git a/manager/tsconfig.json b/manager/tsconfig.json new file mode 100644 index 0000000..7f3b686 --- /dev/null +++ b/manager/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "CommonJS", + "outDir": "dist", + "rootDir": "src", + "esModuleInterop": true, + "strict": false + } +}