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
+41 -43
View File
@@ -16,7 +16,7 @@ use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
Ipv4Range, MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
};
use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap};
use tsunagi::identity::{NetworkName, NetworkSecret};
@@ -99,43 +99,29 @@ struct TunSetupArgs {
#[arg(long)]
wg_mtu: Option<u32>,
/// Match `tsunagi up --no-ipv4`.
#[arg(long)]
no_ipv4: bool,
/// Match `tsunagi up --ipv4-range`.
#[arg(long, value_name = "CIDR", conflicts_with = "no_ipv4")]
#[arg(long, value_name = "CIDR")]
ipv4_range: Option<String>,
}
/// Parses `address/prefix` into an IPv4 range.
fn parse_ipv4_range(text: &str) -> Result<(std::net::Ipv4Addr, u8), String> {
let (address, prefix) = text
.split_once('/')
.ok_or_else(|| format!("`{text}` is not an address with a prefix, e.g. 100.64.0.0/10"))?;
let address = address
.parse()
.map_err(|err| format!("`{address}` is not an IPv4 address: {err}"))?;
let prefix: u8 = prefix
.parse()
.map_err(|err| format!("`{prefix}` is not a prefix length: {err}"))?;
if prefix > 30 {
return Err(format!("a /{prefix} has no room for hosts"));
}
Ok((address, prefix))
/// Strips the error type's own prefix, which is about peers rather than flags.
fn plain_reason(err: &tsunagi::dataplane::PluginError) -> String {
let text = err.to_string();
text.split_once(": ")
.map(|(_, rest)| rest.to_string())
.unwrap_or(text)
}
/// Resolves the IPv4 overlay range from the flags.
/// Resolves the IPv4 overlay range from the flag.
fn resolve_ipv4_range(
no_ipv4: bool,
range: Option<&String>,
) -> Result<Option<(std::net::Ipv4Addr, u8)>, Box<dyn std::error::Error>> {
if no_ipv4 {
return Ok(None);
}
) -> Result<Option<Ipv4Range>, Box<dyn std::error::Error>> {
match range {
Some(text) => Ok(Some(parse_ipv4_range(text)?)),
None => Ok(Some(tsunagi::dataplane::wireguard::DEFAULT_IPV4_RANGE)),
Some(text) => Ok(Some(text.parse::<Ipv4Range>().map_err(|err| {
// The underlying error type is about peers; reword it for a flag.
format!("--ipv4-range {text}: {}", plain_reason(&err))
})?)),
None => Ok(None),
}
}
@@ -251,12 +237,14 @@ struct UpArgs {
#[arg(long)]
wg_mtu: Option<u32>,
/// Run an IPv6-only overlay instead of dual stack.
#[arg(long)]
no_ipv4: bool,
/// IPv4 overlay range, as `address/prefix`. Defaults to 100.64.0.0/10.
#[arg(long, value_name = "CIDR", conflicts_with = "no_ipv4")]
/// Also run an IPv4 overlay in this range, as `address/prefix`.
///
/// Off unless given: no IPv4 range is free on every host. Pick one you
/// know is unused everywhere — not 100.64.0.0/10, which is Tailscale's
/// and carrier-grade NAT's. Every member must pass the same range; a
/// mismatch is detected and reported rather than silently misrouted.
/// IPv6 needs none of this and is always on.
#[arg(long, value_name = "CIDR")]
ipv4_range: Option<String>,
/// How often to print a status summary, in seconds. Zero disables it.
@@ -412,7 +400,7 @@ async fn tun_setup(args: TunSetupArgs) -> Result<(), Box<dyn std::error::Error>>
let interface = interface_name(&args.wg_prefix, network)?;
let address = overlay_address(network, &key.public());
let ipv4_range = resolve_ipv4_range(args.no_ipv4, args.ipv4_range.as_ref())?;
let ipv4_range = resolve_ipv4_range(args.ipv4_range.as_ref())?;
let mtu = args.wg_mtu.unwrap_or(DEFAULT_MTU);
let user = args.user.unwrap_or_else(|| {
std::env::var("SUDO_USER")
@@ -422,10 +410,10 @@ async fn tun_setup(args: TunSetupArgs) -> Result<(), Box<dyn std::error::Error>>
println!("# Network {name} ({network})");
println!("# Interface {interface}, address {address}/{OVERLAY_PREFIX_LEN}, mtu {mtu}");
if let Some((base, prefix)) = ipv4_range
&& let Some(v4) = overlay_address_v4(network, &key.public(), (base, prefix))
if let Some(range) = ipv4_range
&& let Some(v4) = overlay_address_v4(network, &key.public(), range)
{
println!("# IPv4 overlay address {v4}/{prefix}");
println!("# IPv4 overlay address {v4}/{}", range.prefix_len);
}
println!("# Run once as root; then run `tsunagi up` as {user}.");
println!(
@@ -440,10 +428,13 @@ async fn tun_setup(args: TunSetupArgs) -> Result<(), Box<dyn std::error::Error>>
println!("sudo ip link set dev {interface} mtu {mtu} up");
println!("sudo sysctl -qw net.ipv6.conf.{interface}.keep_addr_on_down=1");
println!("sudo ip -6 address add {address}/{OVERLAY_PREFIX_LEN} dev {interface} nodad");
if let Some((base, prefix)) = ipv4_range
&& let Some(v4) = overlay_address_v4(network, &key.public(), (base, prefix))
if let Some(range) = ipv4_range
&& let Some(v4) = overlay_address_v4(network, &key.public(), range)
{
println!("sudo ip address add {v4}/{prefix} dev {interface}");
println!(
"sudo ip address add {v4}/{} dev {interface}",
range.prefix_len
);
}
println!("\n# To check it afterwards:");
println!("ip -6 addr show dev {interface}");
@@ -547,6 +538,13 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?;
let paths = args.paths.resolve()?;
// Parsed up front so a typo is reported immediately, and so the option is
// never silently ignored when the data plane is off.
let ipv4_range = resolve_ipv4_range(args.ipv4_range.as_ref())?;
if ipv4_range.is_some() && !args.wireguard {
return Err("--ipv4-range only applies together with --wireguard".into());
}
let mut bootstrap: Vec<EndpointAddr> = Vec::new();
for peer in &args.peers {
bootstrap.push(parse_peer(peer)?);
@@ -576,7 +574,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
};
let mut wg = WireguardConfig::new(paths.state_dir.join("wireguard"))
.with_interface_prefix(args.wg_prefix.clone())
.with_ipv4_range(resolve_ipv4_range(args.no_ipv4, args.ipv4_range.as_ref())?);
.with_ipv4_range(ipv4_range);
if let Some(mtu) = args.wg_mtu {
wg = wg.with_mtu(mtu);
}
+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",
+11 -7
View File
@@ -42,7 +42,7 @@ use crate::dataplane::transport::{SharedLink, TransportError};
use crate::identity::NetworkId;
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{overlay_address, overlay_address_v4};
use super::overlay::{Ipv4Range, overlay_address};
use super::packet::IpHeader;
use super::tun::TunDevice;
@@ -193,7 +193,7 @@ struct Inner {
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
/// The IPv4 overlay range, when the overlay is dual stack.
ipv4_range: Option<(Ipv4Addr, u8)>,
ipv4_range: Option<Ipv4Range>,
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
/// Both families, so one lookup routes any packet.
routes: RwLock<HashMap<IpAddr, WgPublicKey>>,
@@ -225,7 +225,7 @@ impl WireguardDevice {
network: NetworkId,
private_key: WgSecretKey,
tun: Arc<dyn TunDevice>,
ipv4_range: Option<(Ipv4Addr, u8)>,
ipv4_range: Option<Ipv4Range>,
) -> Self {
let inner = Arc::new(Inner {
network,
@@ -260,10 +260,15 @@ impl WireguardDevice {
}
/// Adds or replaces a peer and starts its tunnel.
///
/// `overlay_v4` is decided by the caller, because only it knows whether
/// both sides agree on an IPv4 range. `None` means this peer is reachable
/// over IPv6 only.
pub fn add_peer(
&self,
endpoint_id: EndpointId,
public_key: WgPublicKey,
overlay_v4: Option<Ipv4Addr>,
link: SharedLink,
keepalive: Option<u16>,
) -> Result<(), PluginError> {
@@ -284,7 +289,7 @@ impl WireguardDevice {
);
let overlay = overlay_address(self.inner.network, &public_key);
let overlay_v4 = self.claim_ipv4(&public_key);
let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4);
let peer = Arc::new(Peer {
endpoint_id,
public_key,
@@ -321,9 +326,8 @@ impl WireguardDevice {
/// so they all agree on the outcome without talking about it. The other
/// member simply has no IPv4 address; it is still fully reachable over
/// IPv6, which never collides.
fn claim_ipv4(&self, public_key: &WgPublicKey) -> Option<Ipv4Addr> {
let range = self.inner.ipv4_range?;
let wanted = overlay_address_v4(self.inner.network, public_key, range)?;
fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
let wanted = wanted?;
let holder = read_lock(&self.inner.routes)
.get(&IpAddr::V4(wanted))
+2 -1
View File
@@ -55,7 +55,8 @@ pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interfa
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use overlay::{
DEFAULT_IPV4_RANGE, OVERLAY_PREFIX_LEN, overlay_address, overlay_address_v4, overlay_prefix,
Ipv4Range, OVERLAY_PREFIX_LEN, RFC6598_SHARED_RANGE, overlay_address, overlay_address_v4,
overlay_prefix,
};
pub use packet::IpHeader;
pub use plugin::{
+103 -17
View File
@@ -22,8 +22,11 @@
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use super::keys::WgPublicKey;
@@ -37,12 +40,76 @@ pub const OVERLAY_PREFIX_LEN: u8 = 64;
/// Prefix length of one member's address inside the overlay.
pub const OVERLAY_HOST_PREFIX_LEN: u8 = 128;
/// Default IPv4 overlay range: RFC 6598 shared address space.
/// An IPv4 range the overlay can be derived into.
///
/// Deliberately not RFC 1918, so it rarely collides with the home or office
/// network the machine is already on. It can collide with a carrier-grade NAT
/// that uses the same range, which is why it is configurable.
pub const DEFAULT_IPV4_RANGE: (Ipv4Addr, u8) = (Ipv4Addr::new(100, 64, 0, 0), 10);
/// Every member of a network must be configured with the same one, because
/// addresses are derived from it. See [`crate::dataplane::wireguard::plugin::WireguardConfig::ipv4_range`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Ipv4Range {
/// Base address of the range.
pub base: Ipv4Addr,
/// Prefix length, at most 30 so there is room for hosts.
pub prefix_len: u8,
}
impl Ipv4Range {
/// Builds a range, rejecting one with no room for hosts.
pub fn new(base: Ipv4Addr, prefix_len: u8) -> Result<Self, PluginError> {
if prefix_len > 30 {
return Err(PluginError::Rejected(format!(
"a /{prefix_len} has no room for hosts; use /30 or larger"
)));
}
Ok(Self { base, prefix_len })
}
/// Whether an address falls inside the range.
pub fn contains(&self, address: Ipv4Addr) -> bool {
let host_bits = 32 - u32::from(self.prefix_len);
let mask = if host_bits >= 32 {
0
} else {
u32::MAX << host_bits
};
u32::from(address) & mask == u32::from(self.base) & mask
}
}
impl std::fmt::Display for Ipv4Range {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.base, self.prefix_len)
}
}
impl std::str::FromStr for Ipv4Range {
type Err = PluginError;
fn from_str(text: &str) -> Result<Self, Self::Err> {
let (base, prefix) = text.split_once('/').ok_or_else(|| {
PluginError::Rejected(format!(
"`{text}` is not an address with a prefix, for example 10.77.0.0/16"
))
})?;
let base = base.parse().map_err(|err| {
PluginError::Rejected(format!("`{base}` is not an IPv4 address: {err}"))
})?;
let prefix_len = prefix.parse().map_err(|err| {
PluginError::Rejected(format!("`{prefix}` is not a prefix length: {err}"))
})?;
Self::new(base, prefix_len)
}
}
/// RFC 6598 shared address space, offered only as a reference point.
///
/// **Not a default, and usually a bad choice.** Tailscale uses exactly this
/// range, and so does carrier-grade NAT, so a machine running either will
/// collide with it. There is no IPv4 range that is free on every host, which
/// is why the IPv4 overlay has no default at all and must be configured.
pub const RFC6598_SHARED_RANGE: Ipv4Range = Ipv4Range {
base: Ipv4Addr::new(100, 64, 0, 0),
prefix_len: 10,
};
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
@@ -107,9 +174,9 @@ pub fn overlay_address(network: NetworkId, key: &WgPublicKey) -> Ipv6Addr {
pub fn overlay_address_v4(
network: NetworkId,
key: &WgPublicKey,
range: (Ipv4Addr, u8),
range: Ipv4Range,
) -> Option<Ipv4Addr> {
let (base, prefix_len) = range;
let Ipv4Range { base, prefix_len } = range;
if prefix_len > 32 {
return None;
}
@@ -185,14 +252,14 @@ mod tests {
#[test]
fn ipv4_addresses_land_inside_the_range_and_avoid_its_edges() {
let id = network("v4");
let range = DEFAULT_IPV4_RANGE;
let range = RFC6598_SHARED_RANGE;
for byte in 0..64u8 {
let key = WgPublicKey::from_bytes([byte; 32]);
let addr = overlay_address_v4(id, &key, range).unwrap();
let raw = u32::from(addr);
assert_eq!(
raw & 0xffc0_0000,
u32::from(range.0),
u32::from(range.base),
"outside 100.64.0.0/10"
);
// Never the network address and never the broadcast address.
@@ -201,12 +268,25 @@ mod tests {
}
}
#[test]
fn a_range_parses_and_prints_round_trip() {
let range: Ipv4Range = "10.77.0.0/16".parse().unwrap();
assert_eq!(range.to_string(), "10.77.0.0/16");
assert!(range.contains("10.77.3.4".parse().unwrap()));
assert!(!range.contains("10.78.3.4".parse().unwrap()));
assert!("10.77.0.0".parse::<Ipv4Range>().is_err());
assert!("nonsense/16".parse::<Ipv4Range>().is_err());
assert!("10.77.0.0/zz".parse::<Ipv4Range>().is_err());
assert!("10.77.0.0/31".parse::<Ipv4Range>().is_err());
}
#[test]
fn ipv4_derivation_is_deterministic_and_scoped_like_ipv6() {
let key = WgPublicKey::from_bytes([9u8; 32]);
let first = network("one");
let second = network("two");
let range = DEFAULT_IPV4_RANGE;
let range = RFC6598_SHARED_RANGE;
assert_eq!(
overlay_address_v4(first, &key, range),
@@ -223,7 +303,11 @@ mod tests {
// A different range moves everybody.
assert_ne!(
overlay_address_v4(first, &key, range),
overlay_address_v4(first, &key, (Ipv4Addr::new(10, 0, 0, 0), 8))
overlay_address_v4(
first,
&key,
Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 8).unwrap()
)
);
}
@@ -231,14 +315,16 @@ mod tests {
fn a_range_with_no_room_yields_nothing() {
let id = network("tiny");
let key = WgPublicKey::from_bytes([1u8; 32]);
// /31 and /32 have no usable host addresses.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 31)).is_none());
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 1), 32)).is_none());
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 33)).is_none());
// /31 and /32 have no usable host addresses, so they are refused.
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 31).is_err());
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 1), 32).is_err());
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 33).is_err());
// A /30 has two usable addresses.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::new(10, 0, 0, 0), 30)).is_some());
let small = Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 30).unwrap();
assert!(overlay_address_v4(id, &key, small).is_some());
// A /0 must not overflow.
assert!(overlay_address_v4(id, &key, (Ipv4Addr::UNSPECIFIED, 0)).is_some());
let everything = Ipv4Range::new(Ipv4Addr::UNSPECIFIED, 0).unwrap();
assert!(overlay_address_v4(id, &key, everything).is_some());
}
#[test]
+56 -12
View File
@@ -44,7 +44,7 @@ use super::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
use super::device::{PeerSummary, WireguardDevice};
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{
DEFAULT_IPV4_RANGE, OVERLAY_PREFIX_LEN, overlay_address, overlay_address_v4, overlay_prefix,
Ipv4Range, OVERLAY_PREFIX_LEN, overlay_address, overlay_address_v4, overlay_prefix,
};
use super::store::WgKeyStore;
use super::tun::{TunFactory, TunRequest};
@@ -90,10 +90,19 @@ pub struct WireguardConfig {
pub mtu: u32,
/// IPv4 overlay range, or `None` for an IPv6-only overlay.
///
/// IPv6 addresses are derived collision-free; IPv4 ones cannot be, so a
/// collision is detected and resolved deterministically instead. See
/// `docs/wireguard.md`.
pub ipv4_range: Option<(Ipv4Addr, u8)>,
/// **Every member of a network must configure the same range.** Addresses
/// are derived from it, so two members configured differently would
/// derive different addresses for each other. The range travels in the
/// announcement purely so that such a mismatch is detected and reported
/// instead of silently misrouting.
///
/// There is no default, because no IPv4 range is free on every host:
/// `100.64.0.0/10` belongs to Tailscale and to carrier-grade NAT,
/// `10.0.0.0/8` and `192.168.0.0/16` are everywhere, `172.17.0.0/16` is
/// Docker. Pick one you know is unused on every machine that will join.
/// IPv6 needs none of this: its addresses are derived from the network
/// id and never collide.
pub ipv4_range: Option<Ipv4Range>,
/// How long to coalesce changes before reconciling.
pub reconcile_debounce: Duration,
/// How often to reconcile anyway, which is also when a packet interface
@@ -109,7 +118,9 @@ impl WireguardConfig {
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
keepalive: Some(25),
mtu: DEFAULT_MTU,
ipv4_range: Some(DEFAULT_IPV4_RANGE),
// Off by default: no IPv4 range is free on every host. See
// `with_ipv4_range`.
ipv4_range: None,
reconcile_debounce: Duration::from_millis(200),
reconcile_interval: Duration::from_secs(15),
}
@@ -130,7 +141,9 @@ impl WireguardConfig {
}
/// Sets the IPv4 overlay range, or disables IPv4 with `None`.
pub fn with_ipv4_range(mut self, range: Option<(Ipv4Addr, u8)>) -> Self {
///
/// Must match on every member; see the field documentation.
pub fn with_ipv4_range(mut self, range: Option<Ipv4Range>) -> Self {
self.ipv4_range = range;
self
}
@@ -168,7 +181,7 @@ pub struct NetworkOverview {
/// This agent's IPv4 overlay address, when the overlay is dual stack.
pub overlay_address_v4: Option<Ipv4Addr>,
/// The IPv4 overlay range in use.
pub ipv4_range: Option<(Ipv4Addr, u8)>,
pub ipv4_range: Option<Ipv4Range>,
/// Peers this agent knows about.
pub peers: Vec<PeerOverview>,
/// Unicast packets the operating system sent to an address no peer owns.
@@ -467,9 +480,11 @@ impl Worker {
name: name.clone(),
address: overlay_address(network, &key.public()),
prefix_len: OVERLAY_PREFIX_LEN,
address_v4: self.config.ipv4_range.and_then(|range| {
overlay_address_v4(network, &key.public(), range).map(|address| (address, range.1))
}),
address_v4: self
.config
.ipv4_range
.and_then(|range| overlay_address_v4(network, &key.public(), range)),
prefix_len_v4: self.config.ipv4_range.map_or(0, |range| range.prefix_len),
mtu: self.config.mtu,
};
let tun = self.tun_factory.create(request).await?;
@@ -503,6 +518,7 @@ impl Worker {
let mut wanted: Vec<WgPublicKey> = Vec::new();
let mut too_small: Vec<(usize, usize)> = Vec::new();
let mut mismatched: Vec<(WgPublicKey, Ipv4Range, Ipv4Range)> = Vec::new();
for (endpoint_id, announcement) in &state.announcements {
let Some(link) = state.links.get(endpoint_id) else {
continue;
@@ -524,9 +540,24 @@ impl Worker {
too_small.push((available, needed));
}
// A peer only gets an IPv4 address if both sides were configured
// with the same range. Otherwise the two would derive different
// addresses for each other and IPv4 would silently misroute.
let peer_v4 = match (self.config.ipv4_range, announcement.ipv4_range) {
(Some(ours), Some(theirs)) if ours == theirs => {
overlay_address_v4(network, &announcement.public_key, ours)
}
(Some(ours), Some(theirs)) => {
mismatched.push((announcement.public_key, ours, theirs));
None
}
(Some(_), None) | (None, Some(_)) | (None, None) => None,
};
if let Err(err) = device.add_peer(
*endpoint_id,
announcement.public_key,
peer_v4,
Arc::clone(link),
self.config.keepalive,
) {
@@ -536,6 +567,18 @@ impl Worker {
device.retain_peers(&wanted);
drop(shared);
for (key, ours, theirs) in mismatched {
self.report(
network,
format!(
"peer {} is configured with the IPv4 overlay range {theirs} but this agent \
uses {ours}; every member must use the same one. That peer has no IPv4 \
address here and is reachable over IPv6 only.",
key.fmt_short()
),
);
}
for (available, needed) in too_small {
self.report(
network,
@@ -664,7 +707,8 @@ impl IpPlugin for WireguardPlugin {
};
// Identity only. Where to send packets is the transport's business.
let announcement = WgAnnouncement::new(network, &state.key.public());
let announcement =
WgAnnouncement::new(network, &state.key.public(), self.worker.config.ipv4_range);
Ok(Some(PluginCapability {
protocol: WIREGUARD_PROTOCOL.to_string(),
version: super::announcement::ANNOUNCEMENT_VERSION,
+9 -5
View File
@@ -12,7 +12,7 @@
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
//! Creating one needs `CAP_NET_ADMIN` on Linux or the equivalent elsewhere.
use std::net::{Ipv4Addr, Ipv6Addr};
use std::net::Ipv6Addr;
use std::sync::Arc;
use bytes::Bytes;
@@ -29,8 +29,10 @@ pub struct TunRequest {
pub address: Ipv6Addr,
/// Prefix length of the overlay subnet, so the OS routes it here.
pub prefix_len: u8,
/// The IPv4 overlay address and its prefix length, when dual stack.
pub address_v4: Option<(Ipv4Addr, u8)>,
/// The IPv4 overlay address this host answers to, when dual stack.
pub address_v4: Option<std::net::Ipv4Addr>,
/// Prefix length of the IPv4 overlay range.
pub prefix_len_v4: u8,
/// Interface MTU.
pub mtu: u32,
}
@@ -401,7 +403,8 @@ mod system {
request.address, request.prefix_len, request.name
),
];
if let Some((address, prefix_len)) = request.address_v4 {
if let Some(address) = request.address_v4 {
let prefix_len = request.prefix_len_v4;
// IPv4 is not sensitive to carrier the way IPv6 is, so it needs
// no extra settings.
commands.push(format!(
@@ -589,7 +592,8 @@ fd559caf9652cb86321feac65c73bd84 05 40 00 08 tsun0
name: "tsun0".into(),
address: "fd00::1".parse().unwrap(),
prefix_len: 64,
address_v4: Some(("100.64.1.2".parse().unwrap(), 10)),
address_v4: Some("100.64.1.2".parse().unwrap()),
prefix_len_v4: 10,
mtu: 1280,
};
let commands = setup_commands(&request, "someone");