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 <noreply@anthropic.com>
This commit is contained in:
ericandClaude Sonnet 5.5 committed 2026-11-13 14:48:51 -05:00
1 parent 8b111ddfdc
commit a9e283a87c
6 files changed
+189 -53

No files matched your search

+9 -9
View File
@@ -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<u8>,
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::<Vec<_>>()).unwrap()
fn filter_for(specs: &[&str]) -> PacketFilter {
PacketFilter::parse(&specs.iter().map(|s| s.to_string()).collect::<Vec<_>>(), &[]).unwrap()
}
fn drain_all(c: &mut RecordAlignedChunker) -> Vec<(ChunkKind, Vec<u8>)> {
@@ -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] {
+13
View File
@@ -48,6 +48,14 @@ pub struct ClientCli {
#[arg(long = "ip", value_name = "ADDR")]
pub ip: Vec<String>,
/// 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<String>,
/// 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<String>,
/// MAC filter for --send mode; repeated, one per address. See the
/// client's --mac.
#[arg(long = "mac", value_name = "ADDR")]
pub mac: Vec<String>,
/// Destination final path (--recv mode).
#[arg(long)]
pub dest_final: Option<String>,
+8 -4
View File
@@ -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());
+147 -28
View File
@@ -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<Mac>,
}
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<Self> {
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<Self> {
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<Mac> {
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(&eth(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(&eth(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<u8> {
let mut f = parse_mac(dst).unwrap().to_vec();
f.extend_from_slice(&parse_mac(src).unwrap());
f.extend_from_slice(&ethertype.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(&eth_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &ip), LINKTYPE_ETHERNET));
assert!(f.matches(&eth_between(UPSTREAM, OTHER, ETHERTYPE_IPV4, &ip), LINKTYPE_ETHERNET));
assert!(!f.matches(&eth_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(&eth_between(OTHER, UPSTREAM, 0x0806, &[0u8; 28]), LINKTYPE_ETHERNET));
assert!(f.matches(&eth_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(&eth_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &wanted), LINKTYPE_ETHERNET));
assert!(!f.matches(&eth_between(OTHER, UPSTREAM, ETHERTYPE_IPV4, &unwanted), LINKTYPE_ETHERNET));
assert!(!f.matches(&eth_between(OTHER, OTHER, ETHERTYPE_IPV4, &wanted), LINKTYPE_ETHERNET));
// ARP on the right MAC still fails the IP dimension.
assert!(!f.matches(&eth_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));
}
}
+10 -10
View File
@@ -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<Logger>,
}
@@ -57,7 +57,7 @@ pub fn run_send(opts: SendOptions, mut sink: Box<dyn MessageSink>) -> 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::<Vec<_>>()).unwrap()
fn ip_filter(specs: &[&str]) -> PacketFilter {
PacketFilter::parse(&specs.iter().map(|s| s.to_string()).collect::<Vec<_>>(), &[]).unwrap()
}
/// Source stream plus the exact pcap a `--ip 1.1.1.1` transfer should
+2 -2
View File
@@ -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),