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() {