Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,23 @@ 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.
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

Expand Down
8 changes: 8 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>,
}

impl Default for Config {
Expand All @@ -145,6 +152,7 @@ impl Default for Config {
}],
dcc_bus: DccBusConfig::default(),
retry: RetryConfig::default(),
skip_interfaces: Vec::new(),
}
}
}
Expand Down
18 changes: 14 additions & 4 deletions src/legacy_unicast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ pub struct AnswerSet {
pub v4: Vec<(Ipv4Addr, Ipv4Addr)>,
/// Preferred global/ULA IPv6 addresses.
pub v6: Vec<Ipv6Addr>,
/// Configured extra interface-name prefixes to skip (mirrors `Config`).
pub skip_interfaces: Vec<String>,
}

pub const MDNS_PORT: u16 = 5353;
Expand Down Expand Up @@ -236,15 +238,23 @@ fn refresh_memberships(
joined_v4: &mut Vec<Ipv4Addr>,
joined_ifindexes: &mut Vec<u32>,
) {
let mut want_v4: Vec<Ipv4Addr> = 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<Ipv4Addr>, Vec<String>) = 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).
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 mut want_idx = mdns::preferred_iface_indexes(&skip);
want_idx.sort_unstable();
want_idx.dedup();

Expand Down
61 changes: 44 additions & 17 deletions src/mdns.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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
Expand Down Expand Up @@ -172,22 +177,22 @@ 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<Ipv4Addr> {
preferred_ipv4_ifaces()
pub fn preferred_ipv4_addrs(skip: &[String]) -> Vec<Ipv4Addr> {
preferred_ipv4_ifaces(skip)
.into_iter()
.map(|(ip, _mask)| ip)
.collect()
}

/// Preferred IPv4 addresses with netmasks (for same-subnet reply selection).
#[must_use]
pub fn preferred_ipv4_ifaces() -> Vec<(Ipv4Addr, Ipv4Addr)> {
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 {
Expand All @@ -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<Ipv6Addr> {
pub fn preferred_ipv6_addrs(skip: &[String]) -> Vec<Ipv6Addr> {
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 {
Expand All @@ -228,13 +233,13 @@ pub fn preferred_ipv6_addrs() -> Vec<Ipv6Addr> {

/// Interface index for multicast group joins (IPv6).
#[must_use]
pub fn preferred_iface_indexes() -> Vec<u32> {
pub fn preferred_iface_indexes(skip: &[String]) -> Vec<u32> {
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 {
Expand All @@ -255,18 +260,37 @@ 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.
///
/// 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-")
|| n.starts_with("br0")
|| n == "cni0"
|| n.starts_with("flannel")
|| n.starts_with("virbr")
{
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();
!p.is_empty()
&& n.get(..p.len())
.is_some_and(|head| head.eq_ignore_ascii_case(p))
})
}

#[derive(Debug)]
Expand Down Expand Up @@ -390,12 +414,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<IpAddr> {
preferred_ipv4_addrs().into_iter().next().map(IpAddr::V4)
pub fn primary_ip(skip: &[String]) -> Option<IpAddr> {
preferred_ipv4_addrs(skip)
.into_iter()
.next()
.map(IpAddr::V4)
}
28 changes: 19 additions & 9 deletions src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ pub struct DesiredAds {
pub dynamic: Vec<DynAd>,
pub beacons: Vec<BeaconWant>,
pub ips: Vec<Ipv4Addr>,
pub skip_interfaces: Vec<String>,
}

struct ActiveBeacon {
Expand Down Expand Up @@ -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<String, ServiceEntry> = HashMap::new();

Expand All @@ -166,12 +168,17 @@ 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",
&"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");
}
Expand All @@ -187,8 +194,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 {
Expand All @@ -202,6 +210,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 {
Expand Down Expand Up @@ -401,6 +410,7 @@ fn reconcile(
ips_changed: bool,
) -> Result<()> {
let pub_guard = publisher.lock().unwrap();
let skip = &desired.skip_interfaces;

let mut desired_map: HashMap<String, ServiceEntry> = HashMap::new();
for svc in &desired.static_services {
Expand All @@ -417,7 +427,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());
}

Expand All @@ -430,7 +440,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());
}

Expand Down
23 changes: 23 additions & 0 deletions tests/config_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions tests/legacy_unicast_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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() {
Expand Down
25 changes: 19 additions & 6 deletions tests/mdns_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +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("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]
Expand Down
1 change: 1 addition & 0 deletions tests/run_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading