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:
@@ -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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user