linkside-cap¶
Captures one monitor interface with an address filter and truncation per frame type, and writes pcap to stdout. The Mac starts it via SSH and decodes the stream with tshark.
Truncation¶
Per frame type, no fixed snaplen. The same rules apply to the export of the app (Truncation.swift) and to tools/anonymize.
| Frame | Kept |
|---|---|
| Management and control | Full |
| EAPOL-Key | Full |
| EAP | Up to and including the EAP type; EAP-Response/Identity in full, so the app can hash the outer identity |
| Other EAPOL | Up to and including the EAPOL header |
| Encrypted data | The MAC header plus the CCMP/GCMP header (8 bytes) |
| Data without a body (Null, QoS Null) and unencrypted A-MSDU | The MAC header only |
| Other unencrypted data | Up to and including the LLC/SNAP header |
| Extension frames (type 3) | The first 24 bytes |
The MAC header length follows Frame Control: QoS +2, four addresses +6, +HTC +4. The radiotap header is kept whole; its length is little-endian in bytes 2–3.
Usage¶
linkside-cap -i capA -f '<filter>' [-B <KiB>] # live, pcap to stdout
linkside-cap -r in.pcap -f '<filter>' # offline, same filter and truncation
linkside-cap -d -f '<filter>' # show the BPF program
-f: a libpcap filter. Without-fevery frame passes, still with truncation.-B: ring buffer in KiB, default 16384.- Monitor interface, channel and width come from the radio scripts.
linkside-capdoes not enable monitor mode.
stderr¶
One line per event, with key=value pairs:
start iface=capA insns=172 kernel_filter=yes usb=5000
stats ts=1760000000.123 recv=812 drop=0 ifdrop=0 written=812 utrunc=0 bad=0
end ts=1760000060.124 recv=48211 drop=0 ifdrop=0 written=48211 utrunc=0 bad=0
| Field | Meaning |
|---|---|
usb |
Mbit/s of the USB port the radio sits on (5000 for USB 3, 480 for USB 2), from sysfs; absent offline and for a radio not on USB. The app takes a focus on 5 or 6 GHz over USB 2 for a measurement limit (rules, usb-port) |
kernel_filter |
yes: the kernel runs the program and truncates the frames. no: libpcap filters in user space; the truncation is still correct, but the Pi copies every frame in full |
recv, drop, ifdrop |
pcap_stats, cumulative since the start. drop: ring full. ifdrop: drops by the interface |
written |
frames to stdout |
utrunc |
frames truncated only in user space. With kernel_filter=yes this should be 0 |
bad |
frames in which the length block read outside the frame; these are not written |
stats comes every second. Each line is also a sign of life: after an error writing to stdout or stderr the program stops. It also stops when the reader of stdout is gone, with nothing to write (How it works).
How it works¶
- libpcap compiles the filter. Each accepting
ret #kbecomes a jump to a custom length block that returns the number of bytes to keep (ret a). The kernel runs that program on the socket and truncates each frame before it reaches user space. - The same length block then runs once more in user space on each frame (
pcap_offline_filter). This keeps the truncation correct when the kernel rejects the program and libpcap falls back to filtering in user space. The length block only reads bytes it keeps itself, so a second run gives the same length. - No immediate mode: it would make libpcap select TPACKET_V2, with ring slots of the full snaplen (256 KiB per frame). With TPACKET_V3 the truncated frames sit close together, and the kernel delivers a block at least every 100 ms. The output is flushed after each block; the latency is therefore at most 100 ms.
- The pcap global header is flushed at the start, before any frame: stdout is a pipe and fully buffered, and a capture whose filter keeps no frame (
capBon a channel where the client never goes) would otherwise end without a header when the Mac stops it, an empty file in the recording. - It watches stdout beside the radio: a pipe whose reader is gone reports
POLLERR, and the capture ends. Over an SSH ControlMaster connection, which the app's first ssh opens with the getting started'sControlMaster auto, sshd closes the reader of stdout when the session's channel closes and keeps that of stderr, so the stats line goes on; without a master the connection closes, and both pipes with it. Nothing else on the Pi ends a session's capture: the claim's end removes the claim file only. Until 2026-11-03 a capture over the master ended with its next frame, and one on a channel where its filter keeps nothing lived on:capB's focus capture after Change capB… to a channel of no BSS it named, 15 minutes after its channel closed, untilcapBcame back to its BSS; of item 041's fifteen CLI sessions that day, ten ended during a search and three left a capture oncapA. Measured on sensor-1 on 2026-11-03 with a capture whose filter keeps nothing, started over ssh and its ssh ended with SIGTERM asSubprocessends it: before, it lived on over the master and was gone within 4 s without one. Now it is gone 3.2 s after SIGTERM either way, as is one whose filter keeps beacons. The ssh itself ends within 0.03 s, and on the Pi the capture ends 0.03 s after its reader goes, so the rest lies between the ssh's end and the pipe's close. - Timestamps in nanoseconds.