Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions build/dashboard/mining_dashboard/service/audit_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
46 changes: 42 additions & 4 deletions build/dashboard/mining_dashboard/web/server.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import logging
import math
import mimetypes
import os
import re
Expand Down Expand Up @@ -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)
Expand Down
20 changes: 20 additions & 0 deletions build/dashboard/mining_dashboard/web/static/dashboard.css
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
154 changes: 142 additions & 12 deletions build/dashboard/mining_dashboard/web/static/securityview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`<div class="chart-controls log-controls" role="group" aria-label=${label}>
${LOG_PRESETS.map(
(p) => html`<button type="button"
class=${"btn-range" + (filters.preset === p.id && !filters.fromDate && !filters.toDate ? " active" : "")}
aria-pressed=${filters.preset === p.id && !filters.fromDate && !filters.toDate}
title=${"Show the last " + p.label}
onClick=${() => set({ preset: p.id, fromDate: "", toDate: "" })}>${p.label}</button>`,
)}
<input type="date" aria-label=${label + ": from date"} value=${filters.fromDate}
onChange=${(e) => set({ fromDate: e.target.value })} />
<span class="text-muted">–</span>
<input type="date" aria-label=${label + ": to date"} value=${filters.toDate}
onChange=${(e) => set({ toDate: e.target.value })} />
<input type="search" class="log-search" placeholder="Search…" aria-label=${label + ": search"}
value=${filters.q} onInput=${(e) => set({ q: e.target.value })} />
</div>`;
};

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.
Expand Down Expand Up @@ -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`<div class="card">
Expand All @@ -59,8 +119,14 @@ const AccessCard = ({ access }) => {
</div>`;
}
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`<div class="card">
<h3>Access log</h3>
<${LogControls} label="Access log filter" filters=${filters} onChange=${onFilters} />
<p class=${failures > 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)}` : ""
Expand All @@ -75,11 +141,14 @@ const AccessCard = ({ access }) => {
<code>./pithead rotate-dashboard-onion</code>.</p>`
: null
}
<div class="table-scroll">
${
shown.length === 0 && isFiltering(filters)
? html`<p class="text-muted">No entries match this filter.</p>`
: html`<div class="table-scroll">
<table>
<thead><tr><th>Time</th><th>Status</th><th>Method</th><th>Path</th><th>User</th></tr></thead>
<tbody>
${(access.entries || []).slice(0, ACCESS_LIMIT_SHOWN).map(
${shown.map(
(e) => html`<tr>
<td>${fmtEpoch(e.ts)}</td>
<td class=${e.status === 401 ? "status-bad" : ""}>${e.status || "?"}</td>
Expand All @@ -90,7 +159,8 @@ const AccessCard = ({ access }) => {
)}
</tbody>
</table>
</div>
</div>`
}
</div>`;
};

Expand All @@ -104,14 +174,14 @@ const AuditRow = (e) => html`<tr>
<td class="font-mono">${e.keys}</td>
</tr>`;

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`<div class="card">
<div class="card-header-row">
<h3>Recent config changes</h3>
${
audit.length > 0
audit.length > 0 || isFiltering(filters)
? html`<select aria-label="Group audit trail by" value=${group} onChange=${(e) => onGroupChange(e.target.value)}>
<option value="flat">All (newest first)</option>
<option value="hour">Group by hour</option>
Expand All @@ -121,9 +191,14 @@ const AuditCard = ({ audit, group, onGroupChange }) => {
: null
}
</div>
<${LogControls} label="Config-change filter" filters=${filters} onChange=${onFilters} />
${
audit.length === 0
? html`<p class="text-muted">No config changes have gone through the dashboard yet.</p>`
? html`<p class="text-muted">${
isFiltering(filters)
? "No entries match this filter."
: "No config changes have gone through the dashboard yet."
}</p>`
: html`<div class="table-scroll">
<table>
<thead><tr><th>Time (UTC)</th><th>User</th><th>Action</th><th>Outcome</th><th>Settings</th></tr></thead>
Expand All @@ -147,9 +222,63 @@ export class SecurityPanel extends Component {
constructor(props) {
super(props);
// auditGroup: "flat" (today's plain newest-first list) is the default so existing behavior
// doesn't change until the operator opts into grouping (#530).
this.state = { access: null, audit: null, auditGroup: "flat", error: null };
// doesn't change until the operator opts into grouping (#530). Each card carries its own
// #823 filter state — following the access log and pinning down one config change are
// different investigations.
this.state = {
access: null,
audit: null,
auditGroup: "flat",
accessFilters: { ...EMPTY_FILTERS },
auditFilters: { ...EMPTY_FILTERS },
error: null,
};
this.setAuditGroup = (group) => this.setState({ auditGroup: group });
// Search keystrokes debounce (300 ms) so each letter doesn't hit the endpoint; preset and
// date changes apply immediately — they are single deliberate clicks.
this.setAccessFilters = (f) => this.applyFilters("access", "accessFilters", f);
this.setAuditFilters = (f) => this.applyFilters("audit", "auditFilters", f);
}

applyFilters(which, key, filters) {
const prev = this.state[key];
this.setState({ [key]: filters });
clearTimeout(this._debounce?.[which]);
const run = () => this.refetch(which, filters);
if (filters.q !== prev.q) {
this._debounce = { ...this._debounce, [which]: setTimeout(run, 300) };
} else {
run();
}
}

async refetch(which, filters) {
// Sequence guard: two quick filter changes can land responses out of order — only the
// NEWEST request for a surface may write state, or a slow stale response would overwrite
// the fresher view the operator is already looking at.
if (!this._seq) this._seq = {};
this._seq[which] = (this._seq[which] || 0) + 1;
const seq = this._seq;
const mine = seq[which];
try {
const qs = buildLogQuery(filters);
if (which === "access") {
const res = await fetch("/api/access" + qs);
if (res.ok && seq[which] === mine) this.setState({ access: await res.json() });
} else {
const res = await fetch("/api/audit" + qs);
if (res.ok && seq[which] === mine)
this.setState({ audit: (await res.json()).entries || [] });
}
} catch (e) {
if (seq[which] === mine) this.setState({ error: String(e) });
}
}

componentWillUnmount() {
// A pending search debounce firing after unmount would setState on a dead component.
for (const t of Object.values(this._debounce || {})) clearTimeout(t);
this._debounce = {};
}

async componentDidMount() {
Expand All @@ -165,11 +294,12 @@ export class SecurityPanel extends Component {
}

render() {
const { access, audit, auditGroup, error } = this.state;
const { access, audit, auditGroup, accessFilters, auditFilters, error } = this.state;
if (error) return html`<div class="card"><p class="status-bad">${error}</p></div>`;
return html`<div class="grid">
<${AccessCard} access=${access} />
<${AuditCard} audit=${audit} group=${auditGroup} onGroupChange=${this.setAuditGroup} />
<${AccessCard} access=${access} filters=${accessFilters} onFilters=${this.setAccessFilters} />
<${AuditCard} audit=${audit} group=${auditGroup} onGroupChange=${this.setAuditGroup}
filters=${auditFilters} onFilters=${this.setAuditFilters} />
</div>`;
}
}
Loading