Skip to main content

net_cli/
capture.rs

1// Copyright 2026 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5//! Packet capture management library.
6
7use std::io::Write as _;
8use std::path::PathBuf;
9use std::str::FromStr as _;
10
11use futures::StreamExt as _;
12use futures::stream::FuturesOrdered;
13
14use flex_client::ProxyHasDomain;
15use flex_fuchsia_ebpf as febpf;
16use flex_fuchsia_io as fio;
17use flex_fuchsia_net as fnet;
18use flex_fuchsia_net_debug as fnet_debug;
19use flex_fuchsia_net_interfaces as fnet_interfaces;
20use zx_status;
21
22#[cfg(not(feature = "fdomain"))]
23use fidl_fuchsia_net_interfaces_ext as fnet_interfaces_ext;
24#[cfg(feature = "fdomain")]
25use fidl_fuchsia_net_interfaces_ext_fdomain as fnet_interfaces_ext;
26
27use anyhow::Context as _;
28use writer::ToolIO as _;
29
30use crate::CaptureDeps;
31use crate::opts::capture::{StartRollingCommand, StopRollingCommand};
32
33/// The default name used for packet captures if none is specified.
34pub const DEFAULT_CAPTURE_NAME: &str = "capture";
35const DEFAULT_OUTPUT_SUBDIR: &str = "pcap";
36
37/// Specifier for matching an interface by name, ID, or IP.
38#[derive(Debug, PartialEq, Eq)]
39enum CaptureInterfaceSpec {
40    /// Match by interface ID.
41    Id(std::num::NonZeroU64),
42    /// Match by interface name.
43    Name(String),
44    /// Match by IP address.
45    Ip(std::net::IpAddr),
46}
47
48impl CaptureInterfaceSpec {
49    /// Parses an interface specifier string (e.g. "name:lo", "id:1", "ip:127.0.0.1").
50    fn parse(s: &str) -> Result<Self, anyhow::Error> {
51        if let Some(id_str) = s.strip_prefix("id:") {
52            let id = id_str
53                .parse::<std::num::NonZeroU64>()
54                .context("failed to parse interface ID as non-zero integer")?;
55            return Ok(Self::Id(id));
56        }
57        if let Some(name_str) = s.strip_prefix("name:") {
58            return Ok(Self::Name(name_str.to_string()));
59        }
60        if let Some(ip_str) = s.strip_prefix("ip:") {
61            let ip = std::net::IpAddr::from_str(ip_str).context("failed to parse IP address")?;
62            return Ok(Self::Ip(ip));
63        }
64        anyhow::bail!(
65            "Invalid interface format. Interface must start with 'id:', 'name:', or 'ip:' prefix \
66                (e.g. 'name:lo')"
67        )
68    }
69}
70
71/// Starts a rolling packet capture on the target.
72pub async fn start_rolling<C>(connector: &C, cmd: StartRollingCommand) -> Result<(), anyhow::Error>
73where
74    C: crate::NetCliDepsConnector,
75{
76    let StartRollingCommand { name, interface, pcap_filter, snap_len, capture_size } = cmd;
77    let name = name.unwrap_or_else(|| DEFAULT_CAPTURE_NAME.to_string());
78
79    let spec = CaptureInterfaceSpec::parse(&interface).map_err(|e| {
80        crate::user_facing_error(format!("Failed to parse interface specifier: {e:?}"))
81    })?;
82
83    let get_interfaces = async {
84        let state_proxy = crate::connect_with_context::<fnet_interfaces::StateMarker, _>(connector)
85            .await
86            .context("Failed to connect to interfaces state")?;
87        let stream =
88            fnet_interfaces_ext::event_stream_from_state::<fnet_interfaces_ext::AllInterest>(
89                &state_proxy,
90                fnet_interfaces_ext::WatchOptions {
91                    included_addresses: fnet_interfaces_ext::IncludedAddresses::All,
92                    ..Default::default()
93                },
94            )
95            .context("Failed to get watcher stream")?;
96        fnet_interfaces_ext::existing(
97            stream,
98            std::collections::HashMap::<
99                std::num::NonZeroU64,
100                fnet_interfaces_ext::PropertiesAndState<(), fnet_interfaces_ext::AllInterest>,
101            >::new(),
102        )
103        .await
104        .context("Failed to list interfaces")
105    };
106    let interface_id = match spec {
107        CaptureInterfaceSpec::Id(id) => id.get(),
108        CaptureInterfaceSpec::Name(name) => {
109            let id = get_interfaces
110                .await?
111                .values()
112                .find_map(|p| (&p.properties.name == &name).then_some(p.properties.id))
113                .ok_or_else(|| {
114                    crate::user_facing_error(format!("No interface found with name '{name}'"))
115                })?;
116            id.get()
117        }
118        CaptureInterfaceSpec::Ip(ip) => {
119            let id = get_interfaces
120                .await?
121                .values()
122                .find_map(|p| {
123                    let has_ip = p.properties.addresses.iter().any(|a| match a.addr.addr {
124                        fnet::IpAddress::Ipv4(v4) => std::net::IpAddr::V4(v4.addr.into()) == ip,
125                        fnet::IpAddress::Ipv6(v6) => std::net::IpAddr::V6(v6.addr.into()) == ip,
126                    });
127                    has_ip.then_some(p.properties.id)
128                })
129                .ok_or_else(|| {
130                    crate::user_facing_error(format!(
131                        "No interface found with IP address '{interface}'"
132                    ))
133                })?;
134            id.get()
135        }
136    };
137
138    // TODO(https://fxbug.dev/535265151): Support capturing on all interfaces
139    // via "any".
140    // TODO(https://fxbug.dev/535265209): Support capturing on multiple interfaces.
141    let interfaces = fnet_debug::InterfaceSpecifier::InterfaceIds(vec![interface_id]);
142
143    let bpf_program = pcap_filter
144        .map(|filter_str| {
145            let fidl_fuchsia_ebpf::VerifiedProgram {
146                code,
147                struct_access_instructions,
148                maps,
149                __source_breaking,
150            } = pcap::compile::compile_filter(&filter_str).map_err(|e| {
151                crate::user_facing_error(format!(
152                    "Failed to compile pcap filter '{filter_str}': {e:?}"
153                ))
154            })?;
155            assert!(maps.unwrap().is_empty());
156            assert!(struct_access_instructions.unwrap().is_empty());
157            Ok::<_, anyhow::Error>(febpf::VerifiedProgram {
158                code,
159                struct_access_instructions: Some(Vec::new()),
160                maps: Some(Vec::new()),
161                ..Default::default()
162            })
163        })
164        .transpose()?;
165
166    let common_params = fnet_debug::CommonPacketCaptureParams {
167        interfaces: Some(interfaces),
168        bpf_program,
169        snap_len,
170        ..Default::default()
171    };
172
173    let provider =
174        crate::connect_with_context::<fnet_debug::PacketCaptureProviderMarker, _>(connector)
175            .await
176            .context("Failed to connect to packet capture provider")?;
177    let rolling_params =
178        fnet_debug::RollingPacketCaptureParams { capture_size, ..Default::default() };
179    let rolling_client = provider
180        .start_rolling(common_params, &rolling_params)
181        .await
182        .context("FIDL error calling StartRolling")?
183        .map_err(|e| crate::user_facing_error(format!("StartRolling error: {e:?}")))?
184        .into_proxy();
185
186    rolling_client
187        .detach(&name)
188        .await
189        .context("FIDL error calling Detach")?
190        .map_err(|e| crate::user_facing_error(format!("Detach failed with error: {e:?}")))?;
191
192    Ok(())
193}
194
195/// Reconnects, stops, and downloads a rolling packet capture from the target.
196pub async fn stop_rolling<C, D>(
197    connector: &C,
198    deps: &D,
199    cmd: StopRollingCommand,
200) -> Result<Option<std::path::PathBuf>, anyhow::Error>
201where
202    C: crate::NetCliDepsConnector,
203    D: CaptureDeps,
204{
205    let StopRollingCommand { name, output, skip_download } = cmd;
206
207    if skip_download && output.is_some() {
208        return Err(crate::user_facing_error("Cannot specify both --skip-download and --output"));
209    }
210
211    let name = name.unwrap_or_else(|| DEFAULT_CAPTURE_NAME.to_string());
212
213    let provider =
214        crate::connect_with_context::<fnet_debug::PacketCaptureProviderMarker, _>(connector)
215            .await
216            .context("Failed to connect to packet capture provider")?;
217    let rolling_client = provider
218        .reconnect_rolling(&name)
219        .await
220        .context("FIDL error calling ReconnectRolling")?
221        .map_err(|e| {
222            crate::user_facing_error(format!("ReconnectRolling to name '{name}' failed: {e:?}"))
223        })?
224        .into_proxy();
225
226    if skip_download {
227        rolling_client.discard().await.context("Failed to discard rolling pcap")?;
228        return Ok(None);
229    }
230
231    let output_path = match output {
232        Some(path) => PathBuf::from(path),
233        None => {
234            let dir = std::env::temp_dir().join(DEFAULT_OUTPUT_SUBDIR);
235            dir.join(format!("{name}.pcapng"))
236        }
237    };
238
239    let download_res = async {
240        let client = rolling_client.domain();
241        let (file_proxy, file_server) = client.create_proxy::<fio::FileMarker>();
242        rolling_client.stop_and_download(file_server).context("Failed to call StopAndDownload")?;
243
244        let mut file = deps.create_output_writer(&output_path).map_err(|e| {
245            crate::user_facing_error(format!(
246                "Failed to create output file {}: {e:?}",
247                output_path.display()
248            ))
249        })?;
250
251        let (_mutable_attributes, immutable_attributes) = file_proxy
252            .get_attributes(fio::NodeAttributesQuery::CONTENT_SIZE)
253            .await
254            .context("Failed get_attributes wire call")?
255            .map_err(zx_status::Status::err_from_raw)
256            .context("Failed to get attributes of file")?;
257        let content_size = immutable_attributes
258            .content_size
259            .ok_or_else(|| anyhow::anyhow!("Failed to get content size of file"))?;
260
261        let mut queue = FuturesOrdered::new();
262        const CONCURRENT_READS: usize = 16;
263
264        for _ in 0..CONCURRENT_READS {
265            queue.push_back(file_proxy.read(fio::MAX_BUF));
266        }
267
268        let mut bytes_written = 0;
269        loop {
270            let data = queue
271                .next()
272                .await
273                .expect("read queue should never exhaust")
274                .context("FIDL error reading packet capture")?
275                .map_err(zx_status::Status::err_from_raw)
276                .context("Failed to read packet capture")?;
277            if data.is_empty() {
278                file.flush().context("Failed to flush packet capture to file")?;
279                break;
280            }
281            file.write_all(&data).context("Failed to write packet capture to file")?;
282            bytes_written += data.len();
283            queue.push_back(file_proxy.read(fio::MAX_BUF));
284        }
285
286        if u64::try_from(bytes_written).expect("bytes written does not fit into u64")
287            != content_size
288        {
289            return Err(anyhow::anyhow!(
290                "Download mismatch: Expected {} bytes, but instead read {} bytes",
291                content_size,
292                bytes_written
293            ));
294        }
295        Ok(())
296    }
297    .await;
298
299    if let Err(e) = download_res {
300        if let Err(e) = std::fs::remove_file(&output_path) {
301            log::warn!("Failed to delete partial file {output_path:?}: {e:?}");
302        }
303        return Err(e);
304    }
305
306    rolling_client.discard().await.context("Failed to discard rolling pcap")?;
307
308    Ok(Some(output_path))
309}
310
311pub async fn do_capture<C, D>(
312    mut out: writer::JsonWriter<serde_json::Value>,
313    crate::opts::capture::Capture { capture_cmd }: crate::opts::capture::Capture,
314    connector: &C,
315    deps: &D,
316) -> Result<(), anyhow::Error>
317where
318    C: crate::NetCliDepsConnector,
319    D: CaptureDeps,
320{
321    match capture_cmd {
322        crate::opts::capture::CaptureEnum::StartRolling(cmd) => start_rolling(connector, cmd).await,
323        crate::opts::capture::CaptureEnum::StopRolling(cmd) => {
324            let path = stop_rolling(connector, deps, cmd).await?;
325            if let Some(path) = path {
326                if out.is_machine() {
327                    out.machine(&serde_json::json!({ "path": path.display().to_string() }))?;
328                } else {
329                    out.line(format!("{}", path.display()))?;
330                }
331            } else {
332                if out.is_machine() {
333                    out.machine(&serde_json::json!({ "path": null }))?;
334                }
335            }
336            Ok(())
337        }
338    }
339}
340
341#[cfg(test)]
342mod tests {
343    use super::*;
344    use assert_matches::assert_matches;
345
346    #[test]
347    fn test_parse_interface_spec() {
348        assert_eq!(
349            CaptureInterfaceSpec::parse("id:12").unwrap(),
350            CaptureInterfaceSpec::Id(std::num::NonZeroU64::new(12).unwrap())
351        );
352        assert_matches!(CaptureInterfaceSpec::parse("id:0"), Err(_));
353        assert_matches!(CaptureInterfaceSpec::parse("id:abc"), Err(_));
354
355        assert_eq!(
356            CaptureInterfaceSpec::parse("name:loopback").unwrap(),
357            CaptureInterfaceSpec::Name("loopback".to_string())
358        );
359
360        assert_eq!(
361            CaptureInterfaceSpec::parse("ip:127.0.0.1").unwrap(),
362            CaptureInterfaceSpec::Ip(net_declare::std_ip!("127.0.0.1"))
363        );
364        assert_eq!(
365            CaptureInterfaceSpec::parse("ip:fe80::1").unwrap(),
366            CaptureInterfaceSpec::Ip(net_declare::std_ip!("fe80::1"))
367        );
368
369        assert_matches!(CaptureInterfaceSpec::parse("ip:invalid_ip"), Err(_));
370
371        assert_matches!(CaptureInterfaceSpec::parse("loopback"), Err(_));
372    }
373}