diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 05d5d60..22c15c1 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js"; import editorRouter from "./routes/editor.js"; import terminalRouter from "./routes/terminal.js"; import buildRouter from "./routes/build.js"; +import serverRouter from "./routes/server.js"; import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js"; import { attachLspWebSocket } from "./services/lsp.service.js"; @@ -35,6 +36,7 @@ app.use("/api/docker", dockerRouter); app.use("/api/editor", editorRouter); app.use("/api/terminal", terminalRouter); app.use("/api/build", buildRouter); +app.use("/api/server", serverRouter); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); app.use(express.static(frontendDist)); diff --git a/packages/backend/src/routes/server.ts b/packages/backend/src/routes/server.ts new file mode 100644 index 0000000..8ac4188 --- /dev/null +++ b/packages/backend/src/routes/server.ts @@ -0,0 +1,70 @@ +import { Router } from "express"; +import { + startServer, + stopServer, + restartServer, + getServerStatus, + generateServerConfig, + seedDatabase, +} from "../services/server.service.js"; + +const router = Router(); + +router.get("/status", async (_req, res) => { + try { + const status = await getServerStatus(); + res.json(status); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +router.post("/start", async (_req, res) => { + try { + await startServer(); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +router.post("/stop", async (_req, res) => { + try { + await stopServer(); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +router.post("/restart", async (_req, res) => { + try { + await restartServer(); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +router.post("/generate-config", async (_req, res) => { + try { + await generateServerConfig(); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +router.post("/seed-db", async (req, res) => { + const { cdKey, playerName } = req.body; + if (!cdKey || !playerName) + return res.status(400).json({ error: "cdKey and playerName required" }); + try { + await seedDatabase(cdKey, playerName); + res.json({ ok: true }); + } catch (err: any) { + res.status(500).json({ error: err.message }); + } +}); + +export default router; diff --git a/packages/backend/src/services/server.service.ts b/packages/backend/src/services/server.service.ts new file mode 100644 index 0000000..892fa7f --- /dev/null +++ b/packages/backend/src/services/server.service.ts @@ -0,0 +1,212 @@ +import fs from "fs/promises"; +import path from "path"; +import { getWorkspacePath } from "./workspace.service.js"; +import { getDockerClient } from "./docker.service.js"; +import { broadcast } from "./ws.service.js"; +import { generateServerEnv } from "../config/env-template.js"; + +const NWSERVER_NAME = "layonara-nwserver"; +const MARIADB_NAME = "layonara-mariadb"; +const NETWORK_NAME = "layonara-forge"; + +export async function generateServerConfig(): Promise { + const configDir = path.join(getWorkspacePath(), "config"); + const envContent = generateServerEnv(); + await fs.writeFile(path.join(configDir, ".env"), envContent); +} + +export async function getServerStatus(): Promise<{ nwserver: string; mariadb: string }> { + const docker = getDockerClient(); + const containers = await docker.listContainers({ all: true }); + + const nwserver = containers.find((c) => + c.Names.some((n) => n.includes(NWSERVER_NAME)), + ); + const mariadb = containers.find((c) => + c.Names.some((n) => n.includes(MARIADB_NAME)), + ); + + return { + nwserver: nwserver?.State || "not created", + mariadb: mariadb?.State || "not created", + }; +} + +export async function startServer(): Promise { + broadcast("docker", "server:starting", {}); + const docker = getDockerClient(); + + try { + await docker.getNetwork(NETWORK_NAME).inspect(); + } catch { + await docker.createNetwork({ Name: NETWORK_NAME }); + } + + const envPath = path.join(getWorkspacePath(), "config", ".env"); + try { + await fs.access(envPath); + } catch { + await generateServerConfig(); + } + + const envContent = await fs.readFile(envPath, "utf-8"); + const env = envContent + .split("\n") + .filter((l) => l.trim() && !l.startsWith("#")) + .map((l) => l.trim()); + + await startMariaDB(docker, env); + await waitForContainer(docker, MARIADB_NAME, 30000); + await startNWServer(docker, env); + + broadcast("docker", "server:started", {}); +} + +async function startMariaDB(docker: ReturnType, env: string[]): Promise { + try { + const existing = docker.getContainer(MARIADB_NAME); + const info = await existing.inspect(); + if (info.State.Running) return; + await existing.start(); + return; + } catch { + /* container doesn't exist */ + } + + const mysqlPassword = + env.find((e) => e.startsWith("MYSQL_PASSWORD="))?.split("=")[1] || "forge"; + const mysqlRootPassword = + env.find((e) => e.startsWith("MYSQL_ROOT_PASSWORD="))?.split("=")[1] || "forge"; + + const container = await docker.createContainer({ + Image: "mariadb:10.11", + name: MARIADB_NAME, + Env: [ + `MYSQL_ROOT_PASSWORD=${mysqlRootPassword}`, + `MYSQL_DATABASE=nwn`, + `MYSQL_USER=nwn`, + `MYSQL_PASSWORD=${mysqlPassword}`, + ], + HostConfig: { + NetworkMode: NETWORK_NAME, + RestartPolicy: { Name: "unless-stopped" }, + }, + Healthcheck: { + Test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"], + Interval: 10000000000, + Timeout: 5000000000, + Retries: 5, + }, + }); + await container.start(); +} + +async function startNWServer(docker: ReturnType, env: string[]): Promise { + try { + const existing = docker.getContainer(NWSERVER_NAME); + const info = await existing.inspect(); + if (info.State.Running) return; + await existing.start(); + return; + } catch { + /* container doesn't exist */ + } + + const workspacePath = getWorkspacePath(); + const container = await docker.createContainer({ + Image: "ghcr.io/plenarius/unified:latest", + name: NWSERVER_NAME, + Env: env, + ExposedPorts: { "5121/udp": {}, "5121/tcp": {} }, + HostConfig: { + NetworkMode: NETWORK_NAME, + PortBindings: { + "5121/udp": [{ HostPort: "5121" }], + "5121/tcp": [{ HostPort: "5121" }], + }, + Binds: [ + `${workspacePath}/server/hak:/nwn/home/hak:ro`, + `${workspacePath}/server/tlk:/nwn/home/tlk:ro`, + `${workspacePath}/server/override:/nwn/home/override:ro`, + `${workspacePath}/server/portraits:/nwn/home/portraits:ro`, + `${workspacePath}/server/modules:/nwn/home/modules`, + `${workspacePath}/server/servervault:/nwn/home/servervault`, + `${workspacePath}/server/database:/nwn/home/database`, + `${workspacePath}/server/development:/nwn/home/development`, + `${workspacePath}/logs:/nwn/run/logs.0`, + ], + RestartPolicy: { Name: "unless-stopped" }, + }, + Tty: true, + OpenStdin: true, + }); + await container.start(); +} + +async function waitForContainer( + docker: ReturnType, + name: string, + timeoutMs: number, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + const container = docker.getContainer(name); + const info = await container.inspect(); + if (info.State.Running) return; + } catch { + /* not ready yet */ + } + await new Promise((r) => setTimeout(r, 2000)); + } +} + +export async function stopServer(): Promise { + broadcast("docker", "server:stopping", {}); + const docker = getDockerClient(); + + try { + await docker.getContainer(NWSERVER_NAME).stop(); + } catch { + /* already stopped or doesn't exist */ + } + try { + await docker.getContainer(MARIADB_NAME).stop(); + } catch { + /* already stopped or doesn't exist */ + } + + broadcast("docker", "server:stopped", {}); +} + +export async function restartServer(): Promise { + await stopServer(); + await startServer(); +} + +export async function seedDatabase(cdKey: string, playerName: string): Promise { + const docker = getDockerClient(); + const container = docker.getContainer(MARIADB_NAME); + + const exec = await container.exec({ + Cmd: [ + "bash", + "-c", + `mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`, + ], + AttachStdout: true, + AttachStderr: true, + }); + await exec.start({}); + + const dmExec = await container.exec({ + Cmd: [ + "bash", + "-c", + `mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`, + ], + AttachStdout: true, + AttachStderr: true, + }); + await dmExec.start({}); +}