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        let rssi = loratap_rssi(i32::from(info.rssi));
177        packet.push(rssi); // packet RSSI
178        packet.push(rssi); // max RSSI
179        packet.push(rssi); // current RSSI
180        packet.push(loratap_snr(info.snr.as_centibels()));
181        packet.push(rf.sync_word);
182        packet.extend_from_slice(frame);
183
184        self.write_record(&packet)
185    }
186
187    pub fn write_ulcp(&mut self, direction: PcapDirection, frame: &[u8]) -> std::io::Result<()> {
188        if !self.layers.ulcp() {
189            return Ok(());
190        }
191        debug_assert!(matches!(self.encapsulation, PcapEncapsulation::Ethernet));
192        let (src_port, dst_port) = match direction {
193            PcapDirection::HostToDevice => (ULCP_HOST_UDP_PORT, ULCP_DEVICE_UDP_PORT),
194            PcapDirection::DeviceToHost => (ULCP_DEVICE_UDP_PORT, ULCP_HOST_UDP_PORT),
195        };
196        self.write_udp(direction, src_port, dst_port, frame)
197    }
198
199    fn write_udp(
200        &mut self,
201        direction: PcapDirection,
202        src_port: u16,
203        dst_port: u16,
204        payload: &[u8],
205    ) -> std::io::Result<()> {
206        let udp_len = 8usize
207            .checked_add(payload.len())
208            .and_then(|len| u16::try_from(len).ok())
209            .ok_or_else(|| std::io::Error::other("capture payload exceeds IPv4 UDP size"))?;
210        let ip_len = 20u16
211            .checked_add(udp_len)
212            .ok_or_else(|| std::io::Error::other("capture packet exceeds IPv4 size"))?;
213        let frame_len = 14usize + usize::from(ip_len);
214        let mut packet = Vec::with_capacity(frame_len);
215
216        // Synthetic Ethernet and loopback IPv4 endpoints. Direction remains
217        // visible in both endpoint addresses and ULCP UDP ports.
218        packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 2]);
219        packet.extend_from_slice(&[0x02, 0, 0, 0, 0, 1]);
220        packet.extend_from_slice(&0x0800u16.to_be_bytes());
221        let (src_ip, dst_ip) = match direction {
222            PcapDirection::HostToDevice => ([127, 0, 0, 1], [127, 0, 0, 2]),
223            PcapDirection::DeviceToHost => ([127, 0, 0, 2], [127, 0, 0, 1]),
224        };
225        let ip_start = packet.len();
226        packet.extend_from_slice(&[
227            0x45,
228            0,
229            (ip_len >> 8) as u8,
230            ip_len as u8,
231            (self.packet_id >> 8) as u8,
232            self.packet_id as u8,
233            0,
234            0,
235            64,
236            17,
237            0,
238            0,
239            src_ip[0],
240            src_ip[1],
241            src_ip[2],
242            src_ip[3],
243            dst_ip[0],
244            dst_ip[1],
245            dst_ip[2],
246            dst_ip[3],
247        ]);
248        let checksum = ipv4_checksum(&packet[ip_start..ip_start + 20]);
249        packet[ip_start + 10..ip_start + 12].copy_from_slice(&checksum.to_be_bytes());
250        packet.extend_from_slice(&src_port.to_be_bytes());
251        packet.extend_from_slice(&dst_port.to_be_bytes());
252        packet.extend_from_slice(&udp_len.to_be_bytes());
253        packet.extend_from_slice(&0u16.to_be_bytes());
254        packet.extend_from_slice(payload);
255        self.packet_id = self.packet_id.wrapping_add(1);
256
257        self.write_record(&packet)
258    }
259
260    fn write_record(&mut self, packet: &[u8]) -> std::io::Result<()> {
261        let timestamp = SystemTime::now()
262            .duration_since(UNIX_EPOCH)
263            .unwrap_or_default();
264        let seconds = u32::try_from(timestamp.as_secs()).unwrap_or(u32::MAX);
265        let captured_len = u32::try_from(packet.len())
266            .map_err(|_| std::io::Error::other("capture record exceeds pcap size"))?;
267        self.output.write_all(&seconds.to_le_bytes())?;
268        self.output
269            .write_all(&timestamp.subsec_micros().to_le_bytes())?;
270        self.output.write_all(&captured_len.to_le_bytes())?;
271        self.output.write_all(&captured_len.to_le_bytes())?;
272        self.output.write_all(packet)?;
273        // Keep the file usable by Wireshark during a long-running capture.
274        self.output.flush()
275    }
276}
277
278/// Map a bandwidth to LoRaTap's enumerated byte.
279///
280/// The field is an enumeration of the three classic LoRa bandwidths
281/// rather than a scale factor, so it cannot express the 62.5 kHz UMSH
282/// normally runs at. Anything it cannot name is reported as zero, which
283/// Wireshark renders as "Unknown" — a narrower bandwidth silently
284/// labelled as one of the three would misdescribe the radio.
285fn loratap_bandwidth(bw_hz: u32) -> u8 {
286    match bw_hz {
287        125_000 => 1,
288        250_000 => 2,
289        500_000 => 4,
290        _ => 0,
291    }
292}
293
294/// Bias a dBm reading into LoRaTap's unsigned byte, saturating rather
295/// than wrapping so an implausible reading stays at the rail.
296fn loratap_rssi(dbm: i32) -> u8 {
297    (dbm + LORATAP_RSSI_BIAS).clamp(0, u8::MAX as i32) as u8
298}
299
300/// Convert centibels to LoRaTap's signed quarter-dB byte, rounding to
301/// nearest.
302fn loratap_snr(centibels: i16) -> u8 {
303    let scaled = i32::from(centibels) * 4;
304    let quarters = if scaled >= 0 {
305        (scaled + 5) / 10
306    } else {
307        (scaled - 5) / 10
308    };
309    quarters.clamp(i8::MIN as i32, i8::MAX as i32) as i8 as u8
310}
311
312fn ipv4_checksum(header: &[u8]) -> u16 {
313    let mut sum = 0u32;
314    for word in header.chunks_exact(2) {
315        sum += u32::from(u16::from_be_bytes([word[0], word[1]]));
316    }
317    while sum > 0xffff {
318        sum = (sum & 0xffff) + (sum >> 16);
319    }
320    !(sum as u16)
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326    use std::path::PathBuf;
327
328    fn temp_capture_path(label: &str) -> PathBuf {
329        let nonce = SystemTime::now()
330            .duration_since(UNIX_EPOCH)
331            .unwrap()
332            .as_nanos();
333        std::env::temp_dir().join(format!(
334            "umshctl-capture-{label}-{}-{nonce}.pcap",
335            std::process::id(),
336        ))
337    }
338
339    #[test]
340    fn raw_pcap_preserves_lora_bytes_and_requested_linktype() {
341        let path = temp_capture_path("raw");
342        let mut writer = PcapWriter::create(
343            &path,
344            CaptureLayers::Radio,
345            PcapEncapsulation::RawLoRa { linktype: 147 },
346        )
347        .unwrap();
348        writer.write_radio(&[0xc0, 0xa1, 0xb2, 0x03]).unwrap();
349        drop(writer);
350
351        let bytes = std::fs::read(&path).unwrap();
352        let _ = std::fs::remove_file(path);
353        assert_eq!(u32::from_le_bytes(bytes[20..24].try_into().unwrap()), 147);
354        assert_eq!(u32::from_le_bytes(bytes[32..36].try_into().unwrap()), 4);
355        assert_eq!(&bytes[40..], &[0xc0, 0xa1, 0xb2, 0x03]);
356    }
357
358    #[test]
359    fn ethernet_pcap_preserves_ulcp_direction_and_payload() {
360        let path = temp_capture_path("ulcp");
361        let mut writer =
362            PcapWriter::create(&path, CaptureLayers::Ulcp, PcapEncapsulation::Ethernet).unwrap();
363        writer
364            .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
365            .unwrap();
366        drop(writer);
367
368        let bytes = std::fs::read(&path).unwrap();
369        let _ = std::fs::remove_file(path);
370        let packet = &bytes[40..];
371        assert_eq!(&packet[12..14], &0x0800u16.to_be_bytes());
372        assert_eq!(packet[23], 17);
373        assert_eq!(
374            u16::from_be_bytes(packet[34..36].try_into().unwrap()),
375            ULCP_HOST_UDP_PORT,
376        );
377        assert_eq!(
378            u16::from_be_bytes(packet[36..38].try_into().unwrap()),
379            ULCP_DEVICE_UDP_PORT,
380        );
381        assert_eq!(&packet[42..], &[0x81, 0x02, 0x26]);
382    }
383
384    /// A sink the test can read back after the writer has been dropped.
385    #[derive(Clone, Default)]
386    struct SharedSink(std::rc::Rc<std::cell::RefCell<Vec<u8>>>);
387
388    impl Write for SharedSink {
389        fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
390            self.0.borrow_mut().extend_from_slice(buf);
391            Ok(buf.len())
392        }
393        fn flush(&mut self) -> std::io::Result<()> {
394            Ok(())
395        }
396    }
397
398    fn sample_rf() -> RfParams {
399        RfParams {
400            freq_hz: 910_525_000,
401            bw_hz: 125_000,
402            sf: 7,
403            sync_word: 0x2b,
404        }
405    }
406
407    fn sample_info(len: usize, rssi: i16, snr_centibels: i16) -> RxInfo {
408        RxInfo {
409            len,
410            rssi,
411            snr: umsh::hal::Snr::from_centibels(snr_centibels),
412            lqi: None,
413        }
414    }
415
416    /// The scalings here are the ones Wireshark's LoRaTap dissector
417    /// actually applies: RSSI is biased by 139 and SNR is in quarter-dB
418    /// steps, both verified against `tshark -V` output.
419    #[test]
420    fn loratap_records_channel_and_reception_metadata() {
421        let sink = SharedSink::default();
422        let bytes = sink.0.clone();
423        let mut writer = PcapWriter::to_writer(
424            Box::new(sink),
425            CaptureLayers::Radio,
426            PcapEncapsulation::LoRaTap,
427        )
428        .unwrap();
429        let frame = [0xc0, 0xa1, 0xb2, 0x03, 0x11, 0x22];
430        writer
431            .write_radio_with_info(&sample_rf(), &sample_info(frame.len(), -52, 95), &frame)
432            .unwrap();
433        drop(writer);
434
435        let bytes = bytes.borrow();
436        assert_eq!(
437            u32::from_le_bytes(bytes[20..24].try_into().unwrap()),
438            PCAP_LINKTYPE_LORATAP,
439        );
440        let packet = &bytes[40..];
441        assert_eq!(packet.len(), 15 + frame.len());
442        assert_eq!(packet[0], 0, "version");
443        assert_eq!(u16::from_be_bytes(packet[2..4].try_into().unwrap()), 15);
444        assert_eq!(
445            u32::from_be_bytes(packet[4..8].try_into().unwrap()),
446            910_525_000,
447        );
448        assert_eq!(packet[8], 1, "125 kHz in 125 kHz steps");
449        assert_eq!(packet[9], 7, "spreading factor");
450        assert_eq!(packet[10], 87, "-52 dBm biased by 139");
451        assert_eq!(packet[13], 38, "9.5 dB in quarter-dB steps");
452        assert_eq!(packet[14], 0x2b, "sync word");
453        assert_eq!(&packet[15..], &frame);
454    }
455
456    /// Wireshark reads this field as an enumeration, so a bandwidth it
457    /// has no name for must not borrow the nearest one.
458    #[test]
459    fn loratap_bandwidth_is_an_enumeration_not_a_scale() {
460        assert_eq!(loratap_bandwidth(125_000), 1);
461        assert_eq!(loratap_bandwidth(250_000), 2);
462        assert_eq!(loratap_bandwidth(500_000), 4);
463        assert_eq!(loratap_bandwidth(62_500), 0, "UMSH's default is unnameable");
464        assert_eq!(loratap_bandwidth(200_000), 0, "not rounded down to 125 kHz");
465    }
466
467    #[test]
468    fn loratap_encodes_negative_snr_as_a_signed_byte() {
469        assert_eq!(loratap_snr(-75) as i8, -30, "-7.5 dB");
470        assert_eq!(loratap_snr(0), 0);
471        assert_eq!(loratap_rssi(-52), 87);
472        assert_eq!(loratap_rssi(-200), 0, "saturates rather than wrapping");
473    }
474
475    /// The metadata-free entry point cannot fabricate a LoRaTap header,
476    /// so it must refuse rather than emit a plausible-looking -139 dBm.
477    #[test]
478    fn loratap_rejects_frames_without_metadata() {
479        let mut writer = PcapWriter::to_writer(
480            Box::new(SharedSink::default()),
481            CaptureLayers::Radio,
482            PcapEncapsulation::LoRaTap,
483        )
484        .unwrap();
485        assert!(writer.write_radio(&[0xc0, 0xa1]).is_err());
486    }
487
488    #[test]
489    fn a_radio_only_file_ignores_ulcp_frames() {
490        let path = temp_capture_path("radio-only");
491        let mut writer =
492            PcapWriter::create(&path, CaptureLayers::Radio, PcapEncapsulation::Ethernet).unwrap();
493        writer
494            .write_ulcp(PcapDirection::HostToDevice, &[0x81, 0x02, 0x26])
495            .unwrap();
496        drop(writer);
497
498        let bytes = std::fs::read(&path).unwrap();
499        let _ = std::fs::remove_file(path);
500        assert_eq!(bytes.len(), 24, "header only, no records");
501    }
502}