1pub 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
29const RELOAD_SCAN: Duration = Duration::from_secs(3);
34
35pub 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#[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 #[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 #[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
110fn 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 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
176fn 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
185async fn reload(args: &ExtcapArgs) -> Result<()> {
187 expect_interface(args)?;
188 if args.extcap_reload_option.as_deref() != Some(protocol::CALL_RADIO) {
189 return Ok(());
192 }
193 let found = connection::scan(RELOAD_SCAN).await.unwrap_or_default();
196 print!("{}", protocol::radio_values(&found));
197 Ok(())
198}
199
200async 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 let _silence = Silenced::install()?;
218 output::set_color(false);
219
220 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 #[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
261fn 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
288async 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 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#[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 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#[cfg(unix)]
369fn duplicate(fd: std::os::fd::RawFd) -> Result<std::os::fd::OwnedFd> {
370 use std::os::fd::FromRawFd as _;
371
372 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 Ok(unsafe { std::os::fd::OwnedFd::from_raw_fd(copy) })
380}
381
382#[cfg(unix)]
384fn point_at(source: std::os::fd::RawFd, target: std::os::fd::RawFd) -> std::io::Result<()> {
385 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 #[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 #[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 #[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}