diff --git a/AGENTS.md b/AGENTS.md index 2a4f229..d2cfc0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,6 +29,12 @@ Keep these separate. Crossing them is the main thing to review for. protocols may be carrying traffic at once and none of them owns the thing they carry it for. A protocol is handed a routed packet and hands back a decrypted one; it never creates an interface and never picks an address. +- **One state directory, one identity, one live agent — many networks.** A + network is added to the agent that is already running, never by starting a + second one on the same directory. A second agent is a second identity and + is isolated: its own directories, its own interface, its own runtime. Do + not add anything that lets two agents share a directory, and do not make a + network's lifetime depend on the process that happened to start it. - **A protocol is a separate crate with its own version.** The version peers compare is the *wire* version, never the software version: two peers on different releases work together for as long as the bytes between them diff --git a/README.md b/README.md index 6c317b9..cf31887 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,7 @@ On the first machine: ```bash cargo build --release -./target/release/tsunagi id secret generate # prints tsn1...; share it privately +./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 @@ -291,10 +291,14 @@ sayable — it remembers who belongs while they are gone, so the report can say dial-failure counter to imply it. Counters are history and are never graded: a peer that left and came back should not leave the report looking broken. -`id` is the other half: it shows what this device is — its signing key, the -name it answers to, and the secret of every network it has joined — and -changes those. Every item takes the same shape, so there is nothing to -remember: name it to see it, name it with a value to change it. +The other two commands split along the line the system itself draws. `id` is +this **device**: the key it signs with and the name it answers to. `network` +is what it **belongs to**: which networks, their secrets, joining and +leaving. A device outlives every network it is in, and a network outlives +any device in it, so a command that mixed them had to be read twice. + +Every item takes the same shape, so there is nothing to remember: name it to +see it, name it with a value to change it. ``` tsunagi id everything about this device @@ -302,12 +306,24 @@ tsunagi id hostname the name it answers to tsunagi id hostname mango change it tsunagi id key the key it signs with tsunagi id key rotate replace that key -tsunagi id secret the secret of each joined network -tsunagi id secret generate a fresh secret for a network that does not exist yet + +tsunagi network the networks this device belongs to +tsunagi network join -n lab -s tsn1… join one; adds it to a running agent +tsunagi network leave give up the address and name, then forget it +tsunagi network secret the secret of each joined network +tsunagi network secret just that one, for copying +tsunagi network secret generate a fresh secret for a network that does not exist yet ``` -Secrets appear in `id`, which is where you go to ask for one, and never in -`status`, in a log, in a `Debug` rendering or in anything sent to a peer. +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. +Asking for it is deliberate, because these reports get pasted into chats. + +`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 +network to the one already running. This adds it over the control socket and +it starts at once. With no agent running it is written to the configuration +and starts with the next `up`. The name is part of the signed state, so changing it revokes the previous one: there is one record per author, a new version replaces the whole claim, @@ -494,7 +510,14 @@ The command line agent puts everything under the platform's per-user directories by default; `--state-dir` and `--cache-dir` override them. One state directory belongs to one live agent, enforced with a real OS file -lock rather than an existence check. +lock rather than an existence check. That directory *is* the identity: one +agent, one device key, one interface, and as many networks as you like on it +— `tsunagi network join` adds them to the agent that is already running, +which is why a second `tsunagi up` on the same directory is refused rather +than made to work. A second agent on the same host is a second identity, and +needs everything of its own: its own state and cache directories, its own +interface name and its own overlay range. The library owns no globals, so +several of them run side by side in one process as readily as in one host. ### Leaving, and starting over diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 293ef9c..756bb0a 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -72,6 +72,25 @@ struct NetworkArgs { #[derive(Debug, Subcommand)] enum NetworkAction { + /// Joins a network, adding it to 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, + + /// Read the shared secret from a file instead of the command line. + #[arg(long, conflicts_with = "secret")] + secret_file: Option, + }, /// Gives up this device's address and name in a network, and forgets it. /// /// A signed release goes out first, so the address and name are freed @@ -89,6 +108,24 @@ enum NetworkAction { #[arg(long)] offline: bool, }, + /// Shows the secret of a network, which is half of its identity. + /// + /// Printed only when asked for, never as part of an overview: these + /// reports get pasted into chats and issue trackers. + Secret { + /// Which network, by id; a unique prefix is enough. All of them if + /// omitted. + network: Option, + + #[command(subcommand)] + action: Option, + }, +} + +#[derive(Debug, Subcommand)] +enum SecretAction { + /// Prints a fresh random secret, for a network that does not exist yet. + Generate, } #[derive(Debug, Args)] @@ -130,11 +167,6 @@ enum IdAction { #[command(subcommand)] action: Option, }, - /// Shows the secret of every network this device has joined. - Secret { - #[command(subcommand)] - action: Option, - }, } #[derive(Debug, Subcommand)] @@ -147,12 +179,6 @@ enum KeyAction { Rotate, } -#[derive(Debug, Subcommand)] -enum SecretAction { - /// Prints a fresh random secret, for a network that does not exist yet. - Generate, -} - #[derive(Debug, Args)] struct StatusArgs { #[command(flatten)] @@ -546,7 +572,7 @@ fn device_section(paths: &StoragePaths, observed: &Observed) -> report::Section /// The networks this device belongs to, named but not described. /// /// No secrets: this is part of `status`, and a status report is somewhere a -/// secret must never appear. `tsunagi id secret` is the place that shows one, +/// secret must never appear. `tsunagi network secret` is the place that shows one, /// because asking for it there is deliberate. fn configured_networks_section(paths: &StoragePaths) -> report::Section { use report::{Health, Row, Section}; @@ -963,6 +989,45 @@ impl tsunagi::ipc::unix::ReportSource for AgentControl { }) } + fn join( + &self, + name: String, + secret: String, + ) -> tsunagi::BoxFuture<'_, Result> { + Box::pin(async move { + let name = NetworkName::new(&name).map_err(|err| err.to_string())?; + let secret = NetworkSecret::decode(&secret).map_err(|err| err.to_string())?; + let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret); + + // Read before joining: afterwards "already configured" is true + // of everything, and the difference is what the user is told. + let before = self + .agent + .list_networks() + .await + .map_err(|err| err.to_string())?; + let already = before + .iter() + .any(|other| other.network_id == keys.network_id()); + let shared = before + .iter() + .find(|other| other.name == name && other.network_id != keys.network_id()) + .map(|other| other.network_id.to_string()); + + let network_id = self + .agent + .join_network(&name, &secret) + .await + .map_err(|err| err.to_string())?; + Ok(tsunagi::ipc::JoinedReport { + name: name.as_str().to_string(), + network_id: network_id.to_string(), + already_configured: already, + name_shared_with: shared, + }) + }) + } + fn leave( &self, network_id: String, @@ -1054,12 +1119,104 @@ async fn network_command(args: NetworkArgs) -> Result<(), Box show_networks(&paths, &socket).await, + Some(NetworkAction::Join { + network, + secret, + secret_file, + }) => { + let secret = load_secret(secret.as_deref(), secret_file.as_deref())?; + join_network(&paths, &socket, &network, secret).await + } Some(NetworkAction::Leave { network, offline }) => { leave_network(&paths, &socket, &network, offline).await } + Some(NetworkAction::Secret { + network, + action: None, + }) => show_secrets(&paths, network.as_deref()), + Some(NetworkAction::Secret { + action: Some(SecretAction::Generate), + .. + }) => { + let secret = NetworkSecret::generate(); + println!("{}", secret.encode().as_str()); + eprintln!( + "\nShare this with every participant, over a channel you trust.\n\ + Anyone who has it can join the network." + ); + Ok(()) + } } } +/// Joins a network: into the running agent if there is one. +async fn join_network( + paths: &StoragePaths, + socket: &std::path::Path, + name: &str, + secret: NetworkSecret, +) -> Result<(), Box> { + // The running agent, because a second `up` cannot have the directory + // and because this way the network starts at once instead of at the + // next restart. + if socket.exists() { + let report = + tsunagi::ipc::unix::join_network(socket, name, secret.encode().as_str()).await?; + if report.already_configured { + println!( + "`{}` ({}) was already configured; it is running", + report.name, + short(&report.network_id, 10) + ); + } else { + println!("joined `{}` ({})", report.name, report.network_id); + } + if let Some(other) = &report.name_shared_with { + eprintln!( + "\nwarning: `{}` is also configured with a different secret, as {}.\n\ + A network is its name *and* its secret, so these two share nothing.\n\ + If that was a mistyped secret, `tsunagi network leave` removes one.", + report.name, + short(other, 10) + ); + } + return Ok(()); + } + + // No agent: configure it, and say when it will take effect rather than + // leaving the impression that it is running. + let name = NetworkName::new(name)?; + 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 + .iter() + .find(|other| other.name == name && other.network_id != keys.network_id()) + .map(|other| other.network_id.to_string()); + storage + .upsert_network(keys.network_id(), name.clone(), secret, true) + .await?; + storage.release_ownership_lock(); + + if already { + println!("`{name}` ({}) was already configured", keys.network_id()); + } else { + println!("joined `{name}` ({})", keys.network_id()); + } + if let Some(other) = shared { + eprintln!( + "\nwarning: `{name}` is also configured with a different secret, as {}.\n\ + A network is its name *and* its secret, so these two share nothing.", + short(&other, 10) + ); + } + eprintln!("\nNo agent is running here, so it starts with the next `tsunagi up`."); + Ok(()) +} + /// Every configured network, live where an agent can say so. async fn show_networks( paths: &StoragePaths, @@ -1261,18 +1418,6 @@ async fn id(args: IdArgs) -> Result<(), Box> { Some(IdAction::Key { action: Some(KeyAction::Rotate), }) => rotate_key(&paths, &socket).await, - Some(IdAction::Secret { action: None }) => show_secrets(&paths), - Some(IdAction::Secret { - action: Some(SecretAction::Generate), - }) => { - let secret = NetworkSecret::generate(); - println!("{}", secret.encode().as_str()); - eprintln!( - "\nShare this with every participant, over a channel you trust.\n\ - Anyone who has it can join the network." - ); - Ok(()) - } } } @@ -1294,21 +1439,28 @@ async fn show_identity( )); out.push(device); + // What this device *is*, not what it belongs to. The networks are + // `tsunagi network`, and their secrets are asked for by name there: + // printing them in an overview put them in every pasted report. let networks = stored_networks(paths); let mut section = Section::new("networks"); - if networks.is_empty() { - section.push(Row::new(Health::Info, "none", "no network has been joined")); - } - for network in &networks { - section.push( - Row::new( - Health::Info, - network.name.as_str(), - network.network_id.to_string(), - ) - .with_note(format!("secret {}", network.secret.encode().as_str())), - ); - } + section.push(match networks.len() { + 0 => Row::new(Health::Info, "none", "no network has been joined") + .with_note("`tsunagi network join --network --secret ` joins one"), + count => Row::new( + Health::Info, + "joined", + format!( + "{count} network(s): {}", + networks + .iter() + .map(|network| network.name.as_str().to_string()) + .collect::>() + .join(", ") + ), + ) + .with_note("`tsunagi network` lists them with their ids and addresses"), + }); out.push(section); print_report("tsunagi id", &out) @@ -1408,14 +1560,30 @@ async fn rotate_key( } /// The secret of every network this device has joined. -fn show_secrets(paths: &StoragePaths) -> Result<(), Box> { +fn show_secrets( + paths: &StoragePaths, + wanted: Option<&str>, +) -> Result<(), Box> { let networks = stored_networks(paths); if networks.is_empty() { eprintln!("no network has been joined"); return Ok(()); } - for network in networks { - println!("{} {}", network.name, network.secret.encode().as_str()); + match wanted { + Some(wanted) => { + let network = resolve_network(&networks, wanted)?; + println!("{}", network.secret.encode().as_str()); + } + None => { + for network in networks { + println!( + "{} {} {}", + network.name, + network.network_id, + network.secret.encode().as_str() + ); + } + } } Ok(()) } @@ -1720,7 +1888,7 @@ fn network_section( ) .with_note( "a network is its name *and* its secret, so these two share nothing. \ - Usually a mistyped secret; `tsunagi id secret` shows which is which.", + Usually a mistyped secret; `tsunagi network secret` shows which is which.", ), ); } @@ -2515,7 +2683,34 @@ async fn up(args: UpArgs) -> Result<(), Box> { } } - let agent = Agent::spawn(config).await?; + let agent = match Agent::spawn(config).await { + Ok(agent) => agent, + // One agent per identity, and the state directory is that identity. + // It can be in as many networks as you like — but only through the + // agent that is already running, so the lock on its own is an + // answer to a question nobody asked. + Err(tsunagi::Error::StateLocked { path }) => { + let socket = control_socket(&paths, args.control_socket.as_ref()); + if socket.exists() { + return Err(format!( + "an agent is already running for {}, and one state directory is one \ + agent.\n\n\ + To add `{name}` to it — same device, same interface, another network:\n\n \ + tsunagi network join --network {name} --secret \n\n\ + To run a second, separate agent instead, give it everything of its \ + own:\n\n \ + tsunagi up --state-dir --cache-dir --interface tsun1 \ + --ipv4-range --network {name} --secret \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() + ) + .into()); + } + return Err(tsunagi::Error::StateLocked { path }.into()); + } + Err(err) => return Err(err.into()), + }; // From here on every exit goes through `agent.shutdown()`, so the endpoint // is never dropped without being closed. let mut events = agent.subscribe(); diff --git a/crates/tsunagi-wg-quic/tests/local_control.rs b/crates/tsunagi-wg-quic/tests/local_control.rs index d735f1f..63f485f 100644 --- a/crates/tsunagi-wg-quic/tests/local_control.rs +++ b/crates/tsunagi-wg-quic/tests/local_control.rs @@ -243,6 +243,36 @@ impl tsunagi::ipc::unix::ReportSource for Control { Box::pin(async move { StatusReport::default() }) } + fn join( + &self, + name: String, + secret: String, + ) -> BoxFuture<'_, Result> { + Box::pin(async move { + let name = tsunagi::identity::NetworkName::new(&name).map_err(|e| e.to_string())?; + let secret = + tsunagi::identity::NetworkSecret::decode(&secret).map_err(|e| e.to_string())?; + let already = self + .0 + .list_networks() + .await + .map_err(|err| err.to_string())? + .iter() + .any(|other| other.name == name); + let network_id = self + .0 + .join_network(&name, &secret) + .await + .map_err(|err| err.to_string())?; + Ok(tsunagi::ipc::JoinedReport { + name: name.as_str().to_string(), + network_id: network_id.to_string(), + already_configured: already, + name_shared_with: None, + }) + }) + } + fn leave(&self, network_id: String) -> BoxFuture<'_, Result> { Box::pin(async move { let wanted: tsunagi::NetworkId = network_id.parse().map_err(|_| "not an id")?; @@ -312,3 +342,63 @@ async fn a_client_can_leave_a_network_through_the_running_agent() { agent.shutdown().await; peer.shutdown().await; } + +#[tokio::test] +async fn a_client_can_add_a_network_to_a_running_agent() { + // One agent per identity, and it may be in several networks at once — + // but the state directory belongs to that one live agent, so a second + // `up` cannot add a network to it. Without this there was no way at + // all: you could leave a network while running, but not join one. + let discovery = SharedMemoryDiscovery::new(); + let (first, first_secret) = network("control-join-one"); + let (second, second_secret) = network("control-join-two"); + + let dir = TempDir::new().unwrap(); + let agent = Agent::spawn(config_with(dir.path(), &discovery)) + .await + .unwrap(); + agent.join_network(&first, &first_secret).await.unwrap(); + + let socket_path = dir.path().join("control.sock"); + let control = ControlSocket::bind(&socket_path, Arc::new(Control(agent.clone()))) + .await + .unwrap(); + + let report = tsunagi::ipc::unix::join_network( + &socket_path, + second.as_str(), + second_secret.encode().as_str(), + ) + .await + .unwrap(); + assert_eq!(report.name, second.as_str()); + assert!(!report.already_configured); + + // Running, not merely written down: it is in the agent's own list and + // answering for status straight away, on the same identity. + let joined: tsunagi::NetworkId = report.network_id.parse().unwrap(); + assert!(agent.is_active(joined).await); + assert_eq!(agent.list_networks().await.unwrap().len(), 2); + assert!(agent.network_status(joined).await.is_ok()); + + // Joining the same one again is not an error, and says which it was. + let again = tsunagi::ipc::unix::join_network( + &socket_path, + second.as_str(), + second_secret.encode().as_str(), + ) + .await + .unwrap(); + assert!(again.already_configured); + assert_eq!(again.network_id, report.network_id); + + // A secret that is not one is refused rather than stored. + let err = tsunagi::ipc::unix::join_network(&socket_path, "rubbish", "not-a-secret") + .await + .unwrap_err(); + assert!(!err.to_string().is_empty()); + assert_eq!(agent.list_networks().await.unwrap().len(), 2); + + control.shutdown().await; + agent.shutdown().await; +} diff --git a/crates/tsunagi/src/agent/mod.rs b/crates/tsunagi/src/agent/mod.rs index fcb1de5..f229abb 100644 --- a/crates/tsunagi/src/agent/mod.rs +++ b/crates/tsunagi/src/agent/mod.rs @@ -473,7 +473,8 @@ impl Agent { tracing::warn!( "`{name}` is already configured with a different secret, as {}. \ Joining with this one adds a second network under the same name; \ - they share nothing. Check the secret, or use `tsunagi id secret` \ + they share nothing. Check the secret, or use \ + `tsunagi network secret` \ to see which is which.", other.network_id ); diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 2d141a8..670e811 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -61,7 +61,11 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf { } /// What a client asks for. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +/// +/// `Debug` is written by hand rather than derived: one of these carries a +/// network secret, and a derived one would put it in any log line that +/// printed a request. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[non_exhaustive] pub enum Request { /// Report what the agent is doing. @@ -78,6 +82,32 @@ pub enum Request { /// only it can publish the release, and only while its sessions are up. /// The network is named by its id, in the text form `status` prints. Leave(String), + /// Join a network, or start one that is configured and not running. + /// + /// Asked of the running agent because that is the only way to add a + /// network to an agent that is already up: the state directory belongs + /// to one live agent, so a second `tsunagi up` cannot. + Join { + /// The network name. + name: String, + /// The shared secret in its `tsn1…` text form. + /// + /// It travels over a socket only its owner can open, to the agent + /// that stores it anyway, and never appears in `Debug`. + secret: String, + }, +} + +impl std::fmt::Debug for Request { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Request::Status => f.write_str("Status"), + Request::SetHostname(name) => write!(f, "SetHostname({name})"), + Request::Leave(network) => write!(f, "Leave({network})"), + // The name is not a secret; the secret is. + Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: }}"), + } + } } /// What the agent answers. @@ -90,10 +120,33 @@ pub enum Response { Hostname(String), /// A network was left. Left(LeftReport), + /// A network was joined, or was already there and is now running. + Joined(JoinedReport), /// The request could not be served. Error(String), } +/// What happened when a network was joined. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JoinedReport { + /// The network's name, as given. + pub name: String, + /// The derived network id, which is what tells two same-named networks + /// apart. + pub network_id: String, + /// Whether this device was already configured for exactly this network. + /// + /// Joining is idempotent, so this is the difference between "added" and + /// "it was already there and is now running". + pub already_configured: bool, + /// Another configured network with the same name but a different + /// secret, if there is one. + /// + /// Almost always a mistyped secret, and the one thing that makes two + /// sections of a report look like one network. + pub name_shared_with: Option, +} + /// What happened when a network was left. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct LeftReport { diff --git a/crates/tsunagi/src/ipc/unix.rs b/crates/tsunagi/src/ipc/unix.rs index 8aecd7b..1f9c8cb 100644 --- a/crates/tsunagi/src/ipc/unix.rs +++ b/crates/tsunagi/src/ipc/unix.rs @@ -16,7 +16,7 @@ use tokio::task::JoinHandle; use crate::BoxFuture; use crate::error::{Error, Result}; -use super::{LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport}; +use super::{JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport}; /// Builds the report that answers a status request. /// @@ -46,6 +46,18 @@ pub trait ReportSource: Send + Sync + 'static { fn leave(&self, _network_id: String) -> BoxFuture<'_, std::result::Result> { Box::pin(async move { Err("this agent cannot leave a network".to_string()) }) } + + /// Joins a network, or starts one that is configured and not running. + /// + /// Defaulted to a refusal, like the others: a source that only reports + /// says so rather than appearing to have done it. + fn join( + &self, + _name: String, + _secret: String, + ) -> BoxFuture<'_, std::result::Result> { + Box::pin(async move { Err("this agent cannot join a network".to_string()) }) + } } impl ReportSource for F @@ -178,6 +190,10 @@ async fn handle(mut stream: UnixStream, source: Arc) -> Result Ok(report) => Response::Left(report), Err(reason) => Response::Error(reason), }, + Request::Join { name, secret } => match source.join(name, secret).await { + Ok(report) => Response::Joined(report), + Err(reason) => Response::Error(reason), + }, }; write_message(&mut stream, &response).await } @@ -252,6 +268,27 @@ pub async fn leave_network(path: impl AsRef, network_id: &str) -> Result, + name: &str, + secret: &str, +) -> Result { + let path = path.as_ref(); + let request = Request::Join { + name: name.to_string(), + secret: secret.to_string(), + }; + match exchange(path, &request, EXCHANGE_TIMEOUT).await? { + Response::Joined(report) => Ok(report), + Response::Error(reason) => Err(Error::Storage(reason)), + other => Err(Error::Storage(format!("unexpected answer: {other:?}"))), + } +} + /// Marks the wire format of the local control socket. /// /// `b"TSN"` followed by the version, so a mismatch is recognised as one @@ -263,7 +300,7 @@ pub async fn leave_network(path: impl AsRef, network_id: &str) -> Result(stream: &mut UnixStream, value: &T) -> Result<()> { let encoded = postcard::to_stdvec(value)