Merge feature/dc-055-journald-viewer: host journald log viewer
This commit is contained in:
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* DC-055: Host journald reader unit tests
|
||||
*
|
||||
* The reader is a security-sensitive shell-out — every test below exists
|
||||
* to prevent a regression that would let a caller pass a tainted unit
|
||||
* name or since/until/search string to journalctl. We never call the real
|
||||
* binary; every spawn is mocked by injecting an `exec` function (the
|
||||
* module accepts exec as the second argument specifically for testability).
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const MODULE_PATH = path.join(__dirname, '..', 'src', 'monitoring', 'journald-reader.js');
|
||||
|
||||
// Construct a fake child process that matches the interface journald-reader
|
||||
// uses: stdout/stderr EventEmitters, kill(), and emits 'exit' on demand.
|
||||
function makeFakeChild({ stdout = '', stderr = '', code = 0, signal = null, failOnSpawn = null, killFn = null } = {}) {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = killFn || (() => {});
|
||||
process.nextTick(() => {
|
||||
if (failOnSpawn) {
|
||||
const err = new Error('spawn fail');
|
||||
err.code = failOnSpawn;
|
||||
child.emit('error', err);
|
||||
return;
|
||||
}
|
||||
if (stdout) child.stdout.emit('data', Buffer.from(stdout));
|
||||
if (stderr) child.stderr.emit('data', Buffer.from(stderr));
|
||||
child.emit('exit', code, signal);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
// Factory for an `exec` function that returns the given fake child.
|
||||
function fakeExec(child) {
|
||||
return jest.fn().mockReturnValue(child);
|
||||
}
|
||||
|
||||
describe('journald-reader', () => {
|
||||
describe('assertUnitAllowed', () => {
|
||||
const { assertUnitAllowed } = require(MODULE_PATH);
|
||||
|
||||
test('accepts allow-listed bare names', () => {
|
||||
expect(assertUnitAllowed('caddy')).toBe('caddy');
|
||||
expect(assertUnitAllowed('docker')).toBe('docker');
|
||||
expect(assertUnitAllowed('dashcaddy-api')).toBe('dashcaddy-api');
|
||||
});
|
||||
|
||||
test('strips .service suffix', () => {
|
||||
expect(assertUnitAllowed('caddy.service')).toBe('caddy');
|
||||
expect(assertUnitAllowed('docker.service')).toBe('docker');
|
||||
});
|
||||
|
||||
test('rejects units not on the allow-list', () => {
|
||||
expect(() => assertUnitAllowed('sshd')).toThrow(/not in allow-list/);
|
||||
expect(() => assertUnitAllowed('nginx')).toThrow(/not in allow-list/);
|
||||
expect(() => assertUnitAllowed('root')).toThrow(/not in allow-list/);
|
||||
});
|
||||
|
||||
test('rejects shell metacharacters and path traversal', () => {
|
||||
expect(() => assertUnitAllowed('caddy; rm -rf /')).toThrow(/invalid characters/);
|
||||
expect(() => assertUnitAllowed('caddy && touch /tmp/pwn')).toThrow(/invalid characters/);
|
||||
expect(() => assertUnitAllowed('caddy|tee /etc/passwd')).toThrow(/invalid characters/);
|
||||
expect(() => assertUnitAllowed('../etc/passwd')).toThrow(/invalid characters/);
|
||||
expect(() => assertUnitAllowed('caddy\nfoo')).toThrow(/invalid characters/);
|
||||
});
|
||||
|
||||
test('rejects empty / non-string', () => {
|
||||
expect(() => assertUnitAllowed('')).toThrow(/unit is required/);
|
||||
expect(() => assertUnitAllowed(null)).toThrow(/unit is required/);
|
||||
expect(() => assertUnitAllowed(undefined)).toThrow(/unit is required/);
|
||||
expect(() => assertUnitAllowed(42)).toThrow(/unit is required/);
|
||||
});
|
||||
|
||||
test('throws ValidationError specifically (route layer keys on .name)', () => {
|
||||
try { assertUnitAllowed('nginx'); }
|
||||
catch (e) { expect(e.name).toBe('ValidationError'); }
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTail', () => {
|
||||
const { parseTail, MAX_TAIL_LINES } = require(MODULE_PATH);
|
||||
|
||||
test('returns fallback on undefined', () => {
|
||||
expect(parseTail(undefined)).toBe(200);
|
||||
expect(parseTail(undefined, 50)).toBe(50);
|
||||
});
|
||||
|
||||
test('clamps to MAX_TAIL_LINES', () => {
|
||||
expect(parseTail('999999999999')).toBe(MAX_TAIL_LINES);
|
||||
expect(parseTail(999999999999)).toBe(MAX_TAIL_LINES);
|
||||
});
|
||||
|
||||
test('rejects non-positive and non-integer', () => {
|
||||
expect(() => parseTail('0')).toThrow(/positive integer/);
|
||||
expect(() => parseTail('-5')).toThrow(/positive integer/);
|
||||
expect(() => parseTail('abc')).toThrow(/positive integer/);
|
||||
expect(() => parseTail('1.5')).toThrow(/positive integer/);
|
||||
expect(() => parseTail(NaN)).toThrow(/positive integer/);
|
||||
});
|
||||
|
||||
test('accepts valid integers', () => {
|
||||
expect(parseTail('1')).toBe(1);
|
||||
expect(parseTail('500')).toBe(500);
|
||||
expect(parseTail(200)).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTimestamp', () => {
|
||||
const { parseTimestamp } = require(MODULE_PATH);
|
||||
|
||||
test('returns null on undefined/empty', () => {
|
||||
expect(parseTimestamp(undefined, 'since')).toBeNull();
|
||||
expect(parseTimestamp('', 'since')).toBeNull();
|
||||
expect(parseTimestamp(null, 'since')).toBeNull();
|
||||
});
|
||||
|
||||
test('parses ISO 8601 timestamps', () => {
|
||||
const out = parseTimestamp('2026-08-18T07:00:00Z', 'since');
|
||||
expect(out).toBe('2026-08-18T07:00:00.000Z');
|
||||
});
|
||||
|
||||
test('parses ISO date-only', () => {
|
||||
const out = parseTimestamp('2026-08-18', 'since');
|
||||
expect(out).toMatch(/^2026-08-18/);
|
||||
});
|
||||
|
||||
test('parses unix epoch in seconds and ms', () => {
|
||||
// Use a known epoch so the test isn't sensitive to "now". The
|
||||
// expected ISO output is computed at runtime so this stays correct.
|
||||
const epochSec = 1787038846; // 2026-08-18T07:00:46Z
|
||||
const expected = new Date(epochSec * 1000).toISOString();
|
||||
expect(parseTimestamp(String(epochSec), 'since')).toBe(expected);
|
||||
expect(parseTimestamp(String(epochSec * 1000), 'since')).toBe(expected);
|
||||
});
|
||||
|
||||
test('passes through journalctl relative syntax', () => {
|
||||
expect(parseTimestamp('30 min ago', 'since')).toBe('30 min ago');
|
||||
expect(parseTimestamp('today', 'until')).toBe('today');
|
||||
});
|
||||
|
||||
test('rejects shell metacharacters in relative syntax', () => {
|
||||
expect(() => parseTimestamp('30 min ago; touch /tmp/pwn', 'since')).toThrow(/forbidden/);
|
||||
expect(() => parseTimestamp('today && rm -rf /', 'until')).toThrow(/forbidden/);
|
||||
});
|
||||
|
||||
test('rejects strings >1024 chars', () => {
|
||||
const huge = 'a'.repeat(1025);
|
||||
expect(() => parseTimestamp(huge, 'since')).toThrow(/forbidden/);
|
||||
});
|
||||
|
||||
test('rejects invalid ISO', () => {
|
||||
// 'not-a-date' doesn't match the ISO_PATTERN and isn't numeric or
|
||||
// safe relative-syntax — falls through to the relative branch but
|
||||
// doesn't contain forbidden chars either, so it would pass through
|
||||
// to journalctl. Use a string with shell metacharacters instead
|
||||
// to prove the path actually rejects.
|
||||
expect(() => parseTimestamp('yesterday | nc evil 1234', 'since')).toThrow();
|
||||
// Numbers that overflow Date.parse
|
||||
expect(() => parseTimestamp('99999999999999999999', 'since')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildArgv', () => {
|
||||
const { buildArgv } = require(MODULE_PATH);
|
||||
|
||||
test('always emits --directory + unit + --no-pager', () => {
|
||||
const argv = buildArgv({ unit: 'caddy', tail: 100 });
|
||||
expect(argv).toContain('--directory');
|
||||
expect(argv[argv.indexOf('--directory') + 1]).toBe('/var/log/journal');
|
||||
expect(argv).toContain('--no-pager');
|
||||
expect(argv).toContain('-u');
|
||||
expect(argv[argv.indexOf('-u') + 1]).toBe('caddy');
|
||||
expect(argv).not.toContain('--follow');
|
||||
});
|
||||
|
||||
test('follow flag is set when requested', () => {
|
||||
const argv = buildArgv({ unit: 'caddy', follow: true });
|
||||
expect(argv).toContain('--follow');
|
||||
});
|
||||
|
||||
test('emits -n <tail> for numeric tail', () => {
|
||||
const argv = buildArgv({ unit: 'caddy', tail: 500 });
|
||||
const idx = argv.indexOf('-n');
|
||||
expect(idx).toBeGreaterThan(-1);
|
||||
expect(argv[idx + 1]).toBe('500');
|
||||
});
|
||||
|
||||
test('emits --since/--until/search when provided', () => {
|
||||
const argv = buildArgv({
|
||||
unit: 'caddy', tail: 100,
|
||||
since: '2026-08-18T00:00:00Z',
|
||||
until: '2026-08-18T23:59:59Z',
|
||||
search: 'health',
|
||||
});
|
||||
expect(argv).toContain('--since');
|
||||
expect(argv).toContain('--until');
|
||||
expect(argv).toContain('-S');
|
||||
expect(argv[argv.indexOf('-S') + 1]).toBe('health');
|
||||
});
|
||||
|
||||
test('emits argv as a flat string array (no shell)', () => {
|
||||
const argv = buildArgv({ unit: 'caddy', tail: 1 });
|
||||
expect(argv.every(a => typeof a === 'string')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readEntries', () => {
|
||||
const reader = require(MODULE_PATH);
|
||||
|
||||
test('parses short-output lines into structured entries', async () => {
|
||||
const child = makeFakeChild({
|
||||
stdout: [
|
||||
'Aug 18 00:42:46 vmi3080415 caddy[3620580]: {"level":"info","msg":"hello"}',
|
||||
'Aug 18 00:42:56 vmi3080415 caddy[3620580]: {"level":"warn","msg":"oops"}',
|
||||
'',
|
||||
].join('\n'),
|
||||
});
|
||||
const entries = await reader.readEntries({ unit: 'caddy', tail: 50 }, { exec: fakeExec(child) });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[0].timestamp).toBe('Aug 18 00:42:46');
|
||||
expect(entries[0].hostname).toBe('vmi3080415');
|
||||
expect(entries[0].unit).toBe('caddy');
|
||||
expect(entries[0].text).toBe('{"level":"info","msg":"hello"}');
|
||||
});
|
||||
|
||||
test('throws on ValidationError for bad unit', async () => {
|
||||
await expect(reader.readEntries({ unit: 'nginx' })).rejects.toMatchObject({
|
||||
name: 'ValidationError',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws on ValidationError for bad tail', async () => {
|
||||
await expect(reader.readEntries({ unit: 'caddy', tail: -1 })).rejects.toMatchObject({
|
||||
name: 'ValidationError',
|
||||
});
|
||||
});
|
||||
|
||||
test('throws on ValidationError for shell-meta since', async () => {
|
||||
await expect(reader.readEntries({ unit: 'caddy', since: 'yesterday; touch /tmp/pwn' }))
|
||||
.rejects.toMatchObject({ name: 'ValidationError' });
|
||||
});
|
||||
|
||||
test('surfaces ENOENT as Error("journalctl unavailable")', async () => {
|
||||
const child = makeFakeChild({ failOnSpawn: 'ENOENT' });
|
||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
|
||||
.then(() => null, e => e);
|
||||
expect(err.message).toBe('journalctl unavailable');
|
||||
});
|
||||
|
||||
test('surfaces non-zero exit with stderr snippet', async () => {
|
||||
const child = makeFakeChild({
|
||||
stdout: '',
|
||||
stderr: 'Failed to open directory: /var/log/journal/foo\n',
|
||||
code: 1,
|
||||
});
|
||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: fakeExec(child) })
|
||||
.then(() => null, e => e);
|
||||
expect(err.message).toMatch(/exited 1/);
|
||||
expect(err.message).toMatch(/Failed to open directory/);
|
||||
});
|
||||
|
||||
test('clamps stdout at MAX_OUTPUT_BUFFER and rejects with overflow', async () => {
|
||||
// Use smaller chunks: 800KB then another 800KB = 1.6MB > 2MB cap.
|
||||
// Wait, that's <2MB. Need: total > 2MB. Use 1MB + 1.2MB.
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = jest.fn();
|
||||
const cap = require(MODULE_PATH).MAX_OUTPUT_BUFFER;
|
||||
const first = Math.floor(cap * 0.4); // 40%
|
||||
const second = Math.floor(cap * 0.7); // 70% more — total 110%
|
||||
process.nextTick(() => {
|
||||
child.stdout.emit('data', Buffer.alloc(first, 'x'));
|
||||
child.stdout.emit('data', Buffer.alloc(second, 'x'));
|
||||
// Don't emit exit — the overflow rejection doesn't depend on it.
|
||||
// Kill the child eventually so Jest can exit cleanly.
|
||||
setTimeout(() => child.emit('exit', null, 'SIGKILL'), 50);
|
||||
});
|
||||
const execSpy = jest.fn().mockReturnValue(child);
|
||||
const err = await reader.readEntries({ unit: 'caddy' }, { exec: execSpy })
|
||||
.then(() => null, e => e);
|
||||
expect(err).not.toBeNull();
|
||||
expect(err.message).toMatch(/exceeded/);
|
||||
expect(child.kill).toHaveBeenCalledWith('SIGKILL');
|
||||
});
|
||||
});
|
||||
|
||||
describe('streamEntries', () => {
|
||||
const reader = require(MODULE_PATH);
|
||||
|
||||
test('emits parsed data + completes on exit', async () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kill = jest.fn();
|
||||
|
||||
process.nextTick(() => {
|
||||
child.stdout.emit('data', Buffer.from('Aug 18 00:42:46 host caddy[1]: hello\n'));
|
||||
child.emit('exit', 0, null);
|
||||
});
|
||||
|
||||
const seen = [];
|
||||
const execSpy = jest.fn().mockReturnValue(child);
|
||||
reader.streamEntries({ unit: 'caddy' }, {
|
||||
exec: execSpy,
|
||||
onData: (e) => seen.push(e),
|
||||
onError: () => {},
|
||||
});
|
||||
// Drain microtasks so the nextTick callback fires.
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
expect(execSpy).toHaveBeenCalledTimes(1);
|
||||
expect(seen.length).toBeGreaterThanOrEqual(1);
|
||||
expect(seen[0].unit).toBe('caddy');
|
||||
expect(seen[0].text).toBe('hello');
|
||||
});
|
||||
|
||||
test('rejects bad unit before opening stream', () => {
|
||||
expect(() => reader.streamEntries({ unit: 'nginx' }, { onError: () => {} }))
|
||||
.toThrow(/not in allow-list/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* DC-055: Host journald route smoke tests.
|
||||
*
|
||||
* Mounts the routes/logs.js journald endpoints into a tiny express app
|
||||
* with a mocked journald reader. The mock mirrors the real module's
|
||||
* validation pipeline (assertUnitAllowed, parseTail, parseTimestamp) so
|
||||
* bad inputs still throw ValidationError -> 400 at the route boundary,
|
||||
* but the actual journalctl spawn is short-circuited.
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const path = require('path');
|
||||
|
||||
const realJournaldPath = require.resolve('../../src/monitoring/journald-reader.js');
|
||||
|
||||
// Pull the real module's validators so the mock's readEntries can
|
||||
// reproduce the same 400-on-bad-input behaviour as production.
|
||||
const realReader = jest.requireActual(realJournaldPath);
|
||||
|
||||
// Mocked journald reader. Variable name MUST start with "mock" so
|
||||
// jest.mock hoisting doesn't reject the factory closure.
|
||||
const mockJournald = {
|
||||
ALLOWED_UNITS: realReader.ALLOWED_UNITS,
|
||||
MAX_TAIL_LINES: realReader.MAX_TAIL_LINES,
|
||||
MAX_OUTPUT_BUFFER: realReader.MAX_OUTPUT_BUFFER,
|
||||
isAvailable: jest.fn().mockResolvedValue(true),
|
||||
// Validation pipeline runs through the real assert/parse functions so
|
||||
// bad unit/tail/since/until still surface as ValidationError. The
|
||||
// journalctl spawn itself is short-circuited — return canned entries.
|
||||
readEntries: jest.fn(async (opts) => {
|
||||
const unit = realReader.assertUnitAllowed(opts.unit);
|
||||
realReader.parseTail(opts.tail); // throws on bad tail
|
||||
realReader.parseTimestamp(opts.since, 'since');
|
||||
realReader.parseTimestamp(opts.until, 'until');
|
||||
return [
|
||||
{ timestamp: 'Aug 18 00:42:46', hostname: 'host', unit, text: 'mock-line-1' },
|
||||
];
|
||||
}),
|
||||
// Default stream mock: invokes onData with one synthetic entry then
|
||||
// returns a no-op handle. Tests override per-case.
|
||||
streamEntries: jest.fn((opts, hooks = {}) => {
|
||||
if (hooks.onData) {
|
||||
hooks.onData({ timestamp: 'Aug 18 00:42:46', unit: opts.unit, text: 'stream-line-1' });
|
||||
}
|
||||
return { kill: jest.fn(), child: {} };
|
||||
}),
|
||||
listUnits: jest.fn(async () => [
|
||||
{ unit: 'caddy', hasEntries: true },
|
||||
{ unit: 'docker', hasEntries: true },
|
||||
]),
|
||||
assertUnitAllowed: realReader.assertUnitAllowed,
|
||||
parseTail: realReader.parseTail,
|
||||
parseTimestamp: realReader.parseTimestamp,
|
||||
parseShortLine: realReader.parseShortLine,
|
||||
buildArgv: realReader.buildArgv,
|
||||
};
|
||||
|
||||
jest.mock('../../src/monitoring/journald-reader.js', () => mockJournald);
|
||||
|
||||
// Force journaldAvailable = true in routes/logs.js. The route checks
|
||||
// /var/log/journal + /usr/bin/journalctl at module-load time, so we stub
|
||||
// fs.existsSync to lie about those paths.
|
||||
const realFs = require('fs');
|
||||
const realExists = realFs.existsSync;
|
||||
realFs.existsSync = function(p) {
|
||||
if (p === '/var/log/journal' || p === '/usr/bin/journalctl') return true;
|
||||
return realExists.apply(this, arguments);
|
||||
};
|
||||
|
||||
const logsRoutes = require('../../routes/logs.js');
|
||||
|
||||
function buildApp() {
|
||||
const app = express();
|
||||
const asyncHandler = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
const ok = (res, data) => res.json({ success: true, ...data });
|
||||
const errorHandler = (err, req, res, next) => {
|
||||
const status = err.statusCode || (err.name === 'ValidationError' ? 400 : 500);
|
||||
res.status(status).json({ success: false, error: err.message });
|
||||
};
|
||||
app.use('/api/v1', logsRoutes({ asyncHandler, ok }));
|
||||
app.use(errorHandler);
|
||||
return app;
|
||||
}
|
||||
|
||||
describe('routes /logs/journal', () => {
|
||||
let app;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockJournald.readEntries.mockClear();
|
||||
mockJournald.streamEntries.mockClear();
|
||||
mockJournald.listUnits.mockClear();
|
||||
app = buildApp();
|
||||
// Let any keep-alive socket from the prior test close before we
|
||||
// bind a new express app.
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
});
|
||||
|
||||
describe('GET /logs/journal/units', () => {
|
||||
test('returns unit list when journald is mounted', async () => {
|
||||
const res = await request(app).get('/api/v1/logs/journal/units');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.available).toBe(true);
|
||||
expect(res.body.units.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /logs/journal', () => {
|
||||
test('returns entries for caddy', async () => {
|
||||
const res = await request(app)
|
||||
.get('/api/v1/logs/journal')
|
||||
.query({ unit: 'caddy', tail: 50 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.entries.length).toBeGreaterThanOrEqual(1);
|
||||
expect(res.body.entries[0].unit).toBe('caddy');
|
||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
||||
const call = mockJournald.readEntries.mock.calls[0][0];
|
||||
expect(call.unit).toBe('caddy');
|
||||
expect(call.tail).toBe('50');
|
||||
});
|
||||
|
||||
test('forwards since/until/search verbatim', async () => {
|
||||
await request(app).get('/api/v1/logs/journal').query({
|
||||
unit: 'caddy', tail: 100,
|
||||
since: '2026-08-18T00:00:00Z',
|
||||
until: '2026-08-18T23:59:59Z',
|
||||
search: 'health',
|
||||
});
|
||||
const call = mockJournald.readEntries.mock.calls[0][0];
|
||||
expect(call.since).toBe('2026-08-18T00:00:00Z');
|
||||
expect(call.until).toBe('2026-08-18T23:59:59Z');
|
||||
expect(call.search).toBe('health');
|
||||
});
|
||||
|
||||
test('returns 400 when unit not in allow-list', async () => {
|
||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'nginx' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not in allow-list/);
|
||||
// The reader is called and rejects; the route layer maps the
|
||||
// ValidationError to 400 without doing any spawn.
|
||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns 400 when unit contains shell metacharacters', async () => {
|
||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy; rm -rf /' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(mockJournald.readEntries).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('returns 400 when tail is invalid', async () => {
|
||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy', tail: 'oops' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
test('returns 500 when reader throws non-validation error', async () => {
|
||||
mockJournald.readEntries.mockRejectedValueOnce(new Error('journalctl exited 1: bad dir'));
|
||||
const res = await request(app).get('/api/v1/logs/journal').query({ unit: 'caddy' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(res.body.error).toMatch(/journalctl exited 1/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /logs/journal/stream', () => {
|
||||
test('opens SSE with correct content-type for a valid unit', async () => {
|
||||
// Stub the mock to immediately call onError so the route ends
|
||||
// the response and supertest can collect it. Production SSE
|
||||
// streams stay open until the client disconnects — covered by
|
||||
// the journald-reader.streamEntries unit tests.
|
||||
mockJournald.streamEntries.mockImplementationOnce((opts, hooks) => {
|
||||
setTimeout(() => hooks.onError && hooks.onError(new Error('synthetic-EOF')), 5);
|
||||
return { kill: jest.fn(), child: {} };
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/v1/logs/journal/stream')
|
||||
.query({ unit: 'caddy' })
|
||||
.timeout(2000);
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers['content-type']).toMatch(/text\/event-stream/);
|
||||
});
|
||||
|
||||
test('400 when unit not in allow-list', async () => {
|
||||
// The route pre-validates with journald.assertUnitAllowed BEFORE
|
||||
// opening SSE — invalid unit returns a 400 JSON response without
|
||||
// touching the stream.
|
||||
const res = await request(app)
|
||||
.get('/api/v1/logs/journal/stream')
|
||||
.query({ unit: 'nginx' });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/not in allow-list/);
|
||||
// streamEntries must NOT have been called for a bad unit.
|
||||
expect(mockJournald.streamEntries).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,15 @@ const { exists } = require('../src/utilities/fs-helpers');
|
||||
const { paginate, parsePaginationParams } = require('../src/utilities/pagination');
|
||||
const { NotFoundError, ValidationError, ForbiddenError } = require('../src/utilities/errors');
|
||||
const { ok } = require('../src/utils/responses');
|
||||
const journald = require('../src/monitoring/journald-reader');
|
||||
|
||||
const journaldAvailable = (() => {
|
||||
try {
|
||||
return fs.existsSync('/var/log/journal') && fs.existsSync('/usr/bin/journalctl');
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
/**
|
||||
* Logs route factory
|
||||
@@ -218,6 +227,99 @@ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenan
|
||||
ok(res, { result });
|
||||
}, 'logs-docker-maintenance'));
|
||||
|
||||
// ===== DC-055: Host journald log viewer =====
|
||||
// Reads from the host's /var/log/journal via bind-mount in start.sh.
|
||||
// Returns 503 if the bind-mount isn't present (dev containers, Windows).
|
||||
|
||||
// Allow-list of units the dashboard can stream. Exposed to the client so
|
||||
// the dropdown stays in sync with the server-side allow-list.
|
||||
router.get('/logs/journal/units', asyncHandler(async (req, res) => {
|
||||
if (!journaldAvailable) {
|
||||
return ok(res, { available: false, units: [] });
|
||||
}
|
||||
const units = await journald.listUnits();
|
||||
ok(res, { available: true, units });
|
||||
}, 'logs-journal-units'));
|
||||
|
||||
// Read a bounded tail of entries for a unit.
|
||||
router.get('/logs/journal', asyncHandler(async (req, res) => {
|
||||
if (!journaldAvailable) {
|
||||
throw new Error('journald not mounted in this container (host /var/log/journal + /usr/bin/journalctl required)');
|
||||
}
|
||||
const entries = await journald.readEntries({
|
||||
unit: req.query.unit,
|
||||
tail: req.query.tail,
|
||||
since: req.query.since,
|
||||
until: req.query.until,
|
||||
search: req.query.search,
|
||||
});
|
||||
ok(res, { entries, count: entries.length });
|
||||
}, 'logs-journal-read'));
|
||||
|
||||
// Stream entries as they arrive (Server-Sent Events).
|
||||
router.get('/logs/journal/stream', asyncHandler(async (req, res) => {
|
||||
if (!journaldAvailable) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.write(`data: ${JSON.stringify({ error: 'journald not mounted in this container' })}\n\n`);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate BEFORE writing SSE headers — once headers go out we
|
||||
// can't change statusCode. The reader does the same validation but
|
||||
// we want to short-circuit here so the response status reflects the
|
||||
// right category (400 for validation, 503 for bind-mount missing).
|
||||
try {
|
||||
journald.assertUnitAllowed(req.query.unit);
|
||||
if (req.query.since) journald.parseTimestamp(req.query.since, 'since');
|
||||
} catch (err) {
|
||||
// Pass through the global error middleware so the response status
|
||||
// + shape matches every other validation error in the API.
|
||||
throw err;
|
||||
}
|
||||
|
||||
// SSE headers — same convention as /logs/stream/:id.
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.setHeader('X-Accel-Buffering', 'no');
|
||||
|
||||
let settled = false;
|
||||
const cleanup = (handle) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { handle && handle.kill(); } catch (_) { /* already dead */ }
|
||||
try { res.end(); } catch (_) { /* already closed */ }
|
||||
};
|
||||
|
||||
let handle;
|
||||
try {
|
||||
handle = journald.streamEntries(
|
||||
{ unit: req.query.unit, since: req.query.since, search: req.query.search },
|
||||
{
|
||||
onData(entry) {
|
||||
if (settled) return;
|
||||
res.write(`data: ${JSON.stringify(entry)}\n\n`);
|
||||
},
|
||||
onError(err) {
|
||||
if (settled) return;
|
||||
res.write(`data: ${JSON.stringify({ error: err.message || String(err) })}\n\n`);
|
||||
cleanup(handle);
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (err) {
|
||||
res.write(`data: ${JSON.stringify({ error: (err && err.message) || 'stream failed' })}\n\n`);
|
||||
try { res.end(); } catch (_) { /* ignore */ }
|
||||
return;
|
||||
}
|
||||
|
||||
// Modern Node fires 'close' for both clean disconnects and aborts;
|
||||
// the separate 'aborted' listener is deprecated as of Node 18.
|
||||
req.on('close', () => cleanup(handle));
|
||||
}, 'logs-journal-stream'));
|
||||
|
||||
// Get logs from a file path (for native applications)
|
||||
router.get('/logs/file', asyncHandler(async (req, res) => {
|
||||
const { path: logPath, tail = 100 } = req.query;
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/**
|
||||
* DC-055: Host journald reader
|
||||
*
|
||||
* Wraps the host's `journalctl` binary so the API can stream host service
|
||||
* logs (caddy, dashcaddy-api, docker, ...) without exposing the binary
|
||||
* directly to the web layer. The CLI is invoked with --directory pointed at
|
||||
* the bind-mounted /var/log/journal from start.sh so we don't need the
|
||||
* systemd-journal remote protocol or a privileged socket.
|
||||
*
|
||||
* Security contract:
|
||||
* - `unit` MUST be in the allow-list `ALLOWED_UNITS`. We never accept a
|
||||
* raw unit name from the caller and pass it to the shell, even with
|
||||
* shell:false — because an attacker who can set unit=caddy.service;
|
||||
* touch /tmp/x could use the CLI itself as a confused-deputy vector.
|
||||
* - All journalctl invocations use `spawn` (not `exec`) and pass arguments
|
||||
* as an array (`shell:false`). No shell metacharacters can be smuggled
|
||||
* in through any field — the unit, since/until, search, tail numbers
|
||||
* are validated separately before being added to argv.
|
||||
* - Streams (SSE) cap to MAX_STREAM_BYTES and kill the child on overflow
|
||||
* so a `tail=999999999999` request can't OOM the process.
|
||||
*
|
||||
* Failure modes that surface to the route layer:
|
||||
* - journalctl missing in the container (DN container, dev container):
|
||||
* every call throws Error('journalctl unavailable'). Route 503s.
|
||||
* - unit not in allow-list: throws ValidationError. Route 400s.
|
||||
* - non-zero exit code: child stderr is captured and surfaced verbatim
|
||||
* up to LOG_PREVIEW_BYTES so the operator can see "Failed to open
|
||||
* directory" instead of a generic 500.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const JOURNAL_DIR = '/var/log/journal';
|
||||
const ALLOWED_UNITS = Object.freeze([
|
||||
// Core reverse proxy + DNS host services
|
||||
'caddy',
|
||||
'dashcaddy-api',
|
||||
'docker',
|
||||
'systemd-journald',
|
||||
'networkd-dispatcher',
|
||||
'tailscaled',
|
||||
'ssh',
|
||||
// Permit the unit with and without the .service suffix. The CLI accepts
|
||||
// both; we store the bare name and append nothing — journalctl treats
|
||||
// "caddy" and "caddy.service" identically.
|
||||
]);
|
||||
|
||||
// Cap how much a single request can read — prevents `tail=999999999` from
|
||||
// piping half the journal into memory. The dashboard doesn't have a UI for
|
||||
// "load 100MB of logs" and journalctl itself caps at 2GB anyway.
|
||||
const MAX_TAIL_LINES = 5000;
|
||||
// Streaming cap: how many journal entries we hand to the SSE consumer
|
||||
// before killing the child. The dashboard shouldn't accumulate more than
|
||||
// this in memory — pair with MAX_OUTPUT_BUFFER for a defense-in-depth
|
||||
// bound on what the route layer will hold.
|
||||
const MAX_STREAM_LINES = 5000;
|
||||
const MAX_OUTPUT_BUFFER = 2 * 1024 * 1024; // 2MB hard cap on total stdout
|
||||
const LOG_PREVIEW_BYTES = 4096;
|
||||
|
||||
const UNIT_PATTERN = /^[a-zA-Z0-9_.@-]+$/;
|
||||
const ISO_PATTERN = /^\d{4}-\d{2}-\d{2}(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/;
|
||||
|
||||
/**
|
||||
* Validate a unit name against the allow-list. Returns the canonical name
|
||||
* or throws ValidationError.
|
||||
*/
|
||||
function assertUnitAllowed(unit) {
|
||||
if (typeof unit !== 'string' || !unit) {
|
||||
const err = new Error('unit is required');
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
// Strip the .service suffix defensively so callers don't have to remember
|
||||
// which form journalctl prefers for a given unit.
|
||||
const normalised = unit.endsWith('.service') ? unit.slice(0, -8) : unit;
|
||||
if (!UNIT_PATTERN.test(normalised)) {
|
||||
const err = new Error(`unit contains invalid characters: ${unit}`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
if (!ALLOWED_UNITS.includes(normalised)) {
|
||||
const err = new Error(`unit not in allow-list: ${normalised}`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
return normalised;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse tail to a bounded positive integer.
|
||||
*/
|
||||
function parseTail(raw, fallback = 200) {
|
||||
if (raw === undefined || raw === null || raw === '') return fallback;
|
||||
const n = Number(raw);
|
||||
if (!Number.isFinite(n) || !Number.isInteger(n) || n <= 0) {
|
||||
const err = new Error(`tail must be a positive integer (got ${raw})`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
return Math.min(n, MAX_TAIL_LINES);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse since/until — accept either an ISO timestamp, a unix epoch in ms, or
|
||||
* journalctl's relative syntax ("30 min ago", "today", "yesterday"). The
|
||||
* dashboard uses ISO timestamps from `<input type="datetime-local">`; the
|
||||
* relative syntax is for power users typing into the search bar.
|
||||
*/
|
||||
function parseTimestamp(raw, fieldName) {
|
||||
if (raw === undefined || raw === null || raw === '') return null;
|
||||
if (typeof raw !== 'string') {
|
||||
const err = new Error(`${fieldName} must be a string`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
// ISO 8601
|
||||
if (ISO_PATTERN.test(raw)) {
|
||||
const ms = Date.parse(raw);
|
||||
if (!Number.isFinite(ms)) {
|
||||
const err = new Error(`${fieldName} is not a valid ISO timestamp: ${raw}`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
// Numeric (unix epoch seconds OR ms — journalctl accepts seconds)
|
||||
if (/^-?\d+$/.test(raw)) {
|
||||
const n = Number(raw);
|
||||
const ms = n > 1e12 ? n : n * 1000;
|
||||
if (!Number.isFinite(ms)) {
|
||||
const err = new Error(`${fieldName} is not a valid epoch: ${raw}`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
// Relative syntax: pass through to journalctl, but cap to 1024 chars and
|
||||
// disallow shell metacharacters.
|
||||
if (raw.length > 1024 || /[`$;&|><\\\n\r]/.test(raw)) {
|
||||
const err = new Error(`${fieldName} contains forbidden characters: ${raw}`);
|
||||
err.name = 'ValidationError';
|
||||
throw err;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect whether journalctl is reachable. Cheap probe (no-op flag) so we
|
||||
* don't shell out on every request when the binary is missing (dev
|
||||
* container, Windows host, etc.).
|
||||
*/
|
||||
function isAvailable({ journalDir = JOURNAL_DIR, exec = spawn } = {}) {
|
||||
if (!fs.existsSync(journalDir)) return false;
|
||||
return new Promise((resolve) => {
|
||||
const child = exec('journalctl', ['--no-pager', '--version'], { stdio: 'ignore' });
|
||||
child.on('error', () => resolve(false));
|
||||
child.on('exit', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build argv for journalctl. Exposed so tests can assert exactly what we
|
||||
* shell out — never build the arg array inline anywhere else.
|
||||
*/
|
||||
function buildArgv({ unit, since, until, tail, search, follow = false }) {
|
||||
const argv = [
|
||||
'--directory', JOURNAL_DIR,
|
||||
'--no-pager',
|
||||
'--output=short',
|
||||
'-u', unit,
|
||||
];
|
||||
if (since) argv.push('--since', since);
|
||||
if (until) argv.push('--until', until);
|
||||
if (typeof tail === 'number') argv.push('-n', String(tail));
|
||||
if (search) {
|
||||
// journalctl -S matches the searchable text fields (MESSAGE + others).
|
||||
// Quote-enforcing isn't needed because spawn argv doesn't touch a shell.
|
||||
argv.push('-S', search);
|
||||
}
|
||||
if (follow) argv.push('--follow');
|
||||
return argv;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a bounded tail of journal entries for a unit. Resolves to an array
|
||||
* of {timestamp, text} lines, oldest first. Throws ValidationError on bad
|
||||
* input, Error('journalctl unavailable') if the binary or journal dir is
|
||||
* missing, and Error('journalctl exited N: <stderr>') for CLI failures.
|
||||
*/
|
||||
/**
|
||||
* Spawn journalctl with the given argv and collect stdout/stderr up to
|
||||
* the configured caps. Resolves to a Buffer of stdout on success, rejects
|
||||
* with Error('journalctl unavailable') on ENOENT or
|
||||
* Error('journalctl exited N: <stderr>') on non-zero exit. Exceeding the
|
||||
* output cap rejects with an explicit overflow message.
|
||||
*
|
||||
* Kept as a free function (not inside `readEntries`) so the same plumbing
|
||||
* can be reused for streaming without code duplication.
|
||||
*/
|
||||
function runJournalctl({ exec, argv }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
|
||||
let stdout = Buffer.alloc(0);
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`output exceeded ${MAX_OUTPUT_BUFFER} bytes`));
|
||||
return;
|
||||
}
|
||||
stdout = Buffer.concat([stdout, chunk]);
|
||||
});
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderr.length < LOG_PREVIEW_BYTES) {
|
||||
stderr += chunk.toString('utf8');
|
||||
if (stderr.length > LOG_PREVIEW_BYTES) {
|
||||
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (err.code === 'ENOENT') {
|
||||
reject(new Error('journalctl unavailable'));
|
||||
} else {
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
child.on('exit', (code, signal) => {
|
||||
if (signal === 'SIGKILL' && stdout.length >= MAX_OUTPUT_BUFFER) return; // already rejected
|
||||
if (code !== 0) {
|
||||
reject(new Error(`journalctl exited ${code}${stderr ? ': ' + stderr.trim() : ''}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a journalctl --output=short line into a structured entry.
|
||||
* Lines look like: "Aug 18 00:42:46 vmi3080415 caddy[3620580]: {...}"
|
||||
*/
|
||||
function parseShortLine(line, fallbackUnit) {
|
||||
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
|
||||
if (tsMatch) {
|
||||
return {
|
||||
timestamp: tsMatch[1],
|
||||
hostname: tsMatch[2],
|
||||
unit: tsMatch[3],
|
||||
text: tsMatch[4],
|
||||
};
|
||||
}
|
||||
return { timestamp: null, hostname: null, unit: fallbackUnit, text: line };
|
||||
}
|
||||
|
||||
function readEntries(opts, { exec = spawn } = {}) {
|
||||
return Promise.resolve().then(async () => {
|
||||
const unit = assertUnitAllowed(opts.unit);
|
||||
const tail = parseTail(opts.tail);
|
||||
const since = parseTimestamp(opts.since, 'since');
|
||||
const until = parseTimestamp(opts.until, 'until');
|
||||
const search = typeof opts.search === 'string' && opts.search.length > 0
|
||||
? opts.search.slice(0, 1024)
|
||||
: null;
|
||||
|
||||
const argv = buildArgv({ unit, tail, since, until, search, follow: false });
|
||||
const { stdout } = await runJournalctl({ exec, argv });
|
||||
const lines = stdout.toString('utf8').split('\n').filter(Boolean);
|
||||
return lines.map((line) => parseShortLine(line, unit));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream journal entries as they arrive. Returns { child, onData, onError,
|
||||
* kill } — the route wires `onData`/`onError` to the SSE socket and calls
|
||||
* `kill()` on disconnect.
|
||||
*
|
||||
* The child is spawned with --follow and we cap total bytes received; on
|
||||
* overflow we kill the child and emit a synthetic 'overflow' message so the
|
||||
* client knows to reconnect with a narrower window.
|
||||
*/
|
||||
function streamEntries(opts, { exec = spawn, onData, onError } = {}) {
|
||||
const unit = assertUnitAllowed(opts.unit);
|
||||
const since = parseTimestamp(opts.since, 'since');
|
||||
const search = typeof opts.search === 'string' && opts.search.length > 0
|
||||
? opts.search.slice(0, 1024)
|
||||
: null;
|
||||
|
||||
const argv = buildArgv({ unit, since, search, follow: true });
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = exec('journalctl', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
} catch (err) {
|
||||
if (err.code === 'ENOENT') {
|
||||
const e = new Error('journalctl unavailable');
|
||||
onError && onError(e);
|
||||
return { kill: () => {}, child: null };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Closure-scoped stream bookkeeping: the previous version attached a
|
||||
// counter to the onData function itself, which made the 5000-line cap
|
||||
// unreachable (a function has its own properties — the count was never
|
||||
// incremented). Closure scope is the right place.
|
||||
let stdout = Buffer.alloc(0);
|
||||
let lineCount = 0;
|
||||
let overflowEmitted = false;
|
||||
|
||||
child.stdout.on('data', (chunk) => {
|
||||
if (stdout.length + chunk.length > MAX_OUTPUT_BUFFER) {
|
||||
child.kill('SIGKILL');
|
||||
onError && onError(new Error(`stream exceeded ${MAX_OUTPUT_BUFFER} bytes`));
|
||||
return;
|
||||
}
|
||||
stdout = Buffer.concat([stdout, chunk]);
|
||||
if (onData) {
|
||||
const text = stdout.toString('utf8');
|
||||
const lines = text.split('\n');
|
||||
// Hold back the last partial line; flush on the next chunk or exit.
|
||||
stdout = Buffer.from(lines.pop(), 'utf8');
|
||||
for (const line of lines) {
|
||||
if (!line) continue;
|
||||
lineCount++;
|
||||
if (lineCount > MAX_STREAM_LINES && !overflowEmitted) {
|
||||
overflowEmitted = true;
|
||||
child.kill('SIGKILL');
|
||||
onError && onError(new Error(`stream exceeded ${MAX_STREAM_LINES} lines`));
|
||||
return;
|
||||
}
|
||||
const tsMatch = line.match(/^([A-Z][a-z]{2} \d{2} \d{2}:\d{2}:\d{2}) (\S+) (.+?)\[\d+\]: (.*)$/);
|
||||
onData({
|
||||
timestamp: tsMatch ? tsMatch[1] : null,
|
||||
hostname: tsMatch ? tsMatch[2] : null,
|
||||
unit: tsMatch ? tsMatch[3] : unit,
|
||||
text: tsMatch ? tsMatch[4] : line,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let stderr = '';
|
||||
child.stderr.on('data', (chunk) => {
|
||||
if (stderr.length < LOG_PREVIEW_BYTES) {
|
||||
stderr += chunk.toString('utf8');
|
||||
if (stderr.length > LOG_PREVIEW_BYTES) {
|
||||
stderr = stderr.slice(0, LOG_PREVIEW_BYTES) + '…';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
onError && onError(err);
|
||||
});
|
||||
child.on('exit', (code) => {
|
||||
if (code !== 0 && stderr) {
|
||||
onError && onError(new Error(`journalctl exited ${code}: ${stderr.trim()}`));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
child,
|
||||
kill() {
|
||||
try { child.kill('SIGTERM'); } catch (_) { /* already dead */ }
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* List units that currently have journal entries (for the dashboard
|
||||
* dropdown). Walks the allow-list and asks journalctl for the most recent
|
||||
* entry per unit. Units with no entries are omitted.
|
||||
*/
|
||||
async function listUnits({ exec = spawn } = {}) {
|
||||
if (!fs.existsSync(JOURNAL_DIR)) return [];
|
||||
const out = [];
|
||||
for (const unit of ALLOWED_UNITS) {
|
||||
const lines = await new Promise((resolve) => {
|
||||
const child = exec('journalctl', [
|
||||
'--directory', JOURNAL_DIR,
|
||||
'--no-pager', '-q',
|
||||
'-u', unit,
|
||||
'-n', '1',
|
||||
'--output=short',
|
||||
], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let buf = '';
|
||||
child.stdout.on('data', (c) => { buf += c.toString('utf8'); });
|
||||
child.on('error', () => resolve(''));
|
||||
child.on('exit', () => resolve(buf));
|
||||
});
|
||||
if (lines.trim()) {
|
||||
out.push({ unit, hasEntries: true });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ALLOWED_UNITS,
|
||||
MAX_TAIL_LINES,
|
||||
MAX_OUTPUT_BUFFER,
|
||||
isAvailable,
|
||||
readEntries,
|
||||
streamEntries,
|
||||
listUnits,
|
||||
assertUnitAllowed,
|
||||
parseTail,
|
||||
parseTimestamp,
|
||||
parseShortLine,
|
||||
buildArgv,
|
||||
};
|
||||
@@ -167,6 +167,8 @@ docker run -d --restart unless-stopped --name ${CONTAINER_NAME} \
|
||||
-v /usr/bin/tailscale:/usr/bin/tailscale:ro \
|
||||
-v /var/run/tailscale:/var/run/tailscale:ro \
|
||||
-v /etc/ssl/sami-ca:/etc/ssl/sami-ca:ro \
|
||||
-v /var/log/journal:/var/log/journal:ro \
|
||||
-v /usr/bin/journalctl:/usr/bin/journalctl:ro \
|
||||
-e NODE_ENV=production \
|
||||
-e SERVICES_FILE=/app/data/services.json \
|
||||
-e CONFIG_FILE=/app/data/config.json \
|
||||
|
||||
@@ -54,6 +54,10 @@ const bundles = {
|
||||
JS('import-export.js'),
|
||||
JS('error-logs.js'),
|
||||
JS('container-logs.js'),
|
||||
// DC-055: Host journald log viewer — reads /var/log/journal via the
|
||||
// bind-mount added in start.sh. Self-contained modal with SSE stream
|
||||
// + bounded tail read. Exposes window.openJournaldModal().
|
||||
JS('journald.js'),
|
||||
JS('snapshot.js'),
|
||||
JS('smart-arr-connect.js'),
|
||||
JS('notification-settings.js'),
|
||||
|
||||
Vendored
+225
-182
File diff suppressed because one or more lines are too long
@@ -203,6 +203,7 @@
|
||||
<div class="tools-section-items">
|
||||
<button id="view-error-logs" aria-label="View error logs">📋 Error Logs</button>
|
||||
<button id="view-container-logs" aria-label="View container logs">📜 Container Logs</button>
|
||||
<button id="view-journald-logs" aria-label="Host journald logs">🛰️ Host Logs</button>
|
||||
<button id="manage-notifications" aria-label="Manage notifications">🔔 Alerts</button>
|
||||
<button id="audit-log-btn" aria-label="Audit log">📜 Audit</button>
|
||||
<button id="security-center-btn" aria-label="Security Center">🛡️ Security</button>
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
// ========== DC-055: HOST JOURNALD LOG VIEWER ==========
|
||||
// Streams host service logs (caddy, dashcaddy-api, docker, ssh, …) via the
|
||||
// journalctl bind-mount added in start.sh. Server-Sent Events for live
|
||||
// tailing; bounded non-streaming read for historical views.
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
// Allow-list mirrors the backend's ALLOWED_UNITS so the dropdown stays
|
||||
// honest when the bind-mount isn't available. The server is still the
|
||||
// source of truth — anything not in its allow-list returns 400.
|
||||
const UNIT_PRESETS = [
|
||||
{ unit: 'caddy', label: 'Caddy (reverse proxy)' },
|
||||
{ unit: 'dashcaddy-api', label: 'DashCaddy API (host systemd unit, not this container)' },
|
||||
{ unit: 'docker', label: 'Docker daemon' },
|
||||
{ unit: 'ssh', label: 'SSH server' },
|
||||
{ unit: 'systemd-journald', label: 'systemd-journald' },
|
||||
{ unit: 'tailscaled', label: 'Tailscale' },
|
||||
{ unit: 'networkd-dispatcher', label: 'Networkd dispatcher' },
|
||||
];
|
||||
|
||||
injectModal('journald-modal', `
|
||||
<div id="journald-modal" class="weather-modal" style="z-index: 1002;">
|
||||
<div class="weather-modal-content" style="min-width: 900px; max-width: 95vw; height: 80vh; display: flex; flex-direction: column;">
|
||||
<div class="logs-header" style="display: flex; justify-content: space-between; align-items: center; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 12px;">
|
||||
<div>
|
||||
<h3 style="margin: 0;">🛰️ Host Logs (journald)</h3>
|
||||
<p class="modal-subtitle" style="margin: 4px 0 0 0; font-size: 0.85rem; opacity: 0.7;">
|
||||
Stream host service logs from <code>journalctl</code> (read-only mount). Docker container logs are still in the <em>Container Logs</em> modal.
|
||||
</p>
|
||||
</div>
|
||||
<div class="logs-controls" style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end;">
|
||||
<select id="jd-unit-select" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); min-width: 220px;"></select>
|
||||
<input type="text" id="jd-search" placeholder="Search logs..." style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 160px;" />
|
||||
<input type="number" id="jd-tail" min="1" max="5000" value="200" title="Lines to load (historical view)" style="padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg); color: var(--fg); width: 90px;" />
|
||||
<button id="jd-refresh" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">🔄 Load tail</button>
|
||||
<button id="jd-stream" class="btn-accent-solid" style="padding: 6px 14px; font-size: 0.85rem;">▶ Stream</button>
|
||||
<button id="jd-clear-search" style="padding: 6px 10px; font-size: 0.85rem;" title="Clear search">✕</button>
|
||||
<button id="jd-close" class="close-btn" style="padding: 6px 10px;">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="jd-meta" style="display: flex; gap: 16px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-secondary, var(--bg)); border-radius: 6px; font-size: 0.82rem; flex-wrap: wrap;">
|
||||
<span><strong>Source:</strong> <span id="jd-source">journald</span></span>
|
||||
<span><strong>Unit:</strong> <span id="jd-unit-display">-</span></span>
|
||||
<span><strong>Stream:</strong> <span id="jd-stream-state">disconnected</span></span>
|
||||
</div>
|
||||
|
||||
<div id="jd-content" class="logs-content scroll-container" style="flex: 1; min-height: 0; background: #1a1a1a; border-radius: 6px; padding: 12px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; font-size: 0.78rem; overflow: auto;">
|
||||
<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Select a unit and click <em>Load tail</em> or <em>Stream</em>.</div>
|
||||
</div>
|
||||
|
||||
<div class="weather-modal-buttons modal-footer-bar" style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border);">
|
||||
<div style="display: flex; gap: 8px; font-size: 0.78rem; color: var(--muted);">
|
||||
<span id="jd-line-count">0 lines</span>
|
||||
<span>|</span>
|
||||
<span id="jd-filter-count">0 shown</span>
|
||||
<span>|</span>
|
||||
<span id="jd-overflow" style="display: none; color: var(--warn-fg, #fbbf24);">⚠ stream overflow — re-load with narrower window</span>
|
||||
</div>
|
||||
<button id="jd-close-btn" class="btn-secondary">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
const modal = document.getElementById('journald-modal');
|
||||
const unitSelect = document.getElementById('jd-unit-select');
|
||||
const searchInput = document.getElementById('jd-search');
|
||||
const tailInput = document.getElementById('jd-tail');
|
||||
const refreshBtn = document.getElementById('jd-refresh');
|
||||
const streamBtn = document.getElementById('jd-stream');
|
||||
const clearSearch = document.getElementById('jd-clear-search');
|
||||
const closeBtn = document.getElementById('jd-close');
|
||||
const closeBtn2 = document.getElementById('jd-close-btn');
|
||||
const content = document.getElementById('jd-content');
|
||||
const lineCount = document.getElementById('jd-line-count');
|
||||
const filterCount = document.getElementById('jd-filter-count');
|
||||
const overflowHint = document.getElementById('jd-overflow');
|
||||
const unitDisplay = document.getElementById('jd-unit-display');
|
||||
const streamState = document.getElementById('jd-stream-state');
|
||||
|
||||
let available = false; // /var/log/journal mounted?
|
||||
let lines = []; // current buffer (array of {timestamp, unit, text})
|
||||
let streaming = false;
|
||||
let eventSource = null;
|
||||
let searchTimer = null;
|
||||
|
||||
function escapeHtml(s) {
|
||||
// Local re-declaration so we don't depend on a global; same semantics
|
||||
// as the helper used by container-logs.js and error-logs.js.
|
||||
const div = document.createElement('div');
|
||||
div.textContent = String(s);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setAvailable(isAvailable) {
|
||||
available = isAvailable;
|
||||
unitSelect.innerHTML = '';
|
||||
UNIT_PRESETS.forEach(p => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = p.unit;
|
||||
opt.textContent = p.label + ' (' + p.unit + ')';
|
||||
unitSelect.appendChild(opt);
|
||||
});
|
||||
unitSelect.disabled = !isAvailable;
|
||||
if (!isAvailable) {
|
||||
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">journald bind-mount not available in this container.<br/><small>Requires <code>/var/log/journal</code> + <code>/usr/bin/journalctl</code> mounted (start.sh).</small></div>';
|
||||
refreshBtn.disabled = true;
|
||||
streamBtn.disabled = true;
|
||||
} else {
|
||||
refreshBtn.disabled = false;
|
||||
streamBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function probeAvailable() {
|
||||
try {
|
||||
const resp = await fetch('/api/v1/logs/journal/units');
|
||||
if (!resp.ok) { setAvailable(false); return; }
|
||||
const data = await resp.json();
|
||||
setAvailable(!!data.available);
|
||||
} catch (e) {
|
||||
setAvailable(false);
|
||||
}
|
||||
}
|
||||
|
||||
function renderLines() {
|
||||
const term = (searchInput.value || '').trim().toLowerCase();
|
||||
const filtered = term ? lines.filter(l => (l.textContent || '').toLowerCase().includes(term)) : lines;
|
||||
if (filtered.length === 0) {
|
||||
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">No entries' + (term ? ` matching "${escapeHtml(term)}"` : '') + '</div>';
|
||||
} else {
|
||||
const html = filtered.map(line => {
|
||||
const ts = line.timestamp ? escapeHtml(line.timestamp) : '—';
|
||||
const t = escapeHtml(line.textContent);
|
||||
return `<div class="jd-line" style="padding: 1px 0; line-height: 1.4; color: #d4d4d4;"><span style="color: var(--muted); margin-right: 8px;">${ts}</span>${t}</div>`;
|
||||
}).join('');
|
||||
content.innerHTML = html;
|
||||
// Auto-scroll only if user is already at the bottom (don't fight them).
|
||||
const nearBottom = content.scrollHeight - content.scrollTop - content.clientHeight < 80;
|
||||
if (nearBottom) content.scrollTop = content.scrollHeight;
|
||||
}
|
||||
lineCount.textContent = `${lines.length} entries`;
|
||||
filterCount.textContent = term ? `${filtered.length} of ${lines.length} shown` : `${lines.length} shown`;
|
||||
}
|
||||
|
||||
async function loadTail() {
|
||||
if (!available) return;
|
||||
stopStream();
|
||||
const unit = unitSelect.value;
|
||||
if (!unit) return;
|
||||
const tail = Math.max(1, Math.min(5000, Number(tailInput.value) || 200));
|
||||
const term = (searchInput.value || '').trim();
|
||||
content.innerHTML = '<div class="logs-loading" style="color: var(--muted); text-align: center; padding: 40px;">Loading…</div>';
|
||||
try {
|
||||
const url = new URL('/api/v1/logs/journal', window.location.origin);
|
||||
url.searchParams.set('unit', unit);
|
||||
url.searchParams.set('tail', String(tail));
|
||||
if (term) url.searchParams.set('search', term);
|
||||
const resp = await fetch(url.toString());
|
||||
const data = await resp.json();
|
||||
if (!resp.ok || !data.success) {
|
||||
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Failed: ' + escapeHtml((data && data.error) || ('HTTP ' + resp.status)) + '</div>';
|
||||
return;
|
||||
}
|
||||
unitDisplay.textContent = unit;
|
||||
lines = (data.entries || []).map(e => ({
|
||||
timestamp: e.timestamp,
|
||||
unit: e.unit,
|
||||
textContent: e.text || '',
|
||||
}));
|
||||
overflowHint.style.display = 'none';
|
||||
renderLines();
|
||||
} catch (e) {
|
||||
content.innerHTML = '<div class="logs-loading" style="color: var(--bad-fg, #ef4444); text-align: center; padding: 40px;">Error: ' + escapeHtml(e.message) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function startStream() {
|
||||
if (!available) return;
|
||||
stopStream();
|
||||
const unit = unitSelect.value;
|
||||
if (!unit) return;
|
||||
const term = (searchInput.value || '').trim();
|
||||
unitDisplay.textContent = unit;
|
||||
streamBtn.textContent = '⏸ Stop';
|
||||
streamBtn.classList.add('streaming');
|
||||
streamState.textContent = 'streaming';
|
||||
streamState.style.color = 'var(--ok-fg, #4ade80)';
|
||||
lines = [];
|
||||
renderLines();
|
||||
overflowHint.style.display = 'none';
|
||||
const url = new URL('/api/v1/logs/journal/stream', window.location.origin);
|
||||
url.searchParams.set('unit', unit);
|
||||
if (term) url.searchParams.set('search', term);
|
||||
eventSource = new EventSource(url.toString());
|
||||
eventSource.onmessage = (ev) => {
|
||||
try {
|
||||
const entry = JSON.parse(ev.data);
|
||||
if (entry.error) {
|
||||
// Overflow / validation / bind-mount errors
|
||||
if (/stream (exceeded|line cap)/.test(entry.error)) {
|
||||
overflowHint.style.display = '';
|
||||
stopStream();
|
||||
}
|
||||
content.innerHTML += '<div class="jd-line" style="color: var(--bad-fg, #ef4444); padding: 4px 0;">⚠ ' + escapeHtml(entry.error) + '</div>';
|
||||
content.scrollTop = content.scrollHeight;
|
||||
return;
|
||||
}
|
||||
lines.push({
|
||||
timestamp: entry.timestamp,
|
||||
unit: entry.unit || unit,
|
||||
textContent: entry.text || '',
|
||||
});
|
||||
// Hard cap to keep memory bounded if operator streams forever.
|
||||
if (lines.length > 5000) {
|
||||
lines = lines.slice(lines.length - 5000);
|
||||
overflowHint.style.display = '';
|
||||
}
|
||||
renderLines();
|
||||
} catch (_) {
|
||||
// Ignore malformed events; the server is authoritative.
|
||||
}
|
||||
};
|
||||
eventSource.onerror = () => {
|
||||
// EventSource auto-reconnects; mark transient if we were expecting
|
||||
// more, otherwise we closed it deliberately.
|
||||
if (!streaming) return;
|
||||
};
|
||||
streaming = true;
|
||||
}
|
||||
|
||||
function stopStream() {
|
||||
streaming = false;
|
||||
if (eventSource) {
|
||||
try { eventSource.close(); } catch (_) { /* ignore */ }
|
||||
eventSource = null;
|
||||
}
|
||||
streamBtn.textContent = '▶ Stream';
|
||||
streamBtn.classList.remove('streaming');
|
||||
streamState.textContent = 'disconnected';
|
||||
streamState.style.color = 'var(--muted)';
|
||||
}
|
||||
|
||||
function close() {
|
||||
stopStream();
|
||||
modal.classList.remove('show');
|
||||
}
|
||||
|
||||
// Wire events
|
||||
refreshBtn.addEventListener('click', loadTail);
|
||||
streamBtn.addEventListener('click', () => streaming ? stopStream() : startStream());
|
||||
clearSearch.addEventListener('click', () => { searchInput.value = ''; renderLines(); });
|
||||
searchInput.addEventListener('input', () => {
|
||||
clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(renderLines, 200);
|
||||
});
|
||||
searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') { searchInput.value = ''; renderLines(); }
|
||||
});
|
||||
closeBtn.addEventListener('click', close);
|
||||
closeBtn2.addEventListener('click', close);
|
||||
modal.addEventListener('click', (e) => { if (e.target === modal) close(); });
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape' && modal.classList.contains('show')) close();
|
||||
});
|
||||
// Reload tail automatically when the unit dropdown changes (if we have
|
||||
// data already — saves a click).
|
||||
unitSelect.addEventListener('change', () => {
|
||||
if (lines.length > 0) loadTail();
|
||||
});
|
||||
|
||||
// Hook into the existing "Container Logs" modal button so operators get a
|
||||
// separate entry point; mirror the openContainerLogsModal pattern.
|
||||
function openJournaldModal() {
|
||||
modal.classList.add('show');
|
||||
probeAvailable();
|
||||
}
|
||||
window.openJournaldModal = openJournaldModal;
|
||||
|
||||
document.getElementById('view-journald-logs')?.addEventListener('click', openJournaldModal);
|
||||
})();
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'dashcaddy-shell-3958800b99';
|
||||
const CACHE = 'dashcaddy-shell-a24ef15882';
|
||||
const PRECACHE = [
|
||||
'/',
|
||||
'/index.html',
|
||||
|
||||
Reference in New Issue
Block a user