diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..1be2114 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,12 @@ +# Cross-link musl arm64 from x86_64 without a distro-specific musl-gcc package. +# Uses Rust's self-contained crt + rust-lld (rustup target add aarch64-unknown-linux-musl). +# +# CI (rust-musl-ci) overrides via CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=musl-gcc. +# Arch AUR alternative: aarch64-linux-musl-cross → linker = "aarch64-linux-musl-gcc". + +[target.aarch64-unknown-linux-musl] +linker = "rust-lld" +rustflags = [ + "-C", "link-self-contained=yes", + "-C", "target-feature=+crt-static", +] diff --git a/Makefile b/Makefile index 24463cd..0bdbaae 100644 --- a/Makefile +++ b/Makefile @@ -6,7 +6,7 @@ RUSTUP_TOOLCHAIN ?= stable export RUSTUP_TOOLCHAIN .PHONY: all build release release-musl check test test-release-assertions \ - clean fmt clippy + clean fmt clippy hub-upload deploy all: build @@ -41,3 +41,24 @@ clippy: clean: $(CARGO) clean rm -rf dist + +# --- Hub deploy (RO rootfs: binary lives on /data) ------------------------- +# Hub runs Dropbear. Older images lack /usr/libexec/sftp-server; -O uses legacy scp. +# Harmless on images that ship openssh sftp-server (bigfred-os defconfig). +HUB ?= 192.168.0.1 +HUB_USER ?= root +HUB_SSH ?= $(HUB_USER)@$(HUB) +SCP ?= scp +SCP_OPTS ?= -O +SSH ?= ssh +DIST_ARM64 ?= dist/microdns-linux-arm64 +HUB_BIN_DIR ?= /data/opt/microdns + +# Build arm64 musl binary and upload to the hub's writable /data partition. +# Requires /etc/init.d/microdns to prefer $(HUB_BIN_DIR)/microdns (bigfred-os overlay). +deploy: hub-upload + +hub-upload: release-musl + @test -f $(DIST_ARM64) || { echo "error: $(DIST_ARM64) missing — run make release-musl" >&2; exit 1; } + $(SCP) $(SCP_OPTS) $(DIST_ARM64) $(HUB_SSH):/tmp/microdns + $(SSH) $(HUB_SSH) 'mkdir -p $(HUB_BIN_DIR) && cp /tmp/microdns $(HUB_BIN_DIR)/microdns && chmod 755 $(HUB_BIN_DIR)/microdns && rm -f /tmp/microdns && microinit stop microdns; microinit start microdns' diff --git a/README.md b/README.md index ebaf257..7625497 100644 --- a/README.md +++ b/README.md @@ -21,15 +21,24 @@ per receiving interface so a WiFi client gets the WiFi address. - Optional dcc-bus discovery: when `bigfred.enabled` (default true), polls the loco-server Unix socket (`$DATA_DIR/run/bigfred.sock`) for `dcc_bus_list` and advertises `_z21._udp` / `_withrottle._tcp` on the ports in that JSON. Missing - socket is retried every `retry.bigfredMs` (default 45s). + socket is retried with exponential backoff (2 s … `retry.bigfredMs`, default + 45 s). Last-good dcc-bus ads are kept across a short socket outage and only + withdrawn after `retry.bigfredMs` of consecutive failures. - Optional microinit watch: when `microinit.enabled` (default true), holds one connection to `$DATA_DIR/run/microinit.sock` (`{type:watch,label_keys:["microdns-port"]}`) and advertises running services that have `microdns-port` + `microdns-type`. `microdns-host` is optional (kernel hostname if omitted). `microdns-txt-*` labels become TXT pairs. Reconnect backoff is `retry.microinitReconnectMs` (default 3s). Last-good ads are kept across a dropped socket. +- Periodic unsolicited re-announcements (`announce.periodMs`, default 55 s) plus + a 1/2/4/8 s burst after a real advertisement change. Re-register never sends + a goodbye (TTL 0); `unregister` is only used when a service is actually gone. +- Self-check every `selfcheck.periodMs` (default 60 s): IGMP 224.0.0.251 on + used NICs, mdns-sd thread alive, recent `Announce` events. Escalates + re-announce → daemon recreate. - Optional Z21 UDP LAN discovery beacon (LAN_GET_SERIAL_NUMBER reply broadcast) -- Unix control socket (`$DATA_DIR/run/microdns.sock`): `microdns services list` queries the live daemon +- Unix control socket (`$DATA_DIR/run/microdns.sock`): `microdns services list` + and `microdns doctor` query the live daemon - Hot-reload via inotify on the config file - Static musl builds for linux/arm64 and linux/amd64 @@ -67,6 +76,8 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. "ifaceMs": 5000, "microinitReconnectMs": 3000 }, + "announce": { "periodMs": 55000, "burstCount": 4 }, + "selfcheck": { "periodMs": 60000 }, "skipInterfaces": [], "interfaces": [] } @@ -80,11 +91,18 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. - `dccBus.host` (optional): DNS-SD hostname without `.local` for `_z21._udp` / `_withrottle._tcp` ads. When omitted, mdns-sd uses the kernel hostname and `microdns services list` shows `-` in HOST. Product templates set `"bigfred"`. -- `retry.bigfredMs` (default `45000`): wait between probes while the socket is down. +- `retry.bigfredMs` (default `45000`): cap on backoff while the BigFred socket + is down, and grace period before withdrawing last-good dcc-bus ads. + First failures retry at 2 s, 4 s, 8 s, … up to this cap. `retry.pollMs` (default `25000`) is the poll interval once connected. Existing files may still use `retry.microinitMs`; that alias still maps to `pollMs` (BigFred), **not** the microinit watch. Use `retry.microinitReconnectMs` for watch reconnect backoff. +- `announce.periodMs` (default `55000`): unsolicited re-announce interval, kept + below the 120 s host-record TTL. `announce.burstCount` (default `4`) extra + announcements at 1 s, 2 s, 4 s, 8 s after a real change. +- `selfcheck.periodMs` (default `60000`): how often to verify multicast + membership and recent announcements. - Retry intervals are configurable; config changes are hot-reloaded. - `skipInterfaces` (default `[]`): extra interface-name prefixes to skip (case-insensitive), in addition to the built-in docker/veth/br-*/cni/ @@ -104,7 +122,12 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. - 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. + detected via rtnetlink with polling fallback. Netlink events on skipped + interfaces (e.g. `wlan0` on the hub) do not re-announce Ethernet mDNS. + IGMP leave+join is reserved for real address-set changes and suspend/resume. +- On a BigFred hub, `micronet` in gateway mode re-probes foreign DHCP every 15 s + and a failed `micronet check` tears the address down. microdns treats that as + a normal address change (re-announce, no goodbye). ## Run @@ -132,12 +155,31 @@ plus optional `host` / `txt`). The CLI talks to the live daemon over the ctl socket — it does not read `microdns.json` on its own. If the socket is missing, the error is the same shape as `bf` (`is microdns running?`). +Diagnose sockets, IGMP membership, mdns-sd counters, and the last self-check +(works even if the daemon is down for the local kernel half): + +```bash +microdns doctor +microdns doctor -o json +``` + +On a second machine in the same LAN, confirm there are no goodbye packets +(TTL 0) except when a service is actually removed, and that unsolicited +announcements repeat about every 55 s: + +```bash +tcpdump -ni -vv 'udp port 5353 and host ' +# look for: bigfred._http._tcp.local, TTL 0 (bad unless the service went away) +microinit logs microdns --follow # on the hub +cat /proc/net/igmp # 224.0.0.251 on eth0 +``` + Flags: - `--config ` — config file (default `$DATA_DIR/etc/microdns.json`) - `--data-dir ` — set `DATA_DIR` before start - `--socket ` — ctl socket (default `$DATA_DIR/run/microdns.sock`) -- `-o, --output human|json` — `services list` output (default `human`) +- `-o, --output human|json` — `services list` / `doctor` output (default `human`) - `--version` / `info` — build and release metadata ## Build diff --git a/src/config.rs b/src/config.rs index b1e41a4..397e449 100644 --- a/src/config.rs +++ b/src/config.rs @@ -199,6 +199,61 @@ fn default_microinit_reconnect_ms() -> u64 { 3000 } +/// Unsolicited mDNS re-announcement (RFC 6762 §8.3 plus a periodic refresh). +/// +/// `mdns-sd` only sends two announcements at register time. Clients that miss +/// those packets (or flush on a later goodbye) would never see the service +/// again without this ticker. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AnnounceConfig { + /// Interval between full re-announcements of the current set (milliseconds). + /// Default 55 s, below the 120 s host-record TTL. + #[serde(default = "default_announce_period_ms")] + pub period_ms: u64, + /// Extra announcements after a real advertisement change, at 1 s, 2 s, 4 s, … + /// `burstCount` 4 → 1 s, 2 s, 4 s, 8 s. Zero disables the burst. + #[serde(default = "default_announce_burst_count")] + pub burst_count: u8, +} + +impl Default for AnnounceConfig { + fn default() -> Self { + Self { + period_ms: default_announce_period_ms(), + burst_count: default_announce_burst_count(), + } + } +} + +fn default_announce_period_ms() -> u64 { + 55_000 +} +fn default_announce_burst_count() -> u8 { + 4 +} + +/// Periodic self-verification of multicast membership and announcements. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SelfCheckConfig { + /// How often to verify IGMP membership and recent announcements (milliseconds). + #[serde(default = "default_selfcheck_period_ms")] + pub period_ms: u64, +} + +impl Default for SelfCheckConfig { + fn default() -> Self { + Self { + period_ms: default_selfcheck_period_ms(), + } + } +} + +fn default_selfcheck_period_ms() -> u64 { + 60_000 +} + /// Top-level microdns configuration. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] @@ -213,6 +268,10 @@ pub struct Config { pub dcc_bus: DccBusConfig, #[serde(default)] pub retry: RetryConfig, + #[serde(default)] + pub announce: AnnounceConfig, + #[serde(default)] + pub selfcheck: SelfCheckConfig, /// Extra interface name prefixes to skip (case-insensitive), in addition /// to the built-in docker/veth/br-*/... list. Empty by default so mDNS /// advertises on every usable interface (including `wlan*`) — operators @@ -244,6 +303,8 @@ impl Default for Config { microinit: MicroinitConfig::default(), dcc_bus: DccBusConfig::default(), retry: RetryConfig::default(), + announce: AnnounceConfig::default(), + selfcheck: SelfCheckConfig::default(), skip_interfaces: Vec::new(), interfaces: Vec::new(), } @@ -274,6 +335,21 @@ impl Config { } validate_iface_prefixes("skipInterfaces", &self.skip_interfaces)?; validate_iface_prefixes("interfaces", &self.interfaces)?; + if self.announce.period_ms < 1000 { + return Err(Error::Config( + "announce.periodMs must be at least 1000".into(), + )); + } + if self.announce.burst_count > 8 { + return Err(Error::Config( + "announce.burstCount must be at most 8".into(), + )); + } + if self.selfcheck.period_ms < 1000 { + return Err(Error::Config( + "selfcheck.periodMs must be at least 1000".into(), + )); + } Ok(()) } } diff --git a/src/ctl.rs b/src/ctl.rs index b115edd..4255f03 100644 --- a/src/ctl.rs +++ b/src/ctl.rs @@ -4,11 +4,12 @@ //! Request `{ "type": "services_list" }` returns the current DesiredAds snapshot //! (static `services[]` plus dynamic dcc-bus / microinit DNS-SD; not Z21 LAN beacons). +use std::collections::HashMap; use std::io::{Read, Write}; use std::os::unix::fs::PermissionsExt; use std::os::unix::net::{UnixListener, UnixStream}; use std::path::{Path, PathBuf}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, Mutex, RwLock}; use std::thread; use std::time::Duration; @@ -17,10 +18,19 @@ use serde::{Deserialize, Serialize}; use crate::config::ServiceEntry; use crate::datadir; use crate::error::{Error, Result}; +use crate::mdns::MdnsPublisher; use crate::run::{DesiredAds, DynSource}; +use crate::selfcheck; const MAX_FRAME: usize = 1024 * 1024; +/// Live daemon handles the ctl `doctor` request can read. +#[derive(Clone)] +pub struct CtlRuntime { + pub publisher: Arc>, + pub selfcheck: Arc>, +} + /// Default control socket under the data root. #[must_use] pub fn default_socket() -> PathBuf { @@ -94,6 +104,17 @@ struct ErrorBody { error: String, } +/// Live-daemon slice of a doctor report (ctl `doctor` response). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DaemonDoctor { + pub services: Vec, + pub registered: Vec, + pub metrics: HashMap, + pub last_announce_secs_ago: HashMap, + pub selfcheck: selfcheck::Report, +} + /// Flatten the current desired advertisement set for the ctl API. /// /// Beacons are omitted: they are Z21 LAN broadcasts, not DNS-SD. @@ -203,6 +224,15 @@ fn apply_socket_perms(socket_path: &Path) -> Result<()> { /// Bind `$DATA_DIR/run/microdns.sock` (or `path`) and serve `services_list` in a /// background accept thread. Snapshot is read on each request. pub fn serve(path: &Path, snapshot: Arc>) -> Result<()> { + serve_with_runtime(path, snapshot, None) +} + +/// Like [`serve`], with optional live publisher / selfcheck for `doctor`. +pub fn serve_with_runtime( + path: &Path, + snapshot: Arc>, + runtime: Option, +) -> Result<()> { if let Some(parent) = path.parent() { if !parent.as_os_str().is_empty() { std::fs::create_dir_all(parent).map_err(|e| Error::io_at(parent, e))?; @@ -220,7 +250,8 @@ pub fn serve(path: &Path, snapshot: Arc>) -> Result<()> { match conn { Ok(stream) => { let snap = Arc::clone(&snapshot); - thread::spawn(move || handle_conn(stream, snap)); + let rt = runtime.clone(); + thread::spawn(move || handle_conn(stream, snap, rt)); } Err(_) => { if !path.exists() { @@ -234,7 +265,11 @@ pub fn serve(path: &Path, snapshot: Arc>) -> Result<()> { Ok(()) } -fn handle_conn(mut stream: UnixStream, snapshot: Arc>) { +fn handle_conn( + mut stream: UnixStream, + snapshot: Arc>, + runtime: Option, +) { let raw = match read_frame(&mut stream) { Ok(b) => b, Err(_) => return, @@ -251,31 +286,88 @@ fn handle_conn(mut stream: UnixStream, snapshot: Arc>) { return; } }; - if req.type_ != "services_list" { - let _ = write_frame( - &mut stream, - &ErrorBody { - error: "invalid_request".into(), - }, - ); - return; - } - let ads = match snapshot.read() { - Ok(g) => g.clone(), - Err(_) => { + match req.type_.as_str() { + "services_list" => { + let ads = match snapshot.read() { + Ok(g) => g.clone(), + Err(_) => { + let _ = write_frame( + &mut stream, + &ErrorBody { + error: "internal_error".into(), + }, + ); + return; + } + }; + let body = ServicesListBody { + services: listed_services(&ads), + }; + let _ = write_frame(&mut stream, &body); + } + "doctor" => { + let body = match build_daemon_doctor(&snapshot, runtime.as_ref()) { + Ok(b) => b, + Err(e) => { + let _ = write_frame( + &mut stream, + &ErrorBody { + error: e.to_string(), + }, + ); + return; + } + }; + let _ = write_frame(&mut stream, &body); + } + _ => { let _ = write_frame( &mut stream, &ErrorBody { - error: "internal_error".into(), + error: "invalid_request".into(), }, ); - return; } + } +} + +fn build_daemon_doctor( + snapshot: &RwLock, + runtime: Option<&CtlRuntime>, +) -> Result { + let ads = snapshot + .read() + .map(|g| g.clone()) + .map_err(|_| Error::Ipc("internal_error".into()))?; + let services = listed_services(&ads); + let (registered, metrics, last_announce_secs_ago) = if let Some(rt) = runtime { + let pub_guard = match rt.publisher.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let now = std::time::Instant::now(); + let last: HashMap = pub_guard + .announce_log() + .snapshot() + .into_iter() + .map(|(k, at)| (k, now.saturating_duration_since(at).as_secs())) + .collect(); + let mut names: Vec = pub_guard.registered_names().into_iter().collect(); + names.sort(); + (names, pub_guard.metrics(), last) + } else { + (Vec::new(), HashMap::new(), HashMap::new()) }; - let body = ServicesListBody { - services: listed_services(&ads), - }; - let _ = write_frame(&mut stream, &body); + let selfcheck = runtime + .and_then(|rt| rt.selfcheck.read().ok().map(|g| g.clone())) + .unwrap_or_default(); + Ok(DaemonDoctor { + services, + registered, + metrics, + last_announce_secs_ago, + selfcheck, + }) } fn connect(socket_path: &Path) -> Result { @@ -307,6 +399,17 @@ pub fn services_list(socket_path: &Path) -> Result> { Ok(body.services) } +/// Query a live daemon for a doctor snapshot (metrics, selfcheck, registered names). +pub fn doctor(socket_path: &Path) -> Result { + let raw = request_raw(socket_path, &serde_json::json!({"type": "doctor"}))?; + if let Ok(err) = serde_json::from_slice::(&raw) { + if !err.error.is_empty() { + return Err(Error::Ipc(err.error)); + } + } + Ok(serde_json::from_slice(&raw)?) +} + /// Human table: NAME, TYPE, PROTO, PORT, HOST, SOURCE (tabwriter-style). pub fn print_human(w: &mut impl Write, services: &[ListedService]) -> Result<()> { const HDR: [&str; 6] = ["NAME", "TYPE", "PROTO", "PORT", "HOST", "SOURCE"]; diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..0f7c8ed --- /dev/null +++ b/src/doctor.rs @@ -0,0 +1,177 @@ +//! Local + live-daemon diagnostics for `microdns doctor`. + +use std::collections::HashMap; +use std::fs; +use std::io::Write; +use std::path::Path; + +use serde::{Deserialize, Serialize}; + +use crate::config::{load_or_create, Config}; +use crate::ctl::{self, DaemonDoctor}; +use crate::error::Result; +use crate::legacy_unicast::IfaceAddr4; +use crate::mdns; +use crate::selfcheck; + +/// Combined doctor output (local kernel state plus optional live daemon). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct DoctorReport { + pub interfaces: Vec, + pub skip_interfaces: Vec, + pub allow_interfaces: Vec, + pub igmp: HashMap>, + pub igmp_raw: String, + pub dev_mcast: String, + pub daemon: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct IfaceRow { + pub name: String, + pub ifindex: u32, + pub ipv4: Vec, + pub ipv6: Vec, +} + +/// Collect a report from this host. Daemon fields require a running ctl socket. +pub fn collect(config_path: &Path, socket: &Path) -> Result { + let cfg = load_or_create(config_path)?; + Ok(collect_with_config(&cfg, socket)) +} + +#[must_use] +pub fn collect_with_config(cfg: &Config, socket: &Path) -> DoctorReport { + let v4 = mdns::preferred_ipv4_ifaces(&cfg.interfaces, &cfg.skip_interfaces); + let v6 = mdns::preferred_ipv6_addrs(&cfg.interfaces, &cfg.skip_interfaces); + let interfaces = iface_rows(&v4, &v6); + let igmp_raw = fs::read_to_string("/proc/net/igmp").unwrap_or_default(); + let igmp_parsed = selfcheck::parse_igmp(&igmp_raw); + let igmp: HashMap> = igmp_parsed + .into_iter() + .map(|(k, v)| (k, v.into_iter().map(|ip| ip.to_string()).collect())) + .collect(); + let dev_mcast = fs::read_to_string("/proc/net/dev_mcast").unwrap_or_default(); + let daemon = ctl::doctor(socket).ok(); + DoctorReport { + interfaces, + skip_interfaces: cfg.skip_interfaces.clone(), + allow_interfaces: cfg.interfaces.clone(), + igmp, + igmp_raw, + dev_mcast, + daemon, + } +} + +fn iface_rows(v4: &[IfaceAddr4], v6: &[crate::legacy_unicast::IfaceAddr6]) -> Vec { + let mut names: Vec = v4 + .iter() + .map(|a| a.iface.clone()) + .chain(v6.iter().map(|a| a.iface.clone())) + .collect(); + names.sort(); + names.dedup(); + names + .into_iter() + .map(|name| { + let ifindex = v4 + .iter() + .find(|a| a.iface == name) + .map(|a| a.ifindex) + .or_else(|| v6.iter().find(|a| a.iface == name).map(|a| a.ifindex)) + .unwrap_or(0); + let ipv4: Vec = v4 + .iter() + .filter(|a| a.iface == name) + .map(|a| a.addr.to_string()) + .collect(); + let ipv6: Vec = v6 + .iter() + .filter(|a| a.iface == name) + .map(|a| a.addr.to_string()) + .collect(); + IfaceRow { + name, + ifindex, + ipv4, + ipv6, + } + }) + .collect() +} + +pub fn print_human(w: &mut impl Write, report: &DoctorReport) -> Result<()> { + writeln!(w, "interfaces (usable for mDNS):")?; + if report.interfaces.is_empty() { + writeln!(w, " (none)")?; + } + for iface in &report.interfaces { + writeln!( + w, + " {} ifindex={} ipv4={:?} ipv6={:?}", + iface.name, iface.ifindex, iface.ipv4, iface.ipv6 + )?; + } + writeln!( + w, + "allow={:?} skip={:?}", + report.allow_interfaces, report.skip_interfaces + )?; + writeln!(w, "igmp groups:")?; + if report.igmp.is_empty() { + writeln!(w, " (empty /proc/net/igmp)")?; + } + let mut names: Vec<_> = report.igmp.keys().cloned().collect(); + names.sort(); + for name in names { + let groups = &report.igmp[&name]; + let mdns = groups.iter().any(|g| g == "224.0.0.251"); + writeln!( + w, + " {name}: {:?} mdns={}", + groups, + if mdns { "yes" } else { "NO" } + )?; + } + match &report.daemon { + None => writeln!(w, "daemon: not running (ctl socket unreachable)")?, + Some(d) => { + writeln!(w, "daemon: running")?; + writeln!(w, " registered: {:?}", d.registered)?; + writeln!( + w, + " services: {}", + d.services + .iter() + .map(|s| format!("{} {} :{}", s.name, s.type_, s.port)) + .collect::>() + .join(", ") + )?; + writeln!( + w, + " metrics: register={} register-resend={} unregister={} unregister-resend={} respond={}", + d.metrics.get("register").copied().unwrap_or(0), + d.metrics.get("register-resend").copied().unwrap_or(0), + d.metrics.get("unregister").copied().unwrap_or(0), + d.metrics.get("unregister-resend").copied().unwrap_or(0), + d.metrics.get("respond").copied().unwrap_or(0), + )?; + writeln!(w, " last announce (seconds ago): {:?}", d.last_announce_secs_ago)?; + writeln!( + w, + " selfcheck: ok={} escalation={:?} {}", + d.selfcheck.ok, d.selfcheck.escalation, d.selfcheck.message + )?; + } + } + Ok(()) +} + +pub fn print_json(w: &mut impl Write, report: &DoctorReport) -> Result<()> { + serde_json::to_writer_pretty(&mut *w, report)?; + writeln!(w)?; + Ok(()) +} diff --git a/src/iface_watch.rs b/src/iface_watch.rs index 3a221da..44b9f69 100644 --- a/src/iface_watch.rs +++ b/src/iface_watch.rs @@ -1,11 +1,15 @@ //! 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 -//! and always force-rejoins multicast, even when the address set is unchanged -//! (suspend/resume, link down/up with the same IP). Polling remains the -//! fallback when netlink is unavailable. +//! signals [`IfaceChange`] when a **relevant** interface (allow/skip filtered) +//! changes. Loopback, docker/veth/br-*, and configured skip prefixes are +//! ignored so a wireless-programmer job on `wlan0` does not re-announce +//! Ethernet mDNS. Events are debounced ([`DEBOUNCE`]) so a burst (DHCP, link +//! flap) becomes one signal. +//! +//! Payload parsing extracts the interface name / ifindex; the main loop still +//! re-scans addresses 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 @@ -14,26 +18,43 @@ 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::sync::{Arc, RwLock}; use std::thread; -use std::time::Duration; +use std::time::{Duration, Instant}; +use crate::config::Config; use crate::error::{Error, Result}; +use crate::mdns; use crate::sys; const BIND_RETRY: Duration = Duration::from_secs(3); const RECV_TIMEOUT: Duration = Duration::from_millis(500); +/// Coalesce a burst of netlink events into one signal. +pub(crate) const DEBOUNCE: Duration = Duration::from_millis(400); /// 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. +const NLMSG_HDRLEN: usize = 16; +const IFINFOMSG_LEN: usize = 16; +const IFADDRMSG_LEN: usize = 8; +const RTA_HDRLEN: usize = 4; + +/// Signal that interface or address state may have changed on a used NIC. #[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. +#[cfg_attr(not(test), allow(dead_code))] pub(crate) fn spawn() -> Result<(Receiver, Arc)> { + spawn_filtered(Arc::new(RwLock::new(Config::default()))) +} + +/// Like [`spawn`], with a live config so skip/allow lists apply after reload. +pub(crate) fn spawn_filtered( + config: Arc>, +) -> 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); @@ -41,7 +62,7 @@ pub(crate) fn spawn() -> Result<(Receiver, Arc)> { thread::Builder::new() .name("iface-watch".into()) .spawn(move || { - if let Err(e) = watch_loop(tx, stop_thr) { + if let Err(e) = watch_loop(tx, stop_thr, config) { log::warn!("iface watcher stopped: {e}"); } }) @@ -50,7 +71,11 @@ pub(crate) fn spawn() -> Result<(Receiver, Arc)> { Ok((rx, stop)) } -fn watch_loop(tx: SyncSender, stop: Arc) -> Result<()> { +fn watch_loop( + tx: SyncSender, + stop: Arc, + config: Arc>, +) -> Result<()> { let mut warned_bind = false; while !stop.load(Ordering::SeqCst) { @@ -78,26 +103,145 @@ fn watch_loop(tx: SyncSender, stop: Arc) -> Result<()> } }; + let mut pending = false; + let mut deadline = Instant::now(); + 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 + let mut buf = [0u8; 8192]; + match sys::recv_netlink(&sock, &mut buf) { + Ok(0) => {} + Ok(n) => { + let (allow, skip) = config + .read() + .map(|c| (c.interfaces.clone(), c.skip_interfaces.clone())) + .unwrap_or_default(); + if netlink_is_relevant(&buf[..n], &allow, &skip) { + pending = true; + deadline = Instant::now() + DEBOUNCE; + } + } Err(e) if e.kind() == ErrorKind::Interrupted => {} Err(e) => { log::warn!("iface watcher: recv failed: {e}; rebinding"); break; } } + + if pending && Instant::now() >= deadline { + loop { + buf = [0u8; 8192]; + match sys::recv_netlink(&sock, &mut buf) { + Ok(0) => break, + Ok(_) => {} + Err(_) => break, + } + } + pending = false; + match tx.try_send(IfaceChange) { + Ok(()) => {} + Err(TrySendError::Full(_)) => {} + Err(TrySendError::Disconnected(_)) => return Ok(()), + } + } } } Ok(()) } +/// True when `buf` contains a link/addr event for an interface we advertise on. +#[must_use] +pub(crate) fn netlink_is_relevant(buf: &[u8], allow: &[String], skip: &[String]) -> bool { + for (ifindex, name) in parse_netlink_ifaces(buf) { + let resolved = if name.is_empty() { + name_for_ifindex(ifindex).unwrap_or_default() + } else { + name + }; + if resolved.is_empty() { + // Unknown name: let the main loop re-scan rather than drop the event. + return true; + } + if mdns::iface_name_relevant(&resolved, allow, skip) { + return true; + } + } + false +} + +/// Parse interface index + name from a netlink route dump / event buffer. +#[must_use] +pub(crate) fn parse_netlink_ifaces(buf: &[u8]) -> Vec<(u32, String)> { + let mut out = Vec::new(); + let mut off = 0usize; + while off + NLMSG_HDRLEN <= buf.len() { + let len = u32::from_ne_bytes(buf[off..off + 4].try_into().unwrap_or([0; 4])) as usize; + if len < NLMSG_HDRLEN || off + len > buf.len() { + break; + } + let nlmsg_type = u16::from_ne_bytes(buf[off + 4..off + 6].try_into().unwrap_or([0; 2])); + let payload = &buf[off + NLMSG_HDRLEN..off + len]; + match nlmsg_type { + libc::RTM_NEWLINK | libc::RTM_DELLINK | libc::RTM_GETLINK + if payload.len() >= IFINFOMSG_LEN => + { + let ifindex = + i32::from_ne_bytes(payload[4..8].try_into().unwrap_or([0; 4])) as u32; + let name = + rta_str(&payload[IFINFOMSG_LEN..], libc::IFLA_IFNAME).unwrap_or_default(); + if ifindex != 0 { + out.push((ifindex, name)); + } + } + libc::RTM_NEWADDR | libc::RTM_DELADDR if payload.len() >= IFADDRMSG_LEN => { + let ifindex = u32::from_ne_bytes(payload[4..8].try_into().unwrap_or([0; 4])); + let name = + rta_str(&payload[IFADDRMSG_LEN..], libc::IFA_LABEL).unwrap_or_default(); + if ifindex != 0 { + out.push((ifindex, name)); + } + } + _ => {} + } + off += align4(len); + } + out +} + +fn rta_str(attrs: &[u8], want: u16) -> Option { + let mut off = 0usize; + while off + RTA_HDRLEN <= attrs.len() { + let rta_len = u16::from_ne_bytes(attrs[off..off + 2].try_into().ok()?) as usize; + let rta_type = u16::from_ne_bytes(attrs[off + 2..off + 4].try_into().ok()?); + if rta_len < RTA_HDRLEN || off + rta_len > attrs.len() { + break; + } + if rta_type == want { + let data = &attrs[off + RTA_HDRLEN..off + rta_len]; + let end = data.iter().position(|&b| b == 0).unwrap_or(data.len()); + return String::from_utf8(data[..end].to_vec()).ok(); + } + off += align4(rta_len); + } + None +} + +fn align4(n: usize) -> usize { + n.saturating_add(3) & !3 +} + +fn name_for_ifindex(ifindex: u32) -> Option { + let entries = std::fs::read_dir("/sys/class/net").ok()?; + for entry in entries.flatten() { + let Ok(idx) = std::fs::read_to_string(entry.path().join("ifindex")) else { + continue; + }; + if idx.trim().parse::().ok() == Some(ifindex) { + return Some(entry.file_name().to_string_lossy().into_owned()); + } + } + None +} + /// Drain any pending iface-change signals (for tests / main-loop coalescing). pub(crate) fn drain(rx: &Receiver) { while rx.try_recv().is_ok() {} @@ -118,6 +262,103 @@ mod tests { use super::*; use std::time::Duration; + fn encode_rta(typ: u16, data: &[u8]) -> Vec { + let rta_len = RTA_HDRLEN + data.len(); + let mut out = Vec::new(); + out.extend_from_slice(&(rta_len as u16).to_ne_bytes()); + out.extend_from_slice(&typ.to_ne_bytes()); + out.extend_from_slice(data); + while out.len() % 4 != 0 { + out.push(0); + } + out + } + + fn encode_nlmsg(nlmsg_type: u16, payload: &[u8]) -> Vec { + let len = NLMSG_HDRLEN + payload.len(); + let mut out = Vec::new(); + out.extend_from_slice(&(len as u32).to_ne_bytes()); + out.extend_from_slice(&nlmsg_type.to_ne_bytes()); + out.extend_from_slice(&0u16.to_ne_bytes()); // flags + out.extend_from_slice(&0u32.to_ne_bytes()); // seq + out.extend_from_slice(&0u32.to_ne_bytes()); // pid + out.extend_from_slice(payload); + out + } + + fn ifinfomsg(ifindex: i32) -> Vec { + let mut p = vec![0u8; IFINFOMSG_LEN]; + p[4..8].copy_from_slice(&ifindex.to_ne_bytes()); + p + } + + fn ifaddrmsg(ifindex: u32) -> Vec { + let mut p = vec![0u8; IFADDRMSG_LEN]; + p[4..8].copy_from_slice(&ifindex.to_ne_bytes()); + p + } + + fn link_event(ifindex: i32, name: &str) -> Vec { + let mut payload = ifinfomsg(ifindex); + let mut name_bytes = name.as_bytes().to_vec(); + name_bytes.push(0); + payload.extend_from_slice(&encode_rta(libc::IFLA_IFNAME, &name_bytes)); + encode_nlmsg(libc::RTM_NEWLINK, &payload) + } + + fn addr_event(ifindex: u32, name: &str) -> Vec { + let mut payload = ifaddrmsg(ifindex); + let mut name_bytes = name.as_bytes().to_vec(); + name_bytes.push(0); + payload.extend_from_slice(&encode_rta(libc::IFA_LABEL, &name_bytes)); + encode_nlmsg(libc::RTM_NEWADDR, &payload) + } + + #[test] + fn parse_newlink_extracts_name() { + let buf = link_event(2, "eth0"); + assert_eq!(parse_netlink_ifaces(&buf), vec![(2, "eth0".into())]); + } + + #[test] + fn parse_newaddr_extracts_label() { + let buf = addr_event(3, "wlan0"); + assert_eq!(parse_netlink_ifaces(&buf), vec![(3, "wlan0".into())]); + } + + #[test] + fn skipped_wlan_is_not_relevant() { + let buf = link_event(3, "wlan0"); + assert!(!netlink_is_relevant(&buf, &[], &["wlan".into()])); + assert!(netlink_is_relevant(&buf, &[], &[])); + } + + #[test] + fn docker_veth_not_relevant() { + assert!(!netlink_is_relevant( + &link_event(10, "veth0abc"), + &[], + &[] + )); + assert!(!netlink_is_relevant(&link_event(11, "docker0"), &[], &[])); + assert!(!netlink_is_relevant(&link_event(1, "lo"), &[], &[])); + } + + #[test] + fn eth_is_relevant_by_default() { + assert!(netlink_is_relevant(&link_event(2, "eth0"), &[], &[])); + assert!(netlink_is_relevant( + &link_event(2, "eth0"), + &["eth".into()], + &[] + )); + assert!(!netlink_is_relevant( + &link_event(2, "eth0"), + &["wlan".into()], + &[] + )); + } + #[test] fn spawn_starts_and_stops() { let (rx, stop) = spawn().expect("spawn iface watcher"); @@ -128,7 +369,6 @@ mod tests { #[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) }; } @@ -172,8 +412,6 @@ mod tests { #[test] fn iface_change_on_dummy_down_up_same_addr() { - // Same address after a link down/up (the suspend analogue): netlink - // must still signal so the main loop can force IGMP leave+join. let name = format!("mdnsdn{}", std::process::id() % 10000); let add = std::process::Command::new("ip") .args(["link", "add", &name, "type", "dummy"]) @@ -236,7 +474,6 @@ mod tests { prev, prev + Duration::from_secs(60) )); - // Skew shrinking is not a suspend. assert!(!crate::sys::suspend_detected( Duration::from_secs(10), Duration::from_secs(1) diff --git a/src/legacy_unicast.rs b/src/legacy_unicast.rs index 95c59ea..26a9b09 100644 --- a/src/legacy_unicast.rs +++ b/src/legacy_unicast.rs @@ -289,6 +289,9 @@ fn handle_packet( Ok(g) => g.clone(), Err(_) => return, }; + if !should_answer_legacy(peer, &answers) { + return; + } let Some(resp) = build_response(&query, &answers, peer.ip(), ifindex) else { return; }; @@ -440,6 +443,28 @@ fn refresh_memberships( } } +/// Whether this peer should get a legacy (RFC 6762 §6.7) unicast reply. +/// +/// Multicast queries (source port 5353) are left to mdns-sd. Packets from our +/// own addresses are dropped so we never answer our own probes via loopback. +#[must_use] +pub fn should_answer_legacy(peer: SocketAddr, answers: &AnswerSet) -> bool { + if peer.port() == MDNS_PORT { + return false; + } + !is_own_addr(peer.ip(), answers) +} + +fn is_own_addr(ip: IpAddr, answers: &AnswerSet) -> bool { + if ip.is_loopback() { + return false; + } + match ip { + IpAddr::V4(v4) => answers.v4.iter().any(|a| a.addr == v4), + IpAddr::V6(v6) => answers.v6.iter().any(|a| a.addr == v6), + } +} + /// 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 { @@ -455,6 +480,13 @@ pub fn parse_query(packet: &[u8]) -> Option { if qdcount == 0 { return None; } + let nscount = u16::from_be_bytes([packet[8], packet[9]]); + if nscount != 0 { + // Probe queries put unique records in the authority section (RFC 6762 + // §8.1). Answering those with our own A/AAAA looks like a conflict to + // mdns-sd and can rename the host to bigfred-2.local. + return None; + } let mut pos = 12usize; let qname = read_name(packet, &mut pos)?; diff --git a/src/lib.rs b/src/lib.rs index 757a824..01751ec 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -6,12 +6,14 @@ pub mod config; pub mod config_watch; pub mod ctl; pub mod datadir; +pub mod doctor; pub mod error; pub mod iface_watch; pub mod legacy_unicast; pub mod mdns; pub mod microinit_watch; pub mod run; +pub mod selfcheck; pub mod signals; pub(crate) mod sys; pub mod version; diff --git a/src/main.rs b/src/main.rs index ea92bdf..d31e39f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ use clap::{Parser, Subcommand, ValueEnum}; use microdns::config::default_config_path; use microdns::ctl; use microdns::datadir; +use microdns::doctor; use microdns::run; use microdns::version; @@ -47,6 +48,12 @@ enum Commands { #[command(subcommand)] command: ServicesCommands, }, + /// Diagnose mDNS sockets, IGMP membership, and live daemon state + Doctor { + /// Output format + #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Human)] + output: OutputFormat, + }, } #[derive(Subcommand, Debug)] @@ -113,5 +120,27 @@ fn main() -> ExitCode { } }, }, + Commands::Doctor { output } => { + let report = doctor::collect(&config_path, &socket); + match report { + Ok(report) => { + let result = match output { + OutputFormat::Human => doctor::print_human(&mut std::io::stdout(), &report), + OutputFormat::Json => doctor::print_json(&mut std::io::stdout(), &report), + }; + match result { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } + } + Err(e) => { + eprintln!("{e}"); + ExitCode::FAILURE + } + } + } } } diff --git a/src/mdns.rs b/src/mdns.rs index 8d78378..c0954b2 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -6,19 +6,55 @@ use std::collections::{HashMap, HashSet}; use std::fs; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; -use std::sync::Mutex; +use std::sync::{Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; -use mdns_sd::{ServiceDaemon, ServiceInfo}; +use mdns_sd::{DaemonEvent, DaemonStatus, ServiceDaemon, ServiceInfo}; use crate::config::ServiceEntry; use crate::error::{Error, Result}; use crate::legacy_unicast::{IfaceAddr4, IfaceAddr6}; use crate::version; +/// Last successful unsolicited announcement per service fullname. +#[derive(Debug, Default, Clone)] +pub struct AnnounceLog { + inner: Arc>>, +} + +impl AnnounceLog { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn record(&self, fullname: &str) { + if let Ok(mut g) = self.inner.lock() { + g.insert(fullname.to_string(), Instant::now()); + } + } + + #[must_use] + pub fn snapshot(&self) -> HashMap { + self.inner + .lock() + .map(|g| g.clone()) + .unwrap_or_default() + } + + pub fn clear(&self) { + if let Ok(mut g) = self.inner.lock() { + g.clear(); + } + } +} + /// Tracked registrations keyed by full service name. pub struct MdnsPublisher { daemon: Option, registered: Mutex>, + announce_log: AnnounceLog, } impl MdnsPublisher { @@ -29,9 +65,16 @@ impl MdnsPublisher { Self { daemon: None, registered: Mutex::new(HashSet::new()), + announce_log: AnnounceLog::new(), } } + /// Last unsolicited announcements observed via `ServiceDaemon::monitor`. + #[must_use] + pub fn announce_log(&self) -> &AnnounceLog { + &self.announce_log + } + /// Ensure the underlying `ServiceDaemon` is running. pub fn ensure_daemon(&mut self) -> Result<()> { if self.daemon.is_some() { @@ -40,6 +83,7 @@ impl MdnsPublisher { match ServiceDaemon::new() { Ok(d) => { log::info!("mDNS service daemon started"); + spawn_monitor(&d, self.announce_log.clone()); self.daemon = Some(d); Ok(()) } @@ -47,6 +91,35 @@ impl MdnsPublisher { } } + /// Snapshot of mdns-sd counters, or empty if the daemon is down. + #[must_use] + pub fn metrics(&self) -> HashMap { + let Some(d) = self.daemon.as_ref() else { + return HashMap::new(); + }; + match d.get_metrics() { + Ok(rx) => rx + .recv_timeout(Duration::from_millis(400)) + .unwrap_or_else(|_| HashMap::new()), + Err(_) => HashMap::new(), + } + } + + /// Whether the mdns-sd thread is alive and reports [`DaemonStatus::Running`]. + #[must_use] + pub fn daemon_alive(&self) -> bool { + let Some(d) = self.daemon.as_ref() else { + return false; + }; + match d.status() { + Ok(rx) => matches!( + rx.recv_timeout(Duration::from_millis(400)), + Ok(DaemonStatus::Running) + ), + Err(_) => false, + } + } + /// Register (or re-register) a service. /// /// Host A/AAAA records are published by mdns-sd via multicast so that local @@ -96,16 +169,28 @@ impl MdnsPublisher { .register(info) .map_err(|e| Error::Mdns(format!("register {fullname}: {e}")))?; - if let Ok(mut set) = self.registered.lock() { - set.insert(fullname.clone()); + let first = if let Ok(mut set) = self.registered.lock() { + set.insert(fullname.clone()) + } else { + true + }; + if first { + log::info!( + "registered mDNS service instance={} type={} host={} port={}", + entry.name, + ty, + host, + entry.port + ); + } else { + log::debug!( + "re-announced mDNS service instance={} type={} host={} port={}", + entry.name, + ty, + host, + entry.port + ); } - log::info!( - "registered mDNS service instance={} type={} host={} port={}", - entry.name, - ty, - host, - entry.port - ); Ok(()) } @@ -151,6 +236,7 @@ impl MdnsPublisher { if let Ok(mut set) = self.registered.lock() { set.clear(); } + self.announce_log.clear(); } /// Drop the current `ServiceDaemon` and start a new one. @@ -176,6 +262,52 @@ impl Drop for MdnsPublisher { } } +fn spawn_monitor(daemon: &ServiceDaemon, log: AnnounceLog) { + let rx = match daemon.monitor() { + Ok(rx) => rx, + Err(e) => { + log::warn!("mDNS monitor subscribe failed: {e}"); + return; + } + }; + if let Err(e) = thread::Builder::new() + .name("mdns-monitor".into()) + .spawn(move || { + while let Ok(event) = rx.recv() { + consume_daemon_event(&event, &log); + } + log::debug!("mDNS monitor channel closed"); + }) + { + log::warn!("mDNS monitor thread: {e}"); + } +} + +fn consume_daemon_event(event: &DaemonEvent, log: &AnnounceLog) { + match event { + DaemonEvent::Announce(name, iface) => { + log::info!("mDNS announced service={name} iface={iface}"); + log.record(name); + } + DaemonEvent::Respond(iface) => { + log::debug!("mDNS multicast response iface={iface}"); + } + DaemonEvent::IpAdd(ip) => log::info!("mDNS host address added {ip}"), + DaemonEvent::IpDel(ip) => log::info!("mDNS host address removed {ip}"), + DaemonEvent::Error(e) => log::warn!("mDNS daemon error: {e}"), + DaemonEvent::NameChange(change) => { + log::error!( + "mDNS name conflict original={} new={} rr_type={:?} iface={}", + change.original, + change.new_name, + change.rr_type, + change.intf_name + ); + } + _ => {} + } +} + /// Ensure service type ends with `.local.` (e.g. `_http._tcp` → `_http._tcp.local.`). #[must_use] pub fn normalize_service_type(type_: &str) -> String { @@ -227,6 +359,7 @@ pub fn preferred_ipv4_ifaces(allow: &[String], skip: &[String]) -> Vec Vec Vec { out.push(iface.ifindex); } } + out.sort_unstable(); + out.dedup(); out } @@ -289,6 +425,18 @@ fn iface_usable(iface: &IfaceInfo, allow: &[String], skip: &[String]) -> bool { true } +/// Whether a netlink event for `name` should wake the main loop. +/// +/// Name-only: skip/allow filters apply, loopback is ignored. Link-up state is +/// not required — a DOWN on an advertised NIC still needs a re-announce. +#[must_use] +pub fn iface_name_relevant(name: &str, allow: &[String], skip: &[String]) -> bool { + if name.eq_ignore_ascii_case("lo") { + return false; + } + !should_skip_iface(name, skip) && is_allowed_iface(name, allow) +} + /// Whether the link is usable for mDNS: `IFF_RUNNING` or `operstate == "up"`. /// /// `IFF_UP` alone is not enough — after suspend a Wi‑Fi NIC often stays @@ -397,6 +545,7 @@ fn list_interfaces() -> Result> { ipv6, }); } + out.sort_by(|a, b| a.name.cmp(&b.name)); Ok(out) } @@ -441,6 +590,8 @@ fn addrs_for_iface(name: &str) -> (Vec<(Ipv4Addr, Ipv4Addr)>, Vec) { } libc::freeifaddrs(ifap); } + ipv4.sort_by_key(|a| a.0); + ipv6.sort(); (ipv4, ipv6) } diff --git a/src/run.rs b/src/run.rs index 22a9dc3..bb731f3 100644 --- a/src/run.rs +++ b/src/run.rs @@ -134,7 +134,7 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &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() { + let (iface_rx, iface_watch_stop) = match iface_watch::spawn_filtered(Arc::clone(&config)) { Ok(pair) => pair, Err(e) => { log::warn!("iface watcher failed to start: {e}; relying on polling"); @@ -157,7 +157,15 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { let publisher = Arc::new(Mutex::new(MdnsPublisher::new())); let beacons: Arc>> = Arc::new(Mutex::new(Vec::new())); let desired_snapshot = Arc::new(RwLock::new(DesiredAds::default())); - ctl::serve(ctl_socket, Arc::clone(&desired_snapshot))?; + let selfcheck_snapshot = Arc::new(RwLock::new(crate::selfcheck::Report::default())); + ctl::serve_with_runtime( + ctl_socket, + Arc::clone(&desired_snapshot), + Some(ctl::CtlRuntime { + publisher: Arc::clone(&publisher), + selfcheck: Arc::clone(&selfcheck_snapshot), + }), + )?; let answer_set = Arc::new(RwLock::new(AnswerSet::default())); let membership = Arc::new(MembershipRefresh::new()); if let Err(e) = legacy_unicast::spawn_with_refresh( @@ -184,6 +192,8 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { let mut mdns_thr = FailThrottle::new(); let mut bigfred_thr = FailThrottle::new(); let mut last_programs: Option> = None; + let mut programs_stale_since: Option = None; + let mut bigfred_failures: u32 = 0; let mut next_bigfred_probe = Instant::now(); let mut last_microinit: Option> = None; @@ -192,6 +202,18 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { let mut force_reannounce = false; let mut recreate_daemon = false; let mut last_skew = sys::boottime_monotonic_skew(); + let mut next_periodic = Instant::now() + + Duration::from_millis(shared.config.read().map(|c| c.announce.period_ms).unwrap_or(55_000)); + let mut burst_deadlines: Vec = Vec::new(); + let mut next_selfcheck = Instant::now() + + Duration::from_millis( + shared + .config + .read() + .map(|c| c.selfcheck.period_ms) + .unwrap_or(60_000), + ); + let mut selfcheck_escalation = crate::selfcheck::Escalation::None; while !signals::shutdown_requested() && !stop.load(Ordering::SeqCst) { let now_skew = sys::boottime_monotonic_skew(); @@ -211,6 +233,21 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { last_microinit = None; } let mdns_ms = cfg.retry.mdns_ms; + let announce_period = Duration::from_millis(cfg.announce.period_ms.max(1000)); + let selfcheck_period = Duration::from_millis(cfg.selfcheck.period_ms.max(1000)); + let now = Instant::now(); + if now >= next_periodic { + force_reannounce = true; + next_periodic = now + announce_period; + } + burst_deadlines.retain(|t| { + if now >= *t { + force_reannounce = true; + false + } else { + true + } + }); // Ensure mDNS daemon (quiet retry). Recreate after suspend so mdns-sd // binds fresh sockets and rejoins 224.0.0.251. @@ -335,15 +372,30 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { match bigfred_watch::dcc_bus_list(&bigfred_socket) { Ok(programs) => { last_programs = Some(programs); + programs_stale_since = None; + bigfred_failures = 0; bigfred_thr.ok("bigfred socket"); next_bigfred_probe = Instant::now() + Duration::from_millis(retry.poll_ms.max(500)); } Err(e) => { - last_programs = None; bigfred_thr.fail("bigfred socket", &e); - next_bigfred_probe = - Instant::now() + Duration::from_millis(retry.bigfred_ms.max(500)); + if last_programs.is_some() { + let stale_from = *programs_stale_since.get_or_insert(Instant::now()); + if Instant::now().saturating_duration_since(stale_from) + >= Duration::from_millis(retry.bigfred_ms.max(500)) + { + log::warn!( + "bigfred socket down for {}ms; withdrawing dcc-bus ads", + retry.bigfred_ms + ); + last_programs = None; + programs_stale_since = None; + } + } + let wait = bigfred_backoff_ms(bigfred_failures, retry.bigfred_ms); + bigfred_failures = bigfred_failures.saturating_add(1); + next_bigfred_probe = Instant::now() + Duration::from_millis(wait); } } } @@ -361,13 +413,7 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { }); } } - desired.dynamic.sort_by(|a, b| { - (&a.entry.name, &a.entry.type_, a.entry.port).cmp(&( - &b.entry.name, - &b.entry.type_, - b.entry.port, - )) - }); + sort_dynamic_ads(&mut desired.dynamic); desired.beacons.sort_unstable(); desired.beacons.dedup(); @@ -376,8 +422,11 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { } // Reconcile advertisements when desired set changes (incl. IP churn) - // or when netlink/suspend forced a refresh with the same IPs. - if desired != last_desired || force_reannounce { + // or when netlink/suspend/periodic ticker forced a refresh. Refresh is + // register() only — never unregister() — so we do not emit goodbye + // packets that wipe client caches. + let desired_changed = desired != last_desired; + if desired_changed || force_reannounce { let ips_changed = force_reannounce || desired.ips != last_desired.ips || desired.ips_v6 != last_desired.ips_v6 @@ -388,12 +437,81 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { mdns_thr.fail("mDNS register", &e); } else { mdns_thr.ok("mDNS register"); + if desired_changed { + burst_deadlines = announce_burst_deadlines(cfg.announce.burst_count); + } last_desired = desired; force_reannounce = false; } } - // Sleep until next poll; wake early on shutdown, netlink, or suspend. + if Instant::now() >= next_selfcheck { + let expected: Vec = last_desired + .static_services + .iter() + .chain(last_desired.dynamic.iter().map(|d| &d.entry)) + .map(|e| MdnsPublisher::fullname(&e.name, &e.type_)) + .collect(); + let want_v4 = mdns::preferred_ipv4_ifaces( + &last_desired.interfaces, + &last_desired.skip_interfaces, + ); + let fresh_for = announce_period + Duration::from_secs(15); + let mut report = { + let pub_guard = lock_mutex(&publisher); + crate::selfcheck::evaluate( + &pub_guard, + &want_v4, + &expected, + fresh_for, + Instant::now(), + ) + }; + if report.ok { + selfcheck_escalation = crate::selfcheck::Escalation::None; + } else { + match selfcheck_escalation { + crate::selfcheck::Escalation::None => { + log::warn!("selfcheck failed; re-announcing: {}", report.message); + force_reannounce = true; + selfcheck_escalation = crate::selfcheck::Escalation::Reannounce; + } + crate::selfcheck::Escalation::Reannounce => { + log::error!( + "selfcheck still failing; recreating mDNS daemon: {}", + report.message + ); + recreate_daemon = true; + force_reannounce = true; + selfcheck_escalation = crate::selfcheck::Escalation::RecreateDaemon; + } + crate::selfcheck::Escalation::RecreateDaemon => { + log::error!( + "selfcheck still failing after daemon recreate: {}", + report.message + ); + } + } + } + report.escalation = selfcheck_escalation; + if let Ok(mut w) = selfcheck_snapshot.write() { + *w = report; + } + next_selfcheck = Instant::now() + selfcheck_period; + } + + // Sleep until next poll / announce / selfcheck; wake early on netlink. + let until_periodic = next_periodic + .saturating_duration_since(Instant::now()) + .as_millis() as u64; + let until_burst = burst_deadlines + .iter() + .map(|t| t.saturating_duration_since(Instant::now()).as_millis() as u64) + .min() + .unwrap_or(u64::MAX); + let until_selfcheck = next_selfcheck + .saturating_duration_since(Instant::now()) + .as_millis() as u64; let sleep_ms = if bigfred_enabled { let until_probe = next_bigfred_probe .saturating_duration_since(Instant::now()) @@ -402,11 +520,15 @@ pub fn run_with_socket(config_path: &Path, ctl_socket: &Path) -> Result<()> { } else { retry.iface_ms.min(retry.mdns_ms) }; + let sleep_ms = sleep_ms + .min(until_periodic) + .min(until_burst) + .min(until_selfcheck); let reason = sleep_or_iface( &iface_rx, µinit_rx, &mut last_microinit, - Duration::from_millis(sleep_ms.max(500)), + Duration::from_millis(sleep_ms.max(200)), &mut last_skew, ); apply_wake( @@ -514,11 +636,11 @@ fn reload_loop( /// Apply desired ads without withdrawing working records first. /// -/// 1. Register entries that are entirely new. -/// 2. Refresh entries that changed (or when IPs changed): unregister then register. -/// 3. Only then unregister keys that are no longer desired. -/// 4. On register failure, return Err so the caller keeps `last_desired` and retries -/// without committing a broken state. +/// The action list comes from [`plan_reconcile`], which orders `Add` before +/// `Refresh` before `Drop` so existing records keep answering queries until the +/// desired set is registered, and never pairs a refresh with an unregister. +/// On register failure this returns `Err` so the caller keeps `last_desired` and +/// retries without committing a broken state. fn reconcile( publisher: &Arc>, desired: &DesiredAds, @@ -540,38 +662,24 @@ fn reconcile( desired_map.insert(key, dyn_ad.entry.clone()); } - // Phase 1: add brand-new keys while old ads still answer queries. - for (key, entry) in &desired_map { - if registered.contains_key(key) { - continue; - } - pub_guard.register(entry, entry.host.as_deref(), allow, skip)?; - registered.insert(key.clone(), entry.clone()); - } - - // Phase 2: refresh changed content or rebound addresses after DHCP/iface churn. - for (key, entry) in &desired_map { - let Some(prev) = registered.get(key) else { - continue; - }; - if prev == entry && !ips_changed { - continue; + for action in plan_reconcile(&desired_map, registered, ips_changed) { + match action { + // A refresh re-registers without unregister: mdns-sd overwrites the + // existing ServiceInfo, while unregister would send a goodbye + // (TTL 0) plus a second one 120 ms later, landing after the new + // announcement and wiping client caches. + ReconcileAction::Add(key) | ReconcileAction::Refresh(key) => { + let Some(entry) = desired_map.get(&key) else { + continue; + }; + pub_guard.register(entry, entry.host.as_deref(), allow, skip)?; + registered.insert(key, entry.clone()); + } + ReconcileAction::Drop(key) => { + let _ = pub_guard.unregister(&key); + registered.remove(&key); + } } - let _ = pub_guard.unregister(key); - pub_guard.register(entry, entry.host.as_deref(), allow, skip)?; - registered.insert(key.clone(), entry.clone()); - } - - // Phase 3: drop obsolete keys only after desired set is registered. - let desired_keys: HashSet = desired_map.keys().cloned().collect(); - let stale: Vec = registered - .keys() - .filter(|k| !desired_keys.contains(*k)) - .cloned() - .collect(); - for key in stale { - let _ = pub_guard.unregister(&key); - registered.remove(&key); } drop(pub_guard); @@ -621,6 +729,111 @@ fn lock_mutex(mutex: &Mutex) -> std::sync::MutexGuard<'_, T> { } } +/// Exponential backoff for a missing BigFred socket: 2 s, 4 s, 8 s, … cap. +#[must_use] +pub fn bigfred_backoff_ms(failures: u32, cap: u64) -> u64 { + let shift = failures.min(15); + (2000u64.saturating_mul(1u64 << shift)).min(cap.max(500)).max(500) +} + +/// Unsolicited re-announce deadlines after a real advertisement change. +/// `burst_count` 4 → now+1s, +2s, +4s, +8s. +#[must_use] +pub fn announce_burst_deadlines(burst_count: u8) -> Vec { + let now = Instant::now(); + announce_burst_delays(burst_count) + .into_iter() + .map(|d| now + d) + .collect() +} + +/// Burst delays as durations (testable without Instant::now). +#[must_use] +pub fn announce_burst_delays(burst_count: u8) -> Vec { + (0..burst_count) + .map(|i| Duration::from_secs(1u64 << i.min(31))) + .collect() +} + +fn txt_sort_key(txt: &Option>) -> Vec<(String, String)> { + let mut pairs: Vec<(String, String)> = txt + .iter() + .flatten() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + pairs.sort(); + pairs +} + +fn sort_dynamic_ads(dynamic: &mut [DynAd]) { + dynamic.sort_by(|a, b| { + ( + a.entry.name.as_str(), + a.entry.type_.as_str(), + a.entry.port, + a.entry.host.as_deref().unwrap_or(""), + txt_sort_key(&a.entry.txt), + ) + .cmp(&( + b.entry.name.as_str(), + b.entry.type_.as_str(), + b.entry.port, + b.entry.host.as_deref().unwrap_or(""), + txt_sort_key(&b.entry.txt), + )) + }); +} + +/// Plan of register/refresh/drop actions. Refresh never includes unregister. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReconcileAction { + Add(String), + Refresh(String), + Drop(String), +} + +/// Compute reconcile actions. Refresh is register-only (no goodbye). +#[must_use] +pub fn plan_reconcile( + desired: &HashMap, + registered: &HashMap, + content_or_addr_changed: bool, +) -> Vec { + let mut actions = Vec::new(); + for (key, entry) in desired { + if !registered.contains_key(key) { + actions.push(ReconcileAction::Add(key.clone())); + continue; + } + if registered.get(key) != Some(entry) || content_or_addr_changed { + actions.push(ReconcileAction::Refresh(key.clone())); + } + } + for key in registered.keys() { + if !desired.contains_key(key) { + actions.push(ReconcileAction::Drop(key.clone())); + } + } + actions.sort_by(|a, b| { + fn rank(x: &ReconcileAction) -> u8 { + match x { + ReconcileAction::Add(_) => 0, + ReconcileAction::Refresh(_) => 1, + ReconcileAction::Drop(_) => 2, + } + } + fn key(x: &ReconcileAction) -> &str { + match x { + ReconcileAction::Add(k) | ReconcileAction::Refresh(k) | ReconcileAction::Drop(k) => { + k + } + } + } + rank(a).cmp(&rank(b)).then_with(|| key(a).cmp(key(b))) + }); + actions +} + /// 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() { @@ -693,9 +906,9 @@ fn apply_wake( if *recreate_daemon { return; } - // Same IPs after a link flap / resume still need IGMP leave+join - // and an mDNS announce; AddressSet equality would skip both. - membership.request_rejoin(); + // Same IPs after a link flap still need a re-announce. Do NOT + // leave+join IGMP here: that drops multicast on snooping switches. + // Membership is refreshed when AnswerSet addresses actually change. *force_reannounce = true; } WakeReason::Suspend => { @@ -786,7 +999,7 @@ mod tests { use std::sync::mpsc; #[test] - fn iface_change_forces_rejoin_not_rebind() { + fn iface_change_forces_reannounce_not_rejoin() { let membership = MembershipRefresh::new(); let mut force = false; let mut recreate = false; @@ -798,7 +1011,7 @@ mod tests { ); assert!(force); assert!(!recreate); - assert_eq!(membership.epoch(), 1); + assert_eq!(membership.epoch(), 0); assert!(!membership.take_rebind()); } diff --git a/src/selfcheck.rs b/src/selfcheck.rs new file mode 100644 index 0000000..4a62eed --- /dev/null +++ b/src/selfcheck.rs @@ -0,0 +1,195 @@ +//! Periodic self-verification of mDNS multicast membership and announcements. + +use std::collections::HashMap; +use std::fs; +use std::net::Ipv4Addr; +use std::time::{Duration, Instant}; + +use serde::{Deserialize, Serialize}; + +use crate::legacy_unicast::IfaceAddr4; +use crate::mdns::{AnnounceLog, MdnsPublisher}; + +/// IPv4 mDNS group 224.0.0.251, as it appears in `/proc/net/igmp` (LE hex). +pub const MDNS_GROUP_V4: Ipv4Addr = Ipv4Addr::new(224, 0, 0, 251); + +/// Snapshot of the last self-check, shared with `microdns doctor`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct Report { + pub ok: bool, + pub igmp_ok: bool, + pub daemon_alive: bool, + pub announce_fresh: bool, + pub missing_igmp_ifaces: Vec, + pub stale_services: Vec, + pub escalation: Escalation, + pub message: String, +} + +impl Default for Report { + fn default() -> Self { + Self { + ok: true, + igmp_ok: true, + daemon_alive: true, + announce_fresh: true, + missing_igmp_ifaces: Vec::new(), + stale_services: Vec::new(), + escalation: Escalation::None, + message: "not checked yet".into(), + } + } +} + +/// How far the main loop has gone to recover from a failed check. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum Escalation { + None, + Reannounce, + RecreateDaemon, +} + +/// Parse `/proc/net/igmp` into iface → joined groups. +/// +/// Group addresses are stored little-endian hex (e.g. `FB0000E0` = 224.0.0.251). +#[must_use] +pub fn parse_igmp(contents: &str) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + let mut current: Option = None; + for line in contents.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with("Idx") { + continue; + } + if !line.starts_with(|c: char| c.is_whitespace()) { + // `2 eth0 : 2 V3` + let mut parts = trimmed.split_whitespace(); + let _idx = parts.next(); + if let Some(name) = parts.next() { + let name = name.trim_end_matches(':').to_string(); + current = Some(name.clone()); + out.entry(name).or_default(); + } + continue; + } + if let Some(name) = ¤t { + if let Some(token) = trimmed.split_whitespace().next() { + if let Some(ip) = parse_igmp_group(token) { + out.entry(name.clone()).or_default().push(ip); + } + } + } + } + out +} + +fn parse_igmp_group(token: &str) -> Option { + let hex = token.trim(); + if hex.len() != 8 { + return None; + } + let le = u32::from_str_radix(hex, 16).ok()?; + // /proc/net/igmp prints the address in host nibble order on little-endian: + // 224.0.0.251 (0xE00000FB) → FB0000E0. + Some(Ipv4Addr::from(le.to_le_bytes())) +} + +/// Ifaces from `want` that do not currently have 224.0.0.251 joined. +#[must_use] +pub fn missing_mdns_membership( + igmp: &HashMap>, + want: &[IfaceAddr4], +) -> Vec { + let mut missing = Vec::new(); + let mut seen = std::collections::HashSet::new(); + for a in want { + if !seen.insert(&a.iface) { + continue; + } + let groups = igmp.get(&a.iface).map(Vec::as_slice).unwrap_or(&[]); + if !groups.contains(&MDNS_GROUP_V4) { + missing.push(a.iface.clone()); + } + } + missing.sort(); + missing +} + +/// Services whose last `Announce` event is older than `fresh_for`. +#[must_use] +pub fn stale_announces( + expected: &[String], + log: &AnnounceLog, + fresh_for: Duration, + now: Instant, +) -> Vec { + let snap = log.snapshot(); + let mut stale = Vec::new(); + for name in expected { + match snap.get(name) { + Some(at) if now.saturating_duration_since(*at) <= fresh_for => {} + _ => stale.push(name.clone()), + } + } + stale.sort(); + stale +} + +/// Run one check against live kernel + daemon state. +#[must_use] +pub fn evaluate( + publisher: &MdnsPublisher, + want_v4: &[IfaceAddr4], + expected_names: &[String], + fresh_for: Duration, + now: Instant, +) -> Report { + let igmp_raw = fs::read_to_string("/proc/net/igmp").unwrap_or_default(); + let igmp = parse_igmp(&igmp_raw); + let missing_igmp = if want_v4.is_empty() { + Vec::new() + } else { + missing_mdns_membership(&igmp, want_v4) + }; + let daemon_alive = publisher.daemon_alive(); + let log_empty = publisher.announce_log().snapshot().is_empty(); + let stale = if expected_names.is_empty() || log_empty { + // Cold start / monitor not yet delivering Announce events. + Vec::new() + } else { + stale_announces(expected_names, publisher.announce_log(), fresh_for, now) + }; + let igmp_ok = missing_igmp.is_empty(); + let announce_fresh = stale.is_empty(); + let ok = igmp_ok && daemon_alive && announce_fresh; + let message = if ok { + "ok".into() + } else { + let mut parts = Vec::new(); + if !daemon_alive { + parts.push("mdns-sd thread not running".to_string()); + } + if !igmp_ok { + parts.push(format!( + "224.0.0.251 not joined on {}", + missing_igmp.join(",") + )); + } + if !announce_fresh { + parts.push(format!("stale announces: {}", stale.join(","))); + } + parts.join("; ") + }; + Report { + ok, + igmp_ok, + daemon_alive, + announce_fresh, + missing_igmp_ifaces: missing_igmp, + stale_services: stale, + escalation: Escalation::None, + message, + } +} diff --git a/src/sys.rs b/src/sys.rs index 574d8a6..a8b4e9d 100644 --- a/src/sys.rs +++ b/src/sys.rs @@ -83,13 +83,13 @@ pub fn open_rtnetlink(recv_timeout: Duration) -> std::io::Result { 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. +/// Receive one netlink datagram into `buf`. Returns the number of bytes read. +/// +/// `Ok(0)` means the receive timed out. +pub fn recv_netlink(sock: &OwnedFd, buf: &mut [u8]) -> std::io::Result { + let mut iov = [IoSliceMut::new(buf)]; + // SAFETY: `sock` is a valid netlink fd. `iov` points at `buf` for the + // duration of recvmsg. We do not retain pointers after return. let n = unsafe { let mut msg: libc::msghdr = mem::zeroed(); msg.msg_iov = iov.as_mut_ptr().cast(); @@ -99,12 +99,11 @@ pub fn recv_netlink_any(sock: &OwnedFd) -> std::io::Result { if n < 0 { let err = std::io::Error::last_os_error(); if matches!(err.kind(), ErrorKind::WouldBlock | ErrorKind::TimedOut) { - return Ok(false); + return Ok(0); } return Err(err); } - debug_assert!(n >= 0); - Ok(n > 0) + Ok(n as usize) } /// Enable `IP_PKTINFO` so recvmsg delivers receiving-interface metadata (IPv4). diff --git a/tests/config_test.rs b/tests/config_test.rs index a984bf6..0f1715b 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -59,6 +59,9 @@ fn default_roundtrip() { assert_eq!(back.retry.mdns_ms, 3000); assert_eq!(back.retry.bigfred_ms, 45_000); assert_eq!(back.retry.microinit_reconnect_ms, 3000); + assert_eq!(back.announce.period_ms, 55_000); + assert_eq!(back.announce.burst_count, 4); + assert_eq!(back.selfcheck.period_ms, 60_000); assert!(!json.contains("microinitMs")); assert!(!json.contains("procMs")); assert!(!json.contains("z21Port")); @@ -180,3 +183,26 @@ fn dcc_bus_blank_host_is_none() { assert_eq!(cfg.dcc_bus.host.as_deref(), Some(" ")); assert_eq!(cfg.dcc_bus.advertised_host(), None); } + +#[test] +fn announce_and_selfcheck_bind_camel_case() { + let json = r#"{ + "services": [], + "announce": { "periodMs": 40000, "burstCount": 3 }, + "selfcheck": { "periodMs": 15000 } + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.announce.period_ms, 40_000); + assert_eq!(cfg.announce.burst_count, 3); + assert_eq!(cfg.selfcheck.period_ms, 15_000); +} + +#[test] +fn validate_rejects_tiny_announce_period() { + let mut cfg = Config::default(); + cfg.announce.period_ms = 50; + assert!(cfg.validate().is_err()); + cfg.announce.period_ms = 1000; + cfg.announce.burst_count = 9; + assert!(cfg.validate().is_err()); +} diff --git a/tests/ctl_test.rs b/tests/ctl_test.rs index 7cbf158..054851b 100644 --- a/tests/ctl_test.rs +++ b/tests/ctl_test.rs @@ -252,3 +252,52 @@ fn cli_rejects_invalid_output_format() { "{err}" ); } + +#[test] +fn doctor_request_returns_services_without_runtime() { + let sock = tmp_sock(); + let _ = std::fs::remove_file(&sock); + serve(&sock, Arc::new(RwLock::new(sample_ads()))).unwrap(); + + let mut stream = UnixStream::connect(&sock).unwrap(); + write_frame(&mut stream, &serde_json::json!({"type": "doctor"})).unwrap(); + let raw = microdns::ctl::read_frame(&mut stream).unwrap(); + let v: serde_json::Value = serde_json::from_slice(&raw).unwrap(); + assert!(v["services"].is_array()); + assert_eq!(v["services"].as_array().unwrap().len(), 4); + + let _ = std::fs::remove_file(&sock); +} + +#[test] +fn cli_doctor_json_works_without_daemon() { + let exe = env!("CARGO_BIN_EXE_microdns"); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let cfg = std::env::temp_dir().join(format!("microdns-doctor-{nanos}.json")); + let sock = std::env::temp_dir().join(format!("microdns-doctor-{nanos}.sock")); + std::fs::write(&cfg, r#"{"services":[]}"#).unwrap(); + let out = std::process::Command::new(exe) + .args([ + "doctor", + "-o", + "json", + "--config", + cfg.to_str().unwrap(), + "--socket", + sock.to_str().unwrap(), + ]) + .output() + .unwrap(); + let _ = std::fs::remove_file(&cfg); + assert!( + out.status.success(), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + let v: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap(); + assert!(v["interfaces"].is_array()); + assert!(v["daemon"].is_null()); +} diff --git a/tests/legacy_unicast_test.rs b/tests/legacy_unicast_test.rs index 3e8cb27..aaba59d 100644 --- a/tests/legacy_unicast_test.rs +++ b/tests/legacy_unicast_test.rs @@ -1,6 +1,6 @@ //! Unit + integration tests for the legacy unicast mDNS responder. -use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, UdpSocket}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr, UdpSocket}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, RwLock}; use std::thread; @@ -8,8 +8,8 @@ use std::time::Duration; use microdns::legacy_unicast::{ build_response, choose_v4, choose_v4_for_iface, choose_v6_for_iface, hosts_match, parse_query, - spawn, AnswerSet, IfaceAddr4, IfaceAddr6, MembershipRefresh, ParsedQuery, LEGACY_TTL, QTYPE_A, - QTYPE_AAAA, QTYPE_ANY, + should_answer_legacy, spawn, AnswerSet, IfaceAddr4, IfaceAddr6, MembershipRefresh, ParsedQuery, + LEGACY_TTL, MDNS_PORT, QTYPE_A, QTYPE_AAAA, QTYPE_ANY, }; fn encode_name(name: &str) -> Vec { @@ -25,13 +25,17 @@ fn encode_name(name: &str) -> Vec { } fn build_query(id: u16, qname: &str, qtype: u16, qclass: u16) -> Vec { + build_query_counts(id, qname, qtype, qclass, 0) +} + +fn build_query_counts(id: u16, qname: &str, qtype: u16, qclass: u16, nscount: 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(&nscount.to_be_bytes()); pkt.extend_from_slice(&0u16.to_be_bytes()); pkt.extend_from_slice(&name); pkt.extend_from_slice(&qtype.to_be_bytes()); @@ -75,12 +79,31 @@ fn parse_rejects_response_qr() { #[test] fn parse_accepts_mdns_multicast_query() { - // Multicast queries (src port 5353) are answered; content selects per-iface IP. + // Packet content is still a valid A query; handle_packet refuses src port 5353. let pkt = build_query(1, "bigfred.local.", QTYPE_A, 1); let q = parse_query(&pkt).expect("parse"); assert_eq!(q.qtype, QTYPE_A); } +#[test] +fn parse_rejects_probe_with_nscount() { + let pkt = build_query_counts(1, "bigfred.local.", QTYPE_ANY, 1, 1); + assert!(parse_query(&pkt).is_none()); +} + +#[test] +fn should_answer_only_legacy_unicast_from_others() { + let answers = sample_answers(); + let ephemeral = SocketAddr::from((Ipv4Addr::new(192, 168, 1, 50), 12345)); + let mdns_port = SocketAddr::from((Ipv4Addr::new(192, 168, 1, 50), MDNS_PORT)); + let own = SocketAddr::from((Ipv4Addr::new(192, 168, 1, 10), 12345)); + let loopback = SocketAddr::from((Ipv4Addr::LOCALHOST, 12345)); + assert!(should_answer_legacy(ephemeral, &answers)); + assert!(should_answer_legacy(loopback, &answers)); + assert!(!should_answer_legacy(mdns_port, &answers)); + assert!(!should_answer_legacy(own, &answers)); +} + #[test] fn parse_extracts_android_style_query() { let pkt = build_query(11110, "bigfred.local.", QTYPE_A, 1); diff --git a/tests/mdns_test.rs b/tests/mdns_test.rs index b97e303..d079259 100644 --- a/tests/mdns_test.rs +++ b/tests/mdns_test.rs @@ -1,6 +1,6 @@ use microdns::mdns::{ - dcc_service_entry, iface_link_ready, is_allowed_iface, normalize_hostname, - normalize_service_type, should_skip_iface, + dcc_service_entry, iface_link_ready, iface_name_relevant, is_allowed_iface, normalize_hostname, + normalize_service_type, preferred_ipv4_ifaces, preferred_ipv6_addrs, should_skip_iface, }; #[test] @@ -132,3 +132,28 @@ fn dcc_entry_blank_host_is_none() { ); assert_eq!(e.host, None); } + +#[test] +fn iface_name_relevant_skips_lo_docker_and_configured() { + assert!(!iface_name_relevant("lo", &[], &[])); + assert!(!iface_name_relevant("docker0", &[], &[])); + assert!(!iface_name_relevant("wlan0", &[], &["wlan".into()])); + assert!(iface_name_relevant("eth0", &[], &["wlan".into()])); + assert!(iface_name_relevant("wlan0", &[], &[])); +} + +#[test] +fn preferred_addrs_are_sorted_and_stable() { + let a = preferred_ipv4_ifaces(&[], &[]); + let b = preferred_ipv4_ifaces(&[], &[]); + assert_eq!(a, b, "IPv4 iface list must be deterministic"); + let v6a = preferred_ipv6_addrs(&[], &[]); + let v6b = preferred_ipv6_addrs(&[], &[]); + assert_eq!(v6a, v6b, "IPv6 addr list must be deterministic"); + for window in a.windows(2) { + assert!( + (&window[0].iface, &window[0].addr) <= (&window[1].iface, &window[1].addr), + "IPv4 list not sorted: {a:?}" + ); + } +} diff --git a/tests/run_test.rs b/tests/run_test.rs index 4212325..1a8f515 100644 --- a/tests/run_test.rs +++ b/tests/run_test.rs @@ -1,5 +1,12 @@ use microdns::bigfred_watch::{instance_name, DccBusList, Program, Request}; -use microdns::run::{append_station_ads, BeaconWant, DesiredAds}; +use microdns::config::ServiceEntry; +use microdns::mdns::MdnsPublisher; +use microdns::run::{ + announce_burst_delays, append_station_ads, bigfred_backoff_ms, plan_reconcile, BeaconWant, + DesiredAds, ReconcileAction, +}; +use std::collections::HashMap; +use std::time::Duration; fn program_running_wit() -> Program { Program { @@ -124,3 +131,84 @@ fn append_station_skips_stopped_and_disabled() { append_station_ads(&mut desired, &no_wit, true, None); assert!(desired.dynamic.is_empty()); } + +#[test] +fn plan_reconcile_refresh_does_not_drop() { + let key = MdnsPublisher::fullname("bigfred", "_http._tcp"); + let entry = ServiceEntry { + name: "bigfred".into(), + type_: "_http._tcp".into(), + protocol: "tcp".into(), + port: 8080, + host: Some("bigfred".into()), + txt: None, + }; + let mut desired = HashMap::new(); + desired.insert(key.clone(), entry.clone()); + let mut registered = HashMap::new(); + registered.insert(key.clone(), entry); + + let same = plan_reconcile(&desired, ®istered, false); + assert!(same.is_empty(), "unchanged set must not unregister: {same:?}"); + + let refresh = plan_reconcile(&desired, ®istered, true); + assert_eq!(refresh, vec![ReconcileAction::Refresh(key.clone())]); + assert!( + !refresh + .iter() + .any(|a| matches!(a, ReconcileAction::Drop(_))), + "refresh must not emit goodbye/drop" + ); +} + +#[test] +fn plan_reconcile_add_and_drop() { + let keep = MdnsPublisher::fullname("keep", "_http._tcp"); + let gone = MdnsPublisher::fullname("gone", "_http._tcp"); + let newbie = MdnsPublisher::fullname("new", "_http._tcp"); + let entry = |name: &str| ServiceEntry { + name: name.into(), + type_: "_http._tcp".into(), + protocol: "tcp".into(), + port: 80, + host: None, + txt: None, + }; + let mut desired = HashMap::new(); + desired.insert(keep.clone(), entry("keep")); + desired.insert(newbie.clone(), entry("new")); + let mut registered = HashMap::new(); + registered.insert(keep.clone(), entry("keep")); + registered.insert(gone.clone(), entry("gone")); + + let actions = plan_reconcile(&desired, ®istered, false); + assert_eq!( + actions, + vec![ + ReconcileAction::Add(newbie), + ReconcileAction::Drop(gone), + ] + ); +} + +#[test] +fn announce_burst_is_powers_of_two() { + assert_eq!( + announce_burst_delays(4), + vec![ + Duration::from_secs(1), + Duration::from_secs(2), + Duration::from_secs(4), + Duration::from_secs(8), + ] + ); + assert!(announce_burst_delays(0).is_empty()); +} + +#[test] +fn bigfred_backoff_grows_to_cap() { + assert_eq!(bigfred_backoff_ms(0, 45_000), 2_000); + assert_eq!(bigfred_backoff_ms(1, 45_000), 4_000); + assert_eq!(bigfred_backoff_ms(2, 45_000), 8_000); + assert_eq!(bigfred_backoff_ms(10, 45_000), 45_000); +} diff --git a/tests/selfcheck_test.rs b/tests/selfcheck_test.rs new file mode 100644 index 0000000..f7c2bb9 --- /dev/null +++ b/tests/selfcheck_test.rs @@ -0,0 +1,63 @@ +use std::net::Ipv4Addr; +use std::time::{Duration, Instant}; + +use microdns::legacy_unicast::IfaceAddr4; +use microdns::mdns::AnnounceLog; +use microdns::selfcheck::{ + missing_mdns_membership, parse_igmp, stale_announces, MDNS_GROUP_V4, +}; + +const SAMPLE_IGMP: &str = "\ +Idx\tDevice : Count Querier\tGroup Users Timer Reporter +1\tlo : 1 V3 +\t\t\t\t010000E0 1 0:00000000\t0 +2\teth0 : 2 V3 +\t\t\t\tFB0000E0 1 0:00000000\t0 +\t\t\t\t010000E0 1 0:00000000\t0 +3\twlan0 : 1 V3 +\t\t\t\t010000E0 1 0:00000000\t0 +"; + +#[test] +fn parse_igmp_finds_mdns_group_on_eth0() { + let groups = parse_igmp(SAMPLE_IGMP); + assert!(groups["eth0"].contains(&MDNS_GROUP_V4)); + assert!(!groups["wlan0"].contains(&MDNS_GROUP_V4)); + assert!(!groups["lo"].contains(&MDNS_GROUP_V4)); +} + +#[test] +fn missing_membership_lists_ifaces_without_group() { + let groups = parse_igmp(SAMPLE_IGMP); + let want = vec![ + IfaceAddr4 { + iface: "eth0".into(), + addr: Ipv4Addr::new(192, 168, 0, 1), + mask: Ipv4Addr::new(255, 255, 255, 0), + ifindex: 2, + }, + IfaceAddr4 { + iface: "wlan0".into(), + addr: Ipv4Addr::new(192, 168, 1, 1), + mask: Ipv4Addr::new(255, 255, 255, 0), + ifindex: 3, + }, + ]; + assert_eq!( + missing_mdns_membership(&groups, &want), + vec!["wlan0".to_string()] + ); +} + +#[test] +fn stale_announces_when_missing_or_old() { + let log = AnnounceLog::new(); + log.record("fresh._http._tcp.local."); + let now = Instant::now(); + let expected = vec![ + "fresh._http._tcp.local.".into(), + "gone._http._tcp.local.".into(), + ]; + let stale = stale_announces(&expected, &log, Duration::from_secs(60), now); + assert_eq!(stale, vec!["gone._http._tcp.local.".to_string()]); +}