diff --git a/README.md b/README.md index cf31887..fff2d46 100644 --- a/README.md +++ b/README.md @@ -127,18 +127,21 @@ ping 100.65.243.53 `tx` and `rx` in the status should start moving. -IPv6 works out of the box: each member's address is derived from the network -id and collides with essentially nothing. - -**IPv4 addresses are allocated and then remembered.** The default range is +**Addresses are allocated and then remembered.** The default range is `10.13.37.0/24`; the first member to join settles it and later members adopt what they find, so `--ipv4-range` only matters for whoever starts the network: ```bash tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16 -tsunagi up --network lab --secret "$SECRET" --ipv4-range none # IPv6 only +tsunagi up --network lab --secret "$SECRET" --ipv4-range none # no data plane ``` +One agent has one interface, so two of its networks cannot both use that +range. The second takes the range **derived from its own network id**: not +picked locally — every member derives the same one from something they all +already have — so it is an agreement rather than a guess, and a device in +several networks gets an address in each. + An address is claimed with a record signed by that member's persistent device key, stored, and merged between every replica. A member that disappears for a month comes back to the same address, because the claim outlived the session. @@ -187,24 +190,35 @@ session with no agreed protocol. ## Names -`--dns` serves a local DNS zone for the network's members, so they can be -reached by name instead of by address: +`--dns` serves a local DNS zone for **every network this device is in**, +each named after the network, so members can be reached by name instead of +by address: ```bash tsunagi up --network lab --secret "$SECRET" --dns -dig @10.13.37.69 -p 5354 music.lab +dig @127.0.0.1 -p 5354 music.lab ``` +It is remembered with the device rather than with the command line: once on +it stays on across restarts, and `tsunagi dns off` is what turns it off. A +resolver that quietly disappears because a flag was not retyped is worse +than none, because the names simply stop working. `tsunagi dns on` also +turns it on for an agent that is already running, without restarting it. + +The zone of a network is its name — `--dns-zone` is gone, because with +several networks there is no single zone to name. A network name may contain +dots, so `--network lab.internal` is how you get `music.lab.internal`. + Names come from signed state, which is the point: **a member that is switched off still resolves**, because its claim outlived the session. The answers are IPv4 addresses, because that is what the overlay is. The -*questions* are taken over both families, on UDP and TCP: the server listens -on the overlay address and on `127.0.0.1`, and on `[::1]` for IPv6, so a -resolver reaches it over whichever it uses. All of those are published to -the system resolver together. A listening address is disposable — unlike an -address a member holds in signed state — so serving one family over the -overlay and the other over loopback costs nothing. +*questions* are taken on `127.0.0.1` and `[::1]`, over UDP and TCP, so a +resolver reaches it over whichever family it uses; both are published to the +system resolver together. Loopback and nothing else: the zones are a view +for the host running the agent, and binding an overlay address would put +them in front of the whole mesh — an agent in two networks would then answer +one network's questions about the other's names. The zone is the network name unless `--dns-zone` says otherwise. It is yours to choose, so a name that shadows a real public domain is reported and @@ -309,12 +323,40 @@ tsunagi id key rotate replace that key 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 join -n lab join one whose secret this device already has 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 + +tsunagi dns whether the local resolver is serving, and what +tsunagi dns on start it, now and after every restart +tsunagi dns off stop it, now and after every restart ``` +**A network without a secret makes one.** `tsunagi up --network lab` with no +`--secret` resolves in the obvious way: if this device is already in exactly +one network called `lab`, that one — so the name alone resumes what you have; +if it is in none, a fresh random secret, printed in full along with the one +line to send the others: + +``` +tsunagi is up + network lab (k2on43wadb…) + secret tsn1u7c… + +No --peer was given, so this agent waits to be contacted. Run this on the +other machine: + + tsunagi up --network lab --secret tsn1u7c… --peer 91e83a6e2b7a… +``` + +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 +nowhere else to read it from — and never when it was supplied, because then +it is already yours. Two networks of one name and no secret is the one case +with no answer, and it says so instead of choosing. + 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. diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 756bb0a..82eaec8 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -53,10 +53,37 @@ enum Command { Protocols, /// Shows the networks this device belongs to, and leaves them. Network(NetworkArgs), + /// Shows the local resolver, and turns it on or off. + Dns(DnsArgs), /// Removes everything this device has stored and starts over. Wipe(WipeArgs), } +#[derive(Debug, Args)] +struct DnsArgs { + #[command(flatten)] + paths: PathArgs, + + /// Control socket to talk to. Derived from the state directory by default. + #[arg(long, global = true)] + control_socket: Option, + + #[command(subcommand)] + action: Option, +} + +#[derive(Debug, Subcommand)] +enum DnsAction { + /// Starts serving, now and after every restart. + On { + /// Port to listen on, on loopback of both families. + #[arg(long, value_name = "PORT")] + port: Option, + }, + /// Stops serving, now and after every restart. + Off, +} + #[derive(Debug, Args)] struct NetworkArgs { #[command(flatten)] @@ -357,22 +384,23 @@ struct UpArgs { #[arg(long, value_name = "CIDR", help_heading = "System")] ipv4_range: Option, - /// Serve a local DNS zone for this network's members. + /// Serve a local DNS zone for every network this device is in. /// - /// Members resolve as `.`, from signed state, so a - /// member that is switched off still resolves. The answers are the - /// overlay's IPv4 addresses; questions are taken over both IPv4 and - /// IPv6, on UDP and TCP. + /// Each network becomes a zone named after it, and its members resolve + /// as `.` from signed state, so a member that is + /// switched off still resolves. Answers are the overlay's IPv4 + /// addresses; questions are taken on loopback of both families, over + /// UDP and TCP. + /// + /// Remembered: once on it stays on, and `tsunagi dns off` turns it off. #[arg(long, help_heading = "System")] dns: bool, - /// The zone to answer for. Defaults to the network name. - #[arg(long, value_name = "NAME", help_heading = "System")] - dns_zone: Option, - - /// Port for the local DNS server, on every address it listens on. - #[arg(long, default_value_t = 5354, help_heading = "System")] - dns_port: u16, + /// Port for the local DNS server, on loopback of both families. + /// + /// Remembered with the setting, so it needs giving only when changing. + #[arg(long, value_name = "PORT", help_heading = "System")] + dns_port: Option, /// How often to print a status summary, in seconds. Zero disables it. #[arg(long, default_value_t = 15)] @@ -404,6 +432,115 @@ fn load_secret( } } +/// Port the local resolver listens on unless told otherwise. +const DEFAULT_DNS_PORT: u16 = 5354; + +/// Settings key: whether the local resolver is wanted. +const DNS_ENABLED: &str = "dns.enabled"; +/// Settings key: which port it listens on. +const DNS_PORT: &str = "dns.port"; + +/// Whether the local resolver is on, and on which port. +/// +/// Stored with the device rather than passed on every start: a resolver +/// that quietly goes away when a command line is retyped is worse than no +/// resolver at all, because the names simply stop working. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct DnsSetting { + enabled: bool, + port: u16, +} + +impl Default for DnsSetting { + fn default() -> Self { + Self { + enabled: false, + port: DEFAULT_DNS_PORT, + } + } +} + +/// Reads the stored resolver setting. Read-only, so it needs no lock. +fn dns_setting(paths: &StoragePaths) -> DnsSetting { + let Ok(store) = tsunagi::storage::StateStore::open(paths.state_db()) else { + return DnsSetting::default(); + }; + DnsSetting { + enabled: matches!( + store.get_setting(DNS_ENABLED).ok().flatten().as_deref(), + Some("1") + ), + port: store + .get_setting(DNS_PORT) + .ok() + .flatten() + .and_then(|port| port.parse().ok()) + .unwrap_or(DEFAULT_DNS_PORT), + } +} + +/// Writes the resolver setting, for the next start and for this one. +fn store_dns_setting( + paths: &StoragePaths, + setting: DnsSetting, +) -> Result<(), Box> { + let store = tsunagi::storage::StateStore::open(paths.state_db())?; + store.set_setting(DNS_ENABLED, if setting.enabled { "1" } else { "0" })?; + store.set_setting(DNS_PORT, &setting.port.to_string())?; + Ok(()) +} + +/// Where the secret a command is about to use came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SecretOrigin { + /// Given on the command line, in a file or in the environment. + Given, + /// Read from the one network of that name this device already has. + Stored, + /// Invented here, because there was nothing to go on. + Generated, +} + +/// Works out which secret a network name means. +/// +/// Three cases, and they are what make the name alone a usable command: +/// +/// * given — use it, whatever is stored; +/// * not given and this device is already in exactly one network of that +/// name — that one, so `up --network lab` resumes rather than making a +/// stranger with the same name; +/// * not given and there is none — invent one, because an ad-hoc network is +/// a thing people want and "generate a secret first" is a step with no +/// purpose. The caller prints it: a secret nobody can read is no use. +/// +/// Two networks of one name and no secret is the one case with no answer, +/// and it says so rather than choosing. +fn resolve_secret( + paths: &StoragePaths, + name: &NetworkName, + secret: Option<&str>, + secret_file: Option<&std::path::Path>, +) -> Result<(NetworkSecret, SecretOrigin), Box> { + if secret.is_some() || secret_file.is_some() { + return Ok((load_secret(secret, secret_file)?, SecretOrigin::Given)); + } + + let known: Vec = stored_networks(paths) + .into_iter() + .filter(|network| network.name == *name) + .collect(); + match known.as_slice() { + [] => Ok((NetworkSecret::generate(), SecretOrigin::Generated)), + [one] => Ok((one.secret.clone(), SecretOrigin::Stored)), + several => Err(format!( + "this device is in {} networks called `{name}`, so the name alone does not say \ + which. Give --secret, or `tsunagi network` lists them with their ids.", + several.len() + ) + .into()), + } +} + /// Parses `` or `@,`. fn parse_peer(text: &str) -> Result { let (id_text, addr_text) = match text.split_once('@') { @@ -462,6 +599,7 @@ async fn run(command: Command) -> Result<(), Box> { Command::Status(args) => status(args).await, Command::Protocols => show_protocols(), Command::Network(args) => network_command(args).await, + Command::Dns(args) => dns_command(args).await, Command::Wipe(args) => wipe(args).await, } } @@ -603,12 +741,14 @@ fn configured_networks_section(paths: &StoragePaths) -> report::Section { /// What the local DNS service is doing, for `status` to report. #[derive(Debug, Clone, Default)] struct DnsState { - zone: String, + /// One zone per network, named after it. + zones: Vec, + /// Anything worth saying about those names, one line each. + zone_warnings: Vec, listening: Vec, bind_error: Option, publish_error: Option, publish_remedy: Option, - zone_warning: Option, names: u32, } @@ -618,9 +758,14 @@ struct DnsState { /// the resolver can be configured, because a resolver the user can point at /// by hand is worth more than nothing, and the reason it was not configured /// is reported rather than swallowed. +/// +/// One service for the agent, not one per network: an agent has one +/// identity and as many networks as it likes, each of them a zone named +/// after it, and they all arrive at the same socket. struct DnsService { state: Arc>, publisher: Arc, + port: u16, task: tokio::task::JoinHandle<()>, } @@ -634,6 +779,23 @@ impl DnsService { } } +/// What the control socket reports about the resolver. +fn dns_report(dns: DnsState) -> tsunagi::ipc::DnsReport { + tsunagi::ipc::DnsReport { + zones: dns.zones, + listening: dns + .listening + .iter() + .map(|address| address.to_string()) + .collect(), + bind_error: dns.bind_error, + publish_error: dns.publish_error, + publish_remedy: dns.publish_remedy, + zone_warnings: dns.zone_warnings, + names: dns.names, + } +} + /// Picks the publisher for this platform. fn dns_publisher() -> Arc { #[cfg(target_os = "linux")] @@ -646,38 +808,27 @@ fn dns_publisher() -> Arc { } } -/// Starts the DNS service for one network and keeps it in step with state. -fn spawn_dns( - agent: Agent, - network: NetworkId, - zone: tsunagi::dns::ZoneName, - port: u16, -) -> DnsService { - use tsunagi::dns::{DnsServer, SharedZone, Zone, listen_plan}; +/// Starts the DNS service and keeps it in step with the agent's state. +/// +/// Every network the agent is in becomes a zone named after it, so joining +/// or leaving one changes what resolves without restarting anything. +fn spawn_dns(agent: Agent, port: u16) -> DnsService { + use tsunagi::dns::{DnsServer, SharedZone, Zone, ZoneName, Zones, listen_plan}; - let state = Arc::new(std::sync::Mutex::new(DnsState { - zone: zone.as_str().to_string(), - zone_warning: zone.collision(), - ..DnsState::default() - })); + let state = Arc::new(std::sync::Mutex::new(DnsState::default())); let publisher = dns_publisher(); let task = { let state = Arc::clone(&state); let publisher = Arc::clone(&publisher); tokio::spawn(async move { - let shared = SharedZone::new(Zone::new(zone.clone(), [])); - // Held for their `Drop`, which stops each server: the values are - // never read, but letting them go is what closes the sockets. - // One per address family, so a question is answered over - // whichever the resolver uses. + let shared = SharedZone::default(); + // Held for their `Drop`, which stops each server: the values + // are never read, but letting them go is what closes the + // sockets. One per address family, so a question is answered + // over whichever the resolver uses. let mut _servers: Vec = Vec::new(); let mut bound: Vec = Vec::new(); - // What was tried last time, not what was got. Comparing against - // what was got would rebind on every tick whenever the preferred - // address is one that cannot be bound, closing the port each - // time for no reason. - let mut attempted: Option = None; let mut published: Option = None; // A condition that persists is worth saying once, not every // pass; and a refusal will not lift without somebody acting, so @@ -687,87 +838,95 @@ fn spawn_dns( let mut recipe_shown = false; let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2)); + // The port does not change while the service runs; turning DNS + // off and on again is what changes it, and that is a new + // service. So this is bound once and kept. + let wanted = listen_plan(port); + let mut last: Option = None; + for family in wanted.families() { + for candidate in family { + match DnsServer::bind(*candidate, shared.clone()).await { + Ok(fresh) => { + tracing::info!(address = %fresh.local_addr(), "dns listening"); + bound.push(fresh.local_addr()); + _servers.push(fresh); + break; + } + Err(err) => last = Some(err), + } + } + } + let bind_error = bound.is_empty().then(|| { + last.map_or_else( + || "no address to listen on".to_string(), + |err| err.to_string(), + ) + }); + { + let listening = bound.clone(); + update(&state, |state| { + state.listening = listening; + state.bind_error = bind_error; + }); + } + loop { ticker.tick().await; - let Ok(status) = agent.network_status(network).await else { + let Ok(status) = agent.status().await else { continue; }; - // Names come from signed state, so a member that is away is - // in here too. - let members = status.members.iter().filter_map(|member| { - Some((member.hostname.clone()?, member.overlay_address_v4?)) + // One zone per network, named after it. A name that cannot + // be a zone is said once and skipped: the network works, + // it just has no names. + let mut zones = Vec::new(); + let mut labels = Vec::new(); + let mut warnings = Vec::new(); + for network in &status.networks { + let zone = match ZoneName::new(network.name.as_str()) { + Ok(zone) => zone, + Err(err) => { + warnings.push(format!( + "`{}` cannot be a zone, so its members have no names: {err}", + network.name + )); + continue; + } + }; + if let Some(warning) = zone.collision() { + warnings.push(warning); + } + labels.push(zone.as_str().to_string()); + // Names come from signed state, so a member that is + // away is in here too. + let members = network.members.iter().filter_map(|member| { + Some((member.hostname.clone()?, member.overlay_address_v4?)) + }); + zones.push(Zone::new(zone, members)); + } + let zones = Zones::new(zones); + let names = zones.names() as u32; + shared.set(zones); + update(&state, |state| { + state.zones = labels.clone(); + state.zone_warnings = warnings; + state.names = names; }); - let fresh = Zone::new(zone.clone(), members); - let names = fresh.len() as u32; - shared.set(fresh); - // Listen where the resolver will be told to ask, which is an - // address on the overlay interface when there is one. - let own = agent.endpoint_id(); - let overlay = status - .members - .iter() - .find(|member| member.endpoint_id == own) - .and_then(|member| member.overlay_address_v4); + if bound.is_empty() { + continue; + } // The interface belongs to the agent, so the resolver - // setting attaches to that one and not to a protocol's. Only - // one that is really on the host: an in-memory interface has - // a name and nothing else, and telling the operating system - // about that name would configure whatever else happens to - // be called it. + // setting attaches to that one and not to a protocol's. + // Only one that is really on the host: an in-memory + // interface has a name and nothing else, and telling the + // operating system about that name would configure + // whatever else happens to be called it. let interface = agent .overlay() .filter(|overlay| overlay.on_host) .map(|overlay| overlay.interface) .filter(|name| !name.is_empty()); - let wanted = listen_plan(overlay, port); - if attempted.as_ref() != Some(&wanted) { - attempted = Some(wanted.clone()); - // Dropping the old ones first releases the port, so the - // rebind is not racing itself. - _servers.clear(); - let mut last: Option = None; - bound.clear(); - // Each family on its own: one of them being unavailable - // — IPv6 switched off, an address not on an interface — - // is no reason to answer on neither. - for family in wanted.families() { - for candidate in family { - match DnsServer::bind(*candidate, shared.clone()).await { - Ok(fresh) => { - tracing::info!( - address = %fresh.local_addr(), - zone = %zone.as_str(), - "dns listening" - ); - bound.push(fresh.local_addr()); - _servers.push(fresh); - break; - } - Err(err) => last = Some(err), - } - } - } - let bind_error = bound.is_empty().then(|| { - last.map_or_else( - || "no address to listen on".to_string(), - |err| err.to_string(), - ) - }); - let listening = bound.clone(); - update(&state, |state| { - state.listening = listening; - state.bind_error = bind_error; - }); - // The addresses moved, so whatever the resolver was told - // is now wrong. - published = None; - } - - if bound.is_empty() { - continue; - } let Some(interface) = interface else { update(&state, |state| { state.publish_error = Some( @@ -776,22 +935,29 @@ fn spawn_dns( .to_string(), ); state.publish_remedy = None; - state.names = names; }); continue; }; + if labels.is_empty() { + // Nothing to route here yet. Whatever was published is + // now wrong, and saying nothing is the honest setting. + if published.take().is_some() { + let _ = publisher.revert().await; + } + continue; + } let want_published = tsunagi::dns::Published { interface, servers: bound.clone(), - domains: vec![zone.as_str().to_string()], + domains: labels.clone(), }; let due = retry_after.is_none_or(|at| tokio::time::Instant::now() >= at); if published.as_ref() != Some(&want_published) && due { match publisher.apply(&want_published).await { Ok(()) => { tracing::info!( - zone = %zone.as_str(), + zones = %labels.join(", "), interface = %want_published.interface, "the system resolver was told where to ask" ); @@ -834,7 +1000,6 @@ fn spawn_dns( } } } - update(&state, |state| state.names = names); } }) }; @@ -842,6 +1007,7 @@ fn spawn_dns( DnsService { state, publisher, + port, task, } } @@ -966,20 +1132,80 @@ fn show_protocols() -> Result<(), Box> { struct AgentControl { agent: Agent, plugin: Option>, - dns: Option>>, + /// The resolver, which this owns so it can be switched while running. + dns: Arc>>, + /// Where the setting is remembered, so it survives a restart. + paths: StoragePaths, } impl tsunagi::ipc::unix::ReportSource for AgentControl { fn report(&self) -> tsunagi::BoxFuture<'_, tsunagi::ipc::StatusReport> { Box::pin(async move { - let dns = self.dns.as_ref().map(|state| match state.lock() { - Ok(guard) => guard.clone(), - Err(poisoned) => poisoned.into_inner().clone(), - }); + let dns = self.dns_state().await; build_report(&self.agent, self.plugin.as_deref(), dns).await }) } + fn set_dns( + &self, + enable: bool, + port: Option, + ) -> tsunagi::BoxFuture<'_, Result, String>> { + Box::pin(async move { + let mut service = self.dns.lock().await; + let port = port + .or_else(|| service.as_ref().map(|service| service.port)) + .unwrap_or_else(|| dns_setting(&self.paths).port); + + // Remembered first: what the agent is doing and what it will do + // after a restart must not drift apart, and a failure to store + // it is exactly the kind of drift. + store_dns_setting( + &self.paths, + DnsSetting { + enabled: enable, + port, + }, + ) + .map_err(|err| err.to_string())?; + + match (enable, service.take()) { + // Already serving on that port: nothing to restart. + (true, Some(running)) if running.port == port => { + *service = Some(running); + } + // A different port means a different socket. + (true, previous) => { + if let Some(previous) = previous { + previous.shutdown().await; + } + *service = Some(spawn_dns(self.agent.clone(), port)); + } + (false, Some(running)) => running.shutdown().await, + (false, None) => {} + } + let handle = service.as_ref().map(|service| Arc::clone(&service.state)); + // The lock goes before the wait: nothing else should queue + // behind a sleep, and the state is shared by an `Arc` anyway. + drop(service); + + let state = match handle { + // Freshly started: let it bind and collect a zone or two + // before answering, so the report is the state and not a + // snapshot of nothing. + Some(state) => { + tokio::time::sleep(std::time::Duration::from_millis(300)).await; + Some(match state.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }) + } + None => None, + }; + Ok(state.map(dns_report)) + }) + } + fn set_hostname(&self, hostname: String) -> tsunagi::BoxFuture<'_, Result> { Box::pin(async move { self.agent @@ -1062,6 +1288,17 @@ impl tsunagi::ipc::unix::ReportSource for AgentControl { } } +impl AgentControl { + /// The resolver's state, when there is one. + async fn dns_state(&self) -> Option { + let service = self.dns.lock().await; + service.as_ref().map(|service| match service.state.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }) + } +} + /// The networks this device has joined, read straight from the store. /// /// Secrets live only in the mandatory state, never in a status report and @@ -1405,6 +1642,98 @@ async fn wipe(args: WipeArgs) -> Result<(), Box> { Ok(()) } +/// `tsunagi dns`: the local resolver, and turning it on or off. +async fn dns_command(args: DnsArgs) -> Result<(), Box> { + let paths = args.paths.resolve()?; + let socket = control_socket(&paths, args.control_socket.as_ref()); + + let enable = match args.action { + None => return show_dns(&paths, &socket).await, + Some(DnsAction::On { .. }) => true, + Some(DnsAction::Off) => false, + }; + let port = match args.action { + Some(DnsAction::On { port }) => port, + _ => None, + }; + + // The running agent, so it takes effect now; it stores the setting too, + // so the two can never say different things. + if socket.exists() { + let report = tsunagi::ipc::unix::set_dns(&socket, enable, port).await?; + match report { + Some(report) => { + println!( + "serving {} on {}", + match report.zones.as_slice() { + [] => "no zone yet".to_string(), + zones => zones.join(", "), + }, + if report.listening.is_empty() { + report + .bind_error + .clone() + .unwrap_or_else(|| "nothing".to_string()) + } else { + report.listening.join(", ") + } + ); + if let Some(err) = &report.publish_error { + eprintln!("\nthe system resolver was not told: {err}"); + } + } + None => println!("not serving"), + } + return Ok(()); + } + + let stored = dns_setting(&paths); + store_dns_setting( + &paths, + DnsSetting { + enabled: enable, + port: port.unwrap_or(stored.port), + }, + )?; + println!("{} for future starts", if enable { "on" } else { "off" }); + eprintln!("\nNo agent is running here, so it takes effect with the next `tsunagi up`."); + Ok(()) +} + +/// What the resolver is doing, or why it is not. +async fn show_dns( + paths: &StoragePaths, + socket: &std::path::Path, +) -> Result<(), Box> { + use report::Report; + + let observed = observe(paths, socket).await; + let mut out = Report::new(); + match &observed { + Observed::Agent(report) => out.push(match &report.dns { + Some(dns) => dns_section(dns), + None => dns_absent_section(), + }), + // Not running: the stored setting is what it will do next time, + // which is the only truthful thing to say. + Observed::Stored { .. } => { + use report::{Health, Row, Section}; + let setting = dns_setting(paths); + let mut section = Section::new("dns"); + section.push( + Row::new( + Health::Info, + if setting.enabled { "on" } else { "off" }, + format!("port {}, for the next start", setting.port), + ) + .with_note("no agent is running, so nothing is answering right now"), + ); + out.push(section); + } + } + print_report("tsunagi dns", &out) +} + /// `tsunagi id`: what this device is, and what changes it. async fn id(args: IdArgs) -> Result<(), Box> { let paths = args.paths.resolve()?; @@ -1689,10 +2018,13 @@ fn dns_section(dns: &tsunagi::ipc::DnsReport) -> report::Section { let mut section = Section::new("dns"); section.push(Row::new( Health::Info, - "zone", - format!("{} · {} name(s)", dns.zone, dns.names), + "zones", + match dns.zones.as_slice() { + [] => "none yet: this agent is in no network that can be one".to_string(), + zones => format!("{} · {} name(s)", zones.join(", "), dns.names), + }, )); - if let Some(warning) = &dns.zone_warning { + for warning in &dns.zone_warnings { section.push(Row::new(Health::Degraded, "zone name", warning.clone())); } @@ -1730,7 +2062,7 @@ fn dns_section(dns: &tsunagi::ipc::DnsReport) -> report::Section { "resolve names yourself with `dig @{} -p {} .{}`", address.rsplit_once(':').map_or("", |(host, _)| host), address.rsplit_once(':').map_or("", |(_, port)| port), - dns.zone + dns.zones.first().map_or("", String::as_str) )), (None, None) => row, }); @@ -2585,13 +2917,37 @@ async fn netwatch_addresses() -> Vec { async fn up(args: UpArgs) -> Result<(), Box> { let name = NetworkName::new(args.network.clone())?; - let secret = load_secret(args.secret.as_deref(), args.secret_file.as_deref())?; let paths = args.paths.resolve()?; + let (secret, secret_origin) = resolve_secret( + &paths, + &name, + args.secret.as_deref(), + args.secret_file.as_deref(), + )?; // Parsed up front so a typo is reported immediately, and so the option is // never silently ignored when the data plane is off. let ipv4_range = resolve_ipv4_range(args.ipv4_range.as_ref())?; + // The resolver is a property of the device, not of this command line. + // `--dns` turns it on and it stays on; `tsunagi dns off` is what turns + // it off. Anything else means names work today and are gone tomorrow + // because a flag was not retyped. + let stored_dns = dns_setting(&paths); + let serve_dns = args.dns || stored_dns.enabled; + let dns_port = args.dns_port.unwrap_or(stored_dns.port); + if serve_dns != stored_dns.enabled || dns_port != stored_dns.port { + // Written before the agent takes the directory, which it is about + // to do; nothing else holds it at this point. + store_dns_setting( + &paths, + DnsSetting { + enabled: serve_dns, + port: dns_port, + }, + )?; + } + let mut bootstrap: Vec = Vec::new(); for peer in &args.peers { bootstrap.push(parse_peer(peer)?); @@ -2727,43 +3083,40 @@ async fn up(args: UpArgs) -> Result<(), Box> { println!(" hostname {}", agent.hostname()); println!(" network {name} ({network})"); println!(" state {}", paths.state_dir.display()); - if args.peers.is_empty() { - println!( - "\nNo --peer was given, so this agent waits to be contacted.\n\ - On the other machine run:\n\n tsunagi up --network {name} --secret \\\n --peer {}\n", - agent.endpoint_id() - ); + // 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. + let shareable = match secret_origin { + SecretOrigin::Generated => secret.encode().as_str().to_string(), + SecretOrigin::Given | SecretOrigin::Stored => "".to_string(), + }; + if secret_origin == SecretOrigin::Generated { + println!(" secret {}", secret.encode().as_str()); } - // A local resolver for this network's members. The zone name is the - // user's to choose; a name that shadows a public one is reported and - // then used, because that is a decision and not a mistake. - let dns = if args.dns { - let raw = args.dns_zone.clone().unwrap_or_else(|| name.to_string()); - match tsunagi::dns::ZoneName::new(&raw) { - Ok(zone) => { - if let Some(warning) = zone.collision() { - tracing::warn!("{warning}"); - } - println!(" dns zone {}", zone.as_str()); - Some(spawn_dns(agent.clone(), network, zone, args.dns_port)) - } - Err(err) => { - agent.shutdown().await; - return Err(format!("--dns-zone {raw}: {err}").into()); - } - } + // A local resolver for every network this agent is in, each a zone + // named after it. A name that shadows a public one is reported and then + // used, because that is a decision and not a mistake. + let dns = Arc::new(tokio::sync::Mutex::new(if serve_dns { + println!(" dns 127.0.0.1:{dns_port} and [::1]:{dns_port}"); + Some(spawn_dns(agent.clone(), dns_port)) } else { None - }; + })); // Serve `tsunagi status` for as long as this agent runs. Failing to bind // is not fatal: the agent itself works fine without it. let control = { let agent = agent.clone(); let plugin = wireguard.clone(); - let dns = dns.as_ref().map(|service| Arc::clone(&service.state)); - let source: Arc = - Arc::new(AgentControl { agent, plugin, dns }); + let dns = Arc::clone(&dns); + let source: Arc = Arc::new(AgentControl { + agent, + plugin, + dns, + paths: paths.clone(), + }); let path = control_socket(&paths, args.control_socket.as_ref()); match tsunagi::ipc::unix::ControlSocket::bind(path, source).await { Ok(socket) => { @@ -2777,7 +3130,17 @@ async fn up(args: UpArgs) -> Result<(), Box> { } }; - println!("Press Ctrl-C to stop.\n"); + // Last, after the facts, because it is the line to act on: one + // command with everything the other side needs. + if args.peers.is_empty() { + println!( + "\nNo --peer was given, so this agent waits to be contacted. \ + Run this on the other machine:\n\n \ + tsunagi up --network {name} --secret {shareable} --peer {}", + agent.endpoint_id() + ); + } + println!("\nPress Ctrl-C to stop.\n"); let status_every = (args.status_interval > 0).then(|| Duration::from_secs(args.status_interval)); @@ -2812,7 +3175,7 @@ async fn up(args: UpArgs) -> Result<(), Box> { } // Before the agent, so the resolver stops being pointed at a server // that is about to stop answering. - if let Some(dns) = dns { + if let Some(dns) = dns.lock().await.take() { dns.shutdown().await; } agent.shutdown().await; @@ -2829,24 +3192,11 @@ async fn build_report( dns: Option, ) -> tsunagi::ipc::StatusReport { use tsunagi::ipc::{ - DnsReport, MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, - StatusReport, + MemberReport, NetworkReport, OverlayPeerReport, OverlayReport, PeerReport, StatusReport, }; let overlay = agent.overlay(); - let dns = dns.map(|dns| DnsReport { - zone: dns.zone, - listening: dns - .listening - .iter() - .map(|address| address.to_string()) - .collect(), - bind_error: dns.bind_error, - publish_error: dns.publish_error, - publish_remedy: dns.publish_remedy, - zone_warning: dns.zone_warning, - names: dns.names, - }); + let dns = dns.map(dns_report); let Ok(status) = agent.status().await else { return StatusReport::default(); @@ -3303,7 +3653,7 @@ mod status_tests { use tsunagi::ipc::DnsReport; let both = DnsReport { - zone: "lab".into(), + zones: vec!["lab".into()], listening: vec!["10.13.37.69:5354".into(), "[::1]:5354".into()], names: 2, ..Default::default() @@ -3520,3 +3870,92 @@ mod network_tests { assert!(err.contains("use more of the id"), "{err}"); } } + +#[cfg(test)] +mod secret_tests { + #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + + use super::*; + use tsunagi::identity::NetworkKeys; + + fn paths(dir: &tempfile::TempDir) -> StoragePaths { + StoragePaths::new(dir.path().join("state"), dir.path().join("cache")) + } + + fn already_joined(paths: &StoragePaths, name: &str) -> NetworkSecret { + let name = NetworkName::new(name).unwrap(); + let secret = NetworkSecret::generate(); + let keys = NetworkKeys::derive(&name, &secret); + std::fs::create_dir_all(&paths.state_dir).unwrap(); + let store = tsunagi::storage::StateStore::open(paths.state_db()).unwrap(); + store + .upsert_network(keys.network_id(), &name, &secret, true) + .unwrap(); + secret + } + + #[test] + fn a_name_nobody_has_yet_gets_a_secret_of_its_own() { + // An ad-hoc network is a thing people want, and "generate a secret + // first" is a step with no purpose. The caller prints what this + // invents, because a secret nobody can read is no use. + let dir = tempfile::tempdir().unwrap(); + let paths = paths(&dir); + let name = NetworkName::new("spontaneous").unwrap(); + + let (secret, origin) = resolve_secret(&paths, &name, None, None).unwrap(); + assert_eq!(origin, SecretOrigin::Generated); + // A real one: decodable, and different every time. + let text = secret.encode().as_str().to_string(); + assert!(NetworkSecret::decode(&text).is_ok()); + let (other, _) = resolve_secret(&paths, &name, None, None).unwrap(); + assert_ne!(other.encode().as_str(), text); + } + + #[test] + fn a_name_this_device_already_has_resumes_it() { + // Otherwise `tsunagi up --network lab` would invent a stranger with + // the same name every time, which is the confusion this whole + // report format exists to prevent. + let dir = tempfile::tempdir().unwrap(); + let paths = paths(&dir); + let joined = already_joined(&paths, "lab"); + + let (secret, origin) = + resolve_secret(&paths, &NetworkName::new("lab").unwrap(), None, None).unwrap(); + assert_eq!(origin, SecretOrigin::Stored); + assert_eq!(secret.encode().as_str(), joined.encode().as_str()); + } + + #[test] + fn a_secret_that_was_given_wins_over_the_stored_one() { + let dir = tempfile::tempdir().unwrap(); + let paths = paths(&dir); + already_joined(&paths, "lab"); + let given = NetworkSecret::generate(); + + let (secret, origin) = resolve_secret( + &paths, + &NetworkName::new("lab").unwrap(), + Some(given.encode().as_str()), + None, + ) + .unwrap(); + assert_eq!(origin, SecretOrigin::Given); + assert_eq!(secret.encode().as_str(), given.encode().as_str()); + } + + #[test] + fn two_networks_of_one_name_refuse_to_guess() { + // The mistyped-secret case. Picking one would be picking wrong half + // the time, and doing it silently. + let dir = tempfile::tempdir().unwrap(); + let paths = paths(&dir); + already_joined(&paths, "lab"); + already_joined(&paths, "lab"); + + let err = resolve_secret(&paths, &NetworkName::new("lab").unwrap(), None, None) + .expect_err("it cannot choose"); + assert!(err.to_string().contains("does not say which"), "{err}"); + } +} diff --git a/crates/tsunagi-cli/tests/dns_service.rs b/crates/tsunagi-cli/tests/dns_service.rs index 7aaa247..c2714f5 100644 --- a/crates/tsunagi-cli/tests/dns_service.rs +++ b/crates/tsunagi-cli/tests/dns_service.rs @@ -20,6 +20,8 @@ use tempfile::TempDir; const PORT_BINDS: u16 = 15361; const PORT_REBIND: u16 = 15362; const PORT_REFUSE: u16 = 15363; +const PORT_TWO_ZONES: u16 = 15364; +const PORT_SWITCH: u16 = 15365; /// Asks, and returns the raw reply. Raw because a parsed packet borrows /// from the bytes it came out of. @@ -67,7 +69,21 @@ fn wait_for_answer(server: SocketAddr, name: &str) -> Vec { /// The agent, running as a real process with its DNS service on. struct Running { child: std::process::Child, - _dir: TempDir, + dir: TempDir, +} + +impl Running { + /// Runs another `tsunagi` command against this agent's directory. + fn run(&self, args: &[&str]) -> std::process::Output { + std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) + .args(args) + .arg("--state-dir") + .arg(self.dir.path().join("state")) + .arg("--cache-dir") + .arg(self.dir.path().join("cache")) + .output() + .expect("the agent binary runs") + } } impl Drop for Running { @@ -77,13 +93,17 @@ impl Drop for Running { } } +/// Starts an agent in one network, whose name is therefore the zone. +/// +/// There is no separate zone setting: an agent serves a zone per network, +/// named after it, so the network name is the zone name. fn start(zone: &str, port: u16) -> Running { let dir = TempDir::new().unwrap(); let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi")) .args([ "up", "--network", - "dnswiring", + zone, "--secret", "a-secret-for-the-dns-test", ]) @@ -93,21 +113,44 @@ fn start(zone: &str, port: u16) -> Running { .arg(dir.path().join("cache")) // No real interface and no internet: this is about the wiring. .args(["--reach", "local", "--no-tun", "--dns"]) - .args(["--dns-zone", zone]) .args(["--dns-port", &port.to_string()]) .args(["--log", "error", "--status-interval", "0"]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .expect("the agent binary starts"); - Running { child, _dir: dir } + 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"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .expect("the agent binary starts"); + Running { child, dir } } #[test] fn the_resolver_comes_up_even_with_no_overlay_interface_to_put_it_on() { - // The promise is that the port is served whatever else fails. With - // `--no-tun` the allocated overlay address is on no interface, so - // binding to it cannot work and loopback is the answer — getting this + // The promise is that the port is served whatever else fails: with + // `--no-tun` there is no interface to attach a resolver setting to, + // and the zone is answered on loopback all the same. Getting this // wrong left the feature silently dead. let _agent = start("lab.internal", PORT_BINDS); let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap(); @@ -123,10 +166,10 @@ fn the_resolver_comes_up_even_with_no_overlay_interface_to_put_it_on() { #[test] fn the_listener_is_not_rebuilt_on_every_pass() { - // The supervisor compares what it tried last time, not what it got. The - // other way round it rebound on every tick, because the preferred - // address is one that never binds here — and the port was shut for a - // moment each time. + // The listener is bound once and kept, and the zones are swapped + // underneath it as networks and members come and go. Rebuilding it on + // the way past — which an earlier version did on every tick — shut the + // port for a moment each time. let _agent = start("rebind.internal", PORT_REBIND); let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap(); let name = format!("{}.rebind.internal", hostname()); @@ -157,3 +200,72 @@ fn a_name_outside_the_zone_is_refused_and_never_forwarded() { fn hostname() -> String { tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into()) } + +#[test] +fn every_network_gets_a_zone_of_its_own() { + // One agent, one identity, several networks — and a question carries a + // name, not the network it belongs to. Each network is a zone named + // after it, and joining one while the agent runs adds its zone without + // restarting anything. + let agent = start("first.internal", PORT_TWO_ZONES); + let server: SocketAddr = format!("127.0.0.1:{PORT_TWO_ZONES}").parse().unwrap(); + let host = hostname(); + wait_for_answer(server, &format!("{host}.first.internal")); + + let joined = agent.run(&[ + "network", + "join", + "--network", + "second.internal", + "--secret", + "another-secret-for-the-dns-test", + ]); + assert!( + joined.status.success(), + "joining failed: {}", + String::from_utf8_lossy(&joined.stderr) + ); + + // The second network's zone answers too, and neither leaks into the + // other: a member of one is not a name in the other. + let reply = wait_for_answer(server, &format!("{host}.second.internal")); + assert_eq!(Packet::parse(&reply).unwrap().rcode(), RCODE::NoError); + let first = wait_for_answer(server, &format!("{host}.first.internal")); + assert_eq!(Packet::parse(&first).unwrap().rcode(), RCODE::NoError); +} + +#[test] +fn the_resolver_can_be_switched_on_and_off_while_the_agent_runs() { + // Forgetting `--dns` on a command line should not be a decision that + // lasts until the next restart, and it is not: the setting belongs to + // the device, and turning it on takes effect at once. + let agent = start_without_dns("switch.internal"); + let server: SocketAddr = format!("127.0.0.1:{PORT_SWITCH}").parse().unwrap(); + let host = hostname(); + + // Give the agent time to be up before asking it anything. + std::thread::sleep(Duration::from_secs(1)); + assert!( + query(server, &format!("{host}.switch.internal"), TYPE::A).is_none(), + "nothing should be answering yet" + ); + + let on = agent.run(&["dns", "on", "--port", &PORT_SWITCH.to_string()]); + assert!( + on.status.success(), + "dns on failed: {}", + String::from_utf8_lossy(&on.stderr) + ); + wait_for_answer(server, &format!("{host}.switch.internal")); + + let off = agent.run(&["dns", "off"]); + assert!(off.status.success()); + let deadline = Instant::now() + Duration::from_secs(10); + while query(server, &format!("{host}.switch.internal"), TYPE::A).is_some() { + assert!( + Instant::now() < deadline, + "it kept answering after `dns off`" + ); + std::thread::sleep(Duration::from_millis(200)); + } +} diff --git a/crates/tsunagi/src/agent/mod.rs b/crates/tsunagi/src/agent/mod.rs index f229abb..75bc251 100644 --- a/crates/tsunagi/src/agent/mod.rs +++ b/crates/tsunagi/src/agent/mod.rs @@ -355,21 +355,51 @@ impl Agent { /// waits to adopt whatever it settles on. One agent has one interface, so /// proposing a range it could not route would be worse than having none: /// the lowest author's range wins, and the collision would spread. - fn reserve_range(&self, network: NetworkId) -> (Option, Option) { - let Some(wanted) = self.inner.config.overlay_ipv4_range else { - return (None, None); + fn reserve_range(&self, network: NetworkId) -> RangePlan { + let Some(configured) = self.inner.config.overlay_ipv4_range else { + return RangePlan::default(); }; - let reservation = crate::overlay::NetworkRoutes { - range: Some(wanted), - local: None, - peers: Vec::new(), + let reserve = |wanted: Ipv4Range| { + let reservation = crate::overlay::NetworkRoutes { + range: Some(wanted), + local: None, + peers: Vec::new(), + }; + self.inner.routes.set_network(network, reservation).is_ok() }; - match self.inner.routes.set_network(network, reservation) { - Ok(()) => (Some(wanted), None), - Err(err) => { - tracing::info!(%err, "not proposing a range for this network"); - (None, Some(wanted)) - } + + // The configured range first, so a network a device has always had + // keeps the addresses it has always had. + if reserve(configured) { + return RangePlan { + propose: Some(configured), + ..RangePlan::default() + }; + } + + // A second network on the same agent cannot have it — one agent, + // one interface — so it falls back to the range derived from its + // own id, which every one of its members derives identically + // without being told. + let derived = crate::state::derived_ipv4_range(network); + if reserve(derived) { + tracing::info!( + %network, + range = %derived, + "another network here holds the configured range; this one uses the \ + range derived from its id unless its members settled another" + ); + return RangePlan { + fallback: Some(derived), + conflict: Some(configured), + ..RangePlan::default() + }; + } + + tracing::info!(%network, "not proposing a range for this network"); + RangePlan { + conflict: Some(configured), + ..RangePlan::default() } } @@ -521,7 +551,7 @@ impl Agent { return Err(Error::NetworkAlreadyActive(network_id)); } - let (reserved, conflict) = self.reserve_range(network_id); + let range = self.reserve_range(network_id); let handle = network::spawn(RuntimeParams { keys, adapter: self.inner.adapter.clone(), @@ -537,8 +567,9 @@ impl Agent { hostname: self.inner.read_hostname(), transport: self.inner.transport.get().cloned(), device_secret: self.inner.identity.signing_key(), - ipv4_range: reserved, - range_conflict: conflict, + ipv4_range: range.propose, + ipv4_fallback: range.fallback, + range_conflict: range.conflict, }); networks.insert(network_id, handle); drop(networks); @@ -856,6 +887,18 @@ impl Agent { } } +/// What a network may propose as its overlay range, and what it cannot have. +#[derive(Debug, Clone, Copy, Default)] +struct RangePlan { + /// Proposed straight away: the configured range, when it is free here. + propose: Option, + /// Proposed after a moment, when the configured range is another + /// network's: the range derived from this network's own id. + fallback: Option, + /// The configured range, when this network cannot have it. + conflict: Option, +} + /// How long a release is given to reach the sessions it was queued on. /// /// Short: it is one small message on a connection that is already open, and diff --git a/crates/tsunagi/src/agent/network.rs b/crates/tsunagi/src/agent/network.rs index 9d23914..08d8f79 100644 --- a/crates/tsunagi/src/agent/network.rs +++ b/crates/tsunagi/src/agent/network.rs @@ -143,6 +143,14 @@ pub(crate) struct RuntimeParams { /// The range it was configured with but cannot have, because another /// network on this agent holds it. pub(crate) range_conflict: Option, + /// The range derived from this network's own id, proposed when the + /// configured one belongs to another network here. + /// + /// Held back at first: a network that already exists has a range of its + /// own, and a member that proposed before hearing anything would be + /// arguing with it instead of adopting it. Every member derives the + /// same one, so once the wait is over there is nothing to argue about. + pub(crate) ipv4_fallback: Option, /// How data plane links are opened. `None` disables the data plane. pub(crate) transport: Option>, } @@ -204,8 +212,18 @@ pub(crate) fn spawn(params: RuntimeParams) -> NetworkHandle { } } +/// How long a network waits to be told its range before proposing the one +/// derived from its id. +/// +/// Only for that fallback: the configured range is proposed at once, as it +/// always was. This is the window in which an existing network's records +/// can arrive and be adopted instead. +const RANGE_PROPOSAL_GRACE: Duration = Duration::from_secs(3); + struct Runtime { params: RuntimeParams, + /// When this runtime started, for the fallback range's grace period. + activated: std::time::Instant, network_id: NetworkId, local_id: EndpointId, shutdown: Shutdown, @@ -226,6 +244,12 @@ struct Runtime { /// Peers already told about a protocol version that cannot match, so it /// is said once rather than on every announcement. reported_mismatch: HashSet<(EndpointId, String)>, + /// Set once this agent has given everything up here. + /// + /// A release is a statement on the way out, and anything that would + /// publish a claim afterwards — the periodic check, a peer's records + /// arriving — would silently take it back. + released: bool, /// The address last reported as absent from the interface, so it is said /// once rather than for ever. reported_missing: Option, @@ -246,6 +270,7 @@ impl Runtime { let (link_results_tx, link_results_rx) = mpsc::channel(64); Self { params, + activated: std::time::Instant::now(), network_id, local_id, shutdown, @@ -262,6 +287,7 @@ impl Runtime { link_results_tx, link_results_rx, reported_mismatch: HashSet::new(), + released: false, reported_missing: None, state: StateSet::new(), pending_state: Vec::new(), @@ -372,6 +398,7 @@ impl Runtime { // are freed for somebody else instead of staying reserved // to a member that has gone. self.publish_record(RecordBody::Release).await; + self.released = true; let _ = reply.send(self.sessions.len()); } NetCommand::SetHostname(hostname) => { @@ -460,6 +487,10 @@ impl Runtime { if self.shutdown.is_triggered() { return; } + // The fallback range becomes available with the passage of time + // alone, so something has to look again; this runs on a timer and + // the check is a comparison when nothing has changed. + self.ensure_own_claim().await; let mut candidates: Vec = Vec::new(); @@ -664,12 +695,30 @@ impl Runtime { // one interface, and claiming an address in a range another network // already owns would spread the collision rather than contain it — // "the lowest author's range wins" would carry it to everybody. - // Better to hold off and adopt whatever the network settles on. - let wanted = self.params.ipv4_range?; - match self.params.routes.would_overlap(self.network_id, wanted) { - None => Some(wanted), - Some(_) => None, + if let Some(wanted) = self.params.ipv4_range + && self + .params + .routes + .would_overlap(self.network_id, wanted) + .is_none() + { + return Some(wanted); } + + // The configured range is another network's here, so this one falls + // back to the range derived from its id — after a moment's wait, so + // that a network which already exists gets to say what it uses + // first. Every member derives the same one, so members that reach + // this point agree without negotiating. + let fallback = self.params.ipv4_fallback?; + if self.activated.elapsed() < RANGE_PROPOSAL_GRACE { + return None; + } + self.params + .routes + .would_overlap(self.network_id, fallback) + .is_none() + .then_some(fallback) } /// Makes sure this agent holds an address, claiming one if it does not. @@ -677,6 +726,11 @@ impl Runtime { /// Called after anything that could change the picture: startup, and /// every time another replica's records arrive. async fn ensure_own_claim(&mut self) { + // On the way out. Claiming again here is how a goodbye becomes a + // hello nobody asked for. + if self.released { + return; + } let wanted_hostname = { let hostname = crate::state::sanitise_hostname(&self.params.hostname); (!hostname.is_empty()).then_some(hostname) @@ -1470,7 +1524,13 @@ impl Runtime { candidates, members, range: self.effective_range(), - range_conflict: self.params.range_conflict, + // Only worth reporting while it is actually stuck: with a + // fallback in play the configured range being another + // network's is how it is meant to work, not a fault. + range_conflict: self + .params + .range_conflict + .filter(|_| self.effective_range().is_none()), metrics: self.metrics.clone(), } } diff --git a/crates/tsunagi/src/dns/mod.rs b/crates/tsunagi/src/dns/mod.rs index 74a544a..7748b0a 100644 --- a/crates/tsunagi/src/dns/mod.rs +++ b/crates/tsunagi/src/dns/mod.rs @@ -20,30 +20,19 @@ pub use publish::{DnsPublisher, PublishError, Published}; use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr}; -/// Where the server should try to listen, one list per address family. +/// Where the server listens, one list per address family. /// -/// Both families are served, and independently: a question arrives over -/// whichever one the resolver happens to use, and on a host where one of -/// them is switched off the other must still answer. So neither list failing -/// says anything about the other, and a listener is opened from each. +/// Loopback, and only loopback. The zones are a view *for the host running +/// the agent*: it is that host's resolver that is pointed at them. Binding +/// an overlay address instead would put them in front of the whole mesh — +/// and an agent in several networks would then answer one network's +/// questions about another's names, which is precisely what separate +/// networks are for. /// -/// Within a list the order is best first. The overlay address comes first: -/// that is what the system resolver is pointed at, and it is reachable only -/// over the overlay interface, so a question for these names cannot arrive -/// from anywhere else. -/// -/// Loopback second, and it is not merely a fallback for having no overlay -/// address. An address this agent has been *allocated* is not necessarily an -/// address that is *on an interface* — with no privileges, with `--no-tun`, -/// or in the moment before the interface is configured, it is not — and -/// binding to one that is not there fails. Trying loopback afterwards is -/// what keeps the promise that the port comes up regardless. -/// -/// The overlay itself is IPv4, so there is no overlay address to offer for -/// IPv6 and that list is loopback alone. That is a property of the overlay -/// and not of this: a listening address is disposable, unlike an address a -/// member holds in signed state, so serving one family over loopback and the -/// other over the overlay costs nothing and loses nothing. +/// Both families, and independently: a question arrives over whichever one +/// the resolver happens to use, and on a host with one of them switched off +/// the other must still answer. So neither list failing says anything about +/// the other, and a listener is opened from each. #[derive(Debug, Clone, PartialEq, Eq)] pub struct ListenPlan { /// IPv4 candidates, best first. @@ -60,20 +49,15 @@ impl ListenPlan { } /// Builds the listen plan for one port. -pub fn listen_plan(overlay: Option, port: u16) -> ListenPlan { - let mut v4 = Vec::with_capacity(2); - if let Some(overlay) = overlay { - v4.push(SocketAddr::from((overlay, port))); - } - v4.push(SocketAddr::from((Ipv4Addr::LOCALHOST, port))); +pub fn listen_plan(port: u16) -> ListenPlan { ListenPlan { - v4, + v4: vec![SocketAddr::from((Ipv4Addr::LOCALHOST, port))], v6: vec![SocketAddr::from((Ipv6Addr::LOCALHOST, port))], } } pub use server::{DnsServer, SharedZone}; -pub use zone::{Answer, Query, Zone, ZoneError, ZoneName}; +pub use zone::{Answer, Query, Zone, ZoneError, ZoneName, Zones}; #[cfg(test)] mod tests { @@ -82,31 +66,19 @@ mod tests { use super::*; #[test] - fn both_families_are_planned_for_and_the_overlay_is_preferred() { - let plan = listen_plan(Some(Ipv4Addr::new(10, 13, 37, 69)), 5354); - // Loopback is in the list even when there is an overlay address, - // because being allocated one is not the same as it being on an - // interface — and binding to one that is not there fails. - assert_eq!( - plan.v4, - vec![ - "10.13.37.69:5354".parse::().unwrap(), - "127.0.0.1:5354".parse::().unwrap(), - ] - ); - // IPv6 is served too, over loopback: the overlay has no IPv6 - // address to offer, and that is no reason to answer only one family. - assert_eq!(plan.v6, vec!["[::1]:5354".parse::().unwrap()]); - assert_eq!(plan.families().len(), 2); - } - - #[test] - fn with_no_overlay_address_each_family_still_has_somewhere_to_listen() { - let plan = listen_plan(None, 5354); + fn both_families_are_planned_for_and_neither_is_reachable_off_this_host() { + let plan = listen_plan(5354); assert_eq!( plan.v4, vec!["127.0.0.1:5354".parse::().unwrap()] ); assert_eq!(plan.v6, vec!["[::1]:5354".parse::().unwrap()]); + assert_eq!(plan.families().len(), 2); + // Nothing here is reachable from another member: the zones are a + // view for this host, and an agent in two networks must not answer + // one network's questions about the other's names. + for address in plan.families().concat() { + assert!(address.ip().is_loopback(), "{address} is not loopback"); + } } } diff --git a/crates/tsunagi/src/dns/server.rs b/crates/tsunagi/src/dns/server.rs index a6edc3a..bb4abaf 100644 --- a/crates/tsunagi/src/dns/server.rs +++ b/crates/tsunagi/src/dns/server.rs @@ -16,7 +16,7 @@ use std::sync::{Arc, RwLock}; use simple_dns::rdata::{A, PTR, RData, SOA}; use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE}; -use super::zone::{Answer, Query, Zone}; +use super::zone::{Answer, Query, Zone, Zones}; /// How long an answer may be cached. /// @@ -39,30 +39,35 @@ const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); /// How many TCP questions may be in flight at once. const MAX_TCP_CONNECTIONS: usize = 32; -/// The zone the server answers from, swapped as the roster changes. +/// The zones the server answers from, swapped as the rosters change. /// -/// Shared rather than copied into the server so that a roster change is one -/// write, not a restart: rebinding the socket would drop questions in flight -/// for no reason. -#[derive(Debug, Clone)] -pub struct SharedZone(Arc>>); +/// Shared rather than copied into the server so that a roster change — or a +/// network joining or leaving — is one write, not a restart: rebinding the +/// socket would drop questions in flight for no reason. +#[derive(Debug, Clone, Default)] +pub struct SharedZone(Arc>>); impl SharedZone { - /// Wraps a zone. - pub fn new(zone: Zone) -> Self { - Self(Arc::new(RwLock::new(Arc::new(zone)))) + /// Wraps a set of zones. + pub fn new(zones: Zones) -> Self { + Self(Arc::new(RwLock::new(Arc::new(zones)))) } - /// Replaces it. - pub fn set(&self, zone: Zone) { + /// Wraps a single zone, which is the common case in a test. + pub fn one(zone: Zone) -> Self { + Self::new(Zones::new([zone])) + } + + /// Replaces them. + pub fn set(&self, zones: Zones) { match self.0.write() { - Ok(mut guard) => *guard = Arc::new(zone), - Err(poisoned) => *poisoned.into_inner() = Arc::new(zone), + Ok(mut guard) => *guard = Arc::new(zones), + Err(poisoned) => *poisoned.into_inner() = Arc::new(zones), } } - /// The zone as it is now. - pub fn get(&self) -> Arc { + /// The zones as they are now. + pub fn get(&self) -> Arc { match self.0.read() { Ok(guard) => Arc::clone(&guard), Err(poisoned) => Arc::clone(&poisoned.into_inner()), @@ -75,7 +80,7 @@ impl SharedZone { /// `None` means say nothing at all: the message was not a question this /// server should reply to, and replying anyway would make this a useful /// amplifier for somebody spoofing a source address. -pub fn respond(zone: &Zone, query: &[u8]) -> Option> { +pub fn respond(zones: &Zones, query: &[u8]) -> Option> { let packet = simple_dns::Packet::parse(query).ok()?; if packet.has_flags(PacketFlag::RESPONSE) { return None; @@ -105,7 +110,16 @@ pub fn respond(zone: &Zone, query: &[u8]) -> Option> { } let qname = question.qname.to_string(); - let answer = zone.lookup(&qname, query_kind(question.qtype)); + // Which zone answers is part of the answer: a denial is bounded by the + // SOA of the zone that denied it, and this server may hold several. + let (zone, answer) = match zones.lookup(&qname, query_kind(question.qtype)) { + Some(answered) => answered, + None => { + reply.questions.push(question.clone()); + *reply.rcode_mut() = RCODE::Refused; + return reply.build_bytes_vec_compressed().ok(); + } + }; reply.questions.push(question.clone()); let name = Name::new(&qname).ok()?; @@ -338,7 +352,11 @@ mod tests { use crate::dns::zone::ZoneName; use std::net::Ipv4Addr; - fn zone() -> Zone { + fn zone() -> Zones { + Zones::new([one_zone()]) + } + + fn one_zone() -> Zone { Zone::new( ZoneName::new("lab").unwrap(), [ @@ -468,7 +486,7 @@ mod tests { let many: Vec<(String, Ipv4Addr)> = (0..200) .map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8))) .collect(); - let wide = Zone::new(ZoneName::new("lab").unwrap(), many); + let wide = Zones::new([Zone::new(ZoneName::new("lab").unwrap(), many)]); let query = ask("host.lab", TYPE::A); let full = respond(&wide, &query).unwrap(); assert!( @@ -525,7 +543,7 @@ mod tests { // answer the same thing. Answering one family only leaves the other // timing out, which looks like a broken overlay. let shared = SharedZone::new(zone()); - let plan = crate::dns::listen_plan(None, 0); + let plan = crate::dns::listen_plan(0); let mut answered = 0; for family in plan.families() { @@ -575,7 +593,7 @@ mod tests { #[tokio::test] async fn replacing_the_zone_changes_what_the_running_server_answers() { - let shared = SharedZone::new(Zone::new(ZoneName::new("lab").unwrap(), [])); + let shared = SharedZone::one(Zone::new(ZoneName::new("lab").unwrap(), [])); let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone()) .await .unwrap(); diff --git a/crates/tsunagi/src/dns/zone.rs b/crates/tsunagi/src/dns/zone.rs index daea416..f49787e 100644 --- a/crates/tsunagi/src/dns/zone.rs +++ b/crates/tsunagi/src/dns/zone.rs @@ -336,6 +336,86 @@ impl Zone { } } +/// Every zone one agent serves. +/// +/// One agent has one identity and as many networks as it likes, and each of +/// them is a zone of its own named after the network. They are served +/// together because they arrive at the same socket: a question is not +/// labelled with the network it belongs to, only with a name, and the +/// suffix is what decides. Nothing is shared between them — a member of one +/// network is not a name in another. +#[derive(Debug, Clone, Default)] +pub struct Zones { + zones: Vec, +} + +impl Zones { + /// Collects zones, dropping any that repeats an origin already taken. + /// + /// Two networks can be given the same name, and then only one of them + /// can own the suffix. First wins, deterministically, rather than one + /// shadowing the other depending on the order they happened to start. + pub fn new(zones: impl IntoIterator) -> Self { + let mut taken: Vec = Vec::new(); + for zone in zones { + if taken.iter().any(|other| other.origin() == zone.origin()) { + continue; + } + taken.push(zone); + } + Self { zones: taken } + } + + /// The zones, in the order they will be consulted. + pub fn iter(&self) -> impl Iterator { + self.zones.iter() + } + + /// How many zones are served. + pub fn len(&self) -> usize { + self.zones.len() + } + + /// Whether nothing is served. + pub fn is_empty(&self) -> bool { + self.zones.is_empty() + } + + /// How many names are answered for, across every zone. + pub fn names(&self) -> usize { + self.zones.iter().map(Zone::len).sum() + } + + /// Answers one question, and says which zone answered it. + /// + /// The answer has to travel with its zone because the SOA that bounds a + /// denial belongs to the zone that denied it, and serving several means + /// that is no longer a foregone conclusion. + /// + /// A reverse question carries no suffix to match on, so every zone is + /// asked and the one that holds the address answers; if none does, the + /// denial comes from whichever is authoritative for that reverse zone, + /// and from nowhere at all when none is. + pub fn lookup(&self, qname: &str, query: Query) -> Option<(&Zone, Answer)> { + let mut denial: Option<(&Zone, Answer)> = None; + for zone in &self.zones { + match zone.lookup(qname, query) { + // Not this zone's question. Another may still own it. + Answer::NotOurs => continue, + // A zone that owns the suffix but not the name can still be + // overruled by one that has an answer: only reverse + // questions reach more than one zone, and there the zone + // holding the address is the one that knows. + answer @ (Answer::NoSuchName | Answer::NoData) => { + denial.get_or_insert((zone, answer)); + } + answer => return Some((zone, answer)), + } + } + denial + } +} + /// The address a reverse name asks about, if it is one. fn reverse_address(qname: &str) -> Option { let name = qname.trim_end_matches('.').to_ascii_lowercase(); @@ -577,4 +657,94 @@ mod tests { ); assert_ne!(a.serial(), changed.serial()); } + + #[test] + fn each_network_answers_only_for_its_own_zone() { + // One agent, several networks, one socket. A question carries a + // name and not the network it belongs to, so the suffix is what + // decides — and a member of one network is not a name in another. + let zones = Zones::new([ + Zone::new( + ZoneName::new("lab").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))], + ), + Zone::new( + ZoneName::new("home").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))], + ), + ]); + assert_eq!(zones.len(), 2); + assert_eq!(zones.names(), 2); + + let (zone, answer) = zones.lookup("music.lab", Query::A).unwrap(); + assert_eq!(zone.origin().as_str(), "lab"); + assert_eq!( + answer, + Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)]) + ); + + let (zone, answer) = zones.lookup("music.home", Query::A).unwrap(); + assert_eq!(zone.origin().as_str(), "home"); + assert_eq!(answer, Answer::Addresses(vec![Ipv4Addr::new(10, 20, 0, 2)])); + + // A name under neither is nobody's business here. + assert!(zones.lookup("music.example.com", Query::A).is_none()); + assert!(Zones::default().lookup("music.lab", Query::A).is_none()); + } + + #[test] + fn a_denial_comes_from_the_zone_that_owns_the_name() { + // The SOA that bounds a negative answer belongs to the zone that + // denied it; with several served that is no longer a given. + let zones = Zones::new([ + Zone::new(ZoneName::new("lab").unwrap(), []), + Zone::new( + ZoneName::new("home").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 20, 0, 2))], + ), + ]); + let (zone, answer) = zones.lookup("nobody.lab", Query::A).unwrap(); + assert_eq!(zone.origin().as_str(), "lab"); + assert_eq!(answer, Answer::NoSuchName); + } + + #[test] + fn a_reverse_question_is_answered_by_whichever_zone_holds_the_address() { + // It carries no suffix to match on, so every zone is asked. + let zones = Zones::new([ + Zone::new( + ZoneName::new("lab").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))], + ), + Zone::new( + ZoneName::new("home").unwrap(), + [("kitchen".to_string(), Ipv4Addr::new(10, 20, 0, 2))], + ), + ]); + let (_, answer) = zones.lookup("2.0.20.10.in-addr.arpa", Query::Ptr).unwrap(); + assert_eq!(answer, Answer::Name("kitchen.home".to_string())); + let (_, answer) = zones.lookup("2.37.13.10.in-addr.arpa", Query::Ptr).unwrap(); + assert_eq!(answer, Answer::Name("music.lab".to_string())); + } + + #[test] + fn two_networks_of_one_name_do_not_shadow_each_other_at_random() { + // Exactly the two-`LAB` case. Only one can own the suffix; which + // one must not depend on the order they happened to start in. + let first = Zone::new( + ZoneName::new("lab").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 13, 37, 2))], + ); + let second = Zone::new( + ZoneName::new("lab").unwrap(), + [("music".to_string(), Ipv4Addr::new(10, 99, 0, 2))], + ); + let zones = Zones::new([first, second]); + assert_eq!(zones.len(), 1, "one suffix, one zone"); + let (_, answer) = zones.lookup("music.lab", Query::A).unwrap(); + assert_eq!( + answer, + Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 2)]) + ); + } } diff --git a/crates/tsunagi/src/ipc/mod.rs b/crates/tsunagi/src/ipc/mod.rs index 670e811..6ede801 100644 --- a/crates/tsunagi/src/ipc/mod.rs +++ b/crates/tsunagi/src/ipc/mod.rs @@ -82,6 +82,13 @@ 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), + /// Turn the local resolver on or off, now and for future starts. + Dns { + /// Whether it should be serving. + enable: bool, + /// The port to serve on. Keeps the stored one when absent. + port: Option, + }, /// 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 @@ -105,6 +112,9 @@ impl std::fmt::Debug for Request { Request::SetHostname(name) => write!(f, "SetHostname({name})"), Request::Leave(network) => write!(f, "Leave({network})"), // The name is not a secret; the secret is. + Request::Dns { enable, port } => { + write!(f, "Dns {{ enable: {enable}, port: {port:?} }}") + } Request::Join { name, .. } => write!(f, "Join {{ name: {name}, secret: }}"), } } @@ -122,6 +132,10 @@ pub enum Response { Left(LeftReport), /// A network was joined, or was already there and is now running. Joined(JoinedReport), + /// What the local resolver is doing, after being changed or asked. + /// + /// `None` means it is not serving at all. + Dns(Option), /// The request could not be served. Error(String), } @@ -184,8 +198,8 @@ pub struct StatusReport { /// The local DNS service. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct DnsReport { - /// The zone it answers for. - pub zone: String, + /// The zones it answers for: one per network, named after it. + pub zones: Vec, /// Every address it is listening on, one per family where it could. /// /// Empty means it answers nowhere, and `bind_error` says why. @@ -199,8 +213,8 @@ pub struct DnsReport { pub publish_error: Option, /// What to do about that, when there is something. pub publish_remedy: Option, - /// Something worth saying about the zone name itself. - pub zone_warning: Option, + /// Anything worth saying about those names, one line each. + pub zone_warnings: Vec, /// How many names it answers for. pub names: u32, } diff --git a/crates/tsunagi/src/ipc/unix.rs b/crates/tsunagi/src/ipc/unix.rs index 1f9c8cb..f97a527 100644 --- a/crates/tsunagi/src/ipc/unix.rs +++ b/crates/tsunagi/src/ipc/unix.rs @@ -16,7 +16,9 @@ use tokio::task::JoinHandle; use crate::BoxFuture; use crate::error::{Error, Result}; -use super::{JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport}; +use super::{ + DnsReport, JoinedReport, LeftReport, MAX_MESSAGE_LEN, Request, Response, StatusReport, +}; /// Builds the report that answers a status request. /// @@ -58,6 +60,17 @@ pub trait ReportSource: Send + Sync + 'static { ) -> BoxFuture<'_, std::result::Result> { Box::pin(async move { Err("this agent cannot join a network".to_string()) }) } + + /// Turns the local resolver on or off while the agent runs. + /// + /// Defaulted to a refusal, like the others. + fn set_dns( + &self, + _enable: bool, + _port: Option, + ) -> BoxFuture<'_, std::result::Result, String>> { + Box::pin(async move { Err("this agent cannot serve DNS".to_string()) }) + } } impl ReportSource for F @@ -194,6 +207,10 @@ async fn handle(mut stream: UnixStream, source: Arc) -> Result Ok(report) => Response::Joined(report), Err(reason) => Response::Error(reason), }, + Request::Dns { enable, port } => match source.set_dns(enable, port).await { + Ok(report) => Response::Dns(report), + Err(reason) => Response::Error(reason), + }, }; write_message(&mut stream, &response).await } @@ -289,6 +306,20 @@ pub async fn join_network( } } +/// Turns the running agent's local resolver on or off. +pub async fn set_dns( + path: impl AsRef, + enable: bool, + port: Option, +) -> Result> { + let path = path.as_ref(); + match exchange(path, &Request::Dns { enable, port }, EXCHANGE_TIMEOUT).await? { + Response::Dns(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 @@ -300,7 +331,7 @@ pub async fn join_network( /// /// Bump it whenever [`Request`], [`Response`] or anything they contain /// changes shape. -pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 9]); +pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 10]); async fn write_message(stream: &mut UnixStream, value: &T) -> Result<()> { let encoded = postcard::to_stdvec(value) diff --git a/crates/tsunagi/src/state/mod.rs b/crates/tsunagi/src/state/mod.rs index 61b44fb..07c4375 100644 --- a/crates/tsunagi/src/state/mod.rs +++ b/crates/tsunagi/src/state/mod.rs @@ -120,6 +120,35 @@ pub const DEFAULT_IPV4_RANGE: Ipv4Range = Ipv4Range { prefix_len: 24, }; +/// A range derived from a network's own identity. +/// +/// One agent has one interface, so two of its networks cannot both use the +/// configured range. The second needs another one — and it cannot be picked +/// locally, because every member has to arrive at the same answer without +/// being told. So it comes from the network id, which every member already +/// has and nobody can choose: the same network derives the same range on +/// every device. +/// +/// Inside 10/8 like the default, and never equal to it, so the first +/// network keeps what it has always had. +pub fn derived_ipv4_range(network: NetworkId) -> Ipv4Range { + let bytes = network.as_bytes(); + let second = bytes[0]; + let third = bytes[1]; + let candidate = Ipv4Addr::new(10, second, third, 0); + // The default is somebody's already. One step along is still derived + // from the id and still the same everywhere. + let base = if candidate == DEFAULT_IPV4_RANGE.base { + Ipv4Addr::new(10, second, third.wrapping_add(1), 0) + } else { + candidate + }; + Ipv4Range { + base, + prefix_len: 24, + } +} + /// Frozen domain separator for the bytes a record signature covers. pub const RECORD_DOMAIN: &str = "tsunagi-signed-record-v2"; @@ -1053,4 +1082,32 @@ mod tests { SignedRecord::canonical_bytes(network("other"), author, 1, &claim("10.13.37.5")) ); } + + #[test] + fn a_derived_range_is_the_same_wherever_it_is_derived() { + // Every member has to arrive at it without being told, so it comes + // from the one thing they all already agree on. + let network = NetworkKeys::derive( + &NetworkName::new("second").unwrap(), + &NetworkSecret::from_bytes([7u8; 32]).unwrap(), + ) + .network_id(); + let once = derived_ipv4_range(network); + assert_eq!(once, derived_ipv4_range(network)); + assert_eq!(once.prefix_len, 24); + assert_eq!(once.base.octets()[0], 10, "inside 10/8 like the default"); + assert_ne!( + once, DEFAULT_IPV4_RANGE, + "the default belongs to whichever network asked first" + ); + + // A different network derives a different range, which is the + // whole point of deriving it. + let other = NetworkKeys::derive( + &NetworkName::new("third").unwrap(), + &NetworkSecret::from_bytes([9u8; 32]).unwrap(), + ) + .network_id(); + assert_ne!(once, derived_ipv4_range(other)); + } } diff --git a/crates/tsunagi/tests/network_isolation.rs b/crates/tsunagi/tests/network_isolation.rs index 8a75cec..37e87fc 100644 --- a/crates/tsunagi/tests/network_isolation.rs +++ b/crates/tsunagi/tests/network_isolation.rs @@ -13,7 +13,7 @@ use tsunagi::proto::message::{ }; use tsunagi::proto::{read_frame, write_frame}; use tsunagi::test_support; -use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers}; +use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers, wait_until}; const LIMIT: usize = 64 * 1024; @@ -262,3 +262,73 @@ async fn deactivating_one_network_leaves_the_agent_and_others_running() { agent.agent.shutdown().await; } + +#[tokio::test] +async fn two_networks_on_one_agent_each_get_a_range_of_their_own() { + // One agent, one interface: the configured range can only belong to one + // of them. The second does not go without — it takes the range derived + // from its own id, which every one of its members derives identically, + // so it is an agreement and not a local invention. + let discovery = SharedMemoryDiscovery::new(); + let (first, first_secret) = network("two-ranges-one"); + let (second, second_secret) = network("two-ranges-two"); + + let agent = TestAgent::spawn(&discovery).await.unwrap(); + let one = agent + .agent + .join_network(&first, &first_secret) + .await + .unwrap(); + let two = agent + .agent + .join_network(&second, &second_secret) + .await + .unwrap(); + + let own = agent.agent.endpoint_id(); + let addresses = wait_until("both networks allocate an address", || { + let handle = agent.agent.clone(); + async move { + let mine = async |network| { + handle + .network_status(network) + .await + .ok()? + .members + .into_iter() + .find(|member| member.endpoint_id == own)? + .overlay_address_v4 + }; + Some((mine(one).await?, mine(two).await?)) + } + }) + .await; + + assert_ne!( + addresses.0, addresses.1, + "different networks, different addresses" + ); + assert!( + tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.0), + "the first keeps the configured range: {}", + addresses.0 + ); + assert!( + tsunagi::state::derived_ipv4_range(two).contains(addresses.1), + "the second is in the range derived from its id: {}", + addresses.1 + ); + assert!( + !tsunagi::state::DEFAULT_IPV4_RANGE.contains(addresses.1), + "and not in the one the first network holds" + ); + + // And the ranges do not overlap, which is what lets one interface + // carry both without a packet being ambiguous. + assert!( + !tsunagi::state::derived_ipv4_range(two).contains(addresses.0), + "the two ranges must not overlap" + ); + + agent.agent.shutdown().await; +} diff --git a/docs/testing.md b/docs/testing.md index 46e118b..3e93f94 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -41,6 +41,7 @@ of several system processes, and is not presented as one. | 9 | wrong version, a message before authentication, a proof replayed on another connection, an oversized frame and a `Hello` for an inactive network are all rejected without taking the agent down | `tests/authentication.rs` | | 10 | a second agent on the same state directory gets a clear error; after a clean stop the directory reopens; shutdown ends background tasks and refuses further work; independent agents coexist in one process | `tests/resilience.rs` | | 11 | leaving a network frees the address for the others, says plainly when there was nobody to tell, and rejoining afterwards is not mistaken for a stale record; a wipe empties both directories and the next start is a stranger, while a directory that is not ours is refused | `tests/leaving.rs`, `tests/cache_and_state.rs` | +| 12 | a network can be joined into a running agent over the control socket and is live at once; a name with no secret resumes the one network of that name, invents one when there is none, and refuses to choose between two; two networks on one agent each get a range of their own | `tests/local_control.rs`, `tests/network_isolation.rs`, CLI unit tests | `tests/wireguard.rs` drives the WireGuard data plane over real iroh connections. Everything is real except the packet interface: real agents, real @@ -64,9 +65,15 @@ walks past everything taken and reports a full range instead of handing out a duplicate. `tests/local_control.rs` covers the local control socket end to end: a client -asking a running agent for status over a real Unix socket, a leftover socket -file being replaced while a live one is not, and the derived socket path -staying short enough to bind. +asking a running agent for status over a real Unix socket, joining and +leaving a network through it, a leftover socket file being replaced while a +live one is not, and the derived socket path staying short enough to bind. + +`crates/tsunagi-cli/tests/dns_service.rs` runs the real binary: the resolver +comes up with no interface to attach it to, the listener is not rebuilt on +the way past, a name outside every zone is refused, each network gets a zone +of its own as it is joined, and the resolver can be switched on and off +while the agent runs. `tests/discovery.rs` covers the discovery contract itself: a static bootstrap candidate is enough to join, several backends compose, entries are withdrawn