Files
electric-cherry/src/lib/server/fleet/logs.ts
T

196 lines
6.6 KiB
TypeScript

import { open, stat } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { resolve, sep } from 'node:path';
import { rootOfServer, gameOfServer } from '../config.js';
import { nativeTail, nativeLogChunk } 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
}
export interface LogCursor {
fileId: string;
offset: number;
size: number;
modified: string;
anchorLength: number;
anchorHash: string;
}
export interface LogChunk {
contentBase64: string;
size: number;
mtime: string;
truncated: boolean;
cursor: LogCursor;
reset: boolean;
}
/** Bounded byte reads; an anchor before the offset detects copy-truncate and regrowth. */
export async function readLogChunk(
path: string,
cursor?: LogCursor
): Promise<LogChunk | TailResult | null> {
const remote = await nativeLogChunk(path, cursor);
if (remote !== undefined) return remote;
let file;
try {
file = await open(path, 'r');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
throw error;
}
try {
const st = await file.stat();
if (!st.isFile()) return null;
const fileId = `${st.dev}:${st.ino}`,
modified = `${st.mtimeMs}:${st.ctimeMs}`,
size = st.size;
const mtime = st.mtime.toISOString();
if (
cursor?.fileId === fileId &&
cursor.modified === modified &&
cursor.size === size &&
cursor.offset === size
) {
return {
contentBase64: '',
size,
mtime,
truncated: cursor.offset > 0,
cursor,
reset: false
};
}
const hash = (value: Buffer) => createHash('sha256').update(value).digest('hex');
let reset = !cursor || cursor.fileId !== fileId || cursor.offset > size;
if (cursor && !reset) {
const anchor = Buffer.alloc(cursor.anchorLength);
const read = await file.read(anchor, 0, anchor.length, cursor.offset - anchor.length);
reset = read.bytesRead !== anchor.length || hash(anchor) !== cursor.anchorHash;
}
const start = reset ? Math.max(0, size - READ_WINDOW_BYTES) : cursor!.offset;
const content = Buffer.alloc(Math.min(READ_WINDOW_BYTES, size - start));
const read = await file.read(content, 0, content.length, start);
const offset = start + read.bytesRead;
const anchor = Buffer.alloc(Math.min(256, offset));
await file.read(anchor, 0, anchor.length, offset - anchor.length);
return {
contentBase64: content.subarray(0, read.bytesRead).toString('base64'),
size,
mtime,
truncated: start > 0,
reset,
cursor: {
fileId,
modified,
size,
offset,
anchorLength: anchor.length,
anchorHash: hash(anchor)
}
};
} finally {
await file.close();
}
}
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
};
}