Put every network's address on the interface, and split up from join

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>
This commit is contained in:
tsunagi
2026-09-22 00:30:03 +01:00
co-authored by Claude Opus 5
parent 1b2f050c05
commit 9b240672e6
8 changed files with 331 additions and 335 deletions
+37 -26
View File
@@ -70,18 +70,29 @@ On the first machine:
```bash ```bash
cargo build --release 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 status # this device, the agent, and this host
./target/release/tsunagi network # the networks this device belongs to ./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 ```bash
./target/release/tsunagi up --network lab --secret "$SECRET" \ # Terminal one: the agent. It prints its endpoint id and then serves.
--peer <endpoint-id-from-the-first-machine> ./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 <endpoint-id-from-the-first-machine>
./target/release/tsunagi join --network lab --secret tsn1...
``` ```
Within a few seconds both print something like: 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: what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash ```bash
tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16 tsunagi up --ipv4-range 10.44.0.0/16
tsunagi up --network lab --secret "$SECRET" --ipv4-range none # no data plane tsunagi up --ipv4-range none # no data plane at all
``` ```
One agent has one interface, so two of its networks cannot both use that 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: by address:
```bash ```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 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 tsunagi dns off stop it, now and after every restart
``` ```
**A network without a secret makes one.** `tsunagi up --network lab` and **A network without a secret makes one.** `tsunagi join --network lab`
`tsunagi network join --network lab` both resolve a bare name the same way: if this device is already in exactly resolves a bare name in the obvious way: if this device is already in
one network called `lab`, that one — so the name alone resumes what you have; exactly one network called `lab`, that one — so the name alone resumes what
if it is in none, a fresh random secret, printed in full along with the one you have; if it is in none, a fresh random secret, printed in full with the
line to send the others: line to send the other machine:
``` ```
tsunagi is up joined `lab` (k2on43wadbi5x267vp6z3ogkm7nbjedfdoxyauxhtxhwqrprylba)
network lab (k2on43wadb…) secret tsn1u7c…
secret tsn1u7c…
No --peer was given, so this agent waits to be contacted. Run this on the Run this on the other machine:
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 Two networks of one name is a thing that happensa mistyped secret makes
the network — and warns when the name is one another configured network one — so joining says whether the network was already here, and warns when
also answers to, because a name is a label and the id is the identity. A another configured network answers to the same name. A name is a label and
command line with a different secret makes a *different* network of the the id is the identity.
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
+128 -205
View File
@@ -37,12 +37,17 @@ struct Cli {
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum Command { 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 /// Every item follows the same shape: name it to see it, name it with a
/// value to change it. /// value to change it.
Id(IdArgs), 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 // 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 // but this one would otherwise pay for its size. A `//` comment, not a
// `///` one, or clap would print it as help. // `///` one, or clap would print it as help.
@@ -51,6 +56,11 @@ enum Command {
Status(StatusArgs), Status(StatusArgs),
/// Shows the protocols this build can carry packets with. /// Shows the protocols this build can carry packets with.
Protocols, 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. /// Shows the networks this device belongs to, and leaves them.
Network(NetworkArgs), Network(NetworkArgs),
/// Shows the local resolver, and turns it on or off. /// Shows the local resolver, and turns it on or off.
@@ -97,27 +107,42 @@ struct NetworkArgs {
action: Option<NetworkAction>, action: Option<NetworkAction>,
} }
/// 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<PathBuf>,
/// 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<String>,
/// Read the shared secret from a file instead of the command line.
#[arg(long, conflicts_with = "secret")]
secret_file: Option<PathBuf>,
}
#[derive(Debug, Subcommand)] #[derive(Debug, Subcommand)]
enum NetworkAction { 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` /// The state directory belongs to one live agent, so this is how a
/// cannot add a network to it — this can, and takes effect at once. /// network is added to it, and it takes effect at once. With no agent
/// With no agent running it is configured and starts with the next /// running it is configured and starts with the next `tsunagi up`.
/// `tsunagi up`. /// `tsunagi join` is the same command, spelled shorter.
Join { Join(JoinArgs),
/// 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<String>,
/// Read the shared secret from a file instead of the command line.
#[arg(long, conflicts_with = "secret")]
secret_file: Option<PathBuf>,
},
/// Stops serving a network, keeping everything so it can be resumed. /// Stops serving a network, keeping everything so it can be resumed.
/// ///
/// Not leaving: the configuration, the secret, the address and the /// Not leaving: the configuration, the secret, the address and the
@@ -311,28 +336,6 @@ struct UpArgs {
#[command(flatten)] #[command(flatten)]
paths: PathArgs, 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<String>,
/// The shared secret, as printed by `tsunagi secret`.
#[arg(
long,
short = 's',
env = "TSUNAGI_SECRET",
conflicts_with = "secret_file"
)]
secret: Option<String>,
/// Read the shared secret from a file instead of the command line.
#[arg(long)]
secret_file: Option<PathBuf>,
/// Hostname to announce. Defaults to the machine's. /// Hostname to announce. Defaults to the machine's.
#[arg(long, help_heading = "System")] #[arg(long, help_heading = "System")]
hostname: Option<String>, hostname: Option<String>,
@@ -560,17 +563,6 @@ enum NetworkStanding {
Stopped, 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. /// 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 {
@@ -588,7 +580,7 @@ enum SecretOrigin {
/// ///
/// * given — use it, whatever is stored; /// * given — use it, whatever is stored;
/// * not given and this device is already in exactly one network of that /// * 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; /// stranger with the same name;
/// * not given and there is none — invent one, because an ad-hoc network is /// * 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 /// 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<dyn std::error::Error>> {
Command::Up(args) => up(*args).await, Command::Up(args) => up(*args).await,
Command::Status(args) => status(args).await, Command::Status(args) => status(args).await,
Command::Protocols => show_protocols(), Command::Protocols => show_protocols(),
Command::Join(args) => join_command(args).await,
Command::Network(args) => network_command(args).await, Command::Network(args) => network_command(args).await,
Command::Dns(args) => dns_command(args).await, Command::Dns(args) => dns_command(args).await,
Command::Wipe(args) => wipe(args).await, Command::Wipe(args) => wipe(args).await,
@@ -1480,19 +1473,7 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
let socket = control_socket(&paths, args.control_socket.as_ref()); let socket = control_socket(&paths, args.control_socket.as_ref());
match args.action { match args.action {
None => show_networks(&paths, &socket).await, None => show_networks(&paths, &socket).await,
Some(NetworkAction::Join { Some(NetworkAction::Join(args)) => join_command(args).await,
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::Stop { network }) => set_active(&paths, &socket, &network, false).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::Start { network }) => set_active(&paths, &socket, &network, true).await,
Some(NetworkAction::Leave { network, offline }) => { Some(NetworkAction::Leave { network, offline }) => {
@@ -1517,6 +1498,27 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box<dyn std::error::Er
} }
} }
/// `tsunagi join`: make a network or join one.
async fn join_command(args: JoinArgs) -> Result<(), Box<dyn std::error::Error>> {
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. /// Joins a network: into the running agent if there is one.
async fn join_network( async fn join_network(
paths: &StoragePaths, paths: &StoragePaths,
@@ -1524,6 +1526,7 @@ async fn join_network(
name: &NetworkName, name: &NetworkName,
secret: NetworkSecret, secret: NetworkSecret,
origin: SecretOrigin, origin: SecretOrigin,
standing: NetworkStanding,
) -> 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
@@ -1534,13 +1537,21 @@ async fn join_network(
.await?; .await?;
// The id in full either way: it is what every other command takes, // 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. // and the shortened form in a report is for reading, not copying.
if report.already_configured { match standing {
println!( NetworkStanding::New => {
"`{}` ({}) was already configured; it is running", println!("joined `{}` ({})", report.name, report.network_id)
}
NetworkStanding::Known => println!(
"`{}` ({}) was already here; it is running",
report.name, report.network_id report.name, report.network_id
); ),
} else { // Joining is an instruction to run it, so it undoes a stop —
println!("joined `{}` ({})", report.name, report.network_id); // 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 { if let Some(other) = &report.name_shared_with {
eprintln!( eprintln!(
@@ -1561,11 +1572,10 @@ async fn join_network(
// leaving the impression that it is running. // leaving the impression that it is running.
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 shared = storage
let already = existing .list_networks()
.iter() .await
.any(|other| other.network_id == keys.network_id()); .unwrap_or_default()
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());
@@ -1574,10 +1584,17 @@ async fn join_network(
.await?; .await?;
storage.release_ownership_lock(); storage.release_ownership_lock();
if already { match standing {
println!("`{name}` ({}) was already configured", keys.network_id()); NetworkStanding::New => println!("joined `{name}` ({})", keys.network_id()),
} else { NetworkStanding::Known => {
println!("joined `{name}` ({})", keys.network_id()); 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 { if let Some(other) = shared {
eprintln!( eprintln!(
@@ -1607,18 +1624,17 @@ async fn invite(socket: &std::path::Path, name: &NetworkName, secret: &NetworkSe
.map(|report| report.endpoint_id) .map(|report| report.endpoint_id)
.filter(|id| !id.is_empty()); .filter(|id| !id.is_empty());
println!(" secret {}", secret.encode().as_str()); 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 { match endpoint {
Some(endpoint) => println!( Some(endpoint) => println!(
"\nRun this on the other machine:\n\n \ "\nIts agent has to be running. If it is not:\n\n \
tsunagi up --network {name} --secret {} --peer {endpoint}", tsunagi up --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()
), ),
None => println!("\nIts agent has to be running: `tsunagi up`."),
} }
} }
@@ -2018,7 +2034,7 @@ async fn show_identity(
let mut section = Section::new("networks"); let mut section = Section::new("networks");
section.push(match networks.len() { section.push(match networks.len() {
0 => Row::new(Health::Info, "none", "no network has been joined") 0 => Row::new(Health::Info, "none", "no network has been joined")
.with_note("`tsunagi network join --network <name> --secret <secret>` joins one"), .with_note("`tsunagi join --network <name>` makes or joins one"),
count => Row::new( count => Row::new(
Health::Info, Health::Info,
"joined", "joined",
@@ -3160,35 +3176,6 @@ async fn netwatch_addresses() -> Vec<std::net::IpAddr> {
async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> { async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let paths = args.paths.resolve()?; 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 // 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.
@@ -3313,19 +3300,15 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
Err(tsunagi::Error::StateLocked { path }) => { Err(tsunagi::Error::StateLocked { path }) => {
let socket = control_socket(&paths, args.control_socket.as_ref()); let socket = control_socket(&paths, args.control_socket.as_ref());
if socket.exists() { if socket.exists() {
let named = joining
.as_ref()
.map_or_else(|| "<name>".to_string(), |(name, ..)| name.to_string());
return Err(format!( return Err(format!(
"an agent is already running for {}, and one state directory is one \ "an agent is already running for {}, and one state directory is one \
agent.\n\n\ agent — it is the device, not a network.\n\n\
To add `{named}` to it — same device, same interface, another \ To add a network to it:\n\n \
network:\n\n \ tsunagi join --network <name>\n\n\
tsunagi network join --network {named}\n\n\
To run a second, separate agent instead, give it everything of its \ To run a second, separate agent instead, give it everything of its \
own:\n\n \ own:\n\n \
tsunagi up --state-dir <dir> --cache-dir <dir> --interface tsun1 \ tsunagi up --state-dir <dir> --cache-dir <dir> --interface tsun1 \
--ipv4-range <cidr> --network {named}\n\n\ --ipv4-range <cidr>\n\n\
That is a different identity with its own interface, not this one \ That is a different identity with its own interface, not this one \
with another network. `tsunagi network` lists what this one has.", with another network. `tsunagi network` lists what this one has.",
path.display() path.display()
@@ -3339,51 +3322,22 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
// From here on every exit goes through `agent.shutdown()`, so the endpoint // From here on every exit goes through `agent.shutdown()`, so the endpoint
// is never dropped without being closed. // is never dropped without being closed.
let mut events = agent.subscribe(); 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!("tsunagi is up");
println!(" endpoint id {}", agent.endpoint_id()); println!(" endpoint id {}", agent.endpoint_id());
println!(" hostname {}", agent.hostname()); println!(" hostname {}", agent.hostname());
match (&joining, joined) { // What this device belongs to is a separate question from whether its
// Whether this command line just made a network or picked up one // agent is running, and `tsunagi join` answers it at any time.
// that was already here. Without it, a secret that has quietly let configured = agent.list_networks().await.unwrap_or_default();
// created a second network of the same name — or recreated one let running = configured.iter().filter(|network| network.active).count();
// that was left — looks exactly like the network you meant. println!(
(Some((name, _, _, standing, _)), Some(network)) => { " networks {}",
println!(" network {name} ({network}) · {}", standing.label()) match configured.len() {
0 => "none yet · `tsunagi join --network <name>` 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 <name>` adds one".to_string(),
total =>
format!("{running} of {total} running · `tsunagi network` lists them"),
}
);
}
}
println!(" state {}", paths.state_dir.display()); 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 // 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 // named after it. A name that shadows a public one is reported and then
// used, because that is a decision and not a mistake. // used, because that is a decision and not a mistake.
@@ -3419,36 +3373,6 @@ 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((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 => "<secret>".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"); println!("\nPress Ctrl-C to stop.\n");
let status_every = let status_every =
@@ -3474,7 +3398,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
None => std::future::pending::<()>().await, None => std::future::pending::<()>().await,
} }
}, if ticker.is_some() => { }, 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] #[test]
fn a_name_this_device_already_has_resumes_it() { 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 // the same name every time, which is the confusion this whole
// report format exists to prevent. // report format exists to prevent.
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -4345,10 +4269,10 @@ mod network_context_tests {
} }
#[test] #[test]
fn a_stopped_network_on_the_command_line_says_it_is_being_started() { fn a_stopped_network_is_told_from_one_that_is_merely_known() {
// A command line naming a network says to run it, so it overrides // Joining a stopped network starts it — which is right, it is an
// a `stop`. Silently, that is a pause that comes back from the // instruction to run it — and saying so is what keeps a pause
// dead at the next restart with nothing to explain it. // from ending without a word.
let mut stopped = configured("lab", 1); let mut stopped = configured("lab", 1);
stopped.auto_start = false; stopped.auto_start = false;
let name = stopped.name.clone(); let name = stopped.name.clone();
@@ -4356,6 +4280,5 @@ mod network_context_tests {
let (standing, _) = let (standing, _) =
network_context(std::slice::from_ref(&stopped), &name, stopped.network_id); network_context(std::slice::from_ref(&stopped), &name, stopped.network_id);
assert_eq!(standing, NetworkStanding::Stopped); assert_eq!(standing, NetworkStanding::Stopped);
assert!(standing.label().contains("was stopped"));
} }
} }
+46 -35
View File
@@ -98,51 +98,63 @@ impl Drop for Running {
/// There is no separate zone setting: an agent serves a zone per network, /// There is no separate zone setting: an agent serves a zone per network,
/// named after it, so the network name is the zone name. /// named after it, so the network name is the zone name.
fn start(zone: &str, port: u16) -> Running { 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<u16>) -> Running {
let dir = TempDir::new().unwrap(); let dir = TempDir::new().unwrap();
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) let mut command = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"));
.args([ command
"up", .arg("up")
"--network",
zone,
"--secret",
"a-secret-for-the-dns-test",
])
.arg("--state-dir") .arg("--state-dir")
.arg(dir.path().join("state")) .arg(dir.path().join("state"))
.arg("--cache-dir") .arg("--cache-dir")
.arg(dir.path().join("cache")) .arg(dir.path().join("cache"))
// No real interface and no internet: this is about the wiring. // No real interface and no internet: this is about the wiring.
.args(["--reach", "local", "--no-tun", "--dns"]) .args(["--reach", "local", "--no-tun"]);
.args(["--dns-port", &port.to_string()]) if let Some(port) = dns_port {
.args(["--log", "error", "--status-interval", "0"]) command.arg("--dns").args(["--dns-port", &port.to_string()]);
.stdout(std::process::Stdio::null()) }
.stderr(std::process::Stdio::null()) let child = command
.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(["--log", "error", "--status-interval", "0"]) .args(["--log", "error", "--status-interval", "0"])
.stdout(std::process::Stdio::null()) .stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null()) .stderr(std::process::Stdio::null())
.spawn() .spawn()
.expect("the agent binary starts"); .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 } Running { child, dir }
} }
@@ -213,7 +225,6 @@ fn every_network_gets_a_zone_of_its_own() {
wait_for_answer(server, &format!("{host}.first.internal")); wait_for_answer(server, &format!("{host}.first.internal"));
let joined = agent.run(&[ let joined = agent.run(&[
"network",
"join", "join",
"--network", "--network",
"second.internal", "second.internal",
+43 -45
View File
@@ -58,31 +58,22 @@ fn start_bare(port: u16) -> Running {
Running { child, dir } Running { child, dir }
} }
/// An agent already in one network, the way most of these start.
fn start(network: &str, port: u16) -> Running { fn start(network: &str, port: u16) -> Running {
let dir = TempDir::new().unwrap(); let agent = start_bare(port);
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) let joined = agent.run(&[
.args([ "join",
"up", "--network",
"--network", network,
network, "--secret",
"--secret", "a-secret-for-the-cli-test",
"a-secret-for-the-cli-test", ]);
]) assert!(
.arg("--state-dir") joined.status.success(),
.arg(dir.path().join("state")) "{}",
.arg("--cache-dir") String::from_utf8_lossy(&joined.stderr)
.arg(dir.path().join("cache")) );
.args(["--reach", "local", "--no-tun"]) agent
.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] #[test]
@@ -109,26 +100,24 @@ fn joining_with_no_secret_makes_one_and_prints_what_to_send() {
assert!(secret.starts_with("tsn1"), "{out}"); assert!(secret.starts_with("tsn1"), "{out}");
let command = out let command = out
.lines() .lines()
.find(|line| line.contains("tsunagi up --network spontaneous")) .find(|line| line.contains("tsunagi join --network spontaneous"))
.expect("a command to send"); .expect("a command to send");
assert!(command.contains(secret), "with the secret in it: {out}"); assert!(command.contains(secret), "with the secret in it: {out}");
assert!( // And where to find this device, for an agent that is not up yet.
command.contains("--peer "), assert!(out.contains("--peer "), "somewhere to find us: {out}");
"and somewhere to find us: {out}"
);
// Joining the same name again resumes it rather than making another // Joining the same name again resumes it rather than making another
// network that merely looks the same. // network that merely looks the same.
let again = agent.run(&["network", "join", "--network", "spontaneous"]); let again = agent.run(&["network", "join", "--network", "spontaneous"]);
let out = String::from_utf8_lossy(&again.stdout); let out = String::from_utf8_lossy(&again.stdout);
assert!(again.status.success()); 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}"); assert!(!out.contains("secret tsn1"), "no second secret: {out}");
} }
#[test] #[test]
fn a_bare_name_resumes_the_network_of_that_name_rather_than_inventing_one() { 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. // has exactly one network of that name, so that is the one meant.
let agent = start("resident", 45072); let agent = start("resident", 45072);
let listed = agent.run(&["network"]); 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()); assert!(joined.status.success());
let out = String::from_utf8_lossy(&joined.stdout); let out = String::from_utf8_lossy(&joined.stdout);
assert!(out.contains(&id), "the same network, not a new one: {out}"); 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] #[test]
@@ -238,17 +227,26 @@ fn an_agent_starts_with_no_network_and_takes_one_later() {
} }
#[test] #[test]
fn a_secret_with_no_network_is_refused_rather_than_ignored() { fn up_is_the_agent_and_takes_no_network_at_all() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) // The two commands are separate on purpose: `up` runs the device's
.args(["up", "--secret", "tsn1whatever"]) // agent, `join` decides what it is in. A network on `up` would be a
.arg("--state-dir") // second way to do the same thing, and the one that cannot be undone
.arg(TempDir::new().unwrap().path().join("state")) // without a restart.
.output() for argument in ["--network", "--secret"] {
.expect("the agent binary runs"); let out = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
assert!(!out.status.success()); .args(["up", argument, "whatever"])
assert!( .arg("--state-dir")
String::from_utf8_lossy(&out.stderr).contains("says nothing without a network"), .arg(TempDir::new().unwrap().path().join("state"))
"{}", .output()
String::from_utf8_lossy(&out.stderr) .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)
);
}
} }
+4 -7
View File
@@ -207,15 +207,12 @@ impl Interface {
} }
} }
// One address per network, so at most a handful; the request carries // Every address this agent holds, in every network it is in. The
// the first and the provisioner reconciles the rest. // 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 { let request = TunRequest {
name: self.name.clone(), name: self.name.clone(),
address: wanted.first().and_then(|cidr| match cidr.addr { addresses: wanted.clone(),
IpAddr::V4(address) => Some(address),
IpAddr::V6(_) => None,
}),
prefix_len: wanted.first().map_or(0, |cidr| cidr.prefix_len),
mtu: self.mtu, mtu: self.mtu,
}; };
self.factory.reconfigure(request).await?; self.factory.reconfigure(request).await?;
@@ -11,20 +11,18 @@ use std::sync::Arc;
use crate::BoxFuture; use crate::BoxFuture;
use crate::overlay::OverlayError; use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{TunDevice, TunFactory, TunRequest}; use super::super::tun::{TunDevice, TunFactory, TunRequest};
use super::{InterfacePlan, InterfaceProvisioner}; use super::{InterfacePlan, InterfaceProvisioner};
/// Turns a [`TunRequest`] into the plan for a host interface. /// Turns a [`TunRequest`] into the plan for a host interface.
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> { fn plan_for(request: &TunRequest) -> Result<InterfacePlan, OverlayError> {
let mut addresses = Vec::new(); // Every address, because the plan is exhaustive: the provisioner adds
if let Some(address) = request.address { // what is missing and removes what is not in it. Passing one network's
addresses.push(Cidr::new(address.into(), request.prefix_len)?); // address would take every other network's off the host.
}
Ok(InterfacePlan::new( Ok(InterfacePlan::new(
request.name.clone(), request.name.clone(),
request.mtu, request.mtu,
addresses, request.addresses.clone(),
)) ))
} }
@@ -113,14 +111,21 @@ mod tests {
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use super::super::super::config::Cidr;
use super::super::{LinkKind, MockHost, MockProvisioner}; use super::super::{LinkKind, MockHost, MockProvisioner};
use super::*; use super::*;
fn request(v4: Option<Ipv4Addr>) -> TunRequest { fn request(v4: Option<Ipv4Addr>) -> TunRequest {
addressed(v4.into_iter().collect())
}
fn addressed(v4: Vec<Ipv4Addr>) -> TunRequest {
TunRequest { TunRequest {
name: "tsunfactory".into(), name: "tsunfactory".into(),
address: v4, addresses: v4
prefix_len: 24, .into_iter()
.map(|address| Cidr::new(address.into(), 24).unwrap())
.collect(),
mtu: 1280, mtu: 1280,
} }
} }
@@ -201,4 +206,50 @@ mod tests {
let err = factory.create(request(None)).await.unwrap_err(); let err = factory.create(request(None)).await.unwrap_err();
assert!(err.to_string().contains("bridge"), "{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<String> = 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<String> = state
.addresses
.iter()
.map(|entry| entry.to_string())
.collect();
assert_eq!(addresses, vec!["10.13.37.69/24".to_string()]);
}
} }
+11 -7
View File
@@ -25,13 +25,18 @@ use crate::overlay::OverlayError;
pub struct TunRequest { pub struct TunRequest {
/// Interface name to ask for. /// Interface name to ask for.
pub name: String, 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 /// Allocated and signed at the system level, never derived from a
/// protocol's key: every protocol carries traffic for the same address. /// protocol's key: every protocol carries traffic for the same
pub address: Option<std::net::Ipv4Addr>, /// addresses.
/// Prefix length of the overlay range. pub addresses: Vec<super::Cidr>,
pub prefix_len: u8,
/// Interface MTU. /// Interface MTU.
pub mtu: u32, pub mtu: u32,
} }
@@ -44,8 +49,7 @@ impl TunRequest {
pub fn bare(name: impl Into<String>, mtu: u32) -> Self { pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
Self { Self {
name: name.into(), name: name.into(),
address: None, addresses: Vec::new(),
prefix_len: 0,
mtu, mtu,
} }
} }
+3 -2
View File
@@ -206,8 +206,9 @@ edit. Reconciliation is purely "do the running tunnels match what is known".
## Using it ## Using it
```bash ```bash
# On both machines # On both machines: the agent, then the network
tsunagi up --network lab --secret "$SECRET" --wireguard tsunagi up
tsunagi join --network lab --secret "$SECRET"
``` ```
See the two-machine walkthrough in [../README.md](../README.md#trying-it-on-two-machines). See the two-machine walkthrough in [../README.md](../README.md#trying-it-on-two-machines).