diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ed10b1d..55401410 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ Put changes for the upcoming release here! - Changed (Rust API, lang bindings): the `TS_RS_EXPERIMENT` environment variable is no longer required to use the library. The library logs a warning during initialization, as a reminder that it's still work-in-progress software. - Updated MSRV to 1.97. +- Added (ts_netmon): support for macOS. This brings macOS support for direct peer-to-peer connections to parity with + Linux and Windows. [#396](https://github.com/tailscale/tailscale-rs/pull/396) ## [0.5.0](https://github.com/tailscale/tailscale-rs/releases/tag/v0.5.0) - 2026-08-14 diff --git a/Cargo.lock b/Cargo.lock index a142f988..5d08aa64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -504,11 +504,12 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", + "regex-automata", "serde_core", ] @@ -1803,7 +1804,7 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", + "windows-link 0.2.1", "windows-result 0.4.1", ] @@ -4110,9 +4111,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -4122,9 +4123,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -6168,20 +6169,32 @@ dependencies = [ name = "ts_netmon" version = "0.5.0" dependencies = [ + "bitflags 2.13.0", + "bstr", + "bytes", "cfg-if", + "clap", "flume", "futures-util", "ipnet", - "nix 0.31.3", + "libc", + "nom 8.0.0", "pin-project-lite", + "proptest", + "regex", "rtnetlink", "smallvec", "socket2", + "static_assertions", "tokio", "tokio-stream", "tracing", + "tracing-test", "ts_cli_util", + "ts_future_util", + "ts_hexdump", "windows 0.62.2", + "zerocopy", ] [[package]] diff --git a/ts_cli_util/src/lib.rs b/ts_cli_util/src/lib.rs index 8757cceb..6e7cdd42 100644 --- a/ts_cli_util/src/lib.rs +++ b/ts_cli_util/src/lib.rs @@ -115,7 +115,7 @@ pub fn init_tracing() { .with_default_directive(LevelFilter::INFO.into()) .from_env_lossy(); - let fmt_layer = tracing_subscriber::fmt::layer(); + let fmt_layer = tracing_subscriber::fmt::layer().with_writer(std::io::stderr); let fmt_layer = if std::env::var("TS_RS_LOG_PRETTY") == Ok("1".into()) { fmt_layer.pretty().boxed() diff --git a/ts_netmon/Cargo.toml b/ts_netmon/Cargo.toml index fb9890b9..6cc0c73e 100644 --- a/ts_netmon/Cargo.toml +++ b/ts_netmon/Cargo.toml @@ -11,19 +11,30 @@ license.workspace = true rust-version.workspace = true [dependencies] +bytes.workspace = true cfg-if.workspace = true flume.workspace = true futures-util.workspace = true ipnet.workspace = true pin-project-lite.workspace = true +regex = "1.13" smallvec.workspace = true +static_assertions.workspace = true tokio.workspace = true tokio-stream.workspace = true tracing.workspace = true +ts_future_util.workspace = true +ts_hexdump.workspace = true + [dev-dependencies] ts_cli_util.workspace = true +bstr = "1.13" +clap.workspace = true +proptest.workspace = true +tracing-test = { version = "0.2", features = ["no-env-filter"] } + [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ "Win32_NetworkManagement_IpHelper", @@ -35,9 +46,12 @@ windows = { version = "0.62", features = [ rtnetlink = "0.21" [target.'cfg(target_os = "macos")'.dependencies] -nix = "0.31" +bitflags = "2.13" +libc = "0.2" +nom = "8.0" socket2 = "0.6" tokio = { workspace = true, features = ["net"] } +zerocopy.workspace = true [lints] workspace = true diff --git a/ts_netmon/examples/macos_dump_route.rs b/ts_netmon/examples/macos_dump_route.rs new file mode 100644 index 00000000..a63b750b --- /dev/null +++ b/ts_netmon/examples/macos_dump_route.rs @@ -0,0 +1,152 @@ +//! Dump route tables on macOS. + +#[cfg(target_os = "macos")] +mod _mac { + use std::{io::Read, path::PathBuf}; + + use nom::combinator::ParserIterator; + use ts_netmon::{ + FamilyOrBoth, + bsd::{net_table, net_table::DumpType}, + }; + + #[derive(clap::Parser)] + pub struct Args { + /// Rather than querying the OS for a RIB, read the file instead. + #[arg(short = 'i', long, conflicts_with_all = ["out_file", "interface2", "interface", "route2", "route"])] + pub in_file: Option, + + /// Write out the RIB data received from the OS to the specified file. + #[arg(short = 'o', long, conflicts_with("in_file"))] + pub out_file: Option, + + #[command(flatten)] + pub ty: Ty, + } + + #[derive(clap::Args, Debug)] + #[group(multiple = false)] + pub struct Ty { + /// Fetch the IFMIB in `NET_RT_IFLIST2` format. + #[clap(long = "if2")] + pub interface2: bool, + + /// Fetch the IFMIB in `NET_RT_IFLIST` format. + #[clap(long = "if")] + pub interface: bool, + + /// Fetch the RIB in `NET_RT_DUMP2` format. + #[clap(long = "rt2")] + pub route2: bool, + + /// Fetch the RIB in `NET_RT_DUMP` RIB format. + #[clap(long = "rt")] + pub route: bool, + } + + impl Ty { + pub fn get(&self) -> Option { + if self.interface2 { + Some(DumpType::Interface2) + } else if self.interface { + Some(DumpType::Interface) + } else if self.route2 { + Some(DumpType::Route2) + } else if self.route { + Some(DumpType::Route) + } else { + None + } + } + } + + pub fn load_rib(args: &Args) -> Result, Box> { + if let Some(in_file) = &args.in_file { + let mut rib = vec![]; + + let mut f = std::fs::File::open(in_file)?; + f.read_to_end(&mut rib)?; + + Ok(rib) + } else { + let rib = net_table::dump( + FamilyOrBoth::Both, + args.ty.get().unwrap_or(DumpType::Interface2), + 0, + )?; + + if let Some(out_file) = &args.out_file { + use std::io::Write; + let mut f = std::fs::File::create(out_file)?; + f.write_all(&rib)?; + } + + Ok(rib) + } + } + + pub fn finish_iter<'i, F>( + iter: ParserIterator<&'i [u8], nom::error::Error<&'i [u8]>, F>, + ) -> Result<&'i [u8], nom::Err>>> { + match iter.finish() { + Err(ref e @ (nom::Err::Error(ref inner) | nom::Err::Failure(ref inner))) + if !inner.input.is_empty() => + { + panic!("{e}"); + } + Err(e) => { + tracing::warn!("{e}"); + Ok(&[]) + } + x => x.map(|x| x.0).map_err(|e| e.to_owned()), + } + } +} + +#[cfg(target_os = "macos")] +use _mac::*; + +#[cfg(target_os = "macos")] +fn main() -> Result<(), Box> { + use clap::Parser; + use ts_netmon::bsd::{ + net_table, + net_table::{Address, MessageHeader}, + }; + + ts_cli_util::init_tracing(); + + let args = Args::parse(); + + let rib = load_rib(&args)?; + tracing::debug!(rib_len = rib.len()); + + let mut iter = nom::combinator::iterator( + rib.as_slice(), + nom::combinator::complete(net_table::msg_chunk()), + ); + for chunk in &mut iter { + let (rest, (_ty, hdr)) = MessageHeader::parse(chunk).map_err(|e| format!("{e}"))?; + + tracing::info!(?hdr); + + let mut iter = nom::combinator::iterator(rest, nom::combinator::complete(Address::parse())); + + for (addr, flag) in (&mut iter).zip(hdr.addrs().iter()) { + tracing::info!(?flag, ?addr, "ADDR"); + } + + let rest = finish_iter(iter)?; + assert!(rest.is_empty()); + } + + let rest = finish_iter(iter)?; + assert!(rest.is_empty()); + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("error: this example only runs on macOS") +} diff --git a/ts_netmon/examples/macos_raw_monitor.rs b/ts_netmon/examples/macos_raw_monitor.rs new file mode 100644 index 00000000..a1b08d44 --- /dev/null +++ b/ts_netmon/examples/macos_raw_monitor.rs @@ -0,0 +1,45 @@ +//! Dump route tables on macOS. + +#[cfg(target_os = "macos")] +#[tokio::main] +async fn main() -> Result<(), Box> { + use clap::Parser; + use futures_util::StreamExt; + use ts_netmon::bsd::{ + RouteSocket, + net_table::{Address, MessageHeader}, + }; + use zerocopy::IntoBytes; + + #[derive(clap::Parser)] + struct Args {} + + ts_cli_util::init_tracing(); + let _args = Args::parse(); + + let sock = RouteSocket::new()?; + let mut raw_stream = sock.raw_msg_stream(); + + while let Some(msg) = raw_stream.next().await { + let msg = msg?; + + let (rest, (ty, msg)) = MessageHeader::parse(msg.as_bytes()) + .map_err(|e| std::io::Error::other(e.to_string()))?; + + let mut iter = nom::combinator::iterator(rest, Address::parse::<_, nom::error::Error<_>>()); + + let addrs = msg.addrs().into_iter().zip(&mut iter).collect::>(); + iter.finish() + .map_err(|e| e.to_string()) + .map_err(std::io::Error::other)?; + + tracing::info!(?ty, ?msg, ?addrs); + } + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("error: this example only runs on macOS") +} diff --git a/ts_netmon/examples/macos_sanitize_dump.rs b/ts_netmon/examples/macos_sanitize_dump.rs new file mode 100644 index 00000000..c0168095 --- /dev/null +++ b/ts_netmon/examples/macos_sanitize_dump.rs @@ -0,0 +1,108 @@ +//! Sanitize a BSD route/interface dump by replacing all system IP and MAC addresses +//! with dummy values. +//! +//! Usage: +//! +//! ```shell +//! $ macos_dump_route -o dump.dat +//! $ macos_sanitize_dump < dump.dat > dump_san.dat +//! ``` + +#[cfg(target_os = "macos")] +fn main() -> Result<(), Box> { + use std::{ + collections::HashSet, + io::{Read, Write, stdin, stdout}, + net::IpAddr, + }; + + use nom::{Parser, combinator::complete, multi::many0}; + use ts_netmon::bsd::Message; + + const FILL_BYTE: u8 = 0xb5; + + fn addr_octets(addr: &IpAddr) -> Vec { + match addr { + IpAddr::V4(addr) => addr.octets().to_vec(), + IpAddr::V6(addr) => addr.octets().to_vec(), + } + } + + ts_cli_util::init_tracing(); + + let mut buf = vec![]; + stdin().read_to_end(&mut buf)?; + + let (_rest, msgs) = many0(complete(Message::parse)) + .parse_complete(&buf) + .map_err(|e| format!("{e}"))?; + let mut patterns = HashSet::new(); + + for msg in msgs { + if let Some(addr) = msg.dest_addr() { + patterns.insert(addr_octets(&addr.addr())); + } + + if let Some(addr) = msg.gateway() { + patterns.insert(addr_octets(&addr)); + } + + if let Some(la) = msg.interface_name() { + patterns.insert(la.addr); + } + + if let Some(addr) = msg.interface_addr() { + patterns.insert(addr_octets(&addr.addr())); + } + } + + patterns.retain(|x| { + x.len() >= 4 + && !x.starts_with(&[0]) + && !x.iter().all(|b| *b == FILL_BYTE || *b == 0 || *b == 1) + + // Ignore broadcast address + && x != &[0xff, 0xff, 0xff, 0xff] + + // Ignore ff01::, ff02::, ff00:: fe80::, fe80::1 + && x != (&[&[0xffu8, 0x02], &[0u8; 14][..]].concat()) + && x != (&[&[0xffu8, 0x01], &[0u8; 14][..]].concat()) + && x != (&[&[0xffu8], &[0u8; 15][..]].concat()) + && x != (&[&[0xfeu8, 0x80], &[0u8; 14][..]].concat()) + && x != (&[&[0xfeu8, 0x80], &[0u8; 13][..], &[1u8]].concat()) + }); + let mut patterns = patterns.into_iter().collect::>(); + patterns.sort_by_key(|a| -(a.len() as isize)); + + for pattern in &patterns { + let mut pattern = pattern.as_slice(); + + eprintln!("remove: {pattern:x?}"); + + // Chop off fe80:$KAME?:0:0 + if pattern.starts_with(&[0xfe, 0x80]) && pattern[2..8] == [0; 6] { + pattern = &pattern[8..]; + eprintln!("\ttruncate: {pattern:x?}"); + + // If this was truncated before, it won't have been filtered out above, manually skip + if pattern.iter().all(|x| x == &FILL_BYTE || x == &0) { + continue; + } + } + + let finder = bstr::Finder::new(pattern); + + while let Some(x) = finder.find(&buf) { + buf[x..x + pattern.len()].fill(FILL_BYTE); + } + } + + stdout().write_all(&buf)?; + + Ok(()) +} + +#[cfg(not(target_os = "macos"))] +fn main() { + eprintln!("error: this example only runs on macOS") +} diff --git a/ts_netmon/src/bsd/message.rs b/ts_netmon/src/bsd/message.rs new file mode 100644 index 00000000..4a4ba61b --- /dev/null +++ b/ts_netmon/src/bsd/message.rs @@ -0,0 +1,296 @@ +use std::net::IpAddr; + +use ipnet::IpNet; +use nom::{Parser, multi::many0}; + +use crate::{ + Event, Family, Interface, InterfaceId, MonType, Route, bsd, + bsd::{ + net_table, + net_table::{Address, Addrs, Flags, LinkAddr, MessageHeader, PrefixLen}, + }, +}; + +/// A `PF_ROUTE` message parsed from a buffer. +/// +/// This is lifted out of [`net_table`] because it's essentially an adapter from the types there +/// into the [`ts_netmon`][crate] types. +#[derive(Debug, Clone)] +pub struct Message<'a> { + /// Message header for this message. + pub header: MessageHeader<'a>, + /// Addresses parsed for this message. + pub addrs: Vec>, +} + +impl<'a> Message<'a> { + /// Parse a [`Message`] from a buffer. + pub fn parse(buf: &'a [u8]) -> nom::IResult<&'a [u8], Self> { + let (rest, payload) = net_table::msg_chunk().parse_complete(buf)?; + let (addrs, (_ty, slf)) = MessageHeader::parse.parse_complete(payload)?; + + // NOTE: Typically, `_rest` will be empty here, but there's technically nothing requiring + // that. The kernel could completely legally have given us a route message with an empty + // address block and 32kB of padding if it so chose. + let (_rest, parsed_addrs) = many0(nom::combinator::complete(Address::parse::< + _, + nom::error::Error<_>, + >())) + .parse(addrs)?; + + if parsed_addrs.len() != slf.addrs().bits().count_ones() as usize { + let is_ipv6 = parsed_addrs.iter().any(|x| { + let Some(x) = x else { + return false; + }; + + let Ok(addr) = IpAddr::try_from(x) else { + return false; + }; + + addr.is_ipv6() + }); + + let ends_with_broadcast = slf.addrs().iter().last() == Some(Addrs::BROADCAST_ADDR); + + // Don't warn if this is a known case where XNU will omit the address entirely. + // + // We've seen this occur when we would otherwise expect the final address to be nulled + // out but padded up to 4 bytes. + // + // E.g., if the kernel handed me a message with RTA_IFA | RTA_BRD (in that order) and + // the RTA_IFA was null but a RTA_BRD wasn't, it would need to put something + // in the RTA_IFA spot to disambiguate the parse (so I know there's something before + // the RTA_BRD). This is when it hands us [0, 0, 0, 0], which is special-cased in the + // address parsing. + // + // If on the other hand it wanted to do the other thing (RTA_IFA present, RTA_BRD null), + // it could maintain a disambiguous parse by just ending the address block after + // RTA_IFA. In terms of parsing ambiguity, there's no need for a placeholder if there is + // no later address, you can just omit it entirely, and the theory is that the user is + // expected to just know how to interpret this (oh, I'm out of addresses to parse, that + // means that all the following ones are null). It's unclear whether the kernel will do + // this if there are multiple trailing missing addresses. + // + // This is a working theory backreasoned from this one example we have, which is a null + // trailing IFA_BRD on IPv6 RTM_IFADDR messages. This address is always null because + // IPv6 doesn't have broadcast addresses, but it seems that conventionally, RTM_IFADDR + // always sets the bit, and it's typically the last bit set. So we see this situation a + // lot, hence quieting the trace for that one case. We still want it to see if we can + // catch any other scenarios where the kernel does this to validate the theory. + if !(is_ipv6 && ends_with_broadcast) { + tracing::warn!( + addrs = ?slf.addrs(), + parsed = ?parsed_addrs, + addr_payload = ?format_args!("{addrs:x?}"), + "parsed wrong addrs count", + ); + } + } + + Ok(( + rest, + Message { + header: slf, + addrs: parsed_addrs, + }, + )) + } + + /// Report the destination address from `RTA_DEST`, if present. + pub fn dest_addr(&self) -> Option { + self.masked_addr(Addrs::DESTINATION) + } + + /// Report the netmask len from the `RTA_NETMASK` address, if present. + /// + /// The `family` parameter informs how to interpret the netmask bytes, as they can't be + /// parsed unambiguously. + /// + /// If the netmask was not valid as a strict prefix, `None` is returned. + pub fn netmask_len(&self, family: Family) -> Option { + match self.get_addr(Addrs::NETMASK)? { + Address::Ipv4(ip) if family == Family::Ipv4 => net_table::netmask_to_prefix(&ip.into()), + Address::Ipv6 { addr: ip, .. } if family == Family::Ipv6 => { + net_table::netmask_to_prefix(&ip.into()) + } + Address::PrefixLen(PrefixLen { v4, v6 }) => match family { + Family::Ipv4 => v4, + Family::Ipv6 => v6, + }, + Address::Unspecified => Some(0), + _ => None, + } + } + + /// Report the gateway address, if present. + pub fn gateway(&self) -> Option { + self.get_addr(Addrs::GATEWAY)?.try_into().ok() + } + + /// Report the `RTA_IFP` link address, which typically contains the interface name. + pub fn interface_name(&self) -> Option { + let addr = self.get_addr(Addrs::INTERFACE_NAME)?; + let Address::Link(la) = addr else { + return None; + }; + + Some(la) + } + + /// Report the `RTA_IFA` interface address. + pub fn interface_addr(&self) -> Option { + self.masked_addr(Addrs::INTERFACE_ADDR) + } + + /// Report whether this has `RTA_IFP` and the contained name is ignored by + /// [`bsd::ignore_interface_name`]. + pub fn ignored_interface_name(&self) -> bool { + let Some(ifp) = self.interface_name() else { + return false; + }; + + bsd::ignore_interface_name(&ifp.name) + } + + /// Get an address corresponding to the query. Only one address bit should be set. + /// + /// If the indicated address is not present in the underlying `addresses` vec, this is not taken + /// to have been the result of a parsing error, but instead implies that the kernel meant to + /// communicate an `AF_UNSPEC` address. This is due to undocumented truncation behavior + /// previously witnessed in XNU, where trailing addresses that the kernel would have populated + /// as `AF_UNSPEC` placeholders are dropped completely. + pub fn get_addr(&self, query: Addrs) -> Option
{ + debug_assert!( + query.bits().count_ones() <= 1, + "Message::get_addr with more than one address bit" + ); + + let pos = self.header.addrs().iter().position(|x| x == query)?; + + Some( + self.addrs + .get(pos) + .cloned() + .flatten() + .unwrap_or(Address::Unspecified), + ) + } + + /// Attempt to convert this message into an [`Event`]. + pub fn as_event(&self) -> Option { + match &self.header { + MessageHeader::Route(..) | MessageHeader::Route2(..) => { + self.as_route().map(|(iid, rt)| Event::RouteUpsert(iid, rt)) + } + MessageHeader::Interface(..) | MessageHeader::Interface2(..) => { + self.as_interface().map(Event::InterfaceUpsert) + } + MessageHeader::InterfaceAddr(..) => self + .as_interface_addr() + .map(|(iid, addr)| Event::AddrUpsert(iid, addr)), + MessageHeader::MulticastAddr(..) | MessageHeader::MulticastAddr2(..) => { + tracing::trace!("drop multicast addr"); + None + } + } + } + + /// Attempt to interpret this message as a [`net_table::InterfaceAddr`] and convert it to + /// an [`IpNet`]. + pub fn as_interface_addr(&self) -> Option<(InterfaceId, IpNet)> { + let MessageHeader::InterfaceAddr(net_table::InterfaceAddr { index, .. }) = self.header + else { + tracing::warn!("wrong message type (expected interface addr)"); + return None; + }; + + Some((bsd::iid(index.get()), self.interface_addr()?)) + } + + /// Attempt to convert this message to a [`Route`]. + /// + /// Returns `None` if this isn't a route message or if it's dead or ignored. + pub fn as_route(&self) -> Option<(InterfaceId, Route)> { + if self.is_dead() || self.ignored_interface_name() { + tracing::trace!("route is dead or interface is ignored"); + return None; + } + + let (MessageHeader::Route(net_table::Route { index, .. }) + | MessageHeader::Route2(net_table::Route2 { index, .. })) = &self.header + else { + tracing::warn!("wrong message type (expected route)"); + return None; + }; + + Some(( + InterfaceId::new(MonType::PF_ROUTE, index.get() as _), + Route { + dst: self.dest_addr()?, + gateway: self.gateway().into_iter().collect(), + metric: 0, // macOS doesn't set a per-route metric, it comes from the interface + }, + )) + } + + /// Attempt to convert this to an [`Interface`]. + /// + /// Returns `None` if this isn't an interface message or the interface is dead or ignored. + pub fn as_interface(&self) -> Option { + if self.is_dead() || self.ignored_interface_name() { + tracing::trace!("interface is dead or ignored"); + return None; + } + + let (MessageHeader::Interface(net_table::Interface { + index, + data: net_table::InterfaceData { mtu, .. }, + .. + }) + | MessageHeader::Interface2(net_table::Interface2 { + index, + data: net_table::InterfaceData64 { mtu, .. }, + .. + })) = &self.header + else { + tracing::warn!("wrong message type (expected interface)"); + return None; + }; + + let la = self.interface_name()?; + let mtu = (mtu.get() != 0).then_some(mtu.get() as _); + + Some(Interface { + id: bsd::iid(index.get()), + mtu, + hardware_addr: if !la.addr.is_empty() { + Some(la.addr.iter().copied().collect()) + } else { + None + }, + name: la.name.clone(), + up: self.header.flags().contains(Flags::UP), + }) + } + + /// Report whether the flags on this message indicate that the route or interface it represents + /// is dead or inoperative. + /// + /// Asserts `RTF_UP` and not `RTF_BLACKHOLE | RTF_REJECT | RTF_DEAD`. + pub fn is_dead(&self) -> bool { + !self.header.flags().contains(Flags::UP) + || self + .header + .flags() + .intersects(Flags::BLACKHOLE | Flags::REJECT | Flags::DEAD) + } + + /// Get the address specified by `addr_ty`, then mask it according to the netmask address. + fn masked_addr(&self, addr_ty: Addrs) -> Option { + let dest_ip: IpAddr = self.get_addr(addr_ty)?.try_into().ok()?; + let mask = self.netmask_len(dest_ip.into())?; + + IpNet::new(dest_ip, mask).ok() + } +} diff --git a/ts_netmon/src/bsd/mod.rs b/ts_netmon/src/bsd/mod.rs new file mode 100644 index 00000000..155a26b0 --- /dev/null +++ b/ts_netmon/src/bsd/mod.rs @@ -0,0 +1,368 @@ +//! BSD network monitor implementation. +//! +//! There are two means of accessing routing and interface info: the `PF_ROUTE` socket, which +//! broadcasts routing and interface changes, and the `CTL_NET/PF_ROUTE` `sysctl` which can dump the +//! current route or interface table. +//! +//! We monitor the socket interface for changes using [`RouteSocket`], but it unfortunately doesn't +//! provide sufficient information in-band to fully determine system state: on macOS (at least), new +//! route notifications are issued before the corresponding interface is determined, so the +//! interface index is set to zero. You can ask the kernel to resolve the route completely, but this +//! requires superuser permissions, which we don't have in the general case. macOS also doesn't have +//! `RTA_IFANNOUNCE` as other BSDs do, so interface changes never appear on the socket other than +//! indirectly (via subsequent address and route assignments). +//! +//! For these reasons, we mirror the Go implementation and use the `PF_ROUTE` socket as a stream of +//! notifications of possible events of interest, which are themselves discarded, but trigger +//! updates via `sysctl` dumps (which are fully resolved, atomic snapshots of system state). We +//! still can't grab simultaneous snapshots of both the interface and route states, but we'll just +//! have to live with that minor potential for a race. + +use core::net::IpAddr; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + time::Duration, +}; + +use futures_util::{StreamExt, TryStreamExt}; +use ipnet::IpNet; +use nom::Parser; +use ts_future_util::DebounceExt; + +mod message; +pub mod net_table; +mod route_socket; + +pub use message::Message; +use net_table::{MessageHeader, MessageType}; +pub use route_socket::{MsgStream, RouteSocket}; + +use crate::{ + BoxStream, Event, FamilyOrBoth, Interface, InterfaceId, MonType, Netmon, Route, RouteUnique, + bsd::net_table::DumpType, +}; + +/// Canonical platform [`Netmon`] for BSD based on `PF_ROUTE` sockets. +pub struct PfRouteMon; + +/// Whether this message indicates an update to the network interface state or the route +/// state. +enum UpdateKind { + Interface, + Route, +} + +/// Aggregated network state we've seen so far. +/// +/// Used to compute deltas to hand through the event stream interface. +#[derive(Default)] +struct State { + routes: HashMap<(InterfaceId, RouteUnique), Route>, + interfaces: HashMap)>, +} + +impl Netmon for PfRouteMon { + fn ty(&self) -> MonType { + MonType::PF_ROUTE + } + + fn strong_delete_consistency(&self) -> bool { + false + } + + fn event_stream(&self) -> std::io::Result>> { + let sock = Arc::new(RouteSocket::new()?); + let stream = MsgStream::new(sock); + + let socket_updates = stream.try_filter_map(process_raw_socket_msg).fold_debounce( + Duration::from_millis(50), + |acc: &mut Option>, x| { + let Ok((iface, rt)) = acc.get_or_insert(Ok((false, false))) else { + return; + }; + + match x { + Ok(UpdateKind::Interface) => *iface = true, + Ok(UpdateKind::Route) => *rt = true, + Err(e) => *acc = Some(Err(e)), + } + }, + ); + + // Force an update to both interfaces and routes to start, then chain updates from the + // PF_ROUTE socket. + let stream = futures_util::stream::once(async move { Ok((true, true)) }) + .chain(socket_updates) + .and_then(|(iface, rt)| { + tracing::trace!(iface, rt, "update triggered"); + + async move { + let rt_dump = rt + .then(|| net_table::dump(FamilyOrBoth::Both, DumpType::Route2, 0)) + .transpose()?; + + let if_dump = iface + .then(|| net_table::dump(FamilyOrBoth::Both, DumpType::Interface2, 0)) + .transpose()?; + + Ok((rt_dump, if_dump)) as std::io::Result<(OptDump, OptDump)> + } + }) + .scan(State::default(), |state, x| { + let events = update_state(state, x); + + async move { Some(futures_util::stream::iter(events)) } + }) + .flatten(); + + Ok(Box::pin(stream)) + } +} + +/// Decode a message from the socket, rejecting irrelevant updates and reporting what +/// kind of update this represents ([`UpdateKind::Route`] or [`UpdateKind::Interface`]). +/// The relevant kind of update will eventually trigger a [`net_table::dump`] to update the +/// [`State`]. +async fn process_raw_socket_msg(msg: bytes::BytesMut) -> std::io::Result> { + let (_rest, msg) = Message::parse(msg.as_ref()) + .map_err(|e| e.to_string()) + .map_err(std::io::Error::other)?; + + // Reject don't-care message types (RTM_MISS, RTM_LOCK, RTM_RESOLVE, RTM_REDIRECT), etc. + if !matches!( + msg.header.header().ty, + MessageType::Add + | MessageType::Change + | MessageType::Delete + | MessageType::NewAddr + | MessageType::DelAddr + | MessageType::Get + | MessageType::Get2 + ) { + tracing::trace!(ty = ?msg.header.header().ty, "irrelevant message type"); + return Ok(None); + } + + if msg.ignored_interface_name() { + tracing::trace!( + name = %msg.interface_name().unwrap().name, + "ignored interface" + ); + + return Ok(None); + } + + let dst = msg.dest_addr(); + if matches!( + msg.header, + MessageHeader::Route(..) | MessageHeader::Route2(..) + ) && let Some(dst) = dst + && is_unicast_link_local(dst.addr()) + { + tracing::trace!("ignore unicast link local"); + return Ok(None); + } + + // Explicitly reject multicast address updates. Following the Go, IfInfo + // messages are also discarded (only care about routes and unicast addr changes). + match msg.header { + MessageHeader::MulticastAddr(..) + | MessageHeader::MulticastAddr2(..) + | MessageHeader::Interface(..) + | MessageHeader::Interface2(..) => { + tracing::trace!("ignore socket msg type"); + Ok(None) + } + MessageHeader::InterfaceAddr(_) => Ok(Some(UpdateKind::Interface)), + MessageHeader::Route(_) | MessageHeader::Route2(_) => Ok(Some(UpdateKind::Route)), + } +} + +type OptDump = Option>; + +/// Process [`net_table::dump`]s, updating the current [`State`] and producing [`Event`]s +/// based on the delta. +fn update_state( + state: &mut State, + x: std::io::Result<(OptDump, OptDump)>, +) -> Vec> { + let mut events = vec![]; + + let (rt_dump, if_dump) = match x { + Ok(x) => x, + Err(e) => return vec![Err(e)], + }; + + if let Some(rt_dump) = rt_dump { + update_rt(state, &rt_dump, &mut events); + } + + if let Some(if_dump) = if_dump { + update_iface(state, &if_dump, &mut events); + } + + events +} + +/// Reconcile the route state with a new route dump, adding any deltas to the event vec. +fn update_rt(state: &mut State, mut input: &[u8], events: &mut Vec>) { + let mut routes_maybe_deleted = state.routes.keys().cloned().collect::>(); + + while let Ok((rest, msg)) = Message::parse.parse_complete(input) { + input = rest; + + tracing::trace!(?msg); + + let Some(evt) = msg.as_event() else { + continue; + }; + + tracing::trace!(?evt); + + match evt { + Event::RouteUpsert(iid, rt) => { + let key = (iid.clone(), rt.unique()); + + routes_maybe_deleted.remove(&key); + + if let Some(cur_rt) = state.routes.get(&key) { + if cur_rt == &rt { + continue; + } + + tracing::debug!(old_rt = ?cur_rt, new_rt = ?rt, "update rt"); + } + + events.push(Ok(Event::RouteUpsert(iid, rt.clone()))); + state.routes.insert(key.clone(), rt); + } + + _ => unreachable!(), + } + } + + for (iid, rtu) in routes_maybe_deleted { + state.routes.remove(&(iid.clone(), rtu.clone())); + + events.push(Ok(Event::RouteRemoved( + iid, + Route { + dst: rtu.0, + gateway: rtu.1, + metric: 0, + }, + ))); + } +} + +/// Reconcile the interface state with a new interface dump, adding any deltas to the event vec. +fn update_iface(state: &mut State, mut input: &[u8], events: &mut Vec>) { + let mut new_interface_state = + HashMap::, HashSet)>::default(); + + while let Ok((rest, msg)) = Message::parse.parse_complete(input) { + input = rest; + + tracing::trace!(?msg); + + let Some(evt) = msg.as_event() else { + continue; + }; + + tracing::trace!(?evt); + + match evt { + Event::InterfaceUpsert(iface) => { + let (iface_entry, _addrs) = new_interface_state + .entry(iface.id.clone()) + .or_insert_with(|| (None, Default::default())); + + *iface_entry = Some(iface); + } + + Event::AddrUpsert(iid, inet) => { + let (_iface, addrs) = new_interface_state + .entry(iid) + .or_insert_with(|| (None, Default::default())); + + addrs.insert(inet); + } + + _ => unreachable!(), + } + } + + new_interface_state.retain(|_iid, (iface, _addrs)| iface.is_some()); + + let old_iids = state + .interfaces + .keys() + .cloned() + .collect::>(); + let new_iids = new_interface_state + .keys() + .cloned() + .collect::>(); + + for deleted in old_iids.difference(&new_iids) { + events.push(Ok(Event::InterfaceRemoved(deleted.clone()))); + state.interfaces.remove(deleted); + } + + for added in new_iids { + let (iface, addrs) = new_interface_state.remove(&added).unwrap(); + let iface = iface.unwrap(); + + match state.interfaces.get_mut(&added) { + Some((old_iface, old_addrs)) => { + // Reconcile addrs + { + for &deleted in old_addrs.difference(&addrs) { + events.push(Ok(Event::AddrRemoved(iface.id.clone(), deleted))); + } + + for &added in addrs.difference(old_addrs) { + events.push(Ok(Event::AddrUpsert(iface.id.clone(), added))); + } + + *old_addrs = addrs; + } + + if old_iface != &iface { + events.push(Ok(Event::InterfaceUpsert(iface.clone()))); + *old_iface = iface; + } + } + // New addr + None => { + for addr in addrs { + events.push(Ok(Event::AddrUpsert(iface.id.clone(), addr))); + } + + events.push(Ok(Event::InterfaceUpsert(iface.clone()))); + } + } + } +} + +fn iid(index: impl Into) -> InterfaceId { + InterfaceId::new(MonType::PF_ROUTE, index.into()) +} + +/// Report whether we should ignore route and interface changes related to the given interface name. +/// +/// These interface names typically specify interfaces which do not provide meaningful underlay +/// routing opportunities. +/// +/// See +pub fn ignore_interface_name(name: &str) -> bool { + regex::regex!(r#"^(:?llw|awdl|ipsec|gif|XHC|anpi|lo|utun)\d*$"#).is_match(name) +} + +fn is_unicast_link_local(ip: IpAddr) -> bool { + match ip { + IpAddr::V4(ip) => ip.is_link_local(), + IpAddr::V6(ip) => ip.is_unicast_link_local(), + } +} diff --git a/ts_netmon/src/bsd/net_table/addr.rs b/ts_netmon/src/bsd/net_table/addr.rs new file mode 100644 index 00000000..a5ef3632 --- /dev/null +++ b/ts_netmon/src/bsd/net_table/addr.rs @@ -0,0 +1,710 @@ +//! Address parsing for `PF_ROUTE` messages. +//! +//! This module provides [`Address`], an enum which captures the `sockaddr_*` types we might expect +//! to find in `PF_ROUTE` messages. +//! +//! Addresses are laid out in the message after the +//! [`MessageHeader`][crate::bsd::net_table::MessageHeader] as back-to-back 4-byte-aligned +//! `sockaddr` entries. Because the sockaddr starts with `sa_len`, `sa_family`, this makes them +//! TLVs, so they can mostly be parsed in a context-free manner. The meaning of each [`Address`] is +//! indicated by the [`Addrs`][crate::bsd::net_table::Addrs`] in the message header; we don't need +//! to know that in order to parse the messages. + +use core::{ + fmt::Debug, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +use nom::{ + AsBytes, Compare, Parser, + branch::alt, + bytes::{tag, take}, + combinator::{complete, fail, opt, peek, rest_len, success}, + error::{FromExternalError, ParseError}, + number::{Endianness, u8, u16, u32}, + sequence::{preceded, terminated}, +}; + +use crate::bsd::{ + net_table, + net_table::{ALIGN, Af, CInt}, +}; + +/// Parse a `sockaddr` header for the length and [`Af`]. +pub fn sa_hdr() -> impl Parser +where + I: nom::Input + Copy, + E: ParseError, +{ + (u8(), u8().map(|x| Af::from(CInt::new(x as _)))) +} + +const HEADER_LEN: u8 = 2; + +/// An address parsed after a given message. +#[derive(Clone, PartialEq, Eq)] +pub enum Address { + /// IPv4 address. + Ipv4(Ipv4Addr), + /// IPv6 address with scope. + Ipv6 { + /// The address. + addr: Ipv6Addr, + /// The scope (interface index). + scope: u32, + }, + /// Link address: used as a place to put the link name and Ethernet MAC in most cases. + Link(LinkAddr), + /// Prefix length. + PrefixLen(PrefixLen), + /// This address was unspecified. + Unspecified, +} + +impl Address { + /// Parse a possibly-XNU-truncated `sockaddr` from the input. + /// + /// This parser is complicated by the fact that XNU both (may) truncate addresses with trailing + /// zeroes and pads each possibly-truncated message up to 4-byte alignment. Also, it may null out + /// the `sockaddr` completely (and truncate to _just_ `sa_len` and `sa_family`, plus padding). + /// + /// The `None` return means specifically that the address was of an unsupported `sockaddr` + /// type; all failures are returned as errors, and situations implying the unspecified address + /// return [`Address::Unspecified`]. + pub fn parse() -> impl Parser, Error = E> + where + I: nom::Input + AsBytes + Copy + for<'a> Compare<&'a [u8]>, + E: ParseError + FromExternalError, + { + sa_hdr().flat_map(|(sa_len, af)| { + let (addr_len, read_len) = if af == Af::Unspec && sa_len == 0 { + // Special case: normally, sa_len refers to the whole length of the sockaddr structure. + // If the af and sa_len are nulled out, the kernel is telling us this is an empty address + // entry, so don't interpret sa_len. + (0, ALIGN - HEADER_LEN) + } else if sa_len == 0 { + panic!("sa_len = 0 but af = {af:?} (expected {:?})", Af::Unspec) + } else { + // We need to pad based on the length of the full address chunk (including sa_{len,family}). + // sa_len gives us that full length directly. + let padded_len = net_table::round_up(sa_len as usize, ALIGN as _); + + // But the amount to read _from here_ excludes the len/family: + let read_len = (padded_len - HEADER_LEN as usize) as u8; + + (sa_len - HEADER_LEN, read_len) + }; + + let padding_len = read_len - addr_len; + + terminated(take(addr_len), take(padding_len)).and_then({ + alt(( + cond_fail(af == Af::Link, sockaddr_dl_body()).map(|dl| Some(Address::Link(dl))), + cond_fail(af == Af::Inet, partial_sockaddr_in_body()) + .map(|addr| Some(Address::Ipv4(addr))), + cond_fail(af == Af::Inet6, partial_sockaddr_in6_body()) + .map(|(addr, scope)| Some(Address::Ipv6 { addr, scope })), + cond_fail(af == Af::Unspec, success(Some(Address::Unspecified))), + cond_fail( + af == Af::Other(0xff), + ( + opt(peek(complete(partial_sockaddr_in_body()))), + opt(complete(partial_sockaddr_in6_body())), + ), + ) + .map(|(v4, v6)| { + Some(Address::PrefixLen(PrefixLen { + v4: v4.and_then(|addr| { + let pfx = netmask_to_prefix(&addr.into()); + + if pfx.is_none() { + tracing::warn!(mask = ?addr, "reject non-prefix ipv4 netmask"); + } + + pfx + }), + v6: v6.and_then(|(addr, _)| { + let pfx = netmask_to_prefix(&addr.into()); + + if pfx.is_none() { + tracing::warn!(mask = ?addr, "reject non-prefix ipv4 netmask"); + } + + pfx + }), + })) + }), + success(None).map(move |x| { + tracing::warn!(unhandled_af = ?af); + x + }), + )) + }) + }) + } +} + +impl<'a> TryFrom<&'a Address> for IpAddr { + type Error = (); + + fn try_from(addr: &'a Address) -> Result { + match addr { + Address::Ipv4(ipv4) => Ok(IpAddr::V4(*ipv4)), + Address::Ipv6 { addr, .. } => Ok(IpAddr::V6(*addr)), + _ => Err(()), + } + } +} + +impl TryFrom
for IpAddr { + type Error = (); + + fn try_from(addr: Address) -> Result { + (&addr).try_into() + } +} + +/// An IP address prefix length. +/// +/// Each field is populated if it was possible to parse it from the input. This may be unambiguous +/// if the input was too long to be IPv4 or too short to be IPv6. +/// +/// It is done this way to make the parsing not context-sensitive (otherwise we would need a hint +/// about which kind of address we're supposed to be calculating a mask for). +/// +/// The underlying representation of the mask is an actual address netmask, which could be invalid +/// as a prefix (i.e. discontiguous). These masks are rejected and the prefix len set to `None`. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct PrefixLen { + /// IPv4 prefix length, if it could be parsed from the input. + pub v4: Option, + /// IPv6 prefix length, if it could be parsed from the input. + pub v6: Option, +} + +impl Default for PrefixLen { + fn default() -> Self { + Self { + v4: Some(32), + v6: Some(128), + } + } +} + +impl Debug for Address { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Address::Ipv4(x) => x.fmt(f), + Address::Ipv6 { addr, scope } => { + if *scope == 0 { + addr.fmt(f) + } else { + write!(f, "{addr:?}%{scope}") + } + } + Address::Link(x) => x.fmt(f), + Address::PrefixLen(x) => x.fmt(f), + Address::Unspecified => write!(f, "Unspecified"), + } + } +} + +/// Like [`nom::combinator::cond`], but fails if the condition is `false`. +/// +/// Can be used with [`alt`] to emulate a `match` statement with different parsers in each arm. +fn cond_fail(c: bool, mut parser: P) -> impl Parser +where + P: Parser, +{ + move |input| { + if c { + parser.parse(input) + } else { + fail().parse(input) + } + } +} + +/// Parse a partial `sockaddr_in`, retrieving a possibly-truncated IP address. +/// +/// It's assumed that `sin_len` and `sin_addr` have already been consumed and the input is starting +/// at the first port byte, and all trailing padding has been removed (i.e. the input ends on the +/// last byte of the address). +pub fn partial_sockaddr_in_body() -> impl Parser +where + I: nom::Input + AsBytes, + E: ParseError + FromExternalError, +{ + let port = u16(Endianness::Native); + preceded(port, partial_inaddr()) +} + +/// Parse a partial `sockaddr_in6`, retrieving a possibly-truncated IP address. +/// +/// It's assumed that `sin6_len` and `sin6_addr` have already been consumed and the input is +/// starting at the first port byte, and all trailing padding has been removed (i.e. the input ends +/// on the last byte of the address). +pub fn partial_sockaddr_in6_body() -> impl Parser +where + I: nom::Input + AsBytes + for<'a> Compare<&'a [u8]>, + E: ParseError + FromExternalError, +{ + let port = u16(Endianness::Native); + let flowinfo = u32(Endianness::Native); + + preceded((port, flowinfo), partial_in6addr()) +} + +/// Parse an IPv4 address from a possibly-truncated `in_addr`. +/// +/// The address is left-justified and can be any length up to 4 bytes, including zero; any bytes not +/// included in the address are zeroed. +pub fn partial_inaddr() -> impl Parser +where + I: nom::Input + AsBytes, + E: ParseError, +{ + take_array_zeroed::<_, _, 4>().map(|(addr, _n)| Ipv4Addr::from_octets(addr)) +} + +/// Parse an IPv6 address from a possibly-truncated `in6_addr`. +/// +/// The address is left justified and can be any length up to 16 bytes, including zero; any bytes not +/// included in the address are zeroed. The scope is only parsed if there are a full 20 bytes +/// available in the input. +/// +/// We also attempt to parse the scope from its conventional KAME embedding in an interface- or +/// link-local address. +pub fn partial_in6addr() -> impl Parser +where + I: nom::Input + AsBytes + for<'a> Compare<&'a [u8]>, + E: ParseError + FromExternalError, +{ + ( + peek(opt(complete(kame_scope()))), + take_array_zeroed::<_, _, 16>(), + opt(complete(u32(Endianness::Native))), + ) + .map(|(kame_scope, (mut octets, _n), scope)| { + let mut scope = scope.unwrap_or_default(); + + if let Some(kame_scope) = kame_scope + && kame_scope != 0 + { + if scope != 0 && scope != kame_scope as u32 { + tracing::warn!( + sockaddr_in6_scope = scope, + kame_scope, + "kame-embedded scope mismatched sockaddr_in6 field" + ); + } + + scope = kame_scope as _; + + // Erase the embedded scope. + octets[2..4].fill(0); + } + + (Ipv6Addr::from(octets), scope) + }) +} + +/// KAME stack conventionally embeds the scope in the link- or interface-local address: check +/// whether the prefix matches, then read the second segment. +fn kame_scope() -> impl Parser +where + I: nom::Input + for<'a> Compare<&'a [u8]>, + E: ParseError + FromExternalError, +{ + preceded((peek(kame_pfx()), take(2usize)), u16(Endianness::Big)) +} + +fn kame_pfx() -> impl Parser +where + I: nom::Input + for<'a> Compare<&'a [u8]>, + E: ParseError + FromExternalError, +{ + alt(( + (tag(&[0xfe][..]), masked_u8(0xc0, 0x80)), + ( + tag(&[0xff][..]), + alt((masked_u8(0x0f, 0x01), masked_u8(0x0f, 0x02))), + ), + )) + .map(|_| ()) +} + +fn masked_u8(mask: u8, expected: u8) -> impl Parser +where + I: nom::Input, + E: ParseError + FromExternalError, +{ + u8().map_res(move |x| { + if x & mask == expected { + Ok(()) + } else { + Err(()) + } + }) +} + +/// Link address. +#[derive(Clone, PartialEq, Eq)] +pub struct LinkAddr { + /// Name of this link. + /// + /// May be left empty. + pub name: String, + /// Index of this link. + pub index: u16, + /// Link type. + /// + /// According to [``], this is populated based on [RFC1573] and this + /// [IANA numbers list]. + /// + /// [``]: https://github.com/apple-oss-distributions/xnu/blob/main/bsd/net/if_types.h + /// [RFC1573]: https://datatracker.ietf.org/doc/html/rfc1573 + /// [IANA list]: https://www.iana.org/assignments/smi-numbers/smi-numbers.xhtml#smi-numbers-5 + pub ty: u8, + /// Hardware address of this link. + /// + /// May be left empty. This is often done when the link addr is only populated to give the name, + /// index, and type. + pub addr: Vec, +} + +impl Debug for LinkAddr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut fmt = f.debug_struct("LinkAddr"); + + fmt.field("name", &self.name) + .field("index", &self.index) + .field("ty", &self.ty); + + if self.addr.is_empty() { + fmt.field("addr", &format_args!("")); + } else { + fmt.field( + "addr", + &ts_hexdump::IterFmt::delimited(self.addr.iter(), ":"), + ); + } + + fmt.finish() + } +} + +/// Parse the body of a [`sockaddr_dl`][libc::sockaddr_dl]. +/// +/// Assumes that this is aligned with the start of the address block, i.e. `(sdl_len, sdl_family)` +/// are stripped, and all trailing padding is removed. +/// +/// `sockaddr_dl` is laid out like this: +/// +/// ```text +/// | index (2) | nlen (1) | alen (1) | slen (1) | name (nlen) | addr (alen) | selector (slen) | +/// ``` +pub fn sockaddr_dl_body() -> impl Parser +where + I: nom::Input + AsBytes, + E: ParseError, +{ + /// len = 0xff can mean "don't care" + const fn alias_len(len: u8) -> u8 { + if len == 0xff { + return 0; + } + + len + } + + ( + u16(Endianness::Native), + u8(), + u8().map(alias_len), + u8().map(alias_len), + u8().map(alias_len), + ) + .flat_map(|(index, ty, name_len, addr_len, selector_len)| { + ( + take::<_, I, E>(name_len), + take(addr_len), + take(selector_len), + ) + .map(move |(name, addr, selector)| { + let name = String::from_utf8_lossy(name.as_bytes()); + let addr = addr.as_bytes().to_vec(); + + let sel = selector.as_bytes(); + if !sel.is_empty() { + tracing::debug!("dropping nonempty link selector"); + } + + debug_assert!(sel.len() < ALIGN as _); + + LinkAddr { + name: name.into_owned(), + ty, + addr, + index, + } + }) + }) +} + +/// Produce a parser that populates an array of length `LEN` from the input. +/// +/// The array is zeroed initially and is filled from the left; if there are fewer than `LEN` bytes +/// available, the array is not filled completely. This is not an error. +/// +/// The second return parameter is the number of bytes actually read from the input. +fn take_array_zeroed() +-> impl Parser +where + I: nom::Input + AsBytes, + E: ParseError, +{ + rest_len.flat_map(|n| take(LEN.min(n))).map(|x: I| { + let mut out = [0u8; LEN]; + + let bs = x.as_bytes(); + out[..bs.len()].copy_from_slice(bs); + + (out, bs.len()) + }) +} + +/// Attempt to interpret `netmask` as a strict prefix, returning `Some` only if its set bits +/// are contiguous from the front. +/// +/// Returns the prefix length if the mask is valid. +pub const fn netmask_to_prefix(netmask: &IpAddr) -> Option { + let (count, leading) = match netmask { + IpAddr::V4(ip) => { + let x = ip.to_bits(); + (x.count_ones(), x.leading_ones()) + } + IpAddr::V6(ip) => { + let x = ip.to_bits(); + (x.count_ones(), x.leading_ones()) + } + }; + + if count != leading { + return None; + } + + Some(count as u8) +} + +#[cfg(test)] +mod test { + use nom::multi::count; + + use super::*; + + type E = nom::error::Error; + + #[track_caller] + fn assert_parse<'a, A>( + sample: &'a [u8], + mut parser: impl Parser<&'a [u8], Output = A, Error = E<&'a [u8]>>, + expected: A, + ) where + A: PartialEq + Debug, + { + let (rest, addr) = parser.parse(sample).unwrap(); + assert_eq!(addr, expected); + assert!(rest.is_empty()); + } + + #[test] + fn unspec_chunk() { + assert_parse(&[0, 0, 0, 0], Address::parse(), Some(Address::Unspecified)); + } + + #[test] + fn ipv4() { + assert_parse( + &[224, 0, 0, 1], + partial_inaddr(), + Ipv4Addr::new(224, 0, 0, 1), + ); + + assert_parse(&[224, 0, 0], partial_inaddr(), Ipv4Addr::new(224, 0, 0, 0)); + } + + #[test] + fn ipv4_padded() { + assert_parse( + &[0, 0, 224, 0, 0, 1], + partial_sockaddr_in_body(), + Ipv4Addr::new(224, 0, 0, 1), + ); + } + + #[tracing_test::traced_test] + #[test] + fn ipv4_chunk() { + assert_parse( + &[16u8, 2, 0, 0, 224, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0], + Address::parse(), + Some(Address::Ipv4(Ipv4Addr::new(224, 0, 0, 1))), + ); + } + + #[test] + fn ipv6_full_with_scope() { + assert_parse( + &[ + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, + 0xa5, 0xa5, 0xa5, 0xa5, + ], + partial_in6addr(), + ("1:203:405:607:809:a0b:c0d:e0f".parse().unwrap(), 0xa5a5a5a5), + ); + } + + #[test] + fn ipv6_full_no_scope() { + assert_parse( + &[ + 0x0, 0x1, 0x2, 0x3, 0x4, 0x5, 0x6, 0x7, 0x8, 0x9, 0xa, 0xb, 0xc, 0xd, 0xe, 0xf, + ], + partial_in6addr(), + ("1:203:405:607:809:a0b:c0d:e0f".parse().unwrap(), 0), + ); + } + + #[test] + fn ipv6_partial() { + assert_parse( + &[0xab, 0x00], + partial_in6addr(), + ("ab00::".parse().unwrap(), 0), + ); + + assert_parse(&[], partial_in6addr(), (Ipv6Addr::UNSPECIFIED, 0)); + } + + #[test] + fn ipv6_kame_scope() { + assert_parse( + &[0xff, 0x01, 0xab, 0xcd], + partial_in6addr(), + ("ff01::".parse().unwrap(), 0xabcd), + ); + + assert_parse( + &[0xff, 0x02, 0xab, 0xcd], + partial_in6addr(), + ("ff02::".parse().unwrap(), 0xabcd), + ); + + assert_parse( + &[0xfe, 0x80, 0xab, 0xcd], + partial_in6addr(), + ("fe80::".parse().unwrap(), 0xabcd), + ); + + // non-KAME encoding should not manipulate the address + assert_parse( + &[0xab, 0x80, 0xab, 0xcd], + partial_in6addr(), + ("ab80:abcd::".parse().unwrap(), 0x0), + ); + } + + #[tracing_test::traced_test] + #[test] + fn ipv6_chunk() { + // ff02::2:ffb2:bd7%24 (KAME scope encoding) + assert_parse( + &[ + 28, 30, 0, 0, 0, 0, 0, 0, 255, 2, 0, 24, 0, 0, 0, 0, 0, 0, 0, 2, 255, 178, 11, 215, + 0, 0, 0, 0, + ], + Address::parse(), + Some(Address::Ipv6 { + addr: "ff02::2:ffb2:bd7".parse().unwrap(), + scope: 24, + }), + ); + } + + #[tracing_test::traced_test] + #[test] + fn prefix_v4only() { + const PAYLOAD: &[u8] = &[ + 0x10, 0x2, 0x0, 0x0, 0xe0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, + 0x12, 0x17, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x0, 0x5, 0xff, 0xff, 0xff, 0xf0, 0x0, 0x0, 0x0, 0x10, 0x2, 0x0, 0x0, 0x64, 0x40, + 0x0, 0x14, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + ]; + + assert_parse( + PAYLOAD, + count(Address::parse(), 4), + vec![ + Some(Address::Ipv4([224, 0, 0, 0].into())), + Some(Address::Link(LinkAddr { + addr: vec![], + index: 23, + name: "".to_string(), + ty: 0, + })), + Some(Address::PrefixLen(PrefixLen { + v4: Some(4), + v6: None, + })), + Some(Address::Ipv4([100, 64, 0, 20].into())), + ], + ); + } + + #[tracing_test::traced_test] + #[test] + fn trailing_empty() { + // This is an actual sample address payload captured from a PF_ROUTE socket. + // + // It's supposed to have RTA_NETMASK | RTA_IFP | RTA_IFA | RTA_BRD (4 entries), but it seems + // that XNU will just omit the RTA_BRD address empty _completely_ if it's empty and is the + // trailing address (so an AF_UNSPEC placeholder entry isn't strictly required for parsing + // to succeed as it would in the middle of the sequence). + // + // You might think that this is a parsing bug and the 4 trailing bytes should be treated as + // that placeholder, but the RTA_IFA address is preceded by 0x1c 0x1e, i.e. IPv6, + // full-length. The scope bytes are unfilled because it uses the KAME embedding; there is no + // placeholder at all. We can't have a sequencing issue otherwise – that last IPv6 can't be + // the broadcast address, since IPv6 doesn't have them. + const SAMPLE: &[u8] = &[ + 0x1c, 0x1e, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x14, 0x12, 0x17, + 0x0, 0x1, 0x5, 0x0, 0x0, 0x75, 0x74, 0x75, 0x6e, 0x35, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, + 0x0, 0x1c, 0x1e, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xfe, 0x80, 0x0, 0x17, 0x0, 0x0, 0x0, + 0x0, 0x82, 0xa9, 0x97, 0xff, 0xfe, 0x19, 0xe7, 0x6f, 0x0, 0x0, 0x0, 0x0, + ]; + + let expected = vec![ + Some(Address::Ipv6 { + addr: [0xffffu16, 0xffff, 0xffff, 0xffff, 0, 0, 0, 0].into(), + scope: 0, + }), + Some(Address::Link(LinkAddr { + addr: vec![], + index: 23, + name: "utun5".to_string(), + ty: 1, + })), + Some(Address::Ipv6 { + addr: [0xfe80u16, 0, 0, 0, 0x82a9, 0x97ff, 0xfe19, 0xe76f].into(), + scope: 23, + }), + // normally expected for RTA_BRD, but omitted because of the truncation behavior (see + // note above): + // Some(Address::Unspecified), + ]; + + assert_parse(SAMPLE, count(Address::parse(), expected.len()), expected); + } +} diff --git a/ts_netmon/src/bsd/net_table/dump.rs b/ts_netmon/src/bsd/net_table/dump.rs new file mode 100644 index 00000000..5d366e60 --- /dev/null +++ b/ts_netmon/src/bsd/net_table/dump.rs @@ -0,0 +1,146 @@ +//! Functionality supporting asking the kernel to dump the route or interface table via sysctl. + +use core::{ffi::c_int, ptr::null_mut}; +use std::io; + +use libc::{CTL_NET, NET_RT_DUMP, NET_RT_IFLIST, NET_RT_IFLIST2, PF_ROUTE, size_t, sysctl}; + +use crate::FamilyOrBoth; + +/// Which table dump to request. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum DumpType { + /// Dump routes. + Route = NET_RT_DUMP as _, + /// Dump interfaces. + Interface = NET_RT_IFLIST as _, + + #[cfg(target_os = "macos")] + /// Dump routes in the macOS-specific [`Route2`][crate::bsd::net_table::Route2] format. + // aka `NET_RT_DUMP2`, which isn't defined in rust libc, pending a release including + // https://github.com/rust-lang/libc/pull/5442 + Route2 = 7, + #[cfg(target_os = "macos")] + /// Dump interfaces in the macOS-specific [`Interface2`][crate::bsd::net_table::Interface2] + /// format. + Interface2 = NET_RT_IFLIST2 as _, +} + +impl DumpType { + /// Report all [`DumpType`]s. + pub fn all() -> &'static [Self] { + cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + &[ + DumpType::Route, + DumpType::Route2, + DumpType::Interface, + DumpType::Interface2, + ] + } else { + &[ + DumpType::Route, + DumpType::Interface, + ] + } + } + } +} + +/// Dump the given net table. +/// +/// `arg` is the final (6th) argument to the sysctl, whose meaning is dependent on the +/// `DumpType`, typically filtering the dump by a specific parameter. Zero typically means +/// "give me everything". See your system's `man sysctl` +/// ([FreeBSD's, e.g.](https://man.freebsd.org/cgi/man.cgi?query=sysctl&sektion=3)) for more +/// details: the meanings of these parameters are known to differ between BSD kernels. +pub fn dump(af: FamilyOrBoth, ty: DumpType, arg: c_int) -> io::Result> { + let mut mib_name = [CTL_NET, PF_ROUTE, 0, af.into(), ty as _, arg]; + let mut buf = vec![]; + let mut err: io::Error = io::ErrorKind::Other.into(); + + // There can be a race that can cause reading the MIB to fail, so we retry a few times. See the + // comment below for why this may occur. + for _ in 0..3 { + let mut n = get_mib_size(&mut mib_name[..])?; + if n == 0 { + return Ok(vec![]); + }; + + buf.resize(n, 0); + + // SAFETY: this is the correct way to hold `sysctl`. See `man 3 sysctl`. + let ret = unsafe { + sysctl( + mib_name.as_mut_ptr(), + mib_name.len() as _, + buf.as_mut_ptr() as *mut _, + &mut n, + null_mut(), + 0, + ) + }; + + // It's possible that the MIB can change size substantially between get_mib_size and + // the above sysctl invocation. The kernel optimistically tries to overestimate the MIB size + // so that it doesn't fail in this way, but this may still occur. The kernel reports ENOMEM + // if the error was for this reason (not enough space in the buffer). + if ret < 0 { + err = io::Error::last_os_error(); + + // Retry if ENOMEM, else bail immediately. + if err.kind() == io::ErrorKind::OutOfMemory { + continue; + } + + return Err(err); + } + + buf.truncate(n as usize); + + return Ok(buf); + } + + Err(err) +} + +/// Get the size of the buffer required to hold the MIB table referenced by `mib_name`. +fn get_mib_size(mib_name: &mut [c_int]) -> io::Result { + let mut n: size_t = 0; + + // Per macOS `man 3 sysctl`: + // + // >The size of the available data can be determined by calling sysctl() with the NULL + // >argument for oldp. The size of the available data will be returned in the location + // >pointed to by oldlenp. + // + // SAFETY: this is the correct way to hold `sysctl`. See `man 3 sysctl`. + let ret = unsafe { + sysctl( + mib_name.as_mut_ptr(), + mib_name.len() as _, + null_mut(), + &mut n, + null_mut(), + 0, + ) + }; + if ret < 0 { + return Err(io::Error::last_os_error()); + } + + Ok(n) +} + +#[cfg(test)] +mod test { + use super::*; + + /// Just the dump sysctl wrapper to ensure it doesn't error. + #[test] + fn dump() { + for &ty in DumpType::all() { + super::dump(FamilyOrBoth::Both, ty, 0).unwrap(); + } + } +} diff --git a/ts_netmon/src/bsd/net_table/flags.rs b/ts_netmon/src/bsd/net_table/flags.rs new file mode 100644 index 00000000..99b479be --- /dev/null +++ b/ts_netmon/src/bsd/net_table/flags.rs @@ -0,0 +1,232 @@ +use core::{ + ffi::c_uint, + fmt::{Debug, Formatter}, +}; + +use crate::bsd::net_table::CUint; + +/// Helper type which packs (addrs, flags) in that order and provides accessors for the +/// [`Flags`] and [`Addrs`] bitflags. +/// +/// See [`FlagsAddrs`] for the other field order. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::KnownLayout, + zerocopy::IntoBytes, + zerocopy::FromBytes, + zerocopy::Unaligned, + Default, +)] +#[repr(C)] +pub struct AddrsFlags { + addrs: CUint, + flags: CUint, +} + +impl Debug for AddrsFlags { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("AddrsFlags") + .field("addrs", &self.addrs()) + .field("flags", &self.flags()) + .finish() + } +} + +impl AddrsFlags { + /// [`Flags`] set on this message. + pub const fn flags(&self) -> Flags { + Flags::from_bits_retain(self.flags.get()) + } + + /// Set the [`Flags`]. + pub const fn set_flags(&mut self, flags: Flags) { + self.flags = CUint::new(flags.bits()); + } + + /// Addresses present in this message. + pub const fn addrs(&self) -> Addrs { + Addrs::from_bits_retain(self.addrs.get()) + } + + /// Set the [`Addrs`]. + pub const fn set_addrs(&mut self, addrs: Addrs) { + self.addrs = CUint::new(addrs.bits()); + } +} + +/// Helper type which packs (flags, addrs) in that order and provides accessors for the [`Flags`] +/// and [`Addrs`] bitflags. +/// +/// See [`AddrsFlags`] for the other field order. +#[derive( + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::KnownLayout, + zerocopy::IntoBytes, + zerocopy::FromBytes, + zerocopy::Unaligned, + Default, +)] +#[repr(C)] +pub struct FlagsAddrs { + flags: CUint, + addrs: CUint, +} + +impl Debug for FlagsAddrs { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FlagsAddrs") + .field("flags", &self.flags()) + .field("addrs", &self.addrs()) + .finish() + } +} + +impl FlagsAddrs { + /// [`Flags`] set on this message. + pub const fn flags(&self) -> Flags { + Flags::from_bits_retain(self.flags.get()) + } + + /// Set the [`Flags`]. + pub const fn set_flags(&mut self, flags: Flags) { + self.flags = CUint::new(flags.bits()); + } + + /// Addresses present in this message. + pub const fn addrs(&self) -> Addrs { + Addrs::from_bits_retain(self.addrs.get()) + } + + /// Set the [`Addrs`]. + pub const fn set_addrs(&mut self, addrs: Addrs) { + self.addrs = CUint::new(addrs.bits()); + } +} + +bitflags::bitflags! { + /// Flags indicating the address types present in a message. + #[derive( + Debug, Copy, Clone, PartialEq, Eq, + )] + pub struct Addrs: c_uint { + // NOTE(npry): the specific form of these declarations is a load-bearing part of this API; + // the addresses in a PF_ROUTE message are present in _this specific order_ if the relevant + // bit is set, and the generated `.iter()` iterates them in declaration order. It is relied + // upon elsewhere to be correct. + + /// Route destination is present. + const DESTINATION = libc::RTA_DST as _; + /// Route gateway is present. + const GATEWAY = libc::RTA_GATEWAY as _; + /// Netmask is present. + const NETMASK = libc::RTA_NETMASK as _; + /// Netmask for child (clone) routes of this route. + const GENMASK = libc::RTA_GENMASK as _; + /// The interface name is present. + const INTERFACE_NAME = libc::RTA_IFP as _; + /// THe interface address is present. + const INTERFACE_ADDR = libc::RTA_IFA as _; + /// If this route was authorized by a downstream gateway, its address is present. + const REDIRECT_AUTHORIZER = libc::RTA_AUTHOR as _; + /// The broadcast address is present. + const BROADCAST_ADDR = libc::RTA_BRD as _; + } +} + +impl Default for Addrs { + fn default() -> Self { + Addrs::empty() + } +} + +bitflags::bitflags! { + /// Flags set on a given route message. + #[derive( + Debug, Copy, Clone, PartialEq, Eq, Default, + )] + pub struct Flags: c_uint { + /// The route is active. + const UP = libc::RTF_UP as _; + /// The route is inactive, typically because the underlying interface has been deleted or + /// gone inactive and this route is going to be deleted. + const DEAD = libc::RTF_DEAD as _; + /// Route is pending deletion but has active references. + const CONDEMNED = libc::RTF_CONDEMNED as _; + + /// This route will keep the corresponding network interface alive if it would otherwise be + /// deleted. + const IFREF = libc::RTF_IFREF as _; + /// Prevent this route from keeping the corresponding interface alive (don't increment its + /// refcount). + const NO_IFREF = libc::RTF_NOIFREF as _; + + /// Whether this route has a gateway/next-hop or is on-link. + const GATEWAY = libc::RTF_GATEWAY as _; + /// The destination of this route is for a single host. + const HOST = libc::RTF_HOST as _; + /// The destination is an address on this host. + const LOCAL = libc::RTF_LOCAL as _; + /// The destination is a broadcast address. + const BROADCAST = libc::RTF_BROADCAST as _; + /// The destination is a multicast address. + const MULTICAST = libc::RTF_MULTICAST as _; + /// The destination is a next-hop router. + const ROUTER = libc::RTF_ROUTER as _; + /// Route leads to the public internet. + const GLOBAL = libc::RTF_GLOBAL as _; + + /// The destination is unreachable; ICMP unreachables are returned. + const REJECT = libc::RTF_REJECT as _; + /// Packets matching this route are silently dropped. + const BLACKHOLE = libc::RTF_BLACKHOLE as _; + + /// The route was configured administratively. + const STATIC = libc::RTF_STATIC as _; + /// Route is administratively pinned and can't be modified by a routing protocol. + const PINNED = libc::RTF_PINNED as _; + /// The route was created dynamically. + const DYNAMIC = libc::RTF_DYNAMIC as _; + /// This route was modified dynamically. + const MODIFIED = libc::RTF_MODIFIED as _; + + /// The AF_ROUTE transaction this message is associated with is complete. + const DONE = libc::RTF_DONE as _; + + /// This route has generic link-layer cloning behavior, typically just generating dynamic + /// layer 2 routes from the ARP cache. + const CLONING = libc::RTF_CLONING as _; + /// Protocol-specific cloning behavior, i.e. like [`Flags::CLONING`] except that the L3 + /// network stack is ultimately responsible for creating the entry. + const PRCLONING = libc::RTF_PRCLONING as _; + /// This route was cloned from a parent. + const WAS_CLONED = libc::RTF_WASCLONED as _; + /// Child clone routes are proactively purged by the kernel if this route (the parent) is + /// deleted. + const DELCLONE = libc::RTF_DELCLONE as _; + /// This is a layer 2 route, i.e. an ARP table or ND entry. + const LLINFO = libc::RTF_LLINFO as _; + /// This is a proxy ARP route for the destination. + const PROXY_ARP = libc::RTF_PROXY as _; + + /// This route is resolved externally through a userspace routing daemon. + const XRESOLVE = libc::RTF_XRESOLVE as _; + + /// This is an interface-scoped route. + const IFSCOPE = libc::RTF_IFSCOPE as _; + + /// Flag for protocol-specific use with implementation-defined meaning. + const PROTO1 = libc::RTF_PROTO1 as _; + /// Flag for protocol-specific use with implementation-defined meaning. + const PROTO2 = libc::RTF_PROTO2 as _; + /// Flag for protocol-specific use with implementation-defined meaning. + const PROTO3 = libc::RTF_PROTO3 as _; + } +} diff --git a/ts_netmon/src/bsd/net_table/interface_hdr.rs b/ts_netmon/src/bsd/net_table/interface_hdr.rs new file mode 100644 index 00000000..abbb462b --- /dev/null +++ b/ts_netmon/src/bsd/net_table/interface_hdr.rs @@ -0,0 +1,419 @@ +//! Headers for interface table messages. + +use core::{ffi::c_uchar, fmt::Debug}; + +use libc::{suseconds_t, time_t}; +use zerocopy::{ + Unalign, + native_endian::{I32, U32, U64}, +}; + +use crate::bsd::net_table::{CInt, CUshort, Header, PadUshort, flags::AddrsFlags}; + +/// Header describing an interface. +/// +/// macOS-specific extension which includes additional information. Indicated by the +/// [`MessageType::IfInfo2`][crate::bsd::net_table::MessageType::IfInfo2] (`RTM_IFINFO2`) message +/// type. +/// +/// Called [`if_msghdr2`][libc::if_msghdr2] in libc. +#[cfg(target_os = "macos")] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct Interface2 { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: AddrsFlags, + /// The index of this interface. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// Send-queue current length. + pub snd_len: CInt, + /// Send-queue maximum length. + pub snd_maxlen: CInt, + /// Send-queue packet drops. + pub snd_drops: CInt, + /// Watchdog timer value. + pub timer: CInt, + /// Additional data for this interface. + pub data: InterfaceData64, +} + +#[cfg(target_os = "macos")] +static_assertions::assert_eq_size!(Interface2, libc::if_msghdr2); + +/// Header describing an interface. +/// +/// Describes a physical interface, commonly followed by a +/// [`LinkAddr`][crate::bsd::net_table::LinkAddr] bearing its name. +/// +/// Called [`if_msghdr`][libc::if_msghdr] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct Interface { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: AddrsFlags, + /// The index of this interface. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// Additional data for this interface. + pub data: InterfaceData, +} + +static_assertions::assert_eq_size!(Interface, libc::if_msghdr); + +/// Additional data associated with a given interface. +/// +/// [`if_data`][libc::if_data] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::FromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct InterfaceData { + /// Layer 2 type. + pub ty: c_uchar, + /// Seemingly vestigial field, unused. + pub _typelen: c_uchar, + /// Physical layer type. + pub physical: c_uchar, + /// Media address length. + pub addrlen: c_uchar, + /// Media header length. + pub hdrlen: c_uchar, + /// Polling quota for receive interrupts. + pub recvquota: c_uchar, + /// Polling quota for transmit interrupts. + pub xmitquota: c_uchar, + /// Unused field. + pub _unused1: c_uchar, + /// MTU for this interface. + pub mtu: U32, + /// Routing metric for this interface. + pub metric: U32, + /// The line rate for this interface. + pub baudrate: U32, + /// Incoming packet counter. + pub ipackets: U32, + /// Incoming error counter. + pub ierrors: U32, + /// Outgoing packet counter. + pub opackets: U32, + /// Outgoing error counter. + pub oerrors: U32, + /// Collision counter. + pub collisions: U32, + /// Incoming byte counter. + pub ibytes: U32, + /// Outgoing byte counter. + pub obytes: U32, + /// Incoming multicast packet counter. + pub imcasts: U32, + /// Outgoing multicast packet counter. + pub omcasts: U32, + /// Packets dropped on input on this interface. + pub iqdrops: U32, + /// Packets with unsupported protocol. + pub noproto: U32, + /// Cumulative time spent receiving (usec). + pub recvtiming: U32, + /// Cumulative time spent transmitting (usec). + pub xmittiming: U32, + /// Timestamp of the last change to the interface. + pub lastchange: Time32, + /// Unused field. + pub _unused2: U32, + /// Hardware offload support (flags). + pub hwassist: U32, + /// Reserved field. + pub _reserved1: U32, + /// Reserved field. + pub _reserved2: U32, +} + +/// 64-bit version of [`InterfaceData`]; additional data associated with an interface. +/// +/// macOS-specific, only appears in [`Interface2`]. +/// +/// Called [`if_data64`][libc::if_data64] in libc. +#[cfg(target_os = "macos")] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::FromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct InterfaceData64 { + /// Layer 2 type. + pub ty: c_uchar, + /// Seemingly vestigial field, unused. + pub _typelen: c_uchar, + /// Physical layer type. + pub physical: c_uchar, + /// Media address length. + pub addrlen: c_uchar, + /// Media header length. + pub hdrlen: c_uchar, + /// Polling quota for receive interrupts. + pub recvquota: c_uchar, + /// Polling quota for transmit interrupts. + pub xmitquota: c_uchar, + /// Unused field. + pub _unused1: c_uchar, + /// MTU for this interface. + pub mtu: U32, + /// Routing metric for this interface. + pub metric: U32, + /// The line rate for this interface. + pub baudrate: U64, + /// Incoming packet counter. + pub ipackets: U64, + /// Incoming error counter. + pub ierrors: U64, + /// Outgoing packet counter. + pub opackets: U64, + /// Outgoing error counter. + pub oerrors: U64, + /// Collision counter. + pub collisions: U64, + /// Incoming byte counter. + pub ibytes: U64, + /// Outgoing byte counter. + pub obytes: U64, + /// Incoming multicast packet counter. + pub imcasts: U64, + /// Outgoing multicast packet counter. + pub omcasts: U64, + /// Packets dropped on input on this interface. + pub iqdrops: U64, + /// Packets with unsupported protocol. + pub noproto: U64, + /// Time spent transmitting (usec). + pub recvtiming: U32, + /// Time spent receiving (usec). + pub xmittiming: U32, + #[cfg(target_pointer_width = "32")] + /// Timestamp of the last change to the interface. + pub ifi_lastchange: Time, + #[cfg(not(target_pointer_width = "32"))] + /// Timestamp of the last change to the interface. + pub ifi_lastchange: Time32, +} + +#[cfg(target_os = "macos")] +static_assertions::assert_eq_size!(InterfaceData64, libc::if_data64); + +/// A point-in-time. +/// +/// [`timeval`][libc::timeval] in libc. +#[derive( + Copy, + Clone, + zerocopy::FromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct Time { + /// Seconds part. + pub sec: Unalign, + /// Microseconds part. + pub usec: Unalign, +} + +impl Debug for Time { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("Time") + .field("sec", &self.sec.get()) + .field("usec", &self.usec.get()) + .finish() + } +} + +impl PartialEq for Time { + fn eq(&self, other: &Self) -> bool { + self.sec.get() == other.sec.get() && self.usec.get() == other.usec.get() + } +} + +impl Eq for Time {} + +/// A point-in-time, clamped to 32-bit fields. +/// +/// This is a macOS-specific struct used to control the size of a time struct based on the platform +/// pointer width, used in [`InterfaceData64`]. +/// +/// Called [`timeval32`][libc::timeval32] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::FromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +#[cfg(target_os = "macos")] +pub struct Time32 { + /// Seconds part. + pub sec: I32, + /// Microseconds part. + pub usec: I32, +} + +/// Message attributing an address +/// ([`Addrs::INTERFACE_ADDR`][crate::bsd::net_table::Addrs::INTERFACE_ADDR]) to a given interface. +/// +/// Called [`ifa_msghdr`][libc::ifa_msghdr] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct InterfaceAddr { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: AddrsFlags, + /// The index of the interface this message pertains to. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// Metric for this address as a next-hop. + pub metric: CInt, +} + +static_assertions::assert_eq_size!(InterfaceAddr, libc::ifa_msghdr); + +/// Message attributing a multicast address +/// ([`Addrs::INTERFACE_ADDR`][crate::bsd::net_table::Addrs::INTERFACE_ADDR]) to a given interface. +/// +/// Called [`ifma_msghdr`][libc::ifma_msghdr] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct MulticastAddr { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: AddrsFlags, + /// The index of the interface this message pertains to. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, +} + +static_assertions::assert_eq_size!(MulticastAddr, libc::ifma_msghdr); + +/// Message attributing a multicast address +/// ([`Addrs::INTERFACE_ADDR`][crate::bsd::net_table::Addrs::INTERFACE_ADDR]) to a given interface. +/// +/// This is a macOS-specific extension that includes a `refcount` field. +/// +/// [`ifma_msghdr2`][libc::ifma_msghdr2] in libc. +#[cfg(target_os = "macos")] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Immutable, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct MulticastAddr2 { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: AddrsFlags, + /// The index of the interface this message pertains to. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// Refcount for this address. + pub refcount: I32, +} + +#[cfg(target_os = "macos")] +static_assertions::assert_eq_size!(MulticastAddr2, libc::ifma_msghdr2); diff --git a/ts_netmon/src/bsd/net_table/message_header.rs b/ts_netmon/src/bsd/net_table/message_header.rs new file mode 100644 index 00000000..2692da34 --- /dev/null +++ b/ts_netmon/src/bsd/net_table/message_header.rs @@ -0,0 +1,131 @@ +use nom::{AsBytes, IResult}; +use zerocopy::TryFromBytes; + +use crate::bsd::net_table::{ + Addrs, Flags, Header, Interface, Interface2, InterfaceAddr, MessageType, MulticastAddr, + MulticastAddr2, Route, Route2, +}; + +/// Utility enum covering all `PF_ROUTE` message header variants. +/// +/// Note that many [`MessageType`]s map to the same [`MessageHeader`] variants: the type indicates +/// the semantics, while [`MessageHeader`] discriminates on the actual structural encoding of the +/// message header. +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub enum MessageHeader<'a> { + /// This is a [`Route2`] message. + Route2(&'a Route2), + /// This is a [`Route`] message. + Route(&'a Route), + /// This is an [`Interface2`] message. + Interface2(&'a Interface2), + /// This is an [`Interface`] message. + Interface(&'a Interface), + /// This is an [`InterfaceAddr`] message. + InterfaceAddr(&'a InterfaceAddr), + /// This is a [`MulticastAddr2`] message. + MulticastAddr2(&'a MulticastAddr2), + /// This is a [`MulticastAddr`] message. + MulticastAddr(&'a MulticastAddr), +} + +impl<'a> MessageHeader<'a> { + /// Parse a message header from the input according to the indicated type byte. + pub fn parse(input: &'a [u8]) -> IResult<&'a [u8], (MessageType, Self)> { + let (header, _rest) = + Header::try_ref_from_prefix(input.as_bytes()).map_err(nom_cast_err)?; + + let (hdr, rest) = match header.ty { + MessageType::Get2 => Route2::try_ref_from_prefix(input.as_bytes()) + .map(|(r2, rest)| (MessageHeader::Route2(r2), rest)) + .map_err(nom_cast_err), + + MessageType::IfInfo2 => Interface2::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::Interface2(r), rest)) + .map_err(nom_cast_err), + + MessageType::Get + | MessageType::Add + | MessageType::Delete + | MessageType::Change + | MessageType::Lock + | MessageType::Losing + | MessageType::Miss + | MessageType::Resolve + | MessageType::Redirect => Route::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::Route(r), rest)) + .map_err(nom_cast_err), + + MessageType::IfInfo => Interface::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::Interface(r), rest)) + .map_err(nom_cast_err), + MessageType::NewAddr | MessageType::DelAddr => { + InterfaceAddr::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::InterfaceAddr(r), rest)) + .map_err(nom_cast_err) + } + MessageType::NewMaddr | MessageType::DelMaddr => { + MulticastAddr::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::MulticastAddr(r), rest)) + .map_err(nom_cast_err) + } + MessageType::NewMaddr2 => MulticastAddr2::try_ref_from_prefix(input.as_bytes()) + .map(|(r, rest)| (MessageHeader::MulticastAddr2(r), rest)) + .map_err(nom_cast_err), + }?; + + let diff = input.len() - rest.len(); + let rest = &input[diff..]; + + Ok((rest, (header.ty, hdr))) + } + + /// Get the message header. + pub const fn header(&self) -> Header { + match self { + Self::Route2(r) => r.header, + Self::Route(r) => r.header, + Self::Interface2(i) => i.header, + Self::Interface(i) => i.header, + Self::InterfaceAddr(i) => i.header, + Self::MulticastAddr(i) => i.header, + Self::MulticastAddr2(i) => i.header, + } + } + + /// Get the address flags for the contained message. + pub const fn addrs(&self) -> Addrs { + match self { + Self::Route2(r) => r.flag_block.addrs(), + Self::Route(r) => r.flag_block.addrs(), + Self::Interface2(i) => i.flag_block.addrs(), + Self::Interface(i) => i.flag_block.addrs(), + Self::InterfaceAddr(i) => i.flag_block.addrs(), + Self::MulticastAddr(i) => i.flag_block.addrs(), + Self::MulticastAddr2(i) => i.flag_block.addrs(), + } + } + + /// Get the flags for the contained message. + pub const fn flags(&self) -> Flags { + match self { + Self::Route2(r) => r.flag_block.flags(), + Self::Route(r) => r.flag_block.flags(), + Self::Interface2(i) => i.flag_block.flags(), + Self::Interface(i) => i.flag_block.flags(), + Self::InterfaceAddr(i) => i.flag_block.flags(), + Self::MulticastAddr(i) => i.flag_block.flags(), + Self::MulticastAddr2(i) => i.flag_block.flags(), + } + } +} + +fn nom_cast_err(e: zerocopy::TryCastError<&[u8], D>) -> nom::Err> +where + D: ?Sized + TryFromBytes, +{ + nom::Err::Error(nom::error::Error::new( + e.into_src(), + nom::error::ErrorKind::MapRes, + )) +} diff --git a/ts_netmon/src/bsd/net_table/message_type.rs b/ts_netmon/src/bsd/net_table/message_type.rs new file mode 100644 index 00000000..80f911b2 --- /dev/null +++ b/ts_netmon/src/bsd/net_table/message_type.rs @@ -0,0 +1,60 @@ +/// Type of a `PF_ROUTE` message indicated in the header's type field. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::Unaligned, + zerocopy::KnownLayout, + zerocopy::IntoBytes, + zerocopy::TryFromBytes, +)] +#[repr(u8)] +pub enum MessageType { + /// Add the given route. + Add = libc::RTM_ADD as _, + /// Delete the given route. + Delete = libc::RTM_DELETE as _, + /// Modify the given route. + Change = libc::RTM_CHANGE as _, + + /// This is a request to get a specific route or a message from the kernel populated by a + /// [`Route`][crate::bsd::net_table::Route]. + Get = libc::RTM_GET as _, + /// This is a request to get a specific route or a message from the kernel populated by a + /// [`Route2`][crate::bsd::net_table::Route2]. + Get2 = libc::RTM_GET2 as _, + /// Could not find a matching route for traffic. + Miss = libc::RTM_MISS as _, + + /// Traffic using this route appears to be dropping packets. + Losing = libc::RTM_LOSING as _, + /// We have received an ICMP redirect for a destination. + Redirect = libc::RTM_REDIRECT as _, + + /// Request to lock route attributes against modification by dynamically-learned routing info. + Lock = libc::RTM_LOCK as _, + /// Kernel requests userspace resolution of this route. + Resolve = libc::RTM_RESOLVE as _, + + /// An interface has a new address. + NewAddr = libc::RTM_NEWADDR as _, + /// An address has been deleted from an interface. + DelAddr = libc::RTM_DELADDR as _, + /// Info about a given interface. + /// + /// Typically sent when the link status changes. + IfInfo = libc::RTM_IFINFO as _, + /// Info about a given interface. + /// + /// Typically sent when the link status changes. + IfInfo2 = libc::RTM_IFINFO2 as _, + /// Interface joined a multicast group. + NewMaddr = libc::RTM_NEWMADDR as _, + /// Interface joined a multicast group. + NewMaddr2 = libc::RTM_NEWMADDR2 as _, + /// Interface left a multicast group. + DelMaddr = libc::RTM_DELMADDR as _, +} diff --git a/ts_netmon/src/bsd/net_table/mod.rs b/ts_netmon/src/bsd/net_table/mod.rs new file mode 100644 index 00000000..af088283 --- /dev/null +++ b/ts_netmon/src/bsd/net_table/mod.rs @@ -0,0 +1,205 @@ +//! RIB (route table) and IFMIB (interface table) fetchers and parsers for BSD. +//! +//! Inspired by and substantially borrowed from , but implemented +//! using a mix of rust's [`zerocopy`] and [`nom`] libraries. +//! +//! Currently, this module targets macOS specifically (other BSDs are known not to work), but the +//! functionality here is expected to expand to FreeBSD (at least) eventually, as it has the same +//! `PF_ROUTE` socket and sysctl mechanisms. +//! +//! Many of the struct definitions in the submodules are repeated from [`libc`]'s BSD/macOS +//! definitions – this is to enable [`zerocopy`] derives, cleaner field names, and inherent +//! utility methods. +//! +//! Additionally, these modules support unaligned zerocopy-casts of the underlying structures, as it +//! isn't necessarily guaranteed that the buffer from which these messages are parsed will be +//! 4-byte-aligned in the first place, which the [`libc`] structs assume. +//! +//! ## Functional overview +//! +//! The BSD net subsystem in XNU answers `PF_ROUTE` queries made via a sysctl (see [`dump`]) or +//! through a `PF_ROUTE` socket ([`RouteSocket`][crate::bsd::RouteSocket]). You can ask for both +//! actual routing table (RIB) entries and the table of network interfaces (IFMIB) through the +//! syscall; the socket just dumps everything as it changes. +//! +//! The messages are framed in a TLV [`Header`]. Each message type then has its own type-specific +//! inner header, and it is followed by a variable number of variable-length address +//! entries. The addresses present in a given message are indicated by an [`Addrs`] bitflag word; +//! they follow according to the order of the bits set in the flag word. E.g. if an `RTM_GET2` +//! message ([`Route2`]) were followed by `DESTINATION`, `GATEWAY`, and `NETMASK` addresses, these +//! bits would be set in its `addrs` word. +//! +//! The addrs can be parsed without knowledge of the flag word: they are just successive +//! 4-byte-aligned `sockaddr` structures, which are effectively TLVs: `sa_len` and the AF tell us +//! how to interpret the rest of the structure. Address parsing is implemented in the `addr` +//! module. +//! +//! The route and interface header structures can be found in the `route_hdr` and +//! `interface_hdr` modules, respectively. + +use core::{ffi::c_int, fmt::Debug}; + +use libc::{AF_INET, AF_INET6, AF_LINK, AF_UNIX, AF_UNSPEC}; +use nom::{Parser, combinator::peek, number::Endianness}; +use zerocopy::{NativeEndian, U16}; + +use crate::{Family, FamilyOrBoth}; + +mod addr; +mod dump; +mod flags; +mod interface_hdr; +mod message_header; +mod message_type; +mod route_hdr; + +pub use addr::{ + Address, LinkAddr, PrefixLen, netmask_to_prefix, partial_in6addr, partial_inaddr, + sockaddr_dl_body, +}; +pub use dump::{DumpType, dump}; +pub use flags::{Addrs, AddrsFlags, Flags, FlagsAddrs}; +pub use interface_hdr::{ + Interface, Interface2, InterfaceAddr, InterfaceData, InterfaceData64, MulticastAddr, + MulticastAddr2, Time, Time32, +}; +pub use message_header::MessageHeader; +pub use message_type::MessageType; +pub use route_hdr::{Route, Route2}; + +type CUint = zerocopy::U32; +type CInt = zerocopy::I32; +type CUshort = U16; + +const PAD_USHORT: usize = 4usize.strict_sub(size_of::()); +/// Padding to align a ushort with a 4-byte boundary. +pub type PadUshort = [u8; PAD_USHORT]; + +static_assertions::assert_eq_size!(CUint, core::ffi::c_uint); +static_assertions::assert_eq_size!(CInt, c_int); +static_assertions::assert_eq_size!(CUshort, core::ffi::c_ushort); + +cfg_if::cfg_if! { + if #[cfg(target_os = "macos")] { + /// Message alignment for `PF_ROUTE` sockets in macOS is 4 bytes. + const ALIGN: u8 = 4; + } +} + +impl From for c_int { + fn from(value: Family) -> Self { + match value { + Family::Ipv4 => AF_INET, + Family::Ipv6 => AF_INET6, + } + } +} + +impl From for c_int { + fn from(value: FamilyOrBoth) -> Self { + match value { + FamilyOrBoth::Both => AF_UNSPEC, + FamilyOrBoth::Single(family) => family.into(), + } + } +} + +/// Parse the message header for the length field and take that many bytes. +/// +/// The chunks include the length field, as the `{rt,if}_*hdr` structs canonically include it. +pub fn msg_chunk() -> impl Parser> +where + I: nom::Input, +{ + nom::multi::length_data(peek(nom::number::u16(Endianness::Native))) +} + +/// `PF_ROUTE` message header. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::KnownLayout, + zerocopy::IntoBytes, + zerocopy::TryFromBytes, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct Header { + /// Length of this message (including the header). + pub len: U16, + /// Version of the message. + pub version: u8, + /// Message type. + pub ty: MessageType, +} + +impl Default for Header { + fn default() -> Self { + Self { + len: 0u16.into(), + version: 5, + ty: MessageType::Get, + } + } +} + +/// Address families. +/// +/// Provided as an enum just for cleaner debug output (so they're named rather than being numbers). +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +#[repr(isize)] +pub enum Af { + /// Unspecified address family. + Unspec = AF_UNSPEC as _, + /// IPv4. + Inet = AF_INET as _, + /// IPv6. + Inet6 = AF_INET6 as _, + /// Unix socket. + Unix = AF_UNIX as _, + /// L2 address family. + Link = AF_LINK as _, + /// Catchall for other address families. + Other(isize), +} + +impl From for Af { + fn from(value: CInt) -> Self { + match value.get() { + AF_UNSPEC => Self::Unspec, + AF_INET => Self::Inet, + AF_INET6 => Self::Inet6, + AF_UNIX => Self::Unix, + AF_LINK => Self::Link, + other => Self::Other(other as _), + } + } +} + +impl From for CInt { + fn from(value: Af) -> Self { + CInt::new(match value { + Af::Unspec => AF_UNSPEC as _, + Af::Inet => AF_INET as _, + Af::Inet6 => AF_INET6 as _, + Af::Unix => AF_UNIX as _, + Af::Link => AF_LINK as _, + Af::Other(x) => x as _, + }) + } +} + +/// Round up `len` to the next multiple of `align`. +/// +/// # Panics +/// +/// If `align` is not a power of 2. +const fn round_up(len: usize, align: usize) -> usize { + assert!(align.is_power_of_two()); + + (len + (align - 1)) & !(align - 1) +} diff --git a/ts_netmon/src/bsd/net_table/route_hdr.rs b/ts_netmon/src/bsd/net_table/route_hdr.rs new file mode 100644 index 00000000..da2040cb --- /dev/null +++ b/ts_netmon/src/bsd/net_table/route_hdr.rs @@ -0,0 +1,203 @@ +//! Headers for route messages. + +use core::fmt::Debug; + +use libc::pid_t; +use zerocopy::{ + Unalign, + native_endian::{I32, U32}, +}; + +use crate::bsd::net_table::{CInt, CUint, CUshort, Flags, FlagsAddrs, Header, PadUshort}; + +/// Encodes information about a route. +/// +/// macOS-specific extension of [`Route`] with some additional information. +/// +/// [`rt_msghdr2`][libc::rt_msghdr2] in libc. +#[cfg(target_os = "macos")] +#[derive( + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Unaligned, +)] +#[repr(C)] +pub struct Route2 { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// The index of the interface this route pertains to. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: FlagsAddrs, + /// Kernel refcount for this route. + pub refcount: I32, + /// Flags set on this message's parent, if it was cloned. + /// + /// See [`Route2::parent_flags`] for the interpretation of the field as [`Flags`]. + pub _parent_flags: U32, + /// Reserved region. + pub _reserved: CInt, + /// Usage counter for this route (number of packets sent using the route). + pub use_: CInt, + /// Bitmask indicating which metrics to init/update (userspace -> kernel). + pub inits: U32, + /// Metrics for this route. + pub metrics: Metrics, +} + +#[cfg(target_os = "macos")] +static_assertions::assert_eq_size!(Route2, libc::rt_msghdr2); + +#[cfg(target_os = "macos")] +impl Debug for Route2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Route2") + .field("header", &self.header) + .field("index", &self.index) + .field("flag_block", &self.flag_block) + .field("refcount", &self.refcount) + .field("parent_flags", &self.parent_flags()) + .field("use", &self.use_) + .field("inits", &self.inits) + .field("metrics", &self.metrics) + .finish() + } +} + +#[cfg(target_os = "macos")] +impl Route2 { + /// [`Flags`] set on this message's parent, if it was cloned. + pub const fn parent_flags(&self) -> Flags { + Flags::from_bits_retain(self._parent_flags.get()) + } +} + +/// Message header encoding information about a route. +/// +/// [`rt_msghdr`][libc::rt_msghdr] in libc. +#[derive( + Copy, + Clone, + zerocopy::Immutable, + zerocopy::IntoBytes, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Unaligned, + Default, +)] +#[repr(C)] +pub struct Route { + /// The header for this message. + /// + /// It's included primarily because it includes the length of the whole message, which + /// can be used to deduce the space used by sockaddrs following this header. + pub header: Header, + /// The index of the interface this route pertains to. + pub index: CUshort, + /// Padding (required for [`zerocopy::Unaligned`]). + pub _pad: PadUshort, + /// [`Addrs`][crate::bsd::net_table::Addrs] and [`Flags`][crate::bsd::net_table::Flags] + /// for this message. + pub flag_block: FlagsAddrs, + /// The pid of the process originating this message. + pub pid: Unalign, + /// Sequence number of the message. + pub seq: CUint, + /// Nonzero error number if the operation corresponding to `seq` failed. + pub errno: CInt, + /// Usage counter for this route (number of lookup hits for the route). + pub use_: CUint, + /// Bitmask indicating which metrics to init/update (userspace -> kernel). + pub inits: U32, + /// Metrics for this route. + pub metrics: Metrics, +} + +static_assertions::assert_eq_size!(Route, libc::rt_msghdr); + +impl PartialEq for Route { + fn eq(&self, other: &Self) -> bool { + self.header == other.header + && self.index == other.index + && self.flag_block == other.flag_block + && self.pid.get() == other.pid.get() + && self.seq == other.seq + && self.errno == other.errno + && self.use_ == other.use_ + && self.inits == other.inits + && self.metrics == other.metrics + } +} + +impl Eq for Route {} + +impl Debug for Route { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Route") + .field("header", &self.header) + .field("index", &self.index) + .field("flag_block", &self.flag_block) + .field("pid", &self.pid.get()) + .field("seq", &self.seq) + .field("errno", &self.errno) + .field("use", &self.use_) + .field("inits", &self.inits) + .field("metrics", &self.metrics) + .finish() + } +} + +/// Metrics for a given route, embedded in [`Route`] (and `Route2` on macOS). +/// +/// [`rt_metrics`][libc::rt_metrics] in libc. +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + zerocopy::Immutable, + zerocopy::IntoBytes, + zerocopy::TryFromBytes, + zerocopy::KnownLayout, + zerocopy::Unaligned, + Default, +)] +#[repr(C)] +pub struct Metrics { + /// Bitmasks indicating protection for other metrics field (kernel shouldn't update). + pub locks: U32, + /// MTU for this route. + pub mtu: U32, + /// Maximum hopcount for this route. + pub hopcount: U32, + /// Time remaining on a dynamic routing entry. + pub expire: I32, + /// TCP: receive buffer size. + pub recvpipe: U32, + /// TCP: send buffer size. + pub sendpipe: U32, + /// TCP: ssthresh. + pub ssthresh: U32, + /// TCP RTT. + pub rtt: U32, + /// TCP RTT variance. + pub rttvar: U32, + /// Packets sent using this route. + pub pksent: U32, + /// Reserved. + pub _reserved: [U32; 4], +} + +static_assertions::assert_eq_size!(Metrics, libc::rt_metrics); diff --git a/ts_netmon/src/bsd/route_socket.rs b/ts_netmon/src/bsd/route_socket.rs new file mode 100644 index 00000000..e62e0b9a --- /dev/null +++ b/ts_netmon/src/bsd/route_socket.rs @@ -0,0 +1,255 @@ +use core::{ + borrow::Borrow, + pin::Pin, + task::{Context, Poll}, +}; +use std::{io::Read, os::fd::AsRawFd}; + +use futures_util::Stream; +use libc::{PF_ROUTE, SO_USELOOPBACK, SOL_SOCKET}; +use nom::Parser; +use socket2::{Domain, Socket, Type}; +use tokio::io::{Interest, unix::AsyncFd}; +use zerocopy::IntoBytes; + +use crate::bsd::net_table; + +/// A socket handling [`PF_ROUTE`] messages to/from a BSD kernel. +pub struct RouteSocket { + fd: AsyncFd, +} + +impl RouteSocket { + /// Construct a new [`RouteSocket`]. + pub fn new() -> std::io::Result { + let sock = Socket::new(Domain::from(PF_ROUTE), Type::RAW, None)?; + sock.set_nonblocking(true)?; + + // SAFETY: this usage of the `setsockopt` API is correct. + unsafe { + libc::setsockopt( + sock.as_raw_fd(), + SOL_SOCKET, + SO_USELOOPBACK, + &0u8 as *const u8 as *const _, + 1, + ); + } + + let fd = AsyncFd::new(sock)?; + Ok(Self { fd }) + } + + /// Produce a stream of raw messages as [`bytes::BytesMut`]. + /// + /// The contents are not interpreted or guaranteed to be valid, the messages are simply deframed + /// according to the initial length word. + pub fn raw_msg_stream(&self) -> MsgStream<&Self> { + MsgStream { + rtsock: self, + buf: bytes::BytesMut::new(), + } + } + + /// Send a message over the route socket. + pub async fn send_raw(&self, msg: &[u8]) -> std::io::Result { + self.fd + .async_io(Interest::WRITABLE, |sock| sock.send(msg)) + .await + } +} + +/// A stream of raw messages from a [`RouteSocket`]. +pub struct MsgStream { + /// The socket from which we're streaming packets. + rtsock: RS, + + /// Working buffer which holds undecoded state. + /// + /// We receive from the socket into this buffer and then yield messages out of it one-at-a-time + /// until it empties (then repeat). + /// + /// This is stored as a field rather than as a local var on [`MsgStream`] to avoid thrashing + /// allocations where possible; it's likely that we'll end up with a bigger chunk of memory than + /// we need and will be able to skip allocating in some cases. + buf: bytes::BytesMut, +} + +impl MsgStream { + /// Construct a new [`MsgStream`] around the given routing socket. + pub fn new(rs: RS) -> MsgStream { + Self { + rtsock: rs, + buf: bytes::BytesMut::new(), + } + } +} + +impl MsgStream { + /// Nominal buffer size for receiving from a `PF_ROUTE` socket. + const BUF_SIZE: usize = 8192; +} + +impl Stream for MsgStream +where + RS: Borrow + Unpin, +{ + type Item = std::io::Result; + + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut slf = self.as_mut(); + + loop { + while !slf.buf.is_empty() { + let msg_len = match net_table::msg_chunk().parse_complete(slf.buf.as_bytes()) { + Ok((rest, _msg)) => { + let full_msg_len = slf.buf.len() - rest.len(); + debug_assert_eq!(_msg.len(), full_msg_len); + + tracing::trace!(rest_len = rest.len(), full_msg_len, msg_len = _msg.len()); + + Some(full_msg_len) + } + + // Fine to bail here, the error condition here is that the message didn't start + // with a valid length u16, i.e. the rest of the input wasn't long enough. + Err(nom::Err::Failure(e) | nom::Err::Error(e)) => { + tracing::error!(error = ?e, "malformed PF_ROUTE message"); + None + } + + // Chunk was truncated in the input. Not possible because we're calling + // `parse_complete` above, so incompletes get converted to errors. + Err(nom::Err::Incomplete(_n)) => { + unreachable!("incomplete PF_ROUTE message"); + } + }; + + tracing::trace!(?msg_len); + + let Some(msg_len) = msg_len else { + slf.buf.clear(); + continue; + }; + + // Re-split the message; this is fine because net_table::msg_chunk() doesn't discard + // data, it's actual-size. + let msg = slf.buf.split_to(msg_len); + return Poll::Ready(Some(Ok(msg))); + } + + loop { + let Self { rtsock, buf } = &mut *slf; + let rtsock: &RouteSocket = (*rtsock).borrow(); + + let mut rdy = core::task::ready!(rtsock.fd.poll_read_ready(cx))?; + buf.resize(Self::BUF_SIZE, 0); + + let n = match rdy.try_io(|sock| { + let mut sock = sock.get_ref(); + sock.read(buf.as_mut()) + }) { + Err(_) => { + buf.clear(); + continue; + } + Ok(n) => n?, + }; + + buf.truncate(n); + break; + } + } + } +} + +#[cfg(test)] +mod test { + use std::os::fd::FromRawFd; + + use bytes::{BufMut, BytesMut}; + use futures_util::StreamExt; + + use super::*; + + /// Just assert that opening the socket works. + #[tokio::test] + async fn open() { + RouteSocket::new().unwrap(); + } + + fn mock_socket() -> Result<(tokio::net::UnixDatagram, RouteSocket), Box> + { + let (sock_tx, sock_rx) = std::os::unix::net::UnixDatagram::pair()?; + + sock_tx.set_nonblocking(true)?; + sock_rx.set_nonblocking(true)?; + + let sock_tx = tokio::net::UnixDatagram::from_std(sock_tx)?; + + let sock = unsafe { Socket::from_raw_fd(sock_rx.as_raw_fd()) }; + core::mem::forget(sock_rx); + + Ok(( + sock_tx, + RouteSocket { + fd: AsyncFd::new(sock)?, + }, + )) + } + + async fn assert_roundtrip( + sock_tx: &tokio::net::UnixDatagram, + rtsock: &RouteSocket, + msg: &[u8], + ) -> Result<(), Box> { + let mut stream = rtsock.raw_msg_stream(); + sock_tx.send(msg).await?; + + let next = stream.next().await.unwrap()?; + assert_eq!(next.as_bytes(), msg); + + Ok(()) + } + + #[tracing_test::traced_test] + #[tokio::test] + async fn simple() -> Result<(), Box> { + let (sock_tx, rtsock) = mock_socket()?; + + assert_roundtrip(&sock_tx, &rtsock, &2u16.to_ne_bytes()).await?; + assert_roundtrip( + &sock_tx, + &rtsock, + &[&4u16.to_ne_bytes()[..], &[1, 2]].concat(), + ) + .await?; + assert_roundtrip( + &sock_tx, + &rtsock, + &[&34u16.to_ne_bytes()[..], &[0xab; 32][..]].concat(), + ) + .await?; + + Ok(()) + } + + proptest::proptest! { + #[test] + fn arb_msg( + payload in proptest::collection::vec( + proptest::prelude::any::(), + 0..2046, + ) + ) { + let mut b = BytesMut::new(); + b.put_u16_ne((payload.len() as u16).checked_add(2).unwrap()); + b.put_slice(&payload); + + tokio::runtime::Runtime::new()?.block_on(async move { + let (sock_tx, rtsock) = mock_socket().unwrap(); + assert_roundtrip(&sock_tx, &rtsock, &b).await.unwrap(); + }); + } + } +} diff --git a/ts_netmon/src/darwin/mod.rs b/ts_netmon/src/darwin/mod.rs deleted file mode 100644 index 144b90ed..00000000 --- a/ts_netmon/src/darwin/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! macOS network monitor implementation. - -use nix::libc::AF_ROUTE; -use socket2::{Domain, Type}; -use tokio::io::unix::AsyncFd; - -use crate::{BoxStream, Event, MonType, Netmon}; - -/// Canonical platform [`Netmon`] for macos based on `AF_ROUTE` sockets. -pub struct AfRouteMon; - -impl Netmon for AfRouteMon { - fn ty(&self) -> MonType { - MonType::AF_ROUTE - } - - fn event_stream(&self) -> std::io::Result>> { - let sock = socket2::Socket::new(Domain::from(AF_ROUTE), Type::RAW, None)?; - sock.set_nonblocking(true)?; - - let _fd = AsyncFd::new(sock)?; - - todo!("macos netmon is currently a placeholder") - } -} diff --git a/ts_netmon/src/family.rs b/ts_netmon/src/family.rs index bf5cc9cb..88115fd9 100644 --- a/ts_netmon/src/family.rs +++ b/ts_netmon/src/family.rs @@ -1,4 +1,7 @@ -use core::fmt::{Debug, Formatter}; +use core::{ + fmt::{Debug, Formatter}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; /// Specification of IPv4 or IPv6 or both. #[derive(Copy, Clone, PartialEq, Eq)] @@ -10,7 +13,7 @@ pub enum FamilyOrBoth { } impl Debug for FamilyOrBoth { - fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { match self { FamilyOrBoth::Both => write!(f, "Both"), FamilyOrBoth::Single(family) => family.fmt(f), @@ -43,3 +46,23 @@ impl TryFrom for Family { } } } + +impl From for Family { + fn from(addr: IpAddr) -> Self { + match addr { + IpAddr::V4(_) => Family::Ipv4, + IpAddr::V6(_) => Family::Ipv6, + } + } +} +impl From for Family { + fn from(_: Ipv4Addr) -> Self { + Family::Ipv4 + } +} + +impl From for Family { + fn from(_: Ipv6Addr) -> Self { + Family::Ipv6 + } +} diff --git a/ts_netmon/src/id.rs b/ts_netmon/src/id.rs index fe88ec40..6a74bce7 100644 --- a/ts_netmon/src/id.rs +++ b/ts_netmon/src/id.rs @@ -30,10 +30,10 @@ impl MonType { pub const RTNETLINK: Self = Self::new_static("rtnetlink"); /// [`MonType`] for the canonical macOS-platform network monitor, built around messages - /// sent through `AF_ROUTE` sockets. + /// sent through `PF_ROUTE` sockets. /// /// [`InterfaceId`]s with this type are interface indices. - pub const AF_ROUTE: Self = Self::new_static("af_route"); + pub const PF_ROUTE: Self = Self::new_static("pf_route"); /// Convenience helper to construct a new mon type from a `&'static str`. /// diff --git a/ts_netmon/src/lib.rs b/ts_netmon/src/lib.rs index 9d8ec90e..a1d050f9 100644 --- a/ts_netmon/src/lib.rs +++ b/ts_netmon/src/lib.rs @@ -3,7 +3,7 @@ use std::net::IpAddr; #[cfg(target_os = "macos")] -pub mod darwin; +pub mod bsd; mod family; mod id; #[cfg(target_os = "linux")] diff --git a/ts_netmon/src/netmon.rs b/ts_netmon/src/netmon.rs index bb6723f1..ca4c320c 100644 --- a/ts_netmon/src/netmon.rs +++ b/ts_netmon/src/netmon.rs @@ -4,7 +4,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use futures_util::{Stream, StreamExt, stream}; #[cfg(target_os = "macos")] -pub use crate::darwin::AfRouteMon as PlatformMon; +pub use crate::bsd::PfRouteMon as PlatformMon; #[cfg(target_os = "linux")] pub use crate::linux::RtNetlinkMon as PlatformMon; #[cfg(windows)] @@ -17,7 +17,7 @@ pub type BoxStream = Pin + Send + 'static>>; /// Get the platform [`Netmon`] implementation if there is one. pub const fn platform_mon() -> Option { cfg_if::cfg_if! { - if #[cfg(any(windows, target_os = "linux"))] { + if #[cfg(any(windows, target_os = "linux", target_os = "macos"))] { Some(&PlatformMon) } else { struct NoopMon; diff --git a/ts_netmon/tests/macos.rs b/ts_netmon/tests/macos.rs new file mode 100644 index 00000000..339c66be --- /dev/null +++ b/ts_netmon/tests/macos.rs @@ -0,0 +1,127 @@ +#![cfg(target_os = "macos")] + +//! macOS-specific tests. +//! +//! Might expand to BSD later once the code is made more generic. + +use nom::{Parser, combinator::complete}; +use ts_netmon::{ + FamilyOrBoth, + bsd::{ + Message, net_table, + net_table::{DumpType, MessageHeader}, + }, +}; + +/// Sample captured from a mac using +/// [`DumpType::Interface2`][ts_netmon::bsd::net_table::DumpType::Interface2]. +/// +/// It has been scrubbed to randomize identifying info like IPv6 GUAs and MAC addresses – these may +/// not be sensical addresses, though they should be syntactically valid. +const SAMPLE_IFACE2: &[u8] = include_bytes!("macos_if2_san.dat"); + +/// Sample captured from a mac using +/// [`DumpType::Interface`][ts_netmon::bsd::net_table::DumpType::Interface]. +/// +/// It has been scrubbed to randomize identifying info like IPv6 GUAs and MAC addresses – these may +/// not be sensical addresses, though they should be syntactically valid. +const SAMPLE_IFACE: &[u8] = include_bytes!("macos_if_san.dat"); + +/// Sample captured from a mac using +/// [`DumpType::Route2`][ts_netmon::bsd::net_table::DumpType::Route2]. +/// +/// It has been scrubbed to randomize identifying info like IPv6 GUAs and MAC addresses – these may +/// not be sensical addresses, though they should be syntactically valid. +const SAMPLE_ROUTE2: &[u8] = include_bytes!("macos_rt2_san.dat"); + +/// Sample captured from a mac using +/// [`DumpType::Route`][ts_netmon::bsd::net_table::DumpType::Route]. +/// +/// It has been scrubbed to randomize identifying info like IPv6 GUAs and MAC addresses – these may +/// not be sensical addresses, though they should be syntactically valid. +const SAMPLE_ROUTE: &[u8] = include_bytes!("macos_rt_san.dat"); + +const ALL_SAMPLES: &[(&str, &[u8])] = &[ + ("route2", SAMPLE_ROUTE2), + ("route", SAMPLE_ROUTE), + ("iface2", SAMPLE_IFACE2), + ("iface", SAMPLE_IFACE), +]; + +/// Verify that deframing all the messages in the dumps comes out cleanly. +#[test] +fn chunks() { + for (name, sample) in ALL_SAMPLES { + let (rest, result) = nom::multi::many0(complete(net_table::msg_chunk())) + .parse_complete(*sample) + .unwrap(); + + assert!( + rest.is_empty(), + "{name} did not parse completely ({} bytes remaining)", + rest.len() + ); + println!("{name}: n chunks: {}", result.len()); + } +} + +#[tracing::instrument(skip_all, fields(name = %_name))] +#[track_caller] +fn assert_parse_internal(_name: &str, sample: &[u8]) { + let (_, msgs) = nom::multi::many0(complete(net_table::msg_chunk())) + .parse_complete(sample) + .unwrap(); + + for msg in msgs { + let (rest, (ty, hdr)) = MessageHeader::parse(msg).unwrap(); + tracing::info!(?ty, ?hdr); + + let (rest, addrs) = nom::multi::many0(complete(net_table::Address::parse::< + _, + nom::error::Error<_>, + >())) + .parse_complete(rest) + .unwrap(); + + assert!(rest.is_empty()); + + for addr in addrs { + let addr = addr.unwrap(); + tracing::info!(addr = ?format_args!("{addr:x?}")); + } + } +} + +#[tracing_test::traced_test] +#[test] +fn parse_internal() { + for (name, msg) in ALL_SAMPLES { + assert_parse_internal(name, msg); + } +} + +#[tracing::instrument(skip_all, fields(name = %_name))] +#[track_caller] +fn assert_parse_messages(_name: &str, sample: &[u8]) { + let (_, _msgs) = nom::multi::many0(complete(Message::parse)) + .parse_complete(sample) + .unwrap(); +} + +#[tracing_test::traced_test] +#[test] +fn parse_full() { + for (name, sample) in ALL_SAMPLES { + assert_parse_messages(name, sample); + } +} + +/// Live dump should also work, though unpredictable. +#[tracing_test::traced_test] +#[test] +fn dump() { + for ty in DumpType::all() { + let dump = net_table::dump(FamilyOrBoth::Both, *ty, 0).unwrap(); + assert_parse_messages(&format!("dump_{ty:?}"), &dump); + } +} diff --git a/ts_netmon/tests/macos_if2_san.dat b/ts_netmon/tests/macos_if2_san.dat new file mode 100644 index 00000000..4f10fc28 Binary files /dev/null and b/ts_netmon/tests/macos_if2_san.dat differ diff --git a/ts_netmon/tests/macos_if_san.dat b/ts_netmon/tests/macos_if_san.dat new file mode 100644 index 00000000..ec66264e Binary files /dev/null and b/ts_netmon/tests/macos_if_san.dat differ diff --git a/ts_netmon/tests/macos_rt2_san.dat b/ts_netmon/tests/macos_rt2_san.dat new file mode 100644 index 00000000..33cc039f Binary files /dev/null and b/ts_netmon/tests/macos_rt2_san.dat differ diff --git a/ts_netmon/tests/macos_rt_san.dat b/ts_netmon/tests/macos_rt_san.dat new file mode 100644 index 00000000..24dfabcf Binary files /dev/null and b/ts_netmon/tests/macos_rt_san.dat differ