From ca8759c0239b34e0ef7e6bf7123d1d9aa16374bf Mon Sep 17 00:00:00 2001 From: tsunagi Date: Mon, 21 Sep 2026 17:11:37 +0100 Subject: [PATCH] Say the resolver refusal once, and hand over the fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running it for real turned up the predicted polkit refusal — InteractiveAuthorizationRequired — and two things wrong with how the agent handled it. It logged the same line every two seconds. A condition that persists is worth saying once, so it is now repeated only when the message changes. It also retried at that pace. A refusal will not lift until somebody grants permission, so retrying it as often as everything else is noise: refusals now back off to five minutes, other failures to fifteen seconds, and either resets the moment it succeeds or the desired setting changes. The more useful part: the agent prints the polkit rule that grants it, ready to paste, naming the user it is running as. polkit decides by user and not by capability, so this genuinely cannot be arranged from inside the process — which makes "write a polkit rule" the user's work, and handing them the rule rather than describing it is the difference between a minute and an afternoon. It grants the four actions the agent calls and nothing else; a test pins both halves of that, and that the JavaScript stays within what duktape implements. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 33 ++++++- examples/two_agents.rs | 148 --------------------------- examples/wireguard_mesh.rs | 198 ------------------------------------- src/bin/tsunagi.rs | 41 +++++++- src/dns/publish/mod.rs | 74 +++++++++++++- 5 files changed, 135 insertions(+), 359 deletions(-) delete mode 100644 examples/two_agents.rs delete mode 100644 examples/wireguard_mesh.rs diff --git a/README.md b/README.md index 40feafd..191a366 100644 --- a/README.md +++ b/README.md @@ -178,11 +178,34 @@ so it never becomes the resolver for anything else. resolved drops the whole setting when the interface goes, and the interface goes with the agent. That last step needs permission that `CAP_NET_ADMIN` does not give: -systemd-resolved asks polkit, and polkit decides by user. Running as a -system service is enough; so is a polkit rule granting this user the -`org.freedesktop.resolve1.set-*` actions. **Without it the server still -runs** — `tsunagi status` prints where it is listening and the exact `dig` -line — so the automatic part is missing, not the feature. +systemd-resolved asks polkit, and polkit decides by **user**, not by +capability, so there is no way for the agent to arrange it from inside. On +a desktop the refusal reads `Interactive authentication required`. + +Running as a system service is enough. Otherwise the agent prints the rule +that grants it — the four actions it calls and nothing else — ready to +paste: + +```bash +sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE' +polkit.addRule(function(action, subject) { + var allowed = [ + "org.freedesktop.resolve1.set-dns-servers", + "org.freedesktop.resolve1.set-domains", + "org.freedesktop.resolve1.set-default-route", + "org.freedesktop.resolve1.revert" + ]; + if (allowed.indexOf(action.id) >= 0 && subject.user == "YOUR-USER") { + return polkit.Result.YES; + } +}); +RULE +``` + +**Without it the server still runs** — `tsunagi status` prints where it is +listening and the exact `dig` line — so the automatic part is missing, not +the feature. The refusal is said once rather than on every pass, and retried +slowly, because nothing but a person will change it. The server is authoritative for its zone and nothing else. No recursion, no forwarding, no cache: pointing a resolver at it can never make it a route to diff --git a/examples/two_agents.rs b/examples/two_agents.rs deleted file mode 100644 index fa2672d..0000000 --- a/examples/two_agents.rs +++ /dev/null @@ -1,148 +0,0 @@ -//! A tiny runnable demonstration of the library. -//! -//! Run it with: -//! -//! ```text -//! cargo run --example two_agents -//! ``` -//! -//! It starts two agents in one process, on loopback only, joins them to the -//! same network space and exchanges one request/response. It is a demo, not a -//! substitute for the integration tests in `tests/`. - -use std::sync::Arc; -use std::time::Duration; - -use tsunagi::agent::Event; -use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; -use tsunagi::discovery::SharedMemoryDiscovery; -use tsunagi::identity::{NetworkName, NetworkSecret}; -use tsunagi::proto::ControlMessage; -use tsunagi::{Agent, Result}; - -fn config(root: &std::path::Path, discovery: &SharedMemoryDiscovery) -> AgentConfig { - AgentConfig::new(StoragePaths::under(root)) - // Loopback only: no relays, no address lookup, no port mapping. - .with_transport(TransportPolicy::LocalOnly) - .with_loopback_bind() - .with_discovery(Arc::new(discovery.clone())) - .with_discovery_interval(Duration::from_millis(200)) -} - -// The library never starts a runtime of its own; the binary owns it. -#[tokio::main] -async fn main() -> Result<()> { - // The library never installs a global subscriber either. - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), - ) - .init(); - - let root = match tempfile::TempDir::new() { - Ok(root) => root, - Err(err) => { - eprintln!("cannot create a temporary directory: {err}"); - return Ok(()); - } - }; - let discovery = SharedMemoryDiscovery::new(); - - let alice = - Agent::spawn(config(&root.path().join("alice"), &discovery).with_hostname("alice")).await?; - let bob = - Agent::spawn(config(&root.path().join("bob"), &discovery).with_hostname("bob")).await?; - - // The end user supplies exactly these two values. - let name = NetworkName::new("kitchen-table")?; - let secret = NetworkSecret::generate(); - println!( - "network secret (keep it safe): {}", - secret.encode().as_str() - ); - - let network = alice.join_network(&name, &secret).await?; - let same = bob.join_network(&name, &secret).await?; - // The same name and secret always derive the same network space. - if network != same { - eprintln!("network derivation is not deterministic; this is a bug"); - return Ok(()); - } - println!("network id: {network}"); - println!("alice: {}", alice.endpoint_id()); - println!("bob: {}", bob.endpoint_id()); - - let mut events = alice.subscribe(); - loop { - match events.recv().await { - Ok(Event::PeerConnected { - peer, - role, - transport, - rtt, - .. - }) => { - println!("alice authenticated {peer} as {role:?} over {transport:?} rtt={rtt:?}"); - break; - } - Ok(_) => {} - Err(err) => { - eprintln!("event stream ended: {err}"); - break; - } - } - } - - alice - .send( - network, - bob.endpoint_id(), - ControlMessage::Ping { - seq: 1, - payload: b"hello".to_vec(), - }, - ) - .await?; - - while let Ok(event) = events.recv().await { - if let Event::MessageReceived { - peer, - message: ControlMessage::Pong { seq, payload }, - .. - } = event - { - println!( - "pong from {peer}: seq={seq} payload={:?}", - String::from_utf8_lossy(&payload) - ); - break; - } - } - - let status = alice.status().await?; - println!("\nalice status:"); - println!(" hostname {}", status.hostname); - println!(" bound sockets {:?}", status.bound_sockets); - println!(" cache {:?}", status.cache_outcome); - for net in &status.networks { - println!(" network {} ({:?})", net.name, net.state); - for peer in &net.peers { - println!( - " peer {} hostname={:?} transport={:?} rtt={:?}", - peer.endpoint_id, peer.hostname, peer.transport, peer.rtt - ); - for path in &peer.paths { - println!( - " path {:?} selected={} rtt={:?}", - path.remote, path.is_selected, path.rtt - ); - } - } - println!(" metrics {:?}", net.metrics); - } - - alice.shutdown().await; - bob.shutdown().await; - Ok(()) -} diff --git a/examples/wireguard_mesh.rs b/examples/wireguard_mesh.rs deleted file mode 100644 index 470d4e4..0000000 --- a/examples/wireguard_mesh.rs +++ /dev/null @@ -1,198 +0,0 @@ -//! Two agents forming a WireGuard overlay and exchanging a real IP packet. -//! -//! ```text -//! cargo run --example wireguard_mesh -//! ``` -//! -//! It uses an in-memory packet interface, so it needs no privileges and -//! changes nothing on the host: the WireGuard handshake, the encryption and -//! the transport over iroh are all real, only the TUN device is simulated. - -use std::net::{IpAddr, Ipv6Addr}; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use bytes::Bytes; -use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; -use tsunagi::dataplane::IpPlugin; -use tsunagi::dataplane::wireguard::{ - MemoryTun, MemoryTunFactory, WireguardConfig, WireguardPlugin, -}; -use tsunagi::discovery::SharedMemoryDiscovery; -use tsunagi::identity::{NetworkName, NetworkSecret}; -use tsunagi::{Agent, NetworkId, Result}; - -struct Node { - agent: Agent, - plugin: Arc, - tuns: MemoryTunFactory, -} - -async fn start( - root: &std::path::Path, - discovery: &SharedMemoryDiscovery, - prefix: &str, -) -> Result { - let tuns = MemoryTunFactory::new(); - let plugin = WireguardPlugin::open( - WireguardConfig::new(root.join("wireguard")).with_interface_prefix(prefix), - Arc::new(tuns.clone()), - ) - .await - .map_err(|err| tsunagi::Error::Discovery(err.to_string()))?; - - let agent = Agent::spawn( - AgentConfig::new(StoragePaths::under(root)) - .with_transport(TransportPolicy::LocalOnly) - .with_loopback_bind() - .with_discovery(Arc::new(discovery.clone())) - .with_discovery_interval(Duration::from_millis(200)) - .with_plugin(plugin.clone() as Arc), - ) - .await?; - - Ok(Node { - agent, - plugin, - tuns, - }) -} - -fn report(label: &str, node: &Node, network: NetworkId) { - let Some(view) = node.plugin.overview(network) else { - println!("{label}: not prepared yet"); - return; - }; - println!("\n{label}"); - println!(" interface {} (mtu {})", view.interface, view.mtu); - println!(" public key {}", view.public_key); - println!( - " overlay {} in {}/{}", - view.overlay_address, view.overlay_prefix, view.overlay_prefix_len - ); - for peer in &view.peers { - match &peer.tunnel { - Some(tunnel) => println!( - " peer {} at {} — handshake {:?}, tx {} rx {}, path {}", - peer.public_key.fmt_short(), - peer.overlay_address, - tunnel.health.since_handshake, - tunnel.stats.tx_packets, - tunnel.stats.rx_packets, - tunnel.path - ), - None => println!( - " peer {} at {} — no data link yet", - peer.public_key.fmt_short(), - peer.overlay_address - ), - } - } -} - -fn ipv6_packet(source: Ipv6Addr, destination: Ipv6Addr, payload: &[u8]) -> Bytes { - let mut packet = Vec::with_capacity(40 + payload.len()); - packet.push(6 << 4); - packet.extend_from_slice(&[0, 0, 0]); - packet.extend_from_slice(&(payload.len() as u16).to_be_bytes()); - packet.push(59); - packet.push(64); - packet.extend_from_slice(&source.octets()); - packet.extend_from_slice(&destination.octets()); - packet.extend_from_slice(payload); - Bytes::from(packet) -} - -fn overlay_of(node: &Node, network: NetworkId) -> Option { - match node.plugin.overview(network)?.overlay_address { - IpAddr::V6(addr) => Some(addr), - IpAddr::V4(_) => None, - } -} - -fn tun_of(node: &Node, network: NetworkId) -> Option> { - let view = node.plugin.overview(network)?; - node.tuns.device(&view.interface) -} - -#[tokio::main] -async fn main() -> Result<()> { - tracing_subscriber::fmt() - .with_env_filter( - tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), - ) - .init(); - - println!("in-memory packet interface: nothing on this host is changed\n"); - - let root = match tempfile::TempDir::new() { - Ok(root) => root, - Err(err) => { - eprintln!("cannot create a temporary directory: {err}"); - return Ok(()); - } - }; - let discovery = SharedMemoryDiscovery::new(); - - let alice = start(&root.path().join("alice"), &discovery, "wga").await?; - let bob = start(&root.path().join("bob"), &discovery, "wgb").await?; - - let name = NetworkName::new("wireguard-demo")?; - let secret = NetworkSecret::generate(); - println!( - "network secret (keep it safe): {}", - secret.encode().as_str() - ); - - let network = alice.agent.join_network(&name, &secret).await?; - bob.agent.join_network(&name, &secret).await?; - println!("network id: {network}"); - - let deadline = Instant::now() + Duration::from_secs(20); - loop { - let ready = [&alice, &bob].iter().all(|node| { - node.plugin - .overview(network) - .map(|view| view.established_peers() == 1) - .unwrap_or(false) - }); - if ready { - break; - } - if Instant::now() > deadline { - println!("\nthe overlay did not come up in time"); - report("alice", &alice, network); - report("bob", &bob, network); - return Ok(()); - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - - // A real IP packet, encrypted by WireGuard and carried over iroh. - if let (Some(from), Some(to), Some(tun_a), Some(tun_b)) = ( - overlay_of(&alice, network), - overlay_of(&bob, network), - tun_of(&alice, network), - tun_of(&bob, network), - ) { - tun_a.push_from_os(ipv6_packet(from, to, b"hello over the overlay")); - match tokio::time::timeout(Duration::from_secs(5), tun_b.pop_to_os()).await { - Ok(Some(packet)) => println!( - "\nbob received {} bytes from {}: {:?}", - packet.len(), - from, - String::from_utf8_lossy(&packet[40..]) - ), - _ => println!("\nthe packet did not arrive"), - } - } - - report("alice", &alice, network); - report("bob", &bob, network); - - println!("\nshutting down; the plugin removes what it created"); - alice.agent.shutdown().await; - bob.agent.shutdown().await; - Ok(()) -} diff --git a/src/bin/tsunagi.rs b/src/bin/tsunagi.rs index ada11ef..b829221 100644 --- a/src/bin/tsunagi.rs +++ b/src/bin/tsunagi.rs @@ -568,6 +568,12 @@ fn spawn_dns( // time for no reason. let mut attempted: Vec = Vec::new(); 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 + // hammering at it two seconds apart is pure noise. + let mut reported: Option = None; + let mut retry_after: Option = None; + let mut recipe_shown = false; let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2)); loop { @@ -654,7 +660,8 @@ fn spawn_dns( server: address, domains: vec![zone.as_str().to_string()], }; - if published.as_ref() != Some(&want_published) { + 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!( @@ -663,6 +670,8 @@ fn spawn_dns( "the system resolver was told where to ask" ); published = Some(want_published); + reported = None; + retry_after = None; update(&state, |state| { state.publish_error = None; state.publish_remedy = None; @@ -671,10 +680,29 @@ fn spawn_dns( Err(err) => { // Not fatal, by design: the server keeps // answering and the user is told what is missing. - tracing::warn!(%err, "cannot configure the system resolver"); + let text = err.to_string(); + if reported.as_deref() != Some(text.as_str()) { + tracing::warn!("cannot configure the system resolver: {text}"); + if err.needs_a_human() && !recipe_shown { + recipe_shown = true; + tracing::warn!( + "systemd-resolved asks polkit, and polkit decides by \ + user rather than by capability, so this cannot be done \ + from inside the agent. To grant it once:\n\n{}\n", + tsunagi::dns::publish::polkit_recipe(¤t_user()) + ); + } + reported = Some(text.clone()); + } + // Backed off, and further for something only a + // person can change. + let wait = if err.needs_a_human() { 300 } else { 15 }; + retry_after = Some( + tokio::time::Instant::now() + std::time::Duration::from_secs(wait), + ); let remedy = err.remedy().map(str::to_string); update(&state, |state| { - state.publish_error = Some(err.to_string()); + state.publish_error = Some(text); state.publish_remedy = remedy; }); } @@ -1812,6 +1840,13 @@ mod report { } } +/// The user this process is running as, for an instruction it can paste. +fn current_user() -> String { + std::env::var("USER") + .or_else(|_| std::env::var("LOGNAME")) + .unwrap_or_else(|_| "".to_string()) +} + /// This program's path, for an instruction the user can paste. fn program_path() -> String { std::env::current_exe() diff --git a/src/dns/publish/mod.rs b/src/dns/publish/mod.rs index ab40bbe..ed9e587 100644 --- a/src/dns/publish/mod.rs +++ b/src/dns/publish/mod.rs @@ -62,20 +62,54 @@ pub enum PublishError { } impl PublishError { - /// What the user can do about it, when there is something. + /// Whether waiting will fix it. + /// + /// A refusal will not change on its own — somebody has to grant + /// permission — so retrying it at the pace of everything else is just + /// noise. Anything else might be a service still starting. + pub fn needs_a_human(&self) -> bool { + matches!(self, PublishError::Refused(_)) + } + + /// One line for a status table. pub fn remedy(&self) -> Option<&'static str> { match self { PublishError::Refused(_) => Some( - "systemd-resolved asks polkit before accepting this, and polkit \ - decides by user. Run the agent as a system service, or install a \ - polkit rule allowing this user the `org.freedesktop.resolve1.set-*` \ - actions.", + "grant this user the `org.freedesktop.resolve1.set-*` actions in \ + /etc/polkit-1/rules.d, or run the agent as a system service", ), _ => None, } } } +/// The polkit rule that lets this user configure the resolver. +/// +/// Printed in full rather than described, because the point of this feature +/// is that the user has as little to do as possible, and "write a polkit +/// rule" is a great deal more work than pasting one. +/// +/// It grants exactly the four actions this agent calls and nothing else. +/// polkit decides by user id — a capability does not help here — so there is +/// no way to do this from inside the process. +pub fn polkit_recipe(user: &str) -> String { + format!( + "sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'\n\ + polkit.addRule(function(action, subject) {{\n\ + \x20 var allowed = [\n\ + \x20 \"org.freedesktop.resolve1.set-dns-servers\",\n\ + \x20 \"org.freedesktop.resolve1.set-domains\",\n\ + \x20 \"org.freedesktop.resolve1.set-default-route\",\n\ + \x20 \"org.freedesktop.resolve1.revert\"\n\ + \x20 ];\n\ + \x20 if (allowed.indexOf(action.id) >= 0 && subject.user == \"{user}\") {{\n\ + \x20 return polkit.Result.YES;\n\ + \x20 }}\n\ + }});\n\ + RULE" + ) +} + /// Arranges for the operating system to ask this server. pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static { /// A short name used in diagnostics. @@ -122,6 +156,36 @@ mod tests { assert!(PublishError::Failed("bang".into()).remedy().is_none()); } + #[test] + fn only_a_refusal_waits_for_a_person() { + // The rest may come right on their own, so they are worth retrying + // at the ordinary pace; a refusal is not. + assert!(PublishError::Refused("no".into()).needs_a_human()); + assert!(!PublishError::Unavailable("none".into()).needs_a_human()); + assert!(!PublishError::Failed("bang".into()).needs_a_human()); + } + + #[test] + fn the_polkit_recipe_grants_what_is_called_and_no_more() { + let recipe = polkit_recipe("ab"); + for action in [ + "set-dns-servers", + "set-domains", + "set-default-route", + "revert", + ] { + assert!(recipe.contains(action), "{action} missing from:\n{recipe}"); + } + // Nothing beyond what the agent calls: a rule that granted the lot + // would be handing out more than this feature needs. + for other in ["set-dnssec", "set-mdns", "register-service", "set-llmnr"] { + assert!(!recipe.contains(other), "{other} should not be granted"); + } + assert!(recipe.contains("subject.user == \"ab\"")); + // ES5: the rules engine is duktape and has no `startsWith`. + assert!(!recipe.contains("startsWith")); + } + #[cfg(target_os = "linux")] #[test] fn an_interface_index_is_read_from_the_running_kernel() {