diff --git a/README.md b/README.md index 631155c..005b426 100644 --- a/README.md +++ b/README.md @@ -70,18 +70,29 @@ On the first machine: ```bash cargo build --release -./target/release/tsunagi network secret generate # prints tsn1...; share it privately ./target/release/tsunagi status # this device, the agent, and this host ./target/release/tsunagi network # the networks this device belongs to - -./target/release/tsunagi up --network lab --secret "$SECRET" ``` -It prints its endpoint id and then waits. On the second machine, pass that id: +Two commands do the work, and they are separate on purpose: `up` runs the +agent — one per device, for as long as you want it — and `join` decides +what it belongs to, at any time, while it runs. ```bash -./target/release/tsunagi up --network lab --secret "$SECRET" \ - --peer +# Terminal one: the agent. It prints its endpoint id and then serves. +./target/release/tsunagi up + +# Terminal two: make a network. With no --secret it invents one and prints +# it, along with the line to send the other machine. +./target/release/tsunagi join --network lab +``` + +On the second machine, start its agent with the first machine's endpoint id +and join the same network with the secret that was printed: + +```bash +./target/release/tsunagi up --peer +./target/release/tsunagi join --network lab --secret tsn1... ``` Within a few seconds both print something like: @@ -132,8 +143,8 @@ ping 100.65.243.53 what they find, so `--ipv4-range` only matters for whoever starts the network: ```bash -tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16 -tsunagi up --network lab --secret "$SECRET" --ipv4-range none # no data plane +tsunagi up --ipv4-range 10.44.0.0/16 +tsunagi up --ipv4-range none # no data plane at all ``` One agent has one interface, so two of its networks cannot both use that @@ -195,7 +206,7 @@ each named after the network, so members can be reached by name instead of by address: ```bash -tsunagi up --network lab --secret "$SECRET" --dns +tsunagi up --dns # or `tsunagi dns on` against a running agent dig @127.0.0.1 -p 5354 music.lab ``` @@ -336,29 +347,29 @@ 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` 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: +**A network without a secret makes one.** `tsunagi join --network lab` +resolves a bare name in the obvious 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 with the +line to send the other machine: ``` -tsunagi is up - network lab (k2on43wadb…) - secret tsn1u7c… +joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba) + secret tsn1u7c… -No --peer was given, so this agent waits to be contacted. Run this on the -other machine: +Run this on the other machine: - tsunagi up --network lab --secret tsn1u7c… --peer 91e83a6e2b7a… + tsunagi join --network lab --secret tsn1u7c… + +Its agent has to be running. If it is not: + + tsunagi up --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. +Two networks of one name is a thing that happens — a mistyped secret makes +one — so joining says whether the network was already here, and warns when +another configured network answers to the same name. A name is a label and +the id is the identity. 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 diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 1d211ca..c2e8dd5 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -37,12 +37,17 @@ struct Cli { #[derive(Debug, Subcommand)] enum Command { - /// Shows this device's identity and secrets, and changes them. + /// Shows this device's identity, and changes it. /// /// Every item follows the same shape: name it to see it, name it with a /// value to change it. Id(IdArgs), - /// Joins a network and runs until interrupted. + /// Runs the agent until interrupted: the device's one process. + /// + /// It serves every network this device has joined, answers `status`, + /// and is what `join`, `leave`, `stop` and `dns` talk to. Which + /// networks it is in is decided separately, and can change while it + /// runs. // Boxed: it is much larger than the other variants, and every command // but this one would otherwise pay for its size. A `//` comment, not a // `///` one, or clap would print it as help. @@ -51,6 +56,11 @@ enum Command { Status(StatusArgs), /// Shows the protocols this build can carry packets with. Protocols, + /// Makes a network, or joins one — the command that does the work. + /// + /// The same as `tsunagi network join`, at the top level because it is + /// what gets typed: `up` runs the agent, this decides what it is in. + Join(JoinArgs), /// Shows the networks this device belongs to, and leaves them. Network(NetworkArgs), /// Shows the local resolver, and turns it on or off. @@ -97,27 +107,42 @@ struct NetworkArgs { action: Option, } +/// Making or joining one network. +/// +/// The name alone is enough: this device's network of that name if it has +/// one, and otherwise a new network with a secret invented here and printed +/// so it can be passed on. +#[derive(Debug, Args)] +struct JoinArgs { + #[command(flatten)] + paths: PathArgs, + + /// Control socket to talk to. Derived from the state directory by default. + #[arg(long)] + control_socket: Option, + + /// Network name. Must be identical on every participant. + #[arg(long, short = 'n')] + network: String, + + /// The shared secret, as printed when a network is made. + #[arg(long, short = 's', env = "TSUNAGI_SECRET")] + secret: Option, + + /// Read the shared secret from a file instead of the command line. + #[arg(long, conflicts_with = "secret")] + secret_file: Option, +} + #[derive(Debug, Subcommand)] enum NetworkAction { - /// Joins a network, adding it to the agent that is already running. + /// Makes a network, or joins one, in the agent that is already running. /// - /// The state directory belongs to one live agent, so a second `up` - /// cannot add a network to it — this can, and takes effect at once. - /// With no agent running it is configured and starts with the next - /// `tsunagi up`. - Join { - /// Network name. Must be identical on every participant. - #[arg(long, short = 'n')] - network: String, - - /// The shared secret, as printed by `tsunagi network secret generate`. - #[arg(long, short = 's', env = "TSUNAGI_SECRET")] - secret: Option, - - /// Read the shared secret from a file instead of the command line. - #[arg(long, conflicts_with = "secret")] - secret_file: Option, - }, + /// The state directory belongs to one live agent, so this is how a + /// network is added to it, and it takes effect at once. With no agent + /// running it is configured and starts with the next `tsunagi up`. + /// `tsunagi join` is the same command, spelled shorter. + Join(JoinArgs), /// Stops serving a network, keeping everything so it can be resumed. /// /// Not leaving: the configuration, the secret, the address and the @@ -311,28 +336,6 @@ struct UpArgs { #[command(flatten)] paths: PathArgs, - /// Network name. Must be identical on every participant. - /// - /// Optional: with no network this starts the agent and whatever it is - /// already configured for, and `tsunagi network join` adds networks to - /// it while it runs. One agent, one identity, as many networks as you - /// like. - #[arg(long, short = 'n')] - network: Option, - - /// The shared secret, as printed by `tsunagi secret`. - #[arg( - long, - short = 's', - env = "TSUNAGI_SECRET", - conflicts_with = "secret_file" - )] - secret: Option, - - /// Read the shared secret from a file instead of the command line. - #[arg(long)] - secret_file: Option, - /// Hostname to announce. Defaults to the machine's. #[arg(long, help_heading = "System")] hostname: Option, @@ -560,17 +563,6 @@ enum NetworkStanding { 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 { @@ -588,7 +580,7 @@ enum SecretOrigin { /// /// * given — use it, whatever is stored; /// * not given and this device is already in exactly one network of that -/// name — that one, so `up --network lab` resumes rather than making a +/// name — that one, so `join --network lab` resumes rather than making a /// stranger with the same name; /// * not given and there is none — invent one, because an ad-hoc network is /// a thing people want and "generate a secret first" is a step with no @@ -679,6 +671,7 @@ async fn run(command: Command) -> Result<(), Box> { Command::Up(args) => up(*args).await, Command::Status(args) => status(args).await, Command::Protocols => show_protocols(), + Command::Join(args) => join_command(args).await, Command::Network(args) => network_command(args).await, Command::Dns(args) => dns_command(args).await, Command::Wipe(args) => wipe(args).await, @@ -1480,19 +1473,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box show_networks(&paths, &socket).await, - Some(NetworkAction::Join { - network, - secret, - secret_file, - }) => { - 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::Join(args)) => join_command(args).await, Some(NetworkAction::Stop { network }) => set_active(&paths, &socket, &network, false).await, Some(NetworkAction::Start { network }) => set_active(&paths, &socket, &network, true).await, Some(NetworkAction::Leave { network, offline }) => { @@ -1517,6 +1498,27 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box Result<(), Box> { + let paths = args.paths.resolve()?; + let socket = control_socket(&paths, args.control_socket.as_ref()); + let name = NetworkName::new(args.network)?; + // 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 + // somebody in a hurry. + let (secret, origin) = resolve_secret( + &paths, + &name, + args.secret.as_deref(), + args.secret_file.as_deref(), + )?; + // How it stood before, read now 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 (standing, _) = network_context(&stored_networks(&paths), &name, network_id); + join_network(&paths, &socket, &name, secret, origin, standing).await +} + /// Joins a network: into the running agent if there is one. async fn join_network( paths: &StoragePaths, @@ -1524,6 +1526,7 @@ async fn join_network( name: &NetworkName, secret: NetworkSecret, origin: SecretOrigin, + standing: NetworkStanding, ) -> 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 @@ -1534,13 +1537,21 @@ async fn join_network( .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", + match standing { + NetworkStanding::New => { + println!("joined `{}` ({})", report.name, report.network_id) + } + NetworkStanding::Known => println!( + "`{}` ({}) was already here; it is running", report.name, report.network_id - ); - } else { - println!("joined `{}` ({})", report.name, report.network_id); + ), + // Joining is an instruction to run it, so it undoes a stop — + // said out loud, because a pause that ends without a word is + // a pause nobody can rely on. + NetworkStanding::Stopped => println!( + "`{}` ({}) was stopped; it is running again", + report.name, report.network_id + ), } if let Some(other) = &report.name_shared_with { eprintln!( @@ -1561,11 +1572,10 @@ async fn join_network( // leaving the impression that it is running. 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 - .iter() - .any(|other| other.network_id == keys.network_id()); - let shared = existing + let shared = storage + .list_networks() + .await + .unwrap_or_default() .iter() .find(|other| other.name == *name && other.network_id != keys.network_id()) .map(|other| other.network_id.to_string()); @@ -1574,10 +1584,17 @@ async fn join_network( .await?; storage.release_ownership_lock(); - if already { - println!("`{name}` ({}) was already configured", keys.network_id()); - } else { - println!("joined `{name}` ({})", keys.network_id()); + match standing { + NetworkStanding::New => println!("joined `{name}` ({})", keys.network_id()), + NetworkStanding::Known => { + println!("`{name}` ({}) was already here", keys.network_id()) + } + NetworkStanding::Stopped => { + println!( + "`{name}` ({}) was stopped; it will start", + keys.network_id() + ) + } } if let Some(other) = shared { eprintln!( @@ -1607,18 +1624,17 @@ async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSe .map(|report| report.endpoint_id) .filter(|id| !id.is_empty()); println!(" secret {}", secret.encode().as_str()); + println!( + "\nRun this on the other machine:\n\n \ + tsunagi join --network {name} --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() + "\nIts agent has to be running. If it is not:\n\n \ + tsunagi up --peer {endpoint}" ), + None => println!("\nIts agent has to be running: `tsunagi up`."), } } @@ -2018,7 +2034,7 @@ async fn show_identity( let mut section = Section::new("networks"); section.push(match networks.len() { 0 => Row::new(Health::Info, "none", "no network has been joined") - .with_note("`tsunagi network join --network --secret ` joins one"), + .with_note("`tsunagi join --network ` makes or joins one"), count => Row::new( Health::Info, "joined", @@ -3160,35 +3176,6 @@ async fn netwatch_addresses() -> Vec { async fn up(args: UpArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; - // A network on the command line is joined at start; without one the - // agent brings up whatever it is already configured for and waits for - // `tsunagi network join`. - let joining = match &args.network { - Some(network) => { - let name = NetworkName::new(network.clone())?; - let (secret, origin) = resolve_secret( - &paths, - &name, - 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 (standing, shared) = network_context(&stored_networks(&paths), &name, network_id); - Some((name, secret, origin, standing, shared)) - } - None => { - if args.secret.is_some() || args.secret_file.is_some() { - return Err( - "a secret says nothing without a network: add --network, or leave both \ - out to start the agent with what it is already configured for" - .into(), - ); - } - None - } - }; // Parsed up front so a typo is reported immediately, and so the option is // never silently ignored when the data plane is off. @@ -3313,19 +3300,15 @@ async fn up(args: UpArgs) -> Result<(), Box> { Err(tsunagi::Error::StateLocked { path }) => { let socket = control_socket(&paths, args.control_socket.as_ref()); if socket.exists() { - let named = joining - .as_ref() - .map_or_else(|| "".to_string(), |(name, ..)| name.to_string()); return Err(format!( "an agent is already running for {}, and one state directory is one \ - agent.\n\n\ - To add `{named}` to it — same device, same interface, another \ - network:\n\n \ - tsunagi network join --network {named}\n\n\ + agent — it is the device, not a network.\n\n\ + To add a network to it:\n\n \ + tsunagi join --network \n\n\ To run a second, separate agent instead, give it everything of its \ own:\n\n \ tsunagi up --state-dir --cache-dir --interface tsun1 \ - --ipv4-range --network {named}\n\n\ + --ipv4-range \n\n\ That is a different identity with its own interface, not this one \ with another network. `tsunagi network` lists what this one has.", path.display() @@ -3339,51 +3322,22 @@ async fn up(args: UpArgs) -> Result<(), Box> { // From here on every exit goes through `agent.shutdown()`, so the endpoint // is never dropped without being closed. let mut events = agent.subscribe(); - let joined = match &joining { - Some((name, secret, _, _, _)) => match agent.join_network(name, secret).await { - Ok(network) => Some(network), - Err(err) => { - agent.shutdown().await; - return Err(err.into()); - } - }, - None => None, - }; println!("tsunagi is up"); println!(" endpoint id {}", agent.endpoint_id()); println!(" hostname {}", agent.hostname()); - match (&joining, joined) { - // 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. - (Some((name, _, _, standing, _)), Some(network)) => { - println!(" network {name} ({network}) · {}", standing.label()) + // What this device belongs to is a separate question from whether its + // agent is running, and `tsunagi join` answers it at any time. + let configured = agent.list_networks().await.unwrap_or_default(); + let running = configured.iter().filter(|network| network.active).count(); + println!( + " networks {}", + match configured.len() { + 0 => "none yet · `tsunagi join --network ` makes or joins one".to_string(), + total => format!("{running} of {total} running · `tsunagi network` lists them"), } - _ => { - let configured = agent.list_networks().await.unwrap_or_default(); - let running = configured.iter().filter(|network| network.active).count(); - println!( - " networks {}", - match configured.len() { - 0 => - "none yet · `tsunagi network join --network ` adds one".to_string(), - total => - format!("{running} of {total} running · `tsunagi network` lists them"), - } - ); - } - } + ); 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 - // there is nowhere else to read it from, and an ad-hoc network is - // exactly "one person made it and sent the command round". A secret - // the user supplied is theirs already and is not echoed. - if let Some((_, secret, SecretOrigin::Generated, _, _)) = &joining { - println!(" secret {}", secret.encode().as_str()); - } // A local resolver for every network this agent is in, each a zone // named after it. A name that shadows a public one is reported and then // used, because that is a decision and not a mistake. @@ -3419,36 +3373,6 @@ 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((name, _, _, _, Some(other))) = &joining { - 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. Only when a network - // was named — with none there is nothing to invite anybody to. - if args.peers.is_empty() - && let Some((name, secret, origin, _, _)) = &joining - { - let shareable = match origin { - SecretOrigin::Generated => secret.encode().as_str().to_string(), - SecretOrigin::Given | SecretOrigin::Stored => "".to_string(), - }; - println!( - "\nNo --peer was given, so this agent waits to be contacted. \ - Run this on the other machine:\n\n \ - tsunagi up --network {name} --secret {shareable} --peer {}", - agent.endpoint_id() - ); - } println!("\nPress Ctrl-C to stop.\n"); let status_every = @@ -3474,7 +3398,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { None => std::future::pending::<()>().await, } }, if ticker.is_some() => { - print_status(&agent, joined, wireguard.as_deref()).await; + print_status(&agent, None, wireguard.as_deref()).await; } } } @@ -4240,7 +4164,7 @@ mod secret_tests { #[test] fn a_name_this_device_already_has_resumes_it() { - // Otherwise `tsunagi up --network lab` would invent a stranger with + // Otherwise `tsunagi join --network lab` would invent a stranger with // the same name every time, which is the confusion this whole // report format exists to prevent. let dir = tempfile::tempdir().unwrap(); @@ -4345,10 +4269,10 @@ mod network_context_tests { } #[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. + fn a_stopped_network_is_told_from_one_that_is_merely_known() { + // Joining a stopped network starts it — which is right, it is an + // instruction to run it — and saying so is what keeps a pause + // from ending without a word. let mut stopped = configured("lab", 1); stopped.auto_start = false; let name = stopped.name.clone(); @@ -4356,6 +4280,5 @@ mod network_context_tests { 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/dns_service.rs b/crates/tsunagi-cli/tests/dns_service.rs index c2714f5..139435d 100644 --- a/crates/tsunagi-cli/tests/dns_service.rs +++ b/crates/tsunagi-cli/tests/dns_service.rs @@ -98,51 +98,63 @@ impl Drop for Running { /// There is no separate zone setting: an agent serves a zone per network, /// named after it, so the network name is the zone name. fn start(zone: &str, port: u16) -> Running { + let agent = start_without_dns_at(zone, Some(port)); + let joined = agent.run(&[ + "join", + "--network", + zone, + "--secret", + "a-secret-for-the-dns-test", + ]); + assert!( + joined.status.success(), + "{}", + String::from_utf8_lossy(&joined.stderr) + ); + agent +} + +/// Starts an agent in one network with the resolver off, to switch on later. +fn start_without_dns(network: &str) -> Running { + let agent = start_without_dns_at(network, None); + let joined = agent.run(&[ + "join", + "--network", + network, + "--secret", + "a-secret-for-the-dns-test", + ]); + assert!( + joined.status.success(), + "{}", + String::from_utf8_lossy(&joined.stderr) + ); + agent +} + +/// The agent alone: `up` runs it, and what it belongs to is decided after. +fn start_without_dns_at(_network: &str, dns_port: Option) -> Running { let dir = TempDir::new().unwrap(); - let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) - .args([ - "up", - "--network", - zone, - "--secret", - "a-secret-for-the-dns-test", - ]) + let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")); + command + .arg("up") .arg("--state-dir") .arg(dir.path().join("state")) .arg("--cache-dir") .arg(dir.path().join("cache")) // No real interface and no internet: this is about the wiring. - .args(["--reach", "local", "--no-tun", "--dns"]) - .args(["--dns-port", &port.to_string()]) - .args(["--log", "error", "--status-interval", "0"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .expect("the agent binary starts"); - Running { child, dir } -} - -/// Starts an agent with the resolver off, to be switched on later. -fn start_without_dns(network: &str) -> 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-dns-test", - ]) - .arg("--state-dir") - .arg(dir.path().join("state")) - .arg("--cache-dir") - .arg(dir.path().join("cache")) - .args(["--reach", "local", "--no-tun"]) + .args(["--reach", "local", "--no-tun"]); + if let Some(port) = dns_port { + command.arg("--dns").args(["--dns-port", &port.to_string()]); + } + let child = command .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 } } @@ -213,7 +225,6 @@ fn every_network_gets_a_zone_of_its_own() { wait_for_answer(server, &format!("{host}.first.internal")); let joined = agent.run(&[ - "network", "join", "--network", "second.internal", diff --git a/crates/tsunagi-cli/tests/network_cli.rs b/crates/tsunagi-cli/tests/network_cli.rs index 85fb715..3e51cbb 100644 --- a/crates/tsunagi-cli/tests/network_cli.rs +++ b/crates/tsunagi-cli/tests/network_cli.rs @@ -58,31 +58,22 @@ fn start_bare(port: u16) -> Running { Running { child, dir } } +/// An agent already in one network, the way most of these start. 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 } + let agent = start_bare(port); + let joined = agent.run(&[ + "join", + "--network", + network, + "--secret", + "a-secret-for-the-cli-test", + ]); + assert!( + joined.status.success(), + "{}", + String::from_utf8_lossy(&joined.stderr) + ); + agent } #[test] @@ -109,26 +100,24 @@ fn joining_with_no_secret_makes_one_and_prints_what_to_send() { assert!(secret.starts_with("tsn1"), "{out}"); let command = out .lines() - .find(|line| line.contains("tsunagi up --network spontaneous")) + .find(|line| line.contains("tsunagi join --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}" - ); + // And where to find this device, for an agent that is not up yet. + assert!(out.contains("--peer "), "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("already here"), "{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 + // `join --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"]); @@ -144,7 +133,7 @@ fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() { 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}"); + assert!(out.contains("already here"), "{out}"); } #[test] @@ -238,17 +227,26 @@ fn an_agent_starts_with_no_network_and_takes_one_later() { } #[test] -fn a_secret_with_no_network_is_refused_rather_than_ignored() { - let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) - .args(["up", "--secret", "tsn1whatever"]) - .arg("--state-dir") - .arg(TempDir::new().unwrap().path().join("state")) - .output() - .expect("the agent binary runs"); - assert!(!out.status.success()); - assert!( - String::from_utf8_lossy(&out.stderr).contains("says nothing without a network"), - "{}", - String::from_utf8_lossy(&out.stderr) - ); +fn up_is_the_agent_and_takes_no_network_at_all() { + // The two commands are separate on purpose: `up` runs the device's + // agent, `join` decides what it is in. A network on `up` would be a + // second way to do the same thing, and the one that cannot be undone + // without a restart. + for argument in ["--network", "--secret"] { + let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) + .args(["up", argument, "whatever"]) + .arg("--state-dir") + .arg(TempDir::new().unwrap().path().join("state")) + .output() + .expect("the agent binary runs"); + assert!( + !out.status.success(), + "`up {argument}` should not be a thing" + ); + assert!( + String::from_utf8_lossy(&out.stderr).contains("unexpected argument"), + "{}", + String::from_utf8_lossy(&out.stderr) + ); + } } diff --git a/crates/tsunagi/src/overlay/interface.rs b/crates/tsunagi/src/overlay/interface.rs index 2338ca6..ac56e08 100644 --- a/crates/tsunagi/src/overlay/interface.rs +++ b/crates/tsunagi/src/overlay/interface.rs @@ -207,15 +207,12 @@ impl Interface { } } - // One address per network, so at most a handful; the request carries - // the first and the provisioner reconciles the rest. + // Every address this agent holds, in every network it is in. The + // plan is exhaustive by contract, so this is also what takes an + // address off the interface when a network is left or stopped. let request = TunRequest { name: self.name.clone(), - address: wanted.first().and_then(|cidr| match cidr.addr { - IpAddr::V4(address) => Some(address), - IpAddr::V6(_) => None, - }), - prefix_len: wanted.first().map_or(0, |cidr| cidr.prefix_len), + addresses: wanted.clone(), mtu: self.mtu, }; self.factory.reconfigure(request).await?; diff --git a/crates/tsunagi/src/overlay/provision/factory.rs b/crates/tsunagi/src/overlay/provision/factory.rs index a376bb3..3771035 100644 --- a/crates/tsunagi/src/overlay/provision/factory.rs +++ b/crates/tsunagi/src/overlay/provision/factory.rs @@ -11,20 +11,18 @@ use std::sync::Arc; use crate::BoxFuture; use crate::overlay::OverlayError; -use super::super::config::Cidr; use super::super::tun::{TunDevice, TunFactory, TunRequest}; use super::{InterfacePlan, InterfaceProvisioner}; /// Turns a [`TunRequest`] into the plan for a host interface. fn plan_for(request: &TunRequest) -> Result { - let mut addresses = Vec::new(); - if let Some(address) = request.address { - addresses.push(Cidr::new(address.into(), request.prefix_len)?); - } + // Every address, because the plan is exhaustive: the provisioner adds + // what is missing and removes what is not in it. Passing one network's + // address would take every other network's off the host. Ok(InterfacePlan::new( request.name.clone(), request.mtu, - addresses, + request.addresses.clone(), )) } @@ -113,14 +111,21 @@ mod tests { use std::net::Ipv4Addr; + use super::super::super::config::Cidr; use super::super::{LinkKind, MockHost, MockProvisioner}; use super::*; fn request(v4: Option) -> TunRequest { + addressed(v4.into_iter().collect()) + } + + fn addressed(v4: Vec) -> TunRequest { TunRequest { name: "tsunfactory".into(), - address: v4, - prefix_len: 24, + addresses: v4 + .into_iter() + .map(|address| Cidr::new(address.into(), 24).unwrap()) + .collect(), mtu: 1280, } } @@ -201,4 +206,50 @@ mod tests { let err = factory.create(request(None)).await.unwrap_err(); assert!(err.to_string().contains("bridge"), "{err}"); } + + #[tokio::test] + async fn every_network_s_address_reaches_the_host_not_just_the_first() { + // One agent has one interface and a network apiece on it. Carrying + // only the first address is carrying only the first network: the + // rest have addresses the operating system has never heard of, and + // their traffic goes nowhere while the status says all is well. + let provisioner = Arc::new(MockProvisioner::default()); + let factory = ManagedTunFactory::new(provisioner.clone()); + factory + .create(addressed(vec![ + Ipv4Addr::new(10, 13, 37, 69), + Ipv4Addr::new(10, 156, 200, 116), + ])) + .await + .unwrap(); + + let state = provisioner.host().get("tsunfactory").unwrap(); + let addresses: Vec = state + .addresses + .iter() + .map(|entry| entry.to_string()) + .collect(); + assert!( + addresses.contains(&"10.13.37.69/24".to_string()), + "{addresses:?}" + ); + assert!( + addresses.contains(&"10.156.200.116/24".to_string()), + "{addresses:?}" + ); + + // And leaving one network takes its address off, because the plan + // is what the interface should carry and nothing else. + factory + .reconfigure(addressed(vec![Ipv4Addr::new(10, 13, 37, 69)])) + .await + .unwrap(); + let state = provisioner.host().get("tsunfactory").unwrap(); + let addresses: Vec = state + .addresses + .iter() + .map(|entry| entry.to_string()) + .collect(); + assert_eq!(addresses, vec!["10.13.37.69/24".to_string()]); + } } diff --git a/crates/tsunagi/src/overlay/tun.rs b/crates/tsunagi/src/overlay/tun.rs index 94e58d3..3abace7 100644 --- a/crates/tsunagi/src/overlay/tun.rs +++ b/crates/tsunagi/src/overlay/tun.rs @@ -25,13 +25,18 @@ use crate::overlay::OverlayError; pub struct TunRequest { /// Interface name to ask for. pub name: String, - /// The overlay address this host answers to, and its prefix length. + /// Every overlay address this host answers to, and no others. + /// + /// One per network the agent is in — one agent has one interface, and + /// it carries them all. A list rather than one address because + /// carrying only the first is carrying only the first network: the + /// others have addresses the operating system has never heard of, and + /// their traffic goes nowhere. /// /// Allocated and signed at the system level, never derived from a - /// protocol's key: every protocol carries traffic for the same address. - pub address: Option, - /// Prefix length of the overlay range. - pub prefix_len: u8, + /// protocol's key: every protocol carries traffic for the same + /// addresses. + pub addresses: Vec, /// Interface MTU. pub mtu: u32, } @@ -44,8 +49,7 @@ impl TunRequest { pub fn bare(name: impl Into, mtu: u32) -> Self { Self { name: name.into(), - address: None, - prefix_len: 0, + addresses: Vec::new(), mtu, } } diff --git a/docs/wireguard.md b/docs/wireguard.md index bad7509..fc9009a 100644 --- a/docs/wireguard.md +++ b/docs/wireguard.md @@ -206,8 +206,9 @@ edit. Reconciliation is purely "do the running tunnels match what is known". ## Using it ```bash -# On both machines -tsunagi up --network lab --secret "$SECRET" --wireguard +# On both machines: the agent, then the network +tsunagi up +tsunagi join --network lab --secret "$SECRET" ``` See the two-machine walkthrough in [../README.md](../README.md#trying-it-on-two-machines).