Review from ai-app's session, acted on. **A test that asserted nothing.** `a_missing_tunnel_explains_itself` returned early whenever `wg0` was up -- which it is on this VM and on the host, so it passed everywhere and never once checked the message. The same shape of test that let the file-permissions bug survive three codebases. `netif` now splits the lookup from the decision: `wg_address_among` and `addresses_among` take the interfaces, so the failure is reachable by handing in an empty list rather than by hoping the machine has no tunnel. Four tests where there were two, including that an IPv6-only `wg0` is not an answer. **Two modules the docs promised and the crate did not have.** `certs`, which is the piece where being written twice is worst -- a trust anchor built two ways can be built differently two ways, and the difference reaches a phone as an opaque handshake failure. And `format`, the RON house rules, which were byte-identical in both projects and so the clearest thing in the evidence table. `product` names the certificate and is the whole of what is per-project; the test decodes the DER and looks for it there rather than trusting what was passed in. **The README title still said wg-server-app**, three commits after everything else was renamed. Two judgement calls promoted from silent to written down, both of which would otherwise be inherited rather than chosen: `WG_INTERFACE` is a constant because *these* projects have one tunnel, which is the first thing a third user should expect to change; and `local_addresses` puts the emulator's host alias in every certificate, a SAN for an address the machine does not own. And a review heuristic the day kept proving: when you find a rule stated, grep for its siblings. Three bugs today were the correct rule already written down and applied to one member of a set.
181 lines
7.0 KiB
Rust
181 lines
7.0 KiB
Rust
//! 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<IpAddr> {
|
|
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<Item = IpAddr>) -> Vec<IpAddr> {
|
|
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<IpAddr> {
|
|
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<Item = (&'a str, IpAddr)>,
|
|
) -> Result<IpAddr> {
|
|
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}");
|
|
}
|
|
}
|