Phase 4 gave us structured logging, webhooks, Slack, email, and Prometheus metrics. But managing Guardian Shell still required SSH access, editing TOML files, and reading raw logs:
Problem 1: No visual overview. There's no at-a-glance view of system status — how many agents are active, how many events have fired, what's been blocked. You had to run guardian-ctl list and query Prometheus manually.
Problem 2: No real-time event visibility. Watching events required tailing log files or stderr. There was no way to filter events by severity, agent, or action without grep. Incident response meant scrolling through walls of text.
Problem 3: Policy editing is error-prone. Editing TOML files by hand is tedious and error-prone. One misplaced quote breaks the config. There's no immediate feedback on what rules are active for each agent.
Problem 4: Agent management requires CLI. Stopping an agent or granting temporary access required guardian-ctl over SSH. There was no web-based management interface for teams.
Problem 5: Alert configuration is static. Changing webhook URLs, enabling Slack, or adjusting severity thresholds required editing the config file and restarting the daemon.
Phase 5 solves all five by embedding a web dashboard directly into the guardian binary: real-time SSE event streaming, visual policy editing, agent management, alert configuration — all from a browser.
| File | Lines | Purpose |
|---|---|---|
guardian/src/dashboard/mod.rs |
84 | Axum router, static file handler via rust-embed, server startup |
guardian/src/dashboard/state.rs |
16 | DashboardState struct: shared refs to IPC state, alert sender, event bus, SQLite DB |
guardian/src/dashboard/db.rs |
200 | SQLite event store: schema, insert, query with filters, pagination, pruning |
guardian/src/dashboard/routes/mod.rs |
3 | Route sub-module declarations |
guardian/src/dashboard/routes/pages.rs |
188 | Full page handlers with askama template rendering for all 5 pages |
guardian/src/dashboard/routes/api.rs |
420 | API handlers: stop/grant agents, policy CRUD, alert config, config reload, Prometheus, event query |
guardian/src/dashboard/routes/sse.rs |
35 | Resilient SSE endpoint with lag event forwarding via tokio broadcast channel |
guardian/askama.toml |
2 | Askama template directory configuration |
guardian/templates/base.html |
62 | Base layout: sidebar navigation, TailwindCSS/htmx/Alpine.js CDN links, toast area |
guardian/templates/index.html |
105 | Overview page: auto-refreshing status cards + SSE recent events with reconnection |
guardian/templates/events.html |
200 | Events page: Live + History tabs, SSE with reconnection, SQLite-backed history with pagination |
guardian/templates/agents.html |
95 | Agent management: configured agents table + active cgroup agents with stop/grant |
guardian/templates/policy.html |
82 | Policy editor: accordion per agent, editable allow/deny/exec rules |
guardian/templates/alerts.html |
100 | Alert config: global settings + per-output toggle and field editing |
guardian/static/app.js |
45 | Custom JavaScript for SSE event rendering (index page) |
guardian/static/app.css |
2 | Custom CSS placeholder (TailwindCSS handles most styling) |
| File | What Changed |
|---|---|
guardian/Cargo.toml |
Added axum 0.8, askama 0.12, askama_axum 0.4, rust-embed 8, tower 0.5, tower-http 0.6, tokio-stream 0.1, rusqlite 0.31 (bundled) |
guardian/src/main.rs |
Added mod dashboard, created broadcast::channel for SSE, spawned dashboard tokio task, spawned DB writer task and daily pruning task, added dashboard handle to shutdown cleanup |
guardian/src/config.rs |
Added DashboardConfig struct with enabled, listen_address, and db_path fields. Added dashboard field to Config. |
guardian/src/alerting/mod.rs |
Extended AlertSender with event_bus: Option<broadcast::Sender<AlertEvent>>, added with_event_bus() builder method. send() now broadcasts to SSE clients before queuing to AlertManager. |
guardian/src/ipc.rs |
Added public wrapper functions (cleanup_agent_pub, path_to_lpm_key_pub, path_to_map_key_pub) for dashboard API access |
config.toml |
Added [dashboard] section with enabled = true and listen_address |
configs/development.toml |
Added [dashboard] section |
CLAUDE.md |
Updated to Phase 5 with new file structure, design decisions, dependencies, limitations |
USER SPACE
┌──────────────────────────────────────────────────────────────────┐
│ │
│ guardian-launch Guardian Daemon │
│ ┌────────────────┐ IPC ┌──────────────────────┐ │
│ │ Create cgroup ├──────────────>│ Unix socket listener │ │
│ │ Set limits │ register │ /run/guardian.sock │ │
│ │ Register │<─────────────┤ │ │
│ │ exec(agent) │ ACK │ Populates BPF maps │ │
│ └────────────────┘ │ Background tasks │ │
│ └──────────┬───────────┘ │
│ │ │
│ Alerting Subsystem (Phase 4): │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ Event Processors ──► AlertSender │ │ │
│ │ ├► Prometheus counters (sync) │ │ │
│ │ ├► broadcast::channel ──► SSE ─────┼─────┼─► Browser │
│ │ └► mpsc channel ──► AlertManager │ │ │
│ │ ├► JSON Log ├► Webhook │ │ │
│ │ ├► Slack └► Email │ │ │
│ └──────────────────────────────────────┘ │ │
│ │ │
│ Dashboard (Phase 5): │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ axum HTTP server (:8080) │ │ │
│ │ ├► GET / Overview page │ │ │
│ │ ├► GET /events Live SSE feed │ │ │
│ │ ├► GET /agents Agent mgmt │ │ │
│ │ ├► GET /policy Policy editor │ │ │
│ │ ├► GET /alerts Alert config │ │ │
│ │ ├► GET /metrics Prometheus │ │ │
│ │ ├► GET /events/stream SSE endpoint│ │ │
│ │ ├► POST /api/agents/*/stop │ │ │
│ │ ├► POST /api/agents/*/grant │ │ │
│ │ ├► PUT /api/policy/* │ │ │
│ │ ├► PUT /api/alerts │ │ │
│ │ ├► POST /api/config/reload │ │ │
│ │ └► GET /api/status (htmx poll) │ │ │
│ │ │ │ │
│ │ State: Arc<DashboardState> │ │ │
│ │ ├► ipc_state (SharedIpcState) │ │ │
│ │ ├► alert_sender (AlertSender) │ │ │
│ │ ├► event_bus (broadcast::Sender) │ │ │
│ │ ├► config_path (PathBuf) │ │ │
│ │ └► db (Arc<EventDb>) │ │ │
│ └──────────────────────────────────────┘ │ │
│ │ │
│ SQLite Event Store: │ │
│ ┌──────────────────────────────────────┐ │ │
│ │ /var/lib/guardian/events.db │ │ │
│ │ ├► events table (all fields) │ │ │
│ │ ├► Indexes: timestamp, severity, │ │ │
│ │ │ agent_name, action │ │ │
│ │ ├► WAL mode (concurrent R/W) │ │ │
│ │ └► Auto-prune after 30 days │ │ │
│ └──────────────────────────────────────┘ │ │
│ │ │
├════════════════════════════════════════════════╪════════════════┤
│ KERNEL SPACE │ │
│ eBPF Programs (unchanged from Phase 4) │ │
└──────────────────────────────────────────────────────────────────┘
eBPF event → per-CPU perf reader → process_file_event()
│
├─► AlertSender.send()
│ │
│ ├─► Prometheus counter (atomic)
│ ├─► broadcast::channel
│ │ │
│ │ ├─► SSE subscribers (/events/stream)
│ │ │ │
│ │ │ └─► EventSource (browser)
│ │ │ ├─► Overview recent events
│ │ │ └─► Live events tab
│ │ │
│ │ ├─► DB writer task ──► SQLite
│ │ │ └─► events table (persistent)
│ │ │ └─► /api/events (history queries)
│ │ │ └─► History tab (browser)
│ │ │
│ │ └─► (lagged clients receive "lag" SSE event)
│ │
│ └─► mpsc channel ──► AlertManager
│ └─► JSON/webhook/Slack/email
│
└─► env_logger (stderr, unchanged)
Embedded server, not standalone. The dashboard runs inside the guardian daemon as a tokio task — no separate process, no separate binary, no IPC overhead. It shares the same SharedIpcState that the IPC server uses, and the same AlertSender metrics that event processors use.
Broadcast channel for fan-out. tokio::sync::broadcast is designed for multi-consumer scenarios. Each SSE client subscribes and gets its own receiver. A dedicated DB writer task also subscribes, persisting every event to SQLite. If a client falls behind, the BroadcastStream adapter handles the Lagged error by sending a "lag" SSE event to the client — no backpressure, no blocking of event producers, and the client knows events were missed.
SQLite for event persistence. All events are stored in a local SQLite database (WAL mode for concurrent reads/writes). This enables historical queries via the /api/events endpoint and the History tab. A background pruning task removes events older than 30 days to prevent unbounded growth. The rusqlite crate with the bundled feature compiles SQLite from source — no system library dependency.
Server-rendered HTML with htmx. Full pages are rendered server-side by askama templates. Interactive updates (status cards, agent stop, policy save) use htmx for partial page swaps. This avoids a separate JavaScript build process, npm, webpack, or any Node.js tooling. The entire frontend is <30KB of CDN-loaded libraries.
Alpine.js for client-side filtering. The live events page uses Alpine.js x-data components for local filtering by severity and action. Events arrive via EventSource (native browser SSE) and are prepended to the table. No events are re-fetched from the server — filtering is purely client-side on the buffered event list.
rust-embed for single binary. Static files (app.js, app.css) are embedded into the binary at compile time. The binary is completely self-contained — no external file dependencies, no static/ directory to deploy alongside.
The landing page provides a quick system health snapshot.
Status Cards (auto-refresh every 5 seconds via hx-get="/api/status" hx-trigger="every 5s"):
- Mode:
enforce(red badge) ormonitor(blue badge) - Configured Agents: Total count with active cgroup sub-count
- File Events: Total file access events from Prometheus counter
- Blocked: Total blocked events (enforce mode) in red
Recent Events (SSE via Alpine.js EventSource with auto-reconnection):
- Last 50 events, most recent first
- Color-coded severity and action columns
- Auto-updates as events arrive — no polling, no page refresh
- Green/red "Live" / "Reconnecting..." connection status indicator
- Automatic reconnection with exponential backoff on disconnect
Dual-mode event viewer with Live and History tabs.
Real-time event feed via SSE with resilient reconnection.
Features:
- SSE connection to
/events/streamwith automatic reconnection (exponential backoff, 1s → 30s max) - Green/red connection status indicator ("Connected" / "Reconnecting...")
- Missed-events counter (shown when broadcast channel lag occurs)
- Alpine.js-driven severity filter dropdown (All / Info / Warning / Critical)
- Action filter dropdown (All / Allow / Deny / Blocked)
- Clear button to reset the event list
- Event counter showing total buffered events
- Scrollable table with 500-event client-side buffer
- Full event details: timestamp (ms precision), severity, agent, type, action, PID, comm, path, access mode
Client-side filtering: Events are stored in an Alpine.js reactive array. Filters use computed properties — filtering is instant with no server round-trip.
Reconnection logic: The EventSource has onopen, onerror, and lag event handlers. On disconnect, the connection is closed and a reconnect is scheduled with exponential backoff (capped at 30s). On successful reconnect, the backoff resets to 1s.
SQLite-backed event history with server-side filtering and pagination.
Features:
- Search form: agent name, path contains, event type dropdown
- Server-side filtering via
GET /api/eventsquery parameters - Pagination with Previous/Next buttons, showing "X-Y of Z"
- Same table layout as Live tab for visual consistency
- Severity and action filters shared with Live tab (applied both client-side and server-side)
Two-section agent management interface.
Configured Agents (read-only table):
- Name, identity type (comm/cgroup badge), default action, allow/deny rule counts, exec policy status
- Shows all agents from
config.toml, not just active ones
Active Cgroup Agents (interactive):
- Name, cgroup path, cgroup ID, process count, uptime
- Stop button:
hx-postwith confirmation dialog, sends SIGTERM to all cgroup processes - Grant button: Alpine.js popover form with path input and duration selector,
hx-postto/api/agents/{name}/grant
Accordion-based per-agent policy editor.
Per agent section:
- Collapsible panel (first agent open by default)
- Agent name with identity badge and current default action
- Two-column layout: File Access Policy (left) and Exec Policy (right)
- File Access: default action dropdown, allow rules textarea (one per line), deny rules textarea
- Exec Policy: default action dropdown, allow/deny rules textareas (if configured)
- Save button per agent:
hx-put="/api/policy/{name}"— updates in-memory config, writes TOML to disk
Config write-back: The API handler modifies the Config in IpcState, then serializes the entire config to TOML using a manual serializer (not serde_toml) to maintain readable formatting. The file is written to the original config path.
Form-based alerting output editor.
Global Settings:
- Min severity dropdown (info / warning / critical)
- Dedup window seconds (numeric input)
- Rate limit per minute (numeric input)
Output Channels (grid of cards, each with enable toggle):
- JSON Log: path input
- Webhook: URL input
- Slack: webhook URL input
- Email: SMTP host input
- Prometheus: listen address input
Save button: hx-put="/api/alerts" — updates alerting config in memory and writes to disk. Note: alerting output changes require daemon restart to take effect (the AlertManager is initialized once at startup).
The SSE stream uses map (not filter_map) so that broadcast lag errors become real SSE events instead of being silently dropped. This prevents the stream from appearing frozen when the client falls behind.
pub async fn event_stream(
State(state): State<Arc<DashboardState>>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let rx = state.event_bus.subscribe();
let stream = BroadcastStream::new(rx).map(|result| match result {
Ok(event) => {
let json = serde_json::to_string(&event).unwrap_or_default();
Ok(Event::default().event("event").data(json))
}
Err(BroadcastStreamRecvError::Lagged(n)) => {
// BroadcastStream internally resubscribes after lag.
// Send a "lag" event so the client knows events were missed.
let msg = format!("{{\"missed\": {}}}", n);
Ok(Event::default().event("lag").data(msg))
}
});
Sse::new(stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(5))
.text("heartbeat"),
)
}Key fix: The previous implementation used filter_map which mapped lag errors to None (silently skipping them). While BroadcastStream internally resubscribes after a Lagged error, if many lags happened in succession the client would see no data, appearing frozen. By using map and converting lags to actual "lag" SSE events, the stream stays active and the client is informed.
connect() {
if (this.sse) { this.sse.close(); this.sse = null; }
this.sse = new EventSource('/events/stream');
this.sse.onopen = () => {
this.connected = true;
this.retryDelay = 1000; // Reset backoff on success
};
this.sse.addEventListener('event', (e) => {
const data = JSON.parse(e.data);
data.id = this.nextId++;
this.events.unshift(data);
if (this.events.length > 500) this.events.pop();
});
this.sse.addEventListener('lag', (e) => {
const data = JSON.parse(e.data);
this.missedEvents += data.missed;
});
this.sse.onerror = () => {
this.connected = false;
this.sse.close(); this.sse = null;
// Exponential backoff: 1s → 2s → 4s → ... → 30s max
const delay = Math.min(this.retryDelay, 30000);
this.retryDelay = Math.min(this.retryDelay * 2, 30000);
setTimeout(() => this.connect(), delay);
};
}Reconnection strategy: The native EventSource has built-in reconnection, but it is unreliable across browsers and doesn't support backoff. Our implementation closes the connection on error and manually reconnects with exponential backoff (1s doubling up to 30s max). The UI shows a red "Reconnecting..." indicator during disconnection.
The SSE stream sends two event types:
event: event
data: {"timestamp":"2026-03-10T14:30:00Z","severity":"critical",...}
event: lag
data: {"missed": 42}
:heartbeat
event: event
data: {"timestamp":"2026-03-10T14:30:01Z","severity":"info",...}
event: Normal alert event, fullAlertEventas JSONlag: Broadcast channel overflow notification, tells client how many events were missed:heartbeat: SSE comment sent every 5 seconds to keep the connection alive through proxies
| Method | Path | Description | Response |
|---|---|---|---|
| GET | / |
Dashboard overview | Full HTML page |
| GET | /events |
Live event feed | Full HTML page |
| GET | /agents |
Agent management | Full HTML page |
| GET | /policy |
Policy editor | Full HTML page |
| GET | /alerts |
Alert configuration | Full HTML page |
| Method | Path | Parameters | Description | Response |
|---|---|---|---|---|
| GET | /api/status |
— | Status summary cards | HTML partial (4 cards) |
| POST | /api/agents/{name}/stop |
— | Stop cgroup agent | HTML partial (stopped row) |
| POST | /api/agents/{name}/grant |
path, duration (form) |
Grant temp access | HTML partial (toast) |
| PUT | /api/policy/{agent_name} |
default_action, allow_rules, deny_rules, exec_* (form) |
Update agent policy | HTML partial (toast) |
| PUT | /api/alerts |
min_severity, dedup_*, rate_*, output fields (form) |
Update alerting config | HTML partial (toast) |
| POST | /api/config/reload |
— | Reload config from disk | HTML partial (toast) |
| GET | /metrics |
— | Prometheus metrics | text/plain (Prometheus format) |
| Method | Path | Parameters | Description | Response |
|---|---|---|---|---|
| GET | /api/events |
severity, action, agent_name, event_type, path_contains, limit, offset (query) |
Query historical events from SQLite | JSON: { events: [...], total, limit, offset } |
| Method | Path | Description | Protocol |
|---|---|---|---|
| GET | /events/stream |
Live event stream | SSE (text/event-stream) |
| Method | Path | Description |
|---|---|---|
| GET | /static/{*path} |
Embedded static files (app.js, app.css) |
When the dashboard saves policy or alert changes, it writes the full config back to disk as valid TOML. The serialization is done manually (not via serde::Serialize) to produce readable, well-formatted output.
- User saves policy or alerts via dashboard form
- API handler modifies
IpcState.configin memory (under Mutex lock) - Serializer converts the full
Configstruct to TOML string with proper formatting:[global]section with all fields[dashboard]section if present[alerting]section with sub-tables[[agents]]array entries with[agents.file_access],[agents.exec_policy],[agents.resources]
- Write to the original config file path stored in
DashboardState.config_path - Response includes toast notification with success/failure message
- Comments are lost. The original TOML comments are not preserved. The output is clean, machine-generated TOML.
- Policy changes don't update BPF maps. File access rules that are enforced in-kernel via BPF maps are populated at daemon startup. Dashboard edits update the userspace config (affecting monitor-mode decisions) and disk. To apply changes to kernel enforcement, use "Reload Config" or restart the daemon.
- Alerting output changes require restart. The
AlertManagerand its output connections (webhook client, SMTP transport) are initialized once at startup. Changing URLs or credentials via the dashboard saves to disk but requires a daemon restart to take effect.
All alert events are persisted to a local SQLite database, enabling historical queries, audit trails, and the History tab on the events page. The database is created automatically on first startup.
CREATE TABLE events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL, -- ISO 8601 (RFC 3339)
severity TEXT NOT NULL, -- "info", "warning", "critical"
event_type TEXT NOT NULL, -- "file_access", "exec_attempt"
action TEXT NOT NULL, -- "allow", "deny", "blocked"
agent_name TEXT NOT NULL,
pid INTEGER NOT NULL,
comm TEXT NOT NULL,
path TEXT NOT NULL,
access_mode TEXT NOT NULL DEFAULT '',
identity_method TEXT NOT NULL DEFAULT '',
policy_mode TEXT NOT NULL DEFAULT ''
);
-- Indexes for common query patterns
CREATE INDEX idx_events_timestamp ON events(timestamp DESC);
CREATE INDEX idx_events_severity ON events(severity);
CREATE INDEX idx_events_agent ON events(agent_name);
CREATE INDEX idx_events_action ON events(action);[dashboard]
enabled = true
listen_address = "127.0.0.1:8080"
db_path = "/var/lib/guardian/events.db" # Optional, this is the default- eBPF event arrives via perf buffer →
process_file_event()→AlertSender.send() - Broadcast channel fans out to all subscribers (SSE clients + DB writer)
- DB writer task (dedicated tokio task) receives events and calls
EventDb::insert_event() - SQLite stores the event with WAL mode (concurrent reads during writes)
- History queries via
GET /api/eventscallEventDb::query_events()with filters - Daily pruning task calls
EventDb::prune_old_events(30)to remove events older than 30 days
GET /api/events accepts these query parameters:
| Parameter | Type | Description |
|---|---|---|
severity |
string | Filter by severity: info, warning, critical |
action |
string | Filter by action: allow, deny, blocked |
agent_name |
string | Filter by exact agent name |
event_type |
string | Filter by type: file_access, exec_attempt |
path_contains |
string | Substring match on path (SQL LIKE %...%) |
limit |
u32 | Max results (default: 100, max: 1000) |
offset |
u32 | Skip N results for pagination |
Response:
{
"events": [
{
"id": 42,
"timestamp": "2026-03-10T14:30:00+00:00",
"severity": "critical",
"event_type": "file_access",
"action": "blocked",
"agent_name": "claude-code",
"pid": 12345,
"comm": "cat",
"path": "/etc/shadow",
"access_mode": "READ",
"identity_method": "cgroup",
"policy_mode": "enforce"
}
],
"total": 1847,
"limit": 100,
"offset": 0
}- WAL mode enables concurrent readers during writes — the DB writer never blocks API queries
std::sync::Mutex(nottokio::Mutex) wraps the connection because all operations are sub-millisecond- Indexed columns (timestamp, severity, agent, action) ensure fast filtered queries
- Single-row inserts are adequate for typical event rates (hundreds/sec). For higher throughput, batch inserts could be added
- 30-day retention with automatic pruning prevents unbounded disk growth
| Library | Version | Size | Purpose |
|---|---|---|---|
| TailwindCSS | 2.2.19 | 16KB (gzip) | Utility-first CSS framework |
| htmx | 2.0.4 | 14KB (gzip) | HTML-over-the-wire for partial page updates |
| htmx SSE extension | 2.2.2 | 2KB | SSE integration for htmx (available for future use) |
| Alpine.js | 3.14.8 | 8KB (gzip) | Lightweight reactive JavaScript |
Total frontend size: ~40KB (gzipped, CDN-cached after first load)
| Decision | Rationale |
|---|---|
| TailwindCSS via CDN | No build step. Utility classes work directly in templates. CDN is cached globally. |
| htmx | Server-rendered HTML fragments with AJAX-like behavior. No client-side routing, no virtual DOM, no state management complexity. |
| Alpine.js | "jQuery for the modern web." Reactive x-data components inline in HTML. Perfect for client-side filtering and toggle state. |
| No React/Vue/Svelte | A full SPA framework would require Node.js, webpack/vite, npm, and a build pipeline. This is a security tool — build simplicity matters. |
| No bundling | CDN is faster than embedding minified JS (cache shared across sites). For air-gapped deployments, replace CDN URLs with local copies via rust-embed. |
Askama is a compile-time template engine for Rust, inspired by Jinja2.
Key features used:
- Template inheritance (
{% extends "base.html" %}) - Named blocks (
{% block content %}) - Variable interpolation (
{{ agent.name }}) - Loops (
{% for agent in agents %}) - Conditionals (
{% if agent.has_exec_policy %}) - Method calls in templates (
{% if agent.exec_default.is_some() %})
Compile-time safety: Template variables are checked against Rust struct fields at compile time. If a template references {{ foo.bar }} and the struct doesn't have a bar field, the build fails. This eliminates an entire class of runtime template errors.
| Crate | Version | Features | Purpose |
|---|---|---|---|
axum |
0.8 | macros |
HTTP framework with native tokio integration |
askama |
0.12 | — | Compile-time Jinja2-like template engine |
askama_axum |
0.4 | — | IntoResponse impl for askama templates |
rust-embed |
8 | — | Embed static files into the binary at compile time |
tower |
0.5 | — | Service trait (required by axum) |
tower-http |
0.6 | cors |
CORS middleware for dashboard |
tokio-stream |
0.1 | sync |
BroadcastStream adapter for SSE |
rusqlite |
0.31 | bundled |
Embedded SQLite database for event persistence |
| Decision | Rationale |
|---|---|
| Embedded dashboard | Single binary deployment. No separate process, no reverse proxy required. The daemon itself serves the dashboard. |
| axum over warp/actix | Axum is built on top of tokio (already a dependency). It has first-class SSE support via axum::response::Sse. The 0.8 version has a clean extractor-based API. |
| askama over tera/handlebars | Compile-time checking catches template errors at build time. Zero-allocation rendering. Jinja2-like syntax is familiar to most developers. |
| CDN for frontend libraries | Avoids 200MB+ of node_modules. No npm, no webpack, no build pipeline for JavaScript. Guardian Shell is a security tool — keeping the build simple reduces supply chain risk. |
| rust-embed for static files | Embeds files at compile time. The binary is fully self-contained — scp it to any machine and it works. No static/ directory to manage. |
| broadcast channel for SSE | tokio::sync::broadcast is designed for multi-consumer fan-out. The BroadcastStream adapter handles lagged clients by sending "lag" SSE events (not silently dropping). |
| SQLite for event persistence | Embedded database with zero external dependencies (bundled feature). WAL mode enables concurrent reads during writes. std::sync::Mutex wrapping is safe because all DB operations are sub-millisecond. |
| Dedicated DB writer task | A separate tokio task subscribes to the broadcast channel and writes events to SQLite. This decouples persistence from the event hot path — DB latency never affects SSE or alerting. |
| Exponential backoff reconnection | Client-side EventSource reconnection uses 1s → 2s → 4s → ... → 30s backoff. Prevents thundering herd on server restart. Resets on successful connect. |
map over filter_map for SSE lag |
Using map converts lag errors into real "lag" SSE events. The previous filter_map approach silently dropped them as None, making the stream appear frozen under load. |
| 30-day event retention | Background pruning task runs daily, deleting events older than 30 days. Prevents unbounded disk growth while keeping useful audit history. |
| No authentication | The dashboard listens on localhost by default. For remote access, use a reverse proxy (nginx, caddy) with authentication. Adding auth to the daemon itself would add complexity without solving the common case. |
| Manual TOML serializer | toml::to_string (via serde) produces valid but ugly TOML — arrays on single lines, no comments, inconsistent formatting. The manual serializer produces human-readable output with proper indentation and array formatting. |
| htmx for partial updates | Status cards auto-refresh via hx-trigger="every 5s". Agent stop/grant actions swap single table rows. No full page reloads except navigation. |
| Alpine.js for event filtering | Client-side filtering is instant — no server round-trip. The event buffer (500 items) is filtered via Alpine.js computed properties. Perfect for the "show only critical" use case. |
Dashboard behind enabled flag |
Zero overhead when disabled. No axum server spawned. No broadcast channel fan-out. The event_bus field on AlertSender is Option<broadcast::Sender>. |
| Prometheus on dashboard port | When the dashboard is enabled, /metrics is served on the same port (8080). This eliminates the need for a separate Prometheus TCP server on port 9090. Both can coexist — the dashboard serves /metrics in addition to the existing standalone server. |
This implementation was informed by analysis of several open-source security and observability dashboards:
| Pattern | Source Project | How We Applied It |
|---|---|---|
| Server-rendered HTML + htmx | FleetDM (osquery management) | Full pages rendered by askama, partial updates via htmx |
| SSE for real-time events | Hubble UI (Cilium), Grafana Live | EventSource + Alpine.js for event streaming, no WebSocket complexity |
| Sidebar navigation | Wazuh Dashboard, Grafana | Fixed sidebar with section links, active state highlighting |
| Accordion policy editor | FleetDM query editor | Collapsible per-agent sections with save-per-section |
| Status cards with auto-refresh | Grafana stat panels | htmx polling every 5s for mode/agent/event/blocked counts |
| Single binary deployment | Tetragon, Vector | rust-embed + askama compiled-in templates, zero external dependencies |
| CDN for frontend frameworks | Many admin dashboards | TailwindCSS + htmx + Alpine.js loaded from CDN, ~40KB total |
| Toast notifications for actions | Modern SPA patterns | htmx hx-target="#toast" for action feedback |
| Feature | Phase 1 | Phase 2 | Phase 3 | Phase 4 | Phase 5 |
|---|---|---|---|---|---|
| Output | stderr | stderr | stderr | JSON + webhook + Slack + email | + Web dashboard |
| Metrics | None | None | None | Prometheus | Prometheus + dashboard cards |
| Event persistence | None | None | None | JSON log file | SQLite database |
| Real-time view | tail -f stderr |
tail -f stderr |
tail -f stderr |
jq on JSON log |
SSE event stream + History tab |
| Agent management | Edit config | Edit config | guardian-ctl CLI |
guardian-ctl CLI |
+ Web UI stop/grant |
| Policy editing | Edit TOML | Edit TOML | Edit TOML | Edit TOML + --validate-config |
+ Visual editor |
| Alert config | N/A | N/A | N/A | Edit TOML | + Web form |
| Config reload | Restart daemon | Restart daemon | Restart daemon | SIGHUP | SIGHUP + dashboard button |
| Enforcement | Monitor only | Kernel blocks | Kernel blocks | Kernel blocks | Kernel blocks |
| Identity | Comm name | Comm + TGID | Cgroup (unspoofable) | Cgroup | Cgroup |
| Resource limits | None | None | Memory/CPU/PIDs | Memory/CPU/PIDs | Memory/CPU/PIDs |
All 14 existing unit tests pass without modification:
running 14 tests
test config::tests::test_effective_identity_comm ... ok
test config::tests::test_effective_identity_cgroup ... ok
test config::tests::test_exact_match ... ok
test config::tests::test_exec_policy ... ok
test config::tests::test_pattern_to_policy_rule ... ok
test config::tests::test_policy_default_allow ... ok
test config::tests::test_policy_default_deny ... ok
test config::tests::test_policy_deny_takes_precedence ... ok
test config::tests::test_recursive_wildcard ... ok
test config::tests::test_single_level_wildcard ... ok
test tests::test_comm_to_key ... ok
test tests::test_decode_open_flags_rdwr_trunc ... ok
test tests::test_decode_open_flags_read_only ... ok
test tests::test_decode_open_flags_write_create ... ok
test result: ok. 14 passed; 0 failed; 0 ignored
All 5 config files pass --validate-config:
$ for f in config.toml configs/*.toml; do
echo "=== $f ==="
target/release/guardian --config "$f" --validate-config 2>&1 | grep -E "valid|enabled"
done
=== config.toml ===
Configuration is valid.
JSON log: enabled
Prometheus: enabled
=== configs/development.toml ===
Configuration is valid.
JSON log: enabled
Prometheus: enabled
=== configs/minimal.toml ===
Configuration is valid.
Alerting: not configured
=== configs/recommended.toml ===
Configuration is valid.
JSON log: enabled
Prometheus: enabled
=== configs/strict.toml ===
Configuration is valid.
JSON log: enabled
Prometheus: enabledBoth debug and release builds compile cleanly:
cargo build --package guardian # debug: OK
cargo build --package guardian --release # release: OKZero warnings.
To test the dashboard manually:
# Terminal 1: Start the daemon (requires root for eBPF)
sudo RUST_LOG=info target/release/guardian --config config.toml
# Terminal 2: Open the dashboard
xdg-open http://127.0.0.1:8080
# Terminal 3: Generate events (trigger file access)
cat /tmp/test.txt # Should show as ALLOW
cat /etc/shadow # Should show as DENY/BLOCKEDThe dashboard should show:
- Status cards with mode, agent count, event counts
- Recent events appearing in real-time via SSE with green "Live" indicator
- Navigation to all pages
- Functional policy editor and alert configuration forms
Testing SSE resilience:
- Open the Events page → Live tab should show green "Connected"
- Restart the daemon → UI should show red "Reconnecting..." then auto-reconnect
- Generate many events rapidly → any lag shows "(N missed)" counter
- Switch to History tab → events from SQLite should appear with pagination
Testing History queries:
# Query all events (JSON API)
curl http://127.0.0.1:8080/api/events
# Filter by severity
curl "http://127.0.0.1:8080/api/events?severity=critical&limit=10"
# Search by path
curl "http://127.0.0.1:8080/api/events?path_contains=/etc&action=blocked"The dashboard listens on 127.0.0.1:8080 by default — localhost only. This is intentional:
- No authentication. The dashboard provides full control over agent policies and can stop agents. It should not be exposed to untrusted networks without authentication.
- Config file writes. The dashboard can write to the config file. An attacker with dashboard access could weaken security policies.
- Agent management. The stop/grant actions directly affect running agents.
For remote access, use a reverse proxy with authentication:
# nginx reverse proxy with basic auth
server {
listen 443 ssl;
server_name guardian.internal;
auth_basic "Guardian Shell";
auth_basic_user_file /etc/nginx/.htpasswd;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
# SSE requires these headers
proxy_set_header Connection '';
proxy_http_version 1.1;
chunked_transfer_encoding off;
proxy_buffering off;
proxy_cache off;
}
}- The dashboard only writes to the original config file path (specified via
--config) - Writes use
std::fs::writewhich is atomic on most filesystems - The API validates policy rules before saving (empty/invalid rules are filtered)
- The "Reload Config" button re-validates the config from disk before applying
- The DB file (
/var/lib/guardian/events.db) is created by the daemon running as root - It contains a full audit trail of all file access events (paths, PIDs, agent names)
- The
/api/eventsendpoint exposes this data without authentication — bind to localhost - WAL mode creates additional
-waland-shmfiles alongside the main DB - For sensitive environments, ensure the DB directory has restrictive permissions (
chmod 700)
The dashboard loads TailwindCSS, htmx, and Alpine.js from CDN. For air-gapped or high-security environments:
- Download the CDN files
- Place them in
guardian/static/ - Update the
<script>and<link>tags intemplates/base.htmlto use/static/paths - Rebuild — rust-embed will embed them in the binary
| Limitation | Impact | Workaround |
|---|---|---|
| No authentication | Anyone with network access to the port can control the daemon | Bind to localhost; use reverse proxy with auth |
| CDN dependency | First dashboard load requires internet | Bundle libraries locally via rust-embed |
| Policy edits don't update BPF maps | Kernel enforcement rules unchanged until restart | Use "Reload Config" button or send SIGHUP |
| Config comments lost on save | Original TOML comments removed by write-back | Maintain comments in a separate file or use version control |
| Alerting changes need restart | Output connections (SMTP, webhook) initialized once | Restart daemon after changing alert output config |
| 500-event live buffer | Only last 500 events visible in Live tab | Switch to History tab for full SQLite-backed event archive |
| DB path needs writable directory | SQLite DB at /var/lib/guardian/events.db requires the directory to exist and be writable |
Configure db_path in [dashboard] config, or ensure /var/lib/guardian/ exists |
| No mobile responsive design | Dashboard designed for desktop browsers | Use on desktop or laptop screens |
| SQLite single-process only | Only one guardian daemon can write to a given DB file at a time | Use separate db_path per daemon instance |