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:
@@ -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 happens — a 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
@@ -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"));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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).
|
||||||
|
|||||||
Reference in New Issue
Block a user