Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-common/src/bindspec.rs

//! **Where a listener binds, when the address is not known until boot.**
//!
//! An appliance on a cloud takes its addresses from DHCP. `203.0.113.152` is
//! UpCloud's answer for ONE server on ONE day; the next box, and the same box
//! after a reinstall, get another. So a medium that is mastered once and
//! installed many times cannot carry a bind ADDRESS — and the alternative
//! everything reached for instead, `0.0.0.0`, is how gunnar's gRPC control
//! plane ended up on the public internet (MEASURED 2026-08-31: `repo list`,
//! `user list` and `repo create` all answered an unrelated caller with no
//! credential at all).
//!
//! The value a medium CAN carry is the **interface**: `eth1` is the utility
//! NIC on every UpCloud server that has one, on every box, forever.
//! [`BindSpec::Interface`] is that spelling, and it is resolved to an address
//! once — after `GUNNAR_NET` has brought the link up and before any listener
//! binds.
//!
//! Since the lean estate (owner 2026-10-23) a box may be laid with its utility NIC alone, and
//! then that NIC is `eth0`. A medium is the same for every box, so it names the ROLE:
//! [`BindSpec::Role`] (`utility:50051`) becomes the box's utility NIC
//! ([`BindSpec::for_this_box`]) and from there is the interface spelling.
//!
//! There is deliberately **no fallback**. An interface with no address is a
//! named refusal that stops the boot, not a quiet slide back to `0.0.0.0`:
//! falling back is what would put the control plane back on the public
//! interface at exactly the moment nobody is watching.

use std::fmt;
use std::net::{Ipv4Addr, SocketAddr};
use std::str::FromStr;

/// What a [`BindSpec`] resolved to.
///
/// Two arms and no third, and neither of them is a wildcard: an address to
/// bind, or a named interface that has none. "Bind everything instead" is not
/// an outcome this type can express, which is the strongest form the guarantee
/// can take.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolved {
    /// Bind here.
    Bound(SocketAddr),
    /// The interface is present and holds no IPv4 address. Nothing to bind.
    NoAddress {
        /// The interface that was asked about.
        interface: String,
        /// The sentence to log, naming the interface and how to fix it.
        why: String,
    },
}

/// Where a listener binds: a literal socket address, or an interface whose
/// address is learned at boot.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BindSpec {
    /// `127.0.0.1:50051`, `0.0.0.0:50051`, `[::]:50051` — verbatim.
    Addr(SocketAddr),
    /// `eth1:50051` — the IPv4 address `interface` holds when the listener
    /// binds. Refused by name if it holds none.
    Interface {
        /// The interface name, e.g. `eth1`.
        interface: String,
        /// The port to bind on it.
        port: u16,
    },
    /// `utility:50051` — the NIC that plays `role` on THIS box (the lean estate, A19 row 17):
    /// the medium is the same for a box with two NICs and a box laid behind its front with its
    /// utility NIC alone, so it names the role and the box names the NIC
    /// ([`BindSpec::for_this_box`]). Never bound as it is.
    Role {
        /// The role, e.g. [`BindRole::Utility`].
        role: BindRole,
        /// The port to bind on its NIC.
        port: u16,
    },
}

/// The role a NIC plays on a box.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BindRole {
    /// The provider's utility (private) network: the LAST NIC the net spec declares that the
    /// box has (`eth0:dhcp,eth1:dhcp`: `eth1` on a box with both, `eth0` on a box with one).
    Utility,
}

impl BindRole {
    /// Its spelling in a bind (`utility`).
    pub fn word(self) -> &'static str {
        match self {
            BindRole::Utility => "utility",
        }
    }
}

/// Pure: **the box's utility NIC**: the last interface the net spec `declared` that this box
/// has (`exists`). A box laid behind its front (the lean estate) has its utility NIC alone, and
/// it boots the same two-NIC spec, so its one NIC is the utility one. No NIC is a named refusal,
/// never a wildcard.
pub fn utility_interface(
    declared: &[String],
    exists: impl Fn(&str) -> bool,
) -> Result<String, String> {
    if declared.is_empty() {
        return Err(
            "`utility:<port>` names the box's utility NIC, and GUNNAR_NET declares no \
             interface to find it among (e.g. GUNNAR_NET=eth0:dhcp,eth1:dhcp). gunnar will NOT \
             fall back to 0.0.0.0."
                .to_string(),
        );
    }
    declared
        .iter()
        .rev()
        .find(|i| exists(i))
        .cloned()
        .ok_or_else(|| {
            format!(
            "`utility:<port>` names the box's utility NIC, and none of the interfaces GUNNAR_NET \
             declares ({}) is on this box. gunnar will NOT fall back to 0.0.0.0.",
            declared.join(", ")
        )
        })
}

impl BindSpec {
    /// Is this an address, and is it a loopback one? An interface spec is
    /// neither — its address is not known yet, and answering "no" for it would
    /// be a guess dressed as a fact.
    #[must_use]
    pub fn is_loopback_addr(&self) -> bool {
        match self {
            BindSpec::Addr(a) => a.ip().is_loopback(),
            BindSpec::Interface { .. } | BindSpec::Role { .. } => false,
        }
    }

    /// The port, whichever spelling this is.
    #[must_use]
    pub fn port(&self) -> u16 {
        match self {
            BindSpec::Addr(a) => a.port(),
            BindSpec::Interface { port, .. } | BindSpec::Role { port, .. } => *port,
        }
    }

    /// **A role becomes this box's NIC** ([`utility_interface`] over the interfaces the net
    /// spec `declared` and the ones the box has); every other spelling is returned unchanged.
    /// Called once, before anything binds.
    pub fn for_this_box(
        self,
        declared: &[String],
        exists: impl Fn(&str) -> bool,
    ) -> Result<BindSpec, String> {
        match self {
            BindSpec::Role {
                role: BindRole::Utility,
                port,
            } => Ok(BindSpec::Interface {
                interface: utility_interface(declared, exists)?,
                port,
            }),
            other => Ok(other),
        }
    }

    /// **Resolve to the address a listener can actually bind.**
    ///
    /// `lookup` answers "what IPv4 does this interface hold" — injected rather
    /// than called directly so this function, which owns every sentence it
    /// produces, is testable without a network stack. `gunnar-server` passes
    /// `pid1::interface_ipv4`.
    ///
    /// **Three outcomes, and the grading is the same law `bring_up_network`
    /// already applies to `GUNNAR_NET` — because it is the same distinction:**
    ///
    /// * an address, or an interface holding one — [`Resolved::Bound`];
    /// * the interface EXISTS and holds no IPv4 — [`Resolved::NoAddress`]. The
    ///   DECLARATION did not lie; the world declined to answer, exactly like a
    ///   DHCP timeout. `Ok`, and the caller decides. On the appliance that is
    ///   "serve git, refuse to serve a control plane, and say so", because
    ///   killing PID 1 there is a reboot loop that takes the git listener with
    ///   it — and a box in a reboot loop is not more secure than a box with no
    ///   control plane, it is only less reachable.
    /// * the interface is not there at all, or the kernel refused the question
    ///   — `Err`. The declaration named a NIC this machine does not have, which
    ///   is a lie a medium told, and it stops the boot the way every other
    ///   lying declaration does.
    ///
    /// No arm anywhere returns a wildcard. That is the whole point.
    pub fn resolve_with<E: fmt::Display>(
        &self,
        lookup: impl FnOnce(&str) -> Result<Option<Ipv4Addr>, E>,
    ) -> Result<Resolved, String> {
        let (interface, port) = match self {
            BindSpec::Addr(addr) => return Ok(Resolved::Bound(*addr)),
            BindSpec::Role { role, port } => {
                return Err(format!(
                    "--grpc-bind {}:{port} names a role, which becomes this box's NIC first \
                     (`BindSpec::for_this_box`); it is never bound as it is, and gunnar will NOT \
                     fall back to 0.0.0.0.",
                    role.word()
                ))
            }
            BindSpec::Interface { interface, port } => (interface, port),
        };
        match lookup(interface) {
            Ok(Some(ip)) => Ok(Resolved::Bound(SocketAddr::from((ip, *port)))),
            Ok(None) => Ok(Resolved::NoAddress {
                interface: interface.clone(),
                why: format!(
                    "--grpc-bind {interface}:{port} names interface {interface:?}, which exists \
                     but holds NO IPv4 address, so there is nothing to bind on it. On an \
                     appliance that means the DHCP handshake on {interface} did not complete — \
                     check that GUNNAR_NET names it (e.g. \
                     GUNNAR_NET=eth0:dhcp,{interface}:dhcp) and that the network answered. \
                     gunnar will NOT fall back to 0.0.0.0: an unauthenticated control plane on \
                     every interface is the defect this spelling exists to close."
                ),
            }),
            Err(err) => Err(format!(
                "--grpc-bind {interface}:{port} names interface {interface:?}, and this host \
                 could not be asked about it: {err}. A medium that names a NIC this machine \
                 does not have is a declaration that lies, and gunnar stops rather than \
                 falling back to 0.0.0.0."
            )),
        }
    }
}

impl From<SocketAddr> for BindSpec {
    fn from(addr: SocketAddr) -> Self {
        BindSpec::Addr(addr)
    }
}

impl fmt::Display for BindSpec {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            BindSpec::Addr(a) => write!(f, "{a}"),
            BindSpec::Interface { interface, port } => write!(f, "{interface}:{port}"),
            BindSpec::Role { role, port } => write!(f, "{}:{port}", role.word()),
        }
    }
}

/// A legal Linux interface name: non-empty, at most `IFNAMSIZ - 1` = 15 bytes,
/// no `/`, no whitespace, no `:` (the separator itself), and not `.`/`..`.
///
/// Checked HERE rather than at resolution, so `--grpc-bind bogus name:50051`
/// is refused by clap with the argument in hand instead of at boot on a box
/// with no console.
fn is_interface_name(s: &str) -> bool {
    !s.is_empty()
        && s.len() <= 15
        && s != "."
        && s != ".."
        && !s
            .bytes()
            .any(|b| b == b'/' || b == b':' || b.is_ascii_whitespace() || b == 0)
}

impl FromStr for BindSpec {
    type Err = String;

    /// `<addr>:<port>` or `<iface>:<port>`.
    ///
    /// The address form is tried FIRST and verbatim, so every value that
    /// parsed before this type existed still parses to exactly what it used to
    /// — `[::1]:50051` included, whose brackets contain the colons that would
    /// otherwise make the split ambiguous.
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        if let Ok(addr) = s.parse::<SocketAddr>() {
            return Ok(BindSpec::Addr(addr));
        }
        let (host, port) = s.rsplit_once(':').ok_or_else(|| {
            format!("{s:?} is not <addr>:<port> or <interface>:<port> — there is no `:` in it")
        })?;
        let port: u16 = port
            .parse()
            .map_err(|_| format!("{s:?}: {port:?} is not a port number"))?;
        // **Address-SHAPED and not an address.** `203.0.113.999` passes every
        // interface-name rule below — no `/`, no `:`, no whitespace, under
        // IFNAMSIZ — so without this arm a mistyped address is silently accepted
        // as the name of a NIC that will never exist, and the refusal arrives
        // at boot on a box with no console instead of here with the argument
        // in hand. Caught by `malformed_specs_are_refused_rather_than_guessed`,
        // which was RED over exactly this value.
        //
        // The shape test is "digits and dots only", or a leading `[`. No
        // interface name Linux hands out looks like that; every one of them
        // (`eth0`, `ens3`, `enp1s0`, `lo`) carries a letter.
        let address_shaped = host.starts_with('[')
            || (!host.is_empty() && host.bytes().all(|b| b.is_ascii_digit() || b == b'.'));
        if address_shaped {
            // It looked like an address and was not one. Saying "not an
            // interface name" here would send the reader looking for a NIC
            // they never meant to name.
            return Err(format!("{s:?}: {host:?} is not a valid IP address"));
        }
        if !is_interface_name(host) {
            return Err(format!(
                "{s:?}: {host:?} is neither an IP address nor a legal interface name (1..=15 \
                 bytes, no `/`, no `:`, no whitespace)"
            ));
        }
        // **A role, not a NIC** (`utility`): no Linux interface carries that name, and the
        // lean estate needs one medium for a box with two NICs and a box with one.
        if host == BindRole::Utility.word() {
            return Ok(BindSpec::Role {
                role: BindRole::Utility,
                port,
            });
        }
        Ok(BindSpec::Interface {
            interface: host.to_owned(),
            port,
        })
    }
}

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

    /// Every spelling that parsed before this type existed must still parse to
    /// the same socket address. A widening that quietly re-reads an old value
    /// as something else is worse than no widening.
    #[test]
    fn every_address_spelling_still_parses_to_an_address() {
        for (text, want) in [
            ("127.0.0.1:50051", SocketAddr::from(([127, 0, 0, 1], 50051))),
            ("0.0.0.0:50051", SocketAddr::from(([0, 0, 0, 0], 50051))),
            (
                "203.0.113.152:50051",
                SocketAddr::from(([203, 0, 113, 152], 50051)),
            ),
            ("[::1]:50051", "[::1]:50051".parse().unwrap()),
            ("[::]:50051", "[::]:50051".parse().unwrap()),
        ] {
            assert_eq!(
                text.parse::<BindSpec>().expect(text),
                BindSpec::Addr(want),
                "{text} must still parse as an ADDRESS"
            );
            assert_eq!(text.parse::<BindSpec>().unwrap().to_string(), text);
        }
    }

    #[test]
    fn an_interface_name_parses_as_one_and_round_trips() {
        assert_eq!(
            "eth1:50051".parse::<BindSpec>().expect("eth1:50051"),
            BindSpec::Interface {
                interface: "eth1".into(),
                port: 50051
            }
        );
        assert_eq!(
            "eth1:50051".parse::<BindSpec>().unwrap().to_string(),
            "eth1:50051"
        );
    }

    /// The refusals. Each of these was accepted by an earlier draft of
    /// `from_str` that split on the last colon and asked no further questions.
    #[test]
    fn malformed_specs_are_refused_rather_than_guessed() {
        for bad in [
            "eth1",                   // no port
            "eth1:",                  // empty port
            "eth1:notaport",          // not a number
            "eth1:70000",             // out of range
            "203.0.113.999:50051",    // address-shaped and not an address
            "[::1:50051",             // bracket-shaped and not an address
            "eth 1:50051",            // whitespace in a name
            "a/b:50051",              // `/` in a name
            "abcdefghijklmnop:50051", // 16 bytes, IFNAMSIZ is 16 INCLUDING the NUL
            ":50051",                 // no name at all
        ] {
            assert!(
                bad.parse::<BindSpec>().is_err(),
                "{bad:?} was ACCEPTED by BindSpec::from_str"
            );
        }
    }

    /// Measured on the resolver: an address resolves to itself without consulting
    /// the lookup at all, and the proof is that the lookup PANICS if called.
    #[test]
    fn an_address_resolves_to_itself_and_never_asks_the_kernel() {
        let spec: BindSpec = "203.0.113.152:50051".parse().unwrap();
        let got = spec
            .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> {
                panic!("an address must not be looked up")
            })
            .expect("an address resolves");
        assert_eq!(
            got,
            Resolved::Bound(SocketAddr::from(([203, 0, 113, 152], 50051)))
        );
    }

    #[test]
    fn an_interface_resolves_to_the_address_it_holds() {
        let spec: BindSpec = "eth1:50051".parse().unwrap();
        let got = spec
            .resolve_with(|name| -> Result<Option<Ipv4Addr>, String> {
                assert_eq!(name, "eth1");
                Ok(Some(Ipv4Addr::new(203, 0, 113, 152)))
            })
            .expect("eth1 holds an address");
        assert_eq!(
            got,
            Resolved::Bound(SocketAddr::from(([203, 0, 113, 152], 50051)))
        );
    }

    /// **The half that matters.** An interface with no address must REFUSE,
    /// and the refusal must not be a wildcard bind wearing an apology.
    /// **An interface with no address yields NO ADDRESS — never a wildcard,
    /// and never an address belonging to something else.**
    ///
    /// It is `Ok` rather than `Err` because the DECLARATION did not lie: the
    /// NIC is there, the network declined to answer, and that is the grade
    /// `bring_up_network` already gives a DHCP timeout. The caller turns it
    /// into "no control plane", which on the appliance keeps the git listener
    /// alive instead of killing PID 1 into a reboot loop.
    #[test]
    fn an_interface_with_no_address_yields_no_address_and_never_widens() {
        let spec: BindSpec = "eth1:50051".parse().unwrap();
        let got = spec
            .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> { Ok(None) })
            .expect("an interface with no address is not a lying declaration");
        let Resolved::NoAddress { interface, why } = got else {
            panic!("an interface with no address must NOT resolve to a bind: {got:?}");
        };
        assert_eq!(interface, "eth1");
        assert!(
            why.contains("eth1"),
            "the line must name the interface: {why}"
        );
        assert!(
            why.contains("GUNNAR_NET"),
            "the line must say how to fix it: {why}"
        );
        assert!(
            why.contains("0.0.0.0"),
            "the line must say out loud that it does NOT fall back: {why}"
        );
    }

    /// A NIC that is not there at all IS a lying declaration — a medium that
    /// names `eth1` on a one-NIC machine — and that stops the boot. The two
    /// cases are graded differently on purpose; conflating them would either
    /// brick a box whose lease was slow, or quietly serve nothing on a box
    /// whose medium was wrong for it.
    #[test]
    fn a_kernel_that_cannot_be_asked_is_an_error_and_not_merely_addressless() {
        let spec: BindSpec = "eth1:50051".parse().unwrap();
        let err = spec
            .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> {
                Err("no interface named \"eth1\"".into())
            })
            .expect_err("a NIC that does not exist must stop the boot");
        assert!(
            err.contains("eth1"),
            "the refusal must carry the cause: {err}"
        );
        assert!(
            err.contains("0.0.0.0"),
            "and must say it is not widening instead: {err}"
        );
    }

    #[test]
    fn loopback_is_only_claimed_for_an_address_that_is_one() {
        assert!("127.0.0.1:50051"
            .parse::<BindSpec>()
            .unwrap()
            .is_loopback_addr());
        assert!("[::1]:50051"
            .parse::<BindSpec>()
            .unwrap()
            .is_loopback_addr());
        assert!(!"0.0.0.0:50051"
            .parse::<BindSpec>()
            .unwrap()
            .is_loopback_addr());
        assert!(
            !"eth1:50051".parse::<BindSpec>().unwrap().is_loopback_addr(),
            "an unresolved interface must not be claimed as loopback — its address is not known"
        );
    }

    // ── the lean estate: the utility door bound by ROLE (A19 row 17) ──────────

    /// ★ **`utility:<port>` names the box's utility NIC by its role, not by `eth1`** (owner
    /// 2026-10-23, the lean estate): every box boots the same two-NIC spec
    /// (`eth0:dhcp,eth1:dhcp`), and a box laid behind its front has its utility NIC alone, as
    /// `eth0`. The role is the LAST NIC the spec declares that the box has: `eth1` on a box
    /// with both, `eth0` on a box with one. It becomes that interface before anything binds,
    /// and from there it is the interface spelling as ever (no wildcard, no fallback).
    #[test]
    fn the_utility_role_is_the_last_declared_nic_this_box_has() {
        let spec: BindSpec = "utility:50051".parse().expect("the role spelling");
        assert_eq!(
            spec,
            BindSpec::Role {
                role: BindRole::Utility,
                port: 50051
            }
        );
        assert_eq!(spec.to_string(), "utility:50051");
        assert_eq!(spec.port(), 50051);
        assert!(
            !spec.is_loopback_addr(),
            "a role's address is not known yet"
        );
        let declared = ["eth0".to_string(), "eth1".to_string()];
        let eth = |i: &str| BindSpec::Interface {
            interface: i.into(),
            port: 50051,
        };
        assert_eq!(
            spec.clone().for_this_box(&declared, |_| true).unwrap(),
            eth("eth1"),
            "two NICs: the utility one is eth1"
        );
        assert_eq!(
            spec.clone()
                .for_this_box(&declared, |i| i == "eth0")
                .unwrap(),
            eth("eth0"),
            "a lean box: its one NIC is the utility one"
        );
        assert_eq!(
            spec.clone()
                .for_this_box(&["eth0".to_string()], |_| true)
                .unwrap(),
            eth("eth0"),
            "a one-NIC spec"
        );
        let none = spec.clone().for_this_box(&[], |_| true).unwrap_err();
        assert!(
            none.contains("GUNNAR_NET") && none.contains("0.0.0.0"),
            "{none}"
        );
        let absent = spec.clone().for_this_box(&declared, |_| false).unwrap_err();
        assert!(
            absent.contains("eth0") && absent.contains("eth1"),
            "{absent}"
        );
        for kept in ["eth1:50051", "127.0.0.1:50051", "10.6.0.2:50051"] {
            let k: BindSpec = kept.parse().unwrap();
            assert_eq!(
                k.clone().for_this_box(&declared, |_| false).unwrap(),
                k,
                "{kept}: not a role, unchanged"
            );
        }
        let unresolved = spec
            .resolve_with(|_| -> Result<Option<Ipv4Addr>, String> { Ok(Some(Ipv4Addr::LOCALHOST)) })
            .unwrap_err();
        assert!(
            unresolved.contains("utility") && unresolved.contains("0.0.0.0"),
            "a role is never bound as it is: {unresolved}"
        );
    }
}