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>
97 lines
3.8 KiB
TypeScript
97 lines
3.8 KiB
TypeScript
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']);
|
|
});
|
|
});
|