Add Node.js manager service and updated docker-compose
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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" };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`));
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "CommonJS",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"esModuleInterop": true,
|
||||
"strict": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user