umshctl/extcap/
mod.rs

1//! Wireshark extcap interface.
2//!
3//! Wireshark discovers capture interfaces by running every executable in
4//! its `extcap` directory and asking each one what it offers. That makes
5//! `umshctl` appear in Wireshark's interface list, so a live capture is a
6//! double-click rather than a file written in one window and opened in
7//! another.
8//!
9//! Wireshark identifies itself by its arguments, not by the name it
10//! invokes us under, so nothing here depends on how the executable was
11//! installed. The vocabulary is kept out of the tool's own argument
12//! parser: these flags belong to Wireshark, and the two would otherwise
13//! have to agree forever.
14
15pub mod protocol;
16
17use std::io::Write as _;
18use std::path::PathBuf;
19use std::time::Duration;
20
21use anyhow::{Context as _, Result, bail};
22use clap::Parser;
23
24use crate::command::capture::CaptureArgs;
25use crate::command::capture::pcap::{CaptureLayers, PcapEncapsulation, PcapWriter};
26use crate::connection::{self, Prefs, Target};
27use crate::{App, output};
28
29/// How long a Reload in the configuration dialog spends scanning.
30///
31/// Longer than a plain `discover`, because the user pressed a button and
32/// is watching a dropdown rather than waiting on a capture.
33const RELOAD_SCAN: Duration = Duration::from_secs(3);
34
35/// Whether Wireshark, rather than a person, is running this process.
36///
37/// Every phase carries at least one `--extcap-` flag — `--capture` always
38/// arrives with `--extcap-interface` — so the arguments alone settle it.
39/// Deliberately not a check on argv[0]: a copy and a symlink of the same
40/// binary must behave identically.
41pub fn is_extcap_invocation() -> bool {
42    std::env::args_os()
43        .skip(1)
44        .any(|arg| arg.as_encoded_bytes().starts_with(b"--extcap-"))
45}
46
47/// The arguments Wireshark passes.
48///
49/// Unknown-but-harmless flags are accepted and ignored rather than
50/// rejected: Wireshark passes `--extcap-version` and the logging flags
51/// unconditionally, and a hard error on any one of them would make the
52/// interface vanish from the list entirely.
53#[derive(Debug, Parser)]
54#[command(
55    name = "umshctl",
56    about = "Wireshark extcap interface",
57    disable_help_flag = true
58)]
59pub struct ExtcapArgs {
60    #[arg(long)]
61    extcap_interfaces: bool,
62    #[arg(long)]
63    extcap_dlts: bool,
64    #[arg(long)]
65    extcap_config: bool,
66    #[arg(long, value_name = "NAME")]
67    extcap_interface: Option<String>,
68    #[arg(long, value_name = "CALL")]
69    extcap_reload_option: Option<String>,
70    #[arg(long)]
71    capture: bool,
72    #[arg(long, value_name = "PATH")]
73    fifo: Option<PathBuf>,
74
75    // Accepted so Wireshark's unconditional flags cannot break a phase.
76    #[arg(long, value_name = "VERSION")]
77    extcap_version: Option<String>,
78    #[arg(long, value_name = "FILTER")]
79    extcap_capture_filter: Option<String>,
80    #[arg(long, value_name = "LEVEL")]
81    log_level: Option<String>,
82    #[arg(long, value_name = "PATH")]
83    log_file: Option<PathBuf>,
84
85    // The capture options, mirroring `protocol::config`.
86    #[arg(long, value_name = "ID")]
87    radio: Option<String>,
88    #[arg(long, value_name = "PORT")]
89    serial_port: Option<String>,
90    #[arg(long, default_value_t = 115_200, value_name = "N")]
91    baud: u32,
92    #[arg(long)]
93    umsh_only: bool,
94    #[arg(long, default_value_t = 10, value_name = "SECS")]
95    idle_probe_secs: u64,
96    #[arg(long)]
97    no_reconnect: bool,
98    #[arg(long, value_name = "KHZ")]
99    freq_khz: Option<String>,
100    #[arg(long, value_name = "HZ")]
101    bw_hz: Option<String>,
102    #[arg(long, value_name = "N")]
103    sf: Option<String>,
104    #[arg(long, value_name = "N")]
105    cr: Option<String>,
106    #[arg(long, value_name = "N")]
107    sync_word: Option<String>,
108}
109
110/// Parse an RF override that the dialog leaves empty when unset.
111///
112/// Wireshark sends the flag with an empty value for a string argument
113/// the user did not fill in, which has to mean "leave the radio alone"
114/// rather than "set it to zero".
115fn rf_override<T>(
116    value: Option<&String>,
117    what: &str,
118    range: std::ops::RangeInclusive<u64>,
119) -> Result<Option<T>>
120where
121    T: TryFrom<u64>,
122{
123    let Some(text) = value
124        .map(|text| text.trim())
125        .filter(|text| !text.is_empty())
126    else {
127        return Ok(None);
128    };
129    let parsed = match text.strip_prefix("0x").or_else(|| text.strip_prefix("0X")) {
130        Some(hex) => u64::from_str_radix(hex, 16),
131        None => text.parse::<u64>(),
132    }
133    .with_context(|| format!("{what}: {text:?} is not a number"))?;
134    if !range.contains(&parsed) {
135        bail!(
136            "{what}: {parsed} is outside {}..={}",
137            range.start(),
138            range.end(),
139        );
140    }
141    T::try_from(parsed)
142        .map(Some)
143        .map_err(|_| anyhow::anyhow!("{what}: {parsed} does not fit"))
144}
145
146pub async fn run() -> Result<()> {
147    let args = ExtcapArgs::parse();
148
149    // Order matters: a reload is a narrower case of `--extcap-config`.
150    if args.extcap_reload_option.is_some() {
151        return reload(&args).await;
152    }
153    if args.extcap_config {
154        expect_interface(&args)?;
155        print!("{}", protocol::config());
156        return Ok(());
157    }
158    if args.extcap_dlts {
159        expect_interface(&args)?;
160        print!("{}", protocol::dlts());
161        return Ok(());
162    }
163    if args.extcap_interfaces {
164        print!(
165            "{}",
166            protocol::interfaces(Prefs::load().default_device.as_ref())
167        );
168        return Ok(());
169    }
170    if args.capture {
171        return capture(args).await;
172    }
173    bail!("no extcap phase requested")
174}
175
176/// Reject an interface we did not advertise.
177fn expect_interface(args: &ExtcapArgs) -> Result<()> {
178    match args.extcap_interface.as_deref() {
179        Some(protocol::INTERFACE) => Ok(()),
180        Some(other) => bail!("unknown interface {other:?}"),
181        None => bail!("--extcap-interface is required"),
182    }
183}
184
185/// Re-populate the radio dropdown from a fresh scan.
186async fn reload(args: &ExtcapArgs) -> Result<()> {
187    expect_interface(args)?;
188    if args.extcap_reload_option.as_deref() != Some(protocol::CALL_RADIO) {
189        // Wireshark only reloads options we marked reloadable, but an
190        // empty list is a better answer than a failed dialog.
191        return Ok(());
192    }
193    // A build without BLE, or a machine without an adapter, still has to
194    // render a usable dialog.
195    let found = connection::scan(RELOAD_SCAN).await.unwrap_or_default();
196    print!("{}", protocol::radio_values(&found));
197    Ok(())
198}
199
200/// Stream a live capture into the FIFO Wireshark is reading.
201async fn capture(args: ExtcapArgs) -> Result<()> {
202    expect_interface(&args)?;
203    let fifo = args
204        .fifo
205        .as_deref()
206        .context("--capture requires --fifo=PATH")?;
207
208    // Everything downstream narrates the capture on stdout, which here is
209    // a pipe Wireshark does not promise to drain — a full pipe would park
210    // the capture forever. Stderr is no place for it either: Wireshark
211    // reads this process's stderr as a fault report and raises whatever
212    // accumulated there when the capture ends, so a progress line becomes
213    // an error dialog. Silence both.
214    //
215    // Declared before the radio so it is dropped after it: attaching,
216    // recovering, and detaching all narrate too.
217    let _silence = Silenced::install()?;
218    output::set_color(false);
219
220    // Before the radio: opening the FIFO and declaring the link type is
221    // what lets Wireshark settle into a live capture, and BLE discovery
222    // and attach can take seconds.
223    let sink = std::fs::OpenOptions::new()
224        .write(true)
225        .open(fifo)
226        .with_context(|| format!("opening capture FIFO {}", fifo.display()))?;
227    let writer = PcapWriter::to_writer(
228        Box::new(sink),
229        CaptureLayers::Radio,
230        PcapEncapsulation::LoRaTap,
231    )?;
232
233    let capture_args = capture_args(&args)?;
234    capture_args.validate()?;
235
236    let prefs = Prefs::load();
237    let target = resolve_target(&args, &prefs).await?;
238    let session = connection::connect(target, false, false).await?;
239    let mut app = App::for_extcap(session, prefs, args.baud);
240
241    // Wireshark stops a capture by closing the FIFO and signalling. The
242    // signal is the one that also arrives when the air is quiet, where
243    // there is no write to discover the closed FIFO through.
244    #[cfg(unix)]
245    {
246        use tokio::signal::unix::{SignalKind, signal};
247        let mut terminate = signal(SignalKind::terminate())?;
248        let mut interrupt = signal(SignalKind::interrupt())?;
249        tokio::select! {
250            result = crate::command::capture::run_with_writer(&mut app, &capture_args, Some(writer)) => result,
251            _ = terminate.recv() => Ok(()),
252            _ = interrupt.recv() => Ok(()),
253        }
254    }
255    #[cfg(not(unix))]
256    {
257        crate::command::capture::run_with_writer(&mut app, &capture_args, Some(writer)).await
258    }
259}
260
261/// Translate the dialog's answers into the capture command's own
262/// arguments, so both entry points run exactly the same capture.
263fn capture_args(args: &ExtcapArgs) -> Result<CaptureArgs> {
264    Ok(CaptureArgs {
265        pcap: None,
266        layers: CaptureLayers::Radio,
267        pcap_raw: false,
268        pcap_linktype: None,
269        umsh_only: args.umsh_only,
270        quiet: true,
271        idle_probe_secs: args.idle_probe_secs.max(1),
272        no_reconnect: args.no_reconnect,
273        reconnect_delay_secs: 2,
274        freq_khz: rf_override(
275            args.freq_khz.as_ref(),
276            "--freq-khz",
277            1..=u64::from(u32::MAX),
278        )?,
279        bw_hz: rf_override(args.bw_hz.as_ref(), "--bw-hz", 1..=u64::from(u32::MAX))?,
280        sf: rf_override(args.sf.as_ref(), "--sf", 5..=12)?,
281        cr: rf_override(args.cr.as_ref(), "--cr", 5..=8)?,
282        sync_word: rf_override::<u16>(args.sync_word.as_ref(), "--sync-word", 0..=0xffff)?
283            .map(crate::command::values::HexU16Arg),
284        tx_power: None,
285    })
286}
287
288/// Pick the radio to capture from.
289///
290/// A serial port is only ever used when named, the same rule the rest of
291/// the tool follows: identifying one means opening it, and opening a port
292/// can reset hardware that is not a radio at all.
293async fn resolve_target(args: &ExtcapArgs, prefs: &Prefs) -> Result<Target> {
294    if let Some(port) = &args.serial_port {
295        return Ok(Target::Serial {
296            port: port.clone(),
297            baud: args.baud,
298        });
299    }
300    if let Some(selector) = args
301        .radio
302        .as_deref()
303        .map(str::trim)
304        .filter(|selector| !selector.is_empty())
305    {
306        return Ok(Target::Ble {
307            selector: selector.to_string(),
308            name: None,
309        });
310    }
311    // No person is watching, so an ambiguous scan has to fail rather
312    // than ask which radio was meant. Naming the dialog is the actionable
313    // part: that is where the choice can be made once and remembered.
314    connection::discover(prefs, false, connection::Discovery::Auto)
315        .await?
316        .context(
317            "no radio selected: pick one in the interface's capture options, or name a serial port",
318        )
319}
320
321/// Both standard streams pointed at `/dev/null`, with the originals
322/// restored when the value is dropped.
323///
324/// A blunt instrument on purpose: it catches every print in the capture
325/// path, including ones added later, which auditing call sites would not.
326/// Restoring on the way out is what keeps the one message Wireshark's
327/// error dialog is good for — the fatal error `main` reports — from being
328/// silenced along with the narration.
329#[cfg(unix)]
330struct Silenced {
331    stdout: std::os::fd::OwnedFd,
332    stderr: std::os::fd::OwnedFd,
333}
334
335#[cfg(unix)]
336impl Silenced {
337    fn install() -> Result<Self> {
338        use std::os::fd::AsRawFd as _;
339
340        let null = std::fs::OpenOptions::new()
341            .write(true)
342            .open("/dev/null")
343            .context("opening /dev/null")?;
344        let stdout = duplicate(libc::STDOUT_FILENO)?;
345        let stderr = duplicate(libc::STDERR_FILENO)?;
346
347        std::io::stdout().flush().ok();
348        point_at(null.as_raw_fd(), libc::STDOUT_FILENO).context("silencing stdout")?;
349        point_at(null.as_raw_fd(), libc::STDERR_FILENO).context("silencing stderr")?;
350        Ok(Self { stdout, stderr })
351    }
352}
353
354#[cfg(unix)]
355impl Drop for Silenced {
356    fn drop(&mut self) {
357        use std::os::fd::AsRawFd as _;
358
359        // Anything still buffered was written while silenced and belongs
360        // to /dev/null, not to the stream being restored.
361        std::io::stdout().flush().ok();
362        let _ = point_at(self.stdout.as_raw_fd(), libc::STDOUT_FILENO);
363        let _ = point_at(self.stderr.as_raw_fd(), libc::STDERR_FILENO);
364    }
365}
366
367/// `dup`, as a descriptor that closes itself.
368#[cfg(unix)]
369fn duplicate(fd: std::os::fd::RawFd) -> Result<std::os::fd::OwnedFd> {
370    use std::os::fd::FromRawFd as _;
371
372    // SAFETY: `fd` is a standard stream, open for the life of the process.
373    let copy = unsafe { libc::dup(fd) };
374    if copy < 0 {
375        return Err(std::io::Error::last_os_error())
376            .with_context(|| format!("duplicating descriptor {fd}"));
377    }
378    // SAFETY: dup returned a fresh descriptor that nothing else owns.
379    Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(copy) })
380}
381
382/// Make `target` refer to whatever `source` refers to.
383#[cfg(unix)]
384fn point_at(source: std::os::fd::RawFd, target: std::os::fd::RawFd) -> std::io::Result<()> {
385    // SAFETY: both descriptors are valid, and dup2 is defined for any two
386    // valid descriptors.
387    if unsafe { libc::dup2(source, target) } < 0 {
388        return Err(std::io::Error::last_os_error());
389    }
390    Ok(())
391}
392
393#[cfg(not(unix))]
394struct Silenced;
395
396#[cfg(not(unix))]
397impl Silenced {
398    fn install() -> Result<Self> {
399        Ok(Self)
400    }
401}
402
403#[cfg(test)]
404mod tests {
405    use super::*;
406
407    fn parse(args: &[&str]) -> ExtcapArgs {
408        let mut argv = vec!["umshctl"];
409        argv.extend_from_slice(args);
410        ExtcapArgs::try_parse_from(argv).expect("should parse")
411    }
412
413    /// Wireshark passes these unconditionally; rejecting one would make
414    /// the interface disappear from the list.
415    #[test]
416    fn wiresharks_ambient_flags_are_tolerated() {
417        let args = parse(&[
418            "--extcap-interfaces",
419            "--extcap-version=4.6",
420            "--log-level=debug",
421        ]);
422        assert!(args.extcap_interfaces);
423    }
424
425    #[test]
426    fn every_phase_parses() {
427        assert!(parse(&["--extcap-interfaces"]).extcap_interfaces);
428        assert!(parse(&["--extcap-dlts", "--extcap-interface=umsh"]).extcap_dlts);
429        assert!(parse(&["--extcap-config", "--extcap-interface=umsh"]).extcap_config);
430        let reload = parse(&[
431            "--extcap-config",
432            "--extcap-interface=umsh",
433            "--extcap-reload-option=--radio",
434        ]);
435        assert_eq!(reload.extcap_reload_option.as_deref(), Some("--radio"));
436        let capture = parse(&[
437            "--capture",
438            "--extcap-interface=umsh",
439            "--fifo=/tmp/x",
440            "--serial-port=/dev/null",
441        ]);
442        assert!(capture.capture);
443        assert_eq!(
444            capture.fifo.as_deref(),
445            Some(std::path::Path::new("/tmp/x"))
446        );
447    }
448
449    /// `--extcap-interface` and `--extcap-interfaces` differ by one
450    /// character; clap must not infer one from the other.
451    #[test]
452    fn the_singular_and_plural_flags_stay_distinct() {
453        let args = parse(&["--extcap-interface=umsh", "--extcap-dlts"]);
454        assert!(!args.extcap_interfaces);
455        assert_eq!(args.extcap_interface.as_deref(), Some("umsh"));
456    }
457
458    #[test]
459    fn an_empty_rf_override_leaves_the_radio_alone() {
460        let empty = String::new();
461        assert_eq!(
462            rf_override::<u8>(Some(&empty), "--sf", 5..=12).unwrap(),
463            None
464        );
465        assert_eq!(rf_override::<u8>(None, "--sf", 5..=12).unwrap(), None);
466        assert_eq!(
467            rf_override::<u8>(Some(&"7".to_string()), "--sf", 5..=12).unwrap(),
468            Some(7),
469        );
470    }
471
472    #[test]
473    fn an_out_of_range_rf_override_is_rejected() {
474        assert!(rf_override::<u8>(Some(&"13".to_string()), "--sf", 5..=12).is_err());
475        assert!(rf_override::<u8>(Some(&"nope".to_string()), "--sf", 5..=12).is_err());
476    }
477
478    #[test]
479    fn a_hex_sync_word_is_accepted() {
480        assert_eq!(
481            rf_override::<u16>(Some(&"0x2b".to_string()), "--sync-word", 0..=0xffff).unwrap(),
482            Some(0x2b),
483        );
484    }
485
486    /// The capture command validates its own arguments, and extcap
487    /// deliberately builds them with no `--pcap` path.
488    #[test]
489    fn the_translated_capture_arguments_are_valid() {
490        let args = parse(&["--capture", "--extcap-interface=umsh", "--fifo=/tmp/x"]);
491        capture_args(&args).unwrap().validate().unwrap();
492
493        let filtered = parse(&[
494            "--capture",
495            "--extcap-interface=umsh",
496            "--fifo=/tmp/x",
497            "--umsh-only",
498            "--sf=9",
499        ]);
500        let translated = capture_args(&filtered).unwrap();
501        translated.validate().unwrap();
502        assert!(translated.umsh_only);
503        assert_eq!(translated.sf, Some(9));
504    }
505}