From 637e2f74e41ec941e0db5f139c02691f4e309538 Mon Sep 17 00:00:00 2001 From: tsunagi Date: Mon, 21 Sep 2026 23:32:23 +0100 Subject: [PATCH] Tell being away from giving up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Leaving was the only way out of a network, and it is the irreversible one: it publishes a release and then removes the configuration, the secret, the network's signed records, its cached hints and the protocol key it used. What somebody usually wants before a reboot, a trip or an experiment is the other thing — stop serving it and keep everything. `tsunagi network stop ` closes that network's sessions, takes its address off the interface and keeps it from starting again. Nothing is announced, deliberately: to the others this device is away, which is an ordinary condition they already handle, and the address and name it holds stay reserved for it. `tsunagi network start ` resumes it where it left off. Both are remembered, so a restart does what the last instruction said rather than what the last command line happened to say. Except when the command line says otherwise: `up --network X` starts X whatever its stored state, because a command naming a network is an instruction to run it. The banner now says which of the three happened — `new`, `already here`, or `was stopped; this command starts it` — since silently, that is a stop that comes back from the dead with nothing to explain it. The listing tells the three states apart too: running with its address, stopped and kept, or configured and waiting for an agent to start. Each row says what to type to move it, because "stop" and "leave" are a pair that has to be easy to tell apart before the irreversible one is typed. The local control protocol is 11. Covered end to end against a running agent: stopping leaves it configured and says so, stopping twice is the state asked for rather than an error, starting brings it back, and the secret afterwards is the one from before — so it is the same network and not a lookalike. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 19 ++ crates/tsunagi-cli/src/main.rs | 229 ++++++++++++++++++++++-- crates/tsunagi-cli/tests/network_cli.rs | 64 +++++++ crates/tsunagi/src/ipc/mod.rs | 30 ++++ crates/tsunagi/src/ipc/unix.rs | 40 ++++- docs/testing.md | 5 +- 6 files changed, 364 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 0819c90..59d9543 100644 --- a/README.md +++ b/README.md @@ -324,6 +324,8 @@ tsunagi id key rotate replace that key tsunagi network the networks this device belongs to tsunagi network join -n lab -s tsn1… join one; adds it to a running agent tsunagi network join -n lab resume one this device has, or make it on the spot +tsunagi network stop stop serving it, keeping everything +tsunagi network start serve it again, from where it left off tsunagi network leave give up the address and name, then forget it tsunagi network secret the secret of each joined network tsunagi network secret just that one, for copying @@ -578,6 +580,23 @@ tsunagi network leave # give up the address and the name, then for tsunagi wipe --yes # remove everything and be a stranger again ``` +**Stopping is not leaving.** `stop` closes this network's sessions, takes +its address off the interface and keeps it from starting again, and that is +all: the configuration, the secret, the signed state and the protocol key +stay exactly as they are, nothing is announced, and to the others this +device is simply away — an ordinary condition they already handle, with its +address and name still reserved for it. `start` picks it up where it left +off. A network named on the `up` command line is started by that command +whatever its stored state, and the start-up banner says so rather than +letting a `stop` quietly come back. + +`leave` is the other one, and it removes, locally: the configuration and +the secret, this network's signed records, its cached address hints and the +protocol key it used there. What it keeps is this author's version counter +for that network — a rejoin has to continue above the release, or every +replica would treat the new claim as stale. Everything about *other* +networks, and the device identity itself, is untouched. + `leave` publishes a signed `Release` **first**, while the agent is running and its sessions are up, so the address and the name are freed for the others instead of staying reserved to a member that has gone. They pass the tombstone diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index cb82a22..37af15b 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -118,11 +118,31 @@ enum NetworkAction { #[arg(long, conflicts_with = "secret")] secret_file: Option, }, + /// Stops serving a network, keeping everything so it can be resumed. + /// + /// Not leaving: the configuration, the secret, the address and the + /// signed state all stay. Sessions close and the address comes off the + /// interface, and nothing is announced — to the others this device is + /// simply away, as if it had been switched off. It stays stopped + /// across restarts until `tsunagi network start`. + Stop { + /// Which network, by id. A unique prefix is enough. + network: String, + }, + /// Serves a stopped network again, from where it left off. + Start { + /// Which network, by id. A unique prefix is enough. + network: String, + }, /// Gives up this device's address and name in a network, and forgets it. /// /// A signed release goes out first, so the address and name are freed /// for the others rather than staying reserved to a member that has /// gone. That needs the agent running; without it nothing can be sent. + /// + /// Everything local goes: the configuration, the secret, this + /// network's signed records, its cached hints and the protocol key it + /// used. `stop` is the one that keeps them. Leave { /// Which network, by id. A unique prefix is enough; the name is not, /// because two networks may share one. @@ -503,10 +523,18 @@ fn network_context( configured: &[tsunagi::storage::StoredNetwork], name: &NetworkName, network_id: tsunagi::NetworkId, -) -> (bool, Option) { +) -> (NetworkStanding, Option) { let known = configured .iter() - .any(|other| other.network_id == network_id); + .find(|other| other.network_id == network_id) + .map(|other| { + if other.auto_start { + NetworkStanding::Known + } else { + NetworkStanding::Stopped + } + }) + .unwrap_or(NetworkStanding::New); let shared = configured .iter() .find(|other| other.name == *name && other.network_id != network_id) @@ -514,6 +542,30 @@ fn network_context( (known, shared) } +/// How a network the command line names stood before the command ran. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum NetworkStanding { + /// Not configured here at all: this command makes it. + New, + /// Configured and meant to run. + Known, + /// Configured and deliberately stopped — which this command undoes, + /// because a command line that names a network says to run it. Said + /// out loud, or a `stop` quietly comes back at the next restart. + Stopped, +} + +impl NetworkStanding { + /// What to print beside the network on start-up. + fn label(self) -> &'static str { + match self { + NetworkStanding::New => "new", + NetworkStanding::Known => "already here", + NetworkStanding::Stopped => "was stopped; this command starts it", + } + } +} + /// Where the secret a command is about to use came from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SecretOrigin { @@ -1278,6 +1330,49 @@ impl tsunagi::ipc::unix::ReportSource for AgentControl { }) } + fn set_active( + &self, + network_id: String, + active: bool, + ) -> tsunagi::BoxFuture<'_, Result> { + Box::pin(async move { + let wanted: tsunagi::NetworkId = network_id + .parse() + .map_err(|err| format!("`{network_id}` is not a network id: {err}"))?; + let name = self + .agent + .list_networks() + .await + .map_err(|err| err.to_string())? + .into_iter() + .find(|network| network.network_id == wanted) + .map(|network| network.name.as_str().to_string()) + .ok_or_else(|| format!("this agent is not in {wanted}"))?; + + let was = self.agent.is_active(wanted).await; + if was != active { + // Both of these also remember the answer, so a restart + // does what the last instruction said. + if active { + self.agent + .activate_network(wanted) + .await + .map_err(|err| err.to_string())?; + } else { + self.agent + .deactivate_network(wanted) + .await + .map_err(|err| err.to_string())?; + } + } + Ok(tsunagi::ipc::ActiveReport { + name, + active, + changed: was != active, + }) + }) + } + fn leave( &self, network_id: String, @@ -1393,6 +1488,8 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box set_active(&paths, &socket, &network, false).await, + Some(NetworkAction::Start { network }) => set_active(&paths, &socket, &network, true).await, Some(NetworkAction::Leave { network, offline }) => { leave_network(&paths, &socket, &network, offline).await } @@ -1544,15 +1641,40 @@ async fn show_networks( for network in &stored { let id = network.network_id.to_string(); let running = live.iter().find(|other| other.network_id == id); - let state = match running { - Some(live) if live.active => { + // Three states, and the difference matters: running, stopped on + // purpose and kept, or configured and waiting for an agent. + let (state, note) = match running { + Some(live) if live.active => ( match live.overlay.as_ref().and_then(|o| o.address.clone()) { Some(address) => format!("running · {address}"), None => "running · no address agreed yet".to_string(), - } - } - Some(_) => "configured, not running".to_string(), - None => "configured".to_string(), + }, + format!( + "`tsunagi network stop {}` pauses it, `leave` gives it up", + short(&id, 10) + ), + ), + Some(_) => ( + "stopped".to_string(), + format!( + "kept as it was; `tsunagi network start {}` resumes it", + short(&id, 10) + ), + ), + None if network.auto_start => ( + "configured · starts with the agent".to_string(), + format!( + "`tsunagi network stop {}` keeps it from starting", + short(&id, 10) + ), + ), + None => ( + "stopped".to_string(), + format!( + "kept as it was; `tsunagi network start {}` resumes it", + short(&id, 10) + ), + ), }; section.push( Row::new( @@ -1560,16 +1682,66 @@ async fn show_networks( network.name.as_str().to_string(), format!("{id} · {state}"), ) - .with_note(format!( - "leave it with `tsunagi network leave {}`", - short(&id, 10) - )), + .with_note(note), ); } out.push(section); print_report("tsunagi networks", &out) } +/// Stops serving a network, or starts serving it again. +/// +/// Deliberately not a signed anything: stopping is this device being away, +/// which is an ordinary condition the others already handle, and the whole +/// point is that everything is still here when it comes back. +async fn set_active( + paths: &StoragePaths, + socket: &std::path::Path, + wanted: &str, + active: bool, +) -> Result<(), Box> { + let networks = stored_networks(paths); + let network = resolve_network(&networks, wanted)?; + let id = network.network_id.to_string(); + let name = network.name.clone(); + + if socket.exists() { + let report = tsunagi::ipc::unix::set_active(socket, &id, active).await?; + match (report.active, report.changed) { + (false, true) => println!( + "stopped `{}` ({}); everything it has is kept", + report.name, + short(&id, 10) + ), + (false, false) => { + println!("`{}` ({}) was already stopped", report.name, short(&id, 10)) + } + (true, true) => println!("started `{}` ({})", report.name, short(&id, 10)), + (true, false) => println!("`{}` ({}) was already running", report.name, short(&id, 10)), + } + if !report.active { + eprintln!( + "\nNothing was announced: to the others this device is away, and the address \ + and name it holds stay reserved for it. `tsunagi network start {}` resumes \ + it; `tsunagi network leave` is the one that gives them up.", + short(&id, 10) + ); + } + return Ok(()); + } + + // No agent: the stored flag is what the next start reads. + let storage = tsunagi::storage::Storage::open(paths)?; + storage.set_auto_start(network.network_id, active).await?; + storage.release_ownership_lock(); + println!( + "`{name}` ({}) will {} with the next `tsunagi up`", + short(&id, 10), + if active { "start" } else { "stay stopped" } + ); + Ok(()) +} + /// Leaves one network, announcing it if there is anything to announce with. async fn leave_network( paths: &StoragePaths, @@ -3158,7 +3330,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { // looks exactly like the network you meant. println!( " network {name} ({network}) · {}", - if known_before { "already here" } else { "new" } + known_before.label() ); println!(" state {}", paths.state_dir.display()); // One line, everything the other side needs, ready to paste. The @@ -4078,15 +4250,19 @@ mod network_context_tests { // like the one they meant to start. let known = configured("lab", 1); let name = known.name.clone(); - let (already, shared) = + let (standing, shared) = network_context(std::slice::from_ref(&known), &name, known.network_id); - assert!(already); + assert_eq!(standing, NetworkStanding::Known); assert_eq!(shared, None); let fresh = configured("lab", 2); - let (already, shared) = + let (standing, shared) = network_context(std::slice::from_ref(&known), &name, fresh.network_id); - assert!(!already, "a different secret is a different network"); + assert_eq!( + standing, + NetworkStanding::New, + "a different secret is a different network" + ); assert_eq!( shared, Some(known.network_id.to_string()), @@ -4096,12 +4272,27 @@ mod network_context_tests { #[test] fn a_name_nobody_here_uses_shares_with_nothing() { - let (already, shared) = network_context( + let (standing, shared) = network_context( &[configured("lab", 1)], &NetworkName::new("other").unwrap(), configured("other", 3).network_id, ); - assert!(!already); + assert_eq!(standing, NetworkStanding::New); assert_eq!(shared, None); } + + #[test] + fn a_stopped_network_on_the_command_line_says_it_is_being_started() { + // A command line naming a network says to run it, so it overrides + // a `stop`. Silently, that is a pause that comes back from the + // dead at the next restart with nothing to explain it. + let mut stopped = configured("lab", 1); + stopped.auto_start = false; + let name = stopped.name.clone(); + + let (standing, _) = + network_context(std::slice::from_ref(&stopped), &name, stopped.network_id); + assert_eq!(standing, NetworkStanding::Stopped); + assert!(standing.label().contains("was stopped")); + } } diff --git a/crates/tsunagi-cli/tests/network_cli.rs b/crates/tsunagi-cli/tests/network_cli.rs index bbfce18..b2a0986 100644 --- a/crates/tsunagi-cli/tests/network_cli.rs +++ b/crates/tsunagi-cli/tests/network_cli.rs @@ -124,3 +124,67 @@ fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() { assert!(out.contains(&id), "the same network, not a new one: {out}"); assert!(out.contains("already configured"), "{out}"); } + +#[test] +fn a_network_can_be_stopped_and_resumed_without_losing_anything() { + // Leaving gives everything up; stopping is being away. The difference + // is what somebody wants when they will be back. + let agent = start("resident", 45073); + let listed = agent.run(&["network"]); + let out = String::from_utf8_lossy(&listed.stdout).to_string(); + let id = out + .lines() + .find(|line| line.contains("resident")) + .and_then(|line| line.split_whitespace().nth(1)) + .expect("the network is listed") + .to_string(); + let secret_before = agent.run(&["network", "secret", &id[..10]]); + let secret_before = String::from_utf8_lossy(&secret_before.stdout) + .trim() + .to_string(); + assert!(secret_before.starts_with("tsn1")); + + let stopped = agent.run(&["network", "stop", &id[..10]]); + assert!( + stopped.status.success(), + "{}", + String::from_utf8_lossy(&stopped.stderr) + ); + assert!( + String::from_utf8_lossy(&stopped.stdout).contains("stopped `resident`"), + "{}", + String::from_utf8_lossy(&stopped.stdout) + ); + + // Still configured, and said to be stopped rather than missing. + let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string(); + assert!(listed.contains(&id), "still configured: {listed}"); + assert!(listed.contains("stopped"), "{listed}"); + + // Stopping what is stopped is the state asked for, not an error. + let again = agent.run(&["network", "stop", &id[..10]]); + assert!(again.status.success()); + assert!( + String::from_utf8_lossy(&again.stdout).contains("already stopped"), + "{}", + String::from_utf8_lossy(&again.stdout) + ); + + let started = agent.run(&["network", "start", &id[..10]]); + assert!(started.status.success()); + assert!( + String::from_utf8_lossy(&started.stdout).contains("started `resident`"), + "{}", + String::from_utf8_lossy(&started.stdout) + ); + let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string(); + assert!(listed.contains("running"), "{listed}"); + + // And nothing was given up on the way: the same network, same secret. + let secret_after = agent.run(&["network", "secret", &id[..10]]); + assert_eq!( + String::from_utf8_lossy(&secret_after.stdout).trim(), + secret_before, + "the secret is kept, so this is the same network" + ); +} diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 6ede801..6a7241a 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -82,6 +82,17 @@ pub enum Request { /// only it can publish the release, and only while its sessions are up. /// The network is named by its id, in the text form `status` prints. Leave(String), + /// Stop serving a network, or start serving it again. + /// + /// Not leaving: the configuration, the secret and the signed state all + /// stay, so it can be resumed exactly where it was. The network is + /// named by its id, in the text form `status` prints. + SetActive { + /// Which network. + network_id: String, + /// Whether it should be running. + active: bool, + }, /// Turn the local resolver on or off, now and for future starts. Dns { /// Whether it should be serving. @@ -111,6 +122,9 @@ impl std::fmt::Debug for Request { Request::Status => f.write_str("Status"), Request::SetHostname(name) => write!(f, "SetHostname({name})"), Request::Leave(network) => write!(f, "Leave({network})"), + Request::SetActive { network_id, active } => { + write!(f, "SetActive {{ {network_id}, active: {active} }}") + } // The name is not a secret; the secret is. Request::Dns { enable, port } => { write!(f, "Dns {{ enable: {enable}, port: {port:?} }}") @@ -132,6 +146,8 @@ pub enum Response { Left(LeftReport), /// A network was joined, or was already there and is now running. Joined(JoinedReport), + /// A network was stopped or started. + Active(ActiveReport), /// What the local resolver is doing, after being changed or asked. /// /// `None` means it is not serving at all. @@ -140,6 +156,20 @@ pub enum Response { Error(String), } +/// What happened when a network was stopped or started. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ActiveReport { + /// The network's name, for the message the user reads. + pub name: String, + /// Whether it is running now. + pub active: bool, + /// Whether this request is what changed it. + /// + /// Stopping something already stopped is not an error — it is the state + /// asked for — but it is worth saying which happened. + pub changed: bool, +} + /// What happened when a network was joined. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct JoinedReport { diff --git a/crates/tsunagi/src/ipc/unix.rs b/crates/tsunagi/src/ipc/unix.rs index f97a527..c5daa0b 100644 --- a/crates/tsunagi/src/ipc/unix.rs +++ b/crates/tsunagi/src/ipc/unix.rs @@ -17,7 +17,8 @@ use crate::BoxFuture; use crate::error::{Error, Result}; use super::{ - DnsReport, JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport, + ActiveReport, DnsReport, JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, + StatusReport, }; /// Builds the report that answers a status request. @@ -61,6 +62,17 @@ pub trait ReportSource: Send + Sync + 'static { Box::pin(async move { Err("this agent cannot join a network".to_string()) }) } + /// Stops serving a network, or starts serving it again. + /// + /// Defaulted to a refusal, like the others. + fn set_active( + &self, + _network_id: String, + _active: bool, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { Err("this agent cannot stop or start a network".to_string()) }) + } + /// Turns the local resolver on or off while the agent runs. /// /// Defaulted to a refusal, like the others. @@ -207,6 +219,12 @@ async fn handle(mut stream: UnixStream, source: Arc) -> Result Ok(report) => Response::Joined(report), Err(reason) => Response::Error(reason), }, + Request::SetActive { network_id, active } => { + match source.set_active(network_id, active).await { + Ok(report) => Response::Active(report), + Err(reason) => Response::Error(reason), + } + } Request::Dns { enable, port } => match source.set_dns(enable, port).await { Ok(report) => Response::Dns(report), Err(reason) => Response::Error(reason), @@ -306,6 +324,24 @@ pub async fn join_network( } } +/// Asks a running agent to stop serving a network, or to serve it again. +pub async fn set_active( + path: impl AsRef, + network_id: &str, + active: bool, +) -> Result { + let path = path.as_ref(); + let request = Request::SetActive { + network_id: network_id.to_string(), + active, + }; + match exchange(path, &request, EXCHANGE_TIMEOUT).await? { + Response::Active(report) => Ok(report), + Response::Error(reason) => Err(Error::Storage(reason)), + other => Err(Error::Storage(format!("unexpected answer: {other:?}"))), + } +} + /// Turns the running agent's local resolver on or off. pub async fn set_dns( path: impl AsRef, @@ -331,7 +367,7 @@ pub async fn set_dns( /// /// Bump it whenever [`Request`], [`Response`] or anything they contain /// changes shape. -pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 10]); +pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 11]); async fn write_message(stream: &mut UnixStream, value: &T) -> Result<()> { let encoded = postcard::to_stdvec(value) diff --git a/docs/testing.md b/docs/testing.md index 514861c..dd36c7a 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -71,8 +71,9 @@ live one is not, and the derived socket path staying short enough to bind. `crates/tsunagi-cli/tests/network_cli.rs` runs the real binary too: joining with no secret invents one, prints it in full and prints a line the other -side can paste unchanged, while a bare name this device already knows -resumes that network instead of inventing another of the same name. +side can paste unchanged; a bare name this device already knows resumes +that network instead of inventing another of the same name; and a network +can be stopped and started again with its secret and its place intact. `crates/tsunagi-cli/tests/dns_service.rs` runs the real binary: the resolver comes up with no interface to attach it to, the listener is not rebuilt on