From 6aae72f508e7fb896686b35953905c40344ea803 Mon Sep 17 00:00:00 2001 From: josh Date: Sat, 12 Sep 2026 11:50:19 -0400 Subject: [PATCH] Add per-server log viewer (console.log + games_sp.log) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//logs/console.log /root/zombies/t5//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
 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 
---
 src/lib/server/fleet/logs.ts                  | 102 +++++++
 src/lib/ui/components/LogPanel.svelte         | 276 ++++++++++++++++++
 .../api/servers/[name]/logs/[file]/+server.ts |  73 +++++
 src/routes/servers/[name]/+page.svelte        |  12 +
 tests/unit/logs.test.ts                       |  96 ++++++
 5 files changed, 559 insertions(+)
 create mode 100644 src/lib/server/fleet/logs.ts
 create mode 100644 src/lib/ui/components/LogPanel.svelte
 create mode 100644 src/routes/api/servers/[name]/logs/[file]/+server.ts
 create mode 100644 tests/unit/logs.test.ts

diff --git a/src/lib/server/fleet/logs.ts b/src/lib/server/fleet/logs.ts
new file mode 100644
index 0000000..5b5b171
--- /dev/null
+++ b/src/lib/server/fleet/logs.ts
@@ -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 {
+    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
+    };
+}
diff --git a/src/lib/ui/components/LogPanel.svelte b/src/lib/ui/components/LogPanel.svelte
new file mode 100644
index 0000000..9028f87
--- /dev/null
+++ b/src/lib/ui/components/LogPanel.svelte
@@ -0,0 +1,276 @@
+
+
+
+
+ {#each tabs as t (t.key)} + + {/each} +
+ {#if current?.mtime} + {fmtBytes(current.size)} · updated {ago(current.mtime)} + {/if} + + +
+
+ + {#if !current} +
loading…
+ {:else if !current.ok} +
error: {current.reason ?? 'unknown'}
+ {:else if current.missing} +
+ log file not written yet + {#if current.path}
{current.path}{/if} +
+ {:else if !current.lines || current.lines.length === 0} +
log is empty
+ {:else} +
{current.lines.join('\n')}
+ {#if current.truncated} +
showing last {current.lines.length} lines (file is larger)
+ {:else} +
{current.lines.length} line{current.lines.length === 1 ? '' : 's'}
+ {/if} + {/if} +
+ + diff --git a/src/routes/api/servers/[name]/logs/[file]/+server.ts b/src/routes/api/servers/[name]/logs/[file]/+server.ts new file mode 100644 index 0000000..406694f --- /dev/null +++ b/src/routes/api/servers/[name]/logs/[file]/+server.ts @@ -0,0 +1,73 @@ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { getServer } from '$lib/server/fleet/servers.js'; +import { isLogKey, resolveLogPath, tailLog, KNOWN_LOGS } from '$lib/server/fleet/logs.js'; + +/** + * Tail one of the whitelisted log files for a server. + * GET /api/servers//logs/console?lines=200 + * GET /api/servers//logs/games_sp?lines=200 + * + * Returns { ok, key, path, lines, size, mtime, truncated } on success, + * { ok:false, reason } on failure. Unknown log keys are 400s; missing + * files return ok:true with lines:[] and reason:"missing" so the UI can + * render "log hasn't been written yet" without treating it as an error. + */ +export const GET: RequestHandler = async ({ params, url }) => { + const name = params.name; + const file = params.file; + if (!name || !file) return json({ ok: false, reason: 'missing params' }, { status: 400 }); + + if (!isLogKey(file)) { + return json( + { + ok: false, + reason: 'unknown log key', + allowed: Object.keys(KNOWN_LOGS) + }, + { status: 400 } + ); + } + + const row = getServer(name); + if (!row) return json({ ok: false, reason: `unknown server: ${name}` }, { status: 404 }); + + const path = resolveLogPath(name, file); + if (!path) return json({ ok: false, reason: 'path traversal rejected' }, { status: 400 }); + + const linesParam = url.searchParams.get('lines'); + const wanted = linesParam ? Number.parseInt(linesParam, 10) : 200; + + const tail = await tailLog(path, wanted).catch((err) => { + return { error: (err as Error).message }; + }); + + if (tail && 'error' in tail) { + return json({ ok: false, reason: `read failed: ${tail.error}`, path }, { status: 500 }); + } + + if (!tail) { + // File doesn't exist — normal case for a server that hasn't logged yet. + return json({ + ok: true, + key: file, + path, + lines: [], + size: 0, + mtime: null, + truncated: false, + missing: true + }); + } + + return json({ + ok: true, + key: file, + path, + lines: tail.lines, + size: tail.size, + mtime: tail.mtime, + truncated: tail.truncated, + missing: false + }); +}; diff --git a/src/routes/servers/[name]/+page.svelte b/src/routes/servers/[name]/+page.svelte index 434a21c..bd7a06c 100644 --- a/src/routes/servers/[name]/+page.svelte +++ b/src/routes/servers/[name]/+page.svelte @@ -5,6 +5,7 @@ import type { RosterPlayer, Health, RconEndpoint, RconFailure } from '$ui/stores/servers.js'; import StatusDot from '$ui/components/StatusDot.svelte'; import PlayerRoster from '$ui/components/PlayerRoster.svelte'; + import LogPanel from '$ui/components/LogPanel.svelte'; import { gametypeLabel } from '$ui/gametype.js'; import { decodeRconError, rconFixHint } from '$ui/rconError.js'; import { komodoFixHint, type KomodoPortFailure } from '$ui/komodoError.js'; @@ -436,6 +437,17 @@ +
+

Logs

+ +
+

Config

diff --git a/tests/unit/logs.test.ts b/tests/unit/logs.test.ts new file mode 100644 index 0000000..95eb453 --- /dev/null +++ b/tests/unit/logs.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { tmpdir } from 'node:os'; + +// The logs module reads config.serversRoot lazily, so set SERVERS_ROOT +// before importing it. Vitest imports the module once per file so we +// stash the root globally and reset between describe blocks. +const TMP_ROOT = resolve(tmpdir(), `ec-logs-test-${Date.now()}`); +process.env.SERVERS_ROOT = TMP_ROOT; +process.env.SHARED_SCRIPTS = TMP_ROOT; +process.env.REL_DEST = 'runtime/plutonium/storage/t5/raw/scripts/sp'; +process.env.DB_PATH = resolve(TMP_ROOT, 'test.db'); + +const { resolveLogPath, tailLog, isLogKey } = await import( + '../../src/lib/server/fleet/logs.js' +); + +const SERVER = 't5_test_1'; +const LOG_DIR = resolve(TMP_ROOT, SERVER, 'logs'); + +beforeEach(() => { + mkdirSync(LOG_DIR, { recursive: true }); +}); + +afterEach(() => { + rmSync(TMP_ROOT, { recursive: true, force: true }); +}); + +describe('isLogKey', () => { + it('accepts whitelisted keys and rejects anything else', () => { + expect(isLogKey('console')).toBe(true); + expect(isLogKey('games_sp')).toBe(true); + expect(isLogKey('bogus')).toBe(false); + expect(isLogKey('../etc/passwd')).toBe(false); + }); +}); + +describe('resolveLogPath', () => { + it('resolves whitelisted log keys under the server directory', () => { + const p = resolveLogPath(SERVER, 'console'); + expect(p).toBe(resolve(TMP_ROOT, SERVER, 'logs/console.log')); + }); + + it('rejects a server name that would path-traverse', () => { + // Server name with .. tries to escape the servers root + const p = resolveLogPath('..', 'console'); + // resolve(TMP_ROOT, '..', 'logs/console.log') escapes TMP_ROOT, + // so the guard against non-serverDir-prefixed paths kicks in. + // The check is against the resolved server dir; escaping should + // return null. + expect(p).toBeNull(); + }); +}); + +describe('tailLog', () => { + it('returns null for a missing file (not an error)', async () => { + const path = resolve(LOG_DIR, 'console.log'); + expect(await tailLog(path, 10)).toBeNull(); + }); + + it('returns the last N lines of a small file with truncated=false', async () => { + const path = resolve(LOG_DIR, 'console.log'); + const body = Array.from({ length: 20 }, (_, i) => `line ${i}`).join('\n') + '\n'; + writeFileSync(path, body); + + const tail = await tailLog(path, 5); + expect(tail).not.toBeNull(); + expect(tail?.lines).toEqual(['line 15', 'line 16', 'line 17', 'line 18', 'line 19']); + expect(tail?.truncated).toBe(false); + expect(tail?.size).toBe(body.length); + }); + + it('caps at MAX_TAIL_LINES and marks truncated when reading past the window', async () => { + const path = resolve(LOG_DIR, 'big.log'); + // 500 KB of 60-char lines — comfortably larger than the 256 KB read window. + const line = 'x'.repeat(59); + const body = Array.from({ length: 8500 }, (_, i) => `${i.toString().padStart(4, '0')}${line.slice(4)}`).join('\n'); + writeFileSync(path, body); + + const tail = await tailLog(path, 100); + expect(tail).not.toBeNull(); + expect(tail!.truncated).toBe(true); + expect(tail!.lines.length).toBeLessThanOrEqual(100); + // Last line should be the highest-numbered one; first line of the tail + // is somewhere in the middle since we can only read the last 256 KB. + expect(tail!.lines[tail!.lines.length - 1].startsWith('8499')).toBe(true); + }); + + it('drops the trailing empty line when file ends with a newline', async () => { + const path = resolve(LOG_DIR, 'trailing.log'); + writeFileSync(path, 'a\nb\nc\n'); + const tail = await tailLog(path, 10); + expect(tail?.lines).toEqual(['a', 'b', 'c']); + }); +});