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:
tsunagi
2026-09-21 23:19:58 +01:00
co-authored by Claude Opus 5
parent 0f97d60854
commit 415b6a6667
4 changed files with 298 additions and 14 deletions
+10 -3
View File
@@ -323,7 +323,7 @@ tsunagi id key rotate replace that key
tsunagi network the networks this device belongs to 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 -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 leave <id> give up the address and name, then forget it
tsunagi network secret the secret of each joined network tsunagi network secret the secret of each joined network
tsunagi network secret <id> just that one, for copying 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 tsunagi dns off stop it, now and after every restart
``` ```
**A network without a secret makes one.** `tsunagi up --network lab` with no **A network without a secret makes one.** `tsunagi up --network lab` and
`--secret` resolves in the obvious way: if this device is already in exactly `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; 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 if it is in none, a fresh random secret, printed in full along with the one
line to send the others: line to send the others:
@@ -351,6 +351,13 @@ other machine:
tsunagi up --network lab --secret tsn1u7c… --peer 91e83a6e2b7a… 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 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 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 nowhere else to read it from — and never when it was supplied, because then
+157 -11
View File
@@ -490,6 +490,30 @@ fn store_dns_setting(
Ok(()) 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. /// Where the secret a command is about to use came from.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SecretOrigin { enum SecretOrigin {
@@ -1361,8 +1385,13 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
secret, secret,
secret_file, secret_file,
}) => { }) => {
let secret = load_secret(secret.as_deref(), secret_file.as_deref())?; let name = NetworkName::new(network)?;
join_network(&paths, &socket, &network, secret).await // 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 }) => { Some(NetworkAction::Leave { network, offline }) => {
leave_network(&paths, &socket, &network, offline).await 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( async fn join_network(
paths: &StoragePaths, paths: &StoragePaths,
socket: &std::path::Path, socket: &std::path::Path,
name: &str, name: &NetworkName,
secret: NetworkSecret, secret: NetworkSecret,
origin: SecretOrigin,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
// The running agent, because a second `up` cannot have the directory // The running agent, because a second `up` cannot have the directory
// and because this way the network starts at once instead of at the // and because this way the network starts at once instead of at the
// next restart. // next restart.
if socket.exists() { if socket.exists() {
let report = 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 { if report.already_configured {
println!( println!(
"`{}` ({}) was already configured; it is running", "`{}` ({}) was already configured; it is running",
report.name, report.name, report.network_id
short(&report.network_id, 10)
); );
} else { } else {
println!("joined `{}` ({})", report.name, report.network_id); println!("joined `{}` ({})", report.name, report.network_id);
@@ -1417,13 +1449,15 @@ async fn join_network(
short(other, 10) short(other, 10)
); );
} }
if origin == SecretOrigin::Generated {
invite(socket, name, &secret).await;
}
return Ok(()); return Ok(());
} }
// No agent: configure it, and say when it will take effect rather than // No agent: configure it, and say when it will take effect rather than
// leaving the impression that it is running. // 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 storage = tsunagi::storage::Storage::open(paths)?;
let existing = storage.list_networks().await.unwrap_or_default(); let existing = storage.list_networks().await.unwrap_or_default();
let already = existing let already = existing
@@ -1431,10 +1465,10 @@ async fn join_network(
.any(|other| other.network_id == keys.network_id()); .any(|other| other.network_id == keys.network_id());
let shared = existing let shared = existing
.iter() .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()); .map(|other| other.network_id.to_string());
storage storage
.upsert_network(keys.network_id(), name.clone(), secret, true) .upsert_network(keys.network_id(), name.clone(), secret.clone(), true)
.await?; .await?;
storage.release_ownership_lock(); storage.release_ownership_lock();
@@ -1450,10 +1484,42 @@ async fn join_network(
short(&other, 10) 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`."); eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`.");
Ok(()) 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. /// Every configured network, live where an agent can say so.
async fn show_networks( async fn show_networks(
paths: &StoragePaths, paths: &StoragePaths,
@@ -2924,6 +2990,11 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
args.secret.as_deref(), args.secret.as_deref(),
args.secret_file.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 // Parsed up front so a typo is reported immediately, and so the option is
// never silently ignored when the data plane is off. // 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!("tsunagi is up");
println!(" endpoint id {}", agent.endpoint_id()); println!(" endpoint id {}", agent.endpoint_id());
println!(" hostname {}", agent.hostname()); 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()); println!(" state {}", paths.state_dir.display());
// One line, everything the other side needs, ready to paste. The // One line, everything the other side needs, ready to paste. The
// secret is printed in full only when this agent invented it: then // 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 // Last, after the facts, because it is the line to act on: one
// command with everything the other side needs. // command with everything the other side needs.
if args.peers.is_empty() { if args.peers.is_empty() {
@@ -3959,3 +4050,58 @@ mod secret_tests {
assert!(err.to_string().contains("does not say which"), "{err}"); 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);
}
}
+126
View File
@@ -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}");
}
+5
View File
@@ -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 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. 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 `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 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 the way past, a name outside every zone is refused, each network gets a zone