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:
@@ -0,0 +1,282 @@
|
||||
//! 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) {}
|
||||
}
|
||||
Reference in New Issue
Block a user