Make the IPv4 overlay opt-in and detect a range mismatch

100.64.0.0/10 was a bad default: it is exactly Tailscale's range, and
carrier-grade NAT's. There is no IPv4 range that is free on every host, so
there is now no default at all — IPv4 is off until --ipv4-range names one.
IPv6 is unaffected and still works out of the box, because a ULA derived
from the network id collides with essentially nothing.

The more serious problem this exposed: the range is an input to the address
derivation, and each agent derives every peer's address itself. Two members
configured with different ranges would therefore derive different addresses
for each other and IPv4 would silently misroute. So the range now travels
in the announcement — not as a request and never trusted, only so the
mismatch is seen. A peer whose range disagrees gets no IPv4 address here,
keeps working over IPv6, and the reason is reported with both ranges named.

The announcement format goes to version 2. postcard is not
self-describing, so an older peer cannot read it; the version check already
catches that and now says which side needs updating.

The (Ipv4Addr, u8) tuple that had spread across six modules is now an
Ipv4Range with validation, Display and FromStr, so a bad --ipv4-range is
refused with a reason instead of being accepted and misbehaving later. It
is also rejected when passed without --wireguard rather than ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 13:11:09 +01:00
co-authored by Claude Opus 5
parent cfab38824d
commit ce64264027
10 changed files with 419 additions and 141 deletions
+61 -14
View File
@@ -18,10 +18,14 @@ use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use super::keys::WgPublicKey;
use super::overlay::overlay_address;
use super::overlay::{Ipv4Range, overlay_address};
/// Version of the announcement format.
pub const ANNOUNCEMENT_VERSION: u16 = 1;
///
/// Bumped to 2 when the IPv4 overlay range was added. postcard is not
/// self-describing, so an older peer cannot read a newer announcement; the
/// mismatch is reported rather than misparsed.
pub const ANNOUNCEMENT_VERSION: u16 = 2;
/// What one participant advertises for the WireGuard data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -32,9 +36,15 @@ pub struct WgAnnouncement {
pub public_key: [u8; 32],
/// The overlay address the peer believes it has.
///
/// Carried for diagnostics and cross-checking only. `AllowedIPs` are
/// always derived locally, never taken from this field.
/// Carried for diagnostics and cross-checking only. Addresses are always
/// derived locally, never taken from this field.
pub overlay_address: Ipv6Addr,
/// The IPv4 overlay range this peer is configured with, if any.
///
/// Not a request and not trusted: it exists so that two members who were
/// configured differently find out, instead of silently deriving
/// different addresses for each other and misrouting IPv4.
pub ipv4_range: Option<Ipv4Range>,
}
/// A peer announcement that has been validated against a specific network.
@@ -44,15 +54,22 @@ pub struct ValidatedAnnouncement {
pub public_key: WgPublicKey,
/// The overlay address derived locally for this key. Authoritative.
pub overlay_address: Ipv6Addr,
/// The IPv4 overlay range the peer is configured with.
pub ipv4_range: Option<Ipv4Range>,
}
impl WgAnnouncement {
/// Builds this agent's announcement.
pub fn new(network: NetworkId, public_key: &WgPublicKey) -> Self {
pub fn new(
network: NetworkId,
public_key: &WgPublicKey,
ipv4_range: Option<Ipv4Range>,
) -> Self {
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
overlay_address: overlay_address(network, public_key),
ipv4_range,
}
}
@@ -83,7 +100,8 @@ impl WgAnnouncement {
) -> Result<ValidatedAnnouncement, PluginError> {
if self.version != ANNOUNCEMENT_VERSION {
return Err(PluginError::Rejected(format!(
"unsupported WireGuard announcement version {} (this build speaks {ANNOUNCEMENT_VERSION})",
"peer speaks WireGuard announcement version {} but this build speaks \
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
self.version
)));
}
@@ -109,9 +127,18 @@ impl WgAnnouncement {
));
}
if let Some(range) = self.ipv4_range
&& range.prefix_len > 30
{
return Err(PluginError::Rejected(format!(
"announced IPv4 range {range} has no room for hosts"
)));
}
Ok(ValidatedAnnouncement {
public_key,
overlay_address: derived,
ipv4_range: self.ipv4_range,
})
}
}
@@ -139,7 +166,7 @@ mod tests {
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
let payload = WgAnnouncement::new(id, &peer, None).encode().unwrap();
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
assert_eq!(validated.public_key, peer);
@@ -152,7 +179,7 @@ mod tests {
// carried here, so there is nothing for a peer to lie about.
let id = network("identity-only");
let peer = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
let payload = WgAnnouncement::new(id, &peer, None).encode().unwrap();
assert!(
payload.len() < 80,
"the announcement should stay tiny, got {} bytes",
@@ -168,7 +195,7 @@ mod tests {
let local = WgSecretKey::generate().public();
// An attacker claims the victim's overlay address with its own key.
let mut forged = WgAnnouncement::new(id, &attacker);
let mut forged = WgAnnouncement::new(id, &attacker, None);
forged.overlay_address = overlay_address(id, &victim);
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
@@ -185,7 +212,7 @@ mod tests {
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(there, &peer).encode().unwrap();
let payload = WgAnnouncement::new(there, &peer, None).encode().unwrap();
assert!(WgAnnouncement::decode_and_validate(&payload, here, &local).is_err());
}
@@ -200,7 +227,7 @@ mod tests {
let wrong_version = WgAnnouncement {
version: ANNOUNCEMENT_VERSION + 1,
..WgAnnouncement::new(id, &peer)
..WgAnnouncement::new(id, &peer, None)
};
assert!(
WgAnnouncement::decode_and_validate(&wrong_version.encode().unwrap(), id, &local)
@@ -209,18 +236,38 @@ mod tests {
let zero_key = WgAnnouncement {
public_key: [0u8; 32],
..WgAnnouncement::new(id, &peer)
..WgAnnouncement::new(id, &peer, None)
};
assert!(
WgAnnouncement::decode_and_validate(&zero_key.encode().unwrap(), id, &local).is_err()
);
}
#[test]
fn the_ipv4_range_travels_so_a_mismatch_can_be_seen() {
let id = network("ranges");
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let range = Some(Ipv4Range::new("10.9.0.0".parse().unwrap(), 16).unwrap());
let payload = WgAnnouncement::new(id, &peer, range).encode().unwrap();
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
assert_eq!(validated.ipv4_range, range);
// A range with no usable hosts is nonsense and is refused.
let mut bad = WgAnnouncement::new(id, &peer, range);
bad.ipv4_range = Some(Ipv4Range {
base: "10.9.0.0".parse().unwrap(),
prefix_len: 31,
});
assert!(WgAnnouncement::decode_and_validate(&bad.encode().unwrap(), id, &local).is_err());
}
#[test]
fn a_peer_cannot_claim_our_own_key() {
let id = network("self");
let local = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &local).encode().unwrap();
let payload = WgAnnouncement::new(id, &local, None).encode().unwrap();
assert!(WgAnnouncement::decode_and_validate(&payload, id, &local).is_err());
}
@@ -228,7 +275,7 @@ mod tests {
fn announcements_stay_well_under_the_capability_payload_limit() {
let id = network("size");
let peer = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
let payload = WgAnnouncement::new(id, &peer, None).encode().unwrap();
assert!(
payload.len() < crate::config::Limits::default().max_capability_data_len,
"announcement is {} bytes",