Make the crate do what its documentation already claimed
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.
This commit is contained in:
1 parent
592114bfc9
commit
841a3a8372
7 files changed
+1055
-40
No files matched your search
+94
-25
@@ -30,20 +30,29 @@ const EMULATOR_HOST_ALIAS: [u8; 4] = [10, 0, 2, 2];
|
||||
/// 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),
|
||||
];
|
||||
match if_addrs::get_if_addrs() {
|
||||
Ok(interfaces) => {
|
||||
for interface in interfaces {
|
||||
let ip = interface.ip();
|
||||
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||
addresses.push(ip);
|
||||
}
|
||||
}
|
||||
for ip in found {
|
||||
if ip.is_ipv4() && !addresses.contains(&ip) {
|
||||
addresses.push(ip);
|
||||
}
|
||||
Err(err) => tracing::warn!("couldn't enumerate interfaces for the certificate: {err}"),
|
||||
}
|
||||
addresses
|
||||
}
|
||||
@@ -59,10 +68,30 @@ pub fn local_addresses() -> Vec<IpAddr> {
|
||||
/// 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(|iface| iface.name == WG_INTERFACE && iface.ip().is_ipv4())
|
||||
.map(|iface| iface.ip())
|
||||
.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 \
|
||||
@@ -76,14 +105,24 @@ pub fn wg_address(product: &str) -> Result<IpAddr> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Whatever this machine has, the two that are not interfaces must be
|
||||
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 addresses = local_addresses();
|
||||
assert!(addresses.contains(&IpAddr::from([127, 0, 0, 1])));
|
||||
assert!(addresses.contains(&IpAddr::from(EMULATOR_HOST_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();
|
||||
@@ -92,20 +131,50 @@ mod tests {
|
||||
assert!(addresses.iter().all(|ip| ip.is_ipv4()));
|
||||
}
|
||||
|
||||
/// The failure is the thing a person reads at 2am, so it has to name
|
||||
/// 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() {
|
||||
// Only meaningful where there is no wg0; where there is one, the
|
||||
// call succeeds and there is no message to check.
|
||||
if wg_address("demo-server").is_ok() {
|
||||
return;
|
||||
}
|
||||
let err = wg_address("demo-server")
|
||||
let err = wg_address_among("demo-server", [])
|
||||
.expect_err("no tunnel")
|
||||
.to_string();
|
||||
assert!(err.contains("demo-server"), "{err}");
|
||||
assert!(err.contains(WG_INTERFACE), "{err}");
|
||||
assert!(err.contains("--bind"), "{err}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user