gunnar-common 1.5.0

Shared command line, logging, and build identity for the gunnar binaries
Documentation
//! **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.
//!
//! 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,
    },
}

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 { .. } => false,
        }
    }

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

    /// **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 BindSpec::Interface { interface, port } = self else {
            let BindSpec::Addr(addr) = self else {
                unreachable!("BindSpec has exactly two variants")
            };
            return Ok(Resolved::Bound(*addr));
        };
        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}"),
        }
    }
}

/// 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)"
            ));
        }
        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"
        );
    }
}