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']); + }); +});