Documentation: network policy, security model, forensics, operations, API, testing, limitations
This commit is contained in:
@@ -0,0 +1,74 @@
|
|||||||
|
# PiLens
|
||||||
|
|
||||||
|
PiLens turns a Raspberry Pi into a self-contained Wi-Fi test network with packet
|
||||||
|
capture and Wireshark-grade analysis in the browser. You connect an **authorised**
|
||||||
|
test device to the Pi's access point and watch its traffic: live packets, a
|
||||||
|
protocol tree from TShark, and the original bytes with field highlighting. The
|
||||||
|
original captures are kept unmodified, with SHA-256 integrity hashes.
|
||||||
|
|
||||||
|
No cloud service, account or internet connection is needed for management,
|
||||||
|
capture or analysis.
|
||||||
|
|
||||||
|
> **Use it only on devices and networks you own or are explicitly authorised to
|
||||||
|
> inspect.** PiLens has no covert, impersonation or credential-harvesting
|
||||||
|
> features. Encrypted traffic (HTTPS, QUIC, VPNs, encrypted DNS) stays encrypted:
|
||||||
|
> in this version PiLens shows packets and metadata, not decrypted content.
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
| Phase | Scope | State |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Installer, services, auth, AP / DHCP / DNS / firewall, health | implemented |
|
||||||
|
| 2 | dumpcap capture, pcapng storage, live analyser, filters, export / import | implemented |
|
||||||
|
| 3 | Conversations, follow TCP stream, DNS/TLS explorers, statistics | not started |
|
||||||
|
| 4 | Optional HTTPS inspection (mitmproxy, client-trusted CA) | not started |
|
||||||
|
| 5 | Hardening, advanced UI, benchmarks | partial |
|
||||||
|
|
||||||
|
Pages for later phases are not in the UI. PiLens never shows controls that do
|
||||||
|
nothing. For what was tested and how, see [docs/testing.md](docs/testing.md).
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
On a Raspberry Pi 4/5 with Raspberry Pi OS Lite (64-bit, Bookworm or Trixie),
|
||||||
|
connected by **Ethernet**:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar xzf pilens-<version>-linux-arm64.tar.gz
|
||||||
|
cd pilens-<version>-linux-arm64
|
||||||
|
sudo ./install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
The installer prints the management URL, the certificate fingerprint and a
|
||||||
|
one-time setup token. Open the URL from a machine on the management network and
|
||||||
|
complete the setup wizard. The wizard creates the access point and turns the
|
||||||
|
Wi-Fi interface into the AP, so **manage the Pi over Ethernet**.
|
||||||
|
|
||||||
|
For unattended installation: `sudo ./install.sh --non-interactive --config setup.yaml`
|
||||||
|
(see `setup.example.yaml`).
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
Requirements: Go ≥ 1.24, Node ≥ 20. Docker is used only for the test suite.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make package # dist/pilens-<ver>-linux-arm64.tar.gz (cross-compiled, static)
|
||||||
|
make test # unit tests + TypeScript typecheck
|
||||||
|
make test-docker # tshark/dumpcap tests + network-namespace integration suite
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- [Architecture and design decisions](docs/architecture.md)
|
||||||
|
- [Installation, first run, upgrade and recovery](docs/operations.md)
|
||||||
|
- [Network topology and firewall policy](docs/network.md)
|
||||||
|
- [Security and privacy model](docs/security.md)
|
||||||
|
- [Forensic evidence handling](docs/forensics.md)
|
||||||
|
- [API](docs/api.md)
|
||||||
|
- [Testing and hardware acceptance checklist](docs/testing.md)
|
||||||
|
- [Known limitations](docs/limitations.md)
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
AGPL-3.0-or-later. Bundled JavaScript dependencies (React, MIT) are compatible.
|
||||||
|
TShark/dumpcap (GPL-2.0+), dnsmasq (GPL-2.0/3.0) and nftables (GPL-2.0) are
|
||||||
|
installed from Debian packages and run as separate programs.
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
All endpoints are under `https://<pi>:8443/api/`. Except `GET /health`,
|
||||||
|
`GET /setup/status`, `POST /setup/admin` and `POST /auth/login`, every request
|
||||||
|
needs the session cookie. Every non-GET request also needs the
|
||||||
|
`X-CSRF-Token` header (value from `/auth/session` or the login response).
|
||||||
|
Unauthenticated POSTs need `X-Requested-With: pilens`. Bodies are JSON, limited
|
||||||
|
to 1 MiB, and unknown fields are rejected.
|
||||||
|
|
||||||
|
| Method & path | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /health` | Liveness `{ok:true}` |
|
||||||
|
| `GET /setup/status` | `{needs_admin, setup_complete, version}` |
|
||||||
|
| `POST /setup/admin` | `{token, username, password, authorised:true}` → session |
|
||||||
|
| `POST /auth/login` | `{username, password, totp?}` → `{user, csrf}`; 401 `totp_required` |
|
||||||
|
| `POST /auth/logout` · `GET /auth/session` · `POST /auth/password {Current, New}` | |
|
||||||
|
| `POST /auth/totp/begin` · `/enable {Code}` · `/disable {Password}` | TOTP lifecycle |
|
||||||
|
| `GET /audit` | Last 500 audit events |
|
||||||
|
| `GET /events` | SSE stream: `packets` `{session, rows}`, `capture` (session object, every 1 s while capturing), `resync` (`{session}` or `{ui_dropped}`), `ping` |
|
||||||
|
| `GET /system/interfaces` | Detected interfaces, Wi-Fi capabilities, bootstrap management, caller address |
|
||||||
|
| `GET /system/health` | System, helper, capture, storage, upstream, forwarding state |
|
||||||
|
| `GET /network` | Current config (passphrase redacted) |
|
||||||
|
| `POST /network/validate` | `{valid, errors[]}`, including lock-out detection |
|
||||||
|
| `POST /network/apply` | Transactional apply. `{result:{applied, rolled_back, steps[], error}}`. 502 on failure (rolled back). |
|
||||||
|
| `POST /setup/complete` | Marks first-run done |
|
||||||
|
| `GET /ap/clients` · `PUT /devices/{mac} {Label, Notes}` | AP clients (leases + stations + labels) |
|
||||||
|
| `GET /captures` · `POST /captures {name, interfaces[], capture_filter, ring_segments}` | List / start |
|
||||||
|
| `POST /captures/live/stop` · `POST /captures/live/pause {Paused}` | |
|
||||||
|
| `POST /captures/import?name=` | Raw pcap/pcapng body |
|
||||||
|
| `GET /captures/{id}` · `PATCH {Name, Description, Tags, Notes, Preserved}` · `DELETE ?confirm={id}` | |
|
||||||
|
| `GET /captures/{id}/packets?offset&limit&filter` | Page of summary rows. With `filter`, `nums[]` gives packet numbers per position. |
|
||||||
|
| `GET /captures/{id}/packets/{n}` | `{detail:{row, bytes(hex), tree[], segment, note}, error?}` |
|
||||||
|
| `GET /captures/{id}/export?filter=` | pcapng (or pcap for pcap imports), original records |
|
||||||
|
| `GET /captures/{id}/manifest` · `POST /captures/{id}/verify` | Integrity |
|
||||||
|
| `POST /filters/validate {Kind: display|capture, Filter, Interface}` | tshark / dumpcap compile check |
|
||||||
|
| `GET/POST /filters/saved` · `DELETE /filters/saved/{id}` | Saved filters |
|
||||||
|
| `GET /storage` · `PUT /storage {max_bytes, reserve_bytes, segment_bytes, import_bytes}` | |
|
||||||
|
|
||||||
|
Tree nodes: `{label, field, show, pos, size, verified, children}`. `verified`
|
||||||
|
means the frame bytes at `pos..pos+size` were checked against tshark's raw
|
||||||
|
value, so highlighting them is accurate.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Forensic evidence handling
|
||||||
|
|
||||||
|
## What is evidence and what is derived
|
||||||
|
|
||||||
|
| Data | Status |
|
||||||
|
|---|---|
|
||||||
|
| `captures/<session>/seg-NNNNNN.pcapng` (live) | **Original**. dumpcap's pcapng output, written byte for byte. Each segment starts with the section and interface headers copied verbatim, so it opens on its own. |
|
||||||
|
| `captures/<session>/original.pcap[ng]` (import) | **Original as received**. The uploaded file, unchanged. |
|
||||||
|
| SHA-256 per segment | Computed while writing, and over the whole file for imports. Stored in SQLite. |
|
||||||
|
| Packet summary rows (SQLite) | Derived: tshark output, regenerable |
|
||||||
|
| Protocol trees, filters, exports with a filter | Derived. A filtered export copies the original packet records verbatim, but as a selection it is a new file. |
|
||||||
|
|
||||||
|
PiLens never labels reconstructed or synthetic data as an original capture.
|
||||||
|
|
||||||
|
## Recorded metadata (per session)
|
||||||
|
|
||||||
|
Session `meta` and the manifest record:
|
||||||
|
|
||||||
|
- UTC start/stop (milliseconds), capture interfaces and capture filter
|
||||||
|
- PiLens and TShark versions, hostname
|
||||||
|
- `clock_synchronised` from `timedatectl` at start. If this is not `yes`,
|
||||||
|
absolute timestamps may be wrong. A Pi has no battery-backed clock, so make
|
||||||
|
sure NTP is working before you capture evidence.
|
||||||
|
- Segment size, ring-buffer setting, pause/resume log, packets discarded while
|
||||||
|
paused
|
||||||
|
- dumpcap's own "received/dropped" report, verbatim (kernel and pcap drops)
|
||||||
|
|
||||||
|
## Verifying
|
||||||
|
|
||||||
|
- UI: Capture Library → *Integrity* → *Verify hashes now*. This recomputes
|
||||||
|
SHA-256 for every complete segment.
|
||||||
|
- Manifest: *Download manifest* gives JSON with every file, its hash, packet
|
||||||
|
count and the metadata above.
|
||||||
|
- Offline: `sha256sum seg-*.pcapng` and compare with the manifest.
|
||||||
|
|
||||||
|
## Caveats
|
||||||
|
|
||||||
|
- **Capture loss** happens under load. Check the drop report. A capture with
|
||||||
|
drops is incomplete.
|
||||||
|
- **Segment boundaries**: summaries and dissections are computed per segment,
|
||||||
|
so TCP stream indices and reassembly restart at each boundary. The packet
|
||||||
|
bytes are unaffected.
|
||||||
|
- **Interrupted captures** (power loss): on restart, the last segment is
|
||||||
|
scanned, counted and hashed as found, and the session is marked
|
||||||
|
`interrupted`. A truncated final packet record may be present.
|
||||||
|
- **Pause capture** discards packets while paused. The pause log in the
|
||||||
|
session metadata records when.
|
||||||
|
- **Randomised MAC addresses** do not identify hardware. PiLens never merges
|
||||||
|
device identities automatically.
|
||||||
|
- DNS answers do not prove which host a later connection went to.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
# Known limitations (v0.1)
|
||||||
|
|
||||||
|
**Encryption.** PiLens captures packets; it does not decrypt them. HTTPS, QUIC/HTTP-3, TLS 1.3 with
|
||||||
|
Encrypted ClientHello, DNS-over-HTTPS/TLS, and VPN traffic are visible only as encrypted
|
||||||
|
packets plus whatever metadata the protocol exposes in clear (for TLS ≤1.3 without ECH: SNI,
|
||||||
|
versions, ALPN; for TLS 1.2: certificates). The optional HTTPS inspection mode (client-installed
|
||||||
|
CA via mitmproxy) is **not implemented** in this version, and even when it is, it will not work
|
||||||
|
against certificate pinning, private trust stores, mutual TLS, QUIC, or apps that bypass proxies.
|
||||||
|
|
||||||
|
**Not yet implemented:** conversations view, follow TCP stream, DNS/TLS/HTTP explorers,
|
||||||
|
statistics charts, findings/diagnostics rules, capture scheduling, the strict inspection mode,
|
||||||
|
WireGuard, packet comparison. They are not shown in the UI.
|
||||||
|
|
||||||
|
**IPv6** is blocked for AP clients rather than routed (see network.md).
|
||||||
|
|
||||||
|
**AP features.** Max-client limits are not available through NetworkManager. WPA2/WPA3
|
||||||
|
transition mode is not offered. WPA3 (SAE) in AP mode depends on the Wi-Fi driver and
|
||||||
|
firmware. On the Raspberry Pi's brcmfmac it may fail, in which case the change is
|
||||||
|
rolled back.
|
||||||
|
|
||||||
|
**Capture.**
|
||||||
|
- One live capture at a time.
|
||||||
|
- Display filters on large captures run tshark over every segment. That takes seconds per GiB
|
||||||
|
on a Pi 4/5, and results are cached per filter.
|
||||||
|
- The packet table's scroll height is limited by browser element-size limits (about 1.5 million
|
||||||
|
rows at compact density). Use a display filter for larger captures.
|
||||||
|
- Capture drop counts are reported by dumpcap when the capture stops, not live.
|
||||||
|
|
||||||
|
**Performance figures** have not yet been measured on hardware, so none are claimed.
|
||||||
|
`docs/testing.md` lists the benchmarks to run.
|
||||||
|
|
||||||
|
**Single administrator role.** Every account has full rights.
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
# Network topology and policy
|
||||||
|
|
||||||
|
```
|
||||||
|
upstream LAN / internet
|
||||||
|
│ eth0 (uplink + management, DHCP client as before)
|
||||||
|
┌──────┴───────────────────────────────┐
|
||||||
|
│ Raspberry Pi │
|
||||||
|
│ nft table inet pilens │
|
||||||
|
│ NAT (masquerade) AP subnet → uplink │
|
||||||
|
│ pilens-dnsmasq: DHCP+DNS on wlan0 │
|
||||||
|
└──────┬───────────────────────────────┘
|
||||||
|
│ wlan0 AP "PiLens-Lab", 192.168.50.1/24 (static, NM keyfile)
|
||||||
|
test device(s) 192.168.50.100–199
|
||||||
|
```
|
||||||
|
|
||||||
|
The Pi **routes**: it does not bridge. AP clients are always on their own subnet
|
||||||
|
behind NAT, so every packet they send crosses the Pi's IP stack. That is what
|
||||||
|
makes capture and policy enforcement complete.
|
||||||
|
|
||||||
|
## Interface discovery
|
||||||
|
|
||||||
|
Interface names are discovered, not assumed. The installer picks:
|
||||||
|
|
||||||
|
- **Uplink**: the interface holding the IPv4 default route.
|
||||||
|
- **Management**: the interface your SSH session arrived on, falling back to the
|
||||||
|
uplink. The default allowed sources are that interface's connected subnet,
|
||||||
|
plus your SSH client's /32 if it lies outside that subnet (for example when
|
||||||
|
you connect through NAT or a VPN).
|
||||||
|
- **AP**: the first interface with `/sys/class/net/<if>/wireless`.
|
||||||
|
|
||||||
|
All three can be changed in the wizard or under Network Settings. Validation
|
||||||
|
rejects an AP interface that is also used for management or uplink, and any AP
|
||||||
|
subnet that overlaps a management subnet.
|
||||||
|
|
||||||
|
## Firewall rules, in words
|
||||||
|
|
||||||
|
| Traffic | Decision | Why |
|
||||||
|
|---|---|---|
|
||||||
|
| AP client → Pi UDP 67 (DHCP) | accept | Address assignment |
|
||||||
|
| AP client → Pi gateway :53 (DNS) | accept | Resolution through the Pi, so DNS is observable |
|
||||||
|
| AP client → Pi ICMP echo / errors | accept | Basic diagnostics |
|
||||||
|
| AP client → Pi anything else (SSH, :8443, …) | **drop** | The AP network is untrusted |
|
||||||
|
| AP client IPv6 (to the Pi or forwarded) | **drop** | IPv6 is not inspected, so it is closed rather than left open |
|
||||||
|
| AP client → private / link-local / CGNAT / multicast / management nets | **drop** | Clients cannot pivot into your LAN |
|
||||||
|
| AP client → explicit `allow_cidrs` | accept | An opt-in exception, e.g. a lab server |
|
||||||
|
| AP client → public addresses via uplink | accept when mode = `internet` | The purpose of the gateway |
|
||||||
|
| AP client → anything, mode = `deny` | **drop** | Observe-only lab mode (DHCP and DNS still work) |
|
||||||
|
| Anything → AP subnet, not a reply | **drop** | No inbound connections to clients |
|
||||||
|
| Any interface → :8443 from a non-allowed source | **drop** | Management isolation |
|
||||||
|
| Everything else | not touched by PiLens | Host SSH and other services keep their policy |
|
||||||
|
|
||||||
|
Multicast, broadcast and mDNS from clients are not forwarded. DHCP broadcasts
|
||||||
|
reach dnsmasq because they are delivered locally, not forwarded. With **client
|
||||||
|
isolation** enabled, the Wi-Fi driver stops clients talking to each other
|
||||||
|
directly. Without it, client-to-client frames on the same radio never reach the
|
||||||
|
IP layer, but they are still visible to a capture on the AP interface.
|
||||||
|
|
||||||
|
Rules are applied atomically: one `nft -f` both deletes and recreates the
|
||||||
|
table. A ruleset that fails `nft -c` is never loaded.
|
||||||
|
|
||||||
|
## Failure behaviour
|
||||||
|
|
||||||
|
| Event | Result |
|
||||||
|
|---|---|
|
||||||
|
| Ethernet unplugged / upstream gateway unreachable | Clients keep DHCP and DNS (answers from cache or overrides; other lookups fail). Forwarded traffic has no route and fails. The dashboard shows "no default route". |
|
||||||
|
| DNS upstream fails | dnsmasq returns SERVFAIL. Visible in captures. |
|
||||||
|
| Capture engine crashes | Forwarding is unaffected in capture-only mode. Interrupted segments are hashed and closed on restart, and the session is marked *failed / interrupted*. |
|
||||||
|
| Storage exhausted | The oldest unpreserved sessions are deleted. If none are left, capture stops with an explicit error. Forwarding continues. |
|
||||||
|
| Network apply fails | Automatic rollback to the previous NM keyfile, dnsmasq config and nft table, and the failure is reported with each step. |
|
||||||
|
| Firewall restore fails at boot | `pilens-firewall.service` fails, but **IPv4 forwarding stays disabled** (it is never persisted), so clients cannot be routed. |
|
||||||
|
| Reboot | `pilens-firewall` loads the last good table before NetworkManager starts the AP. Forwarding is enabled only after that. |
|
||||||
|
| Helper unavailable | The UI shows forwarding state "unknown". The last loaded table stays in force. |
|
||||||
|
|
||||||
|
TLS-proxy related behaviour (mode B/C) belongs to a later phase.
|
||||||
|
|
||||||
|
## IPv6
|
||||||
|
|
||||||
|
IPv6 forwarding and inspection are not implemented. The AP profile sets
|
||||||
|
`ipv6.method=disabled`, so clients get no router advertisements from the Pi,
|
||||||
|
and the firewall drops any IPv6 a client sends. Clients therefore fall back to
|
||||||
|
IPv4, which is fully routed and captured.
|
||||||
|
|
||||||
|
## Regulatory domain
|
||||||
|
|
||||||
|
The country code is applied with `iw reg set` on every apply and at boot. Set
|
||||||
|
it correctly: it determines the legal channels and transmit power.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# Operations: install, first run, upgrade, recovery
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Raspberry Pi 4 or 5 (other arm64 Debian systems with NetworkManager and an AP-capable radio
|
||||||
|
may work but are untested)
|
||||||
|
- Raspberry Pi OS Lite 64-bit, Bookworm or Trixie, with NetworkManager (the default)
|
||||||
|
- Ethernet connected to the network you will manage it from
|
||||||
|
- ≥ 1 GiB free disk. An SSD (USB 3 or NVMe) is strongly recommended for captures: SD cards
|
||||||
|
are slow and wear out.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tar xzf pilens-<ver>-linux-arm64.tar.gz && cd pilens-<ver>-linux-arm64
|
||||||
|
sudo ./install.sh # explains every change and asks first
|
||||||
|
```
|
||||||
|
|
||||||
|
What it does, in order: checks the platform; detects interfaces and your SSH path; checks AP
|
||||||
|
support, disk, RAM and conflicting services; shows the plan; backs up
|
||||||
|
`/etc/NetworkManager/system-connections` and `nft list ruleset` to
|
||||||
|
`/var/backups/pilens/<time>/`; installs `tshark wireshark-common nftables dnsmasq-base iw`
|
||||||
|
from apt; installs PiLens to `/opt/pilens`; creates user `pilens`; installs systemd units;
|
||||||
|
initialises the database and TLS certificate; starts the services and checks they are healthy.
|
||||||
|
If any step fails, the trap rolls back (on an upgrade, the previous version is restored).
|
||||||
|
|
||||||
|
It does **not** touch existing network connections. The access point is created by the
|
||||||
|
wizard.
|
||||||
|
|
||||||
|
Options: `--mgmt-iface eth0 --mgmt-subnet 192.168.1.0/24` to override detection, `--yes`,
|
||||||
|
`--non-interactive --config setup.yaml`.
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
Open the URL printed by the installer and compare the certificate fingerprint. The wizard
|
||||||
|
asks for the setup token, creates the administrator, and then walks through management access,
|
||||||
|
uplink, Wi-Fi, client subnet, forwarding, inspection mode and storage. "Apply" performs the
|
||||||
|
transactional network change. If the browser loses power or the page reloads, the wizard
|
||||||
|
resumes: progress is kept in the browser, and the server holds what has actually been applied.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Run the new package's `install.sh`. It detects the existing installation, keeps
|
||||||
|
`/var/lib/pilens`, replaces the binaries and units, and restarts. Migrations run automatically
|
||||||
|
on start. If the health check fails, the previous version is restored from the backup.
|
||||||
|
|
||||||
|
## Recovery
|
||||||
|
|
||||||
|
| Problem | Action |
|
||||||
|
|---|---|
|
||||||
|
| Services unhealthy, permissions wrong, dumpcap lost capabilities | `sudo /opt/pilens/repair.sh` |
|
||||||
|
| Forgotten password | `sudo -u pilens /opt/pilens/bin/pilens reset-password -user admin` (also disables TOTP and revokes sessions) |
|
||||||
|
| Locked out by the management subnet | On the console: `sudo nft delete table inet pilens`, then fix the subnet in the UI and apply. The table is restored at the next apply or reboot. |
|
||||||
|
| AP broken after a manual NM change | Re-apply from Network Settings, or `sudo nmcli connection up pilens-ap` |
|
||||||
|
| Need to return the Wi-Fi to client mode | `sudo nmcli connection down pilens-ap`. Your earlier Wi-Fi profile was never modified. |
|
||||||
|
| Bug report | `sudo /opt/pilens/diagnose.sh` (secrets redacted) |
|
||||||
|
| Remove | `sudo /opt/pilens/uninstall.sh` (keeps captures) or `--purge` |
|
||||||
|
|
||||||
|
The database is `/var/lib/pilens/pilens.db` (SQLite, WAL). To back it up: stop `pilens`, then
|
||||||
|
copy `pilens.db*` and `captures/`.
|
||||||
|
|
||||||
|
## Capture storage on an SSD
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo systemctl stop pilens
|
||||||
|
sudo mkdir -p /mnt/ssd/pilens-captures && sudo chown pilens:pilens /mnt/ssd/pilens-captures
|
||||||
|
sudo systemctl edit pilens
|
||||||
|
# [Service]
|
||||||
|
# ExecStart=
|
||||||
|
# ExecStart=/opt/pilens/bin/pilens serve -data /var/lib/pilens -listen :8443 -captures /mnt/ssd/pilens-captures
|
||||||
|
# ReadWritePaths=/mnt/ssd/pilens-captures
|
||||||
|
# [Unit]
|
||||||
|
# RequiresMountsFor=/mnt/ssd/pilens-captures
|
||||||
|
sudo systemctl start pilens
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service architecture and shutdown
|
||||||
|
|
||||||
|
`pilens-firewall` (oneshot, before networking) → `pilens-helper` (root) → `pilens-dnsmasq` →
|
||||||
|
`pilens` (Type=notify, watchdog 60 s). On SIGTERM, pilens stops accepting requests, stops any
|
||||||
|
capture (dumpcap flushes, the last segment is fsynced and hashed), and then exits. The timeout
|
||||||
|
is 60 s.
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Security and privacy model
|
||||||
|
|
||||||
|
Captured traffic can contain credentials, tokens, private messages, and
|
||||||
|
medical or financial data. PiLens treats the appliance as a privileged,
|
||||||
|
sensitive system.
|
||||||
|
|
||||||
|
## Trust boundaries
|
||||||
|
|
||||||
|
| Boundary | Untrusted side | Controls |
|
||||||
|
|---|---|---|
|
||||||
|
| Browser ↔ pilens | Management network | TLS, source allow-list (nftables **and** middleware), Argon2id passwords, session cookies `Secure; HttpOnly; SameSite=Strict`, CSRF token header, login throttling, optional TOTP, audit log |
|
||||||
|
| AP clients ↔ Pi | Wi-Fi network | nftables drops everything except DHCP, DNS and ICMP. Management is unreachable even through the uplink address. |
|
||||||
|
| pilens ↔ pilens-helper | pilens process | Unix socket 0660 root:pilens. `SO_PEERCRED` uid check. Fixed operations only. The helper re-validates the full config. Requests are capped at 1 MiB. |
|
||||||
|
| Packet data ↔ parsers | Anyone on the wire / uploaded files | Our pcap/pcapng readers bounds-check every length (16 MiB block cap). tshark runs via `rlimit-exec` (RLIMIT_AS, RLIMIT_CPU, no core dumps, `no_new_privs`) with a timeout and empty environment. |
|
||||||
|
| Packet data ↔ browser | Packet content | Rendered as text nodes by React. No `dangerouslySetInnerHTML` and no HTML preview. CSP `default-src 'self'`, `frame-ancestors 'none'`. |
|
||||||
|
| Uploads | Uploaded file | Size limit enforced while streaming. Magic-number check (pcap/pcapng only, no archives, so no decompression bombs). Server-generated file names, so no path traversal. Session IDs are strictly whitelisted (`^[0-9TZ]{16}-[0-9a-f]{6}$`). |
|
||||||
|
|
||||||
|
## Privilege separation
|
||||||
|
|
||||||
|
- **pilens** (user `pilens`): no root and no ambient capabilities. Capture uses
|
||||||
|
`dumpcap`, whose file capabilities are granted to group `wireshark`, which is
|
||||||
|
Debian's standard model. The unit's `CapabilityBoundingSet=CAP_NET_RAW
|
||||||
|
CAP_NET_ADMIN` limits what any child can ever gain. `NoNewPrivileges` cannot
|
||||||
|
be set because it would disable dumpcap's file capabilities.
|
||||||
|
`ProtectSystem=strict` leaves only `/var/lib/pilens` writable.
|
||||||
|
- **pilens-helper** (root): about 400 lines. It can write the PiLens NM
|
||||||
|
keyfile, its own state directory and `ip_forward`; run `nft`, `nmcli`, `iw`
|
||||||
|
and `systemctl` with fixed argv; and nothing else. There is no generic
|
||||||
|
command execution.
|
||||||
|
- **pilens-dnsmasq**: Debian's dnsmasq, which drops privileges itself.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
| Secret | Where | Exposure |
|
||||||
|
|---|---|---|
|
||||||
|
| Admin password | Argon2id hash in SQLite (0640 pilens) | Never returned |
|
||||||
|
| Session tokens | Only the SHA-256 is stored | Cookie only |
|
||||||
|
| Wi-Fi passphrase | SQLite + helper state (0600 root) + NM keyfile (0600 root) | Write-only in the API. Never in a process argv. Redacted in diagnostics. |
|
||||||
|
| Setup token | `/var/lib/pilens/setup-token` (0600) | Deleted when the first admin is created |
|
||||||
|
| Management TLS key | `/var/lib/pilens/tls/mgmt.key` (0600) | Never exported. Separate from any future interception CA. |
|
||||||
|
|
||||||
|
## Data protection
|
||||||
|
|
||||||
|
- PiLens currently stores no decrypted payloads, because it decrypts nothing.
|
||||||
|
- Captures are deleted by retention (oldest unpreserved first) or explicitly
|
||||||
|
by an admin, who must confirm. Deleting a session removes its files and all
|
||||||
|
index rows.
|
||||||
|
- Exports, deletions, configuration changes, logins (including failures),
|
||||||
|
password/TOTP changes and setup are recorded in the audit log.
|
||||||
|
- No telemetry. The only outbound traffic the appliance generates is what the
|
||||||
|
OS already does (apt, NTP) and the DNS forwarding it performs for AP clients.
|
||||||
|
|
||||||
|
## Reviewed attack classes
|
||||||
|
|
||||||
|
| Class | Mitigation |
|
||||||
|
|---|---|
|
||||||
|
| Command injection | All execs use argv lists, never a shell. Filter strings are passed as single arguments and checked for control characters. Interface names are matched against `^[A-Za-z0-9_.-]{1,15}$`. |
|
||||||
|
| Config injection (dnsmasq/NM keyfile) | Every rendered value is strictly validated (SSID charset, hostnames as RFC 1123 labels, IPs parsed), so newlines cannot reach a config file. |
|
||||||
|
| SQL injection | Parameterised queries only |
|
||||||
|
| Path traversal | IDs are server-generated and whitelisted. Segment file names come from the database, not the client. |
|
||||||
|
| XSS via packet content | React text rendering, CSP, no HTML rendering of captured data |
|
||||||
|
| SSRF | The server makes no outbound requests based on user input |
|
||||||
|
| Auth bypass | Every `/api/*` route except health, setup status, first-admin creation and login goes through `requireAuth`. The setup endpoint needs the setup token, and the INSERT is conditional on no user existing (race-safe). |
|
||||||
|
| Resource exhaustion | Bounded SSE queues (slow clients drop updates, never capture). Bounded tshark feed. Per-process rlimits. Upload and request size limits. Storage quota and reserve. Filter cache capped at 16 entries. |
|
||||||
|
| Malicious pcap | Bounds-checked block reader, fuzz-style malformed-input tests, tshark resource limits |
|
||||||
|
| Lock-out | The API refuses a management configuration that would exclude the caller's own address |
|
||||||
|
|
||||||
|
Dependency inventory: Go modules `modernc.org/sqlite`, `golang.org/x/crypto`
|
||||||
|
(Argon2id) and `gopkg.in/yaml.v3`; npm packages `react` and `react-dom`, plus
|
||||||
|
`vite` and `typescript` at build time. Run `govulncheck ./...` and `npm audit`
|
||||||
|
before each release.
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Testing
|
||||||
|
|
||||||
|
## Automated
|
||||||
|
|
||||||
|
| Command | What it covers | Needs |
|
||||||
|
|---|---|---|
|
||||||
|
| `make test` | Config validation, nft/dnsmasq/NM rendering, Argon2id, TOTP (RFC 6238 vector), throttling, helper parsers, pcap/pcapng reader incl. malformed input, API auth flow (setup token, authorised-use, CSRF, source allow-list, lock-out detection, logout, throttling), TypeScript typecheck | Go, Node |
|
||||||
|
| `make test-docker` | Everything above that needs tshark/dumpcap (import → index → filter → PDML with verified byte ranges → filtered export re-read by tshark → hash verify; live capture with segment rotation, counts agreement, per-segment validity), **plus** the netns suite below | Docker |
|
||||||
|
|
||||||
|
The netns suite (`tests/integration/run.sh`) builds an upstream/Pi/client topology from
|
||||||
|
veth pairs and runs the real `pilens` binary plus the configuration rendered by `pilens-helper`:
|
||||||
|
DHCP lease and options, DNS override, NAT to a public address, blocking of RFC 1918 /
|
||||||
|
management / IPv6 / inbound / Pi-SSH, management reachable only from the allowed subnet (not
|
||||||
|
from AP clients, not even via the uplink address), first-admin creation, capture on the AP
|
||||||
|
interface through the API, display filter, packet detail, export opened by tshark, integrity
|
||||||
|
check, CSRF rejection, deny mode, and survival of an unrelated nft table.
|
||||||
|
|
||||||
|
Not covered by automation: NetworkManager AP activation and its rollback (needs a real radio;
|
||||||
|
`mac80211_hwsim` is not available in containers), systemd unit behaviour, installer.
|
||||||
|
|
||||||
|
## Hardware acceptance checklist (Raspberry Pi)
|
||||||
|
|
||||||
|
Record date, Pi model, OS, PiLens version and the result for each item.
|
||||||
|
|
||||||
|
| # | Test | Expected |
|
||||||
|
|---|---|---|
|
||||||
|
| H1 | Fresh install on Pi OS Lite via SSH over Ethernet | Installer completes. Prints URL, fingerprint, token. Existing connections unchanged. |
|
||||||
|
| H2 | Wizard: create admin, apply AP | Steps all ✓. SSID visible. Ethernet SSH session not interrupted. |
|
||||||
|
| H3 | Phone joins the AP | Lease in the pool, Pi as DNS, internet works |
|
||||||
|
| H4 | Phone → LAN host (e.g. router admin page) | Blocked |
|
||||||
|
| H5 | Phone → `https://<pi-ap-ip>:8443` and SSH to the Pi | Blocked |
|
||||||
|
| H6 | Capture on wlan0 while browsing | Packets live in the UI. Export opens in desktop Wireshark. Verify = ok. |
|
||||||
|
| H7 | Cold boot | AP returns. Firewall table loaded before the AP (`journalctl -b -u pilens-firewall -u NetworkManager`). Forwarding works. |
|
||||||
|
| H8 | Power loss during capture | Session marked interrupted. Segment hashed. Earlier segments verify. |
|
||||||
|
| H9 | Unplug Ethernet for 1 min, replug | Dashboard shows no default route, then recovers. Clients keep their leases. |
|
||||||
|
| H10 | Client disconnects and reconnects | Device list updates. Same lease. |
|
||||||
|
| H11 | `kill -9` of pilens during a capture | systemd restarts it. Session interrupted. Forwarding unaffected. |
|
||||||
|
| H12 | Storage exhaustion (small quota) | Oldest unpreserved session deleted, or capture stops with an error. Preserved session intact. |
|
||||||
|
| H13 | Apply an invalid AP config (e.g. WPA3 on a radio without SAE) | Rolled back, previous AP restored, error shown |
|
||||||
|
| H14 | `uninstall.sh` then `install.sh` | Captures kept. Old Wi-Fi client profile works again after uninstall. |
|
||||||
|
| H15 | `repair.sh` after `setcap -r /usr/bin/dumpcap` | Capabilities restored |
|
||||||
|
| H16 | Throughput: iperf3 through the AP while capturing | Record Mbit/s, dumpcap drops, ingest lag |
|
||||||
|
| H17 | Import a 500 MB pcapng | Indexing time recorded. UI responsive. |
|
||||||
|
|
||||||
|
TLS-interception hardware tests belong to phase 4.
|
||||||
|
|
||||||
|
## Results log
|
||||||
|
|
||||||
|
Hardware results are recorded here only after they have actually been run. See the
|
||||||
|
release notes or commit log for the latest run.
|
||||||
@@ -94,7 +94,7 @@ check "public server sees NATed source (10.99.0.2)" sh -c "ip netns exec up ss -
|
|||||||
refuse "client blocked from RFC1918 destination 172.16.5.5" ip netns exec cl curl -s -m 3 -o /dev/null http://172.16.5.5/
|
refuse "client blocked from RFC1918 destination 172.16.5.5" ip netns exec cl curl -s -m 3 -o /dev/null http://172.16.5.5/
|
||||||
refuse "client blocked from management/upstream subnet 10.99.0.1" ip netns exec cl curl -s -m 3 -o /dev/null http://10.99.0.1:8080/
|
refuse "client blocked from management/upstream subnet 10.99.0.1" ip netns exec cl curl -s -m 3 -o /dev/null http://10.99.0.1:8080/
|
||||||
refuse "IPv6 from client is not forwarded" ip netns exec cl ping -6 -c 1 -W 2 fd01::1
|
refuse "IPv6 from client is not forwarded" ip netns exec cl ping -6 -c 1 -W 2 fd01::1
|
||||||
refuse "client blocked from Pi SSH port" ip netns exec cl bash -c 'exec 3<>/dev/tcp/192.168.50.1/22'
|
refuse "client blocked from Pi SSH port" ip netns exec cl timeout 3 bash -c 'exec 3<>/dev/tcp/192.168.50.1/22'
|
||||||
refuse "upstream cannot open connections into the AP subnet" ip netns exec up ping -c 1 -W 2 "$CIP"
|
refuse "upstream cannot open connections into the AP subnet" ip netns exec up ping -c 1 -W 2 "$CIP"
|
||||||
|
|
||||||
echo "== Real pilens service in the pi namespace"
|
echo "== Real pilens service in the pi namespace"
|
||||||
|
|||||||
Reference in New Issue
Block a user