From a9e283a87cd639d781cba6f9ab15ae99caca1649 Mon Sep 17 00:00:00 2001 From: Eric Harding Date: Fri, 13 Nov 2026 14:48:51 -0500 Subject: [PATCH] Add --mac filter and rename IpFilter to PacketFilter A packet is kept if its Ethernet source or destination MAC matches any --mac entry; combined with --ip, both must match. Empty dimensions match everything, so the unfiltered case is still the same code path. Co-Authored-By: Claude Sonnet 5.5 --- src/chunker.rs | 18 ++--- src/cli.rs | 13 ++++ src/client.rs | 12 ++-- src/filter.rs | 175 ++++++++++++++++++++++++++++++++++++++++-------- src/pipeline.rs | 20 +++--- src/server.rs | 4 +- 6 files changed, 189 insertions(+), 53 deletions(-) diff --git a/src/chunker.rs b/src/chunker.rs index 8b19efc..a01d7d6 100644 --- a/src/chunker.rs +++ b/src/chunker.rs @@ -1,4 +1,4 @@ -use crate::filter::{IpFilter, LINKTYPE_ETHERNET}; +use crate::filter::{PacketFilter, LINKTYPE_ETHERNET}; use anyhow::{Result, bail}; use std::time::{Duration, Instant}; @@ -166,7 +166,7 @@ pub struct RecordAlignedChunker { /// KEPT bytes -- a filter keeping 1% of the traffic still produces /// full-size frames rather than 1%-size ones. kept: Vec, - filter: IpFilter, + filter: PacketFilter, /// When the oldest byte currently in `kept` was confirmed, i.e. how /// long the pending output has been waiting. `None` when `kept` is /// empty. @@ -190,20 +190,20 @@ pub struct RecordAlignedChunker { impl RecordAlignedChunker { /// Unfiltered: keeps every record. Equivalent to `with_filter` with - /// `IpFilter::match_all()` -- which is not a separate code path, just + /// `PacketFilter::match_all()` -- which is not a separate code path, just /// a filter that says yes, so this and the filtered case exercise the /// same record-confirmation logic. Production goes through /// `with_filter`; this is the convenience the tests use. #[allow(dead_code)] pub fn new(target: usize, flush_after: Duration, partial: bool) -> Self { - Self::with_filter(target, flush_after, partial, IpFilter::match_all()) + Self::with_filter(target, flush_after, partial, PacketFilter::match_all()) } pub fn with_filter( target: usize, flush_after: Duration, partial: bool, - filter: IpFilter, + filter: PacketFilter, ) -> Self { Self { header_buf: Vec::with_capacity(GLOBAL_HEADER_LEN), @@ -247,7 +247,7 @@ impl RecordAlignedChunker { // stdout: in --server mode stdout is the wire. if !self.filter.is_match_all() && self.linktype != LINKTYPE_ETHERNET { eprintln!( - "scpcap: warning: --ip filtering needs Ethernet (link type {LINKTYPE_ETHERNET}), \ + "scpcap: warning: --ip/--mac filtering needs Ethernet (link type {LINKTYPE_ETHERNET}), \ but this capture is link type {}; no packets will match", self.linktype ); @@ -895,8 +895,8 @@ mod tests { record_bytes_with_ts(&pkt, false, 1_700_000_000) } - fn filter_for(specs: &[&str]) -> IpFilter { - IpFilter::parse(&specs.iter().map(|s| s.to_string()).collect::>()).unwrap() + fn filter_for(specs: &[&str]) -> PacketFilter { + PacketFilter::parse(&specs.iter().map(|s| s.to_string()).collect::>(), &[]).unwrap() } fn drain_all(c: &mut RecordAlignedChunker) -> Vec<(ChunkKind, Vec)> { @@ -1026,7 +1026,7 @@ mod tests { stream.extend_from_slice(&ip_record(src, THIRD, 60 + i as usize)); } - let mut all = RecordAlignedChunker::with_filter(1 << 20, DEFAULT_FLUSH, false, IpFilter::match_all()); + let mut all = RecordAlignedChunker::with_filter(1 << 20, DEFAULT_FLUSH, false, PacketFilter::match_all()); let mut some = RecordAlignedChunker::with_filter(1 << 20, DEFAULT_FLUSH, false, filter_for(&["1.1.1.1"])); let mut none = RecordAlignedChunker::with_filter(1 << 20, DEFAULT_FLUSH, false, filter_for(&["203.0.113.7"])); for c in [&mut all, &mut some, &mut none] { diff --git a/src/cli.rs b/src/cli.rs index 3be61ac..91053bd 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -48,6 +48,14 @@ pub struct ClientCli { #[arg(long = "ip", value_name = "ADDR")] pub ip: Vec, + /// Only forward packets whose Ethernet source or destination MAC + /// matches, e.g. to pull one switch connection out of a capture by its + /// upstream MAC ("aa:bb:cc:dd:ee:ff"). May be repeated; a packet is kept + /// if it matches any entry. Combined with --ip, a packet must match + /// both. Requires an Ethernet-linktype pcap source. + #[arg(long = "mac", value_name = "ADDR")] + pub mac: Vec, + /// Append a diagnostic trace of this (local) process's transfer events /// to this file -- positions, rename detection, finalize -- for /// figuring out where a stalled transfer is stuck. @@ -105,6 +113,11 @@ pub struct ServerCli { #[arg(long = "ip", value_name = "ADDR")] pub ip: Vec, + /// MAC filter for --send mode; repeated, one per address. See the + /// client's --mac. + #[arg(long = "mac", value_name = "ADDR")] + pub mac: Vec, + /// Destination final path (--recv mode). #[arg(long)] pub dest_final: Option, diff --git a/src/client.rs b/src/client.rs index 859d369..1c3e9dd 100644 --- a/src/client.rs +++ b/src/client.rs @@ -1,6 +1,6 @@ use crate::channel::{ChannelSink, ChannelSource}; use crate::cli::ClientCli; -use crate::filter::IpFilter; +use crate::filter::PacketFilter; use crate::naming::{self, resolve_dest_paths, strip_name, Target}; use crate::pipeline::{run_recv, run_send, RecvOptions, SendOptions}; use crate::protocol::{MessageSink, MessageSource, WireSink, WireSource}; @@ -43,7 +43,7 @@ pub fn run(cli: ClientCli) -> Result<()> { // remote is (see the Remote->Local branch). It is never passed to a // --recv server: the receiver writes bytes verbatim and never parses // them. - let filter = IpFilter::parse(&cli.ip)?; + let filter = PacketFilter::parse(&cli.ip, &cli.mac)?; // Filtering means reading pcap records, which an already-compressed // source doesn't expose. Without this the transfer would still pick the // record-aligned chunker (a filter forces it) and abort deep inside with @@ -52,8 +52,8 @@ pub fn run(cli: ClientCli) -> Result<()> { // combination rather than the source. if !filter.is_match_all() && source_stripped.was_zst { bail!( - "cannot apply --ip to an already-compressed source ({}): \ - filtering needs to read pcap records. Copy it without --ip, \ + "cannot apply --ip/--mac to an already-compressed source ({}): \ + filtering needs to read pcap records. Copy it without --ip/--mac, \ or filter the uncompressed capture.", cli.source ); @@ -143,6 +143,10 @@ pub fn run(cli: ClientCli) -> Result<()> { server_args.push("--ip".to_string()); server_args.push(spec.clone()); } + for spec in &cli.mac { + server_args.push("--mac".to_string()); + server_args.push(spec.clone()); + } if effective_compress { server_args.push("--compress".to_string()); server_args.push("zstd".to_string()); diff --git a/src/filter.rs b/src/filter.rs index ce5e5d6..4931bb4 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -19,38 +19,54 @@ const MAX_VLAN_TAGS: usize = 2; const IPV4_HEADER_MIN: usize = 20; const IPV6_HEADER_LEN: usize = 40; -/// A set of IP addresses/prefixes to keep. A packet matches if *either* its -/// source or destination address falls in *any* entry. +const MAC_LEN: usize = 6; + +/// An Ethernet MAC address. +type Mac = [u8; MAC_LEN]; + +/// What to keep from a capture: packets whose IP addresses and Ethernet MAC +/// addresses match. /// -/// Sized for the expected 1-4 entries: two plain vectors, split by address -/// family so a v4 packet never compares against a v6 entry, scanned -/// linearly. No sorting (a prefix match isn't an equality test, so ordering -/// buys nothing) and no hashing. +/// Each dimension is a set of entries; a packet satisfies a dimension if +/// *either* its source or destination falls in *any* entry. An empty +/// dimension is satisfied by everything (without looking at the packet), and +/// a packet must satisfy every dimension to be kept -- so `--ip` and `--mac` +/// together mean "this IP *on* this MAC". +/// +/// Sized for the expected 1-4 entries: plain vectors scanned linearly. IP +/// entries are split by address family so a v4 packet never compares against +/// a v6 entry. No sorting (a prefix match isn't an equality test, so +/// ordering buys nothing) and no hashing. #[derive(Debug, Clone)] -pub struct IpFilter { - /// Set when no `--ip` was given: every packet is kept, without looking - /// at it at all. This is the default so that the filtering code path is - /// the *only* code path -- see the chunker. +pub struct PacketFilter { + /// Set when neither `--ip` nor `--mac` was given: every packet is kept, + /// without looking at it at all. This is the default so that the + /// filtering code path is the *only* code path -- see the chunker. match_all: bool, /// (network, mask) pairs, network already masked at parse time. v4: Vec<(u32, u32)>, v6: Vec<(u128, u128)>, + macs: Vec, } -impl IpFilter { +impl PacketFilter { /// The no-`--ip` default: keeps everything, matches without inspecting. pub fn match_all() -> Self { - Self { match_all: true, v4: Vec::new(), v6: Vec::new() } + Self { match_all: true, v4: Vec::new(), v6: Vec::new(), macs: Vec::new() } } - /// Parses `--ip` specs: a bare address (`1.1.1.1`, `2606:4700::1111`) - /// or a CIDR prefix (`10.0.0.0/8`). An empty list yields `match_all`. - pub fn parse(specs: &[String]) -> Result { - if specs.is_empty() { + /// Parses `--ip` specs -- a bare address (`1.1.1.1`, `2606:4700::1111`) + /// or a CIDR prefix (`10.0.0.0/8`) -- and `--mac` specs + /// (`aa:bb:cc:dd:ee:ff`). Two empty lists yield `match_all`. + pub fn parse(ip_specs: &[String], mac_specs: &[String]) -> Result { + if ip_specs.is_empty() && mac_specs.is_empty() { return Ok(Self::match_all()); } - let mut f = Self { match_all: false, v4: Vec::new(), v6: Vec::new() }; - for spec in specs { + let mut f = Self { match_all: false, v4: Vec::new(), v6: Vec::new(), macs: Vec::new() }; + for spec in mac_specs { + f.macs.push(parse_mac(spec)?); + } + for spec in ip_specs { let (addr_str, prefix_str) = match spec.split_once('/') { Some((a, p)) => (a, Some(p)), None => (spec.as_str(), None), @@ -82,7 +98,8 @@ impl IpFilter { Ok(f) } - /// Whether this filter keeps everything, i.e. no `--ip` was given. + /// Whether this filter keeps everything, i.e. neither `--ip` nor `--mac` + /// was given. /// Callers use it to skip work (and warnings) that only a real filter /// needs. pub fn is_match_all(&self) -> bool { @@ -92,9 +109,10 @@ impl IpFilter { /// Whether to keep `packet` -- the captured bytes of one pcap record, /// starting at the link-layer header. /// - /// Everything unmatched is dropped: non-IP frames (ARP, LLDP), packets - /// truncated by snaplen before the addresses, and any link type other - /// than Ethernet. + /// Everything unmatched is dropped: packets truncated by snaplen before + /// the fields being matched, any link type other than Ethernet, and -- + /// when `--ip` is set -- non-IP frames (ARP, LLDP). With only `--mac`, + /// non-IP frames from a matching MAC are kept. pub fn matches(&self, packet: &[u8], linktype: u32) -> bool { if self.match_all { return true; @@ -102,6 +120,27 @@ impl IpFilter { if linktype != LINKTYPE_ETHERNET || packet.len() < ETH_HEADER_LEN { return false; } + self.mac_matches(packet) && self.ip_matches(packet) + } + + /// Whether the frame's source or destination MAC is in the set; an empty + /// set matches every frame. `packet` is at least an Ethernet header long. + fn mac_matches(&self, packet: &[u8]) -> bool { + if self.macs.is_empty() { + return true; + } + let dst: Mac = packet[0..MAC_LEN].try_into().unwrap(); + let src: Mac = packet[MAC_LEN..2 * MAC_LEN].try_into().unwrap(); + self.macs.iter().any(|&m| m == src || m == dst) + } + + /// Whether the frame's source or destination IP is in the set; an empty + /// set matches every frame, IP or not. `packet` is at least an Ethernet + /// header long. + fn ip_matches(&self, packet: &[u8]) -> bool { + if self.v4.is_empty() && self.v6.is_empty() { + return true; + } let mut ethertype = be16(&packet[12..14]); let mut off = ETH_HEADER_LEN; let mut tags = 0; @@ -137,6 +176,25 @@ impl IpFilter { } } +/// Parses `aa:bb:cc:dd:ee:ff` (or `-` separated) into a MAC address. +fn parse_mac(spec: &str) -> Result { + let bad = || anyhow::anyhow!("--mac {spec:?}: not a MAC address (expected aa:bb:cc:dd:ee:ff)"); + let mut mac = [0u8; MAC_LEN]; + let mut parts = spec.split([':', '-']); + for byte in &mut mac { + let part = parts.next().ok_or_else(bad)?; + // from_str_radix alone would accept a leading '+'. + if part.len() != 2 || !part.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(bad()); + } + *byte = u8::from_str_radix(part, 16).map_err(|_| bad())?; + } + if parts.next().is_some() { + return Err(bad()); + } + Ok(mac) +} + fn be16(b: &[u8]) -> u16 { u16::from_be_bytes(b.try_into().unwrap()) } @@ -157,8 +215,12 @@ mod tests { list.iter().map(|s| s.to_string()).collect() } - fn filter(list: &[&str]) -> IpFilter { - IpFilter::parse(&specs(list)).unwrap() + fn filter(list: &[&str]) -> PacketFilter { + PacketFilter::parse(&specs(list), &[]).unwrap() + } + + fn mac_filter(ips: &[&str], macs: &[&str]) -> PacketFilter { + PacketFilter::parse(&specs(ips), &specs(macs)).unwrap() } /// An Ethernet frame with `vlan_tags` 802.1Q tags in front of `ethertype`. @@ -199,12 +261,12 @@ mod tests { #[test] fn match_all_keeps_everything_including_non_ip_and_odd_linktypes() { - let f = IpFilter::match_all(); + let f = PacketFilter::match_all(); assert!(f.is_match_all()); assert!(f.matches(&[], 113)); assert!(f.matches(ð(0, 0x0806, &[1, 2, 3]), LINKTYPE_ETHERNET)); // An empty --ip list is the same thing. - assert!(IpFilter::parse(&[]).unwrap().is_match_all()); + assert!(PacketFilter::parse(&[], &[]).unwrap().is_match_all()); } #[test] @@ -298,12 +360,12 @@ mod tests { fn bad_specs_are_rejected_at_parse() { for spec in ["1.1.1.1/33", "::1/129", "not-an-ip", "1.1.1.1/x", "1.1.1.256"] { assert!( - IpFilter::parse(&specs(&[spec])).is_err(), + PacketFilter::parse(&specs(&[spec]), &[]).is_err(), "{spec:?} should be rejected" ); } // /0 and full-width prefixes are fine. - assert!(IpFilter::parse(&specs(&["1.1.1.1/32", "0.0.0.0/0", "::/0", "::1/128"])).is_ok()); + assert!(PacketFilter::parse(&specs(&["1.1.1.1/32", "0.0.0.0/0", "::/0", "::1/128"]), &[]).is_ok()); } #[test] @@ -314,4 +376,61 @@ mod tests { assert!(f.matches(ð(0, ETHERTYPE_IPV6, &ipv6("::1", "2606:4700::1")), LINKTYPE_ETHERNET)); assert!(!f.matches(&v4_packet("9.9.9.9", "203.0.113.1"), LINKTYPE_ETHERNET)); } + + const UPSTREAM: &str = "02:00:00:00:00:aa"; + const OTHER: &str = "02:00:00:00:00:bb"; + + /// An Ethernet frame between two MACs carrying `ethertype`/`payload`. + fn eth_between(dst: &str, src: &str, ethertype: u16, payload: &[u8]) -> Vec { + let mut f = parse_mac(dst).unwrap().to_vec(); + f.extend_from_slice(&parse_mac(src).unwrap()); + f.extend_from_slice(ðertype.to_be_bytes()); + f.extend_from_slice(payload); + f + } + + #[test] + fn mac_matches_either_direction_only() { + let f = mac_filter(&[], &[UPSTREAM]); + assert!(!f.is_match_all()); + let ip = ipv4("1.1.1.1", "9.9.9.9"); + assert!(f.matches(ð_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &ip), LINKTYPE_ETHERNET)); + assert!(f.matches(ð_between(UPSTREAM, OTHER, ETHERTYPE_IPV4, &ip), LINKTYPE_ETHERNET)); + assert!(!f.matches(ð_between(OTHER, OTHER, ETHERTYPE_IPV4, &ip), LINKTYPE_ETHERNET)); + } + + #[test] + fn mac_only_keeps_non_ip_frames_and_ignores_vlan_tags() { + let f = mac_filter(&[], &[UPSTREAM]); + assert!(f.matches(ð_between(OTHER, UPSTREAM, 0x0806, &[0u8; 28]), LINKTYPE_ETHERNET)); + assert!(f.matches(ð_between(OTHER, UPSTREAM, ETHERTYPE_VLAN, &[0u8; 4]), LINKTYPE_ETHERNET)); + } + + #[test] + fn mac_and_ip_must_both_match() { + let f = mac_filter(&["1.1.1.1"], &[UPSTREAM]); + let wanted = ipv4("1.1.1.1", "9.9.9.9"); + let unwanted = ipv4("8.8.8.8", "9.9.9.9"); + assert!(f.matches(ð_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &wanted), LINKTYPE_ETHERNET)); + assert!(!f.matches(ð_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &unwanted), LINKTYPE_ETHERNET)); + assert!(!f.matches(ð_between(OTHER, OTHER, ETHERTYPE_IPV4, &wanted), LINKTYPE_ETHERNET)); + // ARP on the right MAC still fails the IP dimension. + assert!(!f.matches(ð_between(OTHER, UPSTREAM, 0x0806, &[0u8; 28]), LINKTYPE_ETHERNET)); + } + + #[test] + fn mac_specs_parse_strictly() { + assert_eq!(parse_mac("AA:bb:0c:0D:ee:ff").unwrap(), [0xaa, 0xbb, 0x0c, 0x0d, 0xee, 0xff]); + assert_eq!(parse_mac("aa-bb-cc-dd-ee-ff").unwrap(), [0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]); + for bad in ["", "aa:bb:cc:dd:ee", "aa:bb:cc:dd:ee:ff:00", "aa:bb:cc:dd:ee:fg", "a:bb:cc:dd:ee:ff", "aabbccddeeff", "+a:bb:cc:dd:ee:ff"] { + assert!(parse_mac(bad).is_err(), "{bad:?} should be rejected"); + } + } + + #[test] + fn mac_filter_on_non_ethernet_matches_nothing() { + let f = mac_filter(&[], &[UPSTREAM]); + let pkt = eth_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &ipv4("1.1.1.1", "9.9.9.9")); + assert!(!f.matches(&pkt, 113)); + } } diff --git a/src/pipeline.rs b/src/pipeline.rs index ebe441d..c6d7fbb 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -1,5 +1,5 @@ use crate::chunker::{Chunker, RawChunker, RecordAlignedChunker}; -use crate::filter::IpFilter; +use crate::filter::PacketFilter; use crate::protocol::{Message, MessageSink, MessageSource}; use crate::tail::{run_tail, SourceEvent}; use crate::tracelog::Logger; @@ -28,10 +28,10 @@ pub struct SendOptions { /// anyway. See `Chunker::should_flush`. pub flush_after: Duration, pub level: i32, - /// Which packets to forward. `IpFilter::match_all()` when no `--ip` was + /// Which packets to forward. `PacketFilter::match_all()` when no `--ip`/`--mac` was /// given -- not a disabled filter, just one that says yes, so there is /// only ever one code path through the chunker. - pub filter: IpFilter, + pub filter: PacketFilter, pub log: Arc, } @@ -57,7 +57,7 @@ pub fn run_send(opts: SendOptions, mut sink: Box) -> Result<()> // structure is the only way to tell real content from a preallocating // writer's not-yet-written tail. Here the chunker is a validator, not // a framer. - // * a real `--ip` filter: deciding whether to forward a packet means + // * a real `--ip`/`--mac` filter: deciding whether to forward a packet means // finding the packet, and record boundaries are the only way to know // where one starts. Here the chunker is a demultiplexer. // A final, uncompressed copy has neither: `run_recv` writes every Data @@ -273,7 +273,7 @@ mod tests { chunk_target: crate::chunker::DEFAULT_TARGET_BYTES, flush_after: crate::chunker::DEFAULT_FLUSH, level: crate::zstd_frame::DEFAULT_LEVEL, - filter: IpFilter::match_all(), + filter: PacketFilter::match_all(), log: Arc::new(Logger::none()), }; run_pipe(opts, dest_final.clone(), dest_temp.clone()).unwrap(); @@ -311,7 +311,7 @@ mod tests { chunk_target: crate::chunker::DEFAULT_TARGET_BYTES, flush_after: crate::chunker::DEFAULT_FLUSH, level: crate::zstd_frame::DEFAULT_LEVEL, - filter: IpFilter::match_all(), + filter: PacketFilter::match_all(), log: Arc::new(Logger::none()), }; @@ -360,7 +360,7 @@ mod tests { chunk_target: 512, // small target to force multiple frames flush_after: crate::chunker::DEFAULT_FLUSH, level: crate::zstd_frame::DEFAULT_LEVEL, - filter: IpFilter::match_all(), + filter: PacketFilter::match_all(), log: Arc::new(Logger::none()), }; run_pipe(opts, dest_final.clone(), dest_temp.clone()).unwrap(); @@ -398,7 +398,7 @@ mod tests { chunk_target: crate::chunker::DEFAULT_TARGET_BYTES, flush_after: crate::chunker::DEFAULT_FLUSH, level: crate::zstd_frame::DEFAULT_LEVEL, - filter: IpFilter::match_all(), + filter: PacketFilter::match_all(), log: Arc::new(Logger::none()), }; assert!(run_pipe(opts, dest_final.clone(), dest_temp).is_err()); @@ -421,8 +421,8 @@ mod tests { record_bytes(&pkt, 1_700_000_000) } - fn ip_filter(specs: &[&str]) -> IpFilter { - IpFilter::parse(&specs.iter().map(|s| s.to_string()).collect::>()).unwrap() + fn ip_filter(specs: &[&str]) -> PacketFilter { + PacketFilter::parse(&specs.iter().map(|s| s.to_string()).collect::>(), &[]).unwrap() } /// Source stream plus the exact pcap a `--ip 1.1.1.1` transfer should diff --git a/src/server.rs b/src/server.rs index 8cddc38..d44f8d4 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,5 +1,5 @@ use crate::cli::{ServerCli, ServerRole}; -use crate::filter::IpFilter; +use crate::filter::PacketFilter; use crate::naming; use crate::pipeline::{run_recv, run_send, RecvOptions, SendOptions}; use crate::protocol::{MessageSink, MessageSource, WireSink, WireSource}; @@ -40,7 +40,7 @@ fn run_send_server(cli: ServerCli) -> Result<()> { // The sending side is the only side that filters -- dropping packets // before they hit the wire is the whole point. `run_recv` never parses // what it writes, so --recv has no --ip. - let filter = IpFilter::parse(&cli.ip)?; + let filter = PacketFilter::parse(&cli.ip, &cli.mac)?; let opts = SendOptions { source_path: PathBuf::from(source),