Files
tsunagi/src/dataplane/mod.rs
T
tsunagiandClaude Opus 5 ea7aaa2b69 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>
2026-09-21 11:07:31 +01:00

283 lines
9.5 KiB
Rust

//! Boundary between the control plane core and future IP plugins.
//!
//! The data plane is where actual IP connectivity is created. WireGuard is the
//! first planned plugin; none is implemented here.
//!
//! Two rules shape this module:
//!
//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries
//! a protocol id, a version, an enabled flag and a bounded opaque blob. The
//! core transports the blob and hands it to the matching plugin. It does not
//! know what a WireGuard configuration looks like.
//! 2. **Plugin keys and lifecycle are separate from iroh identity and from the
//! network secret.** A plugin owns its own keys and its own system objects.
//!
//! An iroh address is *not* automatically a WireGuard address. A future plugin
//! is expected to gather its own reachability information and ship it through
//! the control plane as its announcement payload.
//!
//! A data plane failure never stops the daemon: errors returned here are
//! recorded and surfaced, the control plane keeps running.
pub mod wireguard;
use std::sync::Arc;
use iroh::EndpointId;
use tokio::sync::mpsc;
use crate::BoxFuture;
use crate::identity::NetworkId;
/// Maximum length of a plugin protocol identifier.
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
/// An announcement of one IP plugin's capability.
///
/// `data` is opaque to the core. Nothing in it may be interpreted as a shell
/// command, a filesystem path or an OS setting by the core; a plugin that
/// chooses to do so must validate it itself.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PluginCapability {
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
pub protocol: String,
/// Version of the plugin's announcement format.
pub version: u16,
/// Whether the peer currently has this plugin enabled.
pub enabled: bool,
/// Opaque, bounded, plugin-defined payload.
pub data: Vec<u8>,
}
/// Errors a plugin may return. They are recorded, never fatal for the agent.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PluginError {
/// The plugin is not currently able to produce or apply configuration.
#[error("plugin unavailable: {0}")]
Unavailable(String),
/// A peer announcement was not acceptable to the plugin.
#[error("rejected peer announcement: {0}")]
Rejected(String),
/// Anything else.
#[error("plugin error: {0}")]
Other(String),
}
/// A request a plugin makes of the agent that owns it.
#[derive(Debug)]
pub(crate) enum PluginRequest {
/// Re-send this agent's announcement to every peer of a network.
Reannounce(NetworkId),
/// Surface a plugin error on the agent's event stream.
Error {
/// Network the error is scoped to.
network: NetworkId,
/// Plugin protocol id.
protocol: String,
/// Human readable reason, free of secrets.
reason: String,
},
}
/// The agent-side handle a plugin is given when it is attached.
///
/// It is deliberately tiny: a plugin may ask for its announcement to be resent
/// and may report an error. It cannot reach into agent state, cannot send
/// arbitrary control messages and knows nothing about sessions.
///
/// All calls are non-blocking. If the agent is gone or its queue is full the
/// request is dropped rather than stalling the plugin.
#[derive(Clone)]
pub struct PluginContext {
sender: Option<mpsc::Sender<PluginRequest>>,
}
impl PluginContext {
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>) -> Self {
Self {
sender: Some(sender),
}
}
/// A context that discards everything, for plugins used outside an agent.
pub fn detached() -> Self {
Self { sender: None }
}
fn send(&self, request: PluginRequest) {
let Some(sender) = &self.sender else {
return;
};
if let Err(err) = sender.try_send(request) {
tracing::debug!(%err, "dropping plugin request");
}
}
/// Asks the agent to resend this agent's announcement in `network`.
///
/// A plugin calls this when its own capability changed — it finished
/// starting up, its keys or reachability changed — so that peers learn the
/// new value without waiting for a reconnect.
pub fn request_reannounce(&self, network: NetworkId) {
self.send(PluginRequest::Reannounce(network));
}
/// Reports a plugin error on the agent's event stream.
///
/// Plugin work happens in the plugin's own tasks, so errors cannot always
/// be returned from a trait call. They are never fatal for the agent.
pub fn report_error(
&self,
network: NetworkId,
protocol: impl Into<String>,
reason: impl Into<String>,
) {
self.send(PluginRequest::Error {
network,
protocol: protocol.into(),
reason: reason.into(),
});
}
}
impl std::fmt::Debug for PluginContext {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PluginContext")
.field("attached", &self.sender.is_some())
.finish()
}
}
/// The contract an IP plugin implements.
///
/// Implementations must be cheap and non-blocking: the agent calls them from
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// Stable protocol identifier, e.g. `wireguard`.
///
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
fn protocol_id(&self) -> &str;
/// Called once, when the agent starts, before any network is activated.
///
/// The plugin keeps the context to ask for re-announcements and to report
/// errors that happen in its own tasks.
fn attach(&self, context: PluginContext) {
let _ = context;
}
/// Produces this agent's announcement for a given network.
///
/// Returning `Ok(None)` means "nothing to announce right now", which is
/// different from an error.
fn local_capability(
&self,
network: NetworkId,
) -> std::result::Result<Option<PluginCapability>, PluginError>;
/// Called when a network is activated locally, before any peer appears.
///
/// A plugin uses it to get its per-network state ready, so that the first
/// announcement already carries its capability.
fn on_network_activated(&self, network: NetworkId) {
let _ = network;
}
/// Called when a peer announces a capability for this plugin's protocol.
///
/// The core has already bounded the payload size but has not interpreted it.
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> std::result::Result<(), PluginError>;
/// Called when a peer's session in a network goes away.
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId);
/// Called when a network is deactivated locally.
///
/// This is a local deactivation, not a signed revocation of membership.
/// The plugin is expected to remove whatever it created for that network.
fn on_network_deactivated(&self, network: NetworkId);
/// Called once when the agent shuts down.
///
/// The plugin removes the system objects it created and stops its tasks.
/// It must be bounded: the agent awaits it during shutdown.
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async {})
}
}
/// A shared handle to a plugin.
pub type SharedPlugin = Arc<dyn IpPlugin>;
/// A plugin used in tests and examples.
///
/// It announces an explicitly test-only protocol id, so nothing in this crate
/// ever advertises WireGuard as an available transport before it exists.
#[derive(Debug)]
pub struct TestCapabilityPlugin {
protocol: String,
payload: Vec<u8>,
seen: std::sync::Mutex<Vec<(NetworkId, EndpointId, PluginCapability)>>,
}
impl TestCapabilityPlugin {
/// Creates a plugin announcing `protocol` with a fixed opaque payload.
pub fn new(protocol: impl Into<String>, payload: impl Into<Vec<u8>>) -> Self {
Self {
protocol: protocol.into(),
payload: payload.into(),
seen: std::sync::Mutex::new(Vec::new()),
}
}
/// Returns everything this plugin was handed so far.
pub fn observed(&self) -> Vec<(NetworkId, EndpointId, PluginCapability)> {
match self.seen.lock() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
}
impl IpPlugin for TestCapabilityPlugin {
fn protocol_id(&self) -> &str {
&self.protocol
}
fn local_capability(
&self,
_network: NetworkId,
) -> std::result::Result<Option<PluginCapability>, PluginError> {
Ok(Some(PluginCapability {
protocol: self.protocol.clone(),
version: 1,
enabled: true,
data: self.payload.clone(),
}))
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> std::result::Result<(), PluginError> {
let mut guard = match self.seen.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
guard.push((network, peer, capability.clone()));
Ok(())
}
fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {}
fn on_network_deactivated(&self, _network: NetworkId) {}
}