diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 949794c..039327b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,3 +22,12 @@ jobs: binaries: '[{"name":"microdns","dist":"microdns-linux"}]' build_env: | MICRODNS_GIT_COMMIT=${{ github.sha }} + + # Guidelines §17.2: optimized tests with debug-assertions / overflow-checks. + release-assertions: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - name: cargo test --profile release-assertions + run: cargo test --profile release-assertions --locked diff --git a/README.md b/README.md index 6e4635d..4a08aec 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,9 @@ Micro-daemon that advertises mDNS/DNS-SD services for BigFred OS. Quietly retries when interfaces, the microinit control socket, or dcc-bus are -unavailable. Always starts successfully. +unavailable. Always starts successfully. Survives network drop/return and +interface add/remove via rtnetlink (with polling fallback). Resolves hostnames +per receiving interface so a WiFi client gets the WiFi address. ## Features @@ -55,7 +57,8 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. "mdnsMs": 3000, "ifaceMs": 5000 }, - "skipInterfaces": [] + "skipInterfaces": [], + "interfaces": [] } ``` @@ -71,6 +74,15 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. Entries are name **prefixes**, not globs or exact names: `"wlan"` covers `wlan0`/`wlan1` but not `wlp3s0`, and a short entry like `"e"` would take `eth0` and `enp1s0` with it, leaving nothing to advertise on. +- `interfaces` (default `[]`): optional allowlist of interface-name prefixes + (same prefix rules as `skipInterfaces`). Empty means use every usable + interface that is not skipped. When set (e.g. `["eth","enp"]`), only + matching interfaces are used; a listed interface that disappears logs a + warning and is retried — it does not crash the daemon. +- Hostname A/AAAA answers (`bigfred.local`) are selected **per receiving + interface** (via `IP_PKTINFO`): a client querying on WiFi gets the WiFi + address, not the Ethernet one. Interface add/remove/address changes are + detected via rtnetlink with polling fallback. ## Run diff --git a/src/config.rs b/src/config.rs index f58f905..36ece24 100644 --- a/src/config.rs +++ b/src/config.rs @@ -137,6 +137,13 @@ pub struct Config { /// where `wireless-programmer` owns the radio) add `["wlan"]` here. #[serde(default)] pub skip_interfaces: Vec, + /// Optional allowlist of interface name prefixes (case-insensitive). + /// Empty (default) means advertise on every usable interface that is not + /// skipped. When non-empty, only matching interfaces are used; a listed + /// interface that is temporarily missing logs a warning and is retried — + /// it does not crash the daemon. + #[serde(default)] + pub interfaces: Vec, } impl Default for Config { @@ -153,6 +160,7 @@ impl Default for Config { dcc_bus: DccBusConfig::default(), retry: RetryConfig::default(), skip_interfaces: Vec::new(), + interfaces: Vec::new(), } } } @@ -189,10 +197,29 @@ impl Config { )); } } + validate_iface_prefixes("skipInterfaces", &self.skip_interfaces)?; + validate_iface_prefixes("interfaces", &self.interfaces)?; Ok(()) } } +fn validate_iface_prefixes(field: &str, entries: &[String]) -> Result<()> { + let mut seen = std::collections::HashSet::new(); + for entry in entries { + let trimmed = entry.trim(); + if trimmed.is_empty() { + return Err(Error::Config(format!("{field}: entries must not be empty"))); + } + let key = trimmed.to_ascii_lowercase(); + if !seen.insert(key) { + return Err(Error::Config(format!( + "{field}: duplicate prefix '{trimmed}'" + ))); + } + } + Ok(()) +} + /// Accept `_name._tcp` / `_name._udp`, optionally with a `.local` suffix. fn validate_service_type(name: &str, type_: &str) -> Result<()> { if type_.is_empty() { diff --git a/src/iface_watch.rs b/src/iface_watch.rs new file mode 100644 index 0000000..0ba490e --- /dev/null +++ b/src/iface_watch.rs @@ -0,0 +1,170 @@ +//! Linux rtnetlink watcher for interface / address churn. +//! +//! Subscribes to `RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR` and +//! signals [`IfaceChange`] whenever anything readable arrives. Payload parsing +//! is intentionally skipped — the main loop re-scans interfaces on each signal. +//! Polling remains the fallback when netlink is unavailable. +//! +//! The signal channel is bounded ([`IFACE_CHANGE_CAPACITY`]). Overflow drops +//! events: the signal is idempotent ("something changed"), so losing duplicates +//! under burst is safe and keeps memory bounded (§1.3 / §8.5). + +use std::io::ErrorKind; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError}; +use std::sync::Arc; +use std::thread; +use std::time::Duration; + +use crate::error::{Error, Result}; +use crate::sys; + +const BIND_RETRY: Duration = Duration::from_secs(3); +const RECV_TIMEOUT: Duration = Duration::from_millis(500); + +/// Bound on coalesced iface-change signals waiting for the main loop. +pub(crate) const IFACE_CHANGE_CAPACITY: usize = 32; + +/// Signal that interface or address state may have changed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct IfaceChange; + +/// Spawn a netlink watcher thread. Returns a receiver of change signals and a +/// stop flag. Bind failures are retried quietly; they never crash the daemon. +pub(crate) fn spawn() -> Result<(Receiver, Arc)> { + let (tx, rx) = mpsc::sync_channel(IFACE_CHANGE_CAPACITY); + let stop = Arc::new(AtomicBool::new(false)); + let stop_thr = Arc::clone(&stop); + + thread::Builder::new() + .name("iface-watch".into()) + .spawn(move || { + if let Err(e) = watch_loop(tx, stop_thr) { + log::warn!("iface watcher stopped: {e}"); + } + }) + .map_err(|e| Error::Other(format!("spawn iface-watch: {e}")))?; + + Ok((rx, stop)) +} + +fn watch_loop(tx: SyncSender, stop: Arc) -> Result<()> { + let mut warned_bind = false; + + while !stop.load(Ordering::SeqCst) { + let sock = match sys::open_rtnetlink(RECV_TIMEOUT) { + Ok(s) => { + if warned_bind { + log::info!("iface watcher: netlink socket recovered"); + warned_bind = false; + } else { + log::info!("iface watcher: listening on rtnetlink"); + } + s + } + Err(e) => { + if !warned_bind { + log::warn!( + "iface watcher: netlink bind failed: {e}; retrying (polling fallback active)" + ); + warned_bind = true; + } else { + log::debug!("iface watcher: netlink bind failed: {e}"); + } + thread::sleep(BIND_RETRY); + continue; + } + }; + + while !stop.load(Ordering::SeqCst) { + match sys::recv_netlink_any(&sock) { + Ok(true) => match tx.try_send(IfaceChange) { + Ok(()) => {} + // Full: at least one change is already queued; drop extras. + Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => return Ok(()), + }, + Ok(false) => {} // timeout + Err(e) if e.kind() == ErrorKind::Interrupted => {} + Err(e) => { + log::warn!("iface watcher: recv failed: {e}; rebinding"); + break; + } + } + } + } + Ok(()) +} + +/// Drain any pending iface-change signals (for tests / main-loop coalescing). +pub(crate) fn drain(rx: &Receiver) { + while rx.try_recv().is_ok() {} +} + +/// Wait up to `timeout` for an iface-change, draining coalesced extras. +pub(crate) fn recv_timeout( + rx: &Receiver, + timeout: Duration, +) -> std::result::Result { + let signal = rx.recv_timeout(timeout)?; + drain(rx); + Ok(signal) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + + #[test] + fn spawn_starts_and_stops() { + let (rx, stop) = spawn().expect("spawn iface watcher"); + std::thread::sleep(Duration::from_millis(100)); + stop.store(true, Ordering::SeqCst); + let _ = rx.recv_timeout(Duration::from_millis(200)); + } + + #[test] + fn iface_change_channel_is_bounded() { + // Capacity is a compile-time constant; keep the check as a const assert. + const { assert!(IFACE_CHANGE_CAPACITY > 0) }; + } + + #[test] + fn iface_change_on_dummy_addr_add() { + let name = format!("mdnstst{}", std::process::id() % 10000); + let add = std::process::Command::new("ip") + .args(["link", "add", &name, "type", "dummy"]) + .output(); + let Ok(out) = add else { + eprintln!("skip: ip not available"); + return; + }; + if !out.status.success() { + eprintln!( + "skip: cannot create dummy iface (need CAP_NET_ADMIN): {}", + String::from_utf8_lossy(&out.stderr) + ); + return; + } + + let (rx, stop) = spawn().expect("spawn"); + drain(&rx); + + let _ = std::process::Command::new("ip") + .args(["link", "set", &name, "up"]) + .status(); + let _ = std::process::Command::new("ip") + .args(["addr", "add", "192.0.2.10/32", "dev", &name]) + .status(); + + let got = recv_timeout(&rx, Duration::from_secs(2)).is_ok(); + + let _ = std::process::Command::new("ip") + .args(["link", "del", &name]) + .status(); + stop.store(true, Ordering::SeqCst); + + assert!(got, "expected IfaceChange after adding address on {name}"); + } +} diff --git a/src/legacy_unicast.rs b/src/legacy_unicast.rs index 10823a8..2d2b4e8 100644 --- a/src/legacy_unicast.rs +++ b/src/legacy_unicast.rs @@ -1,36 +1,60 @@ -//! Legacy unicast / one-shot mDNS responder (RFC 6762 §5.1 / §6.7). +//! Legacy unicast / one-shot mDNS responder (RFC 6762 §5.1 / §6.7) plus +//! per-interface A/AAAA answers for multicast queries. //! //! `mdns-sd` 0.20.3 answers legacy queries via unicast but hardcodes transaction //! ID=0 (`DnsOutgoing.multicast` is never cleared). Android `getaddrinfo` rejects //! mismatched IDs. This module listens on the same port with `SO_REUSEPORT`, -//! answers A/AAAA one-shot queries with the query ID echoed, TTL 10, and no -//! cache-flush bit. +//! answers A/AAAA queries with the query ID echoed, TTL 10, and no cache-flush +//! bit — and selects the answer address from the receiving interface via +//! `IP_PKTINFO` / `IPV6_PKTINFO`. //! -//! Remove when upstream fixes `DnsOutgoing` multicast / id encoding. +//! Remove the legacy ID-echo path when upstream fixes `DnsOutgoing` multicast / +//! id encoding; keep the per-interface selection. -use std::io::ErrorKind; -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; use std::time::Duration; -use socket2::{Domain, Protocol, Socket, Type}; +use socket2::{Domain, Protocol, SockAddr, Socket, Type}; use crate::error::{Error, Result}; use crate::mdns; +use crate::sys; -/// Hosts and addresses answered for one-shot A/AAAA queries. +/// IPv4 address bound to a specific interface (for per-iface replies). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfaceAddr4 { + /// Kernel interface name, e.g. `"eth0"` / `"wlan0"`. + pub iface: String, + pub addr: Ipv4Addr, + pub mask: Ipv4Addr, + pub ifindex: u32, +} + +/// IPv6 address bound to a specific interface (for per-iface replies). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct IfaceAddr6 { + /// Kernel interface name, e.g. `"eth0"` / `"wlan0"`. + pub iface: String, + pub addr: Ipv6Addr, + pub ifindex: u32, +} + +/// Hosts and addresses answered for A/AAAA queries. #[derive(Debug, Default, Clone, PartialEq, Eq)] pub struct AnswerSet { /// Normalized hosts, e.g. `"bigfred.local."`. pub hosts: Vec, - /// Preferred IPv4 addresses with netmasks `(addr, mask)`. - pub v4: Vec<(Ipv4Addr, Ipv4Addr)>, - /// Preferred global/ULA IPv6 addresses. - pub v6: Vec, + /// Preferred IPv4 addresses with netmask and ifindex. + pub v4: Vec, + /// Preferred global/ULA IPv6 addresses with ifindex. + pub v6: Vec, /// Configured extra interface-name prefixes to skip (mirrors `Config`). pub skip_interfaces: Vec, + /// Optional allowlist of interface-name prefixes (mirrors `Config`). + pub interfaces: Vec, } pub const MDNS_PORT: u16 = 5353; @@ -45,7 +69,7 @@ const BIND_RETRY: Duration = Duration::from_secs(3); const RECV_TIMEOUT: Duration = Duration::from_millis(200); const HEADER_QR_AA: u16 = 0x8400; -/// Parsed one-shot query (first question only). +/// Parsed A/AAAA/ANY query (first question only). #[derive(Debug, Clone, PartialEq, Eq)] pub struct ParsedQuery { pub id: u16, @@ -55,7 +79,7 @@ pub struct ParsedQuery { pub qclass: u16, } -/// Spawn the legacy-unicast responder thread. +/// Spawn the A/AAAA responder thread. /// /// On `MDNS_PORT` (5353) joins multicast groups on preferred interfaces. /// On any other port (tests), binds loopback / unspecified without multicast. @@ -74,8 +98,8 @@ pub fn spawn(state: Arc>, port: u16, stop: Arc) -> fn run_loop(state: Arc>, port: u16, stop: Arc) -> Result<()> { let test_mode = port != MDNS_PORT; let mut warned_bind = false; - let mut sock_v4: Option = None; - let mut sock_v6: Option = None; + let mut sock_v4: Option = None; + let mut sock_v6: Option = None; let mut joined_v4: Vec = Vec::new(); let mut joined_ifindexes: Vec = Vec::new(); // Cache of the AnswerSet snapshot last used to compute multicast joins. @@ -131,16 +155,15 @@ fn run_loop(state: Arc>, port: u16, stop: Arc) -> } } - let mut buf = [0u8; 2048]; let mut got_any = false; if let Some(sock) = sock_v4.as_ref() { - match sock.recv_from(&mut buf) { - Ok((n, peer)) => { + match sys::recv_with_pktinfo(sock, false) { + Ok(Some(pkt)) => { got_any = true; - handle_packet(sock, &buf[..n], peer, &state); + handle_packet(sock, &pkt.buf[..pkt.len], pkt.peer, pkt.ifindex, &state); } - Err(e) if is_timeout(&e) => {} + Ok(None) => {} Err(e) => { log::debug!("legacy unicast v4 recv: {e}"); sock_v4 = None; @@ -150,12 +173,12 @@ fn run_loop(state: Arc>, port: u16, stop: Arc) -> } } if let Some(sock) = sock_v6.as_ref() { - match sock.recv_from(&mut buf) { - Ok((n, peer)) => { + match sys::recv_with_pktinfo(sock, true) { + Ok(Some(pkt)) => { got_any = true; - handle_packet(sock, &buf[..n], peer, &state); + handle_packet(sock, &pkt.buf[..pkt.len], pkt.peer, pkt.ifindex, &state); } - Err(e) if is_timeout(&e) => {} + Ok(None) => {} Err(e) => { log::debug!("legacy unicast v6 recv: {e}"); sock_v6 = None; @@ -172,27 +195,30 @@ fn run_loop(state: Arc>, port: u16, stop: Arc) -> Ok(()) } -fn is_timeout(e: &std::io::Error) -> bool { - matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) -} - -fn handle_packet(sock: &UdpSocket, packet: &[u8], peer: SocketAddr, state: &RwLock) { - let Some(query) = parse_query(packet, peer.port()) else { +fn handle_packet( + sock: &Socket, + packet: &[u8], + peer: SocketAddr, + ifindex: Option, + state: &RwLock, +) { + let Some(query) = parse_query(packet) else { return; }; let answers = match state.read() { Ok(g) => g.clone(), Err(_) => return, }; - let Some(resp) = build_response(&query, &answers, peer.ip()) else { + let Some(resp) = build_response(&query, &answers, peer.ip(), ifindex) else { return; }; - if let Err(e) = sock.send_to(&resp, peer) { + let dest = SockAddr::from(peer); + if let Err(e) = sock.send_to(&resp, &dest) { log::debug!("legacy unicast send_to {peer}: {e}"); } } -fn bind_v4(port: u16, test_mode: bool) -> Result { +fn bind_v4(port: u16, test_mode: bool) -> Result { let sock = Socket::new(Domain::IPV4, Type::DGRAM, Some(Protocol::UDP))?; sock.set_reuse_address(true)?; #[cfg(unix)] @@ -202,16 +228,17 @@ fn bind_v4(port: u16, test_mode: bool) -> Result { } } sock.set_read_timeout(Some(RECV_TIMEOUT))?; + sys::enable_pktinfo_v4(&sock).map_err(Error::Io)?; let addr = if test_mode { SocketAddr::from((Ipv4Addr::LOCALHOST, port)) } else { SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)) }; sock.bind(&addr.into())?; - Ok(sock.into()) + Ok(sock) } -fn bind_v6(port: u16, test_mode: bool) -> Result { +fn bind_v6(port: u16, test_mode: bool) -> Result { let sock = Socket::new(Domain::IPV6, Type::DGRAM, Some(Protocol::UDP))?; sock.set_reuse_address(true)?; #[cfg(unix)] @@ -222,39 +249,41 @@ fn bind_v6(port: u16, test_mode: bool) -> Result { } sock.set_only_v6(true)?; sock.set_read_timeout(Some(RECV_TIMEOUT))?; + sys::enable_pktinfo_v6(&sock).map_err(Error::Io)?; let addr = if test_mode { SocketAddr::from((Ipv6Addr::LOCALHOST, port)) } else { SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)) }; sock.bind(&addr.into())?; - Ok(sock.into()) + Ok(sock) } fn refresh_memberships( - sock_v4: Option<&UdpSocket>, - sock_v6: Option<&UdpSocket>, + sock_v4: Option<&Socket>, + sock_v6: Option<&Socket>, state: &RwLock, joined_v4: &mut Vec, joined_ifindexes: &mut Vec, ) { - // One read, so the addresses and the skip list are guaranteed to come from - // the same generation of the AnswerSet. - let (mut want_v4, skip): (Vec, Vec) = state + // One read, so the addresses and the skip/allow lists are from the same + // generation of the AnswerSet. + let (v4, v6, allow, skip) = state .read() .map(|g| { ( - g.v4.iter().map(|(ip, _)| *ip).collect(), + g.v4.clone(), + g.v6.clone(), + g.interfaces.clone(), g.skip_interfaces.clone(), ) }) .unwrap_or_default(); + + let mut want_v4: Vec = v4.iter().map(|a| a.addr).collect(); want_v4.sort_unstable(); want_v4.dedup(); - // Prefer explicit iface list; also pick up indexes when AnswerSet has no v4 - // yet (v6-only / A-over-v6 queries). Use the configured skip list so the - // indexes stay consistent with the v4/v6 address selection. - let mut want_idx = mdns::preferred_iface_indexes(&skip); + let mut want_idx = mdns::preferred_iface_indexes(&allow, &skip); want_idx.sort_unstable(); want_idx.dedup(); @@ -264,12 +293,31 @@ fn refresh_memberships( let _ = sock.leave_multicast_v4(&MDNS_GROUP_V4, ip); } joined_v4.clear(); - for ip in &want_v4 { - match sock.join_multicast_v4(&MDNS_GROUP_V4, ip) { - Ok(()) => joined_v4.push(*ip), - Err(e) => log::debug!("join multicast v4 on {ip}: {e}"), + for a in &v4 { + match sock.join_multicast_v4(&MDNS_GROUP_V4, &a.addr) { + Ok(()) => { + log::info!( + "mDNS multicast joined iface={} ifindex={} group={MDNS_GROUP_V4} local={}", + a.iface, + a.ifindex, + a.addr + ); + if !joined_v4.contains(&a.addr) { + joined_v4.push(a.addr); + } + } + Err(e) => log::warn!( + "mDNS multicast join failed iface={} local={}: {e}", + a.iface, + a.addr + ), } } + // Compare as sorted sets so re-deriving `want_v4` (sorted+deduped) + // doesn't flap membership when the join order differs from the + // declaration order. + joined_v4.sort_unstable(); + joined_v4.dedup(); } } @@ -280,21 +328,40 @@ fn refresh_memberships( } joined_ifindexes.clear(); for idx in &want_idx { + let iface_label = v6 + .iter() + .find(|a| a.ifindex == *idx) + .map(|a| a.iface.as_str()) + .or_else(|| { + v4.iter() + .find(|a| a.ifindex == *idx) + .map(|a| a.iface.as_str()) + }) + .unwrap_or("?"); + let local_v6: Vec = v6 + .iter() + .filter(|a| a.ifindex == *idx) + .map(|a| a.addr.to_string()) + .collect(); match sock.join_multicast_v6(&MDNS_GROUP_V6, *idx) { - Ok(()) => joined_ifindexes.push(*idx), - Err(e) => log::debug!("join multicast v6 ifindex {idx}: {e}"), + Ok(()) => { + log::info!( + "mDNS multicast joined iface={iface_label} ifindex={idx} group={MDNS_GROUP_V6} local_v6={local_v6:?}" + ); + joined_ifindexes.push(*idx); + } + Err(e) => log::warn!( + "mDNS multicast join failed iface={iface_label} ifindex={idx}: {e}" + ), } } } } } -/// Parse a DNS query packet; returns [`None`] when the packet is not a legacy -/// one-shot A/AAAA/ANY query we should answer. -pub fn parse_query(packet: &[u8], src_port: u16) -> Option { - if src_port == MDNS_PORT { - return None; - } +/// Parse a DNS query packet; returns [`None`] when the packet is not an +/// A/AAAA/ANY query we should answer. +pub fn parse_query(packet: &[u8]) -> Option { if packet.len() < 12 { return None; } @@ -331,11 +398,12 @@ pub fn parse_query(packet: &[u8], src_port: u16) -> Option { }) } -/// Build a unicast response, or [`None`] when the query should be ignored. +/// Build a response, or [`None`] when the query should be ignored. pub fn build_response( query: &ParsedQuery, answers: &AnswerSet, querier: IpAddr, + ifindex: Option, ) -> Option> { if !hosts_match(&answers.hosts, &query.qname) { return None; @@ -351,12 +419,12 @@ pub fn build_response( IpAddr::V4(v4) => v4, IpAddr::V6(_) => Ipv4Addr::UNSPECIFIED, }; - for ip in choose_v4(answers, querier_v4) { + for ip in choose_v4_for_iface(answers, querier_v4, ifindex) { records.push((QTYPE_A, ip.octets().to_vec())); } } if want_aaaa { - for ip in &answers.v6 { + for ip in choose_v6_for_iface(answers, ifindex) { records.push((QTYPE_AAAA, ip.octets().to_vec())); } } @@ -395,22 +463,88 @@ pub fn build_response( Some(out) } +/// Prefer addresses on the receiving ifindex; within those, same-subnet; +/// otherwise fall back to same-subnet globally, then all. +/// +/// Builds a single result `Vec` with at most two linear scans — no intermediate +/// collections. +#[must_use] +pub fn choose_v4_for_iface( + answers: &AnswerSet, + querier: Ipv4Addr, + ifindex: Option, +) -> Vec { + let mut out = Vec::new(); + if let Some(idx) = ifindex { + let has_iface = answers.v4.iter().any(|a| a.ifindex == idx); + if has_iface { + for a in &answers.v4 { + if a.ifindex == idx && same_subnet(a.addr, a.mask, querier) { + out.push(a.addr); + } + } + if !out.is_empty() { + return out; + } + for a in &answers.v4 { + if a.ifindex == idx { + out.push(a.addr); + } + } + return out; + } + } + choose_v4_into(answers, querier, &mut out); + out +} + +/// Prefer IPv6 addresses on the receiving ifindex; otherwise all configured v6. +/// +/// Single scan into the result — no intermediate collections. +#[must_use] +pub fn choose_v6_for_iface(answers: &AnswerSet, ifindex: Option) -> Vec { + let mut out = Vec::new(); + if let Some(idx) = ifindex { + for a in &answers.v6 { + if a.ifindex == idx { + out.push(a.addr); + } + } + if !out.is_empty() { + return out; + } + } + for a in &answers.v6 { + out.push(a.addr); + } + out +} + /// Prefer same-subnet IPv4 addresses; otherwise all configured v4. #[must_use] pub fn choose_v4(answers: &AnswerSet, querier: Ipv4Addr) -> Vec { - let same: Vec = answers - .v4 - .iter() - .filter(|(addr, mask)| { - u32::from(*addr) & u32::from(*mask) == u32::from(querier) & u32::from(*mask) - }) - .map(|(addr, _)| *addr) - .collect(); - if !same.is_empty() { - same - } else { - answers.v4.iter().map(|(addr, _)| *addr).collect() + let mut out = Vec::new(); + choose_v4_into(answers, querier, &mut out); + out +} + +fn choose_v4_into(answers: &AnswerSet, querier: Ipv4Addr, out: &mut Vec) { + debug_assert!(out.is_empty()); + for a in &answers.v4 { + if same_subnet(a.addr, a.mask, querier) { + out.push(a.addr); + } + } + if !out.is_empty() { + return; } + for a in &answers.v4 { + out.push(a.addr); + } +} + +fn same_subnet(addr: Ipv4Addr, mask: Ipv4Addr, querier: Ipv4Addr) -> bool { + u32::from(addr) & u32::from(mask) == u32::from(querier) & u32::from(mask) } /// Case-insensitive host match against normalized names (trailing dot). diff --git a/src/lib.rs b/src/lib.rs index 71e94b8..6cca779 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,12 +5,14 @@ pub mod config; pub mod config_watch; pub mod datadir; pub mod error; +pub mod iface_watch; pub mod legacy_unicast; pub mod mdns; pub mod microinit_watch; pub mod proc_scan; pub mod run; pub mod signals; +pub(crate) mod sys; pub mod version; pub use error::{Error, Result}; diff --git a/src/mdns.rs b/src/mdns.rs index df9764d..2366010 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -12,6 +12,7 @@ use mdns_sd::{ServiceDaemon, ServiceInfo}; use crate::config::ServiceEntry; use crate::error::{Error, Result}; +use crate::legacy_unicast::{IfaceAddr4, IfaceAddr6}; use crate::version; /// Tracked registrations keyed by full service name. @@ -46,11 +47,18 @@ impl MdnsPublisher { } } - /// Register (or re-register) a service. Uses auto addresses when available. + /// Register (or re-register) a service. + /// + /// Host A/AAAA records are published by mdns-sd via multicast so that local + /// resolvers (e.g. avahi-daemon backing nss-mdns) cache them and the host + /// can resolve its own `.local` names. The legacy unicast responder still + /// answers A/AAAA for direct (non-5353) legacy queries, but mdns-sd owns + /// the multicast A/AAAA announcements. pub fn register( &self, entry: &ServiceEntry, host_override: Option<&str>, + allow: &[String], skip: &[String], ) -> Result<()> { let daemon = self @@ -65,16 +73,19 @@ impl MdnsPublisher { .unwrap_or(&version::hostname()), ); let props = entry.txt.clone().unwrap_or_default(); - let ips = preferred_ipv4_addrs(skip); - let info = if ips.is_empty() { - // No usable interface yet — register with addr_auto so mdns-sd - // fills addresses when interfaces appear. - ServiceInfo::new(&ty, &entry.name, &host, "", entry.port, props.clone()) + // Collect preferred IPv4 + IPv6 addresses (allow/skip filtered) so + // mdns-sd publishes A/AAAA on the wire. Empty → addr_auto lets mdns-sd + // fill from host interfaces when they appear later. + let v4 = preferred_ipv4_addrs(allow, skip); + let v6 = preferred_ipv6_addrs(allow, skip); + let info = if v4.is_empty() && v6.is_empty() { + ServiceInfo::new(&ty, &entry.name, &host, "", entry.port, props) .map_err(|e| Error::Mdns(e.to_string()))? .enable_addr_auto() } else { - let ip_strs: Vec = ips.iter().map(|ip| ip.to_string()).collect(); + let mut ip_strs: Vec = v6.iter().map(|a| a.addr.to_string()).collect(); + ip_strs.extend(v4.iter().map(|ip| ip.to_string())); let joined = ip_strs.join(","); ServiceInfo::new(&ty, &entry.name, &host, joined.as_str(), entry.port, props) .map_err(|e| Error::Mdns(e.to_string()))? @@ -175,57 +186,60 @@ pub fn normalize_hostname(host: &str) -> String { format!("{bare}.local.") } -/// Collect preferred IPv4 addresses: UP, non-loopback, not docker/veth/br-*. +/// Collect preferred IPv4 addresses: UP, non-loopback, allowlisted, not skipped. #[must_use] -pub fn preferred_ipv4_addrs(skip: &[String]) -> Vec { - preferred_ipv4_ifaces(skip) +pub fn preferred_ipv4_addrs(allow: &[String], skip: &[String]) -> Vec { + preferred_ipv4_ifaces(allow, skip) .into_iter() - .map(|(ip, _mask)| ip) + .map(|a| a.addr) .collect() } -/// Preferred IPv4 addresses with netmasks (for same-subnet reply selection). +/// Preferred IPv4 addresses with netmasks and ifindex (for per-iface replies). #[must_use] -pub fn preferred_ipv4_ifaces(skip: &[String]) -> Vec<(Ipv4Addr, Ipv4Addr)> { +pub fn preferred_ipv4_ifaces(allow: &[String], skip: &[String]) -> Vec { let mut addrs = Vec::new(); let Ok(ifaces) = list_interfaces() else { return addrs; }; for iface in ifaces { - if should_skip_iface(&iface.name, skip) { - continue; - } - if !iface.is_up || iface.is_loopback { + if !iface_usable(&iface, allow, skip) { continue; } for (ip, mask) in iface.ipv4 { if !ip.is_loopback() && !ip.is_unspecified() { - addrs.push((ip, mask)); + addrs.push(IfaceAddr4 { + iface: iface.name.clone(), + addr: ip, + mask, + ifindex: iface.ifindex, + }); } } } addrs } -/// Preferred global/ULA IPv6 addresses (no loopback, unspecified, or link-local). +/// Preferred global/ULA IPv6 addresses with ifindex (no loopback/unspecified/link-local). #[must_use] -pub fn preferred_ipv6_addrs(skip: &[String]) -> Vec { +pub fn preferred_ipv6_addrs(allow: &[String], skip: &[String]) -> Vec { let mut addrs = Vec::new(); let Ok(ifaces) = list_interfaces() else { return addrs; }; for iface in ifaces { - if should_skip_iface(&iface.name, skip) { - continue; - } - if !iface.is_up || iface.is_loopback { + if !iface_usable(&iface, allow, skip) { continue; } for ip in iface.ipv6 { if ip.is_loopback() || ip.is_unspecified() || is_ipv6_link_local(&ip) { continue; } - addrs.push(ip); + addrs.push(IfaceAddr6 { + iface: iface.name.clone(), + addr: ip, + ifindex: iface.ifindex, + }); } } addrs @@ -233,16 +247,13 @@ pub fn preferred_ipv6_addrs(skip: &[String]) -> Vec { /// Interface index for multicast group joins (IPv6). #[must_use] -pub fn preferred_iface_indexes(skip: &[String]) -> Vec { +pub fn preferred_iface_indexes(allow: &[String], skip: &[String]) -> Vec { let mut out = Vec::new(); let Ok(ifaces) = list_interfaces() else { return out; }; for iface in ifaces { - if should_skip_iface(&iface.name, skip) { - continue; - } - if !iface.is_up || iface.is_loopback { + if !iface_usable(&iface, allow, skip) { continue; } if iface.ipv4.is_empty() && iface.ipv6.is_empty() { @@ -255,11 +266,42 @@ pub fn preferred_iface_indexes(skip: &[String]) -> Vec { out } +fn iface_usable(iface: &IfaceInfo, allow: &[String], skip: &[String]) -> bool { + if should_skip_iface(&iface.name, skip) { + return false; + } + if !is_allowed_iface(&iface.name, allow) { + return false; + } + if !iface.is_up || iface.is_loopback { + return false; + } + true +} + fn is_ipv6_link_local(ip: &Ipv6Addr) -> bool { let octets = ip.octets(); octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 } +/// Whether `name` is allowed by the optional allowlist. +/// +/// Empty `allow` means every interface is allowed. Otherwise matching is by +/// case-insensitive name **prefix**, same rules as [`should_skip_iface`]. +#[must_use] +pub fn is_allowed_iface(name: &str, allow: &[String]) -> bool { + if allow.is_empty() { + return true; + } + let n = name.to_ascii_lowercase(); + allow.iter().any(|p| { + let p = p.trim(); + !p.is_empty() + && n.get(..p.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(p)) + }) +} + /// Whether to skip an interface by name. /// /// Always skips the built-in container/virtual bridge interfaces @@ -414,14 +456,14 @@ pub fn dcc_service_entry( /// Check whether any preferred interface currently has an IPv4 address. #[must_use] -pub fn has_usable_iface(skip: &[String]) -> bool { - !preferred_ipv4_addrs(skip).is_empty() +pub fn has_usable_iface(allow: &[String], skip: &[String]) -> bool { + !preferred_ipv4_addrs(allow, skip).is_empty() } /// Return first preferred IP as [`IpAddr`], if any. #[must_use] -pub fn primary_ip(skip: &[String]) -> Option { - preferred_ipv4_addrs(skip) +pub fn primary_ip(allow: &[String], skip: &[String]) -> Option { + preferred_ipv4_addrs(allow, skip) .into_iter() .next() .map(IpAddr::V4) diff --git a/src/run.rs b/src/run.rs index 8e5c1de..5f3d033 100644 --- a/src/run.rs +++ b/src/run.rs @@ -1,7 +1,7 @@ //! Main daemon loop: orchestrates config watch, mDNS, dcc-bus discovery, beacon. use std::collections::{HashMap, HashSet}; -use std::net::Ipv4Addr; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -12,6 +12,7 @@ use crate::beacon::{self, virtual_serial}; use crate::config::{self, Config, ServiceEntry}; use crate::config_watch::{self, ReloadSignal}; use crate::error::Result; +use crate::iface_watch::{self, IfaceChange}; use crate::legacy_unicast::{self, AnswerSet}; use crate::mdns::{self, MdnsPublisher}; use crate::microinit_watch; @@ -78,14 +79,17 @@ pub struct BeaconWant { /// Desired advertisement set derived from config + empirical dcc-bus state. /// -/// `ips` is included so DHCP / interface address changes trigger re-registration. +/// `ips` (IPv4) and `ips_v6` (IPv6) are both included so DHCP / SLAAC privacy +/// address churn triggers re-registration of A/AAAA via mdns-sd. #[derive(Debug, Clone, PartialEq, Eq)] pub struct DesiredAds { pub static_services: Vec, pub dynamic: Vec, pub beacons: Vec, pub ips: Vec, + pub ips_v6: Vec, pub skip_interfaces: Vec, + pub interfaces: Vec, } struct ActiveBeacon { @@ -113,6 +117,14 @@ pub fn run(config_path: &Path) -> Result<()> { let stop = Arc::new(AtomicBool::new(false)); let (reload_rx, watch_stop) = config_watch::spawn(config_path.to_path_buf())?; + let (iface_rx, iface_watch_stop) = match iface_watch::spawn() { + Ok(pair) => pair, + Err(e) => { + log::warn!("iface watcher failed to start: {e}; relying on polling"); + let (_tx, rx) = std::sync::mpsc::channel::(); + (rx, Arc::new(AtomicBool::new(true))) + } + }; let publisher = Arc::new(Mutex::new(MdnsPublisher::new())); let beacons: Arc>> = Arc::new(Mutex::new(Vec::new())); @@ -146,43 +158,57 @@ pub fn run(config_path: &Path) -> Result<()> { dynamic: Vec::new(), beacons: Vec::new(), ips: Vec::new(), + ips_v6: Vec::new(), skip_interfaces: Vec::new(), + interfaces: Vec::new(), }; let mut registered: HashMap = HashMap::new(); while !signals::shutdown_requested() && !stop.load(Ordering::SeqCst) { let cfg = shared.config.read().map(|c| c.clone()).unwrap_or_default(); - - let retry = cfg.retry.clone(); + let mdns_ms = cfg.retry.mdns_ms; // Ensure mDNS daemon (quiet retry). { - let mut pub_guard = publisher.lock().unwrap(); + let mut pub_guard = lock_mutex(&publisher); match pub_guard.ensure_daemon() { Ok(()) => mdns_thr.ok("mDNS daemon"), Err(e) => { mdns_thr.fail("mDNS daemon", &e); - sleep_interruptible(Duration::from_millis(retry.mdns_ms)); + sleep_or_iface(&iface_rx, Duration::from_millis(mdns_ms)); continue; } } } - let ips = mdns::preferred_ipv4_addrs(&cfg.skip_interfaces); + let ips = mdns::preferred_ipv4_addrs(&cfg.interfaces, &cfg.skip_interfaces); + let ips_v6: Vec = + mdns::preferred_ipv6_addrs(&cfg.interfaces, &cfg.skip_interfaces) + .into_iter() + .map(|a| a.addr) + .collect(); if ips.is_empty() { - // Name the configured skips too: on a hub with skipInterfaces set, - // this is the message an operator sees when the only addressed - // interface is the one they told us to ignore. - let mut why = String::from("no UP non-loopback IPv4 (skipping docker/veth/br-*"); - if !cfg.skip_interfaces.is_empty() { - why.push_str(&format!(", configured {:?}", cfg.skip_interfaces)); - } - why.push(')'); + let why = if !cfg.interfaces.is_empty() { + format!( + "none of configured interfaces present/usable (interfaces={:?}, skip={:?})", + cfg.interfaces, cfg.skip_interfaces + ) + } else { + let mut why = String::from("no UP non-loopback IPv4 (skipping docker/veth/br-*"); + if !cfg.skip_interfaces.is_empty() { + why.push_str(&format!(", configured {:?}", cfg.skip_interfaces)); + } + why.push(')'); + why + }; iface_thr.fail("network interface", &why); } else { iface_thr.ok("network interface"); } + // Take interface lists once; AnswerSet clones, DesiredAds owns. + let interfaces = cfg.interfaces; + let skip_interfaces = cfg.skip_interfaces; { let mut hosts = Vec::new(); for svc in &cfg.services { @@ -194,26 +220,35 @@ pub fn run(config_path: &Path) -> Result<()> { } let next = AnswerSet { hosts, - v4: mdns::preferred_ipv4_ifaces(&cfg.skip_interfaces), - v6: mdns::preferred_ipv6_addrs(&cfg.skip_interfaces), - skip_interfaces: cfg.skip_interfaces.clone(), + v4: mdns::preferred_ipv4_ifaces(&interfaces, &skip_interfaces), + v6: mdns::preferred_ipv6_addrs(&interfaces, &skip_interfaces), + skip_interfaces: skip_interfaces.clone(), + interfaces: interfaces.clone(), }; if let Ok(mut w) = answer_set.write() { if *w != next { + log_detected_interfaces(&next); *w = next; } } } let mut desired = DesiredAds { - static_services: cfg.services.clone(), + static_services: cfg.services, dynamic: Vec::new(), beacons: Vec::new(), - ips: ips.clone(), - skip_interfaces: cfg.skip_interfaces.clone(), + ips, + ips_v6, + skip_interfaces, + interfaces, }; + let dcc_enabled = cfg.dcc_bus.enabled; + let z21_port = cfg.dcc_bus.z21_port; + let withrottle_port = cfg.dcc_bus.withrottle_port; + let beacon = cfg.dcc_bus.beacon; + let retry = cfg.retry; - if cfg.dcc_bus.enabled { + if dcc_enabled { let sock = config::default_microinit_socket(); match microinit_watch::list_dcc_bus_services(&sock) { Ok(services) => { @@ -227,7 +262,9 @@ pub fn run(config_path: &Path) -> Result<()> { microinit_thr.ok("microinit dcc-bus"); let mut any_scan_ok = false; for st in &running { - let pid = st.pid.unwrap(); + let Some(pid) = st.pid else { + continue; + }; match proc_scan::listen_ports_for_pid(pid) { Ok(ports) => { any_scan_ok = true; @@ -235,9 +272,9 @@ pub fn run(config_path: &Path) -> Result<()> { &mut desired, &st.name, &ports, - cfg.dcc_bus.z21_port, - cfg.dcc_bus.withrottle_port, - cfg.dcc_bus.beacon, + z21_port, + withrottle_port, + beacon, ); } Err(e) => proc_thr.fail("proc listen scan", &e), @@ -265,7 +302,10 @@ pub fn run(config_path: &Path) -> Result<()> { // Reconcile advertisements when desired set changes (incl. IP churn). if desired != last_desired { - let ips_changed = desired.ips != last_desired.ips; + let ips_changed = desired.ips != last_desired.ips + || desired.ips_v6 != last_desired.ips_v6 + || desired.interfaces != last_desired.interfaces + || desired.skip_interfaces != last_desired.skip_interfaces; if let Err(e) = reconcile(&publisher, &desired, &mut registered, &beacons, ips_changed) { mdns_thr.fail("mDNS register", &e); @@ -275,24 +315,27 @@ pub fn run(config_path: &Path) -> Result<()> { } } - // Sleep until next poll; wake early on shutdown. - let sleep_ms = if cfg.dcc_bus.enabled { + // Sleep until next poll; wake early on shutdown or netlink iface change. + let sleep_ms = if dcc_enabled { retry.proc_ms.min(retry.microinit_ms).min(retry.iface_ms) } else { retry.iface_ms.min(retry.mdns_ms) }; - sleep_interruptible(Duration::from_millis(sleep_ms.max(500))); + sleep_or_iface(&iface_rx, Duration::from_millis(sleep_ms.max(500))); } log::info!("microdns shutting down"); stop.store(true, Ordering::SeqCst); watch_stop.store(true, Ordering::SeqCst); - if let Ok(mut active) = beacons.lock() { + iface_watch_stop.store(true, Ordering::SeqCst); + { + let mut active = lock_mutex(&beacons); for b in active.drain(..) { b.stop.store(true, Ordering::SeqCst); } } - if let Ok(mut p) = publisher.lock() { + { + let mut p = lock_mutex(&publisher); p.shutdown(); } Ok(()) @@ -409,7 +452,8 @@ fn reconcile( beacons: &Arc>>, ips_changed: bool, ) -> Result<()> { - let pub_guard = publisher.lock().unwrap(); + let pub_guard = lock_mutex(publisher); + let allow = &desired.interfaces; let skip = &desired.skip_interfaces; let mut desired_map: HashMap = HashMap::new(); @@ -427,7 +471,7 @@ fn reconcile( if registered.contains_key(key) { continue; } - pub_guard.register(entry, entry.host.as_deref(), skip)?; + pub_guard.register(entry, entry.host.as_deref(), allow, skip)?; registered.insert(key.clone(), entry.clone()); } @@ -440,7 +484,7 @@ fn reconcile( continue; } let _ = pub_guard.unregister(key); - pub_guard.register(entry, entry.host.as_deref(), skip)?; + pub_guard.register(entry, entry.host.as_deref(), allow, skip)?; registered.insert(key.clone(), entry.clone()); } @@ -463,7 +507,7 @@ fn reconcile( fn reconcile_beacons(beacons: &Arc>>, want: &[BeaconWant]) -> Result<()> { let want_set: HashSet<&BeaconWant> = want.iter().collect(); - let mut active = beacons.lock().unwrap(); + let mut active = lock_mutex(beacons); active.retain(|b| { if want_set.contains(&b.want) { @@ -490,13 +534,86 @@ fn reconcile_beacons(beacons: &Arc>>, want: &[BeaconWant Ok(()) } -fn sleep_interruptible(total: Duration) { +/// Recover from a poisoned mutex: a poisoned lock means another thread panicked +/// while holding it. Prefer continuing with the inner value over crashing the +/// daemon (reliability contract: warn, do not abort). +fn lock_mutex(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { + match mutex.lock() { + Ok(guard) => guard, + Err(poisoned) => { + log::warn!("mutex poisoned; recovering inner state"); + poisoned.into_inner() + } + } +} + +/// Log usable interfaces and their addresses when the advertisement set changes. +fn log_detected_interfaces(answers: &AnswerSet) { + if answers.v4.is_empty() && answers.v6.is_empty() { + log::info!("network interfaces: none usable for mDNS"); + return; + } + + // Group by interface name so one line lists all addresses for that iface. + let mut names: Vec = answers + .v4 + .iter() + .map(|a| a.iface.clone()) + .chain(answers.v6.iter().map(|a| a.iface.clone())) + .collect(); + names.sort_unstable(); + names.dedup(); + + for name in names { + let v4: Vec = answers + .v4 + .iter() + .filter(|a| a.iface == name) + .map(|a| format!("{} (ifindex={})", a.addr, a.ifindex)) + .collect(); + let v6: Vec = answers + .v6 + .iter() + .filter(|a| a.iface == name) + .map(|a| format!("{} (ifindex={})", a.addr, a.ifindex)) + .collect(); + let ifindex = answers + .v4 + .iter() + .find(|a| a.iface == name) + .map(|a| a.ifindex) + .or_else(|| { + answers + .v6 + .iter() + .find(|a| a.iface == name) + .map(|a| a.ifindex) + }) + .unwrap_or(0); + log::info!( + "network interface detected name={name} ifindex={ifindex} ipv4={v4:?} ipv6={v6:?} multicast_group=224.0.0.251" + ); + } +} + +/// Sleep until `total` elapses, shutdown is requested, or a netlink iface change arrives. +fn sleep_or_iface(iface_rx: &std::sync::mpsc::Receiver, total: Duration) { let deadline = Instant::now() + total; while Instant::now() < deadline { if signals::shutdown_requested() { return; } let remaining = deadline.saturating_duration_since(Instant::now()); - thread::sleep(remaining.min(Duration::from_millis(200))); + let slice = remaining.min(Duration::from_millis(200)); + match iface_watch::recv_timeout(iface_rx, slice) { + Ok(IfaceChange) => { + log::debug!("iface change signaled; refreshing"); + return; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + thread::sleep(slice); + } + } } } diff --git a/src/sys.rs b/src/sys.rs new file mode 100644 index 0000000..4707fc9 --- /dev/null +++ b/src/sys.rs @@ -0,0 +1,246 @@ +//! Isolated Linux socket / netlink FFI helpers. +//! +//! Keeps `unsafe` concentrated here with explicit `SAFETY` proofs. Callers in +//! `iface_watch` and `legacy_unicast` use only the safe wrappers below. + +use std::io::{ErrorKind, IoSliceMut}; +use std::mem::{self, MaybeUninit}; +use std::net::SocketAddr; +use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; +use std::time::Duration; + +use socket2::{SockAddr, SockAddrStorage, Socket}; + +const CMSG_BUF: usize = 256; + +/// Open an `AF_NETLINK` / `NETLINK_ROUTE` socket subscribed to link + address +/// multicast groups, with a receive timeout. +pub fn open_rtnetlink(recv_timeout: Duration) -> std::io::Result { + // SAFETY: `socket` returns a fresh fd on success or -1 on failure. We only + // wrap non-negative fds in `OwnedFd`, which takes exclusive ownership and + // closes on drop. `SOCK_CLOEXEC` prevents fd leaks across `exec`. + let fd = unsafe { + libc::socket( + libc::AF_NETLINK, + libc::SOCK_RAW | libc::SOCK_CLOEXEC, + libc::NETLINK_ROUTE, + ) + }; + if fd < 0 { + return Err(std::io::Error::last_os_error()); + } + // SAFETY: `fd` is a freshly created, exclusive netlink socket fd. + let sock = unsafe { OwnedFd::from_raw_fd(fd) }; + + let usec = i64::try_from(recv_timeout.as_micros()).unwrap_or(i64::MAX); + let tv = libc::timeval { + tv_sec: usec / 1_000_000, + tv_usec: usec % 1_000_000, + }; + // SAFETY: `sock` is a valid netlink fd owned by us. `tv` is a properly + // aligned `timeval` whose lifetime covers the setsockopt call. SO_RCVTIMEO + // expects exactly that layout; no aliasing with other Rust references. + let rc = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_RCVTIMEO, + &tv as *const _ as *const libc::c_void, + mem::size_of_val(&tv) as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + + let groups = (libc::RTMGRP_LINK | libc::RTMGRP_IPV4_IFADDR | libc::RTMGRP_IPV6_IFADDR) as u32; + // SAFETY: zeroed sockaddr_nl is a valid initial state; we then set family + // and groups before bind. No concurrent access to `addr`. + let mut addr: libc::sockaddr_nl = unsafe { mem::zeroed() }; + addr.nl_family = libc::AF_NETLINK as libc::sa_family_t; + addr.nl_groups = groups; + + // SAFETY: `sock` is a valid netlink fd; `addr` is a fully initialized + // sockaddr_nl of the size passed as the third argument. bind does not + // retain the pointer after return. + let rc = unsafe { + libc::bind( + sock.as_raw_fd(), + &addr as *const _ as *const libc::sockaddr, + mem::size_of::() as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(sock) +} + +/// Returns `Ok(true)` when at least one netlink message was read (payload ignored). +pub fn recv_netlink_any(sock: &OwnedFd) -> std::io::Result { + let mut buf = [0u8; 8192]; + let mut iov = [IoSliceMut::new(&mut buf)]; + // SAFETY: `sock` is a valid netlink fd. `iov` points at a live mutable + // buffer of known length for the duration of recvmsg. We do not retain + // pointers after return; payload is discarded. + let n = unsafe { + let mut msg: libc::msghdr = mem::zeroed(); + msg.msg_iov = iov.as_mut_ptr().cast(); + msg.msg_iovlen = 1; + libc::recvmsg(sock.as_raw_fd(), &mut msg, 0) + }; + if n < 0 { + let err = std::io::Error::last_os_error(); + if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) { + return Ok(false); + } + return Err(err); + } + debug_assert!(n >= 0); + Ok(n > 0) +} + +/// Enable `IP_PKTINFO` so recvmsg delivers receiving-interface metadata (IPv4). +pub fn enable_pktinfo_v4(sock: &Socket) -> std::io::Result<()> { + let on: libc::c_int = 1; + // SAFETY: `sock` is a live UDP socket fd. `on` is a properly aligned int + // whose lifetime covers the call. IP_PKTINFO expects a c_int value. + let rc = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::IPPROTO_IP, + libc::IP_PKTINFO, + &on as *const _ as *const libc::c_void, + mem::size_of_val(&on) as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// Enable `IPV6_RECVPKTINFO` so recvmsg delivers receiving-interface metadata (IPv6). +pub fn enable_pktinfo_v6(sock: &Socket) -> std::io::Result<()> { + let on: libc::c_int = 1; + // SAFETY: same contract as enable_pktinfo_v4, for IPV6_RECVPKTINFO. + let rc = unsafe { + libc::setsockopt( + sock.as_raw_fd(), + libc::IPPROTO_IPV6, + libc::IPV6_RECVPKTINFO, + &on as *const _ as *const libc::c_void, + mem::size_of_val(&on) as libc::socklen_t, + ) + }; + if rc != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// One received UDP datagram with optional receiving-interface index. +pub struct RecvPacket { + pub len: usize, + pub peer: SocketAddr, + pub ifindex: Option, + pub buf: [u8; 2048], +} + +/// Receive one datagram and extract optional receiving ifindex from pktinfo. +pub fn recv_with_pktinfo(sock: &Socket, is_v6: bool) -> std::io::Result> { + let mut buf = [0u8; 2048]; + let mut control = [MaybeUninit::::uninit(); CMSG_BUF]; + let mut storage = SockAddrStorage::zeroed(); + let mut addr_len = storage.size_of(); + + let mut iov = [IoSliceMut::new(&mut buf)]; + // SAFETY: + // - `sock` is a live UDP socket with IP_PKTINFO / IPV6_RECVPKTINFO enabled. + // - `iov` points at `buf` for the duration of recvmsg. + // - `storage` is a zeroed SockAddrStorage large enough for any sockaddr; + // view_as yields a mutable reference whose pointer is valid for msg_name. + // - `control` is MaybeUninit; the kernel writes up to msg_controllen bytes + // and sets msg_controllen to the initialized prefix. CMSG_FIRSTHDR / + // CMSG_NXTHDR only traverse that prefix, so CMSG_DATA points into + // initialized control bytes when present. + // - parse_pktinfo_ifindex is called only when n >= 0, while `msg` is still + // live and describes the just-filled control buffer. + let (n, ifindex) = unsafe { + let mut msg: libc::msghdr = mem::zeroed(); + msg.msg_name = storage.view_as::() as *mut _ as *mut libc::c_void; + msg.msg_namelen = addr_len; + msg.msg_iov = iov.as_mut_ptr().cast(); + msg.msg_iovlen = 1; + msg.msg_control = control.as_mut_ptr().cast(); + msg.msg_controllen = control.len(); + let n = libc::recvmsg(sock.as_raw_fd(), &mut msg, 0); + if n < 0 { + (n, None) + } else { + addr_len = msg.msg_namelen; + let ifindex = parse_pktinfo_ifindex(&msg, is_v6); + (n, ifindex) + } + }; + if n < 0 { + let err = std::io::Error::last_os_error(); + if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) { + return Ok(None); + } + return Err(err); + } + if n == 0 { + return Ok(None); + } + debug_assert!(n > 0, "recvmsg returned positive length after zero check"); + // SAFETY: storage was filled by recvmsg with length `addr_len` ≤ capacity. + let addr = unsafe { SockAddr::new(storage, addr_len) }; + let peer = addr.as_socket().ok_or_else(|| { + std::io::Error::new(ErrorKind::InvalidData, "recvmsg returned non-IP address") + })?; + Ok(Some(RecvPacket { + len: n as usize, + peer, + ifindex, + buf, + })) +} + +/// Extract ifindex from IP_PKTINFO / IPV6_PKTINFO control messages. +/// +/// # Safety +/// `msg` must be a valid `msghdr` immediately after a successful `recvmsg`. +/// `msg_control` / `msg_controllen` describe only the kernel-initialized +/// control prefix; CMSG macros must not be used on an uninitialized buffer. +unsafe fn parse_pktinfo_ifindex(msg: &libc::msghdr, is_v6: bool) -> Option { + if msg.msg_control.is_null() || msg.msg_controllen == 0 { + return None; + } + // SAFETY: caller guarantees msg describes an initialized control prefix. + // CMSG_FIRSTHDR / CMSG_NXTHDR walk only within msg_controllen. + let mut cmsg = unsafe { libc::CMSG_FIRSTHDR(msg) }; + while !cmsg.is_null() { + // SAFETY: cmsg is a non-null header inside the initialized control region. + let hdr = unsafe { &*cmsg }; + if !is_v6 && hdr.cmsg_level == libc::IPPROTO_IP && hdr.cmsg_type == libc::IP_PKTINFO { + // SAFETY: cmsg_type/level match in_pktinfo; CMSG_DATA points at + // aligned payload of that size within the control buffer. + let ptr = unsafe { libc::CMSG_DATA(cmsg) as *const libc::in_pktinfo }; + if !ptr.is_null() { + return Some(unsafe { (*ptr).ipi_ifindex as u32 }); + } + } + if is_v6 && hdr.cmsg_level == libc::IPPROTO_IPV6 && hdr.cmsg_type == libc::IPV6_PKTINFO { + // SAFETY: same as IPv4 path for in6_pktinfo / IPV6_PKTINFO. + let ptr = unsafe { libc::CMSG_DATA(cmsg) as *const libc::in6_pktinfo }; + if !ptr.is_null() { + return Some(unsafe { (*ptr).ipi6_ifindex }); + } + } + // SAFETY: cmsg is a valid current header; NXTHDR advances within the + // initialized control region or returns null. + cmsg = unsafe { libc::CMSG_NXTHDR(msg, cmsg) }; + } + None +} diff --git a/tests/config_test.rs b/tests/config_test.rs index 64075d5..7a0d05c 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -70,6 +70,44 @@ fn skip_interfaces_defaults_to_empty_when_absent() { ); } +#[test] +fn interfaces_defaults_to_empty_when_absent() { + let json = r#"{"services": []}"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert!( + cfg.interfaces.is_empty(), + "empty interfaces means advertise on all usable ifaces" + ); +} + +#[test] +fn interfaces_binds_to_the_camel_case_key() { + let json = r#"{ + "services": [], + "interfaces": ["eth", "enp"] + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.interfaces, vec!["eth".to_string(), "enp".to_string()]); +} + +#[test] +fn validate_rejects_duplicate_interfaces() { + let cfg = Config { + interfaces: vec!["eth".into(), "ETH".into()], + ..Config::default() + }; + assert!(cfg.validate().is_err()); +} + +#[test] +fn validate_rejects_empty_interface_entry() { + let cfg = Config { + interfaces: vec![" ".into()], + ..Config::default() + }; + assert!(cfg.validate().is_err()); +} + #[test] fn validate_rejects_bad_dns_sd_type() { let mut cfg = Config::default(); diff --git a/tests/legacy_unicast_test.rs b/tests/legacy_unicast_test.rs index d022733..c0e5112 100644 --- a/tests/legacy_unicast_test.rs +++ b/tests/legacy_unicast_test.rs @@ -7,8 +7,9 @@ use std::thread; use std::time::Duration; use microdns::legacy_unicast::{ - build_response, choose_v4, hosts_match, parse_query, spawn, AnswerSet, ParsedQuery, LEGACY_TTL, - QTYPE_A, QTYPE_AAAA, QTYPE_ANY, + build_response, choose_v4, choose_v4_for_iface, choose_v6_for_iface, hosts_match, parse_query, + spawn, AnswerSet, IfaceAddr4, IfaceAddr6, ParsedQuery, LEGACY_TTL, QTYPE_A, QTYPE_AAAA, + QTYPE_ANY, }; fn encode_name(name: &str) -> Vec { @@ -42,14 +43,26 @@ fn sample_answers() -> AnswerSet { AnswerSet { hosts: vec!["bigfred.local.".into()], v4: vec![ - ( - Ipv4Addr::new(192, 168, 1, 10), - Ipv4Addr::new(255, 255, 255, 0), - ), - (Ipv4Addr::new(10, 0, 0, 5), Ipv4Addr::new(255, 0, 0, 0)), + IfaceAddr4 { + iface: "eth0".into(), + addr: Ipv4Addr::new(192, 168, 1, 10), + mask: Ipv4Addr::new(255, 255, 255, 0), + ifindex: 2, // eth0 + }, + IfaceAddr4 { + iface: "wlan0".into(), + addr: Ipv4Addr::new(10, 0, 0, 5), + mask: Ipv4Addr::new(255, 0, 0, 0), + ifindex: 3, // wlan0 + }, ], - v6: vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)], + v6: vec![IfaceAddr6 { + iface: "eth0".into(), + addr: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1), + ifindex: 2, + }], skip_interfaces: Vec::new(), + interfaces: Vec::new(), } } @@ -57,19 +70,21 @@ fn sample_answers() -> AnswerSet { fn parse_rejects_response_qr() { let mut pkt = build_query(1, "bigfred.local.", QTYPE_A, 1); pkt[2] = 0x80; // QR - assert!(parse_query(&pkt, 54321).is_none()); + assert!(parse_query(&pkt).is_none()); } #[test] -fn parse_rejects_mdns_src_port() { +fn parse_accepts_mdns_multicast_query() { + // Multicast queries (src port 5353) are answered; content selects per-iface IP. let pkt = build_query(1, "bigfred.local.", QTYPE_A, 1); - assert!(parse_query(&pkt, 5353).is_none()); + let q = parse_query(&pkt).expect("parse"); + assert_eq!(q.qtype, QTYPE_A); } #[test] fn parse_extracts_android_style_query() { let pkt = build_query(11110, "bigfred.local.", QTYPE_A, 1); - let q = parse_query(&pkt, 54321).expect("parse"); + let q = parse_query(&pkt).expect("parse"); assert_eq!( q, ParsedQuery { @@ -84,13 +99,13 @@ fn parse_extracts_android_style_query() { #[test] fn parse_rejects_non_in_class() { let pkt = build_query(1, "bigfred.local.", QTYPE_A, 2); - assert!(parse_query(&pkt, 12345).is_none()); + assert!(parse_query(&pkt).is_none()); } #[test] fn parse_accepts_qu_bit_in_class() { let pkt = build_query(1, "bigfred.local.", QTYPE_A, 0x8001); - let q = parse_query(&pkt, 12345).expect("parse"); + let q = parse_query(&pkt).expect("parse"); assert_eq!(q.qclass, 0x8001); } @@ -117,6 +132,46 @@ fn choose_v4_falls_back_to_all() { ); } +#[test] +fn choose_v4_for_iface_prefers_receiving_iface() { + let answers = sample_answers(); + // Query from eth subnet but arrived on wlan (ifindex 3) → answer wlan IP. + let chosen = choose_v4_for_iface(&answers, Ipv4Addr::new(192, 168, 1, 50), Some(3)); + assert_eq!(chosen, vec![Ipv4Addr::new(10, 0, 0, 5)]); +} + +#[test] +fn choose_v4_for_iface_same_subnet_within_iface() { + let mut answers = sample_answers(); + answers.v4.push(IfaceAddr4 { + iface: "wlan0".into(), + addr: Ipv4Addr::new(10, 1, 0, 5), + mask: Ipv4Addr::new(255, 255, 0, 0), + ifindex: 3, + }); + let chosen = choose_v4_for_iface(&answers, Ipv4Addr::new(10, 0, 0, 99), Some(3)); + assert_eq!(chosen, vec![Ipv4Addr::new(10, 0, 0, 5)]); +} + +#[test] +fn choose_v4_for_iface_falls_back_without_ifindex() { + let answers = sample_answers(); + let chosen = choose_v4_for_iface(&answers, Ipv4Addr::new(192, 168, 1, 50), None); + assert_eq!(chosen, vec![Ipv4Addr::new(192, 168, 1, 10)]); +} + +#[test] +fn choose_v6_for_iface_prefers_receiving_iface() { + let mut answers = sample_answers(); + answers.v6.push(IfaceAddr6 { + iface: "wlan0".into(), + addr: Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 2), + ifindex: 3, + }); + let chosen = choose_v6_for_iface(&answers, Some(3)); + assert_eq!(chosen, vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 2)]); +} + #[test] fn build_echoes_id_and_ttl() { let answers = sample_answers(); @@ -126,8 +181,13 @@ fn build_echoes_id_and_ttl() { qtype: QTYPE_A, qclass: 0x8001, }; - let resp = build_response(&query, &answers, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))) - .expect("response"); + let resp = build_response( + &query, + &answers, + IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50)), + Some(2), + ) + .expect("response"); assert_eq!(u16::from_be_bytes([resp[0], resp[1]]), 0x2b76); assert_eq!(u16::from_be_bytes([resp[2], resp[3]]), 0x8400); assert_eq!(u16::from_be_bytes([resp[4], resp[5]]), 1); // qdcount @@ -172,7 +232,7 @@ fn build_none_for_unknown_host() { qtype: QTYPE_A, qclass: 1, }; - assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); + assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST), None).is_none()); } #[test] @@ -185,7 +245,7 @@ fn build_aaaa_none_when_empty() { qtype: QTYPE_AAAA, qclass: 1, }; - assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); + assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST), None).is_none()); } #[test] @@ -197,21 +257,18 @@ fn build_any_includes_a_and_aaaa() { qtype: QTYPE_ANY, qclass: 1, }; - let resp = build_response(&query, &answers, IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))) - .expect("response"); + let resp = build_response( + &query, + &answers, + IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1)), + None, + ) + .expect("response"); assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 3); // 2 A + 1 AAAA } #[test] fn spawn_echoes_transaction_id_on_ephemeral_port() { - // To avoid racing another process for an ephemeral port, we retry the - // whole bind-spawn-query cycle a few times. Each attempt: - // 1. Probe-binds 127.0.0.1:0 to discover a free port P. - // 2. Drops the probe and immediately spawns the responder on P. - // (SO_REUSEPORT lets the responder bind even if a stray process - // grabbed P in the tiny window.) - // 3. Client binds a *different* ephemeral port Q and queries P. - // If we never get a reply, we discard this attempt and try a new port. let query_id = 0xabcd; let pkt = build_query(query_id, "bigfred.local.", QTYPE_A, 1); @@ -226,9 +283,15 @@ fn spawn_echoes_transaction_id_on_ephemeral_port() { let answers = Arc::new(RwLock::new(AnswerSet { hosts: vec!["bigfred.local.".into()], - v4: vec![(Ipv4Addr::new(127, 0, 0, 1), Ipv4Addr::new(255, 0, 0, 0))], + v4: vec![IfaceAddr4 { + iface: "lo".into(), + addr: Ipv4Addr::new(127, 0, 0, 1), + mask: Ipv4Addr::new(255, 0, 0, 0), + ifindex: 1, + }], v6: Vec::new(), skip_interfaces: Vec::new(), + interfaces: Vec::new(), })); let stop = Arc::new(AtomicBool::new(false)); if spawn(Arc::clone(&answers), port, Arc::clone(&stop)).is_err() { @@ -269,7 +332,6 @@ fn spawn_echoes_transaction_id_on_ephemeral_port() { let resp = got.expect("timed out waiting for legacy unicast reply after 5 attempts"); assert_eq!(u16::from_be_bytes([resp[0], resp[1]]), query_id); - // TTL check on first answer let mut pos = 12usize; while pos < resp.len() && resp[pos] != 0 { pos += 1 + resp[pos] as usize; diff --git a/tests/mdns_test.rs b/tests/mdns_test.rs index b87a164..842de0f 100644 --- a/tests/mdns_test.rs +++ b/tests/mdns_test.rs @@ -1,5 +1,6 @@ use microdns::mdns::{ - dcc_service_entry, normalize_hostname, normalize_service_type, should_skip_iface, + dcc_service_entry, is_allowed_iface, normalize_hostname, normalize_service_type, + should_skip_iface, }; #[test] @@ -45,6 +46,20 @@ fn skip_virtual_ifaces() { assert!(!should_skip_iface("wlp3s0", &["wlan".into()])); } +#[test] +fn allowlist_empty_means_all() { + assert!(is_allowed_iface("eth0", &[])); + assert!(is_allowed_iface("wlan0", &[])); +} + +#[test] +fn allowlist_prefix_match() { + assert!(is_allowed_iface("eth0", &["eth".into()])); + assert!(is_allowed_iface("ETH0", &["eth".into()])); + assert!(!is_allowed_iface("wlan0", &["eth".into()])); + assert!(!is_allowed_iface("eth0", &["".into()])); +} + #[test] fn dcc_entry_has_proto_txt() { let e = dcc_service_entry("hub1", "_z21._udp", "udp", 21105, 2, 5, Some(258_002_005)); diff --git a/tests/run_test.rs b/tests/run_test.rs index 3be33af..bcbe6a2 100644 --- a/tests/run_test.rs +++ b/tests/run_test.rs @@ -28,7 +28,9 @@ fn append_station_builds_identity() { dynamic: Vec::new(), beacons: Vec::new(), ips: Vec::new(), + ips_v6: Vec::new(), skip_interfaces: Vec::new(), + interfaces: Vec::new(), }; let mut ports = ListenPorts { tcp: HashSet::new(),