Skip to content

Architecture

thefoulowl edited this page Sep 4, 2026 · 2 revisions

Architecture

provctl is one capture engine with several query views on top of it. The design goal: the kernel side stays dumb (capture + tag with identity, hand to userspace), and every interesting question — "where did this file come from", "what did this process do" — is a query over one durable event log, not a separate subsystem.

                     kernel                          userspace
        ┌─────────────────────────────┐   ┌──────────────────────────────┐
        │ tp/sched_process_fork        │   │                              │
        │ tp/sched_process_exec        │   │  engine.Engine               │
        │ tp/sched_process_exit        │──▶│   - loads probes (bpf2go)    │
        │ fentry/security_file_open    │   │   - attaches all hooks       │
        │ fentry/tcp_v4_connect        │   │   - reads BPF ring buffer    │
        │ fentry/tcp_v6_connect        │   │   - decodes -> model.Event   │
        └──────────────┬────────────────┘   └───────────────┬──────────────┘
                        │ BPF ring buffer                    │
                        │ (struct event, provctl.h)          ▼
                        │                          ┌──────────────────────┐
                        │                          │ store.Store (SQLite) │
                        └─────────────────────────▶│  processes           │
                                                    │  file_events         │
                                                    │  net_events          │
                                                    │  activity_log        │
                                                    └──────────┬────────────┘
                                                               │
                            ┌──────────────────┬───────────────┼───────────────────┬──────────────────┐
                            ▼                  ▼               ▼                   ▼
                     `provctl trace`   `provctl timeline`  `provctl ps`      `provctl top`
                     file provenance    process flight      process tree     live TUI (separate
                                          recorder                            process, polls the
                                                                              same store)

See BPF Hooks Reference for what each hook attaches to and why.

CO-RE

The object is compiled against one machine's /sys/kernel/btf/vmlinux, but field-offset relocations are resolved at load time against whatever kernel provctl actually runs on. That's what lets a single compiled binary run across kernel versions that expose the same fields, without recompiling per-target.

Event flow

  1. At startup, watch snapshots /proc (internal/procscan) and seeds the store with one synthetic exec record per already-running process, using each process's real start time (btime + starttime). Without this, the first events a fresh daemon sees come from pids it never watched start — so trace could report that a path was opened but not by whom, which is the whole question it exists to answer. Most activity right after startup comes from long-lived processes (shells, browsers, desktop apps).
  2. Each hook fills a struct event (internal/bpf/provctl.h) with a timestamp, identity (pid/ppid/uid/gid/comm), and a type-specific payload (exec path, opened path, or destination address), and submits it to a BPF_RINGBUF map.
  3. engine.Engine (Go, via cilium/ebpf) reads the ring buffer and decodes each record by explicit byte offset — deliberately not relying on Go struct layout matching C struct layout, so the two representations can't silently drift apart without a hard decode error.
  4. store.Store persists every event to SQLite: one processes row per process lifetime (keyed by (pid, started_ns) since pids get reused), plus append-only file_events and net_events logs. The database runs in WAL mode with synchronous=NORMAL, and watch batches events into one transaction per 150ms (or 256 events, whichever comes first) — during testing a single execve() produced dozens of file-open events within the same millisecond, and per-event fsync would otherwise make the store the bottleneck.
  5. The CLI subcommands are pure queries over that store:
    • trace <path> — find the earliest open of the path, the process that did it, and what that process connected to just before (the likely download source); then follow later opens (copy/extract) and any execution of the path, including what that process connected to or spawned.
    • timeline <pid> — merge that process's own start/exit with every file it opened, every connection it made, and every child it spawned, sorted by time.
    • ps — the process tree reconstructed from processes.ppid.
    • top — a live TUI (bubbletea/lipgloss) that polls the store from a separate process while watch writes to it: a process tree (sharing ps's tree-rendering code) alongside a scrolling activity_log feed. activity_log is a plain append-only table — one rendered line per live-observed event, via the same model.Event.Describe() watch's own output uses — kept separate from the other tables specifically so top doesn't have to re-derive a chronological feed from rows that mutate in place (a process's row is updated by EXEC, then again by EXIT). The /proc startup backfill does not write to activity_log (Store.Seed vs Store.Apply) — those are synthetic, not live-observed, and logging hundreds of them at every watch startup would flood the feed. The incremental poll query is also intentionally not capped at the same small limit as what's displayed: a single chatty process (a browser polling /proc, observed generating 1000+ events well under a second) can otherwise outpace a tightly-bounded fetch, permanently widening the gap between the feed and "now" instead of catching up between bursts.

For what this doesn't yet handle, see Known Limitations & Roadmap.

Clone this wiki locally