Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
108 lines
3.7 KiB
TypeScript
108 lines
3.7 KiB
TypeScript
import { open, stat } from 'node:fs/promises';
|
|
import { resolve, sep } from 'node:path';
|
|
import { rootOfServer, gameOfServer } from '../config.js';
|
|
import { nativeTail } from '../backends/files.js';
|
|
|
|
// Whitelisted log files exposed to the UI, mapped to their path relative
|
|
// to each server's root directory. Adding a new supported log = one entry
|
|
// here + one tab in LogPanel.svelte.
|
|
export const KNOWN_LOGS = {
|
|
console: 'logs/console.log',
|
|
games_sp: 'logs/games_sp.log',
|
|
games_zm: 'logs/games_zm.log'
|
|
} as const;
|
|
|
|
export type LogKey = keyof typeof KNOWN_LOGS;
|
|
export const gameLogKey = (server: string): LogKey => gameOfServer(server) === 't6' ? 'games_zm' : 'games_sp';
|
|
|
|
export function isLogKey(v: string): v is LogKey {
|
|
return Object.prototype.hasOwnProperty.call(KNOWN_LOGS, v);
|
|
}
|
|
|
|
/**
|
|
* Resolve a per-server log path safely. Two guards:
|
|
* 1. The server directory itself must be a real subdirectory of
|
|
* SERVERS_ROOT — this catches `serverName = ".."` and similar.
|
|
* 2. The final target path must be inside that server directory — this
|
|
* catches escapes in the log filename (defensive; KNOWN_LOGS entries
|
|
* are hardcoded but keep the check for future additions).
|
|
* Returns null on any escape.
|
|
*/
|
|
export function resolveLogPath(serverName: string, key: LogKey): string | null {
|
|
const serversRoot = resolve(rootOfServer(serverName));
|
|
const serverDir = resolve(serversRoot, serverName);
|
|
if (serverDir === serversRoot || !serverDir.startsWith(serversRoot + sep)) {
|
|
return null;
|
|
}
|
|
const target = resolve(serverDir, KNOWN_LOGS[key]);
|
|
if (target !== serverDir && !target.startsWith(serverDir + sep)) {
|
|
return null;
|
|
}
|
|
return target;
|
|
}
|
|
|
|
export interface TailResult {
|
|
lines: string[];
|
|
size: number;
|
|
mtime: string; // ISO
|
|
truncated: boolean; // true when we didn't read from the start of the file
|
|
}
|
|
|
|
const DEFAULT_TAIL_LINES = 200;
|
|
const MAX_TAIL_LINES = 2000;
|
|
const READ_WINDOW_BYTES = 256 * 1024; // 256 KB — plenty for a few hundred lines
|
|
|
|
/**
|
|
* Read the last N lines of a file efficiently: only slurps up to 256 KB
|
|
* from the end of the file, splits on newlines, drops the (potentially
|
|
* partial) first line if we didn't start at byte 0.
|
|
*
|
|
* Returns null when the file doesn't exist yet — treat this as "log
|
|
* hasn't been written to yet" rather than an error.
|
|
*/
|
|
export async function tailLog(
|
|
path: string,
|
|
lines: number = DEFAULT_TAIL_LINES
|
|
): Promise<TailResult | null> {
|
|
const remote = await nativeTail(path, lines);
|
|
if (remote !== undefined) return remote;
|
|
let st;
|
|
try {
|
|
st = await stat(path);
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
|
throw err;
|
|
}
|
|
if (!st.isFile()) return null;
|
|
|
|
const wanted = Math.min(Math.max(1, Math.floor(lines)), MAX_TAIL_LINES);
|
|
const size = st.size;
|
|
const start = Math.max(0, size - READ_WINDOW_BYTES);
|
|
const length = size - start;
|
|
|
|
const buf = Buffer.alloc(length);
|
|
const fd = await open(path, 'r');
|
|
try {
|
|
if (length > 0) await fd.read(buf, 0, length, start);
|
|
} finally {
|
|
await fd.close();
|
|
}
|
|
|
|
const text = buf.toString('utf8');
|
|
let allLines = text.split(/\r?\n/);
|
|
// If we didn't read from position 0, the first "line" is almost
|
|
// certainly a mid-line fragment — drop it.
|
|
if (start > 0 && allLines.length > 0) allLines = allLines.slice(1);
|
|
// A trailing newline creates an empty final entry — drop it.
|
|
if (allLines.length > 0 && allLines[allLines.length - 1] === '') {
|
|
allLines = allLines.slice(0, -1);
|
|
}
|
|
|
|
return {
|
|
lines: allLines.slice(-wanted),
|
|
size,
|
|
mtime: st.mtime.toISOString(),
|
|
truncated: start > 0
|
|
};
|
|
}
|