Print the allocated IPv4 address in tun-setup

The overlay IPv4 address is not derived from the keys: it is allocated at
run time and signed, so on a fresh state directory there is nothing for
tun-setup to print. Once the agent has run, the claim is in state.sqlite,
and reading it back takes no directory lock, so tun-setup can show the
`ip address add` line while the agent is running. Records are verified on
the way out; the database is not a trust boundary.

The line needs no keep_addr_on_down and no nodad, unlike its IPv6
counterpart: Linux keeps IPv4 addresses on an interface that has lost
carrier, and IPv4 has no duplicate address detection to stall.

Also fix a race in the four-agent test. A peer counts as connected once
its session authenticates, which can precede the announcement carrying
its hostname, so reading the hostnames straight away occasionally saw
only two. It now waits for them like every other success condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 14:04:44 +01:00
co-authored by Claude Opus 5
parent 8b333455f1
commit 944d98389f
4 changed files with 80 additions and 16 deletions
+12 -2
View File
@@ -174,8 +174,18 @@ sudo sysctl -qw net.ipv6.conf.tsunjwc6dcrtmo5.keep_addr_on_down=1
sudo ip -6 address add fd80:1210:f724:f620:d1bb:f982:3b6e:19bd/64 dev tsunjwc6dcrtmo5 nodad
```
If you asked for IPv4 with `--ipv4-range`, `tun-setup` adds an `ip address add`
line for it too.
With IPv4 enabled, `tun-setup` adds an `ip address add` line for the overlay
IPv4 address too — but only once there is one to print. Unlike the IPv6
address, the IPv4 address is not derived from the keys: it is allocated at run
time and signed (see [docs/sync-model.md](docs/sync-model.md)), so it exists
only after the agent has run once. `tun-setup` then reads it back out of
`state.sqlite`, which does not disturb a running agent, and includes it from
then on. Until then the first `tsunagi up` prints the exact command for the
address it was given.
That IPv4 line needs no `keep_addr_on_down` and no `nodad`: Linux keeps IPv4
addresses on an interface that loses carrier, and IPv4 has no duplicate
address detection to stall. Adding it once is enough.
The MTU is 1280 because that is the minimum IPv6 requires (RFC 8200). Linux
disables IPv6 entirely on an interface below it — the per-device
+7 -1
View File
@@ -271,7 +271,13 @@ async fn main() -> Result<()> {
attached it has no carrier, and Linux then flushes its IPv6 addresses. The
setup printed by `tsunagi tun-setup` sets it; the agent checks the address is
present *and usable* — not tentative, not DAD-failed — before attaching, and
reports what it actually found.
reports what it actually found. This applies to IPv6 only: IPv4 addresses
survive carrier loss, so the overlay IPv4 address is added once and stays.
* **The overlay IPv4 address is not derivable, so `tun-setup` cannot print it
on a fresh state directory.** It is allocated and signed at run time, so the
first `tsunagi up` is what names it; afterwards `tun-setup` reads it back
from `state.sqlite` — a read that takes no directory lock and so does not
disturb a running agent — and includes the `ip address add` line.
* **The agent cannot assign the overlay address itself.** The `tun` crate sets
addresses through an IPv4-only ioctl, so the IPv6 overlay address must come
from `ip -6 address add` or an equivalent. The agent verifies the address is
+46 -5
View File
@@ -344,6 +344,31 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
}
}
/// The IPv4 address this agent has already been allocated, if any.
///
/// Read straight from the mandatory state store. Opening it for reading does
/// not take the directory lock, so this works while the agent is running.
/// Records are verified here too: the database is not a trust boundary.
fn allocated_ipv4(
paths: &StoragePaths,
network: tsunagi::NetworkId,
) -> Result<Option<(std::net::Ipv4Addr, u8)>, Box<dyn std::error::Error>> {
use tsunagi::state::RecordBody;
use tsunagi::storage::StateStore;
let store = StateStore::open(paths.state_db())?;
let author = store.load_or_create_device_identity()?.endpoint_id();
for record in store.signed_records(network)? {
if record.author != *author.as_bytes() || record.verify(network).is_err() {
continue;
}
if let RecordBody::Ipv4Claim { address, range } = record.body {
return Ok(Some((address, range.prefix_len)));
}
}
Ok(None)
}
/// Path of the local control socket for a state directory.
fn control_socket(paths: &StoragePaths, override_path: Option<&PathBuf>) -> PathBuf {
match override_path {
@@ -406,13 +431,24 @@ 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 ipv4_range.is_some() {
// The IPv4 address is allocated at run time, so it can only be shown once
// the agent has one. Reading the state store does not disturb a running
// agent: the directory lock belongs to the agent, not to this reader.
let allocated_v4 = ipv4_range.and_then(|_| allocated_ipv4(&paths, network).ok().flatten());
match (ipv4_range, allocated_v4) {
(Some(_), Some((address, prefix_len))) => {
println!("# IPv4 overlay address {address}/{prefix_len}, allocated and signed");
}
(Some(_), None) => {
println!(
"# IPv4 is allocated once the agent runs and agrees with its peers, so it\n\
# cannot be printed here. Start `tsunagi up`; it prints the exact\n\
# `ip address add` command for the address it was given."
"# IPv4 is allocated once the agent runs and agrees with its peers, so\n\
# there is nothing to print yet. Start `tsunagi up`: it prints the exact\n\
# `ip address add` command for the address it was given, and this\n\
# command will include it from then on."
);
}
(None, _) => {}
}
println!("# Run once as root; then run `tsunagi up` as {user}.");
println!(
"#\n\
@@ -426,9 +462,14 @@ 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((address, prefix_len)) = allocated_v4 {
// IPv4 addresses are not flushed when an interface loses carrier, so
// this one needs none of the treatment IPv6 does.
println!("sudo ip address add {address}/{prefix_len} dev {interface}");
}
println!("\n# To check it afterwards:");
println!("ip -6 addr show dev {interface}");
println!("ip addr show dev {interface}");
println!("\n# To remove it again:");
println!("sudo ip link del dev {interface}");
Ok(())
+8 -1
View File
@@ -42,12 +42,19 @@ async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
}
// Each peer announced its own hostname, so sessions are distinguishable.
let status = agents[0].agent.network_status(network_id).await.unwrap();
// A peer counts as connected as soon as its session is authenticated, which
// can be a round before its announcement carrying the hostname arrives, so
// this waits for the hostnames rather than reading them straight away.
let hostnames: HashSet<String> = wait_until("three distinct peer hostnames", || async {
let status = agents[0].agent.network_status(network_id).await.ok()?;
let hostnames: HashSet<String> = status
.peers
.iter()
.filter_map(|peer| peer.hostname.clone())
.collect();
(hostnames.len() >= 3).then_some(hostnames)
})
.await;
assert_eq!(
hostnames.len(),
3,