The sensor¶
The sensor is a Raspberry Pi 5 with two Netgear A9000 sticks in monitor mode. It captures and tunes; the Mac does the rest over one Ethernet cable.
Installation¶
Each sensor has a number in its hostname, sensor-1 for the first: two sensors on one LAN both called sensor.local collide, avahi renames one, and the Mac's ssh then refuses its host key. On the Mac the SSH alias stays sensor, so the app and linkside-cli need no change per sensor.
These steps are how a sensor is built from the parts, at another institution too. The image of a sensor stays with that sensor and its operators: it holds Raspberry Pi OS with its kernel and firmware, and publishing it brings the GPL's obligation to offer the kernel's sources along.
- On the Mac, a key pair of the operator's own,
ssh-keygen -t ed25519 -f ~/.ssh/linkside, and theHost sensorblock of Network in~/.ssh/config, itsHostNamethe sensor's (sensor-1.local). - The card, with Raspberry Pi Imager: Raspberry Pi OS Lite 64-bit (Trixie), hostname
sensor-1, userpi, the public key~/.ssh/linkside.pub, no Wi-Fi. The Pi and the Mac then on one LAN with DHCP. -
On the Pi (
ssh sensor), the kernel and the firmware known good, held before anything upgrades them, then the rest:sudo apt update sudo apt install linux-image-rpi-2712=1:6.18.50-1+rpt1 raspi-firmware=1:1.20260915-1 \ firmware-mediatek=1:20260519-1~bpo13+1+rpt1 sudo apt-mark hold linux-image-rpi-2712 raspi-firmware firmware-mediatek sudo apt full-upgrade sudo apt install iw uhubctl tcpdump build-essential libpcap-devMonitor reception on the mt7925 depends on this combination, and a 6.17/6.18 kernel had an RX regression (a9000.md). On 2026-10-25 Raspberry Pi OS Lite from Imager came with exactly these versions, and the archive offered no other: one version per package, so once it moves on only the image keeps them. Where apt no longer offers one of these versions, the one it offers instead is untested: hold it all the same, and check monitor reception and the EHT decode as a9000.md describes before the sensor goes out.
-
On the Mac, in the repository:
make -C sensor copy. It copies the files git knows insensor/to~/linksideon the Pi, without the Mac's build outputs, withVERSIONbeside them: the version of the sensor's sources alone, which a commit outsidesensor/leaves as it was (the version). The archive istools/sensor-archive's, the one the app carries for Update Sensor, and is made whole before anything goes, so a file git knows that is gone stops the copy and the Pi keeps what it had.HOST=names another SSH host thansensor. -
On the Pi:
cd ~/linkside && make -C linkside-cap && sudo make install. That installs:linkside-cap(withsetcap) andlinkside-radioin/usr/local;/etc/linkside/version, fromVERSION:linkside-radio statusprints it, and the app shows it in Settings and records it insession.json;- the config fragments in
/etc; an existingradios.confis kept; - via
linkside-system-setup: the boot fragment withinclude linkside.txtinconfig.txt, the regdomain on the cmdline,allow-interfaces=eth0for avahi,systemd-timesyncdmasked (no network time, also on thesetupprofile), and NetworkManager's Wi-Fi radio off (WirelessEnabled=false), so every new Wi-Fi device comes up soft-blocked untillinkside-radio uplifts the block for its radio.
What can fail without changing anything comes first: the build, the sudoers file,
pi's key. Then a marker (/etc/linkside/installing) goes onto the card, each file is replaced whole, through a hidden copy renamed over it, the version is written last, and the marker goes. An install cut off between the two, by the power or by a step that fails such assshd -t, leaves every file old or new, never part of one, andlinkside-radio statusnames it (install=incomplete) until the install is run again; the app holds such a sensor as not ready. Without an SSH key forpithe installation stops before password login is turned off. -
sudo reboot, before plugging in the A9000s. The Pi comes up onlinkside-link: from here it is on the Ethernet cable to the Mac. - The roles in
/etc/linkside/radios.conf: plug in one A9000 at a time, read its address withcat /sys/class/ieee80211/phy*/macaddress, enter it ascapAorcapB, and label the sticks A and B; sticks moving over from another card keep their roles, itsradios.confread before the swap (ssh sensor cat /etc/linkside/radios.conf). Each stick on a blue USB 3 port: the two hang off different controllers (separate-controllers).usb_hubsnames the hubsusb-resetswitches,2 4on a Pi 5;uhubctllists them. sudo linkside-radio up, thenlinkside-radio status: every checkok=1, and the first line names the sources, the interface they offer the app, the kernel and the firmware (sensor version=… interface=1 kernel=6.18.50+rpt-rpi-2712 firmware=1:20260519-1~bpo13+1+rpt1).
A user other than pi requires a change in config/sudoers.d/linkside and in the authorized_keys check in the Makefile.
The image¶
A sensor whose checks all pass and that has run a live session is cloned: its card to an image, kept outside the repository with the date and the sensor line of linkside-radio status. The image keeps the known good kernel and firmware once apt offers others, and a spare card flashed from it travels with the sensor. The spare is the same sensor, with its hostname, host key, radio roles and the operators' keys of the day of the clone: a key added or revoked later goes onto the spare as well, or the image is made again. Linkside's own sources come onto the spare with Update Sensor once it is in the Pi (Updating); a spare from an image made before the interface number gets them by hand once.
- The free space trimmed and the Pi off:
ssh -t sensor 'sudo fstrim -v / && sudo fstrim -v /boot/firmware && sudo poweroff'. A trimmed block reads back erased, so the image compresses to what is in use: 756 MiB for a card with 3.9 GB in use. - The card in the Mac's SD slot, which
diskutil listshows as internal and physical (/dev/diskN, withbootfsand a Linux partition). The whole card into the image:diskutil unmountDisk /dev/diskN && sudo dd if=/dev/rdiskN bs=4m status=progress | zstd -q -T0 -3 -o sensor-1-<date>.img.zst, 25 minutes at 85 MB/s for 128 GB.zstd -dc sensor-1-<date>.img.zst | wc -cgives the card's size back, and checks the file. - The spare from it:
diskutil unmountDisk /dev/diskN && zstd -dc sensor-1-<date>.img.zst | sudo dd of=/dev/rdiskN ibs=1m obs=4m status=progress. The raw disk takes whole blocks only, andobscollects the pipe's reads into them. An image does not fit a card even slightly smaller than the one it came from: the spare's size in bytes (diskutil info) at least the image's. The two SanDisk High Endurance 128 GB cards of the first sensor are 127,865,454,592 bytes each; the write took 27 minutes at 78 MB/s. - The spare in the Pi once: it comes up as the sensor,
linkside-radio statusevery checkok=1.
The image holds the card, not the Pi's bootloader, which sits in the Pi's EEPROM for every card it boots. At each boot rpi-eeprom-update -s -a updates it only when it is older than the minimum the rpi-eeprom package names, 2025-05-08 for the Pi 5 in 28.34, so the 2026-09-25 release that package carries stays unapplied. A later rpi-eeprom that raises the minimum updates the bootloader at the next boot, under both cards.
Updating¶
Linkside's own sources need no LAN and no repository: the app carries the sensor's sources of its own commit, and Update Sensor, in Settings after Test connection and in New Session's sensor row, installs them over the cable. It is offered where the sensor's software differs from the app's: below the lowest interface the app works with New Session does not start before it, and an older interface or another build of the same one is a note with the button (the interface number). Another build is another version of the files in sensor/ (the version): an app built after a commit that changed nothing there, of the documentation or the app, finds the sensor current. It holds the sensor as a session does, from before anything is sent until it is done (linkside-radio claim): a session, or a second update from another window or Mac, is refused meanwhile, and a session that holds the sensor refuses the update, with that session named. Under the hold it reads the sensor's software again: a sensor that became newer than the app since the button showed is not set back, and one that already runs the app's sources needs nothing. Then it sends the archive into a fresh ~/linkside.stage, builds linkside-cap there, and runs sudo linkside-radio install linkside.stage, which installs it as root under the sudoers rule, without pi's password, and itself refuses sources of a lower interface than the sensor's; the same command then keeps the stage as ~/linkside. A cut before the install, the cable or the power, leaves the sensor as it was. With the cable out the sensor runs the step it is in on to its end, the install too: the app says the connection was lost, and its check of the sensor with the cable back shows where the sensor stands. The update's hold stays while the cable is out and ends a second after it is back, when the Mac resets the old connection. A power cut during the install leaves it marked as an install cut off, not ready until Update Sensor runs again. When the install changed a boot setting, Restart Sensor beside its result applies it. Kernel, firmware and the OS's packages stay the image's.
A sensor installed before the interface number has no install: the app names it too old for Update Sensor, and it gets its sources by hand once, as after a change to them in the repository. On the Mac in the repository make -C sensor copy, then ssh -t sensor 'cd ~/linkside && make -C linkside-cap && sudo make install', where sudo asks for pi's password; from interface 1 on, ssh sensor 'cd ~/linkside && make -C linkside-cap && sudo linkside-radio install linkside' installs the copy without it. linkside-radio status then names the new version. The copy unpacks over ~/linkside: a file removed from the repository stays there, unused, since make install names what it installs.
Packages need the internet: connect the Pi to a LAN with DHCP, with the Mac on the same LAN, and bring up the setup profile, ssh -t sensor sudo nmcli connection up setup. apt full-upgrade leaves the held packages as they are. After a reboot linkside-link is active again. The link-local address is the same in both profiles.
Shutting down¶
File > Shut Down Sensor… in the app runs sudo linkside-radio poweroff (linkside-radio): the Pi powers off two seconds later and starts again only when its power is cut and connected again. The Pi stops answering within seconds of the app's "The sensor is shutting down", and comes back with every check passing once its power is cut and connected again; sudo linkside-radio reboot from the Mac brings it back in about 30 s, both sticks present, their monitor interfaces set up again by up, which New Session runs. The root file system is read-write, so a power cut while the Pi writes can corrupt the card; a capture writes nothing on the Pi. A read-only root with an overlay (raspi-config has the option) is the answer once card corruption shows up, weighed against the files an update writes: the version stamp and radios.conf.
The clock¶
The sensor has no network time: its clock runs on the Pi 5's RTC, which its battery keeps while the Pi has no power (linkside-system-setup turns its charging on). With the battery unplugged and the power cut, the RTC loses its time, and the Pi starts at the date of its last network time, eight days back on 2026-11-02 (clock.md). A session measures its own offset, so its captures stay right; the Pi's logs and apt on the setup profile do not. On the Mac, ssh -t sensor "sudo timedatectl set-time '$(date '+%F %T')'" sets the Pi's clock and its RTC to the Mac's time, to the second, and sudo asks for pi's password; timedatectl on the Pi then shows the two alike.
Network¶
After installation eth0 has only an IPv6 link-local address, derived from the MAC address (EUI-64). No IPv4, no DHCP, no gateway. avahi publishes the sensor's name (sensor-1.local) only on eth0.
On the Mac, in ~/.ssh/config:
Host sensor
HostName sensor-1.local
User pi
IdentityFile ~/.ssh/linkside
IdentitiesOnly yes
ControlMaster auto
ControlPath ~/.ssh/cm-%r@%h:%p
ControlPersist 10m
ServerAliveInterval 5
ServerAliveCountMax 3
With ControlMaster the tune commands, the clock measurement and the two capture streams share one SSH connection. ServerAliveInterval detects an unplugged cable within about 15 seconds.
A second Mac¶
Each operator has a key of their own on the sensor: a key no longer wanted is one line out of authorized_keys, and a laptop lost revokes only its own key, where a shared key stays valid on every Mac it ever reached. Password login is off, so ssh-copy-id does not work: someone who has access adds the key.
- On the new Mac, Wireshark 4.6 and the app. IPv6 on for the Ethernet adapter (System Settings > Network > the adapter > Details > TCP/IP, Configure IPv6 Automatically); a managed Mac can have it off. Link-local needs no router.
- On the new Mac,
ssh-keygen -t ed25519 -f ~/.ssh/linkside -C "<name>"with an empty passphrase, since the app runs ssh withBatchMode=yes, which asks for none, and theHost sensorblock of Network in~/.ssh/config. ~/.ssh/linkside.pubto someone who has access.- On their Mac, the sensor connected:
ssh sensor 'cat >> ~/.ssh/authorized_keys' < linkside-<name>.pub, and the host key's fingerprint for the new operator:ssh sensor ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub. - On the new Mac, the sensor connected:
ssh sensor trueonce, answering yes only when the fingerprint ssh shows is the one from step 4. The app runs ssh withBatchMode=yes, which refuses a host key it does not know yet ("Host key verification failed."). - Settings > Sensor > Test connection: "Ready" with a green dot, and "Software: up to date".
The steps are checked on an Intel MacBook Pro with macOS 15: Test connection said "Reachable", and a live session ran against the sensor.
To revoke a key, delete its line from ~/.ssh/authorized_keys on the Pi.
One session at a time holds the sensor (linkside-radio, claim): a second Mac's session waits for the first to end, or takes it over. Test connection names the session that holds it.