gunnar_common/bindspec.rs
1//! **Where a listener binds, when the address is not known until boot.**
2//!
3//! An appliance on a cloud takes its addresses from DHCP. `203.0.113.152` is
4//! UpCloud's answer for ONE server on ONE day; the next box, and the same box
5//! after a reinstall, get another. So a medium that is mastered once and
6//! installed many times cannot carry a bind ADDRESS — and the alternative
7//! everything reached for instead, `0.0.0.0`, is how gunnar's gRPC control
8//! plane ended up on the public internet (MEASURED 2026-08-31: `repo list`,
9//! `user list` and `repo create` all answered an unrelated caller with no
10//! credential at all).
11//!
12//! The value a medium CAN carry is the **interface**: `eth1` is the utility
13//! NIC on every UpCloud server that has one, on every box, forever.
14//! [`BindSpec::Interface`] is that spelling, and it is resolved to an address
15//! once — after `GUNNAR_NET` has brought the link up and before any listener
16//! binds.
17//!
18//! Since the lean estate (owner 2026-10-23) a box may be laid with its utility NIC alone, and
19//! then that NIC is `eth0`. A medium is the same for every box, so it names the ROLE:
20//! [`BindSpec::Role`] (`utility:50051`) becomes the box's utility NIC
21//! ([`BindSpec::for_this_box`]) and from there is the interface spelling.
22//!
23//! There is deliberately **no fallback**. An interface with no address is a
24//! named refusal that stops the boot, not a quiet slide back to `0.0.0.0`:
25//! falling back is what would put the control plane back on the public
26//! interface at exactly the moment nobody is watching.
27
28use std::fmt;
29use std::net::{Ipv4Addr, SocketAddr};
30use std::str::FromStr;
31
32/// What a [`BindSpec`] resolved to.
33///
34/// Two arms and no third, and neither of them is a wildcard: an address to
35/// bind, or a named interface that has none. "Bind everything instead" is not
36/// an outcome this type can express, which is the strongest form the guarantee
37/// can take.
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub enum Resolved {
40 /// Bind here.
41 Bound(SocketAddr),
42 /// The interface is present and holds no IPv4 address. Nothing to bind.
43 NoAddress {
44 /// The interface that was asked about.
45 interface: String,
46 /// The sentence to log, naming the interface and how to fix it.
47 why: String,
48 },
49}
50
51/// Where a listener binds: a literal socket address, or an interface whose
52/// address is learned at boot.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum BindSpec {
55 /// `127.0.0.1:50051`, `0.0.0.0:50051`, `[::]:50051` — verbatim.
56 Addr(SocketAddr),
57 /// `eth1:50051` — the IPv4 address `interface` holds when the listener
58 /// binds. Refused by name if it holds none.
59 Interface {
60 /// The interface name, e.g. `eth1`.
61 interface: String,
62 /// The port to bind on it.
63 port: u16,
64 },
65 /// `utility:50051` — the NIC that plays `role` on THIS box (the lean estate, A19 row 17):
66 /// the medium is the same for a box with two NICs and a box laid behind its front with its
67 /// utility NIC alone, so it names the role and the box names the NIC
68 /// ([`BindSpec::for_this_box`]). Never bound as it is.
69 Role {
70 /// The role, e.g. [`BindRole::Utility`].
71 role: BindRole,
72 /// The port to bind on its NIC.
73 port: u16,
74 },
75}
76
77/// The role a NIC plays on a box.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum BindRole {
80 /// The provider's utility (private) network: the LAST NIC the net spec declares that the
81 /// box has (`eth0:dhcp,eth1:dhcp`: `eth1` on a box with both, `eth0` on a box with one).
82 Utility,
83}
84
85impl BindRole {
86 /// Its spelling in a bind (`utility`).
87 pub fn word(self) -> &'static str {
88 match self {
89 BindRole::Utility => "utility",
90 }
91 }
92}
93
94/// Pure: **the box's utility NIC**: the last interface the net spec `declared` that this box
95/// has (`exists`). A box laid behind its front (the lean estate) has its utility NIC alone, and
96/// it boots the same two-NIC spec, so its one NIC is the utility one. No NIC is a named refusal,
97/// never a wildcard.
98pub fn utility_interface(
99 declared: &[String],
100 exists: impl Fn(&str) -> bool,
101) -> Result<String, String> {
102 if declared.is_empty() {
103 return Err(
104 "`utility:<port>` names the box's utility NIC, and GUNNAR_NET declares no \
105 interface to find it among (e.g. GUNNAR_NET=eth0:dhcp,eth1:dhcp). gunnar will NOT \
106 fall back to 0.0.0.0."
107 .to_string(),
108 );
109 }
110 declared
111 .iter()
112 .rev()
113 .find(|i| exists(i))
114 .cloned()
115 .ok_or_else(|| {
116 format!(
117 "`utility:<port>` names the box's utility NIC, and none of the interfaces GUNNAR_NET \
118 declares ({}) is on this box. gunnar will NOT fall back to 0.0.0.0.",
119 declared.join(", ")
120 )
121 })
122}
123
124impl BindSpec {
125 /// Is this an address, and is it a loopback one? An interface spec is
126 /// neither — its address is not known yet, and answering "no" for it would
127 /// be a guess dressed as a fact.
128 #[must_use]
129 pub fn is_loopback_addr(&self) -> bool {
130 match self {
131 BindSpec::Addr(a) => a.ip().is_loopback(),
132 BindSpec::Interface { .. } | BindSpec::Role { .. } => false,
133 }
134 }
135
136 /// The port, whichever spelling this is.
137 #[must_use]
138 pub fn port(&self) -> u16 {
139 match self {
140 BindSpec::Addr(a) => a.port(),
141 BindSpec::Interface { port, .. } | BindSpec::Role { port, .. } => *port,
142 }
143 }
144
145 /// **A role becomes this box's NIC** ([`utility_interface`] over the interfaces the net
146 /// spec `declared` and the ones the box has); every other spelling is returned unchanged.
147 /// Called once, before anything binds.
148 pub fn for_this_box(
149 self,
150 declared: &[String],
151 exists: impl Fn(&str) -> bool,
152 ) -> Result<BindSpec, String> {
153 match self {
154 BindSpec::Role {
155 role: BindRole::Utility,
156 port,
157 } => Ok(BindSpec::Interface {
158 interface: utility_interface(declared, exists)?,
159 port,
160 }),
161 other => Ok(other),
162 }
163 }
164
165 /// **Resolve to the address a listener can actually bind.**
166 ///
167 /// `lookup` answers "what IPv4 does this interface hold" — injected rather
168 /// than called directly so this function, which owns every sentence it
169 /// produces, is testable without a network stack. `gunnar-server` passes
170 /// `pid1::interface_ipv4`.
171 ///
172 /// **Three outcomes, and the grading is the same law `bring_up_network`
173 /// already applies to `GUNNAR_NET` — because it is the same distinction:**
174 ///
175 /// * an address, or an interface holding one — [`Resolved::Bound`];
176 /// * the interface EXISTS and holds no IPv4 — [`Resolved::NoAddress`]. The
177 /// DECLARATION did not lie; the world declined to answer, exactly like a
178 /// DHCP timeout. `Ok`, and the caller decides. On the appliance that is
179 /// "serve git, refuse to serve a control plane, and say so", because
180 /// killing PID 1 there is a reboot loop that takes the git listener with
181 /// it — and a box in a reboot loop is not more secure than a box with no
182 /// control plane, it is only less reachable.
183 /// * the interface is not there at all, or the kernel refused the question
184 /// — `Err`. The declaration named a NIC this machine does not have, which
185 /// is a lie a medium told, and it stops the boot the way every other
186 /// lying declaration does.
187 ///
188 /// No arm anywhere returns a wildcard. That is the whole point.
189 pub fn resolve_with<E: fmt::Display>(
190 &self,
191 lookup: impl FnOnce(&str) -> Result<Option<Ipv4Addr>, E>,
192 ) -> Result<Resolved, String> {
193 let (interface, port) = match self {
194 BindSpec::Addr(addr) => return Ok(Resolved::Bound(*addr)),
195 BindSpec::Role { role, port } => {
196 return Err(format!(
197 "--grpc-bind {}:{port} names a role, which becomes this box's NIC first \
198 (`BindSpec::for_this_box`); it is never bound as it is, and gunnar will NOT \
199 fall back to 0.0.0.0.",
200 role.word()
201 ))
202 }
203 BindSpec::Interface { interface, port } => (interface, port),
204 };
205 match lookup(interface) {
206 Ok(Some(ip)) => Ok(Resolved::Bound(SocketAddr::from((ip, *port)))),
207 Ok(None) => Ok(Resolved::NoAddress {
208 interface: interface.clone(),
209 why: format!(
210 "--grpc-bind {interface}:{port} names interface {interface:?}, which exists \
211 but holds NO IPv4 address, so there is nothing to bind on it. On an \
212 appliance that means the DHCP handshake on {interface} did not complete — \
213 check that GUNNAR_NET names it (e.g. \
214 GUNNAR_NET=eth0:dhcp,{interface}:dhcp) and that the network answered. \
215 gunnar will NOT fall back to 0.0.0.0: an unauthenticated control plane on \
216 every interface is the defect this spelling exists to close."
217 ),
218 }),
219 Err(err) => Err(format!(
220 "--grpc-bind {interface}:{port} names interface {interface:?}, and this host \
221 could not be asked about it: {err}. A medium that names a NIC this machine \
222 does not have is a declaration that lies, and gunnar stops rather than \
223 falling back to 0.0.0.0."
224 )),
225 }
226 }
227}
228
229impl From<SocketAddr> for BindSpec {
230 fn from(addr: SocketAddr) -> Self {
231 BindSpec::Addr(addr)
232 }
233}
234
235impl fmt::Display for BindSpec {
236 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
237 match self {
238 BindSpec::Addr(a) => write!(f, "{a}"),
239 BindSpec::Interface { interface, port } => write!(f, "{interface}:{port}"),
240 BindSpec::Role { role, port } => write!(f, "{}:{port}", role.word()),
241 }
242 }
243}
244
245/// A legal Linux interface name: non-empty, at most `IFNAMSIZ - 1` = 15 bytes,
246/// no `/`, no whitespace, no `:` (the separator itself), and not `.`/`..`.
247///
248/// Checked HERE rather than at resolution, so `--grpc-bind bogus name:50051`
249/// is refused by clap with the argument in hand instead of at boot on a box
250/// with no console.
251fn is_interface_name(s: &str) -> bool {
252 !s.is_empty()
253 && s.len() <= 15
254 && s != "."
255 && s != ".."
256 && !s
257 .bytes()
258 .any(|b| b == b'/' || b == b':' || b.is_ascii_whitespace() || b == 0)
259}
260
261impl FromStr for BindSpec {
262 type Err = String;
263
264 /// `<addr>:<port>` or `<iface>:<port>`.
265 ///
266 /// The address form is tried FIRST and verbatim, so every value that
267 /// parsed before this type existed still parses to exactly what it used to
268 /// — `[::1]:50051` included, whose brackets contain the colons that would
269 /// otherwise make the split ambiguous.
270 fn from_str(s: &str) -> Result<Self, Self::Err> {
271 if let Ok(addr) = s.parse::<SocketAddr>() {
272 return Ok(BindSpec::Addr(addr));
273 }
274 let (host, port) = s.rsplit_once(':').ok_or_else(|| {
275 format!("{s:?} is not <addr>:<port> or <interface>:<port> — there is no `:` in it")
276 })?;
277 let port: u16 = port
278 .parse()
279 .map_err(|_| format!("{s:?}: {port:?} is not a port number"))?;
280 // **Address-SHAPED and not an address.** `203.0.113.999` passes every
281 // interface-name rule below — no `/`, no `:`, no whitespace, under
282 // IFNAMSIZ — so without this arm a mistyped address is silently accepted
283 // as the name of a NIC that will never exist, and the refusal arrives
284 // at boot on a box with no console instead of here with the argument
285 // in hand. Caught by `malformed_specs_are_refused_rather_than_guessed`,
286 // which was RED over exactly this value.
287 //
288 // The shape test is "digits and dots only", or a leading `[`. No
289 // interface name Linux hands out looks like that; every one of them
290 // (`eth0`, `ens3`, `enp1s0`, `lo`) carries a letter.
291 let address_shaped = host.starts_with('[')
292 || (!host.is_empty() && host.bytes().all(|b| b.is_ascii_digit() || b == b'.'));
293 if address_shaped {
294 // It looked like an address and was not one. Saying "not an
295 // interface name" here would send the reader looking for a NIC
296 // they never meant to name.
297 return Err(format!("{s:?}: {host:?} is not a valid IP address"));
298 }
299 if !is_interface_name(host) {
300 return Err(format!(
301 "{s:?}: {host:?} is neither an IP address nor a legal interface name (1..=15 \
302 bytes, no `/`, no `:`, no whitespace)"
303 ));
304 }
305 // **A role, not a NIC** (`utility`): no Linux interface carries that name, and the
306 // lean estate needs one medium for a box with two NICs and a box with one.
307 if host == BindRole::Utility.word() {
308 return Ok(BindSpec::Role {
309 role: BindRole::Utility,
310 port,
311 });
312 }
313 Ok(BindSpec::Interface {
314 interface: host.to_owned(),
315 port,
316 })
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 /// Every spelling that parsed before this type existed must still parse to
325 /// the same socket address. A widening that quietly re-reads an old value
326 /// as something else is worse than no widening.
327 #[test]
328 fn every_address_spelling_still_parses_to_an_address() {
329 for (text, want) in [
330 ("127.0.0.1:50051", SocketAddr::from(([127, 0, 0, 1], 50051))),
331 ("0.0.0.0:50051", SocketAddr::from(([0, 0, 0, 0], 50051))),
332 (
333 "203.0.113.152:50051",
334 SocketAddr::from(([203, 0, 113, 152], 50051)),
335 ),
336 ("[::1]:50051", "[::1]:50051".parse().unwrap()),
337 ("[::]:50051", "[::]:50051".parse().unwrap()),
338 ] {
339 assert_eq!(
340 text.parse::<BindSpec>().expect(text),
341 BindSpec::Addr(want),
342 "{text} must still parse as an ADDRESS"
343 );
344 assert_eq!(text.parse::<BindSpec>().unwrap().to_string(), text);
345 }
346 }
347
348 #[test]
349 fn an_interface_name_parses_as_one_and_round_trips() {
350 assert_eq!(
351 "eth1:50051".parse::<BindSpec>().expect("eth1:50051"),
352 BindSpec::Interface {
353 interface: "eth1".into(),
354 port: 50051
355 }
356 );
357 assert_eq!(
358 "eth1:50051".parse::<BindSpec>().unwrap().to_string(),
359 "eth1:50051"
360 );
361 }
362
363 /// The refusals. Each of these was accepted by an earlier draft of
364 /// `from_str` that split on the last colon and asked no further questions.
365 #[test]
366 fn malformed_specs_are_refused_rather_than_guessed() {
367 for bad in [
368 "eth1", // no port
369 "eth1:", // empty port
370 "eth1:notaport", // not a number
371 "eth1:70000", // out of range
372 "203.0.113.999:50051", // address-shaped and not an address
373 "[::1:50051", // bracket-shaped and not an address
374 "eth 1:50051", // whitespace in a name
375 "a/b:50051", // `/` in a name
376 "abcdefghijklmnop:50051", // 16 bytes, IFNAMSIZ is 16 INCLUDING the NUL
377 ":50051", // no name at all
378 ] {
379 assert!(
380 bad.parse::<BindSpec>().is_err(),
381 "{bad:?} was ACCEPTED by BindSpec::from_str"
382 );
383 }
384 }
385
386 /// Measured on the resolver: an address resolves to itself without consulting
387 /// the lookup at all, and the proof is that the lookup PANICS if called.
388 #[test]
389 fn an_address_resolves_to_itself_and_never_asks_the_kernel() {
390 let spec: BindSpec = "203.0.113.152:50051".parse().unwrap();
391 let got = spec
392 .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> {
393 panic!("an address must not be looked up")
394 })
395 .expect("an address resolves");
396 assert_eq!(
397 got,
398 Resolved::Bound(SocketAddr::from(([203, 0, 113, 152], 50051)))
399 );
400 }
401
402 #[test]
403 fn an_interface_resolves_to_the_address_it_holds() {
404 let spec: BindSpec = "eth1:50051".parse().unwrap();
405 let got = spec
406 .resolve_with(|name| -> Result<Option<Ipv4Addr>, String> {
407 assert_eq!(name, "eth1");
408 Ok(Some(Ipv4Addr::new(203, 0, 113, 152)))
409 })
410 .expect("eth1 holds an address");
411 assert_eq!(
412 got,
413 Resolved::Bound(SocketAddr::from(([203, 0, 113, 152], 50051)))
414 );
415 }
416
417 /// **The half that matters.** An interface with no address must REFUSE,
418 /// and the refusal must not be a wildcard bind wearing an apology.
419 /// **An interface with no address yields NO ADDRESS — never a wildcard,
420 /// and never an address belonging to something else.**
421 ///
422 /// It is `Ok` rather than `Err` because the DECLARATION did not lie: the
423 /// NIC is there, the network declined to answer, and that is the grade
424 /// `bring_up_network` already gives a DHCP timeout. The caller turns it
425 /// into "no control plane", which on the appliance keeps the git listener
426 /// alive instead of killing PID 1 into a reboot loop.
427 #[test]
428 fn an_interface_with_no_address_yields_no_address_and_never_widens() {
429 let spec: BindSpec = "eth1:50051".parse().unwrap();
430 let got = spec
431 .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> { Ok(None) })
432 .expect("an interface with no address is not a lying declaration");
433 let Resolved::NoAddress { interface, why } = got else {
434 panic!("an interface with no address must NOT resolve to a bind: {got:?}");
435 };
436 assert_eq!(interface, "eth1");
437 assert!(
438 why.contains("eth1"),
439 "the line must name the interface: {why}"
440 );
441 assert!(
442 why.contains("GUNNAR_NET"),
443 "the line must say how to fix it: {why}"
444 );
445 assert!(
446 why.contains("0.0.0.0"),
447 "the line must say out loud that it does NOT fall back: {why}"
448 );
449 }
450
451 /// A NIC that is not there at all IS a lying declaration — a medium that
452 /// names `eth1` on a one-NIC machine — and that stops the boot. The two
453 /// cases are graded differently on purpose; conflating them would either
454 /// brick a box whose lease was slow, or quietly serve nothing on a box
455 /// whose medium was wrong for it.
456 #[test]
457 fn a_kernel_that_cannot_be_asked_is_an_error_and_not_merely_addressless() {
458 let spec: BindSpec = "eth1:50051".parse().unwrap();
459 let err = spec
460 .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> {
461 Err("no interface named \"eth1\"".into())
462 })
463 .expect_err("a NIC that does not exist must stop the boot");
464 assert!(
465 err.contains("eth1"),
466 "the refusal must carry the cause: {err}"
467 );
468 assert!(
469 err.contains("0.0.0.0"),
470 "and must say it is not widening instead: {err}"
471 );
472 }
473
474 #[test]
475 fn loopback_is_only_claimed_for_an_address_that_is_one() {
476 assert!("127.0.0.1:50051"
477 .parse::<BindSpec>()
478 .unwrap()
479 .is_loopback_addr());
480 assert!("[::1]:50051"
481 .parse::<BindSpec>()
482 .unwrap()
483 .is_loopback_addr());
484 assert!(!"0.0.0.0:50051"
485 .parse::<BindSpec>()
486 .unwrap()
487 .is_loopback_addr());
488 assert!(
489 !"eth1:50051".parse::<BindSpec>().unwrap().is_loopback_addr(),
490 "an unresolved interface must not be claimed as loopback — its address is not known"
491 );
492 }
493
494 // ── the lean estate: the utility door bound by ROLE (A19 row 17) ──────────
495
496 /// ★ **`utility:<port>` names the box's utility NIC by its role, not by `eth1`** (owner
497 /// 2026-10-23, the lean estate): every box boots the same two-NIC spec
498 /// (`eth0:dhcp,eth1:dhcp`), and a box laid behind its front has its utility NIC alone, as
499 /// `eth0`. The role is the LAST NIC the spec declares that the box has: `eth1` on a box
500 /// with both, `eth0` on a box with one. It becomes that interface before anything binds,
501 /// and from there it is the interface spelling as ever (no wildcard, no fallback).
502 #[test]
503 fn the_utility_role_is_the_last_declared_nic_this_box_has() {
504 let spec: BindSpec = "utility:50051".parse().expect("the role spelling");
505 assert_eq!(
506 spec,
507 BindSpec::Role {
508 role: BindRole::Utility,
509 port: 50051
510 }
511 );
512 assert_eq!(spec.to_string(), "utility:50051");
513 assert_eq!(spec.port(), 50051);
514 assert!(
515 !spec.is_loopback_addr(),
516 "a role's address is not known yet"
517 );
518 let declared = ["eth0".to_string(), "eth1".to_string()];
519 let eth = |i: &str| BindSpec::Interface {
520 interface: i.into(),
521 port: 50051,
522 };
523 assert_eq!(
524 spec.clone().for_this_box(&declared, |_| true).unwrap(),
525 eth("eth1"),
526 "two NICs: the utility one is eth1"
527 );
528 assert_eq!(
529 spec.clone()
530 .for_this_box(&declared, |i| i == "eth0")
531 .unwrap(),
532 eth("eth0"),
533 "a lean box: its one NIC is the utility one"
534 );
535 assert_eq!(
536 spec.clone()
537 .for_this_box(&["eth0".to_string()], |_| true)
538 .unwrap(),
539 eth("eth0"),
540 "a one-NIC spec"
541 );
542 let none = spec.clone().for_this_box(&[], |_| true).unwrap_err();
543 assert!(
544 none.contains("GUNNAR_NET") && none.contains("0.0.0.0"),
545 "{none}"
546 );
547 let absent = spec.clone().for_this_box(&declared, |_| false).unwrap_err();
548 assert!(
549 absent.contains("eth0") && absent.contains("eth1"),
550 "{absent}"
551 );
552 for kept in ["eth1:50051", "127.0.0.1:50051", "10.6.0.2:50051"] {
553 let k: BindSpec = kept.parse().unwrap();
554 assert_eq!(
555 k.clone().for_this_box(&declared, |_| false).unwrap(),
556 k,
557 "{kept}: not a role, unchanged"
558 );
559 }
560 let unresolved = spec
561 .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> { Ok(Some(Ipv4Addr::LOCALHOST)) })
562 .unwrap_err();
563 assert!(
564 unresolved.contains("utility") && unresolved.contains("0.0.0.0"),
565 "a role is never bound as it is: {unresolved}"
566 );
567 }
568}