//! Which address to bind, and which addresses the certificate must cover. //! //! Both projects bind the WireGuard interface and nothing else, so that //! neither is reachable from the LAN. That is the outer of two gates -- //! the tunnel decides who can try, the token (see [`crate::enroll`]) //! decides who is answered -- and it is worth having on its own account: //! an unenrolled scanner never reaches the token check, and a plain-HTTP //! bootstrap port travels inside the tunnel's encryption. use std::net::IpAddr; use anyhow::{Context, Result}; /// The interface both projects bind. A constant rather than a parameter /// because a second answer would mean two ideas of what "the tunnel" is. pub const WG_INTERFACE: &str = "wg0"; /// The alias an Android emulator reaches its host by. Not a real /// interface anywhere, which is why it has to be added by hand. const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2]; /// Every address this machine answers on, for the leaf certificate's SANs /// -- so it covers whatever the phone actually dials without anyone /// maintaining a hardcoded IP. /// /// Loopback is included for curl and tests, and the emulator's host alias /// so a debug build can reach a server running beside it. /// /// Failing to enumerate is not fatal: the certificate still covers /// loopback, which is enough to start and to diagnose from the machine /// itself. pub fn local_addresses() -> Vec { match if_addrs::get_if_addrs() { Ok(interfaces) => addresses_among(interfaces.iter().map(|iface| iface.ip())), Err(err) => { tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"); addresses_among(std::iter::empty()) } } } /// The SAN list built from `found`, which is the part worth testing. /// /// Split from the lookup so the outcome does not depend on what this /// machine happens to have; see [`wg_address_among`] for the same /// reasoning stated at length. pub fn addresses_among(found: impl IntoIterator) -> Vec { let mut addresses = vec![ IpAddr::from([127, 0, 0, 1]), IpAddr::from(EMULATOR_HOST_ALIAS), ]; for ip in found { if ip.is_ipv4() && !addresses.contains(&ip) { addresses.push(ip); } } addresses } /// The IPv4 address on the WireGuard interface, or a refusal to start. /// /// Failing closed rather than falling back to 0.0.0.0 is the point. The /// escape hatch belongs to the caller as an explicit `--bind`, because /// each of these servers is also how something stranded gets recovered, /// and that recovery should not depend on the tunnel being healthy. /// /// `product` names the binary in the failure, so the message reads as /// advice rather than as a library complaining. pub fn wg_address(product: &str) -> Result { let interfaces = if_addrs::get_if_addrs().context("enumerate network interfaces")?; wg_address_among( product, interfaces .iter() .map(|iface| (iface.name.as_str(), iface.ip())), ) } /// The same answer, from interfaces the caller supplies. /// /// This exists because the failure is the interesting half -- it is the /// message somebody reads when nothing works -- and it cannot be reached /// on any machine this runs on, all of which have the tunnel up. A test /// that calls [`wg_address`] and skips when it succeeds passes everywhere /// and checks nothing, which is the shape of test this project has been /// bitten by more than once. pub fn wg_address_among<'a>( product: &str, interfaces: impl IntoIterator, ) -> Result { interfaces .into_iter() .find(|(name, ip)| *name == WG_INTERFACE && ip.is_ipv4()) .map(|(_, ip)| ip) .ok_or_else(|| { anyhow::anyhow!( "no IPv4 address on interface {WG_INTERFACE} -- {product} binds only to the \ WireGuard tunnel, so that only enrolled peers can reach its API. Bring the \ tunnel up, or pass --bind 0.0.0.0 to serve the LAN while recovering." ) }) } #[cfg(test)] mod tests { use super::*; fn ip(s: &str) -> IpAddr { s.parse().expect("an address") } /// Whatever the machine has, the two that are not interfaces must be /// there -- loopback for tests and curl, the alias for an emulator -- /// and nothing may appear twice, since these become certificate SANs. #[test] fn the_certificate_always_covers_loopback_and_the_emulator_alias() { let found = [ip("192.168.1.168"), ip("10.66.0.1"), ip("192.168.1.168")]; let addresses = addresses_among(found); assert!(addresses.contains(&ip("127.0.0.1"))); assert!(addresses.contains(&ip("10.0.2.2"))); assert!( addresses.contains(&ip("10.66.0.1")), "the tunnel's own address" ); let mut seen = addresses.clone(); seen.sort(); seen.dedup(); assert_eq!(seen.len(), addresses.len(), "duplicate SANs: {addresses:?}"); assert!(addresses.iter().all(|ip| ip.is_ipv4())); } /// A machine with nothing at all still gets a usable certificate, /// because failing to enumerate must not leave the server unable to /// answer on loopback and diagnose itself. #[test] fn a_machine_with_no_interfaces_still_gets_a_usable_certificate() { assert_eq!( addresses_among(std::iter::empty()), [ip("127.0.0.1"), ip("10.0.2.2")] ); } #[test] fn the_tunnel_is_found_by_name_and_only_over_ipv4() { let found = wg_address_among( "demo-server", [ ("lo", ip("127.0.0.1")), ("eth0", ip("192.168.1.5")), (WG_INTERFACE, ip("10.66.0.1")), ], ); assert_eq!(found.expect("the tunnel"), ip("10.66.0.1")); // An interface of the right name carrying only IPv6 is not an // answer: both listeners bind an IPv4 socket. let v6_only = wg_address_among("demo-server", [(WG_INTERFACE, ip("fd00::1"))]); assert!(v6_only.is_err(), "IPv6 on wg0 is not the address to bind"); } /// The failure is the thing somebody reads at 2am, so it has to name /// the binary, the interface, and the way out. /// /// Reached by handing in an empty interface list rather than by hoping /// the machine has no tunnel. Every machine this runs on has one up, /// so a test that called the real lookup and skipped on success would /// pass everywhere and assert nothing -- which is exactly what it did /// before this was rewritten. #[test] fn a_missing_tunnel_explains_itself() { let err = wg_address_among("demo-server", []) .expect_err("no tunnel") .to_string(); assert!(err.contains("demo-server"), "names the binary: {err}"); assert!(err.contains(WG_INTERFACE), "names the interface: {err}"); assert!(err.contains("--bind"), "names the way out: {err}"); } }