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:
+127
-12
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user