From 797ce82781c8bcafca62ec06335f18a4ff3bcd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:39:00 +0200 Subject: [PATCH 1/3] feat(mdns): skip wlan* interfaces On the BigFred hub the on-board WiFi radio is an exclusive, on-demand resource used only by wireless-programmer to associate to a device config AP (e.g. a NewHeiko WiFred) for the duration of a programming job. It never carries hub services, so advertising mDNS on it would leak bigfred.local / dcc-bus beacons onto a customer's device config network. Add wlan* to the hardcoded interface skip list (case-insensitive, matching the existing docker/veth/br-* convention). Co-authored-by: Cursor --- src/mdns.rs | 10 +++++++++- tests/mdns_test.rs | 3 ++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/mdns.rs b/src/mdns.rs index e19f4f5..bf58b5e 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -255,7 +255,14 @@ fn is_ipv6_link_local(ip: &Ipv6Addr) -> bool { octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 } -/// Whether to skip an interface by name (docker/veth/bridge). +/// Whether to skip an interface by name (docker/veth/bridge/wlan). +/// +/// `wlan*` is skipped because, on the BigFred hub, the on-board WiFi radio is +/// an exclusive, on-demand resource used only by `wireless-programmer` to +/// associate to a device config AP (e.g. a NewHeiko WiFred) for the duration +/// of a programming job. It never carries hub services, so advertising mDNS +/// on it would leak `bigfred.local` / dcc-bus beacons onto a customer's +/// device config network. #[must_use] pub fn should_skip_iface(name: &str) -> bool { let n = name.to_ascii_lowercase(); @@ -267,6 +274,7 @@ pub fn should_skip_iface(name: &str) -> bool { || n == "cni0" || n.starts_with("flannel") || n.starts_with("virbr") + || n.starts_with("wlan") } #[derive(Debug)] diff --git a/tests/mdns_test.rs b/tests/mdns_test.rs index 4b92c88..eb53f83 100644 --- a/tests/mdns_test.rs +++ b/tests/mdns_test.rs @@ -28,7 +28,8 @@ fn skip_virtual_ifaces() { 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("wlan0")); + assert!(should_skip_iface("WLAN0")); assert!(!should_skip_iface("enp1s0")); } From 452fb4e9627b0b837c5eeebf85dca80593c6bd1a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Sun, 9 Aug 2026 23:00:05 +0200 Subject: [PATCH 2/3] feat(mdns): make the interface skip-list configurable via skipInterfaces Replace the hardcoded wlan* skip with a configurable skipInterfaces list on Config (JSON, default empty). The built-in docker/veth/br-*/cni/ flannel/virbr list is always applied; skipInterfaces adds extra case-insensitive name-prefix matches on top. By default wlan* is NOT skipped, so mDNS advertises on WiFi on a generic or laptop install. Operators who reserve the WiFi radio for another purpose (e.g. the BigFred hub, where wireless-programmer owns the radio) add ["wlan"] to skipInterfaces so mDNS does not leak bigfred.local / dcc-bus beacons onto a device config network. Threaded &[String] through should_skip_iface, preferred_ipv4_*, preferred_ipv6_addrs, preferred_iface_indexes, MdnsPublisher::register, and the AnswerSet/DesiredAds so the configured list flows from Config through run.rs and legacy_unicast.rs (and picks up hot-reloads). Co-authored-by: Cursor --- README.md | 10 +++++- src/config.rs | 8 +++++ src/legacy_unicast.rs | 11 +++++-- src/mdns.rs | 62 ++++++++++++++++++++++-------------- src/run.rs | 15 ++++++--- tests/legacy_unicast_test.rs | 2 ++ tests/mdns_test.rs | 26 +++++++++++---- tests/run_test.rs | 1 + 8 files changed, 96 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index f84a729..5fbd115 100644 --- a/README.md +++ b/README.md @@ -54,12 +54,20 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. "procMs": 2000, "mdnsMs": 3000, "ifaceMs": 5000 - } + }, + "skipInterfaces": [] } ``` - `dccBus.enabled` (default `false`): when false, only static `services[]` are advertised. - 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/ + flannel/virbr list. Empty by default so mDNS advertises on every usable + interface, including `wlan*` (a laptop on WiFi). Add `["wlan"]` on a hub + that reserves the WiFi radio for another purpose (e.g. the BigFred hub, + where `wireless-programmer` owns the radio) so mDNS does not leak + `bigfred.local` / dcc-bus beacons onto a device config network. ## Run diff --git a/src/config.rs b/src/config.rs index ef7198c..f58f905 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,13 @@ pub struct Config { pub dcc_bus: DccBusConfig, #[serde(default)] pub retry: RetryConfig, + /// 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 + /// who reserve the WiFi radio for another purpose (e.g. the BigFred hub, + /// where `wireless-programmer` owns the radio) add `["wlan"]` here. + #[serde(default)] + pub skip_interfaces: Vec, } impl Default for Config { @@ -145,6 +152,7 @@ impl Default for Config { }], dcc_bus: DccBusConfig::default(), retry: RetryConfig::default(), + skip_interfaces: Vec::new(), } } } diff --git a/src/legacy_unicast.rs b/src/legacy_unicast.rs index bc9b580..cd3c481 100644 --- a/src/legacy_unicast.rs +++ b/src/legacy_unicast.rs @@ -29,6 +29,8 @@ pub struct AnswerSet { pub v4: Vec<(Ipv4Addr, Ipv4Addr)>, /// Preferred global/ULA IPv6 addresses. pub v6: Vec, + /// Configured extra interface-name prefixes to skip (mirrors `Config`). + pub skip_interfaces: Vec, } pub const MDNS_PORT: u16 = 5353; @@ -243,8 +245,13 @@ fn refresh_memberships( 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(); + // yet (v6-only / A-over-v6 queries). Use the configured skip list so the + // indexes stay consistent with the v4/v6 address selection. + let skip: Vec = state + .read() + .map(|g| g.skip_interfaces.clone()) + .unwrap_or_default(); + let mut want_idx = mdns::preferred_iface_indexes(&skip); want_idx.sort_unstable(); want_idx.dedup(); diff --git a/src/mdns.rs b/src/mdns.rs index bf58b5e..33e7c62 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -47,7 +47,12 @@ impl MdnsPublisher { } /// Register (or re-register) a service. Uses auto addresses when available. - pub fn register(&self, entry: &ServiceEntry, host_override: Option<&str>) -> Result<()> { + pub fn register( + &self, + entry: &ServiceEntry, + host_override: Option<&str>, + skip: &[String], + ) -> Result<()> { let daemon = self .daemon .as_ref() @@ -60,7 +65,7 @@ impl MdnsPublisher { .unwrap_or(&version::hostname()), ); let props = entry.txt.clone().unwrap_or_default(); - let ips = preferred_ipv4_addrs(); + let ips = preferred_ipv4_addrs(skip); let info = if ips.is_empty() { // No usable interface yet — register with addr_auto so mdns-sd @@ -172,8 +177,8 @@ 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() +pub fn preferred_ipv4_addrs(skip: &[String]) -> Vec { + preferred_ipv4_ifaces(skip) .into_iter() .map(|(ip, _mask)| ip) .collect() @@ -181,13 +186,13 @@ pub fn preferred_ipv4_addrs() -> Vec { /// Preferred IPv4 addresses with netmasks (for same-subnet reply selection). #[must_use] -pub fn preferred_ipv4_ifaces() -> Vec<(Ipv4Addr, Ipv4Addr)> { +pub fn preferred_ipv4_ifaces(skip: &[String]) -> Vec<(Ipv4Addr, Ipv4Addr)> { let mut addrs = Vec::new(); let Ok(ifaces) = list_interfaces() else { return addrs; }; for iface in ifaces { - if should_skip_iface(&iface.name) { + if should_skip_iface(&iface.name, skip) { continue; } if !iface.is_up || iface.is_loopback { @@ -204,13 +209,13 @@ pub fn preferred_ipv4_ifaces() -> Vec<(Ipv4Addr, Ipv4Addr)> { /// Preferred global/ULA IPv6 addresses (no loopback, unspecified, or link-local). #[must_use] -pub fn preferred_ipv6_addrs() -> Vec { +pub fn preferred_ipv6_addrs(skip: &[String]) -> Vec { let mut addrs = Vec::new(); let Ok(ifaces) = list_interfaces() else { return addrs; }; for iface in ifaces { - if should_skip_iface(&iface.name) { + if should_skip_iface(&iface.name, skip) { continue; } if !iface.is_up || iface.is_loopback { @@ -228,13 +233,13 @@ pub fn preferred_ipv6_addrs() -> Vec { /// Interface index for multicast group joins (IPv6). #[must_use] -pub fn preferred_iface_indexes() -> Vec { +pub fn preferred_iface_indexes(skip: &[String]) -> Vec { let mut out = Vec::new(); let Ok(ifaces) = list_interfaces() else { return out; }; for iface in ifaces { - if should_skip_iface(&iface.name) { + if should_skip_iface(&iface.name, skip) { continue; } if !iface.is_up || iface.is_loopback { @@ -255,18 +260,18 @@ fn is_ipv6_link_local(ip: &Ipv6Addr) -> bool { octets[0] == 0xfe && (octets[1] & 0xc0) == 0x80 } -/// Whether to skip an interface by name (docker/veth/bridge/wlan). +/// Whether to skip an interface by name. /// -/// `wlan*` is skipped because, on the BigFred hub, the on-board WiFi radio is -/// an exclusive, on-demand resource used only by `wireless-programmer` to -/// associate to a device config AP (e.g. a NewHeiko WiFred) for the duration -/// of a programming job. It never carries hub services, so advertising mDNS -/// on it would leak `bigfred.local` / dcc-bus beacons onto a customer's -/// device config network. +/// Always skips the built-in container/virtual bridge interfaces +/// (docker/veth/br-*/cni/flannel/virbr). The `skip` list adds extra +/// case-insensitive **prefix** matches from configuration (e.g. `["wlan"]` on +/// the BigFred hub, where `wireless-programmer` owns the WiFi radio and mDNS +/// must not leak onto a device config network). By default `wlan*` is NOT +/// skipped, so mDNS advertises on WiFi on a generic/laptop install. #[must_use] -pub fn should_skip_iface(name: &str) -> bool { +pub fn should_skip_iface(name: &str, skip: &[String]) -> bool { let n = name.to_ascii_lowercase(); - n == "docker0" + if n == "docker0" || n.starts_with("docker") || n.starts_with("veth") || n.starts_with("br-") @@ -274,7 +279,13 @@ pub fn should_skip_iface(name: &str) -> bool { || n == "cni0" || n.starts_with("flannel") || n.starts_with("virbr") - || n.starts_with("wlan") + { + return true; + } + skip.iter().any(|p| { + let p = p.trim().to_ascii_lowercase(); + !p.is_empty() && n.starts_with(&p) + }) } #[derive(Debug)] @@ -398,12 +409,15 @@ pub fn dcc_service_entry( /// Check whether any preferred interface currently has an IPv4 address. #[must_use] -pub fn has_usable_iface() -> bool { - !preferred_ipv4_addrs().is_empty() +pub fn has_usable_iface(skip: &[String]) -> bool { + !preferred_ipv4_addrs(skip).is_empty() } /// Return first preferred IP as [`IpAddr`], if any. #[must_use] -pub fn primary_ip() -> Option { - preferred_ipv4_addrs().into_iter().next().map(IpAddr::V4) +pub fn primary_ip(skip: &[String]) -> Option { + preferred_ipv4_addrs(skip) + .into_iter() + .next() + .map(IpAddr::V4) } diff --git a/src/run.rs b/src/run.rs index 16a4234..e949498 100644 --- a/src/run.rs +++ b/src/run.rs @@ -85,6 +85,7 @@ pub struct DesiredAds { pub dynamic: Vec, pub beacons: Vec, pub ips: Vec, + pub skip_interfaces: Vec, } struct ActiveBeacon { @@ -145,6 +146,7 @@ pub fn run(config_path: &Path) -> Result<()> { dynamic: Vec::new(), beacons: Vec::new(), ips: Vec::new(), + skip_interfaces: Vec::new(), }; let mut registered: HashMap = HashMap::new(); @@ -166,7 +168,7 @@ pub fn run(config_path: &Path) -> Result<()> { } } - let ips = mdns::preferred_ipv4_addrs(); + let ips = mdns::preferred_ipv4_addrs(&cfg.skip_interfaces); if ips.is_empty() { iface_thr.fail( "network interface", @@ -187,8 +189,9 @@ pub fn run(config_path: &Path) -> Result<()> { } let next = AnswerSet { hosts, - v4: mdns::preferred_ipv4_ifaces(), - v6: mdns::preferred_ipv6_addrs(), + v4: mdns::preferred_ipv4_ifaces(&cfg.skip_interfaces), + v6: mdns::preferred_ipv6_addrs(&cfg.skip_interfaces), + skip_interfaces: cfg.skip_interfaces.clone(), }; if let Ok(mut w) = answer_set.write() { if *w != next { @@ -202,6 +205,7 @@ pub fn run(config_path: &Path) -> Result<()> { dynamic: Vec::new(), beacons: Vec::new(), ips: ips.clone(), + skip_interfaces: cfg.skip_interfaces.clone(), }; if cfg.dcc_bus.enabled { @@ -401,6 +405,7 @@ fn reconcile( ips_changed: bool, ) -> Result<()> { let pub_guard = publisher.lock().unwrap(); + let skip = &desired.skip_interfaces; let mut desired_map: HashMap = HashMap::new(); for svc in &desired.static_services { @@ -417,7 +422,7 @@ fn reconcile( if registered.contains_key(key) { continue; } - pub_guard.register(entry, entry.host.as_deref())?; + pub_guard.register(entry, entry.host.as_deref(), skip)?; registered.insert(key.clone(), entry.clone()); } @@ -430,7 +435,7 @@ fn reconcile( continue; } let _ = pub_guard.unregister(key); - pub_guard.register(entry, entry.host.as_deref())?; + pub_guard.register(entry, entry.host.as_deref(), skip)?; registered.insert(key.clone(), entry.clone()); } diff --git a/tests/legacy_unicast_test.rs b/tests/legacy_unicast_test.rs index 4dc2ef6..d022733 100644 --- a/tests/legacy_unicast_test.rs +++ b/tests/legacy_unicast_test.rs @@ -49,6 +49,7 @@ fn sample_answers() -> AnswerSet { (Ipv4Addr::new(10, 0, 0, 5), Ipv4Addr::new(255, 0, 0, 0)), ], v6: vec![Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 1)], + skip_interfaces: Vec::new(), } } @@ -227,6 +228,7 @@ fn spawn_echoes_transaction_id_on_ephemeral_port() { hosts: vec!["bigfred.local.".into()], v4: vec![(Ipv4Addr::new(127, 0, 0, 1), Ipv4Addr::new(255, 0, 0, 0))], v6: Vec::new(), + skip_interfaces: Vec::new(), })); let stop = Arc::new(AtomicBool::new(false)); if spawn(Arc::clone(&answers), port, Arc::clone(&stop)).is_err() { diff --git a/tests/mdns_test.rs b/tests/mdns_test.rs index eb53f83..b87a164 100644 --- a/tests/mdns_test.rs +++ b/tests/mdns_test.rs @@ -24,13 +24,25 @@ fn normalize_host() { #[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("WLAN0")); - assert!(!should_skip_iface("enp1s0")); + // Built-in virtual/container interfaces are always skipped. + 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("enp1s0", &[])); + // wlan* is NOT skipped by default (mDNS advertises on WiFi on a laptop). + assert!(!should_skip_iface("wlan0", &[])); + assert!(!should_skip_iface("WLAN0", &[])); + // ...but is skipped when configured (e.g. the BigFred hub reserves the + // radio for wireless-programmer). Matching is by name prefix. + assert!(should_skip_iface("wlan0", &["wlan".into()])); + assert!(should_skip_iface("WLAN0", &["WLAN".into()])); + assert!(should_skip_iface("wlan0", &["wlan".into(), "wlp".into()])); + assert!(should_skip_iface("wlp3s0", &["wlan".into(), "wlp".into()])); + // An empty/blank entry in the skip list matches nothing. + assert!(!should_skip_iface("wlan0", &["".into()])); + assert!(!should_skip_iface("eth0", &["wlan".into()])); + assert!(!should_skip_iface("wlp3s0", &["wlan".into()])); } #[test] diff --git a/tests/run_test.rs b/tests/run_test.rs index 84eece7..3be33af 100644 --- a/tests/run_test.rs +++ b/tests/run_test.rs @@ -28,6 +28,7 @@ fn append_station_builds_identity() { dynamic: Vec::new(), beacons: Vec::new(), ips: Vec::new(), + skip_interfaces: Vec::new(), }; let mut ports = ListenPorts { tcp: HashSet::new(), From 27a434ae0fe294f71f3c0c29ed3d2895f1771f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Damian=20K=C4=99ska?= <372403+keskad@users.noreply.github.com> Date: Mon, 10 Aug 2026 09:27:08 +0200 Subject: [PATCH 3/3] refactor: address review on configurable skipInterfaces - should_skip_iface no longer allocates: the name is already lowercased, so compare its head with eq_ignore_ascii_case instead of building a String per skip entry per interface on every poll. - refresh_memberships takes one read lock, so the addresses and the skip list come from the same generation of the AnswerSet. - The "no UP non-loopback IPv4" diagnostic names the configured skips. On a hub with skipInterfaces set and Ethernet unplugged this is the message an operator sees, and it previously hid the reason entirely. - Pin the JSON key: bigfred-os ships "skipInterfaces" and unknown fields are ignored, so a rename would silently turn the hub's opt-out into a no-op. - README: spell out that entries are name prefixes, since a short entry would take eth0/enp1s0 with it. Co-authored-by: Cursor --- README.md | 3 +++ src/legacy_unicast.rs | 15 +++++++++------ src/mdns.rs | 9 +++++++-- src/run.rs | 13 +++++++++---- tests/config_test.rs | 23 +++++++++++++++++++++++ 5 files changed, 51 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 5fbd115..6e4635d 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,9 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing. that reserves the WiFi radio for another purpose (e.g. the BigFred hub, where `wireless-programmer` owns the radio) so mDNS does not leak `bigfred.local` / dcc-bus beacons onto a device config network. + Entries are name **prefixes**, not globs or exact names: `"wlan"` covers + `wlan0`/`wlan1` but not `wlp3s0`, and a short entry like `"e"` would take + `eth0` and `enp1s0` with it, leaving nothing to advertise on. ## Run diff --git a/src/legacy_unicast.rs b/src/legacy_unicast.rs index cd3c481..10823a8 100644 --- a/src/legacy_unicast.rs +++ b/src/legacy_unicast.rs @@ -238,19 +238,22 @@ fn refresh_memberships( joined_v4: &mut Vec, joined_ifindexes: &mut Vec, ) { - let mut want_v4: Vec = state + // One read, so the addresses and the skip list are guaranteed to come from + // the same generation of the AnswerSet. + let (mut want_v4, skip): (Vec, Vec) = state .read() - .map(|g| g.v4.iter().map(|(ip, _)| *ip).collect()) + .map(|g| { + ( + g.v4.iter().map(|(ip, _)| *ip).collect(), + g.skip_interfaces.clone(), + ) + }) .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). Use the configured skip list so the // indexes stay consistent with the v4/v6 address selection. - let skip: Vec = state - .read() - .map(|g| g.skip_interfaces.clone()) - .unwrap_or_default(); let mut want_idx = mdns::preferred_iface_indexes(&skip); want_idx.sort_unstable(); want_idx.dedup(); diff --git a/src/mdns.rs b/src/mdns.rs index 33e7c62..df9764d 100644 --- a/src/mdns.rs +++ b/src/mdns.rs @@ -282,9 +282,14 @@ pub fn should_skip_iface(name: &str, skip: &[String]) -> bool { { return true; } + // Prefix match without allocating: `n` is already lowercased, so compare + // its head case-insensitively. `get` rather than slicing, so an entry whose + // byte length lands mid-char cannot panic. skip.iter().any(|p| { - let p = p.trim().to_ascii_lowercase(); - !p.is_empty() && n.starts_with(&p) + let p = p.trim(); + !p.is_empty() + && n.get(..p.len()) + .is_some_and(|head| head.eq_ignore_ascii_case(p)) }) } diff --git a/src/run.rs b/src/run.rs index e949498..8e5c1de 100644 --- a/src/run.rs +++ b/src/run.rs @@ -170,10 +170,15 @@ pub fn run(config_path: &Path) -> Result<()> { let ips = mdns::preferred_ipv4_addrs(&cfg.skip_interfaces); if ips.is_empty() { - iface_thr.fail( - "network interface", - &"no UP non-loopback IPv4 (skipping docker/veth/br-*)", - ); + // Name the configured skips too: on a hub with skipInterfaces set, + // this is the message an operator sees when the only addressed + // interface is the one they told us to ignore. + let mut why = String::from("no UP non-loopback IPv4 (skipping docker/veth/br-*"); + if !cfg.skip_interfaces.is_empty() { + why.push_str(&format!(", configured {:?}", cfg.skip_interfaces)); + } + why.push(')'); + iface_thr.fail("network interface", &why); } else { iface_thr.ok("network interface"); } diff --git a/tests/config_test.rs b/tests/config_test.rs index c0f122f..64075d5 100644 --- a/tests/config_test.rs +++ b/tests/config_test.rs @@ -47,6 +47,29 @@ fn type_field_renames() { assert_eq!(cfg.services[0].type_, "_http._tcp"); } +/// The BigFred OS overlay ships `"skipInterfaces": ["wlan"]`, and nothing else +/// guards that JSON key: a rename would silently turn the hub's opt-out into a +/// no-op, since unknown fields are ignored. +#[test] +fn skip_interfaces_binds_to_the_camel_case_key() { + let json = r#"{ + "services": [], + "skipInterfaces": ["wlan"] + }"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert_eq!(cfg.skip_interfaces, vec!["wlan".to_string()]); +} + +#[test] +fn skip_interfaces_defaults_to_empty_when_absent() { + let json = r#"{"services": []}"#; + let cfg: Config = serde_json::from_str(json).unwrap(); + assert!( + cfg.skip_interfaces.is_empty(), + "an existing config file must keep advertising on wlan*" + ); +} + #[test] fn validate_rejects_bad_dns_sd_type() { let mut cfg = Config::default();