Let a device leave a network, and start over
Joining was one command and leaving was nothing at all: a network went into `state.sqlite` on the first `up` and stayed there, so a mistyped secret left a second network beside the working one with no way to remove it but editing the database by hand. `tsunagi network` lists what this device belongs to. `tsunagi network leave <id>` publishes a signed release first — while the agent is running and its sessions are up — and only then deactivates the network and removes it. The order is the whole point: signed state has no expiry, so the tombstone is the only thing that ever frees the address and the name for the others, and after the network is gone there is nothing left here to sign one with. Peers pass it on, so a member that was away hears it from them rather than from an agent that has already left. With no agent running nothing can sign or send, and the command says so instead of quietly succeeding: `--offline` drops the network locally and says plainly that the others keep the old claim. The outcome always distinguishes "published to nobody" from "not published at all", because they leave the network in different states. A network is named by its id, and a unique prefix will do. The name is refused on purpose: two networks can share one — that is exactly the situation this command exists for — and picking between them for the user is how the wrong one gets left. The author's version counter deliberately survives. Rejoining the same network with the same key must continue above the release, or every replica that holds the release would treat the new claim as stale and the returning member would be invisible for good. The protocol key does not survive: rejoining is joining, not resuming, and coming back with a key the network was told to let go claims an identity nobody holds any more. Plugins learn about it through a new `on_network_forgotten`, which is about what outlives a session rather than what a deactivation tears down. A released member also drops out of the roster `status` prints. The tombstone stays in the record set — a replica that never heard of it would otherwise reinstate the old claim — but listing an author that gave everything up as a member made leaving look like a peer that had broken. `tsunagi wipe` is the other half: it empties both directories, so the device identity, every network, every signed record and everything a protocol kept beside them go at once and the next start is a stranger. It refuses while an agent holds the directory, and refuses a directory with no `state.sqlite` in it, so a mistyped `--state-dir` cannot take somebody's documents with it. Without `--yes` it only prints what it would remove and what membership would be lost. It is not a goodbye and says so: leaving the networks first is what frees their addresses. The local control protocol is 8 — the socket carries a `Leave` request now, since only the running agent can publish the release. Exercised end to end against real agents: leaving by prefix released the address to a connected peer, leaving by name was refused, `--offline` was refused until asked for explicitly, wipe was refused while the agent ran, and the directory afterwards had no identity in it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -282,3 +282,85 @@ async fn secrets_never_appear_in_status_or_debug_output() {
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wipe_removes_everything_and_the_next_start_is_a_stranger() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("wiped");
|
||||
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let before = agent.endpoint_id();
|
||||
agent.join_network(&name, &secret).await.unwrap();
|
||||
// Something a plugin keeps beside the state, which a wipe must take too.
|
||||
std::fs::create_dir_all(paths.state_dir.join("wireguard")).unwrap();
|
||||
std::fs::write(paths.state_dir.join("wireguard/keys.sqlite"), b"key").unwrap();
|
||||
|
||||
// Not while an agent owns the directory: a half-wiped state under a
|
||||
// running agent is worse than no wipe at all.
|
||||
let refused = tsunagi::storage::wipe(&paths).unwrap_err();
|
||||
assert!(matches!(refused, Error::StateLocked { .. }), "{refused}");
|
||||
agent.shutdown().await;
|
||||
|
||||
let plan = tsunagi::storage::wipe_plan(&paths).unwrap();
|
||||
assert!(
|
||||
plan.entries().any(|path| path.ends_with("state.sqlite")),
|
||||
"{plan:?}"
|
||||
);
|
||||
assert!(
|
||||
plan.entries().any(|path| path.ends_with("wireguard")),
|
||||
"what a plugin kept is state too: {plan:?}"
|
||||
);
|
||||
|
||||
let wiped = tsunagi::storage::wipe(&paths).unwrap();
|
||||
assert_eq!(wiped, plan);
|
||||
assert!(!paths.state_db().exists());
|
||||
assert!(!paths.state_dir.join("wireguard").exists());
|
||||
assert!(!paths.lock_file().exists(), "no lock is left claiming it");
|
||||
|
||||
// A stranger: new identity, no networks, nothing to be surprised by.
|
||||
let fresh = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(fresh.endpoint_id(), before);
|
||||
assert!(fresh.list_networks().await.unwrap().is_empty());
|
||||
fresh.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn wiping_twice_is_as_ordinary_as_wiping_once() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.shutdown().await;
|
||||
|
||||
assert!(!tsunagi::storage::wipe(&paths).unwrap().is_empty());
|
||||
let again = tsunagi::storage::wipe(&paths).unwrap();
|
||||
assert!(again.is_empty(), "nothing left to remove: {again:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_directory_that_is_not_ours_is_refused_rather_than_emptied() {
|
||||
// The mistyped `--state-dir` that would otherwise remove somebody's
|
||||
// documents. A marker decides, not the name of the directory.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let paths = StoragePaths::new(dir.path(), dir.path().join("cache"));
|
||||
std::fs::write(dir.path().join("thesis.txt"), b"years of work").unwrap();
|
||||
|
||||
let err = tsunagi::storage::wipe(&paths).unwrap_err();
|
||||
assert!(
|
||||
err.to_string().contains("does not look like a tsunagi"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(
|
||||
dir.path().join("thesis.txt").exists(),
|
||||
"nothing was removed"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//! Scenario 11: leaving a network, and what the others are left holding.
|
||||
//!
|
||||
//! Leaving is not deactivating and not forgetting. It is a signed statement
|
||||
//! that this member gives up what it claimed, published while there is still
|
||||
//! somebody to hear it, because signed state has no expiry and nothing else
|
||||
//! will ever free the address.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::testing::{TestAgent, network, wait_for_peers, wait_until};
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_frees_the_address_for_everyone_still_there() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-frees");
|
||||
|
||||
let leaver = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = leaver.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
|
||||
let leaver_id = leaver.agent.endpoint_id();
|
||||
let address = wait_until("the one leaving holds an address", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == leaver_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
|
||||
let outcome = leaver.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(outcome.announced, "there was a session to announce it on");
|
||||
assert_eq!(outcome.peers_told, 1);
|
||||
|
||||
// The one still there drops it from the roster. The tombstone stays in
|
||||
// the record set — it has to, or a replica that never heard of it would
|
||||
// reinstate the old claim — but a member that gave everything up is not
|
||||
// a member, and listing it as one makes leaving look like a fault.
|
||||
wait_until("the member is gone from the roster", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.all(|member| member.endpoint_id != leaver_id)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
let taken = stayer.agent.network_status(network_id).await.unwrap();
|
||||
assert!(
|
||||
!taken
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| member.overlay_address_v4 == Some(address)),
|
||||
"the address is free for somebody else"
|
||||
);
|
||||
|
||||
// And locally there is no membership left to be surprised by.
|
||||
assert!(
|
||||
leaver.agent.list_networks().await.unwrap().is_empty(),
|
||||
"the network is gone from the store"
|
||||
);
|
||||
assert!(!leaver.agent.is_active(network_id).await);
|
||||
|
||||
leaver.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_with_nobody_connected_says_so_rather_than_pretending() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-alone");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Nobody is here, so the tombstone reaches nobody. The network is still
|
||||
// left — the caller asked — but the outcome does not claim an audience
|
||||
// there was not one for.
|
||||
let outcome = agent.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(outcome.announced, "it was published locally");
|
||||
assert_eq!(outcome.peers_told, 0);
|
||||
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_a_network_that_is_not_running_tells_nobody() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-inactive");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
// There is no runtime to sign and send from, so nothing was announced
|
||||
// and the outcome says exactly that instead of a quiet success.
|
||||
let outcome = agent.agent.leave_network(network_id).await.unwrap();
|
||||
assert!(!outcome.announced);
|
||||
assert_eq!(outcome.peers_told, 0);
|
||||
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejoining_after_leaving_is_not_taken_for_a_stale_record() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("leaving-rejoin");
|
||||
|
||||
let returner = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = returner.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
|
||||
let returner_id = returner.agent.endpoint_id();
|
||||
wait_until("the one leaving holds an address", || async {
|
||||
stayer
|
||||
.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.ok()?
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == returner_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
|
||||
returner.agent.leave_network(network_id).await.unwrap();
|
||||
wait_until("the release reached the other one", || async {
|
||||
let status = stayer.agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.all(|member| member.endpoint_id != returner_id)
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// The same device, the same key, the same network. The version counter
|
||||
// survived leaving on purpose: a claim numbered below the release would
|
||||
// be ignored by every replica that already has the release, and this
|
||||
// member would be invisible for good.
|
||||
returner.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&stayer.agent, network_id, 1).await;
|
||||
let again = wait_until("the returning member is seen again", || async {
|
||||
stayer
|
||||
.agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.ok()?
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == returner_id)?
|
||||
.overlay_address_v4
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
tsunagi::state::DEFAULT_IPV4_RANGE.contains(again),
|
||||
"an address in the network's range: {again}"
|
||||
);
|
||||
|
||||
returner.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
Reference in New Issue
Block a user