Skip to main content

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}