Say the resolver refusal once, and hand over the fix

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) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 17:11:37 +01:00
co-authored by Claude Opus 5
parent 75b37fdda3
commit ca8759c023
5 changed files with 135 additions and 359 deletions
+28 -5
View File
@@ -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. setting when the interface goes, and the interface goes with the agent.
That last step needs permission that `CAP_NET_ADMIN` does not give: That last step needs permission that `CAP_NET_ADMIN` does not give:
systemd-resolved asks polkit, and polkit decides by user. Running as a systemd-resolved asks polkit, and polkit decides by **user**, not by
system service is enough; so is a polkit rule granting this user the capability, so there is no way for the agent to arrange it from inside. On
`org.freedesktop.resolve1.set-*` actions. **Without it the server still a desktop the refusal reads `Interactive authentication required`.
runs** — `tsunagi status` prints where it is listening and the exact `dig`
line — so the automatic part is missing, not the feature. 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 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 forwarding, no cache: pointing a resolver at it can never make it a route to
-148
View File
@@ -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(())
}
-198
View File
@@ -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<WireguardPlugin>,
tuns: MemoryTunFactory,
}
async fn start(
root: &std::path::Path,
discovery: &SharedMemoryDiscovery,
prefix: &str,
) -> Result<Node> {
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<dyn IpPlugin>),
)
.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<Ipv6Addr> {
match node.plugin.overview(network)?.overlay_address {
IpAddr::V6(addr) => Some(addr),
IpAddr::V4(_) => None,
}
}
fn tun_of(node: &Node, network: NetworkId) -> Option<Arc<MemoryTun>> {
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(())
}
+38 -3
View File
@@ -568,6 +568,12 @@ fn spawn_dns(
// time for no reason. // time for no reason.
let mut attempted: Vec<SocketAddr> = Vec::new(); let mut attempted: Vec<SocketAddr> = Vec::new();
let mut published: Option<tsunagi::dns::Published> = None; let mut published: Option<tsunagi::dns::Published> = 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<String> = None;
let mut retry_after: Option<tokio::time::Instant> = None;
let mut recipe_shown = false;
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2)); let mut ticker = tokio::time::interval(std::time::Duration::from_secs(2));
loop { loop {
@@ -654,7 +660,8 @@ fn spawn_dns(
server: address, server: address,
domains: vec![zone.as_str().to_string()], 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 { match publisher.apply(&want_published).await {
Ok(()) => { Ok(()) => {
tracing::info!( tracing::info!(
@@ -663,6 +670,8 @@ fn spawn_dns(
"the system resolver was told where to ask" "the system resolver was told where to ask"
); );
published = Some(want_published); published = Some(want_published);
reported = None;
retry_after = None;
update(&state, |state| { update(&state, |state| {
state.publish_error = None; state.publish_error = None;
state.publish_remedy = None; state.publish_remedy = None;
@@ -671,10 +680,29 @@ fn spawn_dns(
Err(err) => { Err(err) => {
// Not fatal, by design: the server keeps // Not fatal, by design: the server keeps
// answering and the user is told what is missing. // 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(&current_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); let remedy = err.remedy().map(str::to_string);
update(&state, |state| { update(&state, |state| {
state.publish_error = Some(err.to_string()); state.publish_error = Some(text);
state.publish_remedy = remedy; 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(|_| "<your-user>".to_string())
}
/// This program's path, for an instruction the user can paste. /// This program's path, for an instruction the user can paste.
fn program_path() -> String { fn program_path() -> String {
std::env::current_exe() std::env::current_exe()
+69 -5
View File
@@ -62,20 +62,54 @@ pub enum PublishError {
} }
impl 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> { pub fn remedy(&self) -> Option<&'static str> {
match self { match self {
PublishError::Refused(_) => Some( PublishError::Refused(_) => Some(
"systemd-resolved asks polkit before accepting this, and polkit \ "grant this user the `org.freedesktop.resolve1.set-*` actions in \
decides by user. Run the agent as a system service, or install a \ /etc/polkit-1/rules.d, or run the agent as a system service",
polkit rule allowing this user the `org.freedesktop.resolve1.set-*` \
actions.",
), ),
_ => None, _ => 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. /// Arranges for the operating system to ask this server.
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static { pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics. /// A short name used in diagnostics.
@@ -122,6 +156,36 @@ mod tests {
assert!(PublishError::Failed("bang".into()).remedy().is_none()); 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")] #[cfg(target_os = "linux")]
#[test] #[test]
fn an_interface_index_is_read_from_the_running_kernel() { fn an_interface_index_is_read_from_the_running_kernel() {