diff --git a/Cargo.lock b/Cargo.lock index 4883561..2427072 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -382,6 +382,7 @@ dependencies = [ "notify", "serde", "serde_json", + "socket2", "thiserror", ] @@ -580,7 +581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -674,7 +675,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b5958b8..2953e2b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ mdns-sd = "0.20" nix = { version = "0.29", features = ["signal", "fs"] } thiserror = "2" libc = "0.2" +socket2 = "0.6" notify = "8.2.0" log = "0.4" env_logger = "0.11" diff --git a/README.md b/README.md index f8d6844..f84a729 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,12 @@ unavailable. Always starts successfully. - Static DNS-SD services from `$DATA_DIR/etc/microdns.json` (default `/data`) - Hostname A records for configured `host` values (e.g. `bigfred` → `bigfred.local`) -- Legacy unicast / one-shot mDNS replies (RFC 6762 §6.7) so browsers and OS - resolvers can resolve `.local` names — not only DNS-SD service browsers +- Own legacy unicast / one-shot mDNS responder (RFC 6762 §6.7) so browsers and OS + resolvers (Android `getaddrinfo`) can resolve `.local` names — not only DNS-SD + browsers. Needed because **mdns-sd 0.20.3** answers unicast but hardcodes + transaction ID=`0` (`dns_parser.rs`: `let id = if self.multicast { 0 } else { self.id }` + while `DnsOutgoing.multicast` is never set false). Remove `legacy_unicast` when + upstream fixes that encoding. - Optional dcc-bus discovery: when enabled, watches microinit for a running `dcc-bus` process and advertises `_z21._udp` / `_withrottle._tcp` only when those ports are empirically listening @@ -18,6 +22,11 @@ unavailable. Always starts successfully. - Hot-reload via inotify on the config file - Static musl builds for linux/arm64 and linux/amd64 +## Tests + +Integration-style unit tests live under `tests/` (one file per module), matching +the microinit layout. Run with `cargo test` / `make test`. + ## Config Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. diff --git a/src/beacon.rs b/src/beacon.rs index b998562..3f74cab 100644 --- a/src/beacon.rs +++ b/src/beacon.rs @@ -108,28 +108,3 @@ fn run_beacon(port: u16, frame: &[u8], stop: Arc) -> Result<()> { } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_serial_frame() { - let frame = serial_reply(DEFAULT_VIRTUAL_SERIAL); - // length=8, header=0x0010, serial LE - assert_eq!(frame.len(), 8); - assert_eq!(u16::from_le_bytes([frame[0], frame[1]]), 8); - assert_eq!(u16::from_le_bytes([frame[2], frame[3]]), 0x0010); - assert_eq!( - u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]), - 258_000_000 - ); - } - - #[test] - fn virtual_serial_matches_go() { - assert_eq!(virtual_serial(0, 0), 258_000_000); - assert_eq!(virtual_serial(2, 1), 258_002_001); - assert_eq!(virtual_serial(1, 2), 258_001_002); - } -} diff --git a/src/config.rs b/src/config.rs index d2ae731..ef7198c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -271,69 +271,3 @@ pub fn save(path: &Path, cfg: &Config) -> Result<()> { fs::write(path, data).map_err(|e| Error::io_at(path, e))?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{SystemTime, UNIX_EPOCH}; - - fn tmp_path(name: &str) -> PathBuf { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - std::env::temp_dir().join(format!("microdns-cfg-{name}-{nanos}.json")) - } - - #[test] - fn default_roundtrip() { - let cfg = Config::default(); - let json = serde_json::to_string_pretty(&cfg).unwrap(); - let back: Config = serde_json::from_str(&json).unwrap(); - assert_eq!(cfg, back); - assert!(!back.dcc_bus.enabled); - assert_eq!(back.dcc_bus.z21_port, 21105); - assert_eq!(back.retry.mdns_ms, 3000); - } - - #[test] - fn load_or_create_seeds_default() { - let path = tmp_path("seed"); - let _ = fs::remove_file(&path); - let cfg = load_or_create(&path).unwrap(); - assert_eq!(cfg.services.len(), 1); - assert_eq!(cfg.services[0].name, "bigfred"); - assert!(path.exists()); - let again = load_or_create(&path).unwrap(); - assert_eq!(cfg, again); - let _ = fs::remove_file(&path); - } - - #[test] - fn type_field_renames() { - let json = r#"{ - "services": [ - {"name":"x","type":"_http._tcp","protocol":"tcp","port":80} - ] - }"#; - let cfg: Config = serde_json::from_str(json).unwrap(); - assert_eq!(cfg.services[0].type_, "_http._tcp"); - } - - #[test] - fn validate_rejects_bad_dns_sd_type() { - let mut cfg = Config::default(); - cfg.services[0].type_ = "_http._sctp".into(); - assert!(cfg.validate().is_err()); - cfg.services[0].type_ = "_http._tcp".into(); - cfg.services[0].protocol = "udp".into(); - assert!(cfg.validate().is_err()); - } - - #[test] - fn validate_rejects_duplicate_names() { - let mut cfg = Config::default(); - cfg.services.push(cfg.services[0].clone()); - assert!(cfg.validate().is_err()); - } -} diff --git a/src/config_watch.rs b/src/config_watch.rs index f8e8150..d3ff81e 100644 --- a/src/config_watch.rs +++ b/src/config_watch.rs @@ -141,28 +141,3 @@ fn watch_loop( } Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn relevant_filters() { - assert!(is_relevant_path( - Path::new("/data/etc/microdns.json"), - "microdns.json" - )); - assert!(!is_relevant_path( - Path::new("/data/etc/.microdns.json"), - "microdns.json" - )); - assert!(!is_relevant_path( - Path::new("/data/etc/microdns.json~"), - "microdns.json" - )); - assert!(!is_relevant_path( - Path::new("/data/etc/other.json"), - "microdns.json" - )); - } -} diff --git a/src/datadir.rs b/src/datadir.rs index a820a49..3abbae3 100644 --- a/src/datadir.rs +++ b/src/datadir.rs @@ -54,18 +54,3 @@ where } out } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_root_is_data() { - // Cannot safely mutate env in parallel tests; just check absolute join. - let p = PathBuf::from(DEFAULT_ROOT) - .join("etc") - .join("microdns.json"); - assert!(p.is_absolute()); - assert!(p.ends_with("etc/microdns.json")); - } -} diff --git a/src/legacy_unicast.rs b/src/legacy_unicast.rs new file mode 100644 index 0000000..bc9b580 --- /dev/null +++ b/src/legacy_unicast.rs @@ -0,0 +1,482 @@ +//! Legacy unicast / one-shot mDNS responder (RFC 6762 §5.1 / §6.7). +//! +//! `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. +//! +//! Remove when upstream fixes `DnsOutgoing` multicast / id encoding. + +use std::io::ErrorKind; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; +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 crate::error::{Error, Result}; +use crate::mdns; + +/// Hosts and addresses answered for one-shot 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, +} + +pub const MDNS_PORT: u16 = 5353; +pub const LEGACY_TTL: u32 = 10; +pub const QTYPE_A: u16 = 1; +pub const QTYPE_AAAA: u16 = 28; +pub const QTYPE_ANY: u16 = 255; + +const MDNS_GROUP_V4: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); +const MDNS_GROUP_V6: Ipv6Addr = Ipv6Addr::new(0xff02, 0, 0, 0, 0, 0, 0, 0xfb); +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). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParsedQuery { + pub id: u16, + /// Lowercased name with trailing dot. + pub qname: String, + pub qtype: u16, + pub qclass: u16, +} + +/// Spawn the legacy-unicast responder thread. +/// +/// On `MDNS_PORT` (5353) joins multicast groups on preferred interfaces. +/// On any other port (tests), binds loopback / unspecified without multicast. +pub fn spawn(state: Arc>, port: u16, stop: Arc) -> Result<()> { + thread::Builder::new() + .name("legacy-unicast".into()) + .spawn(move || { + if let Err(e) = run_loop(state, port, stop) { + log::warn!("legacy unicast responder stopped: {e}"); + } + }) + .map_err(|e| Error::Other(format!("spawn legacy-unicast: {e}")))?; + Ok(()) +} + +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 joined_v4: Vec = Vec::new(); + let mut joined_ifindexes: Vec = Vec::new(); + // Cache of the AnswerSet snapshot last used to compute multicast joins. + // refresh_memberships is expensive (getifaddrs + /sys read) so we only + // recompute when the AnswerSet actually changes. + let mut last_answer_snapshot: Option = None; + + while !stop.load(Ordering::SeqCst) { + if sock_v4.is_none() { + match bind_v4(port, test_mode) { + Ok(s) => { + log::info!("legacy unicast IPv4 listening on 0.0.0.0:{port}"); + sock_v4 = Some(s); + warned_bind = false; + } + Err(e) => { + if !warned_bind { + log::warn!("legacy unicast IPv4 bind :{port}: {e}; retrying"); + warned_bind = true; + } + } + } + } + if sock_v6.is_none() { + match bind_v6(port, test_mode) { + Ok(s) => { + log::info!("legacy unicast IPv6 listening on [::]:{port}"); + sock_v6 = Some(s); + } + Err(e) => { + log::debug!("legacy unicast IPv6 bind :{port}: {e}"); + } + } + } + + if sock_v4.is_none() && sock_v6.is_none() { + thread::sleep(BIND_RETRY); + continue; + } + + if !test_mode { + let current_snapshot = state.read().map(|g| g.clone()).ok(); + let changed = current_snapshot.as_ref() != last_answer_snapshot.as_ref(); + if changed { + refresh_memberships( + sock_v4.as_ref(), + sock_v6.as_ref(), + &state, + &mut joined_v4, + &mut joined_ifindexes, + ); + last_answer_snapshot = current_snapshot; + } + } + + 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)) => { + got_any = true; + handle_packet(sock, &buf[..n], peer, &state); + } + Err(e) if is_timeout(&e) => {} + Err(e) => { + log::debug!("legacy unicast v4 recv: {e}"); + sock_v4 = None; + joined_v4.clear(); + last_answer_snapshot = None; + } + } + } + if let Some(sock) = sock_v6.as_ref() { + match sock.recv_from(&mut buf) { + Ok((n, peer)) => { + got_any = true; + handle_packet(sock, &buf[..n], peer, &state); + } + Err(e) if is_timeout(&e) => {} + Err(e) => { + log::debug!("legacy unicast v6 recv: {e}"); + sock_v6 = None; + joined_ifindexes.clear(); + last_answer_snapshot = None; + } + } + } + + if !got_any { + thread::sleep(Duration::from_millis(50)); + } + } + 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 { + return; + }; + let answers = match state.read() { + Ok(g) => g.clone(), + Err(_) => return, + }; + let Some(resp) = build_response(&query, &answers, peer.ip()) else { + return; + }; + if let Err(e) = sock.send_to(&resp, peer) { + log::debug!("legacy unicast send_to {peer}: {e}"); + } +} + +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)] + { + if let Err(e) = sock.set_reuse_port(true) { + log::debug!("SO_REUSEPORT v4 unavailable: {e}"); + } + } + sock.set_read_timeout(Some(RECV_TIMEOUT))?; + let addr = if test_mode { + SocketAddr::from((Ipv4Addr::LOCALHOST, port)) + } else { + SocketAddr::from((Ipv4Addr::UNSPECIFIED, port)) + }; + sock.bind(&addr.into())?; + Ok(sock.into()) +} + +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)] + { + if let Err(e) = sock.set_reuse_port(true) { + log::debug!("SO_REUSEPORT v6 unavailable: {e}"); + } + } + sock.set_only_v6(true)?; + sock.set_read_timeout(Some(RECV_TIMEOUT))?; + let addr = if test_mode { + SocketAddr::from((Ipv6Addr::LOCALHOST, port)) + } else { + SocketAddr::from((Ipv6Addr::UNSPECIFIED, port)) + }; + sock.bind(&addr.into())?; + Ok(sock.into()) +} + +fn refresh_memberships( + sock_v4: Option<&UdpSocket>, + sock_v6: Option<&UdpSocket>, + state: &RwLock, + joined_v4: &mut Vec, + joined_ifindexes: &mut Vec, +) { + let mut want_v4: Vec = state + .read() + .map(|g| g.v4.iter().map(|(ip, _)| *ip).collect()) + .unwrap_or_default(); + 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). + let mut want_idx = mdns::preferred_iface_indexes(); + want_idx.sort_unstable(); + want_idx.dedup(); + + if let Some(sock) = sock_v4 { + if *joined_v4 != want_v4 { + for ip in joined_v4.iter() { + 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}"), + } + } + } + } + + if let Some(sock) = sock_v6 { + if *joined_ifindexes != want_idx { + for idx in joined_ifindexes.iter() { + let _ = sock.leave_multicast_v6(&MDNS_GROUP_V6, *idx); + } + joined_ifindexes.clear(); + for idx in &want_idx { + match sock.join_multicast_v6(&MDNS_GROUP_V6, *idx) { + Ok(()) => joined_ifindexes.push(*idx), + Err(e) => log::debug!("join multicast v6 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; + } + if packet.len() < 12 { + return None; + } + let id = u16::from_be_bytes([packet[0], packet[1]]); + let flags = u16::from_be_bytes([packet[2], packet[3]]); + if (flags & 0x8000) != 0 { + return None; // QR set → response + } + let qdcount = u16::from_be_bytes([packet[4], packet[5]]); + if qdcount == 0 { + return None; + } + + let mut pos = 12usize; + let qname = read_name(packet, &mut pos)?; + if pos + 4 > packet.len() { + return None; + } + let qtype = u16::from_be_bytes([packet[pos], packet[pos + 1]]); + let qclass = u16::from_be_bytes([packet[pos + 2], packet[pos + 3]]); + + if (qclass & 0x7fff) != 1 { + return None; + } + if qtype != QTYPE_A && qtype != QTYPE_AAAA && qtype != QTYPE_ANY { + return None; + } + + Some(ParsedQuery { + id, + qname, + qtype, + qclass, + }) +} + +/// Build a unicast response, or [`None`] when the query should be ignored. +pub fn build_response( + query: &ParsedQuery, + answers: &AnswerSet, + querier: IpAddr, +) -> Option> { + if !hosts_match(&answers.hosts, &query.qname) { + return None; + } + + let mut records: Vec<(u16, Vec)> = Vec::new(); // (rtype, rdata) + + let want_a = query.qtype == QTYPE_A || query.qtype == QTYPE_ANY; + let want_aaaa = query.qtype == QTYPE_AAAA || query.qtype == QTYPE_ANY; + + if want_a { + let querier_v4 = match querier { + IpAddr::V4(v4) => v4, + IpAddr::V6(_) => Ipv4Addr::UNSPECIFIED, + }; + for ip in choose_v4(answers, querier_v4) { + records.push((QTYPE_A, ip.octets().to_vec())); + } + } + if want_aaaa { + for ip in &answers.v6 { + records.push((QTYPE_AAAA, ip.octets().to_vec())); + } + } + + if records.is_empty() { + return None; + } + + let name_wire = encode_name(&query.qname); + let qclass_clear = query.qclass & 0x7fff; + + let mut out = + Vec::with_capacity(12 + name_wire.len() + 4 + records.len() * (name_wire.len() + 14)); + out.extend_from_slice(&query.id.to_be_bytes()); + out.extend_from_slice(&HEADER_QR_AA.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); // qdcount + out.extend_from_slice(&(records.len() as u16).to_be_bytes()); // ancount + out.extend_from_slice(&0u16.to_be_bytes()); // nscount + out.extend_from_slice(&0u16.to_be_bytes()); // arcount + + // Question (QU bit cleared). + out.extend_from_slice(&name_wire); + out.extend_from_slice(&query.qtype.to_be_bytes()); + out.extend_from_slice(&qclass_clear.to_be_bytes()); + + // Answers: class IN (no cache-flush), TTL 10, uncompressed names. + for (rtype, rdata) in records { + out.extend_from_slice(&name_wire); + out.extend_from_slice(&rtype.to_be_bytes()); + out.extend_from_slice(&1u16.to_be_bytes()); // class IN + out.extend_from_slice(&LEGACY_TTL.to_be_bytes()); + out.extend_from_slice(&(rdata.len() as u16).to_be_bytes()); + out.extend_from_slice(&rdata); + } + + Some(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() + } +} + +/// Case-insensitive host match against normalized names (trailing dot). +#[must_use] +pub fn hosts_match(hosts: &[String], qname: &str) -> bool { + let q = normalize_lookup_name(qname); + hosts.iter().any(|h| normalize_lookup_name(h) == q) +} + +fn normalize_lookup_name(name: &str) -> String { + let mut n = name.trim().to_ascii_lowercase(); + if !n.ends_with('.') { + n.push('.'); + } + n +} + +fn encode_name(name: &str) -> Vec { + let trimmed = name.trim_end_matches('.'); + let mut out = Vec::new(); + if trimmed.is_empty() { + out.push(0); + return out; + } + for label in trimmed.split('.') { + let bytes = label.as_bytes(); + let len = bytes.len().min(63); + out.push(len as u8); + out.extend_from_slice(&bytes[..len]); + } + out.push(0); + out +} + +fn read_name(packet: &[u8], pos: &mut usize) -> Option { + let mut labels = Vec::new(); + let mut jumped = false; + let mut cursor = *pos; + let mut guard = 0usize; + + loop { + if guard > 64 || cursor >= packet.len() { + return None; + } + guard += 1; + let len = packet[cursor]; + if len == 0 { + cursor += 1; + if !jumped { + *pos = cursor; + } + break; + } + if (len & 0xc0) == 0xc0 { + if cursor + 1 >= packet.len() { + return None; + } + let ptr = (((len as usize) & 0x3f) << 8) | (packet[cursor + 1] as usize); + if !jumped { + *pos = cursor + 2; + jumped = true; + } + cursor = ptr; + continue; + } + let label_len = len as usize; + cursor += 1; + if cursor + label_len > packet.len() { + return None; + } + let label = std::str::from_utf8(&packet[cursor..cursor + label_len]).ok()?; + labels.push(label.to_ascii_lowercase()); + cursor += label_len; + } + + let mut name = labels.join("."); + name.push('.'); + Some(name) +} diff --git a/src/lib.rs b/src/lib.rs index 9742fa2..71e94b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -5,6 +5,7 @@ pub mod config; pub mod config_watch; pub mod datadir; pub mod error; +pub mod legacy_unicast; pub mod mdns; pub mod microinit_watch; pub mod proc_scan; diff --git a/src/mdns.rs b/src/mdns.rs index 61ecb4c..e19f4f5 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -5,7 +5,7 @@ use std::collections::{HashMap, HashSet}; use std::fs; -use std::net::{IpAddr, Ipv4Addr}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::sync::Mutex; use mdns_sd::{ServiceDaemon, ServiceInfo}; @@ -173,6 +173,15 @@ pub fn normalize_hostname(host: &str) -> String { /// Collect preferred IPv4 addresses: UP, non-loopback, not docker/veth/br-*. #[must_use] pub fn preferred_ipv4_addrs() -> Vec { + preferred_ipv4_ifaces() + .into_iter() + .map(|(ip, _mask)| ip) + .collect() +} + +/// Preferred IPv4 addresses with netmasks (for same-subnet reply selection). +#[must_use] +pub fn preferred_ipv4_ifaces() -> Vec<(Ipv4Addr, Ipv4Addr)> { let mut addrs = Vec::new(); let Ok(ifaces) = list_interfaces() else { return addrs; @@ -184,15 +193,68 @@ pub fn preferred_ipv4_addrs() -> Vec { if !iface.is_up || iface.is_loopback { continue; } - for ip in iface.ipv4 { + for (ip, mask) in iface.ipv4 { if !ip.is_loopback() && !ip.is_unspecified() { - addrs.push(ip); + addrs.push((ip, mask)); + } + } + } + addrs +} + +/// Preferred global/ULA IPv6 addresses (no loopback, unspecified, or link-local). +#[must_use] +pub fn preferred_ipv6_addrs() -> Vec { + let mut addrs = Vec::new(); + let Ok(ifaces) = list_interfaces() else { + return addrs; + }; + for iface in ifaces { + if should_skip_iface(&iface.name) { + continue; + } + if !iface.is_up || iface.is_loopback { + continue; + } + for ip in iface.ipv6 { + if ip.is_loopback() || ip.is_unspecified() || is_ipv6_link_local(&ip) { + continue; } + addrs.push(ip); } } addrs } +/// Interface index for multicast group joins (IPv6). +#[must_use] +pub fn preferred_iface_indexes() -> Vec { + let mut out = Vec::new(); + let Ok(ifaces) = list_interfaces() else { + return out; + }; + for iface in ifaces { + if should_skip_iface(&iface.name) { + continue; + } + if !iface.is_up || iface.is_loopback { + continue; + } + if iface.ipv4.is_empty() && iface.ipv6.is_empty() { + continue; + } + if iface.ifindex != 0 { + out.push(iface.ifindex); + } + } + out +} + +fn is_ipv6_link_local(ip: &Ipv6Addr) -> bool { + let octets = ip.octets(); + octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 +} + /// Whether to skip an interface by name (docker/veth/bridge). #[must_use] pub fn should_skip_iface(name: &str) -> bool { @@ -212,7 +274,9 @@ struct IfaceInfo { name: String, is_up: bool, is_loopback: bool, - ipv4: Vec, + ifindex: u32, + ipv4: Vec<(Ipv4Addr, Ipv4Addr)>, + ipv6: Vec, } fn list_interfaces() -> Result> { @@ -233,25 +297,30 @@ fn list_interfaces() -> Result> { // IFF_UP=0x1, IFF_LOOPBACK=0x8 let is_up = (flags_val & 0x1) != 0 || operstate == "up"; let is_loopback = (flags_val & 0x8) != 0 || name == "lo"; - let ipv4 = ipv4_for_iface(&name); + let ifindex = fs::read_to_string(entry.path().join("ifindex")) + .ok() + .and_then(|s| s.trim().parse().ok()) + .unwrap_or(0); + let (ipv4, ipv6) = addrs_for_iface(&name); out.push(IfaceInfo { name, is_up, is_loopback, + ifindex, ipv4, + ipv6, }); } Ok(out) } -fn ipv4_for_iface(name: &str) -> Vec { - let mut ips = Vec::new(); - // Parse `ip -o -4 addr show` is unavailable as a dep; use /proc/net/fib_trie - // is complex. Instead read from `getifaddrs` via libc. +fn addrs_for_iface(name: &str) -> (Vec<(Ipv4Addr, Ipv4Addr)>, Vec) { + let mut ipv4 = Vec::new(); + let mut ipv6 = Vec::new(); unsafe { let mut ifap: *mut libc::ifaddrs = std::ptr::null_mut(); if libc::getifaddrs(&mut ifap) != 0 { - return ips; + return (ipv4, ipv6); } let mut cur = ifap; while !cur.is_null() { @@ -268,7 +337,17 @@ fn ipv4_for_iface(name: &str) -> Vec { if addr.sa_family as i32 == libc::AF_INET { let sin = &*(iface.ifa_addr as *const libc::sockaddr_in); let ip = Ipv4Addr::from(u32::from_be(sin.sin_addr.s_addr)); - ips.push(ip); + let mask = if iface.ifa_netmask.is_null() { + Ipv4Addr::new(255, 255, 255, 0) + } else { + let smask = &*(iface.ifa_netmask as *const libc::sockaddr_in); + Ipv4Addr::from(u32::from_be(smask.sin_addr.s_addr)) + }; + ipv4.push((ip, mask)); + } else if addr.sa_family as i32 == libc::AF_INET6 { + let sin6 = &*(iface.ifa_addr as *const libc::sockaddr_in6); + let ip = Ipv6Addr::from(sin6.sin6_addr.s6_addr); + ipv6.push(ip); } } } @@ -276,7 +355,7 @@ fn ipv4_for_iface(name: &str) -> Vec { } libc::freeifaddrs(ifap); } - ips + (ipv4, ipv6) } /// Helper to register a dynamic dcc-bus service (`_z21._udp` / `_withrottle._tcp`). @@ -320,48 +399,3 @@ pub fn has_usable_iface() -> bool { pub fn primary_ip() -> Option { preferred_ipv4_addrs().into_iter().next().map(IpAddr::V4) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn normalize_type() { - assert_eq!(normalize_service_type("_http._tcp"), "_http._tcp.local."); - assert_eq!( - normalize_service_type("_http._tcp.local"), - "_http._tcp.local." - ); - assert_eq!( - normalize_service_type("_http._tcp.local."), - "_http._tcp.local." - ); - } - - #[test] - fn normalize_host() { - assert_eq!(normalize_hostname("bigfred"), "bigfred.local."); - assert_eq!(normalize_hostname("bigfred.local"), "bigfred.local."); - assert_eq!(normalize_hostname("bigfred.local."), "bigfred.local."); - } - - #[test] - fn skip_virtual_ifaces() { - assert!(should_skip_iface("veth0abc")); - assert!(should_skip_iface("br-1234abcd")); - assert!(should_skip_iface("docker0")); - assert!(!should_skip_iface("eth0")); - assert!(!should_skip_iface("wlan0")); - assert!(!should_skip_iface("enp1s0")); - } - - #[test] - fn dcc_entry_has_proto_txt() { - let e = dcc_service_entry("hub1", "_z21._udp", "udp", 21105, 2, 5, Some(258_002_005)); - let txt = e.txt.as_ref().unwrap(); - assert_eq!(txt.get("proto").unwrap(), "udp"); - assert_eq!(txt.get("layoutId").unwrap(), "2"); - assert_eq!(txt.get("commandStationId").unwrap(), "5"); - assert_eq!(txt.get("serial").unwrap(), "258002005"); - } -} diff --git a/src/microinit_watch.rs b/src/microinit_watch.rs index e65db48..7319ee2 100644 --- a/src/microinit_watch.rs +++ b/src/microinit_watch.rs @@ -16,7 +16,7 @@ const MAX_FRAME: usize = 1024 * 1024; #[derive(Debug, Clone, Serialize)] #[serde(tag = "type", rename_all = "snake_case")] -enum Request { +pub enum Request { List, } @@ -128,51 +128,3 @@ fn read_frame(stream: &mut UnixStream) -> Result pub fn is_running(status: &ServiceStatus) -> bool { status.state.eq_ignore_ascii_case("running") && status.pid.is_some_and(|p| p > 0) } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn running_helper() { - assert!(is_running(&ServiceStatus { - name: "dcc-bus-2-5".into(), - state: "running".into(), - pid: Some(42), - })); - assert!(!is_running(&ServiceStatus { - name: "dcc-bus-2-5".into(), - state: "stopped".into(), - pid: Some(42), - })); - assert!(!is_running(&ServiceStatus { - name: "dcc-bus-2-5".into(), - state: "running".into(), - pid: None, - })); - } - - #[test] - fn dcc_bus_name_prefix() { - assert!(is_dcc_bus_name("dcc-bus")); - assert!(is_dcc_bus_name("dcc-bus-2-5")); - assert!(!is_dcc_bus_name("bigfred")); - assert!(!is_dcc_bus_name("dcc-busy")); - } - - #[test] - fn parse_dcc_bus_ids_ok() { - assert_eq!(parse_dcc_bus_ids("dcc-bus-2-5"), Some((2, 5))); - assert_eq!(parse_dcc_bus_ids("dcc-bus-0-1"), Some((0, 1))); - assert_eq!(parse_dcc_bus_ids("dcc-bus"), None); - assert_eq!(parse_dcc_bus_ids("dcc-bus-2"), None); - assert_eq!(parse_dcc_bus_ids("dcc-bus-2-5-9"), None); - assert_eq!(parse_dcc_bus_ids("bigfred"), None); - } - - #[test] - fn request_serializes_snake_case() { - let l = serde_json::to_string(&Request::List).unwrap(); - assert_eq!(l, r#"{"type":"list"}"#); - } -} diff --git a/src/proc_scan.rs b/src/proc_scan.rs index 1586565..39f7ad8 100644 --- a/src/proc_scan.rs +++ b/src/proc_scan.rs @@ -63,7 +63,7 @@ fn socket_inodes(pid: i32) -> Result> { } /// Parse `socket:[12345]` symlink target. -fn parse_socket_link(path: &Path) -> Option { +pub fn parse_socket_link(path: &Path) -> Option { let s = path.to_str()?; let rest = s.strip_prefix("socket:[")?; let num = rest.strip_suffix(']')?; @@ -71,7 +71,7 @@ fn parse_socket_link(path: &Path) -> Option { } /// TCP LISTEN state in `/proc/net/tcp*` is hex `0A`. -const TCP_LISTEN: &str = "0A"; +pub const TCP_LISTEN: &str = "0A"; fn collect_tcp(path: &str, inodes: &HashSet, out: &mut HashSet) -> Result<()> { let data = match fs::read_to_string(path) { @@ -110,7 +110,11 @@ fn collect_udp(path: &str, inodes: &HashSet, out: &mut HashSet) -> Res /// /// Columns (whitespace-separated): sl, local_address, rem_address, st, ... /// local_address is `IP:PORT` in hex. Inode is typically column index 9. -fn parse_net_line(line: &str, inodes: &HashSet, require_state: Option<&str>) -> Option { +pub fn parse_net_line( + line: &str, + inodes: &HashSet, + require_state: Option<&str>, +) -> Option { let cols: Vec<&str> = line.split_whitespace().collect(); if cols.len() < 10 { return None; @@ -129,36 +133,3 @@ fn parse_net_line(line: &str, inodes: &HashSet, require_state: Option<&str> let port = u16::from_str_radix(port_hex, 16).ok()?; Some(port) } - -#[cfg(test)] -mod tests { - use super::*; - use std::path::PathBuf; - - #[test] - fn parse_socket_link_ok() { - assert_eq!( - parse_socket_link(&PathBuf::from("socket:[12345]")), - Some(12345) - ); - assert_eq!(parse_socket_link(&PathBuf::from("pipe:[1]")), None); - } - - #[test] - fn parse_tcp_listen_line() { - let mut inodes = HashSet::new(); - inodes.insert(12345); - // Typical /proc/net/tcp line (abbreviated columns padded). - let line = " 0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0"; - assert_eq!(parse_net_line(line, &inodes, Some(TCP_LISTEN)), Some(8080)); - assert_eq!(parse_net_line(line, &inodes, Some("01")), None); - } - - #[test] - fn self_pid_scan_does_not_panic() { - let pid = std::process::id() as i32; - let ports = listen_ports_for_pid(pid).unwrap(); - // May be empty; just ensure it succeeds. - let _ = ports; - } -} diff --git a/src/run.rs b/src/run.rs index b67ec2d..16a4234 100644 --- a/src/run.rs +++ b/src/run.rs @@ -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::legacy_unicast::{self, AnswerSet}; use crate::mdns::{self, MdnsPublisher}; use crate::microinit_watch; use crate::proc_scan::{self, ListenPorts}; @@ -64,26 +65,26 @@ impl FailThrottle { /// One dynamic DNS-SD registration derived from a running dcc-bus process. #[derive(Debug, Clone, PartialEq, Eq)] -struct DynAd { - entry: ServiceEntry, +pub struct DynAd { + pub entry: ServiceEntry, } /// One Z21 LAN discovery beacon (port + virtual serial). #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] -struct BeaconWant { - port: u16, - serial: u32, +pub struct BeaconWant { + pub port: u16, + pub serial: u32, } /// Desired advertisement set derived from config + empirical dcc-bus state. /// /// `ips` is included so DHCP / interface address changes trigger re-registration. #[derive(Debug, Clone, PartialEq, Eq)] -struct DesiredAds { - static_services: Vec, - dynamic: Vec, - beacons: Vec, - ips: Vec, +pub struct DesiredAds { + pub static_services: Vec, + pub dynamic: Vec, + pub beacons: Vec, + pub ips: Vec, } struct ActiveBeacon { @@ -114,6 +115,14 @@ pub fn run(config_path: &Path) -> Result<()> { let publisher = Arc::new(Mutex::new(MdnsPublisher::new())); let beacons: Arc>> = Arc::new(Mutex::new(Vec::new())); + let answer_set = Arc::new(RwLock::new(AnswerSet::default())); + if let Err(e) = legacy_unicast::spawn( + Arc::clone(&answer_set), + legacy_unicast::MDNS_PORT, + Arc::clone(&stop), + ) { + log::warn!("legacy unicast responder failed to start: {e}"); + } // Config reload thread → updates shared config. { @@ -167,6 +176,27 @@ pub fn run(config_path: &Path) -> Result<()> { iface_thr.ok("network interface"); } + { + let mut hosts = Vec::new(); + for svc in &cfg.services { + let host = + mdns::normalize_hostname(svc.host.as_deref().unwrap_or(&version::hostname())); + if !hosts.iter().any(|h| h == &host) { + hosts.push(host); + } + } + let next = AnswerSet { + hosts, + v4: mdns::preferred_ipv4_ifaces(), + v6: mdns::preferred_ipv6_addrs(), + }; + if let Ok(mut w) = answer_set.write() { + if *w != next { + *w = next; + } + } + } + let mut desired = DesiredAds { static_services: cfg.services.clone(), dynamic: Vec::new(), @@ -259,7 +289,7 @@ pub fn run(config_path: &Path) -> Result<()> { Ok(()) } -fn append_station_ads( +pub fn append_station_ads( desired: &mut DesiredAds, service_name: &str, ports: &ListenPorts, @@ -309,7 +339,7 @@ fn append_station_ads( } } -fn pick_udp_port(ports: &ListenPorts, prefer: u16) -> Option { +pub fn pick_udp_port(ports: &ListenPorts, prefer: u16) -> Option { if ports.has_udp(prefer) { return Some(prefer); } @@ -321,7 +351,7 @@ fn pick_udp_port(ports: &ListenPorts, prefer: u16) -> Option { .or_else(|| udp.first().copied()) } -fn pick_tcp_port(ports: &ListenPorts, prefer: u16) -> Option { +pub fn pick_tcp_port(ports: &ListenPorts, prefer: u16) -> Option { if ports.has_tcp(prefer) { return Some(prefer); } @@ -460,58 +490,3 @@ fn sleep_interruptible(total: Duration) { thread::sleep(remaining.min(Duration::from_millis(200))); } } - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashSet; - - #[test] - fn pick_ports_prefer_configured() { - let mut ports = ListenPorts::default(); - ports.udp.extend([21105, 21106]); - ports.tcp.extend([12090, 12091]); - assert_eq!(pick_udp_port(&ports, 21106), Some(21106)); - assert_eq!(pick_tcp_port(&ports, 12091), Some(12091)); - } - - #[test] - fn pick_ports_fallback_range() { - let mut ports = ListenPorts::default(); - ports.udp.insert(21150); - ports.tcp.insert(12095); - assert_eq!(pick_udp_port(&ports, 21105), Some(21150)); - assert_eq!(pick_tcp_port(&ports, 12090), Some(12095)); - } - - #[test] - fn append_station_builds_identity() { - let mut desired = DesiredAds { - static_services: Vec::new(), - dynamic: Vec::new(), - beacons: Vec::new(), - ips: Vec::new(), - }; - let mut ports = ListenPorts { - tcp: HashSet::new(), - udp: HashSet::new(), - }; - ports.udp.insert(21106); - ports.tcp.insert(12091); - append_station_ads(&mut desired, "dcc-bus-2-5", &ports, 21105, 12090, true); - assert_eq!(desired.dynamic.len(), 2); - assert_eq!(desired.dynamic[0].entry.name, "BigFred #5"); - assert_eq!(desired.dynamic[0].entry.port, 21106); - let txt = desired.dynamic[0].entry.txt.as_ref().unwrap(); - assert_eq!(txt.get("layoutId").map(String::as_str), Some("2")); - assert_eq!(txt.get("commandStationId").map(String::as_str), Some("5")); - assert_eq!(txt.get("serial").map(String::as_str), Some("258002005")); - assert_eq!( - desired.beacons, - vec![BeaconWant { - port: 21106, - serial: 258_002_005 - }] - ); - } -} diff --git a/src/version.rs b/src/version.rs index 53af99a..bf7be66 100644 --- a/src/version.rs +++ b/src/version.rs @@ -220,20 +220,3 @@ pub fn format_info(info: &Info) -> String { lines.push(format!("hostname: {}", hostname())); lines.join("\n") } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn info_has_build_commit() { - let i = info(); - assert!(!i.build_commit.is_empty()); - assert_eq!(i.version, "dev"); // no injected section in test binary - } - - #[test] - fn non_elf_returns_none() { - assert!(read_section_from(Path::new("/etc/hosts")).is_none()); - } -} diff --git a/tests/beacon_test.rs b/tests/beacon_test.rs new file mode 100644 index 0000000..192d48f --- /dev/null +++ b/tests/beacon_test.rs @@ -0,0 +1,21 @@ +use microdns::beacon::{serial_reply, virtual_serial, DEFAULT_VIRTUAL_SERIAL}; + +#[test] +fn default_serial_frame() { + let frame = serial_reply(DEFAULT_VIRTUAL_SERIAL); + // length=8, header=0x0010, serial LE + assert_eq!(frame.len(), 8); + assert_eq!(u16::from_le_bytes([frame[0], frame[1]]), 8); + assert_eq!(u16::from_le_bytes([frame[2], frame[3]]), 0x0010); + assert_eq!( + u32::from_le_bytes([frame[4], frame[5], frame[6], frame[7]]), + 258_000_000 + ); +} + +#[test] +fn virtual_serial_matches_go() { + assert_eq!(virtual_serial(0, 0), 258_000_000); + assert_eq!(virtual_serial(2, 1), 258_002_001); + assert_eq!(virtual_serial(1, 2), 258_001_002); +} diff --git a/tests/config_test.rs b/tests/config_test.rs new file mode 100644 index 0000000..c0f122f --- /dev/null +++ b/tests/config_test.rs @@ -0,0 +1,65 @@ +use std::fs; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +use microdns::config::{load_or_create, Config}; + +fn tmp_path(name: &str) -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!("microdns-cfg-{name}-{nanos}.json")) +} + +#[test] +fn default_roundtrip() { + let cfg = Config::default(); + let json = serde_json::to_string_pretty(&cfg).unwrap(); + let back: Config = serde_json::from_str(&json).unwrap(); + assert_eq!(cfg, back); + assert!(!back.dcc_bus.enabled); + assert_eq!(back.dcc_bus.z21_port, 21105); + assert_eq!(back.retry.mdns_ms, 3000); +} + +#[test] +fn load_or_create_seeds_default() { + let path = tmp_path("seed"); + let _ = fs::remove_file(&path); + let cfg = load_or_create(&path).unwrap(); + assert_eq!(cfg.services.len(), 1); + assert_eq!(cfg.services[0].name, "bigfred"); + assert!(path.exists()); + let again = load_or_create(&path).unwrap(); + assert_eq!(cfg, again); + let _ = fs::remove_file(&path); +} + +#[test] +fn type_field_renames() { + let json = r#"{ + "services": [ + {"name":"x","type":"_http._tcp","protocol":"tcp","port":80} + ] + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.services[0].type_, "_http._tcp"); +} + +#[test] +fn validate_rejects_bad_dns_sd_type() { + let mut cfg = Config::default(); + cfg.services[0].type_ = "_http._sctp".into(); + assert!(cfg.validate().is_err()); + cfg.services[0].type_ = "_http._tcp".into(); + cfg.services[0].protocol = "udp".into(); + assert!(cfg.validate().is_err()); +} + +#[test] +fn validate_rejects_duplicate_names() { + let mut cfg = Config::default(); + cfg.services.push(cfg.services[0].clone()); + assert!(cfg.validate().is_err()); +} diff --git a/tests/config_watch_test.rs b/tests/config_watch_test.rs new file mode 100644 index 0000000..3dd5957 --- /dev/null +++ b/tests/config_watch_test.rs @@ -0,0 +1,23 @@ +use std::path::Path; + +use microdns::config_watch::is_relevant_path; + +#[test] +fn relevant_filters() { + assert!(is_relevant_path( + Path::new("/data/etc/microdns.json"), + "microdns.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/.microdns.json"), + "microdns.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/microdns.json~"), + "microdns.json" + )); + assert!(!is_relevant_path( + Path::new("/data/etc/other.json"), + "microdns.json" + )); +} diff --git a/tests/datadir_test.rs b/tests/datadir_test.rs new file mode 100644 index 0000000..3d1c4ff --- /dev/null +++ b/tests/datadir_test.rs @@ -0,0 +1,13 @@ +use std::path::PathBuf; + +use microdns::datadir::DEFAULT_ROOT; + +#[test] +fn default_root_is_data() { + // Cannot safely mutate env in parallel tests; just check absolute join. + let p = PathBuf::from(DEFAULT_ROOT) + .join("etc") + .join("microdns.json"); + assert!(p.is_absolute()); + assert!(p.ends_with("etc/microdns.json")); +} diff --git a/tests/legacy_unicast_test.rs b/tests/legacy_unicast_test.rs new file mode 100644 index 0000000..4dc2ef6 --- /dev/null +++ b/tests/legacy_unicast_test.rs @@ -0,0 +1,282 @@ +//! Unit + integration tests for the legacy unicast mDNS responder. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, UdpSocket}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, RwLock}; +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, +}; + +fn encode_name(name: &str) -> Vec { + let trimmed = name.trim_end_matches('.'); + let mut out = Vec::new(); + for label in trimmed.split('.') { + let b = label.as_bytes(); + out.push(b.len() as u8); + out.extend_from_slice(b); + } + out.push(0); + out +} + +fn build_query(id: u16, qname: &str, qtype: u16, qclass: u16) -> Vec { + let name = encode_name(qname); + let mut pkt = Vec::with_capacity(12 + name.len() + 4); + pkt.extend_from_slice(&id.to_be_bytes()); + pkt.extend_from_slice(&0u16.to_be_bytes()); // flags + pkt.extend_from_slice(&1u16.to_be_bytes()); // qdcount + pkt.extend_from_slice(&0u16.to_be_bytes()); + pkt.extend_from_slice(&0u16.to_be_bytes()); + pkt.extend_from_slice(&0u16.to_be_bytes()); + pkt.extend_from_slice(&name); + pkt.extend_from_slice(&qtype.to_be_bytes()); + pkt.extend_from_slice(&qclass.to_be_bytes()); + pkt +} + +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)), + ], + v6: vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)], + } +} + +#[test] +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()); +} + +#[test] +fn parse_rejects_mdns_src_port() { + let pkt = build_query(1, "bigfred.local.", QTYPE_A, 1); + assert!(parse_query(&pkt, 5353).is_none()); +} + +#[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"); + assert_eq!( + q, + ParsedQuery { + id: 11110, + qname: "bigfred.local.".into(), + qtype: QTYPE_A, + qclass: 1, + } + ); +} + +#[test] +fn parse_rejects_non_in_class() { + let pkt = build_query(1, "bigfred.local.", QTYPE_A, 2); + assert!(parse_query(&pkt, 12345).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"); + assert_eq!(q.qclass, 0x8001); +} + +#[test] +fn hosts_match_case_insensitive() { + assert!(hosts_match(&["BigFred.Local.".into()], "bigfred.local")); + assert!(!hosts_match(&["bigfred.local.".into()], "other.local.")); +} + +#[test] +fn choose_v4_prefers_same_subnet() { + let answers = sample_answers(); + let chosen = choose_v4(&answers, Ipv4Addr::new(192, 168, 1, 50)); + assert_eq!(chosen, vec![Ipv4Addr::new(192, 168, 1, 10)]); +} + +#[test] +fn choose_v4_falls_back_to_all() { + let answers = sample_answers(); + let chosen = choose_v4(&answers, Ipv4Addr::new(172, 16, 0, 1)); + assert_eq!( + chosen, + vec![Ipv4Addr::new(192, 168, 1, 10), Ipv4Addr::new(10, 0, 0, 5),] + ); +} + +#[test] +fn build_echoes_id_and_ttl() { + let answers = sample_answers(); + let query = ParsedQuery { + id: 0x2b76, + qname: "bigfred.local.".into(), + qtype: QTYPE_A, + qclass: 0x8001, + }; + let resp = build_response(&query, &answers, IpAddr::V4(Ipv4Addr::new(192, 168, 1, 50))) + .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 + assert_eq!(u16::from_be_bytes([resp[6], resp[7]]), 1); // ancount + + // Find TTL in first answer: after echoed question. + // Walk past question name + type/class. + let mut pos = 12usize; + while pos < resp.len() && resp[pos] != 0 { + pos += 1 + resp[pos] as usize; + } + pos += 1; // root + pos += 4; // qtype + qclass + // answer name + while pos < resp.len() && resp[pos] != 0 { + pos += 1 + resp[pos] as usize; + } + pos += 1; + let rtype = u16::from_be_bytes([resp[pos], resp[pos + 1]]); + let rclass = u16::from_be_bytes([resp[pos + 2], resp[pos + 3]]); + let ttl = u32::from_be_bytes([resp[pos + 4], resp[pos + 5], resp[pos + 6], resp[pos + 7]]); + assert_eq!(rtype, QTYPE_A); + assert_eq!(rclass, 0x0001); // no cache-flush + assert_eq!(ttl, LEGACY_TTL); + + // Question class should have QU cleared. + let mut qpos = 12usize; + while qpos < resp.len() && resp[qpos] != 0 { + qpos += 1 + resp[qpos] as usize; + } + qpos += 1 + 2; // root + qtype + let echoed_qclass = u16::from_be_bytes([resp[qpos], resp[qpos + 1]]); + assert_eq!(echoed_qclass, 0x0001); +} + +#[test] +fn build_none_for_unknown_host() { + let answers = sample_answers(); + let query = ParsedQuery { + id: 1, + qname: "other.local.".into(), + qtype: QTYPE_A, + qclass: 1, + }; + assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); +} + +#[test] +fn build_aaaa_none_when_empty() { + let mut answers = sample_answers(); + answers.v6.clear(); + let query = ParsedQuery { + id: 1, + qname: "bigfred.local.".into(), + qtype: QTYPE_AAAA, + qclass: 1, + }; + assert!(build_response(&query, &answers, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_none()); +} + +#[test] +fn build_any_includes_a_and_aaaa() { + let answers = sample_answers(); + let query = ParsedQuery { + id: 9, + qname: "bigfred.local.".into(), + qtype: QTYPE_ANY, + qclass: 1, + }; + let resp = build_response(&query, &answers, IpAddr::V4(Ipv4Addr::new(172, 16, 0, 1))) + .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); + + let mut got = None; + for attempt in 0..5 { + let probe = match UdpSocket::bind("127.0.0.1:0") { + Ok(s) => s, + Err(_) => continue, + }; + let port = probe.local_addr().unwrap().port(); + drop(probe); + + 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))], + v6: Vec::new(), + })); + let stop = Arc::new(AtomicBool::new(false)); + if spawn(Arc::clone(&answers), port, Arc::clone(&stop)).is_err() { + continue; + } + + let client = match UdpSocket::bind("127.0.0.1:0") { + Ok(c) => c, + Err(_) => { + stop.store(true, Ordering::SeqCst); + continue; + } + }; + client + .set_read_timeout(Some(Duration::from_millis(500))) + .unwrap(); + + for _ in 0..20 { + let _ = client.send_to(&pkt, ("127.0.0.1", port)); + let mut buf = [0u8; 512]; + match client.recv_from(&mut buf) { + Ok((n, _)) => { + got = Some(buf[..n].to_vec()); + break; + } + Err(_) => thread::sleep(Duration::from_millis(50)), + } + } + stop.store(true, Ordering::SeqCst); + if got.is_some() { + break; + } + eprintln!( + "spawn_echoes_transaction_id: attempt {} got no reply, retrying", + attempt + ); + } + + 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; + } + pos += 1 + 4; // root + question type/class + while pos < resp.len() && resp[pos] != 0 { + pos += 1 + resp[pos] as usize; + } + pos += 1 + 4; // root + type/class + let ttl = u32::from_be_bytes([resp[pos], resp[pos + 1], resp[pos + 2], resp[pos + 3]]); + assert_eq!(ttl, LEGACY_TTL); +} diff --git a/tests/mdns_test.rs b/tests/mdns_test.rs new file mode 100644 index 0000000..4b92c88 --- /dev/null +++ b/tests/mdns_test.rs @@ -0,0 +1,43 @@ +use microdns::mdns::{ + dcc_service_entry, normalize_hostname, normalize_service_type, should_skip_iface, +}; + +#[test] +fn normalize_type() { + assert_eq!(normalize_service_type("_http._tcp"), "_http._tcp.local."); + assert_eq!( + normalize_service_type("_http._tcp.local"), + "_http._tcp.local." + ); + assert_eq!( + normalize_service_type("_http._tcp.local."), + "_http._tcp.local." + ); +} + +#[test] +fn normalize_host() { + assert_eq!(normalize_hostname("bigfred"), "bigfred.local."); + assert_eq!(normalize_hostname("bigfred.local"), "bigfred.local."); + assert_eq!(normalize_hostname("bigfred.local."), "bigfred.local."); +} + +#[test] +fn skip_virtual_ifaces() { + assert!(should_skip_iface("veth0abc")); + assert!(should_skip_iface("br-1234abcd")); + assert!(should_skip_iface("docker0")); + assert!(!should_skip_iface("eth0")); + assert!(!should_skip_iface("wlan0")); + assert!(!should_skip_iface("enp1s0")); +} + +#[test] +fn dcc_entry_has_proto_txt() { + let e = dcc_service_entry("hub1", "_z21._udp", "udp", 21105, 2, 5, Some(258_002_005)); + let txt = e.txt.as_ref().unwrap(); + assert_eq!(txt.get("proto").unwrap(), "udp"); + assert_eq!(txt.get("layoutId").unwrap(), "2"); + assert_eq!(txt.get("commandStationId").unwrap(), "5"); + assert_eq!(txt.get("serial").unwrap(), "258002005"); +} diff --git a/tests/microinit_watch_test.rs b/tests/microinit_watch_test.rs new file mode 100644 index 0000000..fa6436e --- /dev/null +++ b/tests/microinit_watch_test.rs @@ -0,0 +1,46 @@ +use microdns::microinit_watch::{ + is_dcc_bus_name, is_running, parse_dcc_bus_ids, Request, ServiceStatus, +}; + +#[test] +fn running_helper() { + assert!(is_running(&ServiceStatus { + name: "dcc-bus-2-5".into(), + state: "running".into(), + pid: Some(42), + })); + assert!(!is_running(&ServiceStatus { + name: "dcc-bus-2-5".into(), + state: "stopped".into(), + pid: Some(42), + })); + assert!(!is_running(&ServiceStatus { + name: "dcc-bus-2-5".into(), + state: "running".into(), + pid: None, + })); +} + +#[test] +fn dcc_bus_name_prefix() { + assert!(is_dcc_bus_name("dcc-bus")); + assert!(is_dcc_bus_name("dcc-bus-2-5")); + assert!(!is_dcc_bus_name("bigfred")); + assert!(!is_dcc_bus_name("dcc-busy")); +} + +#[test] +fn parse_dcc_bus_ids_ok() { + assert_eq!(parse_dcc_bus_ids("dcc-bus-2-5"), Some((2, 5))); + assert_eq!(parse_dcc_bus_ids("dcc-bus-0-1"), Some((0, 1))); + assert_eq!(parse_dcc_bus_ids("dcc-bus"), None); + assert_eq!(parse_dcc_bus_ids("dcc-bus-2"), None); + assert_eq!(parse_dcc_bus_ids("dcc-bus-2-5-9"), None); + assert_eq!(parse_dcc_bus_ids("bigfred"), None); +} + +#[test] +fn request_serializes_snake_case() { + let l = serde_json::to_string(&Request::List).unwrap(); + assert_eq!(l, r#"{"type":"list"}"#); +} diff --git a/tests/proc_scan_test.rs b/tests/proc_scan_test.rs new file mode 100644 index 0000000..53e91c4 --- /dev/null +++ b/tests/proc_scan_test.rs @@ -0,0 +1,31 @@ +use std::collections::HashSet; +use std::path::PathBuf; + +use microdns::proc_scan::{listen_ports_for_pid, parse_net_line, parse_socket_link, TCP_LISTEN}; + +#[test] +fn parse_socket_link_ok() { + assert_eq!( + parse_socket_link(&PathBuf::from("socket:[12345]")), + Some(12345) + ); + assert_eq!(parse_socket_link(&PathBuf::from("pipe:[1]")), None); +} + +#[test] +fn parse_tcp_listen_line() { + let mut inodes = HashSet::new(); + inodes.insert(12345); + // Typical /proc/net/tcp line (abbreviated columns padded). + let line = " 0: 00000000:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 12345 1 0000000000000000 100 0 0 10 0"; + assert_eq!(parse_net_line(line, &inodes, Some(TCP_LISTEN)), Some(8080)); + assert_eq!(parse_net_line(line, &inodes, Some("01")), None); +} + +#[test] +fn self_pid_scan_does_not_panic() { + let pid = std::process::id() as i32; + let ports = listen_ports_for_pid(pid).unwrap(); + // May be empty; just ensure it succeeds. + let _ = ports; +} diff --git a/tests/run_test.rs b/tests/run_test.rs new file mode 100644 index 0000000..84eece7 --- /dev/null +++ b/tests/run_test.rs @@ -0,0 +1,53 @@ +use std::collections::HashSet; + +use microdns::proc_scan::ListenPorts; +use microdns::run::{append_station_ads, pick_tcp_port, pick_udp_port, BeaconWant, DesiredAds}; + +#[test] +fn pick_ports_prefer_configured() { + let mut ports = ListenPorts::default(); + ports.udp.extend([21105, 21106]); + ports.tcp.extend([12090, 12091]); + assert_eq!(pick_udp_port(&ports, 21106), Some(21106)); + assert_eq!(pick_tcp_port(&ports, 12091), Some(12091)); +} + +#[test] +fn pick_ports_fallback_range() { + let mut ports = ListenPorts::default(); + ports.udp.insert(21150); + ports.tcp.insert(12095); + assert_eq!(pick_udp_port(&ports, 21105), Some(21150)); + assert_eq!(pick_tcp_port(&ports, 12090), Some(12095)); +} + +#[test] +fn append_station_builds_identity() { + let mut desired = DesiredAds { + static_services: Vec::new(), + dynamic: Vec::new(), + beacons: Vec::new(), + ips: Vec::new(), + }; + let mut ports = ListenPorts { + tcp: HashSet::new(), + udp: HashSet::new(), + }; + ports.udp.insert(21106); + ports.tcp.insert(12091); + append_station_ads(&mut desired, "dcc-bus-2-5", &ports, 21105, 12090, true); + assert_eq!(desired.dynamic.len(), 2); + assert_eq!(desired.dynamic[0].entry.name, "BigFred #5"); + assert_eq!(desired.dynamic[0].entry.port, 21106); + let txt = desired.dynamic[0].entry.txt.as_ref().unwrap(); + assert_eq!(txt.get("layoutId").map(String::as_str), Some("2")); + assert_eq!(txt.get("commandStationId").map(String::as_str), Some("5")); + assert_eq!(txt.get("serial").map(String::as_str), Some("258002005")); + assert_eq!( + desired.beacons, + vec![BeaconWant { + port: 21106, + serial: 258_002_005 + }] + ); +} diff --git a/tests/version_test.rs b/tests/version_test.rs new file mode 100644 index 0000000..3e4b3fd --- /dev/null +++ b/tests/version_test.rs @@ -0,0 +1,15 @@ +use std::path::Path; + +use microdns::version::{info, read_section_from}; + +#[test] +fn info_has_build_commit() { + let i = info(); + assert!(!i.build_commit.is_empty()); + assert_eq!(i.version, "dev"); // no injected section in test binary +} + +#[test] +fn non_elf_returns_none() { + assert!(read_section_from(Path::new("/etc/hosts")).is_none()); +}