MVP version
This commit is contained in:
commit
3f96943858
25 changed files with 3962 additions and 0 deletions
34
src/services/crypto.js
Normal file
34
src/services/crypto.js
Normal 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 };
|
||||
79
src/services/permissions.js
Normal file
79
src/services/permissions.js
Normal 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,
|
||||
};
|
||||
48
src/services/serverLauncher.js
Normal file
48
src/services/serverLauncher.js
Normal 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
35
src/services/sessions.js
Normal 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
62
src/services/status.js
Normal 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
191
src/services/storage.js
Normal 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,
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue