Implement the WireGuard data plane plugin

The first IP plugin, built on the data plane boundary the core already had.

Plugin:
- one X25519 key per network in the plugin's own wireguard.sqlite, separate
  from the iroh identity and from the network secret; a damaged store is an
  error, never a silently regenerated identity
- deterministic IPv6 ULA overlay: every member derives the same /64 from the
  network id and its own /128 from its WireGuard public key, so no
  coordinator allocates addresses
- AllowedIPs are derived locally, never taken from a peer's announcement, so
  a member cannot claim another member's overlay address; a mismatched claim
  is rejected
- bounded, versioned, validated announcement carried as the existing opaque
  capability payload, which the core still never parses
- each agent builds its own full-mesh configuration (N-1 peers) and
  reconciles on every change and on a timer, repairing drift
- WireguardBackend abstraction: RecordingBackend in memory, and WgToolBackend
  driving real wg/ip on Linux, split into a pure planner plus parsers and a
  thin executor so everything interesting is testable without root

Core, three generic additions the plugin needed:
- IpPlugin::on_network_activated, so per-network state is ready before peers
- PluginContext for re-announcements and error reports from plugin tasks,
  with errors counted by the owning network runtime
- IpPlugin::shutdown, awaited with a grace period, so system objects go away

94 tests pass offline with no privileges: 35 new WireGuard unit tests and 12
integration tests over real iroh connections. The real wg/ip backend needs
root and is behind --ignored in tests/wireguard_system.rs; it was not run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 11:07:31 +01:00
co-authored by Claude Opus 5
parent 7cea9afa37
commit ea7aaa2b69
28 changed files with 4790 additions and 46 deletions
+127 -12
View File
@@ -37,6 +37,7 @@ use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::config::{AgentConfig, Limits};
use crate::dataplane::{PluginContext, PluginRequest};
use crate::error::{Error, Result};
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
use crate::net::EndpointAdapter;
@@ -80,6 +81,7 @@ struct Inner {
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
shutdown: Shutdown,
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
}
impl Drop for Inner {
@@ -87,10 +89,12 @@ impl Drop for Inner {
// Nothing here awaits; this is only a safety net for a handle that was
// dropped without an explicit shutdown.
self.shutdown.trigger();
if let Ok(mut guard) = self.accept_task.lock()
&& let Some(task) = guard.take()
{
task.abort();
for guard in [&self.accept_task, &self.plugin_task] {
if let Ok(mut guard) = guard.lock()
&& let Some(task) = guard.take()
{
task.abort();
}
}
}
}
@@ -122,6 +126,7 @@ impl Agent {
networks: RwLock::new(HashMap::new()),
shutdown: Shutdown::new(),
accept_task: std::sync::Mutex::new(None),
plugin_task: std::sync::Mutex::new(None),
config,
});
@@ -134,6 +139,21 @@ impl Agent {
*guard = Some(accept);
}
// Plugins get a handle to ask for re-announcements and report errors.
// A bounded queue keeps a noisy plugin from growing memory without
// bound; overflow drops the request rather than stalling the plugin.
if !inner.config.plugins.is_empty() {
let (plugin_tx, plugin_rx) = mpsc::channel(64);
let context = PluginContext::new(plugin_tx);
for plugin in &inner.config.plugins {
plugin.attach(context.clone());
}
let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx));
if let Ok(mut guard) = inner.plugin_task.lock() {
*guard = Some(task);
}
}
let agent = Self { inner };
for stored in agent.inner.storage.list_networks().await? {
@@ -241,6 +261,10 @@ impl Agent {
networks.insert(network_id, handle);
drop(networks);
for plugin in &self.inner.config.plugins {
plugin.on_network_activated(network_id);
}
let _ = self.inner.events.send(Event::NetworkActivated {
network: network_id,
});
@@ -387,6 +411,15 @@ impl Agent {
})
}
/// Resends this agent's announcement to every peer of a network.
///
/// Plugins normally trigger this themselves through
/// [`crate::dataplane::PluginContext::request_reannounce`] when their
/// capability changes.
pub async fn reannounce(&self, network_id: NetworkId) -> Result<()> {
self.command(network_id, NetCommand::Reannounce).await
}
/// Asks one network to re-run discovery and re-evaluate dials right now.
///
/// Call this when the host's network environment changed. Platform wake-up
@@ -426,14 +459,25 @@ impl Agent {
self.inner.adapter.close().await;
let task = self
.inner
.accept_task
.lock()
.ok()
.and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
let task = handle.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
}
}
// Plugins remove whatever system objects they created. A plugin that
// misbehaves here must not hold up the agent, so this is bounded.
for plugin in &self.inner.config.plugins {
if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown())
.await
.is_err()
{
tracing::warn!(
protocol = plugin.protocol_id(),
"plugin did not shut down in time"
);
}
}
// Release the directory so another instance can claim it right away.
@@ -452,6 +496,77 @@ impl Agent {
}
}
/// How long each plugin gets to tear itself down during agent shutdown.
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
/// Serves requests plugins make of the agent.
///
/// Holds only a weak reference, so it exits once the agent is dropped.
async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<PluginRequest>) {
let Some(inner) = weak.upgrade() else {
return;
};
let shutdown = inner.shutdown.clone();
drop(inner);
loop {
let request = tokio::select! {
biased;
_ = shutdown.wait() => break,
request = requests.recv() => match request {
Some(request) => request,
None => break,
},
};
let Some(inner) = weak.upgrade() else {
break;
};
match request {
PluginRequest::Reannounce(network) => {
let sender = {
let networks = inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
// A plugin asking about a network that is no longer active is
// normal, not an error.
if let Some(sender) = sender {
let _ = sender.send(NetCommand::Reannounce).await;
}
}
PluginRequest::Error {
network,
protocol,
reason,
} => {
let sender = {
let networks = inner.networks.read().await;
networks.get(&network).map(|handle| handle.commands.clone())
};
match sender {
// The runtime owns this network's counters, so the error
// is counted and published in one place.
Some(sender) => {
let _ = sender
.send(NetCommand::PluginError { protocol, reason })
.await;
}
// The network is gone; there is nothing to count it
// against, but the report is still worth publishing.
None => {
let _ = inner.events.send(Event::PluginError {
network,
protocol,
reason,
});
}
}
}
}
}
}
/// Accepts inbound connections and routes authenticated sessions to networks.
///
/// Holds only a weak reference, so dropping every [`Agent`] handle lets the
+36
View File
@@ -54,6 +54,15 @@ pub(crate) enum NetCommand {
reply: oneshot::Sender<Box<NetworkStatus>>,
},
Recheck,
/// Resend this agent's announcement to every peer of this network.
Reannounce,
/// An IP plugin reported an error from one of its own tasks.
PluginError {
/// Plugin protocol id.
protocol: String,
/// Human readable reason, free of secrets.
reason: String,
},
}
impl std::fmt::Debug for NetCommand {
@@ -66,6 +75,8 @@ impl std::fmt::Debug for NetCommand {
NetCommand::Broadcast { message, .. } => write!(f, "Broadcast({})", kind(message)),
NetCommand::Status { .. } => f.write_str("Status"),
NetCommand::Recheck => f.write_str("Recheck"),
NetCommand::Reannounce => f.write_str("Reannounce"),
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
}
}
}
@@ -270,6 +281,31 @@ impl Runtime {
let _ = reply.send(Box::new(self.status()));
}
NetCommand::Recheck => self.discovery_round().await,
NetCommand::Reannounce => self.reannounce(),
NetCommand::PluginError { protocol, reason } => {
// Counted here so that the per-network metric and the event
// always agree, wherever the error came from.
self.metrics.plugin_errors += 1;
self.emit(Event::PluginError {
network: self.network_id,
protocol,
reason,
});
}
}
}
/// Rebuilds this agent's announcement and pushes it to every session.
///
/// Used when a plugin's capability changed, so peers do not have to wait
/// for a reconnect to learn about it.
fn reannounce(&mut self) {
let announcement = ControlMessage::Announce(self.local_announcement());
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
for peer in peers {
if let Err(err) = self.send_to(peer, announcement.clone()) {
tracing::debug!(%err, "could not queue re-announcement");
}
}
}