Files
Hermes 3a74cc423a [glm-grade=B] feat(monitoring): host journald log viewer (DC-055)
Adds a dedicated dashboard surface for host journald logs (caddy, docker,
dashcaddy-api, ssh, ...) via a read-only bind-mount of /var/log/journal +
journalctl. Closes queue item #2: the only way to see the recurring
'100.120.159.34:5000 i/o timeout' spam in Caddy's health_checker logs was
SSH into DNS2.

Backend (dashcaddy-api/):
- src/monitoring/journald-reader.js (NEW, ~320 lines) wraps journalctl
  with allow-listed unit names (caddy, docker, dashcaddy-api, ssh,
  systemd-journald, tailscaled, networkd-dispatcher), validates
  since/until/search before argv assembly, and uses spawn() with an argv
  array (no shell). Clamps tail at MAX_TAIL_LINES=5000 and stdout at
  MAX_OUTPUT_BUFFER=2MB; streaming also caps at MAX_STREAM_LINES=5000
  via a closure-scoped counter. Maps ENOENT cleanly to 'journalctl
  unavailable'.
- routes/logs.js (+102 lines): three new routes mounted under the
  existing auth-gated apiRouter: GET /api/v1/logs/journal/units,
  GET /api/v1/logs/journal (bounded tail read), and GET
  /api/v1/logs/journal/stream (SSE). Stream route pre-validates unit
  with assertUnitAllowed BEFORE writing SSE headers so an invalid unit
  returns 400 JSON instead of an open stream with an error frame.
- 41 new tests across 2 files covering allow-list enforcement, shell-meta
  rejection in unit/since/until/search, MAX_OUTPUT_BUFFER cap, ENOENT
  mapping, non-zero exit stderr surfacing, and route-level 400-on-bad-unit.
  Full local suite 1831/1831 (+41 net).

Container plumbing (start.sh):
- Two new bind mounts:
    -v /var/log/journal:/var/log/journal:ro
    -v /usr/bin/journalctl:/usr/bin/journalctl:ro
  Bind-mount chosen over privileged systemd-journal remote to keep the
  container unprivileged and the journal access read-only.

Frontend (status/js/):
- journald.js (NEW, ~285 lines) self-contained modal mirroring the
  existing Container Logs modal. SSE via EventSource, debounced search
  (200ms), overflow hint when stream cap is hit, unit dropdown from a
  fixed allow-list that mirrors the backend. Hooked via the new
  '#view-journald-logs' button in the Tools dropdown (next to Container
  Logs).
- build.js (+4 lines) adds journald.js to the features bundle. Bundle
  rebuild succeeded (features.js 27 files, 466 KB raw / 1229 KB min).
  CSP hash unchanged (no inline script changes).

GLM judge (round 1, 178s, 14 tool calls, cold diff + 8 file reads):
GRADE=B. Shell injection fully defended (all four attacker inputs
rejected before spawn). Route-level allow-list holds (streamEntries not
called for bad unit). SSE cleanup correct. Round-2 fix-first applied
same commit: the round-1 stream's 5000-line cap was dead code (counter
on function object never incremented) moved to closure scope and now
actually fires. Also dropped deprecated req.on('aborted') listener
(Node 18+ fires 'close' for both clean and abort).

Container live HEAD 901df86 [glm-grade=B]; deploy via start.sh atomic
swap. Live verify: status.sami=200, container Up + healthy, the new
bundle and index.html served.
2026-08-18 01:29:19 -07:00

282 lines
13 KiB
JavaScript

// ========== 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 &quot;${escapeHtml(term)}&quot;` : '') + '</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);
})();