Allocate IPv4 addresses and keep them, as signed state

Derived IPv4 addresses could not survive anything: they changed with the
range, and there was no way for a member to come back to the one it had.
Addresses are now allocated and recorded as signed facts, which is the
first slice of the model in docs/sync-model.md.

src/state/ holds one record per author per network, carrying that author's
complete current statement, signed with its persistent device key over a
length-prefixed canonical encoding. Merging follows the model's rules: a
higher version wins, an older one never rolls back a newer, duplicates are
idempotent, absence from a snapshot is not deletion, and a same-version
conflict is resolved identically on every replica and reported rather than
letting replicas diverge. Records are persisted in state.sqlite, with the
record and the author's version counter committed in one transaction
before anything is announced, and distributed as a State control message
that is merged into what the receiver already holds.

No vote, deliberately, despite the request. A majority is not a trust root
here — anyone with the secret can mint identities — and a quorum would
stall with one peer online and diverge across a partition. Signatures plus
a deterministic merge converge without either failure mode: two members
claiming one address at once are resolved by the lower endpoint id, and
the loser allocates again with a higher version.

The range moved from the plugin to the agent, defaults to 10.13.37.0/24,
and is now agreed rather than configured per member: a joining agent
adopts what the network already uses, so --ipv4-range only matters for
whoever starts it. The announcement went back to identity only (version 3)
since the range travels in signed records now.

A release tombstone exists and merges correctly, but nothing emits one
yet.

116 tests. The headline ones: an address survives restarting both agents,
three members get three distinct addresses, and a member started with a
different range adopts the one in use. Confirmed by hand with two CLI
agents restarted end to end.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 13:43:01 +01:00
co-authored by Claude Opus 5
parent ce64264027
commit 84c06c6cac
25 changed files with 1967 additions and 365 deletions
+19 -25
View File
@@ -16,11 +16,12 @@ use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
Ipv4Range, MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
};
use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap};
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::iroh_types::EndpointAddr;
use tsunagi::state::Ipv4Range;
use tsunagi::{Agent, NetworkId};
/// A small agent for private mesh networks.
@@ -104,24 +105,20 @@ struct TunSetupArgs {
ipv4_range: Option<String>,
}
/// 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 flag.
///
/// Absent means the built-in default. A network that already settled on
/// another range wins over both.
fn resolve_ipv4_range(
range: Option<&String>,
) -> Result<Option<Ipv4Range>, Box<dyn std::error::Error>> {
match 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),
Some(text) if text.eq_ignore_ascii_case("none") => Ok(None),
Some(text) => Ok(Some(
text.parse::<Ipv4Range>()
.map_err(|err| format!("--ipv4-range {text}: {err}"))?,
)),
None => Ok(Some(tsunagi::state::DEFAULT_IPV4_RANGE)),
}
}
@@ -237,13 +234,13 @@ struct UpArgs {
#[arg(long)]
wg_mtu: Option<u32>,
/// Also run an IPv4 overlay in this range, as `address/prefix`.
/// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4.
///
/// 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.
/// Defaults to 10.13.37.0/24. Only the first member to join decides:
/// a network that has already settled on a range wins, and a joining
/// agent adopts what it finds. Addresses are allocated from it and
/// recorded in signed state, so each member keeps its own across
/// restarts and long absences.
#[arg(long, value_name = "CIDR")]
ipv4_range: Option<String>,
@@ -541,9 +538,6 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
// 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 {
@@ -555,6 +549,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
]));
let mut config = AgentConfig::new(paths.clone())
.with_overlay_ipv4_range(ipv4_range)
.with_transport(args.transport.into())
.with_discovery(discovery)
.with_discovery_interval(Duration::from_secs(5));
@@ -573,8 +568,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
system_tun_factory()?
};
let mut wg = WireguardConfig::new(paths.state_dir.join("wireguard"))
.with_interface_prefix(args.wg_prefix.clone())
.with_ipv4_range(ipv4_range);
.with_interface_prefix(args.wg_prefix.clone());
if let Some(mtu) = args.wg_mtu {
wg = wg.with_mtu(mtu);
}