umshctl/command/capture/
pcap.rs

1//! Classic-pcap sink for captured frames.
2//!
3//! Radio frames and ULCP control frames land in one file using the
4//! repository's established synthetic Ethernet/IPv4/UDP encapsulation,
5//! so stock Wireshark opens a capture containing both layers. Raw LoRa
6//! bytes are also available, at the cost of a link type the user has to
7//! name.
8//!
9//! LoRaTap carries the radio frame under a header describing the
10//! channel it arrived on, so the RSSI and SNR the receiver reported
11//! survive into the capture instead of being dropped on the floor. It
12//! is the encapsulation the Wireshark extcap interface uses, because it
13//! invents none of the addressing the Ethernet form has to.
14
15use std::fs::File;
16use std::io::{BufWriter, Write};
17use std::path::Path;
18use std::time::{SystemTime, UNIX_EPOCH};
19
20use umsh::hal::RxInfo;
21
22/// Which layers a pcap file records.
23#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, clap::ValueEnum)]
24pub enum CaptureLayers {
25    /// Frames the radio heard over the air.
26    #[default]
27    Radio,
28    /// Control traffic between this host and the device.
29    #[value(alias = "companion")]
30    Ulcp,
31    Both,
32}
33
34impl CaptureLayers {
35    pub fn radio(self) -> bool {
36        matches!(self, Self::Radio | Self::Both)
37    }
38
39    pub fn ulcp(self) -> bool {
40        matches!(self, Self::Ulcp | Self::Both)
41    }
42}
43
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub enum PcapDirection {
46    HostToDevice,
47    DeviceToHost,
48}
49
50const PCAP_LINKTYPE_ETHERNET: u32 = 1;
51const PCAP_LINKTYPE_LORATAP: u32 = 270;
52const RADIO_UDP_PORT: u16 = 4242;
53const ULCP_HOST_UDP_PORT: u16 = 4243;
54const ULCP_DEVICE_UDP_PORT: u16 = 4244;
55
56/// Fixed part of a LoRaTap v0 header, in bytes.
57const LORATAP_V0_LEN: u16 = 15;
58
59/// LoRaTap reports signal strength as an unsigned byte biased by this
60/// much, so -139 dBm is zero.
61const LORATAP_RSSI_BIAS: i32 = 139;
62
63#[derive(Clone, Copy, Debug)]
64pub enum PcapEncapsulation {
65    Ethernet,
66    RawLoRa { linktype: u32 },
67    LoRaTap,
68}
69
70/// The channel a capture is listening on, read back from the device.
71///
72/// LoRaTap describes every frame in terms of the channel it arrived on,
73/// so these travel with each record even though the radio holds them
74/// still for the whole capture.
75#[derive(Clone, Copy, Debug, Default)]
76pub struct RfParams {
77    pub freq_hz: u32,
78    pub bw_hz: u32,
79    pub sf: u8,
80    pub sync_word: u8,
81}
82
83pub struct PcapWriter {
84    output: BufWriter<Box<dyn Write>>,
85    layers: CaptureLayers,
86    encapsulation: PcapEncapsulation,
87    packet_id: u16,
88}
89
90impl PcapWriter {
91    pub fn create(
92        path: &Path,
93        layers: CaptureLayers,
94        encapsulation: PcapEncapsulation,
95    ) -> std::io::Result<Self> {
96        Self::to_writer(Box::new(File::create(path)?), layers, encapsulation)
97    }
98
99    /// Write the capture into an arbitrary sink.
100    ///
101    /// The extcap interface hands us the FIFO Wireshark is reading, which
102    /// is opened for writing rather than created.
103    pub fn to_writer(
104        sink: Box<dyn Write>,
105        layers: CaptureLayers,
106        encapsulation: PcapEncapsulation,
107    ) -> std::io::Result<Self> {
108        let mut output = BufWriter::new(sink);
109        output.write_all(&0xa1b2_c3d4u32.to_le_bytes())?;
110        output.write_all(&2u16.to_le_bytes())?;
111        output.write_all(&4u16.to_le_bytes())?;
112        output.write_all(&0i32.to_le_bytes())?;
113        output.write_all(&0u32.to_le_bytes())?;
114        output.write_all(&65_535u32.to_le_bytes())?;
115        let linktype = match encapsulation {
116            PcapEncapsulation::Ethernet => PCAP_LINKTYPE_ETHERNET,
117            PcapEncapsulation::RawLoRa { linktype } => linktype,
118            PcapEncapsulation::LoRaTap => PCAP_LINKTYPE_LORATAP,
119        };
120        output.write_all(&linktype.to_le_bytes())?;
121        output.flush()?;
122        Ok(Self {
123            output,
124            layers,
125            encapsulation,
126            packet_id: 0,
127        })
128    }
129
130    pub fn write_radio(&mut self, frame: &[u8]) -> std::io::Result<()> {
131        if self.layers.radio() {
132            match self.encapsulation {
133                PcapEncapsulation::Ethernet => self.write_udp(
134                    PcapDirection::DeviceToHost,
135                    RADIO_UDP_PORT,
136                    RADIO_UDP_PORT,
137                    frame,
138                )?,
139                PcapEncapsulation::RawLoRa { .. } => self.write_record(frame)?,
140                // Without reception metadata the header would be all
141                // zeroes, which reads as a real -139 dBm measurement.
142                PcapEncapsulation::LoRaTap => {
143                    return Err(std::io::Error::other(
144                        "LoRaTap capture requires per-frame reception metadata",
145                    ));
146                }
147            }
148        }
149        Ok(())
150    }
151
152    /// Record a radio frame along with how the receiver heard it.
153    ///
154    /// Only LoRaTap has somewhere to put the metadata; the other
155    /// encapsulations quietly ignore it and record the frame alone.
156    pub fn write_radio_with_info(
157        &mut self,
158        rf: &RfParams,
159        info: &RxInfo,
160        frame: &[u8],
161    ) -> std::io::Result<()> {
162        if !self.layers.radio() {
163            return Ok(());
164        }
165        if !matches!(self.encapsulation, PcapEncapsulation::LoRaTap) {
166            return self.write_radio(frame);
167        }
168
169        let mut packet = Vec::with_capacity(usize::from(LORATAP_V0_LEN) + frame.len());
170        packet.push(0); // version
171        packet.push(0); // padding
172        packet.extend_from_slice(&LORATAP_V0_LEN.to_be_bytes());
173        packet.extend_from_slice(&rf.freq_hz.to_be_bytes());
174        packet.push(loratap_bandwidth(rf.bw_hz));
175        packet.push(rf.sf);
176        // A frame the device transmitted itself was never received, and
177        // LoRaTap v0 has no way to say so — its signal bytes are always
178        // readings. The rails (-139 dBm, -32 dB) are at least values no
179        // real link here produces, where the collapsed 0 dBm would chart
180        // as the strongest signal in the capture.
181        let (rssi, snr) = if info.origin.is_measured() {
182            (
183                loratap_rssi(i32::from(info.rssi)),
184                loratap_snr(info.snr.as_centibels()),
185            )
186        } else {
187            (0, loratap_snr(i16::MIN))
188        };
189        packet.push(rssi); // packet RSSI
190        packet.push(rssi); // max RSSI
191        packet.push(rssi); // current RSSI
192        packet.push(snr);
193        packet.push(rf.sync_word);
194        packet.extend_from_slice(frame);
195
196        self.write_record(&packet)
197    }
198
199    pub fn write_ulcp(&mut self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
200        if !self.layers.ulcp() {
201            return Ok(());
202        }
203        debug_assert!(matches!(self.encapsulation, PcapEncapsulation::Ethernet));
204        let (src_port, dst_port) = match direction {
205            PcapDirection::HostToDevice => (ULCP_HOST_UDP_PORT, ULCP_DEVICE_UDP_PORT),
206            PcapDirection::DeviceToHost => (ULCP_DEVICE_UDP_PORT, ULCP_HOST_UDP_PORT),
207        };
208        self.write_udp(direction, src_port, dst_port, frame)
209    }
210
211    fn write_udp(
212        &mut self,
213        direction: PcapDirection,
214        src_port: u16,
215        dst_port: u16,
216        payload: &[u8],
217    ) -> std::io::Result<()> {
218        let udp_len = 8usize
219            .checked_add(payload.len())
220            .and_then(|len| u16::try_from(len).ok())
221            .ok_or_else(|| std::io::Error::other("capture payload exceeds IPv4 UDP size"))?;
222        let ip_len = 20u16
223            .checked_add(udp_len)
224            .ok_or_else(|| std::io::Error::other("capture packet exceeds IPv4 size"))?;
225        let frame_len = 14usize + usize::from(ip_len);
226        let mut packet = Vec::with_capacity(frame_len);
227
228        // Synthetic Ethernet and loopback IPv4 endpoints. Direction remains
229        // visible in both endpoint addresses and ULCP UDP ports.
230        packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
231        packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
232        packet.extend_from_slice(&0x0800u16.to_be_bytes());
233        let (src_ip, dst_ip) = match direction {
234            PcapDirection::HostToDevice => ([127, 0, 0, 1], [127, 0, 0, 2]),
235            PcapDirection::DeviceToHost => ([127, 0, 0, 2], [127, 0, 0, 1]),
236        };
237        let ip_start = packet.len();
238        packet.extend_from_slice(&[
239            0x45,
240            0,
241            (ip_len >> 8) as u8,
242            ip_len as u8,
243            (self.packet_id >> 8) as u8,
244            self.packet_id as u8,
245            0,
246            0,
247            64,
248            17,
249            0,
250            0,
251            src_ip[0],
252            src_ip[1],
253            src_ip[2],
254            src_ip[3],
255            dst_ip[0],
256            dst_ip[1],
257            dst_ip[2],
258            dst_ip[3],
259        ]);
260        let checksum = ipv4_checksum(&packet[ip_start..ip_start + 20]);
261        packet[ip_start + 10..ip_start + 12].copy_from_slice(&checksum.to_be_bytes());
262        packet.extend_from_slice(&src_port.to_be_bytes());
263        packet.extend_from_slice(&dst_port.to_be_bytes());
264        packet.extend_from_slice(&udp_len.to_be_bytes());
265        packet.extend_from_slice(&0u16.to_be_bytes());
266        packet.extend_from_slice(payload);
267        self.packet_id = self.packet_id.wrapping_add(1);
268
269        self.write_record(&packet)
270    }
271
272    fn write_record(&mut self, packet: &[u8]) -> std::io::Result<()> {
273        let timestamp = SystemTime::now()
274            .duration_since(UNIX_EPOCH)
275            .unwrap_or_default();
276        let seconds = u32::try_from(timestamp.as_secs()).unwrap_or(u32::MAX);
277        let captured_len = u32::try_from(packet.len())
278            .map_err(|_| std::io::Error::other("capture record exceeds pcap size"))?;
279        self.output.write_all(&seconds.to_le_bytes())?;
280        self.output
281            .write_all(&timestamp.subsec_micros().to_le_bytes())?;
282        self.output.write_all(&captured_len.to_le_bytes())?;
283        self.output.write_all(&captured_len.to_le_bytes())?;
284        self.output.write_all(packet)?;
285        // Keep the file usable by Wireshark during a long-running capture.
286        self.output.flush()
287    }
288}
289
290/// Map a bandwidth to LoRaTap's enumerated byte.
291///
292/// The field is an enumeration of the three classic LoRa bandwidths
293/// rather than a scale factor, so it cannot express the 62.5 kHz UMSH
294/// normally runs at. Anything it cannot name is reported as zero, which
295/// Wireshark renders as "Unknown" — a narrower bandwidth silently
296/// labeled as one of the three would misdescribe the radio.
297fn loratap_bandwidth(bw_hz: u32) -> u8 {
298    match bw_hz {
299        125_000 => 1,
300        250_000 => 2,
301        500_000 => 4,
302        _ => 0,
303    }
304}
305
306/// Bias a dBm reading into LoRaTap's unsigned byte, saturating rather
307/// than wrapping so an implausible reading stays at the rail.
308fn loratap_rssi(dbm: i32) -> u8 {
309    (dbm + LORATAP_RSSI_BIAS).clamp(0, u8::MAX as i32) as u8
310}
311
312/// Convert centibels to LoRaTap's signed quarter-dB byte, rounding to
313/// nearest.
314fn loratap_snr(centibels: i16) -> u8 {
315    let scaled = i32::from(centibels) * 4;
316    let quarters = if scaled >= 0 {
317        (scaled + 5) / 10
318    } else {
319        (scaled - 5) / 10
320    };
321    quarters.clamp(i8::MIN as i32, i8::MAX as i32) as i8 as u8
322}
323
324fn ipv4_checksum(header: &[u8]) -> u16 {
325    let mut sum = 0u32;
326    for word in header.chunks_exact(2) {
327        sum += u32::from(u16::from_be_bytes([word[0], word[1]]));
328    }
329    while sum > 0xffff {
330        sum = (sum & 0xffff) + (sum >> 16);
331    }
332    !(sum as u16)
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use std::path::PathBuf;
339
340    fn temp_capture_path(label: &str) -> PathBuf {
341        let nonce = SystemTime::now()
342            .duration_since(UNIX_EPOCH)
343            .unwrap()
344            .as_nanos();
345        std::env::temp_dir().join(format!(
346            "umshctl-capture-{label}-{}-{nonce}.pcap",
347            std::process::id(),
348        ))
349    }
350
351    #[test]
352    fn raw_pcap_preserves_lora_bytes_and_requested_linktype() {
353        let path = temp_capture_path("raw");
354        let mut writer = PcapWriter::create(
355            &path,
356            CaptureLayers::Radio,
357            PcapEncapsulation::RawLoRa { linktype: 147 },
358        )
359        .unwrap();
360        writer.write_radio(&[0xc0, 0xa1, 0xb2, 0x03]).unwrap();
361        drop(writer);
362
363        let bytes = std::fs::read(&path).unwrap();
364        let _ = std::fs::remove_file(path);
365        assert_eq!(u32::from_le_bytes(bytes[20..24].try_into().unwrap()), 147);
366        assert_eq!(u32::from_le_bytes(bytes[32..36].try_into().unwrap()), 4);
367        assert_eq!(&bytes[40..], &[0xc0, 0xa1, 0xb2, 0x03]);
368    }
369
370    #[test]
371    fn ethernet_pcap_preserves_ulcp_direction_and_payload() {
372        let path = temp_capture_path("ulcp");
373        let mut writer =
374            PcapWriter::create(&path, CaptureLayers::Ulcp, PcapEncapsulation::Ethernet).unwrap();
375        writer
376            .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
377            .unwrap();
378        drop(writer);
379
380        let bytes = std::fs::read(&path).unwrap();
381        let _ = std::fs::remove_file(path);
382        let packet = &bytes[40..];
383        assert_eq!(&packet[12..14], &0x0800u16.to_be_bytes());
384        assert_eq!(packet[23], 17);
385        assert_eq!(
386            u16::from_be_bytes(packet[34..36].try_into().unwrap()),
387            ULCP_HOST_UDP_PORT,
388        );
389        assert_eq!(
390            u16::from_be_bytes(packet[36..38].try_into().unwrap()),
391            ULCP_DEVICE_UDP_PORT,
392        );
393        assert_eq!(&packet[42..], &[0x81, 0x02, 0x26]);
394    }
395
396    /// A sink the test can read back after the writer has been dropped.
397    #[derive(Clone, Default)]
398    struct SharedSink(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
399
400    impl Write for SharedSink {
401        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
402            self.0.borrow_mut().extend_from_slice(buf);
403            Ok(buf.len())
404        }
405        fn flush(&mut self) -> std::io::Result<()> {
406            Ok(())
407        }
408    }
409
410    fn sample_rf() -> RfParams {
411        RfParams {
412            freq_hz: 910_525_000,
413            bw_hz: 125_000,
414            sf: 7,
415            sync_word: 0x2b,
416        }
417    }
418
419    fn sample_info(len: usize, rssi: i16, snr_centibels: i16) -> RxInfo {
420        RxInfo {
421            len,
422            rssi,
423            snr: umsh::hal::Snr::from_centibels(snr_centibels),
424            lqi: None,
425            origin: umsh::hal::RxOrigin::Air,
426        }
427    }
428
429    /// The scalings here are the ones Wireshark's LoRaTap dissector
430    /// actually applies: RSSI is biased by 139 and SNR is in quarter-dB
431    /// steps, both verified against `tshark -V` output.
432    #[test]
433    fn loratap_records_channel_and_reception_metadata() {
434        let sink = SharedSink::default();
435        let bytes = sink.0.clone();
436        let mut writer = PcapWriter::to_writer(
437            Box::new(sink),
438            CaptureLayers::Radio,
439            PcapEncapsulation::LoRaTap,
440        )
441        .unwrap();
442        let frame = [0xc0, 0xa1, 0xb2, 0x03, 0x11, 0x22];
443        writer
444            .write_radio_with_info(&sample_rf(), &sample_info(frame.len(), -52, 95), &frame)
445            .unwrap();
446        drop(writer);
447
448        let bytes = bytes.borrow();
449        assert_eq!(
450            u32::from_le_bytes(bytes[20..24].try_into().unwrap()),
451            PCAP_LINKTYPE_LORATAP,
452        );
453        let packet = &bytes[40..];
454        assert_eq!(packet.len(), 15 + frame.len());
455        assert_eq!(packet[0], 0, "version");
456        assert_eq!(u16::from_be_bytes(packet[2..4].try_into().unwrap()), 15);
457        assert_eq!(
458            u32::from_be_bytes(packet[4..8].try_into().unwrap()),
459            910_525_000,
460        );
461        assert_eq!(packet[8], 1, "125 kHz in 125 kHz steps");
462        assert_eq!(packet[9], 7, "spreading factor");
463        assert_eq!(packet[10], 87, "-52 dBm biased by 139");
464        assert_eq!(packet[13], 38, "9.5 dB in quarter-dB steps");
465        assert_eq!(packet[14], 0x2b, "sync word");
466        assert_eq!(&packet[15..], &frame);
467    }
468
469    /// A self-transmitted frame carries no reading, and its collapsed
470    /// 0 dBm placeholder must not chart as the strongest signal in the
471    /// capture; the rails are the honest choice v0 leaves open.
472    #[test]
473    fn loratap_records_self_tx_at_the_rails() {
474        let sink = SharedSink::default();
475        let bytes = sink.0.clone();
476        let mut writer = PcapWriter::to_writer(
477            Box::new(sink),
478            CaptureLayers::Radio,
479            PcapEncapsulation::LoRaTap,
480        )
481        .unwrap();
482        let frame = [0xc0, 0xa1];
483        let mut info = sample_info(frame.len(), 0, 0);
484        info.origin = umsh::hal::RxOrigin::Backhaul;
485        writer
486            .write_radio_with_info(&sample_rf(), &info, &frame)
487            .unwrap();
488        drop(writer);
489
490        let bytes = bytes.borrow();
491        let packet = &bytes[40..];
492        assert_eq!(packet[10], 0, "RSSI at the -139 dBm floor");
493        assert_eq!(packet[13] as i8, i8::MIN, "SNR at the -32 dB rail");
494    }
495
496    /// Wireshark reads this field as an enumeration, so a bandwidth it
497    /// has no name for must not borrow the nearest one.
498    #[test]
499    fn loratap_bandwidth_is_an_enumeration_not_a_scale() {
500        assert_eq!(loratap_bandwidth(125_000), 1);
501        assert_eq!(loratap_bandwidth(250_000), 2);
502        assert_eq!(loratap_bandwidth(500_000), 4);
503        assert_eq!(loratap_bandwidth(62_500), 0, "UMSH's default is unnameable");
504        assert_eq!(loratap_bandwidth(200_000), 0, "not rounded down to 125 kHz");
505    }
506
507    #[test]
508    fn loratap_encodes_negative_snr_as_a_signed_byte() {
509        assert_eq!(loratap_snr(-75) as i8, -30, "-7.5 dB");
510        assert_eq!(loratap_snr(0), 0);
511        assert_eq!(loratap_rssi(-52), 87);
512        assert_eq!(loratap_rssi(-200), 0, "saturates rather than wrapping");
513    }
514
515    /// The metadata-free entry point cannot fabricate a LoRaTap header,
516    /// so it must refuse rather than emit a plausible-looking -139 dBm.
517    #[test]
518    fn loratap_rejects_frames_without_metadata() {
519        let mut writer = PcapWriter::to_writer(
520            Box::new(SharedSink::default()),
521            CaptureLayers::Radio,
522            PcapEncapsulation::LoRaTap,
523        )
524        .unwrap();
525        assert!(writer.write_radio(&[0xc0, 0xa1]).is_err());
526    }
527
528    #[test]
529    fn a_radio_only_file_ignores_ulcp_frames() {
530        let path = temp_capture_path("radio-only");
531        let mut writer =
532            PcapWriter::create(&path, CaptureLayers::Radio, PcapEncapsulation::Ethernet).unwrap();
533        writer
534            .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
535            .unwrap();
536        drop(writer);
537
538        let bytes = std::fs::read(&path).unwrap();
539        let _ = std::fs::remove_file(path);
540        assert_eq!(bytes.len(), 24, "header only, no records");
541    }
542}