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