Derive default network ranges and switch overlapping networks

Every first network previously proposed 10.13.37.0/24. Independently created
networks therefore collided when a device joined more than one of them.
Use the existing stable NetworkId-derived /24 in 10/8 for every new network,
independent of activation order or peer availability. Legacy and explicit
signed ranges outrank the derived default; remaining range disagreements
still use author-id ordering. The derivation, identity labels, handshake and
signed record encoding stay unchanged. There is no creator, timestamp or vote.

Reserve ranges before starting network actors and serialise start, stop and
switching through the agent lifecycle lock. Refuse equal or nested overlaps
without changing a network's subnet locally. A late authenticated range that
collides suspends only its network before updating plugins or the interface.
Coalesced watch notifications let the agent clean up outside the network actor
and reconsider suspended networks when ranges change.

An explicit switch stops every blocker before activating the chosen network.
Suspended networks retain their startup request and resume whenever their
ranges become free, including switching from a wide range to a narrow one.
A manual stop cancels that request even for an already suspended network.
Persist preference order and enabling together in an SQLite transaction so
the user's selection survives restart; no database schema migration is needed.

CLI status and network listing show blocker names, ids, ranges and the start
command. Network start switches explicitly and reports suspended networks;
joining an overlapping network reports it as configured but inactive. The
tray adds conflict details, an amber icon, Switch to this network and Keep
stopped actions. Local IPC wire version is 19 for the extended reports, so
restart the agent and tray together. Release versions and peer wire formats
are unchanged. Update the user documentation and compatibility tests.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets with TSUNAGI_CONTROL_SOCKET
  removed from the test environment: 486 passed, 2 pre-existing ignored tests
- final focused range_conflicts integration suite after making discovery
  fixtures deterministic: 6 passed

Earlier validation attempts caught outdated diagnostic assertions, a new
test helper scope error, and the inherited system control socket override.
Those were corrected before the successful runs. Plain cargo was unavailable
in the host shell, so validation used the Nix development toolchain. macOS,
Windows, privileged host TUN operations and manual tray interaction were not
exercised.
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-11 11:58:27 +01:00
1 parent 781e359775
commit ed28e86748
24 files changed
+1291 -318

No files matched your search

+59 -69
View File
@@ -137,6 +137,7 @@ pub(crate) struct NetworkHandle {
pub(crate) commands: mpsc::Sender<NetCommand>,
shutdown: Shutdown,
task: JoinHandle<()>,
pub(crate) range_blocked: Arc<std::sync::atomic::AtomicBool>,
}
impl NetworkHandle {
@@ -177,17 +178,8 @@ pub(crate) struct RuntimeParams {
/// The IPv4 overlay range this agent would use, if the network has not
/// already settled on another one.
pub(crate) ipv4_range: Option<Ipv4Range>,
/// The range it was configured with but cannot have, because another
/// network on this agent holds it.
pub(crate) range_conflict: Option<Ipv4Range>,
/// The range derived from this network's own id, proposed when the
/// configured one belongs to another network here.
///
/// Held back at first: a network that already exists has a range of its
/// own, and a member that proposed before hearing anything would be
/// arguing with it instead of adopting it. Every member derives the
/// same one, so once the wait is over there is nothing to argue about.
pub(crate) ipv4_fallback: Option<Ipv4Range>,
/// Coalesced notifications to the agent's lifecycle manager.
pub(crate) range_changed: tokio::sync::watch::Sender<()>,
/// How data plane links are opened. `None` disables the data plane.
pub(crate) transport: Option<Arc<dyn PacketTransport>>,
}
@@ -236,8 +228,10 @@ pub(crate) fn spawn(params: RuntimeParams) -> NetworkHandle {
let (commands_tx, commands_rx) = mpsc::channel(64);
let runtime_shutdown = shutdown.clone();
let range_blocked = Arc::new(std::sync::atomic::AtomicBool::new(false));
let runtime_blocked = Arc::clone(&range_blocked);
let task = tokio::spawn(async move {
let mut runtime = Runtime::new(params, runtime_shutdown);
let mut runtime = Runtime::new(params, runtime_shutdown, runtime_blocked);
runtime.run(commands_rx).await;
});
@@ -246,6 +240,7 @@ pub(crate) fn spawn(params: RuntimeParams) -> NetworkHandle {
commands: commands_tx,
shutdown,
task,
range_blocked,
}
}
@@ -257,14 +252,6 @@ pub(crate) fn spawn(params: RuntimeParams) -> NetworkHandle {
/// itself, a few announcements later.
const REACH_EXPIRY: Duration = Duration::from_secs(90);
/// How long a network waits to be told its range before proposing the one
/// derived from its id.
///
/// Only for that fallback: the configured range is proposed at once, as it
/// always was. This is the window in which an existing network's records
/// can arrive and be adopted instead.
const RANGE_PROPOSAL_GRACE: Duration = Duration::from_secs(3);
#[derive(Debug, Clone)]
struct ProtocolDialState {
failures: u32,
@@ -287,8 +274,9 @@ struct Runtime {
discovery_worker: Option<DiscoveryWorker>,
discovery_candidates: mpsc::Receiver<Candidate>,
params: RuntimeParams,
/// When this runtime started, for the fallback range's grace period.
activated: std::time::Instant,
range_blocked: Arc<std::sync::atomic::AtomicBool>,
/// Last range successfully published to the agent's routing table.
published_range: Option<Ipv4Range>,
network_id: NetworkId,
local_id: EndpointId,
shutdown: Shutdown,
@@ -366,7 +354,11 @@ struct Runtime {
}
impl Runtime {
fn new(params: RuntimeParams, shutdown: Shutdown) -> Self {
fn new(
params: RuntimeParams,
shutdown: Shutdown,
range_blocked: Arc<std::sync::atomic::AtomicBool>,
) -> Self {
let network_id = params.keys.network_id();
let local_id = params.adapter.endpoint_id();
let (session_events_tx, session_events_rx) = mpsc::channel(256);
@@ -398,7 +390,8 @@ impl Runtime {
discovery_worker,
discovery_candidates,
params,
activated: std::time::Instant::now(),
range_blocked,
published_range: None,
network_id,
local_id,
shutdown,
@@ -968,35 +961,27 @@ impl Runtime {
if let Some(agreed) = self.state.agreed_range() {
return Some(agreed);
}
// Nobody has settled one yet, so this agent would be proposing its
// own. It must not propose a range it cannot route: one agent has
// one interface, and claiming an address in a range another network
// already owns would spread the collision rather than contain it —
// "the lowest author's range wins" would carry it to everybody.
if let Some(wanted) = self.params.ipv4_range
self.params.ipv4_range
}
/// A late signed range can conflict even though activation was safe.
/// Fail closed before touching claims, plugins or the host interface.
fn suspend_if_overlapping(&self) -> bool {
if let Some(range) = self.effective_range()
&& self
.params
.routes
.would_overlap(self.network_id, wanted)
.is_none()
.would_overlap(self.network_id, range)
.is_some()
{
return Some(wanted);
self.range_blocked
.store(true, std::sync::atomic::Ordering::Release);
self.params.routes.remove_network(self.network_id);
self.shutdown.trigger();
self.params.range_changed.send_replace(());
return true;
}
// The configured range is another network's here, so this one falls
// back to the range derived from its id — after a moment's wait, so
// that a network which already exists gets to say what it uses
// first. Every member derives the same one, so members that reach
// this point agree without negotiating.
let fallback = self.params.ipv4_fallback?;
if self.activated.elapsed() < RANGE_PROPOSAL_GRACE {
return None;
}
self.params
.routes
.would_overlap(self.network_id, fallback)
.is_none()
.then_some(fallback)
false
}
/// Makes sure this agent holds an address, claiming one if it does not.
@@ -1006,7 +991,7 @@ impl Runtime {
async fn ensure_own_claim(&mut self) {
// On the way out. Claiming again here is how a goodbye becomes a
// hello nobody asked for.
if self.released {
if self.released || self.suspend_if_overlapping() {
return;
}
let wanted_hostname = {
@@ -1296,6 +1281,9 @@ impl Runtime {
/// Tells the plugins who holds which overlay address.
async fn publish_allocations(&mut self) {
if self.shutdown.is_triggered() || self.suspend_if_overlapping() {
return;
}
let Some(range) = self.effective_range() else {
return;
};
@@ -1307,10 +1295,6 @@ impl Runtime {
.collect();
allocations.sort_by_key(|(holder, _)| *holder.as_bytes());
for plugin in &self.params.plugins {
plugin.on_address_allocation(self.network_id, range, &allocations);
}
// And the system level's own view, which is what decides whose
// packet is whose. The local address is kept out of the peer list:
// a packet for ourselves does not go over a tunnel.
@@ -1326,17 +1310,27 @@ impl Runtime {
.map(|(holder, address)| (*address, *holder))
.collect(),
};
if let Err(err) = self.params.routes.set_network(self.network_id, routes) {
// Reported once per change rather than swallowed: two networks
// wanting the same addresses is a thing the user has to settle.
self.metrics.plugin_errors += 1;
self.emit(Event::PluginError {
network: self.network_id,
protocol: "overlay".into(),
reason: err.to_string(),
});
if self
.params
.routes
.set_network(self.network_id, routes)
.is_err()
{
self.range_blocked
.store(true, std::sync::atomic::Ordering::Release);
self.params.routes.remove_network(self.network_id);
self.shutdown.trigger();
self.params.range_changed.send_replace(());
return;
}
for plugin in &self.params.plugins {
plugin.on_address_allocation(self.network_id, range, &allocations);
}
if self.published_range != Some(range) {
self.published_range = Some(range);
self.params.range_changed.send_replace(());
}
let Some(interface) = self.params.interface.clone() else {
return;
};
@@ -2387,6 +2381,7 @@ impl Runtime {
members.dedup_by(|a, b| a.endpoint_id == b.endpoint_id);
NetworkStatus {
auto_start: true,
broadcast: self.params.broadcast,
exit_offer: self.params.exit_offer,
exit_via: self.params.exit_via,
@@ -2409,13 +2404,8 @@ impl Runtime {
members,
hostname_conflict: self.hostname_conflict_of(&self.local_id),
range: self.effective_range(),
// Only worth reporting while it is actually stuck: with a
// fallback in play the configured range being another
// network's is how it is meant to work, not a fault.
range_conflict: self
.params
.range_conflict
.filter(|_| self.effective_range().is_none()),
range_conflict: None,
range_conflicts: Vec::new(),
metrics: self.metrics.clone(),
relay: self.hub.counters(),
}