Files
pilens/docs/architecture.md
T
Andy Dixon 52da3c13f6 Core packages: config validation, network renderers, root helper, auth, SQLite store, capture engine
Capture engine streams dumpcap pcapng into verbatim, hashed segments with
per-segment tshark summaries; display filters, PDML dissection with verified
byte ranges, export and import. Tests run in a Debian trixie container.
2026-10-09 17:34:32 +01:00

10 KiB

PiLens architecture

PiLens turns a Raspberry Pi into a routed Wi-Fi test network with packet capture and analysis. This document records the decisions and the reasons for them. Anything here that has not been checked on real hardware says so.

Processes and privilege

             management LAN (TLS :8443, allow-listed subnets only)
                                │
┌───────────────────────────────▼────────────────────────────────┐
│ pilens (user "pilens", no root)                                 │
│   HTTP API + embedded React UI, auth, SQLite, SSE               │
│   capture engine: dumpcap ─stdout─▶ pcapng splitter ─▶ segments │
│                                        └─▶ tshark (per segment) │
│   dissection on demand: tshark -T pdml (rlimited child)         │
└───────────────┬─────────────────────────────────────────────────┘
                │ unix socket /run/pilens/helper.sock (SO_PEERCRED)
┌───────────────▼─────────────────────────────────────────────────┐
│ pilens-helper (root, small, fixed operations only)              │
│   apply_network: NM keyfile + dnsmasq conf + nft table, verify, │
│                  roll back on failure                           │
│   ap_stations, leases, status                                   │
└─────────────────────────────────────────────────────────────────┘
pilens-dnsmasq (DHCP + DNS for the AP interface only)
NetworkManager (owns the AP connection "pilens-ap")
  • The core never runs as root. It captures by running dumpcap, which Debian ships with file capabilities (cap_net_raw,cap_net_admin) for members of the wireshark group. Because file capabilities are lost under NoNewPrivileges, the core unit does not set it. Instead it restricts its CapabilityBoundingSet to those two capabilities, so no child can gain more.
  • tshark parses hostile input (live traffic, uploaded files). Every tshark child goes through pilens rlimit-exec, which sets RLIMIT_AS, RLIMIT_CPU and PR_SET_NO_NEW_PRIVS and then execs tshark. Every call also has a context timeout.
  • The helper is the only root component. It takes typed JSON requests. It checks the peer uid, validates every request again (it does not trust the core), renders the configuration itself, and runs fixed argv lists. It has no generic command API.

Networking

The Pi is a router, not a bridge. The AP interface has a static gateway address; clients get DHCP and DNS from a dedicated dnsmasq instance bound only to that interface.

Concern Owner Why
AP radio NetworkManager keyfile pilens-ap (mode=ap, ipv4.method=manual, ipv6.method=disabled) NM is the supported network manager on Raspberry Pi OS Bookworm and later. We write a keyfile (0600) and nmcli connection load it. The Wi-Fi passphrase never appears in a process argv.
DHCP + DNS pilens-dnsmasq.service NM "shared" mode gives no reservations, query logging or overrides, and it adds its own NAT rules. We use ipv4.method=manual so NM runs no DHCP server, which means there are never two competing DHCP servers. Only dnsmasq-base is installed, so the distro's dnsmasq.service does not exist.
Firewall / NAT nft table inet pilens This is the only table we create or delete. Other tables are never flushed.
Forwarding sysctl helper It is enabled only after the PiLens table has loaded. It is not persisted in sysctl.d, so after a reboot nothing forwards until the rules are back.

Firewall semantics

In nftables a packet must survive every base chain on a hook: accept only ends the current chain, but drop is final. Our chains use policy accept for traffic that is not ours, so SSH and other host services are untouched. For anything that enters through the AP interface we decide explicitly and end with a drop. A permissive table added by someone else (Docker, NM) therefore cannot open a path that we drop.

  • AP → Pi: DHCP, DNS and ICMP echo only. Everything else is dropped, including the management port and SSH.
  • AP → forward: only out of the uplink interface. Never to RFC 1918, link-local, CGNAT, multicast or the management subnets, unless the administrator adds an explicit allow CIDR. New connections are permitted only when forwarding mode is internet.
  • IPv6 from AP clients is dropped in forward, and IPv6 is disabled on the AP interface. That closes the uninspected IPv6 escape path. IPv6 forwarding is not implemented.
  • Management port: accepted only on the configured management interfaces from the configured source subnets, and dropped everywhere else. The core also checks the source address in middleware, as defence in depth.
  • pilens-helper.service loads the last good ruleset in ExecStartPre, ordered Before=network-pre.target, so the rules are in place before NM brings up the AP.

Transactional apply

apply_network is one helper operation:

  1. Validate.
  2. Render the NM keyfile, dnsmasq.conf and nft ruleset.
  3. nft -c dry run.
  4. Snapshot the current files.
  5. Write the files atomically, load the ruleset, nmcli connection load + up, and restart dnsmasq.
  6. Verify: the connection is activated, dnsmasq is active, the table is present, and forwarding matches the configured policy.
  7. On any failure, restore the snapshot and re-apply it, then report both errors.

Management and uplink NM profiles are never modified.

Capture engine

dumpcap -i IF [-f BPF] -w -  ──pcapng──▶ splitter ──▶ seg-000001.pcapng (sha256 while writing)
                                            │              seg-000002.pcapng …
                                            ├──▶ tshark -r - -T fields (summary, one process per segment)
                                            │         └─▶ SQLite packets(session,num,seg,off,…) ─▶ SSE batches
                                            └──▶ ISB drop counters
  • Original bytes are never modified. Every packet block is written verbatim. On rotation, the SHB and IDBs are copied verbatim into the new segment, so each segment is a self-contained, valid pcapng file.
  • One tshark per segment keeps tshark's memory bounded. tshark's conversation state otherwise grows without limit on long captures. The cost: TCP stream numbers and reassembly restart at segment boundaries. Summary rows, filters and the detail view all use the same per-segment dissection, so they agree with each other. Segments default to 64 MiB.
  • SQLite holds one summary row per packet (number, segment, file offset, time, addresses, protocol, length, info). It does not hold dissection trees. The protocol tree comes from tshark -T pdml on demand.
  • Display filters are evaluated by tshark (-Y), segment by segment. The results are cached per session and filter. PiLens does not implement its own filter language.
  • Hex ↔ field mapping. The core reads frame bytes from the segment file at the indexed offset. A PDML field is highlighted only when the frame bytes at pos..pos+size equal its value. Fields from reassembled or decompressed data sources fail that check and are not highlighted.
  • Backpressure. Each SSE subscriber has a bounded queue. When it is full, batches for that subscriber are dropped, counted as UI drops, and the client is told to resync from SQLite. Capture never waits for a browser. Capture drops (from dumpcap/ISB) and ingestion lag (packets written but not yet summarised) are reported separately.
  • Storage. There is a global byte quota and a free-space reserve. When the quota is reached, the oldest unpreserved sessions are deleted. If nothing can be deleted, the capture stops with a visible error. Preserved sessions are never deleted automatically. Per-session ring mode (ring_segments) deletes that session's oldest segments and records which ones were rotated out.
  • Imports keep the uploaded file unchanged as the session's only segment (pcap or pcapng), with its SHA-256. Magic number and size are checked before any parsing.

Web

  • TLS only, on :8443, with a self-signed management certificate generated on first start (ECDSA P-256). It is separate from any interception CA. The installer prints its fingerprint.
  • Auth: Argon2id (64 MiB, t=2, p=2). Sessions are 256-bit random tokens; only their SHA-256 is stored. Cookies are Secure; HttpOnly; SameSite=Strict. CSRF uses a per-session token in the X-CSRF-Token header on every state-changing request. Optional TOTP (RFC 6238). Logins are rate-limited per IP and per user.
  • First run needs a setup token that the installer generates and prints. Without it, anyone on the management LAN could race to create the admin.
  • Live updates use SSE: stdlib only, automatic reconnect, and Last-Event-ID resync.
  • React + TypeScript + Vite. The build is embedded in the Go binary with go:embed. There are no CDNs or external fonts.

Decisions taken without a spec answer

Decision Default Reason
Live transport SSE One-way server push is all that is needed. It's stdlib, and reconnect is built in.
Config storage JSON document in SQLite settings, with a last_good copy One validated struct, simple rollback.
WPA2+WPA3 transition mode Not offered NM AP-mode support depends on the driver. We offer WPA2 or WPA3, and WPA3 failure triggers rollback.
SSID characters Printable ASCII without \ or ; Avoids keyfile escaping ambiguity.
AP client SSH to Pi Blocked The AP network is the untrusted side.
Max clients Not offered NM has no setting for it. The UI says so.

Not yet implemented (phase 3+)

These pages are not in the navigation until they work: conversations / follow TCP stream, DNS/TLS/HTTP explorers, statistics, findings, TLS interception (modes B and C), and scheduling.