Start the agent without deciding anything yet
`up` demanded a network, so there was no way to run the agent in one terminal and decide what it belongs to in another — which is the shape of the thing now that networks are joined, stopped and left while it runs. `--network` is optional. Without it the agent starts with whatever it is already configured for and waits; the banner counts the networks instead of naming one, and points at `tsunagi network join`. A secret with no network is refused rather than ignored, because it says nothing on its own. The periodic summary, which had one network by construction, now falls back to the first running one — `tsunagi status` is the whole picture and this line was never more than a glance. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -370,6 +370,11 @@ A secret is printed by `network secret` and nowhere else — not by `id`, not
|
|||||||
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
by `status`, not in a log, a `Debug` rendering or anything sent to a peer.
|
||||||
Asking for it is deliberate, because these reports get pasted into chats.
|
Asking for it is deliberate, because these reports get pasted into chats.
|
||||||
|
|
||||||
|
**`up` takes no network at all** if you would rather decide later: it
|
||||||
|
brings up the agent and whatever it is already configured for, and waits.
|
||||||
|
That is the shape of a daemon in one terminal and `tsunagi network join`
|
||||||
|
in another.
|
||||||
|
|
||||||
`network join` is also the answer to a question `up` cannot: a state
|
`network join` is also the answer to a question `up` cannot: a state
|
||||||
directory belongs to one live agent, so a second `tsunagi up` cannot add a
|
directory belongs to one live agent, so a second `tsunagi up` cannot add a
|
||||||
network to the one already running. This adds it over the control socket and
|
network to the one already running. This adds it over the control socket and
|
||||||
|
|||||||
@@ -312,8 +312,13 @@ struct UpArgs {
|
|||||||
paths: PathArgs,
|
paths: PathArgs,
|
||||||
|
|
||||||
/// Network name. Must be identical on every participant.
|
/// 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')]
|
#[arg(long, short = 'n')]
|
||||||
network: String,
|
network: Option<String>,
|
||||||
|
|
||||||
/// The shared secret, as printed by `tsunagi secret`.
|
/// The shared secret, as printed by `tsunagi secret`.
|
||||||
#[arg(
|
#[arg(
|
||||||
@@ -3154,9 +3159,14 @@ 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 name = NetworkName::new(args.network.clone())?;
|
|
||||||
let paths = args.paths.resolve()?;
|
let paths = args.paths.resolve()?;
|
||||||
let (secret, secret_origin) = resolve_secret(
|
// 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,
|
&paths,
|
||||||
&name,
|
&name,
|
||||||
args.secret.as_deref(),
|
args.secret.as_deref(),
|
||||||
@@ -3165,8 +3175,20 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
// Read before anything joins, because afterwards everything is
|
// Read before anything joins, because afterwards everything is
|
||||||
// configured and the difference is what the user needs to see.
|
// configured and the difference is what the user needs to see.
|
||||||
let network_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
|
let network_id = tsunagi::identity::NetworkKeys::derive(&name, &secret).network_id();
|
||||||
let (known_before, name_shared_with) =
|
let (standing, shared) = network_context(&stored_networks(&paths), &name, network_id);
|
||||||
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.
|
||||||
@@ -3291,15 +3313,19 @@ 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.\n\n\
|
||||||
To add `{name}` to it — same device, same interface, another network:\n\n \
|
To add `{named}` to it — same device, same interface, another \
|
||||||
tsunagi network join --network {name} --secret <secret>\n\n\
|
network:\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 {name} --secret <secret>\n\n\
|
--ipv4-range <cidr> --network {named}\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()
|
||||||
@@ -3313,36 +3339,49 @@ 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 network = match agent.join_network(&name, &secret).await {
|
let joined = match &joining {
|
||||||
Ok(network) => network,
|
Some((name, secret, _, _, _)) => match agent.join_network(name, secret).await {
|
||||||
|
Ok(network) => Some(network),
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
agent.shutdown().await;
|
agent.shutdown().await;
|
||||||
return Err(err.into());
|
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());
|
||||||
// Whether this command line just made a network or picked up one that
|
match (&joining, joined) {
|
||||||
// was already here. Without it, a secret that has quietly created a
|
// Whether this command line just made a network or picked up one
|
||||||
// second network of the same name — or recreated one that was left —
|
// that was already here. Without it, a secret that has quietly
|
||||||
// looks exactly like the network you meant.
|
// 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())
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let configured = agent.list_networks().await.unwrap_or_default();
|
||||||
|
let running = configured.iter().filter(|network| network.active).count();
|
||||||
println!(
|
println!(
|
||||||
" network {name} ({network}) · {}",
|
" networks {}",
|
||||||
known_before.label()
|
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
|
// One line, everything the other side needs, ready to paste. The
|
||||||
// secret is printed in full only when this agent invented it: then
|
// secret is printed in full only when this agent invented it: then
|
||||||
// there is nowhere else to read it from, and an ad-hoc network is
|
// 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
|
// exactly "one person made it and sent the command round". A secret
|
||||||
// the user supplied is theirs already and is not echoed.
|
// the user supplied is theirs already and is not echoed.
|
||||||
let shareable = match secret_origin {
|
if let Some((_, secret, SecretOrigin::Generated, _, _)) = &joining {
|
||||||
SecretOrigin::Generated => secret.encode().as_str().to_string(),
|
|
||||||
SecretOrigin::Given | SecretOrigin::Stored => "<secret>".to_string(),
|
|
||||||
};
|
|
||||||
if secret_origin == SecretOrigin::Generated {
|
|
||||||
println!(" secret {}", secret.encode().as_str());
|
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
|
||||||
@@ -3383,7 +3422,7 @@ 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
|
// 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
|
// called the same thing and share nothing. Almost always a mistyped
|
||||||
// secret, and the one mistake that makes a report unreadable.
|
// secret, and the one mistake that makes a report unreadable.
|
||||||
if let Some(other) = &name_shared_with {
|
if let Some((name, _, _, _, Some(other))) = &joining {
|
||||||
eprintln!(
|
eprintln!(
|
||||||
"\nwarning: `{name}` is also configured here with a different secret, as {}.\n\
|
"\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\
|
A network is its name *and* its secret, so these two share nothing. If that\n\
|
||||||
@@ -3394,8 +3433,15 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Last, after the facts, because it is the line to act on: one
|
// Last, after the facts, because it is the line to act on: one
|
||||||
// command with everything the other side needs.
|
// command with everything the other side needs. Only when a network
|
||||||
if args.peers.is_empty() {
|
// 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!(
|
println!(
|
||||||
"\nNo --peer was given, so this agent waits to be contacted. \
|
"\nNo --peer was given, so this agent waits to be contacted. \
|
||||||
Run this on the other machine:\n\n \
|
Run this on the other machine:\n\n \
|
||||||
@@ -3428,7 +3474,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, network, wireguard.as_deref()).await;
|
print_status(&agent, joined, wireguard.as_deref()).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3689,7 +3735,24 @@ fn print_event(event: &Event) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&WireguardPlugin>) {
|
async fn print_status(
|
||||||
|
agent: &Agent,
|
||||||
|
network: Option<NetworkId>,
|
||||||
|
wireguard: Option<&WireguardPlugin>,
|
||||||
|
) {
|
||||||
|
// The network this command line named, or — when it named none — the
|
||||||
|
// first one the agent has, since there is no other candidate for "the"
|
||||||
|
// network and `tsunagi status` covers the whole picture anyway.
|
||||||
|
let network = match network {
|
||||||
|
Some(network) => network,
|
||||||
|
None => match agent.list_networks().await {
|
||||||
|
Ok(networks) => match networks.iter().find(|network| network.active) {
|
||||||
|
Some(network) => network.network_id,
|
||||||
|
None => return,
|
||||||
|
},
|
||||||
|
Err(_) => return,
|
||||||
|
},
|
||||||
|
};
|
||||||
let Ok(status) = agent.network_status(network).await else {
|
let Ok(status) = agent.network_status(network).await else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -36,6 +36,28 @@ impl Running {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// An agent with no network at all, the way a daemon is started before
|
||||||
|
/// anything has been decided.
|
||||||
|
fn start_bare(port: u16) -> Running {
|
||||||
|
let dir = TempDir::new().unwrap();
|
||||||
|
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||||
|
.arg("up")
|
||||||
|
.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");
|
||||||
|
std::thread::sleep(Duration::from_secs(2));
|
||||||
|
Running { child, dir }
|
||||||
|
}
|
||||||
|
|
||||||
fn start(network: &str, port: u16) -> Running {
|
fn start(network: &str, port: u16) -> Running {
|
||||||
let dir = TempDir::new().unwrap();
|
let dir = TempDir::new().unwrap();
|
||||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||||
@@ -188,3 +210,45 @@ fn a_network_can_be_stopped_and_resumed_without_losing_anything() {
|
|||||||
"the secret is kept, so this is the same network"
|
"the secret is kept, so this is the same network"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_agent_starts_with_no_network_and_takes_one_later() {
|
||||||
|
// A daemon in one terminal and the deciding done in another: the
|
||||||
|
// agent is the identity, and which networks it is in is a separate
|
||||||
|
// question it can answer at any time.
|
||||||
|
let agent = start_bare(45074);
|
||||||
|
|
||||||
|
let listed = agent.run(&["network"]);
|
||||||
|
assert!(listed.status.success());
|
||||||
|
assert!(
|
||||||
|
String::from_utf8_lossy(&listed.stderr).contains("no network has been joined"),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&listed.stderr)
|
||||||
|
);
|
||||||
|
|
||||||
|
let joined = agent.run(&["network", "join", "--network", "afterwards"]);
|
||||||
|
assert!(
|
||||||
|
joined.status.success(),
|
||||||
|
"{}",
|
||||||
|
String::from_utf8_lossy(&joined.stderr)
|
||||||
|
);
|
||||||
|
let listed = String::from_utf8_lossy(&agent.run(&["network"]).stdout).to_string();
|
||||||
|
assert!(listed.contains("afterwards"), "{listed}");
|
||||||
|
assert!(listed.contains("running"), "and it is live: {listed}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user