const express = require('express'); const fs = require('fs'); const fsp = require('fs').promises; const path = require('path'); 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 * @param {Object} deps - Explicit dependencies * @param {Function} deps.asyncHandler - Async route handler wrapper * @param {Object} deps.docker - Docker client * @param {Object} deps.logDigest - Log digest manager (optional) * @param {Object} deps.dockerMaintenance - Docker maintenance module (optional) * @returns {express.Router} */ module.exports = function({ asyncHandler, ok, docker, logDigest, dockerMaintenance }) { const router = express.Router(); // List containers with logs router.get('/logs/containers', asyncHandler(async (req, res) => { const containers = await docker.client.listContainers({ all: true }); const containerList = containers.map(c => ({ id: c.Id.slice(0, 12), name: c.Names[0]?.replace(/^\//, '') || 'unknown', image: c.Image, status: c.State, created: c.Created })); const paginationParams = parsePaginationParams(req.query); const result = paginate(containerList, paginationParams); ok(res, { containers: result.data, ...(result.pagination && { pagination: result.pagination }) }); }, 'logs-containers')); // Get logs for a specific container router.get('/logs/container/:id', asyncHandler(async (req, res) => { const containerId = req.params.id; const tail = parseInt(req.query.tail) || 100; const since = req.query.since || 0; const timestamps = req.query.timestamps !== 'false'; const container = docker.client.getContainer(containerId); let info; try { info = await container.inspect(); } catch (err) { if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Container ${containerId}`); } throw err; } const containerName = info.Name.replace(/^\//, ''); const logs = await container.logs({ stdout: true, stderr: true, tail, since, timestamps }); // Parse Docker log stream (demultiplex stdout/stderr) const lines = []; let offset = 0; const buffer = Buffer.isBuffer(logs) ? logs : Buffer.from(logs); while (offset < buffer.length) { if (offset + 8 > buffer.length) break; const header = buffer.slice(offset, offset + 8); const streamType = header[0]; const size = header.readUInt32BE(4); if (offset + 8 + size > buffer.length) break; const line = buffer.slice(offset + 8, offset + 8 + size).toString('utf8').trim(); if (line) { lines.push({ stream: streamType === 2 ? 'stderr' : 'stdout', text: line }); } offset += 8 + size; } ok(res, { containerId, containerName, logs: lines, count: lines.length }); }, 'logs-container')); // Stream logs (SSE) router.get('/logs/stream/:id', asyncHandler(async (req, res) => { const containerId = req.params.id; const container = docker.client.getContainer(containerId); try { await container.inspect(); } catch (err) { if (err.statusCode === 404 || (err.message && err.message.includes('no such container'))) { const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError(`Container ${containerId}`); } throw err; } res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); const logStream = await container.logs({ stdout: true, stderr: true, follow: true, tail: 50, timestamps: true }); let buffer = Buffer.alloc(0); logStream.on('data', (chunk) => { buffer = Buffer.concat([buffer, chunk]); while (buffer.length >= 8) { const size = buffer.readUInt32BE(4); if (buffer.length < 8 + size) break; const streamType = buffer[0]; const line = buffer.slice(8, 8 + size).toString('utf8').trim(); if (line) { const data = JSON.stringify({ stream: streamType === 2 ? 'stderr' : 'stdout', text: line, timestamp: new Date().toISOString() }); res.write(`data: ${data}\n\n`); } buffer = buffer.slice(8 + size); } }); logStream.on('error', (err) => { res.write(`data: ${JSON.stringify({ error: err.message || String(err) })}\n\n`); res.end(); }); req.on('close', () => { if (logStream.destroy) logStream.destroy(); }); }, 'logs-stream')); // Get latest daily digest router.get('/logs/digest/latest', asyncHandler(async (req, res) => { if (!logDigest) throw new Error('Log digest not available'); const digest = await logDigest.getLatestDigest(); if (!digest) { return ok(res, { digest: null, message: 'No digest available yet. First digest is generated at midnight.' }); } ok(res, { digest }); }, 'logs-digest-latest')); // Get live digest data (today's accumulated stats) router.get('/logs/digest/live', asyncHandler(async (req, res) => { if (!logDigest) throw new Error('Log digest not available'); const live = logDigest.getLiveData(); ok(res, { ...live }); }, 'logs-digest-live')); // List available digest dates router.get('/logs/digest/history', asyncHandler(async (req, res) => { if (!logDigest) throw new Error('Log digest not available'); const dates = await logDigest.listDigests(); ok(res, { dates }); }, 'logs-digest-history')); // Generate digest on demand (for today or a specific date) router.post('/logs/digest/generate', asyncHandler(async (req, res) => { if (!logDigest) throw new Error('Log digest not available'); const date = req.body.date || new Date().toISOString().slice(0, 10); // Validate date format before passing to digest generator if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new ValidationError('Invalid date format. Use YYYY-MM-DD.'); } const digest = await logDigest.generateDailyDigest(date); ok(res, { digest }); }, 'logs-digest-generate')); // Get digest for a specific date (JSON) router.get('/logs/digest/:date', asyncHandler(async (req, res) => { if (!logDigest) throw new Error('Log digest not available'); const { date } = req.params; if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { throw new ValidationError('Invalid date format. Use YYYY-MM-DD.'); } const format = req.query.format || 'json'; if (format === 'text') { const text = await logDigest.getDigestText(date); if (!text) throw new NotFoundError(`Digest for ${date}`); res.setHeader('Content-Type', 'text/plain'); return res.send(text); } const digest = await logDigest.getDigestByDate(date); if (!digest) throw new NotFoundError(`Digest for ${date}`); ok(res, { digest }); }, 'logs-digest-date')); // Get Docker disk usage snapshot router.get('/logs/docker-disk', asyncHandler(async (req, res) => { if (!dockerMaintenance) throw new Error('Docker maintenance not available'); const diskUsage = await dockerMaintenance.getDiskUsage(); const status = dockerMaintenance.getStatus(); ok(res, { diskUsage, maintenance: status }); }, 'logs-docker-disk')); // Trigger Docker maintenance manually router.post('/logs/docker-maintenance', asyncHandler(async (req, res) => { if (!dockerMaintenance) throw new Error('Docker maintenance not available'); const result = await dockerMaintenance.runMaintenance(); 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; if (!logPath) { throw new ValidationError('Log path is required'); } const platformPaths = require('../platform-paths'); const allowedPaths = platformPaths.allowedLogPaths; const normalizedPath = path.normalize(logPath); // Resolve symlinks to prevent symlink-based traversal let resolvedPath; try { resolvedPath = await fsp.realpath(normalizedPath); } catch { const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Log file'); } // Check path against allowed roots with separator boundary const isAllowed = allowedPaths.some(allowed => { const normalizedAllowed = path.normalize(allowed); return resolvedPath === normalizedAllowed || resolvedPath.startsWith(normalizedAllowed + path.sep); }); if (!isAllowed) { throw new ForbiddenError('Access to this log path is not allowed'); } if (!await exists(resolvedPath)) { const { NotFoundError } = require('../src/utilities/errors'); throw new NotFoundError('Log file'); } const fileContent = await fsp.readFile(resolvedPath, 'utf8'); const lines = fileContent.split('\n').filter(line => line.trim()); const tailLines = lines.slice(-tail); const logs = tailLines.map(line => ({ stream: 'stdout', text: line, timestamp: extractTimestamp(line) })); ok(res, { logPath: normalizedPath, logs, count: logs.length, totalLines: lines.length }); }, 'logs-file')); return router; }; function extractTimestamp(line) { const patterns = [ /^(\d{4}-\d{2}-\d{2}[T\s]\d{2}:\d{2}:\d{2})/, /^(\w{3}\s+\d{1,2},\s+\d{4}\s+\d{2}:\d{2}:\d{2})/, /^\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\]/, ]; for (const pattern of patterns) { const match = line.match(pattern); if (match) return match[1]; } return null; }