Files
pilens/docs/architecture.md
T

174 lines
10 KiB
Markdown

# 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+)
Implemented since Phase 2: conversations, follow TCP stream, DNS explorer, TLS
explorer (metadata only), statistics. Not in the navigation until they work:
HTTP explorer and TLS interception (modes B and C, deferred together), findings,
and capture scheduling.