Add per-server log viewer (console.log + games_sp.log)

New Logs section on the server detail page — tabbed viewer with
auto-refresh every 5s (toggleable), sticky-to-bottom scrolling, and a
manual refresh button. Two whitelisted files per server:

  /root/zombies/t5/<name>/logs/console.log
  /root/zombies/t5/<name>/logs/games_sp.log

Backend:
  - src/lib/server/fleet/logs.ts — safe path resolution + tailLog()
    that reads only the last 256 KB of the file, splits on newlines,
    drops the (potentially partial) first line if we didn't start at
    byte 0, and returns the last N lines with size + mtime + truncated.
    Two path-traversal guards: server directory must be a real
    subdirectory of SERVERS_ROOT, and the target must resolve inside
    it.
  - GET /api/servers/[name]/logs/[file]?lines=200 — whitelisted keys
    only (console, games_sp), 400 on unknown keys, ok:true+missing:
    true when the log file hasn't been written yet.

Frontend:
  - LogPanel.svelte with tabs, auto-refresh checkbox, size/mtime meta,
    and a <pre> body that snaps to bottom on refresh unless the user
    has scrolled up.
  - 7 unit tests covering path safety (including .. traversal) and
    tail behavior (missing file → null, small file, oversized file
    → truncated, trailing newline handling).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 11:50:19 -04:00
co-authored by Claude Opus 4.7
parent d21e8187ff
commit 6aae72f508
5 changed files with 559 additions and 0 deletions
+102
View File
@@ -0,0 +1,102 @@
import { open, stat } from 'node:fs/promises';
import { resolve, sep } from 'node:path';
import { config } from '../config.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'
} as const;
export type LogKey = keyof typeof KNOWN_LOGS;
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(config.serversRoot);
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> {
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
};
}