diff --git a/build/dashboard/mining_dashboard/service/audit_service.py b/build/dashboard/mining_dashboard/service/audit_service.py index 98dcfef8..56e33b01 100644 --- a/build/dashboard/mining_dashboard/service/audit_service.py +++ b/build/dashboard/mining_dashboard/service/audit_service.py @@ -14,6 +14,7 @@ ``control_audit`` and Caddy rolls its own access log — this module only ever reads a tail. """ +import calendar import json import os import re @@ -93,6 +94,47 @@ def recent_changes(limit=50): return entries[::-1][:limit] +# Log-navigation filters (#823). One helper serves BOTH log surfaces even though their timestamps +# differ — access entries carry epoch seconds, audit entries the canonical "YYYY-MM-DDTHH:MM:SSZ" +# string — by normalizing each entry's ts to epoch at the comparison. The window is half-open +# [frm, to) so a "to" built from a date input's next midnight includes that whole day exactly +# once; an entry whose ts cannot be read matches NO window (filtering means placing entries in +# time — an undatable row has no place) but still matches a pure text search. + + +def _entry_epoch(ts): + """``ts`` as epoch seconds, or None when unreadable. Accepts the two shapes the log surfaces + actually emit: a number (access log) or the canonical UTC ISO string (audit trail).""" + if isinstance(ts, (int, float)): + return float(ts) + if isinstance(ts, str): + try: + return float(calendar.timegm(time.strptime(ts, "%Y-%m-%dT%H:%M:%SZ"))) + except ValueError: + return None + return None + + +def filter_log_entries(entries, frm=None, to=None, q=None): + """``entries`` narrowed to the [frm, to) epoch window and/or a case-insensitive substring + ``q`` across every field value. Filters compose; None means "don't filter on this axis".""" + ql = (q or "").lower() + out = [] + for e in entries: + if frm is not None or to is not None: + ep = _entry_epoch(e.get("ts")) + if ep is None: + continue + if frm is not None and ep < frm: + continue + if to is not None and ep >= to: + continue + if ql and not any(ql in str(v).lower() for v in e.values()): + continue + out.append(e) + return out + + def access_summary(limit=50, now=None): """Recent dashboard accesses plus the rotate-signal: 401s in the last 24 h. diff --git a/build/dashboard/mining_dashboard/web/server.py b/build/dashboard/mining_dashboard/web/server.py index 04a18559..05148eee 100644 --- a/build/dashboard/mining_dashboard/web/server.py +++ b/build/dashboard/mining_dashboard/web/server.py @@ -1,4 +1,5 @@ import logging +import math import mimetypes import os import re @@ -359,24 +360,61 @@ def _merged_audit_entries(state_mgr): return sorted(merged.values(), key=lambda e: e.get("ts", ""), reverse=True) +def _log_filters(request): + """The #823 navigation params, parsed defensively: ``from``/``to`` as epoch seconds (anything + non-numeric reads as absent — a malformed bound must never 500 a log view) and ``q`` trimmed + and length-capped (it's compared, never stored or echoed unsanitized).""" + + def _num(name): + v = request.query.get(name) + if v in (None, ""): + return None + try: + f = float(v) + except ValueError: + return None + # float() happily parses "inf"/"nan", which would silently warp the window comparisons + # (nan compares False with everything) — a non-finite bound is malformed, so it's absent. + return f if math.isfinite(f) else None + + q = (request.query.get("q") or "").strip()[:200] + return _num("from"), _num("to"), q or None + + async def handle_audit_log(request): """Config-change audit entries — the #33 control-channel log plus the out-of-band host-edit / rig-edit detections (#530), merged and persisted so the Security panel can group by hour/day/ month deeper than the log's own trimmed tail. Registered only alongside the control channel — - the log is a #33 artifact and the out-of-band watchers only run when it's on.""" + the log is a #33 artifact and the out-of-band watchers only run when it's on. + Accepts the #823 navigation params (``from``/``to`` epoch seconds, ``q`` substring).""" try: state_mgr = request.app["state_manager"] - return web.json_response({"entries": _merged_audit_entries(state_mgr)}) + frm, to, q = _log_filters(request) + entries = audit_service.filter_log_entries(_merged_audit_entries(state_mgr), frm, to, q) + return web.json_response({"entries": entries}) except Exception: logger.exception("Error reading the control audit log") return web.json_response({"error": "Failed to read the audit log."}, status=500) +# How deep the access log is read when the operator is NAVIGATING it (#823) vs the default +# glance. The tail read is byte-bounded either way (audit_service._TAIL_BYTES) — this only stops +# a filtered view from being quietly truncated to the glance depth before the filter even runs. +_ACCESS_NAV_LIMIT = 1000 + + async def handle_access_log(request): """Recent dashboard accesses + failed-login count, from Caddy's JSON access log. Always - registered (Caddy always writes the log); behind the same Caddy basic_auth as every route.""" + registered (Caddy always writes the log); behind the same Caddy basic_auth as every route. + Accepts the #823 navigation params; the failure counters always describe the whole tail, + never the filtered slice.""" try: - return web.json_response(audit_service.access_summary()) + frm, to, q = _log_filters(request) + filtering = frm is not None or to is not None or q is not None + summary = audit_service.access_summary(limit=_ACCESS_NAV_LIMIT if filtering else 50) + if filtering: + summary["entries"] = audit_service.filter_log_entries(summary["entries"], frm, to, q) + return web.json_response(summary) except Exception: logger.exception("Error reading the access log") return web.json_response({"error": "Failed to read the access log."}, status=500) diff --git a/build/dashboard/mining_dashboard/web/static/dashboard.css b/build/dashboard/mining_dashboard/web/static/dashboard.css index e6b03348..dec2c6e9 100644 --- a/build/dashboard/mining_dashboard/web/static/dashboard.css +++ b/build/dashboard/mining_dashboard/web/static/dashboard.css @@ -491,6 +491,26 @@ tr:last-child td { .est-scroll { overflow-x: auto; } +/* Log navigation (#823): the Security cards' shared filter row — the chart-controls preset + * idiom plus native date inputs and a search box, left-aligned to read as part of its card. */ +.log-controls { + justify-content: flex-start; + margin-bottom: 10px; +} +.log-controls input[type="date"], +.log-controls input[type="search"] { + background: var(--bg); + color: var(--text); + border: 1px solid var(--border); + border-radius: 6px; + padding: 2px 6px; + font-size: 0.8rem; +} +.log-controls input[type="search"] { + flex: 1; + min-width: 110px; +} + /* Expected-vs-actual card (#808/#817): three labelled rows, not a shared-precision numeric * grid — values wrap instead of panning (this card must never scroll in either view) and the * table fills the card. table-layout fixed, because in auto layout a cell's min-content width diff --git a/build/dashboard/mining_dashboard/web/static/securityview.mjs b/build/dashboard/mining_dashboard/web/static/securityview.mjs index b79cad92..fec4a9c0 100644 --- a/build/dashboard/mining_dashboard/web/static/securityview.mjs +++ b/build/dashboard/mining_dashboard/web/static/securityview.mjs @@ -13,6 +13,66 @@ import { Component, html } from "./preact.mjs"; const ACCESS_LIMIT_SHOWN = 20; +// --- Log navigation (#823) ------------------------------------------------------------- +// +// Both cards share one control row: the chart's preset idiom (24 Hr / 1 Wk / 1 Mo / All) for +// "following" a live log, two native date inputs for jumping to a specific time, and a search +// box. Filtering is SERVER-side (?from&to&q on /api/access and /api/audit) so a match outside +// the glance tail is still found; the server owns sanitation, this file only builds the query. + +export const LOG_PRESETS = [ + { id: "24h", label: "24 Hr", secs: 86_400 }, + { id: "7d", label: "1 Wk", secs: 7 * 86_400 }, + { id: "30d", label: "1 Mo", secs: 30 * 86_400 }, + { id: "all", label: "All", secs: null }, +]; + +// UI filter state -> the endpoint query string. Preset and explicit dates are mutually +// exclusive (the controls clear one when the other is picked); an explicit "to" date means +// "through that whole day", so it maps to the NEXT midnight as the half-open upper bound. +// Date inputs parse as UTC midnight (the audit trail is displayed in UTC) — close enough for +// day-granularity jumps either way, and stable across viewer timezones. +export function buildLogQuery({ preset = "all", fromDate = "", toDate = "", q = "" } = {}, now) { + const p = new URLSearchParams(); + const nowSec = now !== undefined ? now : Date.now() / 1000; + const chosen = LOG_PRESETS.find((x) => x.id === preset); + if (fromDate || toDate) { + const from = Date.parse(fromDate) / 1000; + const to = Date.parse(toDate) / 1000; + if (Number.isFinite(from)) p.set("from", String(from)); + if (Number.isFinite(to)) p.set("to", String(to + 86_400)); + } else if (chosen && chosen.secs !== null) { + p.set("from", String(nowSec - chosen.secs)); + } + const qq = q.trim(); + if (qq) p.set("q", qq); + const s = p.toString(); + return s ? `?${s}` : ""; +} + +const LogControls = ({ label, filters, onChange }) => { + const set = (patch) => onChange({ ...filters, ...patch }); + return html`
+ ${LOG_PRESETS.map( + (p) => html``, + )} + set({ fromDate: e.target.value })} /> + + set({ toDate: e.target.value })} /> + set({ q: e.target.value })} /> +
`; +}; + +const EMPTY_FILTERS = { preset: "all", fromDate: "", toDate: "", q: "" }; +const isFiltering = (f) => f.preset !== "all" || !!f.fromDate || !!f.toDate || !!f.q.trim(); + // Audit entries share one ts format across every source (control.log's own writer and the #530 // watchers both emit "YYYY-MM-DDTHH:MM:SSZ", see data_service._iso_now), so a bucket key is a // plain string slice — no date parsing, no timezone math. @@ -49,7 +109,7 @@ export function fmtEpoch(ts) { return new Date(ts * 1000).toLocaleString(); } -const AccessCard = ({ access }) => { +const AccessCard = ({ access, filters, onFilters }) => { if (!access) return null; if (!access.available) { return html`
@@ -59,8 +119,14 @@ const AccessCard = ({ access }) => {
`; } const failures = access.failures_24h || 0; + // A filtered view shows every match the server returned (its read is already bounded); only + // the unfiltered glance keeps the short tail so the card stays a glance. + const shown = isFiltering(filters) + ? access.entries || [] + : (access.entries || []).slice(0, ACCESS_LIMIT_SHOWN); return html`

Access log

+ <${LogControls} label="Access log filter" filters=${filters} onChange=${onFilters} />

0 ? "status-warn" : "status-ok"}> ${failures} failed login${failures === 1 ? "" : "s"} in the last 24 h${ access.last_failure_ts ? html` — last at ${fmtEpoch(access.last_failure_ts)}` : "" @@ -75,11 +141,14 @@ const AccessCard = ({ access }) => { ./pithead rotate-dashboard-onion.

` : null } -
+ ${ + shown.length === 0 && isFiltering(filters) + ? html`

No entries match this filter.

` + : html`
- ${(access.entries || []).slice(0, ACCESS_LIMIT_SHOWN).map( + ${shown.map( (e) => html` @@ -90,7 +159,8 @@ const AccessCard = ({ access }) => { )}
TimeStatusMethodPathUser
${fmtEpoch(e.ts)} ${e.status || "?"}
-
+
` + }
`; }; @@ -104,14 +174,14 @@ const AuditRow = (e) => html` ${e.keys} `; -const AuditCard = ({ audit, group, onGroupChange }) => { +const AuditCard = ({ audit, group, onGroupChange, filters, onFilters }) => { // null = control channel off (the /api/audit route 404s) — no card at all. if (!audit) return null; return html`

Recent config changes

${ - audit.length > 0 + audit.length > 0 || isFiltering(filters) ? html` { + const NOW = 1_760_000_000; + // Preset -> a trailing from-window; All -> no params at all. + assert.equal(buildLogQuery({ preset: '24h', fromDate: '', toDate: '', q: '' }, NOW), + `?from=${NOW - 86_400}`); + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: '' }, NOW), ''); + // Explicit dates OVERRIDE the preset, and "to" covers that whole day (next-midnight bound). + const from = Date.parse('2026-07-10') / 1000; + const to = Date.parse('2026-07-11') / 1000 + 86_400; + assert.equal( + buildLogQuery({ preset: '24h', fromDate: '2026-07-10', toDate: '2026-07-11', q: '' }, NOW), + `?from=${from}&to=${to}`); + // Search rides along URL-encoded; blank search adds nothing. + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: 'api state' }, NOW), + '?q=api+state'); + assert.equal(buildLogQuery({ preset: 'all', fromDate: '', toDate: '', q: ' ' }, NOW), ''); +}); + +test('both cards render the shared filter controls with the active preset marked', () => { + const html = renderPanel({ + access: access(), + audit: [{ ts: "2026-07-10T12:00:00Z", actor: "admin", action: "commit", status: "applied", keys: "X" }], + accessFilters: { preset: '24h', fromDate: '', toDate: '', q: '' }, + auditFilters: { preset: 'all', fromDate: '', toDate: '', q: '' }, + }); + assert.match(html, /aria-label="Access log filter"/); + assert.match(html, /aria-label="Config-change filter"/); + assert.match(html, /class="btn-range active"[^>]*>24 Hr { + // Filtered: the 20-row glance cap is lifted (server already bounded the read). + const many = access({ entries: Array.from({ length: 30 }, (_, i) => ({ + ts: 1000 + i, status: 200, method: 'GET', uri: `/p/${i}`, user: 'u' })) }); + const filtered = renderPanel({ + access: many, + accessFilters: { preset: '7d', fromDate: '', toDate: '', q: '' }, + }); + assert.match(filtered, /\/p\/29/); // the 30th row renders under a filter + const glance = renderPanel({ access: many }); + assert.doesNotMatch(glance, /\/p\/29/); // unfiltered keeps the glance cap + // No matches under a filter says so, instead of the no-changes-yet copy. + const empty = renderPanel({ + access: access({ entries: [] }), + accessFilters: { preset: 'all', fromDate: '', toDate: '', q: 'zzz' }, + audit: [], + auditFilters: { preset: 'all', fromDate: '', toDate: '', q: 'zzz' }, + }); + assert.match(empty, /No entries match this filter/); + assert.doesNotMatch(empty, /No config changes have gone through/); +}); diff --git a/build/dashboard/tests/service/test_audit_service.py b/build/dashboard/tests/service/test_audit_service.py index 20736b79..bc11f352 100644 --- a/build/dashboard/tests/service/test_audit_service.py +++ b/build/dashboard/tests/service/test_audit_service.py @@ -170,3 +170,60 @@ def test_entries_newest_first_and_limited(self, access_log): entries = audit_service.access_summary(limit=50, now=100.0)["entries"] assert len(entries) == 50 assert entries[0]["uri"] == "/p59" + + +class TestFilterLogEntries: + # Log navigation (#823): one filter helper for BOTH surfaces — access entries carry epoch ts, + # audit entries the canonical UTC ISO string. Window is half-open [frm, to). + + ENTRIES = [ + {"ts": 100.0, "status": 401, "uri": "/api/state", "user": "admin"}, + {"ts": 200.0, "status": 200, "uri": "/static/app.js", "user": "vijit"}, + {"ts": "2026-08-01T12:00:00Z", "actor": "admin", "action": "commit", "status": "applied"}, + {"ts": "garbage", "actor": "release-smoke", "action": "upgrade"}, + ] + + def test_no_filters_passes_everything_through(self): + assert audit_service.filter_log_entries(self.ENTRIES) == self.ENTRIES + + def test_entry_epoch_reads_only_the_two_real_shapes(self): + # A ts that is neither a number nor a string (a missing key's None, a corrupt row's + # dict) is undatable — no exception, no guess. + assert audit_service._entry_epoch(None) is None + assert audit_service._entry_epoch({"nested": 1}) is None + + def test_window_is_half_open_and_reads_both_ts_shapes(self): + # frm inclusive, to exclusive; the ISO entry's epoch (2026-08-01T12:00Z) sits far above + # the numeric ones, so a tight numeric window keeps only the 200.0 row... + assert audit_service.filter_log_entries(self.ENTRIES, frm=200.0, to=200.1) == [ + self.ENTRIES[1] + ] + # ...and a window around the ISO instant keeps only the audit row — proof both shapes + # normalize onto one axis. + iso_epoch = audit_service._entry_epoch("2026-08-01T12:00:00Z") + got = audit_service.filter_log_entries(self.ENTRIES, frm=iso_epoch, to=iso_epoch + 1) + assert got == [self.ENTRIES[2]] + # to is exclusive: a window ENDING exactly on an entry's ts drops it. + assert audit_service.filter_log_entries(self.ENTRIES, frm=100.0, to=200.0) == [ + self.ENTRIES[0] + ] + + def test_undatable_ts_matches_no_window_but_still_searches(self): + # Filtering by time means placing entries in time — the "garbage"-ts row has no place in + # any window, but a pure text search still finds it. + assert audit_service.filter_log_entries(self.ENTRIES, frm=0.0) == self.ENTRIES[:3] + assert audit_service.filter_log_entries(self.ENTRIES, q="release-smoke") == [ + self.ENTRIES[3] + ] + + def test_search_is_case_insensitive_across_every_field(self): + assert audit_service.filter_log_entries(self.ENTRIES, q="ADMIN") == [ + self.ENTRIES[0], + self.ENTRIES[2], + ] + # Numeric field values participate too (status 401 as text). + assert audit_service.filter_log_entries(self.ENTRIES, q="401") == [self.ENTRIES[0]] + + def test_filters_compose(self): + got = audit_service.filter_log_entries(self.ENTRIES, frm=0.0, to=300.0, q="vijit") + assert got == [self.ENTRIES[1]] diff --git a/build/dashboard/tests/web/test_server.py b/build/dashboard/tests/web/test_server.py index b79c1cca..282941c6 100644 --- a/build/dashboard/tests/web/test_server.py +++ b/build/dashboard/tests/web/test_server.py @@ -474,6 +474,75 @@ async def test_audit_route_serves_sanitized_entries( assert body["entries"][0]["keys"] == "XVB_ENABLED" assert "<" not in json.dumps(body) and ">" not in json.dumps(body) + async def test_access_route_navigation_params_filter_entries( + self, client, tmp_path, monkeypatch + ): + # #823: from/to (epoch seconds, half-open) and q narrow the served entries; the failure + # counters keep describing the whole tail; malformed bounds read as absent, never a 500. + log = tmp_path / "access.log" + rows = [ + { + "ts": 100.0, + "status": 200, + "user_id": "admin", + "request": {"method": "GET", "uri": "/api/state"}, + }, + { + "ts": 200.0, + "status": 401, + "user_id": "guess", + "request": {"method": "GET", "uri": "/login"}, + }, + ] + log.write_text("".join(json.dumps(r) + "\n" for r in rows)) + monkeypatch.setattr(audit_service.config, "ACCESS_LOG_PATH", str(log)) + body = await (await client.get("/api/access?from=150")).json() + assert [e["ts"] for e in body["entries"]] == [200.0] + body = await (await client.get("/api/access?q=api/state")).json() + assert [e["ts"] for e in body["entries"]] == [100.0] + # to is exclusive; and the 401 counter is window-independent (whole-tail semantics). + body = await (await client.get("/api/access?from=100&to=200")).json() + assert [e["ts"] for e in body["entries"]] == [100.0] + assert "failures_24h" in body + # Malformed bounds degrade to unfiltered, HTTP 200 — including the float()-parseable + # non-finite spellings, which would otherwise warp the comparisons (nan is never <). + for bad in ("notanumber", "inf", "-inf", "nan", ""): + resp = await client.get(f"/api/access?from={bad}&to={bad}&q=") + assert resp.status == 200 + assert len((await resp.json())["entries"]) == 2 + + async def test_audit_route_navigation_params_filter_entries( + self, control_client, tmp_path, monkeypatch + ): + # #823 on the audit side: ISO timestamps land on the same epoch axis, and q searches + # the sanitized fields. + log = tmp_path / "control.log" + rows = [ + { + "ts": "2026-07-10T12:00:00Z", + "id": "11111111-1111-4111-8111-111111111111", + "actor": "admin", + "action": "commit", + "status": "applied", + "keys": "XVB_ENABLED", + }, + { + "ts": "2026-07-20T12:00:00Z", + "id": "22222222-2222-4222-8222-222222222222", + "actor": "release-smoke", + "action": "upgrade", + "status": "upgraded", + "keys": "", + }, + ] + log.write_text("".join(json.dumps(r) + "\n" for r in rows)) + monkeypatch.setattr(audit_service.config, "CONTROL_AUDIT_LOG", str(log)) + cutoff = audit_service._entry_epoch("2026-07-15T00:00:00Z") + body = await (await control_client.get(f"/api/audit?from={cutoff}")).json() + assert [e["actor"] for e in body["entries"]] == ["release-smoke"] + body = await (await control_client.get("/api/audit?q=xvb_enabled")).json() + assert [e["actor"] for e in body["entries"]] == ["admin"] + async def test_audit_route_missing_log_is_empty(self, control_client, monkeypatch): monkeypatch.setattr(audit_service.config, "CONTROL_AUDIT_LOG", "/nonexistent/control.log") resp = await control_client.get("/api/audit") diff --git a/docs/dashboard.md b/docs/dashboard.md index efb05dfe..409c3731 100644 --- a/docs/dashboard.md +++ b/docs/dashboard.md @@ -811,6 +811,15 @@ Below the form, the Configuration view shows two read-only security panels that changed. Values are never recorded (several are secrets). Shown only when `dashboard.control.enabled` is on. +Each panel carries the same navigation row: range presets (**24 Hr / 1 Wk / 1 Mo / All**, the +chart's idiom) for following a live log, two date fields for jumping to a specific day or span — +the "to" date covers that whole day — and a search box that matches any field: a user, an action, +a path fragment, a status, a settings name. Filters compose (a search inside a range searches only +that range), the search narrows as you type, and filtering happens on the server, so a match +deeper than the on-screen tail is still found — the access log's read stays size-bounded either +way. A filter with no matches says so; the failed-login counter always describes the whole log, +never the filtered slice. + Both panels read host-written files through read-only mounts, and the dashboard treats every field in them as hostile input — a request path is attacker-chosen bytes — so each string is stripped to a safe character set before it is served. See