From 8e0c112854c3f0512fe7928833a30049a145ce0b Mon Sep 17 00:00:00 2001 From: Arjen <18398758+Origami74@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:16:52 +0100 Subject: [PATCH] feat(config): reject impossible ethernet interface names at load MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Config::validate never inspected transports.ethernet, so an empty interface name, a name past the kernel's 15-byte limit, and two transports naming the same netdev all loaded cleanly and failed only at runtime — the first two as a permanent absence indistinguishable from an interface that has not been created yet. That indistinguishability is deliberate and worth keeping: waiting is the right answer for an interface the operator has not made yet, and the daemon cannot know which of the two it is looking at. Which is exactly why the syntactic gate earns its place. A name that is *impossible* is the one case still separable from "not there yet", and without the check a typo costs a permanently Degraded node whose only symptom is an interface that never arrives — the failure mode the presence machine exists to make legible, reintroduced one level up. Syntax only. Whether a well-formed name exists stays the binder's question, asked once a second, forever. The duplicate check is a different fault: two transports on one netdev means two sockets on the same device at the same ethertype, each receiving every frame the other does. --- src/config/mod.rs | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/src/config/mod.rs b/src/config/mod.rs index 9823afc0..67db9d3d 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1041,6 +1041,68 @@ impl Config { /// Validate cross-field configuration invariants. pub fn validate(&self) -> Result<(), ConfigError> { + self.validate_ethernet_interfaces()?; + self.validate_rendezvous() + } + + /// Reject interface names no kernel could ever hand back. + /// + /// The presence machine deliberately cannot tell a typo from an interface + /// that has not been created yet — both are simply absent, and waiting is + /// the right answer for the second. That is what makes this check worth + /// having: a name that is *impossible* is the one case still separable + /// from "not there yet", and without it a typo costs a permanently + /// `Degraded` node whose only symptom is an interface that never arrives. + /// + /// Syntax only. Whether a well-formed name exists is the binder's + /// question, asked once a second, forever. + fn validate_ethernet_interfaces(&self) -> Result<(), ConfigError> { + // Kernel limit: `IFNAMSIZ` is 16 including the terminating NUL, on + // both Linux and the BSDs. + const MAX_INTERFACE_NAME: usize = 15; + + let mut seen: std::collections::HashMap<&str, &str> = std::collections::HashMap::new(); + + for (name, cfg) in self.transports.ethernet.iter() { + let label = name.unwrap_or("ethernet"); + let iface = cfg.interface.as_str(); + + if iface.is_empty() { + return Err(ConfigError::Validation(format!( + "transport `{label}` has an empty `interface`" + ))); + } + if iface.len() > MAX_INTERFACE_NAME { + return Err(ConfigError::Validation(format!( + "transport `{label}` interface `{iface}` is {} bytes; \ + the kernel limit is {MAX_INTERFACE_NAME}, so no such \ + interface can exist", + iface.len() + ))); + } + if iface.contains('/') || iface.chars().any(char::is_whitespace) { + return Err(ConfigError::Validation(format!( + "transport `{label}` interface `{iface}` contains a \ + character no interface name may hold" + ))); + } + + // Two transports on one netdev means two sockets on the same + // device at the same ethertype, each receiving every frame the + // other does. + if let Some(prior) = seen.insert(iface, label) { + return Err(ConfigError::Validation(format!( + "transports `{prior}` and `{label}` both bind interface \ + `{iface}`" + ))); + } + } + + Ok(()) + } + + /// Cross-checks between transports, peers and the Nostr rendezvous. + fn validate_rendezvous(&self) -> Result<(), ConfigError> { let nostr = &self.node.rendezvous.nostr; let any_transport_advertises_on_nostr = self