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
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,12 @@ jobs:
binaries: '[{"name":"microdns","dist":"microdns-linux"}]'
build_env: |
MICRODNS_GIT_COMMIT=${{ github.sha }}

# Guidelines §17.2: optimized tests with debug-assertions / overflow-checks.
release-assertions:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- name: cargo test --profile release-assertions
run: cargo test --profile release-assertions --locked
16 changes: 14 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
Micro-daemon that advertises mDNS/DNS-SD services for BigFred OS.

Quietly retries when interfaces, the microinit control socket, or dcc-bus are
unavailable. Always starts successfully.
unavailable. Always starts successfully. Survives network drop/return and
interface add/remove via rtnetlink (with polling fallback). Resolves hostnames
per receiving interface so a WiFi client gets the WiFi address.

## Features

Expand Down Expand Up @@ -55,7 +57,8 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing.
"mdnsMs": 3000,
"ifaceMs": 5000
},
"skipInterfaces": []
"skipInterfaces": [],
"interfaces": []
}
```

Expand All @@ -71,6 +74,15 @@ Default path: `$DATA_DIR/etc/microdns.json`. Created with defaults if missing.
Entries are name **prefixes**, not globs or exact names: `"wlan"` covers
`wlan0`/`wlan1` but not `wlp3s0`, and a short entry like `"e"` would take
`eth0` and `enp1s0` with it, leaving nothing to advertise on.
- `interfaces` (default `[]`): optional allowlist of interface-name prefixes
(same prefix rules as `skipInterfaces`). Empty means use every usable
interface that is not skipped. When set (e.g. `["eth","enp"]`), only
matching interfaces are used; a listed interface that disappears logs a
warning and is retried — it does not crash the daemon.
- Hostname A/AAAA answers (`bigfred.local`) are selected **per receiving
interface** (via `IP_PKTINFO`): a client querying on WiFi gets the WiFi
address, not the Ethernet one. Interface add/remove/address changes are
detected via rtnetlink with polling fallback.

## Run

Expand Down
27 changes: 27 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,13 @@ pub struct Config {
/// where `wireless-programmer` owns the radio) add `["wlan"]` here.
#[serde(default)]
pub skip_interfaces: Vec<String>,
/// Optional allowlist of interface name prefixes (case-insensitive).
/// Empty (default) means advertise on every usable interface that is not
/// skipped. When non-empty, only matching interfaces are used; a listed
/// interface that is temporarily missing logs a warning and is retried —
/// it does not crash the daemon.
#[serde(default)]
pub interfaces: Vec<String>,
}

impl Default for Config {
Expand All @@ -153,6 +160,7 @@ impl Default for Config {
dcc_bus: DccBusConfig::default(),
retry: RetryConfig::default(),
skip_interfaces: Vec::new(),
interfaces: Vec::new(),
}
}
}
Expand Down Expand Up @@ -189,10 +197,29 @@ impl Config {
));
}
}
validate_iface_prefixes("skipInterfaces", &self.skip_interfaces)?;
validate_iface_prefixes("interfaces", &self.interfaces)?;
Ok(())
}
}

fn validate_iface_prefixes(field: &str, entries: &[String]) -> Result<()> {
let mut seen = std::collections::HashSet::new();
for entry in entries {
let trimmed = entry.trim();
if trimmed.is_empty() {
return Err(Error::Config(format!("{field}: entries must not be empty")));
}
let key = trimmed.to_ascii_lowercase();
if !seen.insert(key) {
return Err(Error::Config(format!(
"{field}: duplicate prefix '{trimmed}'"
)));
}
}
Ok(())
}

/// Accept `_name._tcp` / `_name._udp`, optionally with a `.local` suffix.
fn validate_service_type(name: &str, type_: &str) -> Result<()> {
if type_.is_empty() {
Expand Down
170 changes: 170 additions & 0 deletions src/iface_watch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//! Linux rtnetlink watcher for interface / address churn.
//!
//! Subscribes to `RTMGRP_LINK | RTMGRP_IPV4_IFADDR | RTMGRP_IPV6_IFADDR` and
//! signals [`IfaceChange`] whenever anything readable arrives. Payload parsing
//! is intentionally skipped — the main loop re-scans interfaces on each signal.
//! Polling remains the fallback when netlink is unavailable.
//!
//! The signal channel is bounded ([`IFACE_CHANGE_CAPACITY`]). Overflow drops
//! events: the signal is idempotent ("something changed"), so losing duplicates
//! under burst is safe and keeps memory bounded (§1.3 / §8.5).

use std::io::ErrorKind;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Receiver, RecvTimeoutError, SyncSender, TrySendError};
use std::sync::Arc;
use std::thread;
use std::time::Duration;

use crate::error::{Error, Result};
use crate::sys;

const BIND_RETRY: Duration = Duration::from_secs(3);
const RECV_TIMEOUT: Duration = Duration::from_millis(500);

/// Bound on coalesced iface-change signals waiting for the main loop.
pub(crate) const IFACE_CHANGE_CAPACITY: usize = 32;

/// Signal that interface or address state may have changed.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct IfaceChange;

/// Spawn a netlink watcher thread. Returns a receiver of change signals and a
/// stop flag. Bind failures are retried quietly; they never crash the daemon.
pub(crate) fn spawn() -> Result<(Receiver<IfaceChange>, Arc<AtomicBool>)> {
let (tx, rx) = mpsc::sync_channel(IFACE_CHANGE_CAPACITY);
let stop = Arc::new(AtomicBool::new(false));
let stop_thr = Arc::clone(&stop);

thread::Builder::new()
.name("iface-watch".into())
.spawn(move || {
if let Err(e) = watch_loop(tx, stop_thr) {
log::warn!("iface watcher stopped: {e}");
}
})
.map_err(|e| Error::Other(format!("spawn iface-watch: {e}")))?;

Ok((rx, stop))
}

fn watch_loop(tx: SyncSender<IfaceChange>, stop: Arc<AtomicBool>) -> Result<()> {
let mut warned_bind = false;

while !stop.load(Ordering::SeqCst) {
let sock = match sys::open_rtnetlink(RECV_TIMEOUT) {
Ok(s) => {
if warned_bind {
log::info!("iface watcher: netlink socket recovered");
warned_bind = false;
} else {
log::info!("iface watcher: listening on rtnetlink");
}
s
}
Err(e) => {
if !warned_bind {
log::warn!(
"iface watcher: netlink bind failed: {e}; retrying (polling fallback active)"
);
warned_bind = true;
} else {
log::debug!("iface watcher: netlink bind failed: {e}");
}
thread::sleep(BIND_RETRY);
continue;
}
};

while !stop.load(Ordering::SeqCst) {
match sys::recv_netlink_any(&sock) {
Ok(true) => match tx.try_send(IfaceChange) {
Ok(()) => {}
// Full: at least one change is already queued; drop extras.
Err(TrySendError::Full(_)) => {}
Err(TrySendError::Disconnected(_)) => return Ok(()),
},
Ok(false) => {} // timeout
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) => {
log::warn!("iface watcher: recv failed: {e}; rebinding");
break;
}
}
}
}
Ok(())
}

/// Drain any pending iface-change signals (for tests / main-loop coalescing).
pub(crate) fn drain(rx: &Receiver<IfaceChange>) {
while rx.try_recv().is_ok() {}
}

/// Wait up to `timeout` for an iface-change, draining coalesced extras.
pub(crate) fn recv_timeout(
rx: &Receiver<IfaceChange>,
timeout: Duration,
) -> std::result::Result<IfaceChange, RecvTimeoutError> {
let signal = rx.recv_timeout(timeout)?;
drain(rx);
Ok(signal)
}

#[cfg(test)]
mod tests {
use super::*;
use std::time::Duration;

#[test]
fn spawn_starts_and_stops() {
let (rx, stop) = spawn().expect("spawn iface watcher");
std::thread::sleep(Duration::from_millis(100));
stop.store(true, Ordering::SeqCst);
let _ = rx.recv_timeout(Duration::from_millis(200));
}

#[test]
fn iface_change_channel_is_bounded() {
// Capacity is a compile-time constant; keep the check as a const assert.
const { assert!(IFACE_CHANGE_CAPACITY > 0) };
}

#[test]
fn iface_change_on_dummy_addr_add() {
let name = format!("mdnstst{}", std::process::id() % 10000);
let add = std::process::Command::new("ip")
.args(["link", "add", &name, "type", "dummy"])
.output();
let Ok(out) = add else {
eprintln!("skip: ip not available");
return;
};
if !out.status.success() {
eprintln!(
"skip: cannot create dummy iface (need CAP_NET_ADMIN): {}",
String::from_utf8_lossy(&out.stderr)
);
return;
}

let (rx, stop) = spawn().expect("spawn");
drain(&rx);

let _ = std::process::Command::new("ip")
.args(["link", "set", &name, "up"])
.status();
let _ = std::process::Command::new("ip")
.args(["addr", "add", "192.0.2.10/32", "dev", &name])
.status();

let got = recv_timeout(&rx, Duration::from_secs(2)).is_ok();

let _ = std::process::Command::new("ip")
.args(["link", "del", &name])
.status();
stop.store(true, Ordering::SeqCst);

assert!(got, "expected IfaceChange after adding address on {name}");
}
}
Loading
Loading