diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 9ce0c5be..6f1b693d 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -23,7 +23,7 @@ env: RUSTFLAGS: -Dwarnings RUSTDOCFLAGS: -Dwarnings SCCACHE_CACHE_SIZE: "10G" - CRATES_LIST: "netwatch,portmapper" + CRATES_LIST: "netwatch,netwatch-netlink,portmapper" IROH_FORCE_STAGING_RELAYS: "1" jobs: diff --git a/Cargo.lock b/Cargo.lock index 79485a5c..b252bae1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -73,15 +73,6 @@ version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block2" -version = "0.6.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" -dependencies = [ - "objc2", -] - [[package]] name = "bumpalo" version = "3.20.3" @@ -110,12 +101,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -252,18 +237,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab03c107fafeb3ee9f5925686dbb7a73bc76e3932abb0d2b365cb64b169cf04c" -[[package]] -name = "dispatch2" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", -] - [[package]] name = "displaydoc" version = "0.2.6" @@ -275,17 +248,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "dlopen2" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" -dependencies = [ - "libc", - "once_cell", - "winapi", -] - [[package]] name = "equivalent" version = "1.0.2" @@ -299,7 +261,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -480,9 +442,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.14" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "171fefbc92fe4a4de27e0698d6a5b392d6a0e333506bc49133760b3bcf948733" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", @@ -527,7 +489,7 @@ dependencies = [ "data-encoding", "idna", "ipnet", - "jni 0.22.4", + "jni", "once_cell", "rand", "thiserror 2.0.18", @@ -823,22 +785,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" -[[package]] -name = "jni" -version = "0.21.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" -dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys 0.3.1", - "log", - "thiserror 1.0.69", - "walkdir", - "windows-sys 0.45.0", -] - [[package]] name = "jni" version = "0.22.4" @@ -848,7 +794,7 @@ dependencies = [ "cfg-if", "combine", "jni-macros", - "jni-sys 0.4.1", + "jni-sys", "log", "simd_cesu8", "thiserror 2.0.18", @@ -869,15 +815,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "jni-sys" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" -dependencies = [ - "jni-sys 0.4.1", -] - [[package]] name = "jni-sys" version = "0.4.1" @@ -969,12 +906,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "mac-addr" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3d25b0e0b648a86960ac23b7ad4abb9717601dec6f66c165f5b037f3f03065f" - [[package]] name = "matchers" version = "0.2.0" @@ -1008,7 +939,7 @@ checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1064,39 +995,6 @@ dependencies = [ "n0-future", ] -[[package]] -name = "ndk-context" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" - -[[package]] -name = "netdev" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "569dfbdd2efd771b24ec9bb57f956e04d4fbfc72f62b2f11961723f9b3f4b020" -dependencies = [ - "block2", - "dispatch2", - "dlopen2", - "ipnet", - "jni 0.21.1", - "libc", - "mac-addr", - "ndk-context", - "netlink-packet-core", - "netlink-packet-route 0.31.0", - "netlink-sys", - "objc2", - "objc2-core-foundation", - "objc2-core-wlan", - "objc2-foundation", - "objc2-system-configuration", - "once_cell", - "plist", - "windows-sys 0.61.2", -] - [[package]] name = "netlink-packet-core" version = "0.8.1" @@ -1118,18 +1016,6 @@ dependencies = [ "netlink-packet-core", ] -[[package]] -name = "netlink-packet-route" -version = "0.31.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2288fcb784eb3defd5fb16f4c4160d5f477de192eac730f43e1d11c24d9a007" -dependencies = [ - "bitflags", - "libc", - "log", - "netlink-packet-core", -] - [[package]] name = "netlink-proto" version = "0.12.0" @@ -1172,14 +1058,8 @@ dependencies = [ "n0-error", "n0-future", "n0-watcher", - "netdev", - "netlink-packet-core", - "netlink-packet-route 0.31.0", - "netlink-proto", - "netlink-sys", + "netwatch-netlink", "noq-udp", - "objc2-core-foundation", - "objc2-system-configuration", "patchbay", "pin-project-lite", "serde", @@ -1198,6 +1078,17 @@ dependencies = [ "wmi", ] +[[package]] +name = "netwatch-netlink" +version = "0.1.0" +dependencies = [ + "libc", + "n0-error", + "socket2", + "tokio", + "tracing", +] + [[package]] name = "nix" version = "0.30.1" @@ -1232,7 +1123,7 @@ dependencies = [ "libc", "socket2", "tracing", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1274,7 +1165,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1315,96 +1206,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "objc2" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" -dependencies = [ - "objc2-encode", -] - -[[package]] -name = "objc2-core-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" -dependencies = [ - "bitflags", - "block2", - "dispatch2", - "libc", - "objc2", -] - -[[package]] -name = "objc2-core-wlan" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c71e34919aba0d701380d911702455038a8a3587467fe0141d6a71501e7ffe48" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", - "objc2-foundation", - "objc2-security", - "objc2-security-foundation", -] - -[[package]] -name = "objc2-encode" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" - -[[package]] -name = "objc2-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" -dependencies = [ - "bitflags", - "block2", - "libc", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" -dependencies = [ - "bitflags", - "objc2", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-security-foundation" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef76382e9cedd18123099f17638715cc3d81dba3637d4c0d39ab69df2ef345a5" -dependencies = [ - "objc2", - "objc2-foundation", -] - -[[package]] -name = "objc2-system-configuration" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" -dependencies = [ - "bitflags", - "dispatch2", - "libc", - "objc2", - "objc2-core-foundation", - "objc2-security", -] - [[package]] name = "once_cell" version = "1.21.4" @@ -1491,19 +1292,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "plist" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" -dependencies = [ - "base64", - "indexmap", - "quick-xml", - "serde", - "time", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -1594,15 +1382,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "quick-xml" -version = "0.39.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" -dependencies = [ - "memchr", -] - [[package]] name = "quote" version = "1.0.45" @@ -1672,7 +1451,7 @@ dependencies = [ "futures-util", "log", "netlink-packet-core", - "netlink-packet-route 0.30.0", + "netlink-packet-route", "netlink-proto", "netlink-sys", "nix 0.30.1", @@ -1840,7 +1619,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -1997,7 +1776,6 @@ dependencies = [ "powerfmt", "serde_core", "time-core", - "time-macros", ] [[package]] @@ -2006,16 +1784,6 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" -[[package]] -name = "time-macros" -version = "0.2.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71c652a3727a9cbb9a02f707f530b618ce00d0ccd762009c8c23bd191df3c17d" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.8.3" @@ -2054,7 +1822,7 @@ dependencies = [ "signal-hook-registry", "socket2", "tokio-macros", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -2459,37 +2227,15 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows" version = "0.62.2" @@ -2591,15 +2337,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -2609,21 +2346,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - [[package]] name = "windows-threading" version = "0.2.1" @@ -2633,48 +2355,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "winnow" version = "1.0.3" diff --git a/Cargo.toml b/Cargo.toml index 314f124f..da2234c2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,8 @@ [workspace] members = [ "portmapper", - "netwatch" + "netwatch", + "netwatch-netlink" ] resolver = "2" diff --git a/deny.toml b/deny.toml index 3ecdc88e..4b7f0a32 100644 --- a/deny.toml +++ b/deny.toml @@ -21,8 +21,6 @@ license-files = [{ path = "LICENSE", hash = 0xbd0eed23 }] [advisories] ignore = [ "RUSTSEC-2024-0436", # paste unmaintained - "RUSTSEC-2026-0194", # netwatch -> netdev -> plist -> quick-xml: remove once https://github.com/ebarnard/rust-plist/pull/191 is released - "RUSTSEC-2026-0195", # netwatch -> netdev -> plist -> quick-xml: remove once https://github.com/ebarnard/rust-plist/pull/191 is released ] [sources] diff --git a/netwatch-netlink/Cargo.toml b/netwatch-netlink/Cargo.toml new file mode 100644 index 00000000..2b3f9e8a --- /dev/null +++ b/netwatch-netlink/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "netwatch-netlink" +version = "0.1.0" +readme = "README.md" +description = "Minimal rtnetlink client for netwatch" +license = "MIT OR Apache-2.0" +authors = ["n0 team"] +repository = "https://github.com/n0-computer/net-tools" +keywords = ["networking", "netlink"] +edition = "2024" + +# Sadly this also needs to be updated in .github/workflows/ci.yml +rust-version = "1.91" + +[lints] +workspace = true + +# The crate is empty on every other platform (see lib.rs), so all +# dependencies are gated on the platforms with rtnetlink. +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] +libc = "0.2.139" +n0-error = "1.0.0" +socket2 = "0.6" +tokio = { version = "1.41", features = ["net", "time"] } +tracing = "0.1" + +[target.'cfg(any(target_os = "linux", target_os = "android"))'.dev-dependencies] +tokio = { version = "1.41", features = ["macros", "rt", "time"] } + +[package.metadata.docs.rs] +targets = ["x86_64-unknown-linux-gnu", "aarch64-linux-android"] diff --git a/netwatch-netlink/README.md b/netwatch-netlink/README.md new file mode 100644 index 00000000..390f17af --- /dev/null +++ b/netwatch-netlink/README.md @@ -0,0 +1,24 @@ +# netwatch-netlink + +Minimal rtnetlink client used by [netwatch](https://crates.io/crates/netwatch) +on Linux and Android. + +It covers exactly the subset of the netlink route protocol that netwatch +needs: dumping links, addresses and routes, looking up a link by index, and +listening to rtnetlink multicast groups for change events. It is not a +general-purpose netlink library; if you need one, use the +[rust-netlink](https://github.com/rust-netlink) crates instead. + +On every platform other than Linux and Android the crate compiles to +nothing. + +# License + +This project is licensed under either of + + * Apache License, Version 2.0, ([LICENSE-APACHE](../LICENSE-APACHE) or + http://www.apache.org/licenses/LICENSE-2.0) + * MIT license ([LICENSE-MIT](../LICENSE-MIT) or + http://opensource.org/licenses/MIT) + +at your option. diff --git a/netwatch-netlink/src/conn.rs b/netwatch-netlink/src/conn.rs new file mode 100644 index 00000000..3e9ea069 --- /dev/null +++ b/netwatch-netlink/src/conn.rs @@ -0,0 +1,543 @@ +//! Netlink route sockets: blocking and async dump connections, and the +//! multicast event socket. + +use std::{ + collections::VecDeque, + io, + mem::MaybeUninit, + os::fd::{AsRawFd, RawFd}, + time::{Duration, Instant}, +}; + +use n0_error::e; +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; +use tokio::io::unix::AsyncFd; +use tracing::warn; + +use crate::{ + Error, + message::{ + self, AddressMessage, Frame, LinkMessage, Message, RouteFamily, RouteMessage, + dump_addresses_request, dump_links_request, dump_routes_request, get_link_request, + }, +}; + +/// Receive buffer size for a single datagram. +/// +/// The kernel caps rtnetlink dump datagrams well below this (32k), so a +/// larger buffer only wastes memory. +const RECV_BUF_SIZE: usize = 64 * 1024; + +/// Deadline for a whole dump. +/// +/// When the deadline passes, the messages collected so far are returned: +/// enumeration should degrade rather than fail when the kernel drops a +/// datagram. +const DUMP_TIMEOUT: Duration = Duration::from_secs(2); + +/// Builds the netlink socket address for the given multicast group mask. +/// +/// A zero mask addresses the kernel for request/response use. +fn netlink_addr(groups: u32) -> SockAddr { + // SAFETY: an all-zero sockaddr_nl is valid; only the family and the + // group mask need real values (pid zero lets the kernel assign one). + // The storage is larger than sockaddr_nl and the length says so. + unsafe { + let mut storage = socket2::SockAddrStorage::zeroed(); + let addr = std::ptr::from_mut(&mut storage).cast::(); + (*addr).nl_family = libc::AF_NETLINK as libc::sa_family_t; + (*addr).nl_groups = groups; + SockAddr::new( + storage, + std::mem::size_of::() as libc::socklen_t, + ) + } +} + +/// A `NETLINK_ROUTE` socket. +#[derive(Debug)] +struct NetlinkSocket { + socket: Socket, +} + +impl NetlinkSocket { + /// Opens the socket, subscribed to the multicast groups in `groups` + /// (zero for request/response use). + /// + /// Blocking receives use a per-call timeout; async users flip the + /// socket to non-blocking and drive it through an [`AsyncFd`]. + fn new(groups: u32, nonblocking: bool) -> io::Result { + let socket = Socket::new( + Domain::from(libc::AF_NETLINK), + Type::DGRAM, + Some(Protocol::from(libc::NETLINK_ROUTE)), + )?; + socket.set_nonblocking(nonblocking)?; + + // On Android 11+ SELinux denies bind on netlink route sockets for + // apps; the kernel auto-binds on the first send instead. Group + // subscription requires the bind and is not used on Android + // (netmon is a no-op there). + let bind = groups != 0 || cfg!(not(target_os = "android")); + if bind { + socket.bind(&netlink_addr(groups))?; + } + Ok(Self { socket }) + } + + /// Sends a request datagram to the kernel. + fn send_request(&self, buf: &[u8]) -> io::Result<()> { + let sent = self.socket.send_to(buf, &netlink_addr(0))?; + // Datagram sockets send whole messages; a short send cannot happen. + debug_assert_eq!(sent, buf.len()); + Ok(()) + } + + /// Receives one datagram. + /// + /// Returns the datagram's true length, which exceeds `buf.len()` when + /// the datagram was truncated (`MSG_TRUNC`). + fn recv(&self, buf: &mut [u8]) -> io::Result { + // SAFETY: MaybeUninit has the same layout as u8, and the + // receive only writes initialized bytes into the buffer. + let buf = unsafe { &mut *(std::ptr::from_mut::<[u8]>(buf) as *mut [MaybeUninit]) }; + self.socket.recv_with_flags(buf, libc::MSG_TRUNC) + } + + /// Blocking receive of one datagram, bounded by `deadline`. + /// + /// Returns `None` when the deadline passes first. + fn recv_deadline(&self, buf: &mut [u8], deadline: Instant) -> io::Result> { + loop { + let Some(remaining) = deadline.checked_duration_since(Instant::now()) else { + return Ok(None); + }; + // A zero timeout would mean "block forever". + self.socket + .set_read_timeout(Some(remaining.max(Duration::from_millis(1))))?; + match self.recv(buf) { + Ok(len) => return Ok(Some(len)), + Err(err) if err.kind() == io::ErrorKind::Interrupted => {} + Err(err) + if matches!( + err.kind(), + io::ErrorKind::WouldBlock | io::ErrorKind::TimedOut + ) => + { + return Ok(None); + } + Err(err) => return Err(err), + } + } + } +} + +impl AsRawFd for NetlinkSocket { + fn as_raw_fd(&self) -> RawFd { + self.socket.as_raw_fd() + } +} + +/// Maps a receive error, turning `ENOBUFS` into [`Error::Overrun`]. +fn map_recv_err(err: io::Error) -> Error { + if err.raw_os_error() == Some(libc::ENOBUFS) { + e!(Error::Overrun) + } else { + e!(Error::Io, err) + } +} + +/// Collects the frames of one dump, keyed by sequence number. +#[derive(Debug, Default)] +struct DumpCollector { + messages: Vec, + done: bool, +} + +impl DumpCollector { + fn push_datagram(&mut self, seq: u32, datagram: &[u8]) -> Result<(), Error> { + for frame in message::parse_frames(datagram) { + match frame { + Frame::Message { seq: s, message } if s == seq => self.messages.push(message), + Frame::Done { seq: s } if s == seq => { + self.done = true; + return Ok(()); + } + Frame::Error { seq: s, code } if s == seq && code != 0 => { + return Err(e!(Error::ErrorMessage { code })); + } + // Acks (code zero), foreign sequence numbers and skipped + // frames are ignored. + _ => {} + } + } + Ok(()) + } +} + +fn filter_links(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewLink(link) => Some(link), + _ => None, + }) + .collect() +} + +fn filter_addresses(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewAddress(address) => Some(address), + _ => None, + }) + .collect() +} + +fn filter_routes(messages: Vec) -> Vec { + messages + .into_iter() + .filter_map(|message| match message { + Message::NewRoute(route) => Some(route), + _ => None, + }) + .collect() +} + +/// A blocking request/response connection. +/// +/// Dumps wait at most two seconds and return the messages received so far +/// when the deadline passes. +#[derive(Debug)] +pub struct Connection { + socket: NetlinkSocket, + seq: u32, + buf: Vec, +} + +impl Connection { + /// Opens a new connection. + pub fn new() -> Result { + Ok(Self { + socket: NetlinkSocket::new(0, false)?, + seq: 0, + buf: vec![0; RECV_BUF_SIZE], + }) + } + + /// Dumps all links. + pub fn dump_links(&mut self) -> Result, Error> { + self.dump(dump_links_request).map(filter_links) + } + + /// Dumps all addresses of both families. + pub fn dump_addresses(&mut self) -> Result, Error> { + self.dump(dump_addresses_request).map(filter_addresses) + } + + /// Dumps the routes of the given family. + pub fn dump_routes(&mut self, family: RouteFamily) -> Result, Error> { + self.dump(|seq| dump_routes_request(seq, family)) + .map(filter_routes) + } + + fn next_seq(&mut self) -> u32 { + self.seq = self.seq.wrapping_add(1); + self.seq + } + + fn dump(&mut self, build: impl FnOnce(u32) -> Vec) -> Result, Error> { + let seq = self.next_seq(); + self.socket.send_request(&build(seq))?; + let deadline = Instant::now() + DUMP_TIMEOUT; + let mut collector = DumpCollector::default(); + while !collector.done { + match self.socket.recv_deadline(&mut self.buf, deadline) { + Ok(None) => { + warn!("netlink dump timed out, returning partial result"); + break; + } + Ok(Some(len)) if len > self.buf.len() => return Err(e!(Error::Truncated)), + Ok(Some(len)) => collector.push_datagram(seq, &self.buf[..len])?, + Err(err) => return Err(map_recv_err(err)), + } + } + Ok(collector.messages) + } +} + +/// An async request/response connection. +/// +/// The async twin of [`Connection`], with the same dump semantics. +#[derive(Debug)] +pub struct AsyncConnection { + socket: AsyncFd, + seq: u32, + buf: Vec, +} + +impl AsyncConnection { + /// Opens a new connection. + /// + /// Must be called from within a tokio runtime. + pub fn new() -> Result { + let socket = NetlinkSocket::new(0, true)?; + Ok(Self { + socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, + seq: 0, + buf: vec![0; RECV_BUF_SIZE], + }) + } + + /// Dumps all links. + pub async fn dump_links(&mut self) -> Result, Error> { + self.dump(dump_links_request).await.map(filter_links) + } + + /// Dumps all addresses of both families. + pub async fn dump_addresses(&mut self) -> Result, Error> { + self.dump(dump_addresses_request) + .await + .map(filter_addresses) + } + + /// Dumps the routes of the given family. + pub async fn dump_routes(&mut self, family: RouteFamily) -> Result, Error> { + self.dump(|seq| dump_routes_request(seq, family)) + .await + .map(filter_routes) + } + + /// Requests a single link by interface index. + /// + /// Returns `None` when the kernel does not answer within the dump + /// deadline. + pub async fn get_link_by_index(&mut self, index: u32) -> Result, Error> { + let seq = self.next_seq(); + self.socket + .get_ref() + .send_request(&get_link_request(seq, index))?; + let deadline = tokio::time::Instant::now() + DUMP_TIMEOUT; + loop { + let Some(len) = self.recv_datagram(deadline).await? else { + return Ok(None); + }; + for frame in message::parse_frames(&self.buf[..len]) { + match frame { + Frame::Message { + seq: s, + message: Message::NewLink(link), + } if s == seq => return Ok(Some(link)), + Frame::Error { seq: s, code } if s == seq && code != 0 => { + return Err(e!(Error::ErrorMessage { code })); + } + Frame::Done { seq: s } if s == seq => return Ok(None), + _ => {} + } + } + } + } + + fn next_seq(&mut self) -> u32 { + self.seq = self.seq.wrapping_add(1); + self.seq + } + + async fn dump(&mut self, build: impl FnOnce(u32) -> Vec) -> Result, Error> { + let seq = self.next_seq(); + self.socket.get_ref().send_request(&build(seq))?; + let deadline = tokio::time::Instant::now() + DUMP_TIMEOUT; + let mut collector = DumpCollector::default(); + while !collector.done { + let Some(len) = self.recv_datagram(deadline).await? else { + warn!("netlink dump timed out, returning partial result"); + break; + }; + collector.push_datagram(seq, &self.buf[..len])?; + } + Ok(collector.messages) + } + + /// Receives one datagram into the connection buffer. + /// + /// Returns its length, or `None` when `deadline` passes first. + async fn recv_datagram( + &mut self, + deadline: tokio::time::Instant, + ) -> Result, Error> { + let Self { socket, buf, .. } = self; + loop { + let Ok(guard) = tokio::time::timeout_at(deadline, socket.readable()).await else { + return Ok(None); + }; + let mut guard = guard.map_err(|err| e!(Error::Io, err))?; + match guard.try_io(|socket| socket.get_ref().recv(buf)) { + Ok(Ok(len)) if len > buf.len() => return Err(e!(Error::Truncated)), + Ok(Ok(len)) => return Ok(Some(len)), + Ok(Err(err)) => return Err(map_recv_err(err)), + // Spurious readiness: wait again. + Err(_would_block) => {} + } + } + } +} + +/// A pending event parsed from a received datagram. +#[derive(Debug)] +enum PendingEvent { + Message(Message), + Done, + Error { code: i32 }, +} + +/// A socket subscribed to rtnetlink multicast groups. +/// +/// Build the `groups` mask from `RTNLGRP_*` values with +/// [`group_flag`](crate::group_flag). +#[derive(Debug)] +pub struct EventSocket { + socket: AsyncFd, + buf: Vec, + pending: VecDeque, +} + +impl EventSocket { + /// Subscribes to the multicast groups in the `groups` bind mask. + /// + /// Must be called from within a tokio runtime. + pub fn subscribe(groups: u32) -> Result { + let socket = NetlinkSocket::new(groups, true)?; + Ok(Self { + socket: AsyncFd::new(socket).map_err(|err| e!(Error::Io, err))?, + buf: vec![0; RECV_BUF_SIZE], + pending: VecDeque::new(), + }) + } + + /// Waits for the next event message. + /// + /// # Errors + /// + /// - [`Error::ErrorMessage`]: the kernel sent an error frame; the + /// socket remains usable and `next` can be called again. + /// - [`Error::Overrun`], [`Error::Done`], [`Error::Truncated`], + /// [`Error::Io`]: events may have been lost; the caller should drop + /// the socket, resubscribe, and re-read the state it watches. + pub async fn next(&mut self) -> Result { + loop { + match self.pending.pop_front() { + Some(PendingEvent::Message(message)) => return Ok(message), + Some(PendingEvent::Done) => return Err(e!(Error::Done)), + Some(PendingEvent::Error { code }) => return Err(e!(Error::ErrorMessage { code })), + None => {} + } + let Self { + socket, + buf, + pending, + } = self; + let mut guard = socket.readable().await.map_err(|err| e!(Error::Io, err))?; + match guard.try_io(|socket| socket.get_ref().recv(buf)) { + Ok(Ok(len)) if len > buf.len() => return Err(e!(Error::Truncated)), + Ok(Ok(len)) => { + for frame in message::parse_frames(&buf[..len]) { + match frame { + Frame::Message { message, .. } => { + pending.push_back(PendingEvent::Message(message)); + } + Frame::Done { .. } => pending.push_back(PendingEvent::Done), + Frame::Error { code, .. } => { + pending.push_back(PendingEvent::Error { code }); + } + Frame::Skip => {} + } + } + } + Ok(Err(err)) => return Err(map_recv_err(err)), + // Spurious readiness: wait again. + Err(_would_block) => {} + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn loopback_link(links: &[LinkMessage]) -> &LinkMessage { + links + .iter() + .find(|link| link.name.as_deref() == Some("lo")) + .expect("no loopback link") + } + + #[test] + fn test_sync_dump_links() { + let mut conn = Connection::new().unwrap(); + let links = conn.dump_links().unwrap(); + let lo = loopback_link(&links); + assert!(lo.index >= 1); + assert_ne!(lo.flags & libc::IFF_LOOPBACK as u32, 0); + } + + #[test] + fn test_sync_dump_addresses() { + let mut conn = Connection::new().unwrap(); + let addresses = conn.dump_addresses().unwrap(); + let localhost = addresses + .iter() + .find(|addr| addr.interface_address() == Some(std::net::Ipv4Addr::LOCALHOST.into())) + .expect("no loopback address"); + assert_eq!(localhost.prefix_len, 8); + assert_eq!(localhost.family as i32, libc::AF_INET); + } + + #[test] + fn test_sync_dump_routes() { + let mut conn = Connection::new().unwrap(); + // The route table may be empty in minimal namespaces; only assert + // that dumping works and parses. + let routes = conn.dump_routes(RouteFamily::Ipv4).unwrap(); + for route in &routes { + assert_eq!(route.family as i32, libc::AF_INET); + } + let _ = conn.dump_routes(RouteFamily::Unspec).unwrap(); + } + + #[tokio::test] + async fn test_async_dumps_and_link_by_index() { + let mut conn = AsyncConnection::new().unwrap(); + let links = conn.dump_links().await.unwrap(); + let lo = loopback_link(&links).clone(); + + let addresses = conn.dump_addresses().await.unwrap(); + assert!(addresses.iter().any(|addr| addr.index == lo.index)); + + let _ = conn.dump_routes(RouteFamily::Ipv6).await.unwrap(); + + let link = conn.get_link_by_index(lo.index).await.unwrap().unwrap(); + assert_eq!(link.name.as_deref(), Some("lo")); + } + + #[tokio::test] + async fn test_get_link_by_index_missing() { + let mut conn = AsyncConnection::new().unwrap(); + let res = conn.get_link_by_index(u32::MAX - 7).await; + assert!(matches!(res, Err(Error::ErrorMessage { .. }))); + } + + #[tokio::test] + async fn test_event_socket_subscribes() { + let groups = crate::group_flag(libc::RTNLGRP_IPV4_IFADDR) + | crate::group_flag(libc::RTNLGRP_IPV6_IFADDR) + | crate::group_flag(libc::RTNLGRP_IPV4_ROUTE) + | crate::group_flag(libc::RTNLGRP_IPV6_ROUTE); + let mut events = EventSocket::subscribe(groups).unwrap(); + // No events are expected in an idle test environment; just make + // sure waiting does not error out immediately. + let next = tokio::time::timeout(Duration::from_millis(50), events.next()).await; + assert!(next.is_err(), "unexpected event: {next:?}"); + } +} diff --git a/netwatch-netlink/src/lib.rs b/netwatch-netlink/src/lib.rs new file mode 100644 index 00000000..3f6ee46c --- /dev/null +++ b/netwatch-netlink/src/lib.rs @@ -0,0 +1,71 @@ +//! Minimal rtnetlink client for netwatch. +//! +//! Covers exactly the subset of the netlink route protocol that netwatch +//! uses on Linux and Android: +//! +//! - dumping links, addresses and routes ([`Connection`] for blocking +//! callers, [`AsyncConnection`] inside tokio), +//! - looking up a single link by interface index, +//! - listening to rtnetlink multicast groups for change events +//! ([`EventSocket`]). +//! +//! Messages are parsed into the small typed structs in this crate +//! ([`LinkMessage`], [`AddressMessage`], [`RouteMessage`]); attributes we do +//! not use are skipped. On every platform other than Linux and Android the +//! crate compiles to nothing. +#![cfg(any(target_os = "linux", target_os = "android"))] + +use n0_error::stack_error; + +mod conn; +mod message; +mod wire; + +pub use self::{ + conn::{AsyncConnection, Connection, EventSocket}, + message::{AddressMessage, LinkMessage, Message, RouteFamily, RouteMessage}, +}; + +/// Errors surfaced by the netlink socket wrappers. +#[stack_error(derive, add_meta, from_sources, std_sources)] +#[non_exhaustive] +pub enum Error { + /// A socket operation failed. + #[error("IO")] + Io { source: std::io::Error }, + /// The kernel replied with an `NLMSG_ERROR` message. + /// + /// `code` is a negative errno value. + #[error("netlink error message: code {code}")] + ErrorMessage { code: i32 }, + /// The kernel dropped events because the socket buffer overran. + /// + /// Subscribers should resynchronize by re-reading the state they care + /// about. + #[error("netlink socket buffer overrun")] + Overrun {}, + /// The kernel signaled the end of a multipart message on an event + /// socket. + /// + /// Subscribers should treat this like a lost connection and + /// resubscribe. + #[error("end of multipart message")] + Done {}, + /// A datagram did not fit the receive buffer and was truncated. + #[error("truncated netlink datagram")] + Truncated {}, +} + +/// Returns the `sockaddr_nl` group mask bit for an rtnetlink multicast +/// group. +/// +/// Only the first 31 groups can be subscribed through the bind mask; all +/// `RTNLGRP_*` groups netwatch uses fall in that range. +/// +/// # Panics +/// +/// Panics when `group` is larger than 31. +pub const fn group_flag(group: u32) -> u32 { + assert!(group <= 31, "group not reachable via the bind mask"); + if group == 0 { 0 } else { 1 << (group - 1) } +} diff --git a/netwatch-netlink/src/message.rs b/netwatch-netlink/src/message.rs new file mode 100644 index 00000000..5e4c70c5 --- /dev/null +++ b/netwatch-netlink/src/message.rs @@ -0,0 +1,578 @@ +//! Typed rtnetlink messages and requests. +//! +//! Only the message families and attributes netwatch consumes are parsed; +//! everything else is preserved as [`Message::Other`] or skipped. + +use std::net::IpAddr; + +use crate::wire::{self, AttrIter}; + +// Attribute types not exposed by the libc crate. +const IFLA_ADDRESS: u16 = 1; +const IFLA_IFNAME: u16 = 3; +const IFA_ADDRESS: u16 = 1; +const IFA_LOCAL: u16 = 2; +const IFA_FLAGS: u16 = 8; +const RTA_DST: u16 = 1; +const RTA_OIF: u16 = 4; +const RTA_GATEWAY: u16 = 5; +const RTA_TABLE: u16 = 15; + +/// Address family selector for route dumps. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RouteFamily { + /// Dump routes of every family. + Unspec, + /// Dump IPv4 routes. + Ipv4, + /// Dump IPv6 routes. + Ipv6, +} + +impl RouteFamily { + fn family(self) -> u8 { + match self { + RouteFamily::Unspec => libc::AF_UNSPEC as u8, + RouteFamily::Ipv4 => libc::AF_INET as u8, + RouteFamily::Ipv6 => libc::AF_INET6 as u8, + } + } +} + +/// A parsed `RTM_NEWLINK` or `RTM_DELLINK` message. +#[derive(Debug, Clone)] +pub struct LinkMessage { + /// Interface index from `ifinfomsg`. + pub index: u32, + /// Interface flags (`IFF_*`) from `ifinfomsg`. + pub flags: u32, + /// Interface name from `IFLA_IFNAME`. + pub name: Option, + /// Hardware address bytes from `IFLA_ADDRESS`. + pub address: Option>, +} + +impl LinkMessage { + /// Parses the message from an `ifinfomsg` payload. + /// + /// Wire layout: family `u8`, pad `u8`, type `u16`, index `i32`, flags + /// `u32`, change mask `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let index = wire::read_i32(data, 4)? as u32; + let flags = wire::read_u32(data, 8)?; + let mut name = None; + let mut address = None; + for (kind, payload) in AttrIter::new(data.get(16..)?) { + match kind { + IFLA_IFNAME => name = parse_string(payload), + IFLA_ADDRESS => address = Some(payload.to_vec()), + _ => {} + } + } + Some(Self { + index, + flags, + name, + address, + }) + } +} + +/// A parsed `RTM_NEWADDR` or `RTM_DELADDR` message. +#[derive(Debug, Clone)] +pub struct AddressMessage { + /// Address family (`AF_INET` or `AF_INET6`) from `ifaddrmsg`. + pub family: u8, + /// Prefix length of the address. + pub prefix_len: u8, + /// Address scope (`RT_SCOPE_*`) from `ifaddrmsg`. + pub scope: u8, + /// Interface index the address belongs to. + pub index: u32, + /// The address from `IFA_ADDRESS`. + /// + /// For IPv4 this is the peer address on point-to-point links and equal + /// to [`AddressMessage::local`] everywhere else; for IPv6 it is the + /// interface address. + pub address: Option, + /// The local interface address from `IFA_LOCAL`, when present. + pub local: Option, + /// Address flags (`IFA_F_*`) from the `IFA_FLAGS` attribute. + /// + /// `None` when the kernel did not send the attribute; use + /// [`AddressMessage::flags`] for the value with the header fallback + /// applied. + pub flags_attr: Option, + /// Address flags from the `ifaddrmsg` header byte. + /// + /// Truncated to eight bits; superseded by `flags_attr` when present. + pub header_flags: u8, +} + +impl AddressMessage { + /// Parses the message from an `ifaddrmsg` payload. + /// + /// Wire layout: family `u8`, prefixlen `u8`, flags `u8`, scope `u8`, + /// index `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let family = *data.first()?; + let prefix_len = *data.get(1)?; + let header_flags = *data.get(2)?; + let scope = *data.get(3)?; + let index = wire::read_u32(data, 4)?; + let mut address = None; + let mut local = None; + let mut flags_attr = None; + for (kind, payload) in AttrIter::new(data.get(8..)?) { + match kind { + IFA_ADDRESS => address = parse_ip(family, payload), + IFA_LOCAL => local = parse_ip(family, payload), + IFA_FLAGS => flags_attr = wire::read_u32(payload, 0), + _ => {} + } + } + Some(Self { + family, + prefix_len, + scope, + index, + address, + local, + flags_attr, + header_flags, + }) + } + + /// The address flags (`IFA_F_*`), preferring the 32-bit `IFA_FLAGS` + /// attribute over the truncated header byte. + pub fn flags(&self) -> u32 { + self.flags_attr.unwrap_or(self.header_flags as u32) + } + + /// The interface address, preferring `IFA_LOCAL` over `IFA_ADDRESS`. + /// + /// This matches what `ip addr` shows: for IPv4 the kernel puts the + /// interface address in `IFA_LOCAL` and the (peer) address in + /// `IFA_ADDRESS`; for IPv6 only `IFA_ADDRESS` is sent. + pub fn interface_address(&self) -> Option { + self.local.or(self.address) + } +} + +/// A parsed `RTM_NEWROUTE` or `RTM_DELROUTE` message. +#[derive(Debug, Clone)] +pub struct RouteMessage { + /// Address family (`AF_INET` or `AF_INET6`) from `rtmsg`. + pub family: u8, + /// Prefix length of the destination; zero for default routes. + pub dst_len: u8, + /// Routing table id from the `RTA_TABLE` attribute. + /// + /// `None` when the kernel did not send the attribute. The legacy + /// eight-bit `rtmsg` table byte is intentionally not exposed: netwatch + /// has always used only the attribute. + pub table: Option, + /// Route destination from `RTA_DST`; absent for default routes. + pub destination: Option, + /// Gateway address from `RTA_GATEWAY`. + pub gateway: Option, + /// Output interface index from `RTA_OIF`. + pub oif: Option, +} + +impl RouteMessage { + /// Parses the message from an `rtmsg` payload. + /// + /// Wire layout: family, dst_len, src_len, tos, table, protocol, scope, + /// type (all `u8`), flags `u32`, then attributes. + fn parse(data: &[u8]) -> Option { + let family = *data.first()?; + let dst_len = *data.get(1)?; + let mut table = None; + let mut destination = None; + let mut gateway = None; + let mut oif = None; + for (kind, payload) in AttrIter::new(data.get(12..)?) { + match kind { + RTA_TABLE => table = wire::read_u32(payload, 0), + RTA_DST => destination = parse_ip(family, payload), + RTA_GATEWAY => gateway = parse_ip(family, payload), + RTA_OIF => oif = wire::read_u32(payload, 0), + _ => {} + } + } + Some(Self { + family, + dst_len, + table, + destination, + gateway, + oif, + }) + } +} + +/// A parsed rtnetlink message. +/// +/// Rule messages carry no payload here because netwatch only reacts to +/// their presence. +#[derive(Debug, Clone)] +pub enum Message { + /// A link was added or changed. + NewLink(LinkMessage), + /// A link was removed. + DelLink(LinkMessage), + /// An address was added or changed. + NewAddress(AddressMessage), + /// An address was removed. + DelAddress(AddressMessage), + /// A route was added or changed. + NewRoute(RouteMessage), + /// A route was removed. + DelRoute(RouteMessage), + /// A routing policy rule was added. + NewRule, + /// A routing policy rule was removed. + DelRule, + /// Any other rtnetlink message type. + Other { + /// The `nlmsghdr` message type. + kind: u16, + }, +} + +impl Message { + /// Parses a message payload for the given `nlmsghdr` type. + /// + /// Returns `None` when the payload is too short for its fixed header. + pub(crate) fn parse(kind: u16, payload: &[u8]) -> Option { + let message = match kind { + libc::RTM_NEWLINK => Message::NewLink(LinkMessage::parse(payload)?), + libc::RTM_DELLINK => Message::DelLink(LinkMessage::parse(payload)?), + libc::RTM_NEWADDR => Message::NewAddress(AddressMessage::parse(payload)?), + libc::RTM_DELADDR => Message::DelAddress(AddressMessage::parse(payload)?), + libc::RTM_NEWROUTE => Message::NewRoute(RouteMessage::parse(payload)?), + libc::RTM_DELROUTE => Message::DelRoute(RouteMessage::parse(payload)?), + libc::RTM_NEWRULE => Message::NewRule, + libc::RTM_DELRULE => Message::DelRule, + kind => Message::Other { kind }, + }; + Some(message) + } +} + +/// One frame of a received datagram, before dump or event bookkeeping. +#[derive(Debug)] +pub(crate) enum Frame { + /// An rtnetlink message. + Message { seq: u32, message: Message }, + /// `NLMSG_DONE`, the end of a multipart response. + Done { seq: u32 }, + /// `NLMSG_ERROR`; `code` is zero for acknowledgments. + Error { seq: u32, code: i32 }, + /// A frame to ignore (noop, overrun or malformed). + Skip, +} + +/// Splits a received datagram into its netlink frames. +/// +/// Iteration stops at the first frame whose length field is inconsistent +/// with the remaining data. +pub(crate) fn parse_frames(datagram: &[u8]) -> impl Iterator + '_ { + let mut data = datagram; + std::iter::from_fn(move || { + let header = wire::Header::parse(data)?; + let len = header.len as usize; + if len < wire::HEADER_LEN || len > data.len() { + return None; + } + let payload = &data[wire::HEADER_LEN..len]; + data = data.get(wire::align(len)..).unwrap_or_default(); + let frame = match header.kind as i32 { + libc::NLMSG_DONE => Frame::Done { seq: header.seq }, + libc::NLMSG_ERROR => match wire::read_i32(payload, 0) { + Some(code) => Frame::Error { + seq: header.seq, + code, + }, + None => Frame::Skip, + }, + libc::NLMSG_NOOP | libc::NLMSG_OVERRUN => Frame::Skip, + _ => match Message::parse(header.kind, payload) { + Some(message) => Frame::Message { + seq: header.seq, + message, + }, + None => Frame::Skip, + }, + }; + Some(frame) + }) +} + +/// Builds an `RTM_GETLINK` dump request. +pub(crate) fn dump_links_request(seq: u32) -> Vec { + dump_request(libc::RTM_GETLINK, seq, &[0u8; 16]) +} + +/// Builds an `RTM_GETADDR` dump request covering both address families. +pub(crate) fn dump_addresses_request(seq: u32) -> Vec { + dump_request(libc::RTM_GETADDR, seq, &[0u8; 8]) +} + +/// Builds an `RTM_GETROUTE` dump request. +/// +/// The filter fields (main table, static protocol, unicast type) match what +/// netwatch has always sent; the kernel ignores them for non-strict dumps +/// and filters by family only. +pub(crate) fn dump_routes_request(seq: u32, family: RouteFamily) -> Vec { + let mut rtmsg = [0u8; 12]; + rtmsg[0] = family.family(); + rtmsg[4] = libc::RT_TABLE_MAIN; + rtmsg[5] = libc::RTPROT_STATIC; + rtmsg[6] = libc::RT_SCOPE_UNIVERSE; + rtmsg[7] = libc::RTN_UNICAST; + dump_request(libc::RTM_GETROUTE, seq, &rtmsg) +} + +/// Builds an `RTM_GETLINK` request for a single interface index. +pub(crate) fn get_link_request(seq: u32, index: u32) -> Vec { + let mut ifinfomsg = [0u8; 16]; + ifinfomsg[4..8].copy_from_slice(&(index as i32).to_ne_bytes()); + let mut buf = Vec::with_capacity(wire::HEADER_LEN + ifinfomsg.len()); + wire::push_header( + &mut buf, + libc::RTM_GETLINK, + libc::NLM_F_REQUEST as u16, + seq, + ifinfomsg.len(), + ); + buf.extend_from_slice(&ifinfomsg); + buf +} + +fn dump_request(kind: u16, seq: u32, fixed_header: &[u8]) -> Vec { + let mut buf = Vec::with_capacity(wire::HEADER_LEN + fixed_header.len()); + let flags = (libc::NLM_F_REQUEST | libc::NLM_F_DUMP) as u16; + wire::push_header(&mut buf, kind, flags, seq, fixed_header.len()); + buf.extend_from_slice(fixed_header); + buf +} + +/// Parses a NUL-terminated attribute payload into a string. +fn parse_string(payload: &[u8]) -> Option { + let end = payload + .iter() + .position(|&b| b == 0) + .unwrap_or(payload.len()); + String::from_utf8(payload[..end].to_vec()).ok() +} + +/// Parses an address attribute payload according to the message family. +fn parse_ip(family: u8, payload: &[u8]) -> Option { + if family as i32 == libc::AF_INET { + let bytes: [u8; 4] = payload.get(..4)?.try_into().ok()?; + Some(IpAddr::from(bytes)) + } else if family as i32 == libc::AF_INET6 { + let bytes: [u8; 16] = payload.get(..16)?.try_into().ok()?; + Some(IpAddr::from(bytes)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + + use super::*; + use crate::wire; + + /// Builds a response frame the way the kernel would. + fn build_frame(kind: u16, seq: u32, payload: &[u8]) -> Vec { + let mut buf = Vec::new(); + wire::push_header(&mut buf, kind, libc::NLM_F_MULTI as u16, seq, payload.len()); + buf.extend_from_slice(payload); + buf.resize(wire::align(buf.len()), 0); + buf + } + + fn push_attr(buf: &mut Vec, kind: u16, payload: &[u8]) { + let len = (4 + payload.len()) as u16; + buf.extend_from_slice(&len.to_ne_bytes()); + buf.extend_from_slice(&kind.to_ne_bytes()); + buf.extend_from_slice(payload); + buf.resize(wire::align(buf.len()), 0); + } + + #[test] + fn test_parse_link_message() { + let mut payload = vec![0u8; 16]; + payload[4..8].copy_from_slice(&2i32.to_ne_bytes()); + payload[8..12].copy_from_slice(&0x1003u32.to_ne_bytes()); + push_attr(&mut payload, IFLA_IFNAME, b"eth0\0"); + push_attr(&mut payload, IFLA_ADDRESS, &[1, 2, 3, 4, 5, 6]); + + let datagram = build_frame(libc::RTM_NEWLINK, 1, &payload); + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert_eq!(frames.len(), 1); + let Frame::Message { + seq: 1, + message: Message::NewLink(link), + } = &frames[0] + else { + panic!("expected NewLink, got {:?}", frames[0]); + }; + assert_eq!(link.index, 2); + assert_eq!(link.flags, 0x1003); + assert_eq!(link.name.as_deref(), Some("eth0")); + assert_eq!(link.address.as_deref(), Some(&[1, 2, 3, 4, 5, 6][..])); + } + + #[test] + fn test_parse_address_message_v4() { + let mut payload = vec![0u8; 8]; + payload[0] = libc::AF_INET as u8; + payload[1] = 24; // prefix len + payload[2] = 0x80; // header flags: IFA_F_PERMANENT + payload[4..8].copy_from_slice(&3u32.to_ne_bytes()); + push_attr(&mut payload, IFA_ADDRESS, &[192, 168, 0, 255]); + push_attr(&mut payload, IFA_LOCAL, &[192, 168, 0, 1]); + + let datagram = build_frame(libc::RTM_NEWADDR, 2, &payload); + let Some(Frame::Message { + message: Message::NewAddress(addr), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewAddress"); + }; + assert_eq!(addr.prefix_len, 24); + assert_eq!(addr.index, 3); + assert_eq!( + addr.address, + Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 255))) + ); + assert_eq!(addr.local, Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1)))); + assert_eq!( + addr.interface_address(), + Some(IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))) + ); + // No IFA_FLAGS attribute: the header byte is used. + assert_eq!(addr.flags(), 0x80); + } + + #[test] + fn test_parse_address_message_v6_flags_attr() { + let ip = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let mut payload = vec![0u8; 8]; + payload[0] = libc::AF_INET6 as u8; + payload[1] = 64; + payload[2] = 0x80; + payload[4..8].copy_from_slice(&5u32.to_ne_bytes()); + push_attr(&mut payload, IFA_ADDRESS, &ip.octets()); + push_attr(&mut payload, IFA_FLAGS, &0x81u32.to_ne_bytes()); + + let datagram = build_frame(libc::RTM_NEWADDR, 2, &payload); + let Some(Frame::Message { + message: Message::NewAddress(addr), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewAddress"); + }; + assert_eq!(addr.address, Some(IpAddr::V6(ip))); + assert_eq!(addr.interface_address(), Some(IpAddr::V6(ip))); + // IFA_FLAGS wins over the header byte. + assert_eq!(addr.flags(), 0x81); + } + + #[test] + fn test_parse_route_message() { + let mut payload = vec![0u8; 12]; + payload[0] = libc::AF_INET as u8; + payload[1] = 0; // default route + push_attr(&mut payload, RTA_TABLE, &254u32.to_ne_bytes()); + push_attr(&mut payload, RTA_GATEWAY, &[10, 0, 0, 1]); + push_attr(&mut payload, RTA_OIF, &2u32.to_ne_bytes()); + + let datagram = build_frame(libc::RTM_NEWROUTE, 3, &payload); + let Some(Frame::Message { + message: Message::NewRoute(route), + .. + }) = parse_frames(&datagram).next() + else { + panic!("expected NewRoute"); + }; + assert_eq!(route.dst_len, 0); + assert_eq!(route.table, Some(254)); + assert_eq!(route.gateway, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))); + assert_eq!(route.oif, Some(2)); + assert_eq!(route.destination, None); + } + + #[test] + fn test_parse_multipart_with_done() { + let mut payload = vec![0u8; 12]; + payload[0] = libc::AF_INET as u8; + let mut datagram = build_frame(libc::RTM_NEWROUTE, 4, &payload); + datagram.extend_from_slice(&build_frame( + libc::NLMSG_DONE as u16, + 4, + &0i32.to_ne_bytes(), + )); + + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert_eq!(frames.len(), 2); + assert!(matches!(frames[0], Frame::Message { seq: 4, .. })); + assert!(matches!(frames[1], Frame::Done { seq: 4 })); + } + + #[test] + fn test_parse_error_frame() { + let mut payload = Vec::new(); + payload.extend_from_slice(&(-13i32).to_ne_bytes()); + payload.extend_from_slice(&[0u8; 16]); // echoed request header + let datagram = build_frame(libc::NLMSG_ERROR as u16, 5, &payload); + let frames: Vec<_> = parse_frames(&datagram).collect(); + assert!(matches!(frames[0], Frame::Error { seq: 5, code: -13 })); + } + + #[test] + fn test_parse_rule_messages() { + let datagram = build_frame(libc::RTM_NEWRULE, 6, &[0u8; 12]); + let Some(Frame::Message { message, .. }) = parse_frames(&datagram).next() else { + panic!("expected message"); + }; + assert!(matches!(message, Message::NewRule)); + } + + #[test] + fn test_requests_have_valid_headers() { + for (buf, kind, payload_len) in [ + (dump_links_request(1), libc::RTM_GETLINK, 16), + (dump_addresses_request(2), libc::RTM_GETADDR, 8), + ( + dump_routes_request(3, RouteFamily::Ipv4), + libc::RTM_GETROUTE, + 12, + ), + (get_link_request(4, 7), libc::RTM_GETLINK, 16), + ] { + let header = wire::Header::parse(&buf).unwrap(); + assert_eq!(header.kind, kind); + assert_eq!(header.len as usize, buf.len()); + assert_eq!(buf.len(), wire::HEADER_LEN + payload_len); + let flags = wire::read_u16(&buf, 6).unwrap(); + assert_ne!(flags & libc::NLM_F_REQUEST as u16, 0); + } + } + + #[test] + fn test_route_family_bytes() { + let buf = dump_routes_request(1, RouteFamily::Ipv6); + assert_eq!(buf[wire::HEADER_LEN], libc::AF_INET6 as u8); + let buf = dump_routes_request(1, RouteFamily::Unspec); + assert_eq!(buf[wire::HEADER_LEN], 0); + } +} diff --git a/netwatch-netlink/src/wire.rs b/netwatch-netlink/src/wire.rs new file mode 100644 index 00000000..87ec0958 --- /dev/null +++ b/netwatch-netlink/src/wire.rs @@ -0,0 +1,148 @@ +//! Netlink wire format primitives. +//! +//! Netlink messages are native endian and 4-byte aligned: a 16-byte +//! `nlmsghdr`, a fixed per-family header, then a list of attributes, each a +//! 4-byte `rtattr` header (length including the header, then type) followed +//! by the padded payload. + +/// Length of `nlmsghdr`. +pub(crate) const HEADER_LEN: usize = 16; + +/// Rounds `len` up to the 4-byte netlink alignment. +pub(crate) const fn align(len: usize) -> usize { + (len + 3) & !3 +} + +/// A parsed `nlmsghdr`. +#[derive(Debug, Clone, Copy)] +pub(crate) struct Header { + pub(crate) len: u32, + pub(crate) kind: u16, + pub(crate) seq: u32, +} + +impl Header { + /// Parses the header from the start of `data`. + pub(crate) fn parse(data: &[u8]) -> Option { + if data.len() < HEADER_LEN { + return None; + } + Some(Self { + len: read_u32(data, 0)?, + kind: read_u16(data, 4)?, + // offset 6: flags, only meaningful in requests + seq: read_u32(data, 8)?, + // offset 12: pid, unused + }) + } +} + +/// Appends an `nlmsghdr` for a request whose payload is `payload_len` bytes. +pub(crate) fn push_header(buf: &mut Vec, kind: u16, flags: u16, seq: u32, payload_len: usize) { + let len = (HEADER_LEN + payload_len) as u32; + buf.extend_from_slice(&len.to_ne_bytes()); + buf.extend_from_slice(&kind.to_ne_bytes()); + buf.extend_from_slice(&flags.to_ne_bytes()); + buf.extend_from_slice(&seq.to_ne_bytes()); + buf.extend_from_slice(&0u32.to_ne_bytes()); // pid: kernel fills in the sender +} + +/// Iterator over the attributes of a message payload. +/// +/// Yields `(type, payload)` pairs and stops at the first malformed +/// attribute. +pub(crate) struct AttrIter<'a> { + data: &'a [u8], +} + +impl<'a> AttrIter<'a> { + pub(crate) fn new(data: &'a [u8]) -> Self { + Self { data } + } +} + +impl<'a> Iterator for AttrIter<'a> { + type Item = (u16, &'a [u8]); + + fn next(&mut self) -> Option { + let len = read_u16(self.data, 0)? as usize; + let kind = read_u16(self.data, 2)?; + if len < 4 || len > self.data.len() { + return None; + } + let payload = &self.data[4..len]; + self.data = self.data.get(align(len)..).unwrap_or_default(); + Some((kind, payload)) + } +} + +pub(crate) fn read_u16(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 2)?; + Some(u16::from_ne_bytes( + bytes.try_into().expect("length checked"), + )) +} + +pub(crate) fn read_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_ne_bytes( + bytes.try_into().expect("length checked"), + )) +} + +pub(crate) fn read_i32(data: &[u8], offset: usize) -> Option { + read_u32(data, offset).map(|v| v as i32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_align() { + assert_eq!(align(0), 0); + assert_eq!(align(1), 4); + assert_eq!(align(4), 4); + assert_eq!(align(5), 8); + } + + #[test] + fn test_header_roundtrip() { + let mut buf = Vec::new(); + push_header(&mut buf, 18, 0x301, 7, 16); + let header = Header::parse(&buf).unwrap(); + assert_eq!(header.len, 32); + assert_eq!(header.kind, 18); + assert_eq!(read_u16(&buf, 6), Some(0x301)); + assert_eq!(header.seq, 7); + } + + #[test] + fn test_attr_iter() { + let mut data = Vec::new(); + // attr 1: type 3, payload "lo\0" (len 7, padded to 8) + data.extend_from_slice(&7u16.to_ne_bytes()); + data.extend_from_slice(&3u16.to_ne_bytes()); + data.extend_from_slice(b"lo\0\0"); + // attr 2: type 4, payload u32 + data.extend_from_slice(&8u16.to_ne_bytes()); + data.extend_from_slice(&4u16.to_ne_bytes()); + data.extend_from_slice(&1500u32.to_ne_bytes()); + + let attrs: Vec<_> = AttrIter::new(&data).collect(); + assert_eq!(attrs.len(), 2); + assert_eq!(attrs[0], (3, &b"lo\0"[..])); + assert_eq!(attrs[1].0, 4); + assert_eq!(read_u32(attrs[1].1, 0), Some(1500)); + } + + #[test] + fn test_attr_iter_stops_on_malformed() { + // Claims 12 bytes but only 8 are present. + let mut data = Vec::new(); + data.extend_from_slice(&12u16.to_ne_bytes()); + data.extend_from_slice(&1u16.to_ne_bytes()); + data.extend_from_slice(&0u32.to_ne_bytes()); + assert_eq!(AttrIter::new(&data).count(), 0); + } +} diff --git a/netwatch/Cargo.toml b/netwatch/Cargo.toml index 17b43ff9..778c3580 100644 --- a/netwatch/Cargo.toml +++ b/netwatch/Cargo.toml @@ -41,24 +41,14 @@ libc = "0.2.139" socket2 = { version = "0.6", features = ["all"] } tokio = { version = "1", features = ["rt", "net"] } -# netdev is available on all non-embedded, non-wasm platforms [target.'cfg(not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))))'.dependencies] -netdev = "0.45.0" tokio = { version = "1", features = [ "fs", "io-std", ] } -[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] -# msrv fix -objc2-core-foundation = "0.3.2" -objc2-system-configuration = "0.3.2" - [target.'cfg(any(target_os = "linux", target_os = "android"))'.dependencies] -netlink-packet-route = "0.31.0" -netlink-packet-core = "0.8.1" -netlink-proto = "0.12.0" -netlink-sys = "0.8.7" +netwatch-netlink = { version = "0.1.0", path = "../netwatch-netlink" } [target.'cfg(target_os = "android")'.dependencies] tokio = { version = "1", features = ["process"] } @@ -109,8 +99,7 @@ rustdoc-args = ["--cfg", "iroh_docsrs"] # Types from these external crates are allowed to appear in netwatch's public # API. The list is enforced by `cargo check-external-types` (see the -# `check-external-types` task in Makefile.toml). Notably absent is `netdev`: -# its types must never leak, which is why the `interfaces` module mirrors them. +# `check-external-types` task in Makefile.toml). [package.metadata.cargo_check_external_types] allowed_external_types = [ # IP network types used by `interfaces::IpNet`. diff --git a/netwatch/build.rs b/netwatch/build.rs index 404d4748..6b537c14 100644 --- a/netwatch/build.rs +++ b/netwatch/build.rs @@ -7,10 +7,9 @@ fn main() { wasm_browser: { all(target_family = "wasm", target_os = "unknown") }, // Limited POSIX platforms (not wasm) posix_minimal: { target_os = "espidf" }, - // Platforms where the `netdev` crate is available, i.e. everything - // except esp-idf and wasm-in-browser. Keep in sync with the `netdev` - // dependency target gate in Cargo.toml. - netdev: { not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))) }, + // Platforms with real interface enumeration (the `interfaces::enumerate` + // module), i.e. everything except esp-idf and wasm-in-browser. + enumerate: { not(any(target_os = "espidf", all(target_family = "wasm", target_os = "unknown"))) }, // BSD-derived platforms that share the `AF_ROUTE` routing-socket code. bsd: { any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd", target_os = "macos", target_os = "ios") }, } diff --git a/netwatch/src/interfaces.rs b/netwatch/src/interfaces.rs index 9073aea5..8e14c726 100644 --- a/netwatch/src/interfaces.rs +++ b/netwatch/src/interfaces.rs @@ -3,8 +3,9 @@ //! All public types are defined here once and have the same shape on every //! platform. The platform-specific work of enumerating interfaces, finding the //! default route, and locating the home router lives in the submodules below, -//! each reached through the cfg-selected `platform` alias. Conversion from the -//! `netdev` crate is confined to the `netdev_impl` module. +//! each reached through the cfg-selected `platform` alias. Interface +//! enumeration and state assembly shared by the full platforms lives in the +//! `enumerate` module. use std::{ collections::HashMap, @@ -19,9 +20,9 @@ use crate::ip::{LocalAddresses, is_link_local}; // Each platform module provides the same three entry points, reached through // the `platform` alias: `get_state()`, `default_route()` and `home_router()`. -// The `netdev`-capable modules share enumeration via `netdev_impl`. -#[cfg(netdev)] -mod netdev_impl; +// The full platforms share enumeration and state assembly via `enumerate`. +#[cfg(enumerate)] +mod enumerate; #[cfg(bsd)] pub(super) mod bsd; @@ -53,9 +54,8 @@ const IFF_UP: u32 = 0x1; /// State flags for a single IPv6 address. /// -/// Hand-kept mirror of netdev's `Ipv6AddrFlags`, so the `interfaces` API is -/// identical on platforms built without `netdev` (e.g. esp-idf). All fields -/// default to `false` when the platform does not provide the information. +/// All fields default to `false` when the platform does not provide the +/// information (e.g. esp-idf, which has no interface enumeration). /// /// Flags are collected from platform-specific sources: /// diff --git a/netwatch/src/interfaces/bsd.rs b/netwatch/src/interfaces/bsd.rs index c5df5f50..46989a0c 100644 --- a/netwatch/src/interfaces/bsd.rs +++ b/netwatch/src/interfaces/bsd.rs @@ -16,7 +16,7 @@ use libc::{ use n0_error::{e, ensure, stack_error}; use tracing::warn; -pub(super) use super::netdev_impl::get_state; +pub(super) use super::enumerate::get_state; use super::{DefaultRouteDetails, HomeRouter}; #[cfg(target_os = "freebsd")] @@ -37,9 +37,12 @@ mod macos; #[cfg(any(target_os = "macos", target_os = "ios"))] use self::macos::*; +// The signature is part of the cross-platform contract; other platforms +// genuinely await in their implementations. +#[allow(clippy::unused_async)] pub async fn default_route() -> Option { let idx = default_route_interface_index()?; - let interfaces = netdev::get_interfaces(); + let interfaces = super::enumerate::interfaces(); let iface = interfaces.into_iter().find(|i| i.index == idx)?; Some(DefaultRouteDetails { @@ -49,14 +52,13 @@ pub async fn default_route() -> Option { /// Locates the home router via the routing table. /// -/// `netdev` cannot yet determine the default gateway on BSD platforms (see -/// ), so this parses the -/// routing table directly. The local IP still comes from `netdev`. +/// BSD platforms parse the routing table directly rather than going through +/// the shared `default_gateway()` backend primitive. pub(super) fn home_router() -> Option { let gateway = likely_home_router()?; Some(HomeRouter { gateway, - my_ip: super::netdev_impl::local_ip(), + my_ip: super::enumerate::local_ip(), }) } diff --git a/netwatch/src/interfaces/enumerate.rs b/netwatch/src/interfaces/enumerate.rs new file mode 100644 index 00000000..4b7477e4 --- /dev/null +++ b/netwatch/src/interfaces/enumerate.rs @@ -0,0 +1,440 @@ +//! Interface enumeration and state assembly. +//! +//! [`get_state`], [`home_router`] and [`LocalAddresses`] are assembled here +//! from two platform primitives: `interfaces()`, the list of network +//! interfaces in our own [`Interface`] type, and `default_gateway()`, the +//! gateway address of the default route. BSD platforms do not use +//! `default_gateway()`; they parse the routing table in the `bsd` module +//! of `crate::interfaces` instead. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; + +use ipnet::{Ipv4Net, Ipv6Net}; + +use super::{Interface, IpNet, Ipv6AddrFlags, State}; +use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; + +#[cfg(target_os = "windows")] +mod adapters; +#[cfg(any(target_os = "linux", target_os = "android", bsd))] +mod ifaddrs; +#[cfg(any(target_os = "linux", target_os = "android"))] +mod netlink; +#[cfg(target_os = "linux")] +mod procfs; + +/// The interface flag bit indicating a loopback interface. +/// +/// Matches the POSIX `IFF_LOOPBACK` value. The windows backend synthesizes +/// BSD-style flags but maps loopback to the winsock value `0x4`, so this +/// bit is never set there; loopback addresses are still classified by +/// [`IpAddr::is_loopback`]. +const IFF_LOOPBACK: u32 = 0x8; + +/// Enumerates the machine's network interfaces. +/// +/// Prefers netlink dumps and falls back to getifaddrs when they fail, +/// which notably happens on Android 11+ where SELinux denies netlink link +/// dumps to apps. +#[cfg(any(target_os = "linux", target_os = "android"))] +pub(super) fn interfaces() -> Vec { + match netlink::interfaces() { + Ok(ifaces) => ifaces, + Err(err) => { + tracing::debug!("netlink enumeration failed ({err:?}), falling back to getifaddrs"); + ifaddrs::interfaces() + } + } +} + +/// Enumerates the machine's network interfaces. +#[cfg(bsd)] +pub(super) fn interfaces() -> Vec { + ifaddrs::interfaces() +} + +/// Enumerates the machine's network interfaces. +#[cfg(target_os = "windows")] +pub(super) fn interfaces() -> Vec { + adapters::interfaces() +} + +/// The gateway address of the default route. +/// +/// Follows netdev's algorithm: find the interface owning the local IP the +/// OS routes outbound traffic through, then return that interface's +/// default-route gateway from a netlink route dump, with a procfs fallback +/// on linux when the dump fails. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn default_gateway() -> Option { + let local_ip = local_ip()?; + let ifaces = interfaces(); + // The first interface owning the local IP that also has a gateway + // wins, like in netdev's algorithm. + let owners = ifaces + .iter() + .filter(|iface| iface.addrs.iter().any(|net| net.addr() == local_ip)); + match netlink::default_gateways_by_interface() { + Ok(mut gateways) => owners + .filter_map(|iface| { + let (v4, v6) = gateways.remove(&iface.index)?; + v4.first() + .copied() + .map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + }) + .next(), + #[cfg(target_os = "linux")] + Err(err) => { + tracing::debug!("netlink route dump failed ({err:?}), trying procfs"); + let mut gateways = procfs::gateways_by_interface_name(); + owners + .filter_map(|iface| { + let (v4, v6) = gateways.remove(iface.name())?; + v4.map(IpAddr::V4) + .or_else(|| v6.first().copied().map(IpAddr::V6)) + }) + .next() + } + // Android has no readable /proc/net/route; without netlink there + // is no gateway source. + #[cfg(target_os = "android")] + Err(_) => None, + } +} + +/// The gateway address of the default route. +#[cfg(target_os = "windows")] +fn default_gateway() -> Option { + adapters::default_gateway() +} + +/// Accumulates one [`Interface`] during enumeration. +/// +/// The push methods deduplicate addresses on (address, prefix) pairs the +/// way netdev did, and `finish` produces the stable address order the +/// state comparison relies on. +#[derive(Debug)] +struct IfaceBuilder { + name: String, + index: u32, + flags: u32, + mac: Option<[u8; 6]>, + v4: Vec, + v6: Vec<(Ipv6Net, u32, Ipv6AddrFlags)>, +} + +impl IfaceBuilder { + fn new(name: String, index: u32, flags: u32) -> Self { + Self { + name, + index, + flags, + mac: None, + v4: Vec::new(), + v6: Vec::new(), + } + } + + fn push_v4(&mut self, net: Ipv4Net) { + let duplicate = self + .v4 + .iter() + .any(|have| have.addr() == net.addr() && have.prefix_len() == net.prefix_len()); + if !duplicate { + self.v4.push(net); + } + } + + fn push_v6(&mut self, net: Ipv6Net, scope_id: u32, flags: Ipv6AddrFlags) { + let duplicate = self + .v6 + .iter() + .any(|(have, _, _)| have.addr() == net.addr() && have.prefix_len() == net.prefix_len()); + if !duplicate { + self.v6.push((net, scope_id, flags)); + } + } + + /// Builds the [`Interface`], sorting each address family by address + /// (IPv4 first) so successive snapshots compare equal. + fn finish(self) -> Interface { + let mut v4: Vec = self.v4.into_iter().map(IpNet::V4).collect(); + let mut v6: Vec = self + .v6 + .into_iter() + .map(|(net, scope_id, flags)| IpNet::V6 { + net, + scope_id, + flags, + }) + .collect(); + v4.sort_by_key(IpNet::addr); + v6.sort_by_key(IpNet::addr); + let mut addrs = v4; + addrs.append(&mut v6); + + Interface { + name: self.name, + index: self.index, + flags: self.flags, + mac_addr: self.mac, + addrs, + } + } +} + +/// Returns the scope ID for an IPv6 interface address. +/// +/// Prefers the scope reported by the OS and falls back to the interface +/// index for link-local addresses, which is netdev behavior the rest of +/// the code relies on. +#[cfg(any(target_os = "linux", target_os = "android", bsd))] +fn resolve_v6_scope_id(addr: &Ipv6Addr, raw_scope_id: u32, if_index: u32) -> u32 { + if raw_scope_id != 0 { + raw_scope_id + } else if crate::ip::is_unicast_link_local(*addr) { + if_index + } else { + 0 + } +} + +/// Enumerates the machine's network interfaces and assembles the [`State`]. +pub(super) async fn get_state() -> State { + let ifaces = interfaces(); + let local_addresses = local_addresses(&ifaces); + + let mut interfaces = std::collections::HashMap::new(); + let mut have_v6 = false; + let mut have_v4 = false; + + for iface in ifaces { + if iface.is_up() { + for pfx in iface.addrs() { + let addr = pfx.addr(); + if addr.is_loopback() { + continue; + } + have_v6 |= is_usable_v6(&addr); + have_v4 |= is_usable_v4(&addr); + } + } + interfaces.insert(iface.name().to_string(), iface); + } + + let default_route_interface = super::default_route_interface().await; + + State { + interfaces, + local_addresses, + have_v4, + have_v6, + is_expensive: false, + default_route_interface, + last_unsuspend: None, + } +} + +/// The shared home-router lookup for linux, android and windows. +/// +/// BSD platforms do not use this; they parse the routing table directly +/// in the `bsd` module of `crate::interfaces`. +#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] +pub(super) fn home_router() -> Option { + let gateway = default_gateway()?; + Some(super::HomeRouter { + gateway, + my_ip: local_ip(), + }) +} + +/// The local IP address selected for outbound traffic. +/// +/// Opens a UDP socket and lets the operating system choose the source +/// address it would use to reach a non-routable destination; no packets +/// are sent. +pub(super) fn local_ip() -> Option { + // Binding the IPv4 socket can succeed while a later step fails in + // IPv6-only environments, so fall back to IPv6 whenever any IPv4 step + // fails. + local_ip_family( + SocketAddr::new(IpAddr::V4(Ipv4Addr::UNSPECIFIED), 0), + SocketAddr::new(IpAddr::V4(Ipv4Addr::new(10, 254, 254, 254)), 1), + ) + .or_else(|| { + local_ip_family( + SocketAddr::new(IpAddr::V6(Ipv6Addr::UNSPECIFIED), 0), + SocketAddr::new( + IpAddr::V6(Ipv6Addr::new( + 0xfdff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, 0xffff, + )), + 1, + ), + ) + }) +} + +fn local_ip_family(bind: SocketAddr, probe: SocketAddr) -> Option { + let socket = UdpSocket::bind(bind).ok()?; + socket.connect(probe).ok()?; + Some(socket.local_addr().ok()?.ip()) +} + +/// Reports whether `ip` is a usable IPv4 address which should have Internet connectivity. +/// +/// Globally routable and private IPv4 addresses are always usable, and link-local +/// 169.254.x.x addresses are in some environments. +fn is_usable_v4(ip: &IpAddr) -> bool { + if !ip.is_ipv4() || ip.is_loopback() { + return false; + } + + true +} + +/// Reports whether `ip` is a usable IPv6 address which should have Internet connectivity. +/// +/// Globally routable IPv6 addresses are always usable, and Unique Local Addresses +/// (fc00::/7) are in some environments used with address translation. +/// +/// We consider all 2000::/3 addresses to be routable, which is the interpretation of +/// +/// as well. However this probably includes some addresses which should not be routed, +/// e.g. documentation addresses. See also +/// for an +/// alternative implementation which is both stricter and laxer in some regards. +fn is_usable_v6(ip: &IpAddr) -> bool { + match ip { + IpAddr::V6(ip) => { + // V6 Global1 2000::/3 + let mask: u16 = 0b1110_0000_0000_0000; + let base: u16 = 0x2000; + let segment1 = ip.segments()[0]; + if (base & mask) == (segment1 & mask) { + return true; + } + + is_private_v6(ip) + } + IpAddr::V4(_) => false, + } +} + +const fn is_loopback(interface: &Interface) -> bool { + interface.flags & IFF_LOOPBACK != 0 +} + +/// Builds the machine's [`LocalAddresses`] from an interface list. +/// +/// If there are no regular addresses it falls back to IPv4 link-local or IPv6 +/// unique-local addresses, because we know of environments where these are used +/// with NAT to provide connectivity. +fn local_addresses(ifaces: &[Interface]) -> LocalAddresses { + let mut loopback = Vec::new(); + let mut regular4 = Vec::new(); + let mut regular6 = Vec::new(); + let mut linklocal4 = Vec::new(); + let mut ula6 = Vec::new(); + + for iface in ifaces { + if !iface.is_up() { + // Skip down interfaces + continue; + } + let ifc_is_loopback = is_loopback(iface); + + for ip in iface.addrs().map(|pfx| pfx.addr()) { + let ip = ip.to_canonical(); + + if ip.is_loopback() || ifc_is_loopback { + loopback.push(ip); + } else if is_link_local(ip) { + if ip.is_ipv4() { + linklocal4.push(ip); + } + + // We know of no cases where the IPv6 fe80:: addresses + // are used to provide WAN connectivity. It is also very + // common for users to have no IPv6 WAN connectivity, + // but their OS supports IPv6 so they have an fe80:: + // address. We don't want to report all of those + // IPv6 LL to Control. + } else if ip.is_ipv6() && is_private(&ip) { + // Google Cloud Run uses NAT with IPv6 Unique + // Local Addresses to provide IPv6 connectivity. + ula6.push(ip); + } else if ip.is_ipv4() { + regular4.push(ip); + } else { + regular6.push(ip); + } + } + } + + if regular4.is_empty() && regular6.is_empty() { + // if we have no usable IP addresses then be willing to accept + // addresses we otherwise wouldn't, like: + // + 169.254.x.x (AWS Lambda uses NAT with these) + // + IPv6 ULA (Google Cloud Run uses these with address translation) + regular4 = linklocal4; + regular6 = ula6; + } + let mut regular = regular4; + regular.extend(regular6); + + regular.sort(); + loopback.sort(); + + LocalAddresses { loopback, regular } +} + +impl LocalAddresses { + /// Returns the machine's IP addresses. + /// + /// If there are no regular addresses it will return any IPv4 link-local or + /// IPv6 unique-local addresses, because we know of environments where these + /// are used with NAT to provide connectivity. + pub fn new() -> Self { + local_addresses(&interfaces()) + } +} + +#[cfg(test)] +mod tests { + use std::net::Ipv6Addr; + + use super::*; + + #[test] + fn test_local_addresses() { + let addrs = LocalAddresses::new(); + dbg!(&addrs); + assert!(!addrs.loopback.is_empty()); + assert!(!addrs.regular.is_empty()); + } + + #[test] + fn test_is_usable_v6() { + let loopback = Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0x1); + assert!(!is_usable_v6(&loopback.into())); + + let link_local = Ipv6Addr::new(0xfe80, 0, 0, 0, 0xcbc9, 0x6aff, 0x5b07, 0x4a9e); + assert!(!is_usable_v6(&link_local.into())); + + let relay_use1 = Ipv6Addr::new(0x2a01, 0x4ff, 0xf0, 0xc4a1, 0, 0, 0, 0x1); + assert!(is_usable_v6(&relay_use1.into())); + + let random_2603 = Ipv6Addr::new(0x2603, 0x3ff, 0xf1, 0xc3aa, 0x1, 0x2, 0x3, 0x1); + assert!(is_usable_v6(&random_2603.into())); + } + + #[test] + fn test_local_ip() { + // Either family may be unavailable in a test environment; only + // check that a returned address is not unspecified. + if let Some(ip) = local_ip() { + assert!(!ip.is_unspecified()); + } + } +} diff --git a/netwatch/src/interfaces/enumerate/adapters.rs b/netwatch/src/interfaces/enumerate/adapters.rs new file mode 100644 index 00000000..05faffe4 --- /dev/null +++ b/netwatch/src/interfaces/enumerate/adapters.rs @@ -0,0 +1,325 @@ +//! Windows interface enumeration via `GetAdaptersAddresses`. +//! +//! Mirrors what netdev did on windows: every adapter is reported +//! (loopback and tunnels included), interface flags are synthesized from +//! the operational status and interface type using winsock `IFF_*` +//! values, and the default gateway requires an ARP-resolvable IPv4 +//! gateway on the adapter owning the local IP. +//! +//! The unsafe surface is confined to the [`Adapters`] buffer wrapper and +//! its accessors; the enumeration logic itself is safe code. + +use std::{ + ffi::CStr, + net::{IpAddr, Ipv4Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; +use windows::Win32::{ + Foundation::{ERROR_BUFFER_OVERFLOW, NO_ERROR}, + NetworkManagement::{ + IpHelper::{ + GAA_FLAG_INCLUDE_GATEWAYS, GetAdaptersAddresses, IP_ADAPTER_ADDRESSES_LH, + IP_ADAPTER_GATEWAY_ADDRESS_LH, IP_ADAPTER_UNICAST_ADDRESS_LH, SendARP, + }, + Ndis::IfOperStatusUp, + }, + Networking::WinSock::{ + AF_INET, AF_INET6, AF_UNSPEC, IpDadStateDeprecated, IpDadStateDuplicate, + IpDadStateTentative, IpSuffixOriginRandom, SOCKADDR_INET, SOCKET_ADDRESS, + }, +}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags}; + +// Winsock interface flag values; these differ from the POSIX ones +// (loopback is 0x4 here, 0x8 there). netdev synthesized flags from these +// values, so keeping them preserves the meaning of `Interface::flags` on +// windows. +const IFF_UP: u32 = 1; +const IFF_BROADCAST: u32 = 2; +const IFF_LOOPBACK: u32 = 4; +const IFF_POINTTOPOINT: u32 = 8; +const IFF_MULTICAST: u32 = 16; + +/// Enumerates the machine's network interfaces. +/// +/// Returns an empty list when the adapter query fails; there is no error +/// channel, matching the behavior callers have relied on so far. +pub(super) fn interfaces() -> Vec { + let Some(adapters) = Adapters::load() else { + return Vec::new(); + }; + + let mut interfaces = Vec::new(); + for adapter in adapters.iter() { + let mut builder = IfaceBuilder::new( + adapter_name(adapter), + adapter_index(adapter), + adapter_flags(adapter), + ); + if adapter.PhysicalAddressLength == 6 { + builder.mac = adapter.PhysicalAddress[..6].try_into().ok(); + } + + for unicast in unicast_addrs(adapter) { + let Some((ip, scope_id)) = socket_address_to_ip(adapter, &unicast.Address) else { + continue; + }; + match ip { + IpAddr::V4(ip) => { + // An out-of-range prefix drops the address, as it + // always has. + if let Ok(net) = Ipv4Net::new(ip, unicast.OnLinkPrefixLength) { + builder.push_v4(net); + } + } + IpAddr::V6(ip) => { + if let Ok(net) = Ipv6Net::new(ip, unicast.OnLinkPrefixLength) { + // No link-local ifindex fallback here: windows + // reports real scope IDs. + builder.push_v6(net, scope_id, unicast_v6_flags(unicast)); + } + } + } + } + + interfaces.push(builder.finish()); + } + interfaces +} + +/// The gateway address of the default route. +/// +/// netdev's rules, preserved: the adapter must own the local IP the OS +/// routes outbound traffic through, be up, and have an IPv4 gateway whose +/// MAC resolves via ARP; the first IPv4 gateway wins. An adapter with +/// only an IPv6 gateway yields nothing, like it did with netdev. +pub(super) fn default_gateway() -> Option { + let local_ip = super::local_ip()?; + let adapters = Adapters::load()?; + + for adapter in adapters.iter() { + if adapter_flags(adapter) & IFF_UP == 0 { + continue; + } + + let mut v4_addrs = Vec::new(); + let mut owns_local_ip = false; + for unicast in unicast_addrs(adapter) { + let Some((ip, _)) = socket_address_to_ip(adapter, &unicast.Address) else { + continue; + }; + owns_local_ip |= ip == local_ip; + if let IpAddr::V4(ip) = ip { + v4_addrs.push(ip); + } + } + if !owns_local_ip { + continue; + } + + for gateway in gateway_addrs(adapter) { + let Some((IpAddr::V4(gateway), _)) = socket_address_to_ip(adapter, &gateway.Address) + else { + continue; + }; + let Some(src) = v4_addrs.first() else { + continue; + }; + if arp_resolves(*src, gateway) { + return Some(IpAddr::V4(gateway)); + } + } + } + None +} + +/// The adapter list returned by `GetAdaptersAddresses`. +/// +/// Owns the backing buffer; all adapter references and the pointers +/// inside them stay valid for as long as this value lives, which is what +/// the accessor functions below rely on. +struct Adapters { + buf: Vec, +} + +impl Adapters { + /// Queries the adapter list, growing the buffer up to three times as + /// `GetAdaptersAddresses` requests. + fn load() -> Option { + // 15k is the size MSDN recommends to avoid the second call. + let mut buf: Vec = Vec::with_capacity(15000); + let mut retries = 3; + loop { + let mut size = buf.capacity() as u32; + // SAFETY: the buffer is valid for writes of `size` bytes, and + // on success the call wrote `size` bytes (bounded by the + // capacity). + let res = unsafe { + let res = GetAdaptersAddresses( + AF_UNSPEC.0 as u32, + GAA_FLAG_INCLUDE_GATEWAYS, + None, + Some(buf.as_mut_ptr().cast()), + &mut size, + ); + if res == NO_ERROR.0 { + buf.set_len(size as usize); + } + res + }; + if res == NO_ERROR.0 { + return Some(Self { buf }); + } else if res == ERROR_BUFFER_OVERFLOW.0 && retries > 0 { + buf.reserve(size as usize); + retries -= 1; + } else { + return None; + } + } + } + + /// Iterates the adapters in the list. + fn iter(&self) -> impl Iterator { + iter_list( + self.buf.as_ptr().cast(), + |adapter: &IP_ADAPTER_ADDRESSES_LH| adapter.Next.cast_const(), + ) + } +} + +/// Iterates the unicast addresses of an adapter. +fn unicast_addrs( + adapter: &IP_ADAPTER_ADDRESSES_LH, +) -> impl Iterator { + iter_list( + adapter.FirstUnicastAddress.cast_const().cast(), + |unicast: &IP_ADAPTER_UNICAST_ADDRESS_LH| unicast.Next.cast_const(), + ) +} + +/// Iterates the gateway addresses of an adapter. +fn gateway_addrs( + adapter: &IP_ADAPTER_ADDRESSES_LH, +) -> impl Iterator { + iter_list(adapter.FirstGatewayAddress.cast_const(), |gateway| { + gateway.Next.cast_const() + }) +} + +/// The adapter name (its GUID string). +fn adapter_name(adapter: &IP_ADAPTER_ADDRESSES_LH) -> String { + // SAFETY: AdapterName is a NUL-terminated ANSI string owned by the + // adapter buffer. + let name = unsafe { CStr::from_ptr(adapter.AdapterName.0.cast()) }; + name.to_string_lossy().into_owned() +} + +/// The adapter's interface index; may be zero when IPv4 is disabled. +fn adapter_index(adapter: &IP_ADAPTER_ADDRESSES_LH) -> u32 { + // SAFETY: reading the IfIndex variant of the union is always valid; + // both variants are plain integers. + unsafe { adapter.Anonymous1.Anonymous.IfIndex } +} + +/// Iterates a `Next`-linked list of structs inside the adapter buffer. +/// +/// The returned references borrow from the start pointer's referent, so +/// they cannot outlive the [`Adapters`] buffer they point into. +fn iter_list<'a, T: 'a>( + mut ptr: *const T, + next: fn(&T) -> *const T, +) -> impl Iterator { + std::iter::from_fn(move || { + // SAFETY: the pointer is null or points into the live adapter + // buffer the caller borrows from. + let current = unsafe { ptr.as_ref() }?; + ptr = next(current); + Some(current) + }) +} + +/// Synthesizes BSD-style flags from the adapter state and type. +fn adapter_flags(adapter: &IP_ADAPTER_ADDRESSES_LH) -> u32 { + let mut flags = 0; + if adapter.OperStatus == IfOperStatusUp { + flags |= IFF_UP; + } + // Raw IANA ifType values, as netdev matched them. + flags |= match adapter.IfType { + // ethernet, token ring, 802.11 wireless, IEEE 1394 + 6 | 9 | 71 | 144 => IFF_BROADCAST | IFF_MULTICAST, + // PPP, tunnel + 23 | 131 => IFF_POINTTOPOINT | IFF_MULTICAST, + // software loopback + 24 => IFF_LOOPBACK | IFF_MULTICAST, + // ATM + 37 => IFF_BROADCAST | IFF_POINTTOPOINT | IFF_MULTICAST, + _ => 0, + }; + flags +} + +/// Maps the address' duplicate-address-detection state and suffix origin +/// into [`Ipv6AddrFlags`]. +fn unicast_v6_flags(unicast: &IP_ADAPTER_UNICAST_ADDRESS_LH) -> Ipv6AddrFlags { + Ipv6AddrFlags { + deprecated: unicast.DadState == IpDadStateDeprecated, + temporary: unicast.SuffixOrigin == IpSuffixOriginRandom, + tentative: unicast.DadState == IpDadStateTentative, + duplicated: unicast.DadState == IpDadStateDuplicate, + // Not reported on windows. + permanent: false, + } +} + +/// Parses a `SOCKET_ADDRESS`, returning the address and, for IPv6, the +/// scope ID. +/// +/// The unused adapter argument pins down that the sockaddr pointer +/// borrows from the adapter buffer, which guarantees it is null or +/// points to a sockaddr at least as large as its family's +/// `SOCKADDR_IN`/`SOCKADDR_IN6`. +fn socket_address_to_ip( + _owner: &IP_ADAPTER_ADDRESSES_LH, + address: &SOCKET_ADDRESS, +) -> Option<(IpAddr, u32)> { + // SAFETY: the sockaddr lives in the adapter buffer (see above); + // si_family overlaps the family field of both union variants and + // selects which one is initialized. + unsafe { + let sockaddr = address.lpSockaddr.cast::().as_ref()?; + let family = sockaddr.si_family; + if family == AF_INET { + let octets = sockaddr.Ipv4.sin_addr.S_un.S_addr.to_ne_bytes(); + Some((IpAddr::V4(Ipv4Addr::from(octets)), 0)) + } else if family == AF_INET6 { + let ip = IpAddr::from(sockaddr.Ipv6.sin6_addr.u.Byte); + // Both variants of the scope union are a u32. + let scope_id = sockaddr.Ipv6.Anonymous.sin6_scope_id; + Some((ip, scope_id)) + } else { + None + } + } +} + +/// Reports whether the gateway's MAC address resolves via ARP. +/// +/// netdev used the resolved MAC only as a liveness gate for the gateway, +/// and so do we. +fn arp_resolves(src: Ipv4Addr, dst: Ipv4Addr) -> bool { + let mut mac = [0u8; 6]; + let mut mac_len = mac.len() as u32; + // SAFETY: `mac` is valid for writes of `mac_len` bytes. + let res = unsafe { + SendARP( + u32::from_ne_bytes(dst.octets()), + u32::from_ne_bytes(src.octets()), + mac.as_mut_ptr().cast(), + &mut mac_len, + ) + }; + res == NO_ERROR.0 && mac_len == 6 && mac != [0u8; 6] +} diff --git a/netwatch/src/interfaces/enumerate/ifaddrs.rs b/netwatch/src/interfaces/enumerate/ifaddrs.rs new file mode 100644 index 00000000..929983eb --- /dev/null +++ b/netwatch/src/interfaces/enumerate/ifaddrs.rs @@ -0,0 +1,493 @@ +//! getifaddrs-based interface enumeration. +//! +//! The primary backend on BSD and Apple platforms, and the fallback on +//! linux and android when netlink is unavailable (Android 11+ denies +//! netlink link dumps to apps via SELinux). +//! +//! Every getifaddrs entry carries one address; entries are merged per +//! interface name, with flags taken from the first entry of a name the +//! way netdev did it. The unsafe surface is confined to the [`IfAddrs`] +//! list wrapper, the [`Entry`] accessors, and the flag ioctl; the walk +//! itself is safe code. +//! +//! We wrap the FFI ourselves instead of using nix, the one crate whose +//! getifaddrs wrapper exposes enough (flags, MAC, scope IDs): nix only +//! zero-pads the truncated netmask sockaddrs BSD kernels produce on +//! Apple targets (on the other BSDs the netmask would parse as /0), +//! links `getifaddrs` directly (bionic exports it only since API 24, so +//! binaries would stop loading on older Android), and does not cover +//! the flag ioctl anyway. + +use std::{ + ffi::CStr, + net::{Ipv4Addr, Ipv6Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags, resolve_v6_scope_id}; + +/// The address family link-layer (MAC) addresses arrive with. +#[cfg(any(target_os = "linux", target_os = "android"))] +const MAC_FAMILY: libc::c_int = libc::AF_PACKET; +#[cfg(bsd)] +const MAC_FAMILY: libc::c_int = libc::AF_LINK; + +/// Enumerates the machine's network interfaces via `getifaddrs(3)`. +/// +/// Returns an empty list when the call fails; there is no error channel, +/// matching the behavior callers have relied on so far. +pub(super) fn interfaces() -> Vec { + let Some(list) = IfAddrs::load() else { + return Vec::new(); + }; + + let mut builders: Vec = Vec::new(); + for entry in list.iter() { + let Some(name) = entry.name() else { + continue; + }; + let position = match builders.iter().position(|builder| builder.name == name) { + Some(position) => position, + None => { + builders.push(IfaceBuilder::new(name, entry.index(), entry.flags())); + builders.len() - 1 + } + }; + let builder = &mut builders[position]; + + let Some((family, sa)) = entry.addr() else { + continue; + }; + if family == MAC_FAMILY { + if let Some(mac) = parse_mac(sa) { + builder.mac = Some(mac); + } + } else if family == libc::AF_INET { + let Some(ip) = parse_v4_addr(sa) else { + continue; + }; + // A non-contiguous netmask fails here and drops the address. + let Ok(net) = Ipv4Net::with_netmask(ip, parse_v4_mask(entry.netmask())) else { + continue; + }; + builder.push_v4(net); + } else if family == libc::AF_INET6 { + let Some((ip, raw_scope_id)) = parse_v6_addr(sa) else { + continue; + }; + let Ok(net) = Ipv6Net::with_netmask(ip, parse_v6_mask(entry.netmask())) else { + continue; + }; + let scope_id = resolve_v6_scope_id(&ip, raw_scope_id, builder.index); + let flags = ipv6_addr_flags(&builder.name, &ip); + builder.push_v6(net, scope_id, flags); + } + } + + builders.into_iter().map(IfaceBuilder::finish).collect() +} + +/// The list returned by `getifaddrs(3)`, freed on drop. +struct IfAddrs { + list: *mut libc::ifaddrs, + free: unsafe extern "C" fn(*mut libc::ifaddrs), +} + +impl IfAddrs { + /// Queries the list. + /// + /// Returns `None` when the call fails or, on android, when the + /// symbols are unavailable. + fn load() -> Option { + #[cfg(target_os = "android")] + let (getifaddrs, freeifaddrs) = compat::symbols()?; + #[cfg(not(target_os = "android"))] + let (getifaddrs, freeifaddrs) = ( + libc::getifaddrs as unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int, + libc::freeifaddrs as unsafe extern "C" fn(*mut libc::ifaddrs), + ); + + let mut list: *mut libc::ifaddrs = std::ptr::null_mut(); + // SAFETY: `list` is a valid out-pointer for getifaddrs. + if unsafe { getifaddrs(&mut list) } != 0 { + return None; + } + Some(Self { + list, + free: freeifaddrs, + }) + } + + /// Iterates the entries of the list. + fn iter(&self) -> impl Iterator> { + let mut next = self.list.cast_const(); + std::iter::from_fn(move || { + // SAFETY: `next` is null or points at a live node of the + // list, which stays allocated for the borrow's lifetime. + let ifa = unsafe { next.as_ref() }?; + next = ifa.ifa_next; + Some(Entry { ifa }) + }) + } +} + +impl Drop for IfAddrs { + fn drop(&mut self) { + if !self.list.is_null() { + // SAFETY: `list` came from getifaddrs and is freed exactly + // once. + unsafe { (self.free)(self.list) }; + } + } +} + +/// One getifaddrs entry: an interface name paired with one address. +/// +/// The accessors encapsulate the pointer handling; the invariants they +/// rely on (NUL-terminated name, length-backed sockaddrs) are guaranteed +/// by getifaddrs for the lifetime of the [`IfAddrs`] list. +#[derive(Clone, Copy)] +struct Entry<'a> { + ifa: &'a libc::ifaddrs, +} + +impl<'a> Entry<'a> { + /// The interface name. + fn name(&self) -> Option { + if self.ifa.ifa_name.is_null() { + return None; + } + // SAFETY: ifa_name is a NUL-terminated string owned by the list. + let name = unsafe { CStr::from_ptr(self.ifa.ifa_name) }; + Some(name.to_string_lossy().into_owned()) + } + + /// The OS interface index; zero when the lookup fails. + fn index(&self) -> u32 { + if self.ifa.ifa_name.is_null() { + return 0; + } + // SAFETY: ifa_name is a valid NUL-terminated string, see name(). + unsafe { libc::if_nametoindex(self.ifa.ifa_name) } + } + + /// The interface flags (`IFF_*`). + fn flags(&self) -> u32 { + self.ifa.ifa_flags + } + + /// The entry's address family and sockaddr bytes. + fn addr(&self) -> Option<(libc::c_int, &'a [u8])> { + // SAFETY: ifa_addr is null or a sockaddr whose reported length is + // backed by its allocation, as getifaddrs guarantees. + unsafe { sockaddr_slice(self.ifa.ifa_addr) } + } + + /// The netmask family and sockaddr bytes. + fn netmask(&self) -> Option<(libc::c_int, &'a [u8])> { + // SAFETY: as for addr(). + unsafe { sockaddr_slice(self.ifa.ifa_netmask) } + } +} + +/// Reads the address family and the valid bytes of a sockaddr. +/// +/// On BSD-derived systems the length comes from `sa_len` (clamped to the +/// size of `sockaddr_storage`, with family-based defaults when zero); on +/// linux and android it is implied by the family. +/// +/// # Safety +/// +/// `sa` must be null or point to a sockaddr whose reported length is +/// backed by its allocation, as getifaddrs guarantees. +unsafe fn sockaddr_slice<'a>(sa: *const libc::sockaddr) -> Option<(libc::c_int, &'a [u8])> { + if sa.is_null() { + return None; + } + // SAFETY: `sa` points at a sockaddr whose reported length is backed + // by its allocation, per the caller contract. + unsafe { + let family = (*sa).sa_family as libc::c_int; + + #[cfg(bsd)] + let len = { + let sa_len = (*sa).sa_len as usize; + if sa_len == 0 { + match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_LINK => std::mem::size_of::(), + _ => return None, + } + } else { + sa_len.min(std::mem::size_of::()) + } + }; + #[cfg(any(target_os = "linux", target_os = "android"))] + let len = match family { + libc::AF_INET => std::mem::size_of::(), + libc::AF_INET6 => std::mem::size_of::(), + libc::AF_PACKET => std::mem::size_of::(), + _ => return None, + }; + + Some((family, std::slice::from_raw_parts(sa.cast::(), len))) + } +} + +/// Parses the address of a full-length `sockaddr_in`. +fn parse_v4_addr(sa: &[u8]) -> Option { + // sin_addr sits at offset 4 on every supported platform. + if sa.len() < std::mem::size_of::() { + return None; + } + Some(Ipv4Addr::new(sa[4], sa[5], sa[6], sa[7])) +} + +/// Parses the address and scope of a full-length `sockaddr_in6`. +fn parse_v6_addr(sa: &[u8]) -> Option<(Ipv6Addr, u32)> { + // sin6_addr sits at offset 8 and sin6_scope_id at 24 on every + // supported platform. + if sa.len() < std::mem::size_of::() { + return None; + } + let octets: [u8; 16] = sa[8..24].try_into().expect("length checked"); + let scope_id = u32::from_ne_bytes(sa[24..28].try_into().expect("length checked")); + Some((Ipv6Addr::from(octets), scope_id)) +} + +/// Extracts an IPv4 netmask, tolerating the truncated sockaddrs BSD +/// kernels produce (trailing zero bytes are trimmed from `sa_len`). +fn parse_v4_mask(netmask: Option<(libc::c_int, &[u8])>) -> Ipv4Addr { + let Some((family, sa)) = netmask else { + return Ipv4Addr::UNSPECIFIED; + }; + if family != libc::AF_INET { + return Ipv4Addr::UNSPECIFIED; + } + let mut octets = [0u8; 4]; + let available = sa.len().saturating_sub(4).min(4); + octets[..available].copy_from_slice(&sa[4..4 + available]); + Ipv4Addr::from(octets) +} + +/// Extracts an IPv6 netmask; see [`parse_v4_mask`] for the truncation +/// handling. +fn parse_v6_mask(netmask: Option<(libc::c_int, &[u8])>) -> Ipv6Addr { + let Some((family, sa)) = netmask else { + return Ipv6Addr::UNSPECIFIED; + }; + if family != libc::AF_INET6 { + return Ipv6Addr::UNSPECIFIED; + } + let mut octets = [0u8; 16]; + let available = sa.len().saturating_sub(8).min(16); + octets[..available].copy_from_slice(&sa[8..8 + available]); + Ipv6Addr::from(octets) +} + +/// Extracts the MAC address from a `sockaddr_ll`. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn parse_mac(sa: &[u8]) -> Option<[u8; 6]> { + // sll_halen sits at offset 11, sll_addr at 12. + let halen = *sa.get(11)? as usize; + if halen < 6 { + return None; + } + sa.get(12..18)?.try_into().ok() +} + +/// Extracts the MAC address from a `sockaddr_dl`. +/// +/// Wire layout: len, family, index (2), type, name length, address +/// length, selector length, then name and address bytes. +#[cfg(bsd)] +fn parse_mac(sa: &[u8]) -> Option<[u8; 6]> { + if sa.len() < 8 { + return None; + } + let name_len = sa[5] as usize; + let addr_len = sa[6] as usize; + if addr_len < 6 { + return None; + } + let start = 8 + name_len; + sa.get(start..start + 6)?.try_into().ok() +} + +/// Queries the per-address IPv6 flags via the `SIOCGIFAFLAG_IN6` ioctl. +/// +/// Any failure yields all-false flags; `permanent` is never reported on +/// these platforms. +#[cfg(bsd)] +fn ipv6_addr_flags(name: &str, addr: &Ipv6Addr) -> Ipv6AddrFlags { + use std::os::fd::AsRawFd; + + // From in6_var.h (xnu and FreeBSD agree); the libc crate exposes + // neither the ioctl number nor the flag bits. The request encodes a + // 288-byte `struct in6_ifreq`. The same number is used on OpenBSD and + // NetBSD even though their struct differs; there the ioctl fails and + // the flags stay false, which is the behavior netdev shipped. + const SIOCGIFAFLAG_IN6: libc::c_ulong = 0xC120_6949; + const IN6_IFF_TENTATIVE: i32 = 0x02; + const IN6_IFF_DUPLICATED: i32 = 0x04; + const IN6_IFF_DEPRECATED: i32 = 0x10; + const IN6_IFF_TEMPORARY: i32 = 0x80; + + /// Layout-compatible with the kernel's `struct in6_ifreq`: the + /// interface name followed by a union accessed only as the request + /// address (in) and the flag word (out). `data` is sized so the + /// struct matches the 288 bytes the ioctl copies. + #[repr(C, align(8))] + struct In6Ifreq { + name: [u8; libc::IFNAMSIZ], + data: [u8; 288 - libc::IFNAMSIZ], + } + + let flags = Ipv6AddrFlags::default(); + + let Ok(socket) = socket2::Socket::new(socket2::Domain::IPV6, socket2::Type::DGRAM, None) else { + return flags; + }; + + let mut req = In6Ifreq { + name: [0; libc::IFNAMSIZ], + data: [0; 288 - libc::IFNAMSIZ], + }; + let name = name.as_bytes(); + let name_len = name.len().min(libc::IFNAMSIZ - 1); + req.name[..name_len].copy_from_slice(&name[..name_len]); + + // The union starts with a sockaddr_in6 holding the queried address: + // the length and family bytes, then the address at offset 8. + req.data[0] = std::mem::size_of::() as u8; + req.data[1] = libc::AF_INET6 as u8; + req.data[8..24].copy_from_slice(&addr.octets()); + + // SAFETY: `req` matches the 288-byte struct size encoded in the + // ioctl request; the kernel reads and writes only within it. + let res = unsafe { libc::ioctl(socket.as_raw_fd(), SIOCGIFAFLAG_IN6, &mut req) }; + if res != 0 { + return flags; + } + // On success the union holds the flag word instead of the address. + let raw = i32::from_ne_bytes(req.data[..4].try_into().expect("length checked")); + + Ipv6AddrFlags { + deprecated: raw & IN6_IFF_DEPRECATED != 0, + temporary: raw & IN6_IFF_TEMPORARY != 0, + tentative: raw & IN6_IFF_TENTATIVE != 0, + duplicated: raw & IN6_IFF_DUPLICATED != 0, + permanent: false, + } +} + +/// On linux and android the per-address IPv6 flags come from netlink; +/// this getifaddrs fallback reports none, like netdev's fallback did. +#[cfg(any(target_os = "linux", target_os = "android"))] +fn ipv6_addr_flags(_name: &str, _addr: &Ipv6Addr) -> Ipv6AddrFlags { + Ipv6AddrFlags::default() +} + +/// `getifaddrs` resolved at runtime. +/// +/// Bionic provides getifaddrs only since API 24; resolving the symbols at +/// runtime keeps binaries loadable on older Android versions, at the +/// price of enumerating nothing there. This matches netdev, which +/// dlopened libc for the same reason. +#[cfg(target_os = "android")] +mod compat { + use std::sync::OnceLock; + + pub(super) type GetIfAddrsFn = unsafe extern "C" fn(*mut *mut libc::ifaddrs) -> libc::c_int; + pub(super) type FreeIfAddrsFn = unsafe extern "C" fn(*mut libc::ifaddrs); + + pub(super) fn symbols() -> Option<(GetIfAddrsFn, FreeIfAddrsFn)> { + static SYMBOLS: OnceLock> = OnceLock::new(); + let (getifaddrs, freeifaddrs) = (*SYMBOLS.get_or_init(|| { + // SAFETY: dlsym with valid NUL-terminated symbol names. + let (getifaddrs, freeifaddrs) = unsafe { + ( + libc::dlsym(libc::RTLD_DEFAULT, c"getifaddrs".as_ptr()), + libc::dlsym(libc::RTLD_DEFAULT, c"freeifaddrs".as_ptr()), + ) + }; + if getifaddrs.is_null() || freeifaddrs.is_null() { + None + } else { + Some((getifaddrs as usize, freeifaddrs as usize)) + } + }))?; + // SAFETY: the addresses come from dlsym for symbols with exactly + // these C signatures. + unsafe { + Some(( + std::mem::transmute::(getifaddrs), + std::mem::transmute::(freeifaddrs), + )) + } + } +} + +#[cfg(test)] +mod tests { + use std::net::IpAddr; + + use super::*; + + #[test] + fn test_interfaces_contains_loopback() { + let ifaces = interfaces(); + let lo = ifaces + .iter() + .find(|iface| iface.addrs.iter().any(|net| net.addr().is_loopback())) + .expect("no loopback interface"); + assert!(lo.index >= 1); + assert!(lo.is_up()); + assert!( + lo.addrs + .iter() + .any(|net| net.addr() == IpAddr::V4(Ipv4Addr::LOCALHOST)) + ); + } + + #[test] + fn test_parse_v4_mask() { + // A full-length AF_INET sockaddr with mask 255.255.255.0. + let mut sa = [0u8; 16]; + sa[4..8].copy_from_slice(&[255, 255, 255, 0]); + assert_eq!( + parse_v4_mask(Some((libc::AF_INET, &sa))), + Ipv4Addr::new(255, 255, 255, 0) + ); + // A truncated sockaddr (BSD style): only one mask byte present. + assert_eq!( + parse_v4_mask(Some((libc::AF_INET, &sa[..5]))), + Ipv4Addr::new(255, 0, 0, 0) + ); + // Missing or wrong-family netmask means prefix zero. + assert_eq!(parse_v4_mask(None), Ipv4Addr::UNSPECIFIED); + assert_eq!( + parse_v4_mask(Some((libc::AF_INET6, &sa))), + Ipv4Addr::UNSPECIFIED + ); + } + + #[test] + fn test_parse_v6_addr_and_mask() { + let ip = Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1); + let mut sa = [0u8; 28]; + sa[8..24].copy_from_slice(&ip.octets()); + sa[24..28].copy_from_slice(&3u32.to_ne_bytes()); + assert_eq!(parse_v6_addr(&sa), Some((ip, 3))); + assert_eq!(parse_v6_addr(&sa[..20]), None); + + let mut mask = [0u8; 28]; + mask[8..16].copy_from_slice(&[0xff; 8]); + assert_eq!( + parse_v6_mask(Some((libc::AF_INET6, &mask))), + Ipv6Addr::new(0xffff, 0xffff, 0xffff, 0xffff, 0, 0, 0, 0) + ); + } +} diff --git a/netwatch/src/interfaces/enumerate/netlink.rs b/netwatch/src/interfaces/enumerate/netlink.rs new file mode 100644 index 00000000..0d5e57e4 --- /dev/null +++ b/netwatch/src/interfaces/enumerate/netlink.rs @@ -0,0 +1,152 @@ +//! Netlink-based enumeration backend for linux and android. +//! +//! Interfaces come from an `RTM_GETLINK` plus an `RTM_GETADDR` dump, the +//! default gateways from an `RTM_GETROUTE` dump. Mirrors what netdev did +//! on these platforms, including the android leniency: there the address +//! dump is allowed to fail (yielding interfaces without addresses) while +//! a failed link dump makes the caller fall back to getifaddrs. + +use std::{ + collections::HashMap, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, +}; + +use ipnet::{Ipv4Net, Ipv6Net}; +use netwatch_netlink::{Connection, RouteFamily}; + +use super::{IfaceBuilder, Interface, Ipv6AddrFlags, resolve_v6_scope_id}; + +/// Enumerates interfaces via netlink dumps. +pub(super) fn interfaces() -> Result, netwatch_netlink::Error> { + let mut conn = Connection::new()?; + let links = conn.dump_links()?; + + // Android 11+ SELinux denies link dumps but allows address dumps; the + // reverse does not happen. netdev nevertheless tolerated a failed + // address dump on android only, so keep that shape. + #[cfg(target_os = "android")] + let addresses = conn.dump_addresses().unwrap_or_default(); + #[cfg(not(target_os = "android"))] + let addresses = conn.dump_addresses()?; + + let mut builders: HashMap = HashMap::new(); + for link in links { + let name = link.name.clone().unwrap_or_else(|| link.index.to_string()); + let mut builder = IfaceBuilder::new(name, link.index, link.flags); + builder.mac = link + .address + .as_deref() + .and_then(|mac| <[u8; 6]>::try_from(mac).ok()); + builders.insert(link.index, builder); + } + + for address in addresses { + // Addresses for unknown link indices are dropped. + let Some(builder) = builders.get_mut(&address.index) else { + continue; + }; + let Some(ip) = address.interface_address() else { + continue; + }; + match ip { + IpAddr::V4(ip) => { + if let Ok(net) = Ipv4Net::new(ip, address.prefix_len) { + builder.push_v4(net); + } + } + IpAddr::V6(ip) => { + if let Ok(net) = Ipv6Net::new(ip, address.prefix_len) { + let scope_id = resolve_v6_scope_id(&ip, 0, address.index); + let flags = from_netlink_flags(address.flags()); + builder.push_v6(net, scope_id, flags); + } + } + } + } + + Ok(builders.into_values().map(IfaceBuilder::finish).collect()) +} + +/// Collects the default-route gateways, keyed by output interface index. +/// +/// Only routes with a zero destination prefix and a gateway attribute +/// count; on-link default routes carry no gateway and are skipped. +#[allow(clippy::type_complexity)] +pub(super) fn default_gateways_by_interface() +-> Result, Vec)>, netwatch_netlink::Error> { + let mut conn = Connection::new()?; + let routes = conn.dump_routes(RouteFamily::Unspec)?; + + let mut gateways: HashMap, Vec)> = HashMap::new(); + for route in routes { + if route.dst_len != 0 { + continue; + } + let (Some(gateway), Some(oif)) = (route.gateway, route.oif) else { + continue; + }; + let (v4, v6) = gateways.entry(oif).or_default(); + match gateway { + IpAddr::V4(ip) if !v4.contains(&ip) => v4.push(ip), + IpAddr::V6(ip) if !v6.contains(&ip) => v6.push(ip), + _ => {} + } + } + Ok(gateways) +} + +/// Maps netlink `IFA_F_*` bits into [`Ipv6AddrFlags`]. +fn from_netlink_flags(raw: u32) -> Ipv6AddrFlags { + Ipv6AddrFlags { + deprecated: raw & libc::IFA_F_DEPRECATED != 0, + temporary: raw & libc::IFA_F_TEMPORARY != 0, + tentative: raw & libc::IFA_F_TENTATIVE != 0, + duplicated: raw & libc::IFA_F_DADFAILED != 0, + permanent: raw & libc::IFA_F_PERMANENT != 0, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_interfaces_contains_loopback() { + let ifaces = interfaces().unwrap(); + let lo = ifaces + .iter() + .find(|iface| iface.name == "lo") + .expect("no loopback interface"); + assert!(lo.index >= 1); + assert!(lo.is_up()); + assert!( + lo.addrs + .iter() + .any(|net| net.addr() == IpAddr::V4(Ipv4Addr::LOCALHOST)) + ); + } + + #[test] + fn test_flag_mapping() { + let flags = from_netlink_flags( + libc::IFA_F_DEPRECATED | libc::IFA_F_TEMPORARY | libc::IFA_F_PERMANENT, + ); + assert!(flags.deprecated); + assert!(flags.temporary); + assert!(flags.permanent); + assert!(!flags.tentative); + assert!(!flags.duplicated); + + assert_eq!(from_netlink_flags(0), Ipv6AddrFlags::default()); + } + + #[test] + fn test_default_gateways() { + // May be empty in an isolated namespace; only check it works. + let gateways = default_gateways_by_interface().unwrap(); + for (oif, (v4, v6)) in gateways { + assert_ne!(oif, 0); + assert!(!v4.is_empty() || !v6.is_empty()); + } + } +} diff --git a/netwatch/src/interfaces/enumerate/procfs.rs b/netwatch/src/interfaces/enumerate/procfs.rs new file mode 100644 index 00000000..4261ace2 --- /dev/null +++ b/netwatch/src/interfaces/enumerate/procfs.rs @@ -0,0 +1,118 @@ +//! `/proc/net` based gateway fallback, used when netlink route dumps fail. +//! +//! Mirrors netdev's procfs parser, including its quirks: the IPv4 pass +//! records the gateway of any gatewayed route (not just default routes), +//! later rows overwriting earlier ones per interface, while the IPv6 pass +//! only accepts real default routes. + +use std::{ + collections::HashMap, + net::{Ipv4Addr, Ipv6Addr}, +}; + +/// Collects gateway addresses per interface name. +pub(super) fn gateways_by_interface_name() -> HashMap, Vec)> { + let mut gateways: HashMap, Vec)> = HashMap::new(); + + if let Ok(routes) = std::fs::read_to_string("/proc/net/route") { + // Header line, then: Iface Destination Gateway Flags RefCnt Use + // Metric Mask MTU Window IRTT. Destination, gateway and mask are + // little-endian hex words. + for line in routes.lines().skip(1) { + let fields: Vec<&str> = line.split_ascii_whitespace().collect(); + let (Some(iface), Some(gateway)) = (fields.first(), fields.get(2)) else { + continue; + }; + if *gateway == "00000000" { + continue; + } + let Some(gateway) = parse_hex_ipv4_le(gateway) else { + continue; + }; + gateways.entry(iface.to_string()).or_default().0 = Some(gateway); + } + } + + if let Ok(routes) = std::fs::read_to_string("/proc/net/ipv6_route") { + // Fields: dest, dest prefix, source, source prefix, next hop, + // metric, refcnt, use, flags, device. + const ZERO_V6: &str = "00000000000000000000000000000000"; + for line in routes.lines() { + let fields: Vec<&str> = line.split_ascii_whitespace().collect(); + if fields.len() < 10 { + continue; + } + if fields[0] != ZERO_V6 || fields[1] != "00" || fields[4] == ZERO_V6 { + continue; + } + let Some(gateway) = parse_hex_ipv6(fields[4]) else { + continue; + }; + let iface = fields[9]; + gateways + .entry(iface.to_string()) + .or_default() + .1 + .push(gateway); + } + } + + gateways +} + +/// Parses an 8-digit little-endian hex word as used in `/proc/net/route`. +fn parse_hex_ipv4_le(hex: &str) -> Option { + if hex.len() != 8 { + return None; + } + let value = u32::from_str_radix(hex, 16).ok()?; + let [a, b, c, d] = value.to_le_bytes(); + Some(Ipv4Addr::new(a, b, c, d)) +} + +/// Parses a 32-digit hex address as used in `/proc/net/ipv6_route`. +fn parse_hex_ipv6(hex: &str) -> Option { + if hex.len() != 32 { + return None; + } + let mut octets = [0u8; 16]; + for (i, octet) in octets.iter_mut().enumerate() { + *octet = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).ok()?; + } + Some(Ipv6Addr::from(octets)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_hex_ipv4_le() { + // 192.168.0.1 in little-endian word order. + assert_eq!( + parse_hex_ipv4_le("0100A8C0"), + Some(Ipv4Addr::new(192, 168, 0, 1)) + ); + assert_eq!(parse_hex_ipv4_le("00000000"), Some(Ipv4Addr::UNSPECIFIED)); + assert_eq!(parse_hex_ipv4_le("123"), None); + } + + #[test] + fn test_parse_hex_ipv6() { + assert_eq!( + parse_hex_ipv6("fe800000000000000000000000000001"), + Some(Ipv6Addr::new(0xfe80, 0, 0, 0, 0, 0, 0, 1)) + ); + assert_eq!(parse_hex_ipv6("fe80"), None); + } + + #[test] + fn test_gateways_smoke() { + // Just verify /proc parsing does not panic on the live system. + let gateways = gateways_by_interface_name(); + for (name, (v4, v6)) in gateways { + assert!(!name.is_empty()); + assert!(v4.is_some() || !v6.is_empty()); + } + } +} diff --git a/netwatch/src/interfaces/linux.rs b/netwatch/src/interfaces/linux.rs index 8b6bbff9..3fcaf7f7 100644 --- a/netwatch/src/interfaces/linux.rs +++ b/netwatch/src/interfaces/linux.rs @@ -7,7 +7,7 @@ use tokio::{ }; use super::DefaultRouteDetails; -pub(super) use super::netdev_impl::{get_state, home_router}; +pub(super) use super::enumerate::{get_state, home_router}; #[stack_error(derive, add_meta, from_sources, std_sources)] #[non_exhaustive] @@ -28,17 +28,7 @@ pub enum Error { MissingMaskField {}, #[cfg(not(target_os = "android"))] #[error("netlink")] - Netlink { - source: netlink_proto::Error, - }, - #[cfg(not(target_os = "android"))] - #[error("unexpected netlink message")] - UnexpectedNetlinkMessage {}, - #[cfg(not(target_os = "android"))] - #[error("netlink error message: {message:?}")] - NetlinkErrorMessage { - message: netlink_packet_core::ErrorMessage, - }, + Netlink { source: netwatch_netlink::Error }, } pub async fn default_route() -> Option { @@ -143,162 +133,57 @@ mod android { #[cfg(not(target_os = "android"))] mod sane { use n0_error::e; - use n0_future::{Either, StreamExt, TryStream}; - use netlink_packet_core::{NLM_F_DUMP, NLM_F_REQUEST, NetlinkMessage}; - use netlink_packet_route::{ - AddressFamily, RouteNetlinkMessage, - link::{LinkAttribute, LinkMessage}, - route::{RouteAttribute, RouteHeader, RouteMessage, RouteProtocol, RouteScope, RouteType}, - }; - use netlink_sys::protocols::NETLINK_ROUTE; - use tracing::{Instrument, info_span}; + use netwatch_netlink::{AsyncConnection, RouteFamily}; use super::*; - type Handle = netlink_proto::ConnectionHandle; - - macro_rules! try_rtnl { - ($msg: expr, $message_type:path) => {{ - use netlink_packet_core::NetlinkPayload; - use netlink_packet_route::RouteNetlinkMessage; - - let (_header, payload) = $msg.into_parts(); - match payload { - NetlinkPayload::InnerMessage($message_type(msg)) => msg, - NetlinkPayload::Error(err) => { - return Err(e!(Error::NetlinkErrorMessage { message: err })); - } - _ => return Err(e!(Error::UnexpectedNetlinkMessage)), - } - }}; - } - pub async fn default_route() -> Result, Error> { - let (connection, handle, _receiver) = - netlink_proto::new_connection::(NETLINK_ROUTE)?; + let mut conn = AsyncConnection::new().map_err(|err| e!(Error::Netlink, err))?; - let task = tokio::spawn(connection.instrument(info_span!("netlink.conn"))); - - let default = default_route_netlink_family(&handle, AddressFamily::Inet).await?; + let default = default_route_netlink_family(&mut conn, RouteFamily::Ipv4).await?; let default = match default { Some(default) => Some(default), - None => { - default_route_netlink_family(&handle, netlink_packet_route::AddressFamily::Inet6) - .await? - } + None => default_route_netlink_family(&mut conn, RouteFamily::Ipv6).await?, }; - task.abort(); - task.await.ok(); Ok(default.map(|(name, _index)| DefaultRouteDetails { interface_name: name, })) } - fn get_route( - handle: Handle, - message: RouteMessage, - ) -> impl TryStream { - let mut req = NetlinkMessage::from(RouteNetlinkMessage::GetRoute(message)); - req.header.flags = NLM_F_REQUEST | NLM_F_DUMP; - - match handle.request(req, netlink_proto::sys::SocketAddr::new(0, 0)) { - Ok(response) => Either::Left( - response.map(move |msg| Ok(try_rtnl!(msg, RouteNetlinkMessage::NewRoute))), - ), - Err(e) => Either::Right(n0_future::stream::once::>(Err( - e!(Error::Netlink, e), - ))), - } - } - - fn create_route_message(family: netlink_packet_route::AddressFamily) -> RouteMessage { - let mut message = RouteMessage::default(); - message.header.table = RouteHeader::RT_TABLE_MAIN; - message.header.protocol = RouteProtocol::Static; - message.header.scope = RouteScope::Universe; - message.header.kind = RouteType::Unicast; - message.header.address_family = family; - message - } - /// Returns the `(name, index)` of the interface for the default route. async fn default_route_netlink_family( - handle: &Handle, - family: netlink_packet_route::AddressFamily, + conn: &mut AsyncConnection, + family: RouteFamily, ) -> Result, Error> { - let msg = create_route_message(family); - let mut routes = get_route(handle.clone(), msg); - - while let Some(route) = routes.try_next().await? { - let route_attrs = route.attributes; - - if !route_attrs - .iter() - .any(|attr| matches!(attr, RouteAttribute::Gateway(_))) - { + let routes = conn.dump_routes(family).await?; + for route in routes { + if route.gateway.is_none() { // A default route has a gateway. continue; } - - if route.header.destination_prefix_length > 0 { + if route.dst_len > 0 { // A default route has no destination prefix length because it needs to route all // destinations. continue; } - - let index = route_attrs.iter().find_map(|attr| match attr { - RouteAttribute::Oif(index) => Some(*index), - _ => None, - }); - - if let Some(index) = index { - if index == 0 { - continue; - } - let name = iface_by_index(handle, index).await?; - return Ok(Some((name, index))); + let Some(index) = route.oif else { + continue; + }; + if index == 0 { + continue; } + let name = iface_by_index(conn, index).await?; + return Ok(Some((name, index))); } Ok(None) } - fn get_link( - handle: Handle, - message: LinkMessage, - ) -> impl TryStream { - let mut req = NetlinkMessage::from(RouteNetlinkMessage::GetLink(message)); - req.header.flags = NLM_F_REQUEST; - - match handle.request(req, netlink_proto::sys::SocketAddr::new(0, 0)) { - Ok(response) => Either::Left( - response.map(move |msg| Ok(try_rtnl!(msg, RouteNetlinkMessage::NewLink))), - ), - Err(e) => Either::Right(n0_future::stream::once::>(Err( - e!(Error::Netlink, e), - ))), - } - } - - fn create_link_get_message(index: u32) -> LinkMessage { - let mut message = LinkMessage::default(); - message.header.index = index; - message - } - - async fn iface_by_index(handle: &Handle, index: u32) -> Result { - let message = create_link_get_message(index); - let mut links = get_link(handle.clone(), message); - let msg = links - .try_next() + async fn iface_by_index(conn: &mut AsyncConnection, index: u32) -> Result { + let link = conn + .get_link_by_index(index) .await? .ok_or_else(|| e!(Error::NoResponse))?; - - for nla in msg.attributes { - if let LinkAttribute::IfName(name) = nla { - return Ok(name); - } - } - Err(e!(Error::InterfaceNotFound)) + link.name.ok_or_else(|| e!(Error::InterfaceNotFound)) } #[cfg(test)] diff --git a/netwatch/src/interfaces/netdev_impl.rs b/netwatch/src/interfaces/netdev_impl.rs deleted file mode 100644 index 749a2ca1..00000000 --- a/netwatch/src/interfaces/netdev_impl.rs +++ /dev/null @@ -1,293 +0,0 @@ -//! Conversion from the `netdev` crate into our platform-agnostic types. -//! -//! This module also holds the interface enumeration and home-router lookup -//! shared by all `netdev`-capable platforms (linux, android, bsd, macos, -//! windows). -//! -//! This is the only module that depends on `netdev`. Everything it produces is -//! expressed in terms of the types defined in [`crate::interfaces`]. - -use std::net::IpAddr; - -use super::{Interface, IpNet, Ipv6AddrFlags, State}; -use crate::ip::{LocalAddresses, is_link_local, is_private, is_private_v6}; - -const IFF_UP: u32 = 0x1; -const IFF_LOOPBACK: u32 = 0x8; - -/// Converts netdev's IPv6 address flags into our mirrored [`Ipv6AddrFlags`]. -/// -/// This is a free function rather than a `From` impl on purpose: a public -/// `From` would re-expose the `netdev` type in our public API, -/// which is exactly what the [`Ipv6AddrFlags`] mirror exists to avoid. -fn to_ipv6_addr_flags(flags: netdev::interface::ipv6_addr_flags::Ipv6AddrFlags) -> Ipv6AddrFlags { - Ipv6AddrFlags { - deprecated: flags.deprecated, - temporary: flags.temporary, - tentative: flags.tentative, - duplicated: flags.duplicated, - permanent: flags.permanent, - } -} - -/// Converts a [`netdev::Interface`] into our platform-agnostic [`Interface`]. -/// -/// Addresses are sorted (IPv4 first, then IPv6, each by address) so that -/// comparisons between successive snapshots are stable. -fn to_interface(iface: netdev::Interface) -> Interface { - // netdev keeps these three IPv6 arrays parallel, one entry per address. - // The zip below relies on that; assert it so a netdev change that breaks - // the invariant surfaces in tests rather than silently dropping addresses. - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_scope_ids.len()); - debug_assert_eq!(iface.ipv6.len(), iface.ipv6_addr_flags.len()); - - let mut v4: Vec = iface.ipv4.iter().copied().map(IpNet::V4).collect(); - let mut v6: Vec = iface - .ipv6 - .iter() - .copied() - .zip(iface.ipv6_scope_ids.iter().copied()) - .zip(iface.ipv6_addr_flags.iter().copied()) - .map(|((net, scope_id), flags)| IpNet::V6 { - net, - scope_id, - flags: to_ipv6_addr_flags(flags), - }) - .collect(); - - // Sort each family by address so successive snapshots compare equal, then - // concatenate as IPv4-first. - v4.sort_by_key(IpNet::addr); - v6.sort_by_key(IpNet::addr); - let mut addrs = v4; - addrs.append(&mut v6); - - Interface { - name: iface.name, - index: iface.index, - flags: iface.flags, - mac_addr: iface.mac_addr.as_ref().map(|a| a.octets()), - addrs, - } -} - -/// Enumerates the machine's network interfaces and assembles the [`State`]. -pub(super) async fn get_state() -> State { - let raw = netdev::interface::get_interfaces(); - let local_addresses = local_addresses(&raw); - - let mut interfaces = std::collections::HashMap::new(); - let mut have_v6 = false; - let mut have_v4 = false; - - for raw in raw { - let iface = to_interface(raw); - if iface.is_up() { - for pfx in iface.addrs() { - let addr = pfx.addr(); - if addr.is_loopback() { - continue; - } - have_v6 |= is_usable_v6(&addr); - have_v4 |= is_usable_v4(&addr); - } - } - interfaces.insert(iface.name().to_string(), iface); - } - - let default_route_interface = super::default_route_interface().await; - - State { - interfaces, - local_addresses, - have_v4, - have_v6, - is_expensive: false, - default_route_interface, - last_unsuspend: None, - } -} - -/// The shared home-router lookup for linux, android and windows. -/// -/// BSD platforms do not use this as `netdev` cannot yet determine their default -/// gateway, so they provide their own implementation. -#[cfg(any(target_os = "linux", target_os = "android", target_os = "windows"))] -pub(super) fn home_router() -> Option { - let gateway = netdev::get_default_gateway().ok()?; - let gateway = gateway - .ipv4 - .iter() - .copied() - .map(IpAddr::V4) - .chain(gateway.ipv6.iter().copied().map(IpAddr::V6)) - .next()?; - - Some(super::HomeRouter { - gateway, - my_ip: local_ip(), - }) -} - -/// Reports whether `ip` is a usable IPv4 address which should have Internet connectivity. -/// -/// Globally routable and private IPv4 addresses are always usable, and link-local -/// 169.254.x.x addresses are in some environments. -fn is_usable_v4(ip: &IpAddr) -> bool { - if !ip.is_ipv4() || ip.is_loopback() { - return false; - } - - true -} - -/// Reports whether `ip` is a usable IPv6 address which should have Internet connectivity. -/// -/// Globally routable IPv6 addresses are always usable, and Unique Local Addresses -/// (fc00::/7) are in some environments used with address translation. -/// -/// We consider all 2000::/3 addresses to be routable, which is the interpretation of -/// -/// as well. However this probably includes some addresses which should not be routed, -/// e.g. documentation addresses. See also -/// for an -/// alternative implementation which is both stricter and laxer in some regards. -fn is_usable_v6(ip: &IpAddr) -> bool { - match ip { - IpAddr::V6(ip) => { - // V6 Global1 2000::/3 - let mask: u16 = 0b1110_0000_0000_0000; - let base: u16 = 0x2000; - let segment1 = ip.segments()[0]; - if (base & mask) == (segment1 & mask) { - return true; - } - - is_private_v6(ip) - } - IpAddr::V4(_) => false, - } -} - -/// The local IP address of this machine, as reported by `netdev`. -pub(super) fn local_ip() -> Option { - netdev::net::ip::get_local_ipaddr() -} - -const fn is_up(interface: &netdev::Interface) -> bool { - interface.flags & IFF_UP != 0 -} - -const fn is_loopback(interface: &netdev::Interface) -> bool { - interface.flags & IFF_LOOPBACK != 0 -} - -/// Builds the machine's [`LocalAddresses`] from a raw `netdev` interface list. -/// -/// If there are no regular addresses it falls back to IPv4 link-local or IPv6 -/// unique-local addresses, because we know of environments where these are used -/// with NAT to provide connectivity. -fn local_addresses(ifaces: &[netdev::Interface]) -> LocalAddresses { - let mut loopback = Vec::new(); - let mut regular4 = Vec::new(); - let mut regular6 = Vec::new(); - let mut linklocal4 = Vec::new(); - let mut ula6 = Vec::new(); - - for iface in ifaces { - if !is_up(iface) { - // Skip down interfaces - continue; - } - let ifc_is_loopback = is_loopback(iface); - let addrs = iface - .ipv4 - .iter() - .map(|a| IpAddr::V4(a.addr())) - .chain(iface.ipv6.iter().map(|a| IpAddr::V6(a.addr()))); - - for ip in addrs { - let ip = ip.to_canonical(); - - if ip.is_loopback() || ifc_is_loopback { - loopback.push(ip); - } else if is_link_local(ip) { - if ip.is_ipv4() { - linklocal4.push(ip); - } - - // We know of no cases where the IPv6 fe80:: addresses - // are used to provide WAN connectivity. It is also very - // common for users to have no IPv6 WAN connectivity, - // but their OS supports IPv6 so they have an fe80:: - // address. We don't want to report all of those - // IPv6 LL to Control. - } else if ip.is_ipv6() && is_private(&ip) { - // Google Cloud Run uses NAT with IPv6 Unique - // Local Addresses to provide IPv6 connectivity. - ula6.push(ip); - } else if ip.is_ipv4() { - regular4.push(ip); - } else { - regular6.push(ip); - } - } - } - - if regular4.is_empty() && regular6.is_empty() { - // if we have no usable IP addresses then be willing to accept - // addresses we otherwise wouldn't, like: - // + 169.254.x.x (AWS Lambda uses NAT with these) - // + IPv6 ULA (Google Cloud Run uses these with address translation) - regular4 = linklocal4; - regular6 = ula6; - } - let mut regular = regular4; - regular.extend(regular6); - - regular.sort(); - loopback.sort(); - - LocalAddresses { loopback, regular } -} - -impl LocalAddresses { - /// Returns the machine's IP addresses. - /// - /// If there are no regular addresses it will return any IPv4 link-local or - /// IPv6 unique-local addresses, because we know of environments where these - /// are used with NAT to provide connectivity. - pub fn new() -> Self { - local_addresses(&netdev::interface::get_interfaces()) - } -} - -#[cfg(test)] -mod tests { - use std::net::Ipv6Addr; - - use super::*; - - #[test] - fn test_local_addresses() { - let addrs = LocalAddresses::new(); - dbg!(&addrs); - assert!(!addrs.loopback.is_empty()); - assert!(!addrs.regular.is_empty()); - } - - #[test] - fn test_is_usable_v6() { - let loopback = Ipv6Addr::new(0, 0, 0, 0, 0, 0, 0, 0x1); - assert!(!is_usable_v6(&loopback.into())); - - let link_local = Ipv6Addr::new(0xfe80, 0, 0, 0, 0xcbc9, 0x6aff, 0x5b07, 0x4a9e); - assert!(!is_usable_v6(&link_local.into())); - - let relay_use1 = Ipv6Addr::new(0x2a01, 0x4ff, 0xf0, 0xc4a1, 0, 0, 0, 0x1); - assert!(is_usable_v6(&relay_use1.into())); - - let random_2603 = Ipv6Addr::new(0x2603, 0x3ff, 0xf1, 0xc3aa, 0x1, 0x2, 0x3, 0x1); - assert!(is_usable_v6(&random_2603.into())); - } -} diff --git a/netwatch/src/interfaces/posix_minimal.rs b/netwatch/src/interfaces/posix_minimal.rs index a0559f5c..b476d1a4 100644 --- a/netwatch/src/interfaces/posix_minimal.rs +++ b/netwatch/src/interfaces/posix_minimal.rs @@ -1,4 +1,5 @@ -//! Interface lookups for POSIX platforms without `netdev` (e.g. esp-idf). +//! Interface lookups for POSIX platforms without interface enumeration +//! (e.g. esp-idf). //! //! No interface enumeration, default route, or home router is available on //! these platforms, so every lookup reports empty or absent. diff --git a/netwatch/src/interfaces/windows.rs b/netwatch/src/interfaces/windows.rs index ed89c58d..022f9320 100644 --- a/netwatch/src/interfaces/windows.rs +++ b/netwatch/src/interfaces/windows.rs @@ -6,7 +6,7 @@ use tracing::warn; use wmi::{FilterValue, WMIConnection}; use super::DefaultRouteDetails; -pub(super) use super::netdev_impl::{get_state, home_router}; +pub(super) use super::enumerate::{get_state, home_router}; /// API Docs: #[derive(Deserialize, Debug)] diff --git a/netwatch/src/ip.rs b/netwatch/src/ip.rs index 234ccf32..0f18aa79 100644 --- a/netwatch/src/ip.rs +++ b/netwatch/src/ip.rs @@ -4,8 +4,9 @@ use std::net::{IpAddr, Ipv6Addr}; /// List of machine's IP addresses. /// -/// The netdev-based constructors live in [`crate::interfaces`]'s `netdev_impl` -/// module; on platforms without `netdev` this is only ever the empty default. +/// The constructors live in [`crate::interfaces`]'s `enumerate` module; on +/// platforms without interface enumeration (esp-idf, browsers) this is only +/// ever the empty default. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct LocalAddresses { /// Loopback addresses. @@ -17,7 +18,7 @@ pub struct LocalAddresses { /// Reports whether `ip` is a private address, according to RFC 1918 /// (IPv4 addresses) and RFC 4193 (IPv6 addresses). That is, it reports whether /// ip is in 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, or fc00::/7. -#[cfg(netdev)] +#[cfg(enumerate)] pub(crate) fn is_private(ip: &IpAddr) -> bool { match ip { IpAddr::V4(ip) => { @@ -32,7 +33,7 @@ pub(crate) fn is_private(ip: &IpAddr) -> bool { } } -#[cfg(netdev)] +#[cfg(enumerate)] pub(crate) fn is_private_v6(ip: &Ipv6Addr) -> bool { // RFC 4193 allocates fc00::/7 as the unique local unicast IPv6 address subnet. ip.octets()[0] & 0xfe == 0xfc diff --git a/netwatch/src/netmon/linux.rs b/netwatch/src/netmon/linux.rs index a0d6d4f8..59bf5a88 100644 --- a/netwatch/src/netmon/linux.rs +++ b/netwatch/src/netmon/linux.rs @@ -9,13 +9,10 @@ use libc::{ }; use n0_error::stack_error; use n0_future::{ - Stream, StreamExt, task::AbortOnDropHandle, time::{self, Duration}, }; -use netlink_packet_core::{NetlinkMessage, NetlinkPayload}; -use netlink_packet_route::{RouteNetlinkMessage, address, route}; -use netlink_sys::{AsyncSocket, SocketAddr}; +use netwatch_netlink::{EventSocket, Message, group_flag}; use tokio::sync::mpsc; use tracing::{trace, warn}; @@ -34,145 +31,99 @@ pub enum Error { Io { source: std::io::Error }, } -const fn nl_mgrp(group: u32) -> u32 { - if group > 31 { - panic!("use netlink_sys::Socket::add_membership() for this group"); - } - if group == 0 { 0 } else { 1 << (group - 1) } -} -macro_rules! get_nla { - ($msg:expr, $nla:path) => { - $msg.attributes.iter().find_map(|nla| match nla { - $nla(n) => Some(n), - _ => None, - }) - }; -} - -#[allow(clippy::type_complexity)] -fn setup_netlink() -> std::io::Result<( - AbortOnDropHandle<()>, - impl Stream, SocketAddr)>, -)> { - use netlink_sys::protocols::NETLINK_ROUTE; - - let (mut conn, _handle, messages) = - netlink_proto::new_connection::(NETLINK_ROUTE)?; - - let groups = nl_mgrp(RTNLGRP_IPV4_IFADDR) - | nl_mgrp(RTNLGRP_IPV6_IFADDR) - | nl_mgrp(RTNLGRP_IPV4_ROUTE) - | nl_mgrp(RTNLGRP_IPV6_ROUTE) - | nl_mgrp(RTNLGRP_IPV4_RULE) - | nl_mgrp(RTNLGRP_IPV6_RULE); - - let addr = SocketAddr::new(0, groups); - conn.socket_mut().socket_mut().bind(&addr)?; - - let conn_handle = AbortOnDropHandle::new(tokio::task::spawn(conn)); - - Ok((conn_handle, messages)) +/// Subscribes to the rtnetlink groups netwatch reacts to: address, route +/// and rule changes for both address families. +fn subscribe() -> Result { + let groups = group_flag(RTNLGRP_IPV4_IFADDR) + | group_flag(RTNLGRP_IPV6_IFADDR) + | group_flag(RTNLGRP_IPV4_ROUTE) + | group_flag(RTNLGRP_IPV6_ROUTE) + | group_flag(RTNLGRP_IPV4_RULE) + | group_flag(RTNLGRP_IPV6_RULE); + EventSocket::subscribe(groups) } /// Returns `true` if the connection was lost (should reconnect), /// `false` if the sender is gone (should shut down). -async fn process_messages( - sender: &mpsc::Sender, - messages: &mut (impl Stream, SocketAddr)> + Unpin), -) -> bool { +async fn process_messages(sender: &mpsc::Sender, events: &mut EventSocket) -> bool { let mut addr_cache: HashMap> = HashMap::new(); - while let Some((message, _)) = messages.next().await { - match message.payload { - NetlinkPayload::Error(err) => { - warn!("error reading netlink payload: {:?}", err); + loop { + let message = match events.next().await { + Ok(message) => message, + Err(netwatch_netlink::Error::ErrorMessage { code, .. }) => { + warn!("error reading netlink payload: code {code}"); + continue; } - NetlinkPayload::Done(_) => { - trace!("done received, reconnecting"); + Err(err) => { + trace!("netlink event socket lost ({err:?}), reconnecting"); return true; } - NetlinkPayload::InnerMessage(msg) => match msg { - RouteNetlinkMessage::NewAddress(msg) => { - trace!("NEWADDR: {:?}", msg); - let addrs = addr_cache.entry(msg.header.index).or_default(); - if let Some(addr) = get_nla!(msg, address::AddressAttribute::Address) { - if addrs.contains(addr) { - continue; - } else { - addrs.insert(*addr); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + }; + match message { + Message::NewAddress(msg) => { + trace!("NEWADDR: {:?}", msg); + let addrs = addr_cache.entry(msg.index).or_default(); + if let Some(addr) = msg.address { + if addrs.contains(&addr) { + continue; + } else { + addrs.insert(addr); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } } } - RouteNetlinkMessage::DelAddress(msg) => { - trace!("DELADDR: {:?}", msg); - let addrs = addr_cache.entry(msg.header.index).or_default(); - if let Some(addr) = get_nla!(msg, address::AddressAttribute::Address) { - addrs.remove(addr); - } - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } - } - RouteNetlinkMessage::NewRoute(msg) | RouteNetlinkMessage::DelRoute(msg) => { - trace!("ROUTE:: {:?}", msg); - - let table = get_nla!(msg, route::RouteAttribute::Table) - .copied() - .unwrap_or_default(); - if let Some(dst) = get_nla!(msg, route::RouteAttribute::Destination) { - match dst { - route::RouteAddress::Inet(addr) - if (table == 255 || table == 254) - && (addr.is_multicast() - || is_link_local(IpAddr::V4(*addr))) => - { - continue; - } - route::RouteAddress::Inet6(addr) - if (table == 255 || table == 254) - && (addr.is_multicast() - || is_link_local(IpAddr::V6(*addr))) => - { - continue; - } - _ => {} - } - } - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + } + Message::DelAddress(msg) => { + trace!("DELADDR: {:?}", msg); + let addrs = addr_cache.entry(msg.index).or_default(); + if let Some(addr) = msg.address { + addrs.remove(&addr); } - RouteNetlinkMessage::NewRule(msg) => { - trace!("NEWRULE: {:?}", msg); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - RouteNetlinkMessage::DelRule(msg) => { - trace!("DELRULE: {:?}", msg); - if sender.send(NetworkMessage::Change).await.is_err() { - return false; - } + } + Message::NewRoute(msg) | Message::DelRoute(msg) => { + trace!("ROUTE:: {:?}", msg); + + let table = msg.table.unwrap_or_default(); + if let Some(dst) = msg.destination + && (table == 255 || table == 254) + && (dst.is_multicast() || is_link_local(dst)) + { + // Ignore multicast and link-local route changes in the + // local and main tables; they are not interesting. + continue; } - RouteNetlinkMessage::NewLink(msg) => { - trace!("NEWLINK: {:?}", msg); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - RouteNetlinkMessage::DelLink(msg) => { - trace!("DELLINK: {:?}", msg); + } + Message::NewRule => { + trace!("NEWRULE"); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - msg => { - trace!("unhandled: {:?}", msg); + } + Message::DelRule => { + trace!("DELRULE"); + if sender.send(NetworkMessage::Change).await.is_err() { + return false; } - }, - _ => {} + } + Message::NewLink(msg) => { + trace!("NEWLINK: {:?}", msg); + } + Message::DelLink(msg) => { + trace!("DELLINK: {:?}", msg); + } + msg => { + trace!("unhandled: {:?}", msg); + } } } - - // Stream ended — connection lost - true } impl RouteMonitor { @@ -182,11 +133,11 @@ impl RouteMonitor { const MAX_BACKOFF: Duration = Duration::from_secs(30); loop { - match setup_netlink() { - Ok((_conn_handle, mut messages)) => { + match subscribe() { + Ok(mut events) => { backoff = Duration::from_secs(1); - let should_reconnect = process_messages(&sender, &mut messages).await; - // _conn_handle dropped here, aborting the connection task + let should_reconnect = process_messages(&sender, &mut events).await; + // events dropped here, closing the socket if !should_reconnect { break; }