From 415b6a666736cbcf895731063c39d07a3f1a850b Mon Sep 17 00:00:00 2001 From: tsunagi Date: Mon, 21 Sep 2026 23:19:58 +0100 Subject: [PATCH] Make a network on the spot, and say which one you just started MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two reports of the same shape: a network was left and came back after a restart, and `network join` asked for a secret it could have invented. The first was not a bug in leaving. The secret on the start command line derives the network id, so a command line carrying the secret of a network you have just left recreates it on the next start — which is right, it says to join that network, but nothing on screen said so. `up` now marks the network `· new` or `· already here`, and warns in full when another configured network answers to the same name. A name is a label; the id is the identity, and the secret is what decides which of them this is. Said at the moment it happens it is obvious; discovered later in a status report it is a mystery, which is exactly how it went. The second was an omission: `up` had learned to invent a secret and `network join` had not, so the quickest possible thing — a network with somebody for as long as it is needed, then gone — still needed a secret generated first. Both now resolve a bare name the same way: the one network of that name this device already has, or a fresh random secret when there is none. It is printed in full, with the single line the other person can paste as it stands, endpoint id included, because a secret nobody can read is a network nobody can join. The id is printed in full by both answers now. The shortened form belongs in a report, where it is read; this one gets copied into the next command. Covered end to end against a running agent: joining with no secret prints a secret and a pasteable command with a peer in it, and joining a name this device already has resumes that network instead of making another that merely looks the same. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 13 +- crates/tsunagi-cli/src/main.rs | 168 ++++++++++++++++++++++-- crates/tsunagi-cli/tests/network_cli.rs | 126 ++++++++++++++++++ docs/testing.md | 5 + 4 files changed, 298 insertions(+), 14 deletions(-) create mode 100644 crates/tsunagi-cli/tests/network_cli.rs diff --git a/README.md b/README.md index fff2d46..0819c90 100644 --- a/README.md +++ b/README.md @@ -323,7 +323,7 @@ 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 join one whose secret this device already has +tsunagi network join -n lab resume one this device has, or make it on the spot 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 @@ -334,8 +334,8 @@ tsunagi dns on start it, now and after every restart tsunagi dns off stop it, now and after every restart ``` -**A network without a secret makes one.** `tsunagi up --network lab` with no -`--secret` resolves in the obvious way: if this device is already in exactly +**A network without a secret makes one.** `tsunagi up --network lab` and +`tsunagi network join --network lab` both resolve a bare name the same way: if this device is already in exactly one network called `lab`, that one — so the name alone resumes what you have; if it is in none, a fresh random secret, printed in full along with the one line to send the others: @@ -351,6 +351,13 @@ other machine: tsunagi up --network lab --secret tsn1u7c… --peer 91e83a6e2b7a… ``` +`up` says which of the two happened — `· new` or `· already here` beside +the network — and warns when the name is one another configured network +also answers to, because a name is a label and the id is the identity. A +command line with a different secret makes a *different* network of the +same name, and that is how a network you left comes back: the secret on the +command line is what decides which network it is. + That is the ad-hoc case: one person makes a network and sends the command round. The secret is printed *only* when the agent invented it — there is nowhere else to read it from — and never when it was supplied, because then diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 82eaec8..cb82a22 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -490,6 +490,30 @@ fn store_dns_setting( Ok(()) } +/// What this device already knows about the network a command names. +/// +/// Two things, and both are only knowable *before* joining: whether this +/// network was already configured here, and whether another one answers to +/// the same name. A name is a label and an id is the identity, so those two +/// are different networks that share nothing — almost always a mistyped +/// secret, and the one mistake that makes a report unreadable. Said at the +/// moment it happens, it is obvious; discovered later in a status report, +/// it is a mystery. +fn network_context( + configured: &[tsunagi::storage::StoredNetwork], + name: &NetworkName, + network_id: tsunagi::NetworkId, +) -> (bool, Option) { + let known = configured + .iter() + .any(|other| other.network_id == network_id); + let shared = configured + .iter() + .find(|other| other.name == *name && other.network_id != network_id) + .map(|other| other.network_id.to_string()); + (known, shared) +} + /// Where the secret a command is about to use came from. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SecretOrigin { @@ -1361,8 +1385,13 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box { - let secret = load_secret(secret.as_deref(), secret_file.as_deref())?; - join_network(&paths, &socket, &network, secret).await + let name = NetworkName::new(network)?; + // The same rule as `up`: a name this device already has means + // that network, a name nobody has means a new one, and no + // secret is needed to make a network with a friend in a hurry. + let (secret, origin) = + resolve_secret(&paths, &name, secret.as_deref(), secret_file.as_deref())?; + join_network(&paths, &socket, &name, secret, origin).await } Some(NetworkAction::Leave { network, offline }) => { leave_network(&paths, &socket, &network, offline).await @@ -1390,20 +1419,23 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box Result<(), Box> { // The running agent, because a second `up` cannot have the directory // and because this way the network starts at once instead of at the // next restart. if socket.exists() { let report = - tsunagi::ipc::unix::join_network(socket, name, secret.encode().as_str()).await?; + tsunagi::ipc::unix::join_network(socket, name.as_str(), secret.encode().as_str()) + .await?; + // The id in full either way: it is what every other command takes, + // and the shortened form in a report is for reading, not copying. if report.already_configured { println!( "`{}` ({}) was already configured; it is running", - report.name, - short(&report.network_id, 10) + report.name, report.network_id ); } else { println!("joined `{}` ({})", report.name, report.network_id); @@ -1417,13 +1449,15 @@ async fn join_network( short(other, 10) ); } + if origin == SecretOrigin::Generated { + invite(socket, name, &secret).await; + } return Ok(()); } // No agent: configure it, and say when it will take effect rather than // leaving the impression that it is running. - let name = NetworkName::new(name)?; - let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret); + let keys = tsunagi::identity::NetworkKeys::derive(name, &secret); let storage = tsunagi::storage::Storage::open(paths)?; let existing = storage.list_networks().await.unwrap_or_default(); let already = existing @@ -1431,10 +1465,10 @@ async fn join_network( .any(|other| other.network_id == keys.network_id()); let shared = existing .iter() - .find(|other| other.name == name && other.network_id != keys.network_id()) + .find(|other| other.name == *name && other.network_id != keys.network_id()) .map(|other| other.network_id.to_string()); storage - .upsert_network(keys.network_id(), name.clone(), secret, true) + .upsert_network(keys.network_id(), name.clone(), secret.clone(), true) .await?; storage.release_ownership_lock(); @@ -1450,10 +1484,42 @@ async fn join_network( short(&other, 10) ); } + if origin == SecretOrigin::Generated { + println!(" secret {}", secret.encode().as_str()); + } eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`."); Ok(()) } +/// Prints the one line that gets somebody else into this network. +/// +/// Only when the secret was invented here: there is nowhere else to read it +/// from, and the whole point of a network made in a hurry is that the +/// command can be pasted to the other person as it stands. The endpoint id +/// comes from the running agent, because without a peer to contact the +/// other side has nothing to go on. +async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSecret) { + let endpoint = tsunagi::ipc::unix::request_status(socket) + .await + .ok() + .map(|report| report.endpoint_id) + .filter(|id| !id.is_empty()); + println!(" secret {}", secret.encode().as_str()); + match endpoint { + Some(endpoint) => println!( + "\nRun this on the other machine:\n\n \ + tsunagi up --network {name} --secret {} --peer {endpoint}", + secret.encode().as_str() + ), + None => println!( + "\nRun this on the other machine, with this device's endpoint id from \ + `tsunagi id`:\n\n \ + tsunagi up --network {name} --secret {} --peer ", + secret.encode().as_str() + ), + } +} + /// Every configured network, live where an agent can say so. async fn show_networks( paths: &StoragePaths, @@ -2924,6 +2990,11 @@ async fn up(args: UpArgs) -> Result<(), Box> { args.secret.as_deref(), args.secret_file.as_deref(), )?; + // Read before anything joins, because afterwards everything is + // configured and the difference is what the user needs to see. + let network_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id(); + let (known_before, name_shared_with) = + network_context(&stored_networks(&paths), &name, network_id); // Parsed up front so a typo is reported immediately, and so the option is // never silently ignored when the data plane is off. @@ -3081,7 +3152,14 @@ async fn up(args: UpArgs) -> Result<(), Box> { println!("tsunagi is up"); println!(" endpoint id {}", agent.endpoint_id()); println!(" hostname {}", agent.hostname()); - println!(" network {name} ({network})"); + // Whether this command line just made a network or picked up one that + // was already here. Without it, a secret that has quietly created a + // second network of the same name — or recreated one that was left — + // looks exactly like the network you meant. + println!( + " network {name} ({network}) · {}", + if known_before { "already here" } else { "new" } + ); println!(" state {}", paths.state_dir.display()); // One line, everything the other side needs, ready to paste. The // secret is printed in full only when this agent invented it: then @@ -3130,6 +3208,19 @@ async fn up(args: UpArgs) -> Result<(), Box> { } }; + // A name is a label and an id is the identity, so two networks can be + // called the same thing and share nothing. Almost always a mistyped + // secret, and the one mistake that makes a report unreadable. + if let Some(other) = &name_shared_with { + eprintln!( + "\nwarning: `{name}` is also configured here with a different secret, as {}.\n\ + A network is its name *and* its secret, so these two share nothing. If that\n\ + was not meant, `tsunagi network leave` removes one — and check the secret on\n\ + this command line, because it is what decides which network this is.", + short(other, 10) + ); + } + // Last, after the facts, because it is the line to act on: one // command with everything the other side needs. if args.peers.is_empty() { @@ -3959,3 +4050,58 @@ mod secret_tests { assert!(err.to_string().contains("does not say which"), "{err}"); } } + +#[cfg(test)] +mod network_context_tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use tsunagi::identity::NetworkKeys; + use tsunagi::storage::StoredNetwork; + + fn configured(name: &str, seed: u8) -> StoredNetwork { + let name = NetworkName::new(name).unwrap(); + let secret = NetworkSecret::from_bytes([seed; 32]).unwrap(); + let keys = NetworkKeys::derive(&name, &secret); + StoredNetwork { + network_id: keys.network_id(), + name, + secret, + auto_start: true, + } + } + + #[test] + fn a_network_already_here_is_told_from_a_new_one() { + // `up` prints which of the two happened. Without it, a command line + // that quietly recreates a network somebody just left looks exactly + // like the one they meant to start. + let known = configured("lab", 1); + let name = known.name.clone(); + let (already, shared) = + network_context(std::slice::from_ref(&known), &name, known.network_id); + assert!(already); + assert_eq!(shared, None); + + let fresh = configured("lab", 2); + let (already, shared) = + network_context(std::slice::from_ref(&known), &name, fresh.network_id); + assert!(!already, "a different secret is a different network"); + assert_eq!( + shared, + Some(known.network_id.to_string()), + "and the one it shares a name with is named" + ); + } + + #[test] + fn a_name_nobody_here_uses_shares_with_nothing() { + let (already, shared) = network_context( + &[configured("lab", 1)], + &NetworkName::new("other").unwrap(), + configured("other", 3).network_id, + ); + assert!(!already); + assert_eq!(shared, None); + } +} diff --git a/crates/tsunagi-cli/tests/network_cli.rs b/crates/tsunagi-cli/tests/network_cli.rs new file mode 100644 index 0000000..bbfce18 --- /dev/null +++ b/crates/tsunagi-cli/tests/network_cli.rs @@ -0,0 +1,126 @@ +//! Making and joining a network from the command line, against a real agent. +//! +//! The rules here are small and easy to get wrong in a way no unit test +//! notices: what a bare network name means, when a secret is invented, and +//! whether what is printed is enough for the other person to paste. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::time::Duration; + +use tempfile::TempDir; + +/// An agent running as a real process, with its own directories. +struct Running { + child: std::process::Child, + dir: TempDir, +} + +impl Drop for Running { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +impl Running { + fn run(&self, args: &[&str]) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) + .args(args) + .arg("--state-dir") + .arg(self.dir.path().join("state")) + .arg("--cache-dir") + .arg(self.dir.path().join("cache")) + .output() + .expect("the agent binary runs") + } +} + +fn start(network: &str, port: u16) -> Running { + let dir = TempDir::new().unwrap(); + let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) + .args([ + "up", + "--network", + network, + "--secret", + "a-secret-for-the-cli-test", + ]) + .arg("--state-dir") + .arg(dir.path().join("state")) + .arg("--cache-dir") + .arg(dir.path().join("cache")) + .args(["--reach", "local", "--no-tun"]) + .arg("--bind") + .arg(format!("127.0.0.1:{port}")) + .args(["--log", "error", "--status-interval", "0"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the agent binary starts"); + // Long enough for the control socket to be there to talk to. + std::thread::sleep(Duration::from_secs(2)); + Running { child, dir } +} + +#[test] +fn joining_with_no_secret_makes_one_and_prints_what_to_send() { + // The hurry case: a network with somebody, for as long as it is needed, + // and then gone. Asking for a secret first is a step with no purpose, + // and a secret that is not printed is a network nobody else can join. + let agent = start("resident", 45071); + + let joined = agent.run(&["network", "join", "--network", "spontaneous"]); + assert!( + joined.status.success(), + "joining failed: {}", + String::from_utf8_lossy(&joined.stderr) + ); + let out = String::from_utf8_lossy(&joined.stdout); + assert!(out.contains("joined `spontaneous`"), "{out}"); + + // The secret in full, and one line the other side can paste as it is. + let secret = out + .lines() + .find_map(|line| line.trim().strip_prefix("secret ")) + .expect("the invented secret is printed"); + assert!(secret.starts_with("tsn1"), "{out}"); + let command = out + .lines() + .find(|line| line.contains("tsunagi up --network spontaneous")) + .expect("a command to send"); + assert!(command.contains(secret), "with the secret in it: {out}"); + assert!( + command.contains("--peer "), + "and somewhere to find us: {out}" + ); + + // Joining the same name again resumes it rather than making another + // network that merely looks the same. + let again = agent.run(&["network", "join", "--network", "spontaneous"]); + let out = String::from_utf8_lossy(&again.stdout); + assert!(again.status.success()); + assert!(out.contains("already configured"), "{out}"); + assert!(!out.contains("secret tsn1"), "no second secret: {out}"); +} + +#[test] +fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() { + // `up --network resident` with no secret is the same rule: this device + // has exactly one network of that name, so that is the one meant. + let agent = start("resident", 45072); + let listed = agent.run(&["network"]); + let before = String::from_utf8_lossy(&listed.stdout).to_string(); + let id = before + .lines() + .find(|line| line.contains("resident")) + .and_then(|line| line.split_whitespace().nth(1)) + .expect("the network is listed") + .to_string(); + + let joined = agent.run(&["network", "join", "--network", "resident"]); + assert!(joined.status.success()); + let out = String::from_utf8_lossy(&joined.stdout); + assert!(out.contains(&id), "the same network, not a new one: {out}"); + assert!(out.contains("already configured"), "{out}"); +} diff --git a/docs/testing.md b/docs/testing.md index 3e93f94..514861c 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -69,6 +69,11 @@ asking a running agent for status over a real Unix socket, joining and leaving a network through it, a leftover socket file being replaced while a 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. + `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 the way past, a name outside every zone is refused, each network gets a zone