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