Two networks on one agent, and only one of them worked. The interface plan is exhaustive by contract — it is what the interface should carry and nothing else — but the request that built it held a single address, so the provisioner was told about the first network and took the second one's address off, or never put it on. On the host that is a network whose address the operating system has never heard of: the tunnel is up, the status says all is well, and nothing routes. The request now carries every address, which is also what takes one off when a network is left or stopped. The other half is the command line. `up --network X --secret Y` and `network join` were two ways to do the same thing, and the one on `up` could only be undone by restarting — which is how a network somebody left came back, and how an invite line told the other side to start their agent with a network baked into it. So they are one thing now, split the way the system is: **`up` runs the agent** — the device's one process, serving whatever it has joined, answering `status`, taking instructions — and **`join` decides what it belongs to**, at any time, while it runs. `join` is at the top level because it is what gets typed; `network join` is the same command for anyone who likes the long form. Every line that told somebody to type the old form is gone with it: the invite after making a network, the lock error from a second `up`, the empty-network hint in `id`, the README walkthrough and the WireGuard document. The invite now prints the `join` line for the other machine and, separately, the `up --peer` line for an agent that is not running yet — two commands, because they really are two, and no amount of wording makes starting an agent the same thing as joining a network. Joining says how the network stood before: new, already here, or stopped and now running again. That last one matters — joining is an instruction to run it, so it undoes a stop, and a pause that ends without a word is a pause nobody can rely on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
253 lines
9.0 KiB
Rust
253 lines
9.0 KiB
Rust
//! 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")
|
|
}
|
|
}
|
|
|
|
/// An agent with no network at all, the way a daemon is started before
|
|
/// anything has been decided.
|
|
fn start_bare(port: u16) -> Running {
|
|
let dir = TempDir::new().unwrap();
|
|
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
|
.arg("up")
|
|
.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");
|
|
std::thread::sleep(Duration::from_secs(2));
|
|
Running { child, dir }
|
|
}
|
|
|
|
/// An agent already in one network, the way most of these start.
|
|
fn start(network: &str, port: u16) -> Running {
|
|
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]
|
|
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 join --network spontaneous"))
|
|
.expect("a command to send");
|
|
assert!(command.contains(secret), "with the secret in it: {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 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() {
|
|
// `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"]);
|
|
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 here"), "{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"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_agent_starts_with_no_network_and_takes_one_later() {
|
|
// A daemon in one terminal and the deciding done in another: the
|
|
// agent is the identity, and which networks it is in is a separate
|
|
// question it can answer at any time.
|
|
let agent = start_bare(45074);
|
|
|
|
let listed = agent.run(&["network"]);
|
|
assert!(listed.status.success());
|
|
assert!(
|
|
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
|
|
"{}",
|
|
String::from_utf8_lossy(&listed.stderr)
|
|
);
|
|
|
|
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
|
|
assert!(
|
|
joined.status.success(),
|
|
"{}",
|
|
String::from_utf8_lossy(&joined.stderr)
|
|
);
|
|
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
|
assert!(listed.contains("afterwards"), "{listed}");
|
|
assert!(listed.contains("running"), "and it is live: {listed}");
|
|
}
|
|
|
|
#[test]
|
|
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)
|
|
);
|
|
}
|
|
}
|