MVP version

This commit is contained in:
Shinuwa 2026-06-12 14:34:44 +02:00
commit 3f96943858
25 changed files with 3962 additions and 0 deletions

34
src/services/crypto.js Normal file
View file

@ -0,0 +1,34 @@
import crypto from "node:crypto";
import { promisify } from "node:util";
const scryptAsync = promisify(crypto.scrypt);
const KEY_LENGTH = 64;
async function hashPassword(password) {
const salt = crypto.randomBytes(16).toString("hex");
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
return `scrypt:${salt}:${derivedKey.toString("hex")}`;
}
async function verifyPassword(password, storedHash) {
if (!password || !storedHash) return false;
const [algorithm, salt, hash] = storedHash.split(":");
if (algorithm !== "scrypt" || !salt || !hash) return false;
const derivedKey = await scryptAsync(password, salt, KEY_LENGTH);
const expected = Buffer.from(hash, "hex");
return (
expected.length === derivedKey.length &&
crypto.timingSafeEqual(expected, derivedKey)
);
}
function generatePassword() {
return crypto.randomBytes(12).toString("base64url");
}
function generateToken() {
return crypto.randomBytes(32).toString("base64url");
}
export { generatePassword, generateToken, hashPassword, verifyPassword };

View file

@ -0,0 +1,79 @@
import { getStartProfile } from "./serverLauncher.js";
function serializeGroup(group, { includePermissions = true } = {}) {
const serialized = {
id: group.id,
name: group.name,
admin: isAdminGroup(group),
createdAt: group.createdAt,
updatedAt: group.updatedAt,
};
if (includePermissions) {
serialized.permissions = normalizePermissions(group.permissions);
}
return serialized;
}
function serializeSession(group) {
if (!isAdminGroup(group)) {
return { admin: false };
}
return {
admin: true,
name: group.name,
};
}
function normalizePermissions(permissions = {}) {
return {
servers: Array.isArray(permissions.servers) ? permissions.servers : [],
};
}
function canAccessServer(group, serverId) {
if (isAdminGroup(group)) return true;
return normalizePermissions(group?.permissions).servers.includes(serverId);
}
function isAdminGroup(group) {
return group?.id === "admin";
}
function getPublicSecrets(secrets = {}) {
return Object.fromEntries(
Object.entries(secrets).filter(([key]) => !key.toLowerCase().startsWith("admin"))
);
}
function serializeServerForGroup(server, group) {
const publicSecrets = getPublicSecrets(server.secrets);
const startProfile = getStartProfile(server);
const serialized = {
id: server.id,
game: server.game,
address: server.address,
port: server.port,
heroImage: server.heroImage || null,
active: Boolean(server.active),
canStart: canAccessServer(group, server.id) && !server.active && Boolean(startProfile),
hasSecrets: Object.keys(publicSecrets).length > 0,
};
if (canAccessServer(group, server.id)) {
serialized.secrets = publicSecrets;
}
return serialized;
}
export {
canAccessServer,
isAdminGroup,
normalizePermissions,
serializeGroup,
serializeSession,
serializeServerForGroup,
};

View file

@ -0,0 +1,48 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
async function launchServer(server) {
const profile = getStartProfile(server);
if (!profile) {
const error = new Error("Aucun profil de lancement configure");
error.code = "MISSING_START_PROFILE";
throw error;
}
if (profile === "TEST") {
return execFileAsync("/bin/echo", ["Mock start test server"], {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
}
const target = process.env[`${profile}_SSH_TARGET`];
const port = process.env[`${profile}_SSH_PORT`];
const command = process.env[`${profile}_START_COMMAND`];
if (!target || !command) {
const error = new Error(`Profil de lancement incomplet: ${profile}`);
error.code = "INCOMPLETE_START_PROFILE";
throw error;
}
const args = [];
if (port) {
args.push("-p", port);
}
args.push(target, command);
return execFileAsync("ssh", args, {
timeout: 60_000,
maxBuffer: 1024 * 1024,
});
}
function getStartProfile(server) {
if (server.startProfile) return server.startProfile;
return "";
}
export { getStartProfile, launchServer };

35
src/services/sessions.js Normal file
View file

@ -0,0 +1,35 @@
import { config } from "../config.js";
import { generateToken } from "./crypto.js";
const sessions = new Map();
function createSession(group) {
const token = generateToken();
const expiresAt = Date.now() + config.sessionTtlMs;
sessions.set(token, { groupId: group.id, expiresAt });
return { token, expiresAt };
}
function destroySession(token) {
if (token) sessions.delete(token);
}
function getSession(token) {
const session = sessions.get(token);
if (!session) return null;
if (session.expiresAt <= Date.now()) {
sessions.delete(token);
return null;
}
return session;
}
function getTokenFromRequest(req) {
const header = req.headers.authorization || "";
if (!header.startsWith("Bearer ")) return "";
return header.slice("Bearer ".length).trim();
}
export { createSession, destroySession, getSession, getTokenFromRequest };

62
src/services/status.js Normal file
View file

@ -0,0 +1,62 @@
import fs from "node:fs";
import { config } from "../config.js";
function readStatusFile() {
try {
const raw = fs.readFileSync(config.statusFile, "utf8");
const parsed = JSON.parse(raw);
return Array.isArray(parsed?.servers) ? parsed.servers : [];
} catch (error) {
console.error(`[status] Failed to read status file ${config.statusFile}:`, error.message);
return [];
}
}
function mergeServersWithStatus(servers) {
const statuses = readStatusFile();
return statuses.map((status) => {
const server = findConfiguredServer(status, servers);
const id = status.id || server?.id || createServerId(status);
return {
...(server || {}),
id,
game: status.game || server?.game || id,
address: status.address || server?.address || "",
port: status.port ?? server?.port ?? null,
active: Boolean(status.active),
};
});
}
function findConfiguredServer(status, servers) {
return (
servers.find((server) => status.id && server.id === status.id) ||
servers.find((server) => sameEndpoint(server, status)) ||
servers.find((server) => server.game === status.game) ||
null
);
}
function sameEndpoint(server, status) {
if (!server.address || !status.address) return false;
return normalizeAddress(server.address) === normalizeAddress(status.address);
}
function normalizeAddress(address) {
return String(address).trim().toLowerCase();
}
function createServerId(status) {
const base = `${status.game || "server"}-${status.address || ""}-${status.port || ""}`;
return (
base
.toLowerCase()
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "") || "server"
);
}
export { createServerId, mergeServersWithStatus };

191
src/services/storage.js Normal file
View file

@ -0,0 +1,191 @@
import fs from "node:fs";
import path from "node:path";
import { config } from "../config.js";
import { generatePassword, hashPassword } from "./crypto.js";
const files = {
servers: path.join(config.storageDir, "servers.json"),
groups: path.join(config.storageDir, "groups.json"),
logs: path.join(config.storageDir, "launch-logs.json"),
devStatus: path.join(config.mockedDir, "dev-gameservers-status.json"),
serverImages: path.join(config.storageDir, "server-images.json"),
};
const defaultServers = [
{
id: "game-1",
game: "Jeu 1",
address: "srv.mocked.com:12345",
port: 12345,
startProfile: "GAME_1",
secrets: {
password: "change-me",
},
},
{
id: "game-2",
game: "Jeu 2",
address: "srv.mocked.com:67890",
port: 67890,
startProfile: "GAME_2",
secrets: {
password: "change-me",
},
},
];
const defaultStatus = {
servers: defaultServers.map(({ game, address, port }) => ({
game,
address,
port,
active: false,
})),
};
function ensureDir() {
fs.mkdirSync(config.storageDir, { recursive: true });
fs.mkdirSync(config.mockedDir, { recursive: true });
fs.mkdirSync(config.serverImagesDir, { recursive: true });
}
function readJson(file, fallback) {
try {
const raw = fs.readFileSync(file, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error.code !== "ENOENT") {
console.error(`[storage] Failed to read ${file}:`, error);
}
return fallback;
}
}
function writeJson(file, data) {
ensureDir();
fs.writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`);
}
async function ensureStorageLayout() {
ensureDir();
if (!fs.existsSync(files.servers)) {
writeJson(files.servers, defaultServers);
}
if (!fs.existsSync(files.logs)) {
writeJson(files.logs, []);
}
if (!fs.existsSync(files.devStatus)) {
writeJson(files.devStatus, defaultStatus);
}
if (!fs.existsSync(files.serverImages)) {
writeJson(files.serverImages, {});
}
const groups = readGroups();
if (!groups.length) {
const password = generatePassword();
const passwordHash = await hashPassword(password);
const adminGroup = {
id: "admin",
name: "Admin",
admin: true,
passwordHash,
permissions: {
servers: defaultServers.map((server) => server.id),
},
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
writeGroups([adminGroup]);
console.log(`[auth] Initial admin password: ${password}`);
}
pruneLogs();
}
function readServers() {
const servers = readJson(files.servers, []);
return Array.isArray(servers) ? servers : [];
}
function writeServers(servers) {
writeJson(files.servers, servers);
}
function readServerImages() {
const images = readJson(files.serverImages, {});
return images && typeof images === "object" && !Array.isArray(images) ? images : {};
}
function writeServerImages(images) {
writeJson(files.serverImages, images);
}
function setServerImage(serverId, image) {
const images = readServerImages();
images[serverId] = image;
writeServerImages(images);
return image;
}
function deleteServerImage(serverId) {
const images = readServerImages();
const previous = images[serverId] || null;
delete images[serverId];
writeServerImages(images);
return previous;
}
function readGroups() {
const groups = readJson(files.groups, []);
return Array.isArray(groups) ? groups : [];
}
function writeGroups(groups) {
writeJson(files.groups, groups);
}
function readLogs() {
const logs = readJson(files.logs, []);
return Array.isArray(logs) ? logs : [];
}
function writeLogs(logs) {
writeJson(files.logs, logs);
}
function pruneLogs() {
const cutoff = Date.now() - config.logRetentionMs;
const logs = readLogs().filter((entry) => {
const time = Date.parse(entry.createdAt);
return Number.isFinite(time) && time >= cutoff;
});
writeLogs(logs);
return logs;
}
function appendLaunchLog(entry) {
const logs = pruneLogs();
logs.push(entry);
writeLogs(logs);
}
export {
appendLaunchLog,
ensureStorageLayout,
files,
pruneLogs,
readGroups,
readLogs,
readServerImages,
readServers,
setServerImage,
deleteServerImage,
writeGroups,
writeServerImages,
writeServers,
};