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