Make a network on the spot, and say which one you just started
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <id> give up the address and name, then forget it
|
||||
tsunagi network secret the secret of each joined network
|
||||
tsunagi network secret <id> 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
|
||||
|
||||
+157
-11
@@ -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<String>) {
|
||||
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<dyn std::error::Er
|
||||
secret,
|
||||
secret_file,
|
||||
}) => {
|
||||
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<dyn std::error::Er
|
||||
async fn join_network(
|
||||
paths: &StoragePaths,
|
||||
socket: &std::path::Path,
|
||||
name: &str,
|
||||
name: &NetworkName,
|
||||
secret: NetworkSecret,
|
||||
origin: SecretOrigin,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// 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 <endpoint-id>",
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
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<dyn std::error::Error>> {
|
||||
}
|
||||
};
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user