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
+357
View File
@@ -0,0 +1,357 @@
//! What a WireGuard peer tells the network about itself.
//!
//! This is the opaque payload the control plane carries in a
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it —
//! only this module does, and only after bounding every field.
//!
//! An iroh address is an address for iroh. It is **not** reused here: the
//! plugin advertises its own reachability, gathered by itself, for its own
//! listening port.
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use serde::{Deserialize, Serialize};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use super::keys::WgPublicKey;
use super::overlay::overlay_address;
/// Version of the announcement format.
pub const ANNOUNCEMENT_VERSION: u16 = 1;
/// Largest number of advertised endpoints accepted from a peer.
pub const MAX_ENDPOINTS: usize = 8;
/// What one participant advertises for the WireGuard data plane.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WgAnnouncement {
/// Announcement format version.
pub version: u16,
/// The peer's WireGuard public key. Its overlay address is derived from it.
pub public_key: [u8; 32],
/// The UDP port the peer's WireGuard interface listens on.
pub listen_port: u16,
/// Reachability the plugin gathered for itself. Advisory, may be empty.
pub endpoints: Vec<SocketAddr>,
/// The overlay address the peer believes it has.
///
/// Carried for diagnostics and cross-checking only. `AllowedIPs` are
/// always derived locally, never taken from this field.
pub overlay_address: Ipv6Addr,
}
/// A peer announcement that has been validated against a specific network.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidatedAnnouncement {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The peer's listening port.
pub listen_port: u16,
/// Usable endpoints, filtered.
pub endpoints: Vec<SocketAddr>,
/// The overlay address derived locally for this key. Authoritative.
pub overlay_address: Ipv6Addr,
}
impl ValidatedAnnouncement {
/// The endpoint to configure for this peer, if any is usable.
///
/// WireGuard takes a single endpoint. The first usable one wins, and
/// WireGuard itself will re-learn the peer's real source address from the
/// first authenticated packet it receives.
pub fn preferred_endpoint(&self) -> Option<SocketAddr> {
self.endpoints.first().copied()
}
}
impl WgAnnouncement {
/// Builds this agent's announcement.
pub fn new(
network: NetworkId,
public_key: &WgPublicKey,
listen_port: u16,
endpoints: Vec<SocketAddr>,
) -> Self {
let mut endpoints = endpoints;
endpoints.truncate(MAX_ENDPOINTS);
Self {
version: ANNOUNCEMENT_VERSION,
public_key: *public_key.as_bytes(),
listen_port,
endpoints,
overlay_address: overlay_address(network, public_key),
}
}
/// Encodes the announcement into the opaque capability payload.
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
postcard::to_stdvec(self)
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
}
/// Decodes and validates a payload received from a peer.
///
/// `network` and `local_key` scope the checks: an announcement is only
/// meaningful inside one network, and a peer must not claim our own key.
pub fn decode_and_validate(
payload: &[u8],
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
let announcement: Self = postcard::from_bytes(payload)
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
announcement.validate(network, local_key)
}
fn validate(
self,
network: NetworkId,
local_key: &WgPublicKey,
) -> Result<ValidatedAnnouncement, PluginError> {
if self.version != ANNOUNCEMENT_VERSION {
return Err(PluginError::Rejected(format!(
"unsupported WireGuard announcement version {} (this build speaks {ANNOUNCEMENT_VERSION})",
self.version
)));
}
let public_key = WgPublicKey::from_bytes(self.public_key);
if public_key.is_zero() {
return Err(PluginError::Rejected(
"WireGuard public key is all zeroes".into(),
));
}
if &public_key == local_key {
return Err(PluginError::Rejected(
"peer announced this agent's own WireGuard key".into(),
));
}
if self.listen_port == 0 {
return Err(PluginError::Rejected(
"WireGuard listen port must not be zero".into(),
));
}
if self.endpoints.len() > MAX_ENDPOINTS {
return Err(PluginError::Rejected(format!(
"announcement carries {} endpoints, at most {MAX_ENDPOINTS} are accepted",
self.endpoints.len()
)));
}
// AllowedIPs are derived, never trusted. A mismatch means the peer is
// confused or lying, and either way its own claim is discarded.
let derived = overlay_address(network, &public_key);
if self.overlay_address != derived {
return Err(PluginError::Rejected(
"announced overlay address does not match the one derived from the peer's key"
.into(),
));
}
let endpoints: Vec<SocketAddr> = self
.endpoints
.into_iter()
.filter(is_usable_endpoint)
.collect();
Ok(ValidatedAnnouncement {
public_key,
listen_port: self.listen_port,
endpoints,
overlay_address: derived,
})
}
}
/// Whether an advertised endpoint is worth trying.
///
/// Nothing here is trusted; this only discards addresses that cannot be a
/// peer, so the plugin does not waste a WireGuard endpoint slot on them.
fn is_usable_endpoint(endpoint: &SocketAddr) -> bool {
if endpoint.port() == 0 {
return false;
}
match endpoint.ip() {
IpAddr::V4(ip) => {
!ip.is_unspecified()
&& !ip.is_multicast()
&& !ip.is_broadcast()
&& !ip.is_documentation()
}
IpAddr::V6(ip) => !ip.is_unspecified() && !ip.is_multicast(),
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
use super::super::keys::WgSecretKey;
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![3u8; 32]).unwrap(),
)
.network_id()
}
fn endpoint(text: &str) -> SocketAddr {
text.parse().unwrap()
}
#[test]
fn a_well_formed_announcement_round_trips() {
let id = network("round-trip");
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement =
WgAnnouncement::new(id, &peer, 51820, vec![endpoint("192.0.2.10:51820")]);
let payload = announcement.encode().unwrap();
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
assert_eq!(validated.public_key, peer);
assert_eq!(validated.listen_port, 51820);
assert_eq!(validated.overlay_address, overlay_address(id, &peer));
// 192.0.2.0/24 is documentation space and is filtered out.
assert!(validated.endpoints.is_empty());
}
#[test]
fn allowed_ips_are_derived_not_taken_from_the_peer() {
let id = network("no-hijack");
let victim = WgSecretKey::generate().public();
let attacker = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
// An attacker claims the victim's overlay address with its own key.
let mut forged = WgAnnouncement::new(id, &attacker, 51820, Vec::new());
forged.overlay_address = overlay_address(id, &victim);
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
assert!(
matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")),
"claiming another member's overlay address must be rejected: {result:?}"
);
}
#[test]
fn an_announcement_from_another_network_does_not_validate() {
let here = network("here");
let there = network("there");
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(there, &peer, 51820, Vec::new())
.encode()
.unwrap();
assert!(WgAnnouncement::decode_and_validate(&payload, here, &local).is_err());
}
#[test]
fn hostile_payloads_are_rejected_without_panicking() {
let id = network("hostile");
let local = WgSecretKey::generate().public();
let peer = WgSecretKey::generate().public();
// Not postcard at all.
assert!(WgAnnouncement::decode_and_validate(&[0xff; 64], id, &local).is_err());
assert!(WgAnnouncement::decode_and_validate(&[], id, &local).is_err());
let wrong_version = WgAnnouncement {
version: ANNOUNCEMENT_VERSION + 1,
..WgAnnouncement::new(id, &peer, 51820, Vec::new())
};
assert!(
WgAnnouncement::decode_and_validate(&wrong_version.encode().unwrap(), id, &local)
.is_err()
);
let zero_key = WgAnnouncement {
public_key: [0u8; 32],
..WgAnnouncement::new(id, &peer, 51820, Vec::new())
};
assert!(
WgAnnouncement::decode_and_validate(&zero_key.encode().unwrap(), id, &local).is_err()
);
let zero_port = WgAnnouncement::new(id, &peer, 0, Vec::new());
assert!(
WgAnnouncement::decode_and_validate(&zero_port.encode().unwrap(), id, &local).is_err()
);
let too_many = WgAnnouncement {
endpoints: (0..MAX_ENDPOINTS + 1)
.map(|index| endpoint(&format!("10.0.0.1:{}", 1000 + index)))
.collect(),
..WgAnnouncement::new(id, &peer, 51820, Vec::new())
};
assert!(
WgAnnouncement::decode_and_validate(&too_many.encode().unwrap(), id, &local).is_err()
);
}
#[test]
fn a_peer_cannot_claim_our_own_key() {
let id = network("self");
let local = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &local, 51820, Vec::new())
.encode()
.unwrap();
assert!(WgAnnouncement::decode_and_validate(&payload, id, &local).is_err());
}
#[test]
fn unusable_endpoints_are_filtered_and_the_rest_kept() {
let id = network("filter");
let peer = WgSecretKey::generate().public();
let local = WgSecretKey::generate().public();
let announcement = WgAnnouncement::new(
id,
&peer,
51820,
vec![
endpoint("0.0.0.0:51820"),
endpoint("224.0.0.1:51820"),
endpoint("10.1.2.3:0"),
endpoint("10.1.2.3:51820"),
endpoint("[2001:db8::1]:51820"),
],
);
let validated =
WgAnnouncement::decode_and_validate(&announcement.encode().unwrap(), id, &local)
.unwrap();
assert_eq!(
validated.endpoints,
vec![endpoint("10.1.2.3:51820"), endpoint("[2001:db8::1]:51820")]
);
assert_eq!(
validated.preferred_endpoint(),
Some(endpoint("10.1.2.3:51820"))
);
}
#[test]
fn announcements_stay_well_under_the_capability_payload_limit() {
let id = network("size");
let peer = WgSecretKey::generate().public();
let endpoints = (0..MAX_ENDPOINTS)
.map(|index| endpoint(&format!("[2001:db8::{index}]:51820")))
.collect();
let payload = WgAnnouncement::new(id, &peer, 51820, endpoints)
.encode()
.unwrap();
assert!(
payload.len() < crate::config::Limits::default().max_capability_data_len,
"announcement is {} bytes",
payload.len()
);
}
}
+214
View File
@@ -0,0 +1,214 @@
//! How a desired configuration reaches the operating system.
//!
//! The plugin computes *what* the interface should look like; a backend makes
//! it so. Splitting them keeps every interesting decision testable without
//! root and without touching the host's network.
//!
//! A backend only ever touches the interface named in the configuration it is
//! given. It never enumerates, adopts or modifies anything else.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::dataplane::PluginError;
use super::config::{InterfaceConfig, InterfaceState};
/// Applies a desired WireGuard configuration.
///
/// Implementations are synchronous and may block; the plugin calls them from a
/// blocking task, never from the async runtime.
pub trait WireguardBackend: Send + Sync + std::fmt::Debug + 'static {
/// A short name used in diagnostics.
fn name(&self) -> &str;
/// Reads back the current state of an interface.
///
/// `Ok(None)` means the interface does not exist, which is different from
/// an error.
fn inspect(&self, interface: &str) -> Result<Option<InterfaceState>, PluginError>;
/// Creates or updates the interface so that it matches `desired`.
fn apply(&self, desired: &InterfaceConfig) -> Result<(), PluginError>;
/// Removes an interface this plugin created. Removing an absent interface
/// succeeds.
fn remove(&self, interface: &str) -> Result<(), PluginError>;
}
/// What a [`RecordingBackend`] was asked to do.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BackendCall {
/// An interface was inspected.
Inspect(String),
/// An interface was created or updated.
Apply(String),
/// An interface was removed.
Remove(String),
}
/// An in-memory backend for tests and dry runs.
///
/// It behaves like a working WireGuard implementation without needing root or
/// touching the host: applied configurations are remembered and can be read
/// back, drift can be injected, and failures can be simulated.
#[derive(Debug, Clone, Default)]
pub struct RecordingBackend {
inner: Arc<Mutex<Recorded>>,
}
#[derive(Debug, Default)]
struct Recorded {
interfaces: HashMap<String, InterfaceState>,
calls: Vec<BackendCall>,
fail_next_apply: Option<String>,
}
impl RecordingBackend {
/// Creates an empty backend.
pub fn new() -> Self {
Self::default()
}
fn with<T>(&self, f: impl FnOnce(&mut Recorded) -> T) -> T {
let mut guard = match self.inner.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
f(&mut guard)
}
/// The state currently configured for an interface, if any.
pub fn state(&self, interface: &str) -> Option<InterfaceState> {
self.with(|recorded| recorded.interfaces.get(interface).cloned())
}
/// Every interface currently configured.
pub fn interfaces(&self) -> Vec<String> {
self.with(|recorded| {
let mut names: Vec<String> = recorded.interfaces.keys().cloned().collect();
names.sort();
names
})
}
/// Everything the backend was asked to do, in order.
pub fn calls(&self) -> Vec<BackendCall> {
self.with(|recorded| recorded.calls.clone())
}
/// How many times an interface was applied.
pub fn apply_count(&self, interface: &str) -> usize {
self.with(|recorded| {
recorded
.calls
.iter()
.filter(|call| matches!(call, BackendCall::Apply(name) if name == interface))
.count()
})
}
/// Replaces an interface's state, simulating someone editing it by hand.
pub fn inject_drift(&self, interface: &str, state: InterfaceState) {
self.with(|recorded| {
recorded.interfaces.insert(interface.to_string(), state);
});
}
/// Makes the next `apply` fail, simulating a data plane error.
pub fn fail_next_apply(&self, reason: impl Into<String>) {
let reason = reason.into();
self.with(|recorded| recorded.fail_next_apply = Some(reason));
}
/// Forgets the recorded call history, keeping configured interfaces.
pub fn clear_calls(&self) {
self.with(|recorded| recorded.calls.clear());
}
}
impl WireguardBackend for RecordingBackend {
fn name(&self) -> &str {
"recording"
}
fn inspect(&self, interface: &str) -> Result<Option<InterfaceState>, PluginError> {
self.with(|recorded| {
recorded
.calls
.push(BackendCall::Inspect(interface.to_string()));
Ok(recorded.interfaces.get(interface).cloned())
})
}
fn apply(&self, desired: &InterfaceConfig) -> Result<(), PluginError> {
let state = desired.to_state();
self.with(|recorded| {
recorded
.calls
.push(BackendCall::Apply(desired.name.clone()));
if let Some(reason) = recorded.fail_next_apply.take() {
return Err(PluginError::Unavailable(reason));
}
recorded.interfaces.insert(desired.name.clone(), state);
Ok(())
})
}
fn remove(&self, interface: &str) -> Result<(), PluginError> {
self.with(|recorded| {
recorded
.calls
.push(BackendCall::Remove(interface.to_string()));
recorded.interfaces.remove(interface);
});
Ok(())
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::dataplane::wireguard::config::{InterfaceParams, build_interface};
use crate::dataplane::wireguard::keys::WgSecretKey;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
#[test]
fn the_recording_backend_behaves_like_a_working_one() {
let network = NetworkKeys::derive(
&NetworkName::new("backend").unwrap(),
&NetworkSecret::from_bytes(vec![2u8; 32]).unwrap(),
)
.network_id();
let backend = RecordingBackend::new();
let config = build_interface(
InterfaceParams {
network,
name: "tsun0".into(),
private_key: WgSecretKey::generate(),
listen_port: 51820,
mtu: None,
keepalive: None,
},
[WgSecretKey::generate().public()],
|_| None,
);
assert_eq!(backend.inspect("tsun0").unwrap(), None);
backend.apply(&config).unwrap();
assert_eq!(backend.inspect("tsun0").unwrap(), Some(config.to_state()));
assert_eq!(backend.interfaces(), vec!["tsun0".to_string()]);
backend.fail_next_apply("no permission");
assert!(backend.apply(&config).is_err());
backend.apply(&config).unwrap();
backend.remove("tsun0").unwrap();
assert_eq!(backend.inspect("tsun0").unwrap(), None);
// Removing something absent is not an error.
backend.remove("tsun0").unwrap();
assert_eq!(backend.apply_count("tsun0"), 3);
}
}
+587
View File
@@ -0,0 +1,587 @@
//! The desired local WireGuard configuration, and how it is rendered.
//!
//! Each agent builds its own configuration from the agreed set of
//! participants. For a full mesh of `N` members that is `N - 1` peers locally;
//! nobody hands out a configuration to anybody else.
//!
//! Nothing in here is free-form text taken from the network. Peer keys,
//! endpoints, allowed prefixes and keepalives are typed values that this
//! module re-serialises itself, so a hostile announcement cannot inject a
//! configuration directive or a command argument.
use std::fmt::Write as _;
use std::net::{IpAddr, Ipv6Addr, SocketAddr};
use sha2::{Digest, Sha256};
use zeroize::Zeroizing;
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{
OVERLAY_HOST_PREFIX_LEN, OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix,
};
/// Longest interface name Linux accepts, excluding the terminating NUL.
pub const MAX_INTERFACE_NAME_LEN: usize = 15;
/// Default prefix for interface names this plugin creates.
pub const DEFAULT_INTERFACE_PREFIX: &str = "tsun";
/// An address with a prefix length.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Cidr {
/// The address.
pub addr: IpAddr,
/// The prefix length in bits.
pub prefix_len: u8,
}
impl Cidr {
/// Builds a CIDR, rejecting an impossible prefix length.
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, PluginError> {
let max = match addr {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
if prefix_len > max {
return Err(PluginError::Other(format!(
"prefix length /{prefix_len} is impossible for {addr}"
)));
}
Ok(Self { addr, prefix_len })
}
/// A single host address.
pub fn host(addr: Ipv6Addr) -> Self {
Self {
addr: IpAddr::V6(addr),
prefix_len: OVERLAY_HOST_PREFIX_LEN,
}
}
}
impl std::fmt::Display for Cidr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}/{}", self.addr, self.prefix_len)
}
}
/// One remote participant, as this agent will configure it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerConfig {
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// Where to send the first packet, when the peer advertised somewhere.
pub endpoint: Option<SocketAddr>,
/// Prefixes accepted from and routed to this peer.
///
/// Always derived locally from the peer's key. Never taken from what the
/// peer claims.
pub allowed_ips: Vec<Cidr>,
/// Keepalive interval, needed to hold a NAT mapping open.
pub persistent_keepalive: Option<u16>,
}
/// The complete local configuration for one network's overlay interface.
#[derive(Debug, Clone)]
pub struct InterfaceConfig {
/// Interface name this plugin owns.
pub name: String,
/// This agent's private key for this network.
pub private_key: WgSecretKey,
/// UDP port the interface listens on.
pub listen_port: u16,
/// Addresses assigned to the interface.
pub addresses: Vec<Cidr>,
/// Interface MTU, when one is configured.
pub mtu: Option<u32>,
/// Remote participants.
pub peers: Vec<PeerConfig>,
}
/// Observable state of a configured interface, without any private key.
///
/// This is what desired and actual are compared on, so reconciliation never
/// needs to move a private key around.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterfaceState {
/// Interface name.
pub name: String,
/// Public key currently configured on the interface.
pub public_key: WgPublicKey,
/// Port currently listened on.
pub listen_port: u16,
/// Addresses currently assigned.
pub addresses: Vec<Cidr>,
/// Peers currently configured, sorted by public key.
pub peers: Vec<PeerState>,
}
/// Observable state of one configured peer.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PeerState {
/// The peer's public key.
pub public_key: WgPublicKey,
/// Endpoint currently configured.
pub endpoint: Option<SocketAddr>,
/// Allowed prefixes currently configured, sorted.
pub allowed_ips: Vec<Cidr>,
/// Keepalive currently configured.
pub persistent_keepalive: Option<u16>,
}
impl PeerState {
/// Puts the state in its canonical, comparable form.
pub fn normalised(mut self) -> Self {
self.allowed_ips.sort();
self.allowed_ips.dedup();
// WireGuard reports a disabled keepalive as zero.
if self.persistent_keepalive == Some(0) {
self.persistent_keepalive = None;
}
self
}
}
impl InterfaceState {
/// Puts the state in its canonical, comparable form.
pub fn normalised(mut self) -> Self {
self.addresses.sort();
self.addresses.dedup();
self.peers = self.peers.into_iter().map(PeerState::normalised).collect();
self.peers.sort_by_key(|peer| peer.public_key);
self
}
}
impl InterfaceConfig {
/// The state this configuration is expected to produce.
pub fn to_state(&self) -> InterfaceState {
InterfaceState {
name: self.name.clone(),
public_key: self.private_key.public(),
listen_port: self.listen_port,
addresses: self.addresses.clone(),
peers: self
.peers
.iter()
.map(|peer| PeerState {
public_key: peer.public_key,
endpoint: peer.endpoint,
allowed_ips: peer.allowed_ips.clone(),
persistent_keepalive: peer.persistent_keepalive,
})
.collect(),
}
.normalised()
}
/// Renders the configuration in the format `wg setconf` and `wg syncconf`
/// read.
///
/// Only WireGuard's own directives appear here. Addresses and MTU are not
/// part of this format — they belong to the network interface and are
/// applied separately.
///
/// The result contains the private key and is zeroized on drop.
pub fn render(&self) -> Zeroizing<String> {
let mut out = String::with_capacity(256 + self.peers.len() * 192);
out.push_str("[Interface]\n");
let _ = writeln!(out, "PrivateKey = {}", self.private_key.encode().as_str());
let _ = writeln!(out, "ListenPort = {}", self.listen_port);
let mut peers = self.peers.clone();
peers.sort_by_key(|peer| peer.public_key);
for peer in &peers {
out.push_str("\n[Peer]\n");
let _ = writeln!(out, "PublicKey = {}", peer.public_key.encode());
let mut allowed = peer.allowed_ips.clone();
allowed.sort();
let rendered: Vec<String> = allowed.iter().map(Cidr::to_string).collect();
let _ = writeln!(out, "AllowedIPs = {}", rendered.join(", "));
if let Some(endpoint) = peer.endpoint {
let _ = writeln!(out, "Endpoint = {endpoint}");
}
if let Some(keepalive) = peer.persistent_keepalive {
let _ = writeln!(out, "PersistentKeepalive = {keepalive}");
}
}
Zeroizing::new(out)
}
}
/// Derives this plugin's interface name for a network.
///
/// The name is stable across restarts and short enough for the platform. Two
/// agents on the same host in the same network must be given different
/// prefixes, or they would derive the same name.
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, PluginError> {
if prefix.is_empty() {
return Err(PluginError::Other(
"interface prefix must not be empty".into(),
));
}
if !prefix
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Err(PluginError::Other(
"interface prefix must be lowercase ASCII letters and digits".into(),
));
}
if prefix.len() >= MAX_INTERFACE_NAME_LEN {
return Err(PluginError::Other(format!(
"interface prefix must be shorter than {MAX_INTERFACE_NAME_LEN} characters"
)));
}
let mut suffix = data_encoding::BASE32_NOPAD.encode(network.as_bytes());
suffix.make_ascii_lowercase();
let room = MAX_INTERFACE_NAME_LEN - prefix.len();
suffix.truncate(room);
Ok(format!("{prefix}{suffix}"))
}
/// How the plugin chooses its UDP port.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PortPolicy {
/// Always this port. Only usable with a single network.
Fixed(u16),
/// A port derived from the network id inside `base .. base + span`.
///
/// Stable across restarts, so a peer's cached endpoint keeps working, and
/// different networks on one host land on different ports.
Derived {
/// First port of the range.
base: u16,
/// How many ports the range covers.
span: u16,
},
}
impl Default for PortPolicy {
fn default() -> Self {
Self::Derived {
base: 51820,
span: 64,
}
}
}
impl PortPolicy {
/// The port to listen on for `network`.
pub fn port_for(&self, network: NetworkId) -> Result<u16, PluginError> {
match *self {
PortPolicy::Fixed(port) => {
if port == 0 {
return Err(PluginError::Other(
"a fixed WireGuard port must not be zero".into(),
));
}
Ok(port)
}
PortPolicy::Derived { base, span } => {
if base == 0 || span == 0 {
return Err(PluginError::Other(
"a derived WireGuard port range must not be empty or start at zero".into(),
));
}
let room = u16::MAX - base;
if span - 1 > room {
return Err(PluginError::Other(
"the derived WireGuard port range runs past port 65535".into(),
));
}
let hash = Sha256::digest(network.as_bytes());
let offset = u16::from_be_bytes([hash[0], hash[1]]) % span;
Ok(base + offset)
}
}
}
}
/// Everything about the local side of one network's interface.
#[derive(Debug, Clone)]
pub struct InterfaceParams {
/// The network the interface serves.
pub network: NetworkId,
/// Interface name, derived by [`interface_name`].
pub name: String,
/// This agent's private key for this network.
pub private_key: WgSecretKey,
/// Port to listen on.
pub listen_port: u16,
/// Interface MTU.
pub mtu: Option<u32>,
/// Keepalive applied to every peer.
pub keepalive: Option<u16>,
}
/// Builds this agent's interface configuration for one network.
///
/// `peers` is the set of participants the control plane agreed on; `endpoints`
/// supplies whatever reachability each of them advertised.
pub fn build_interface(
params: InterfaceParams,
peers: impl IntoIterator<Item = WgPublicKey>,
endpoints: impl Fn(&WgPublicKey) -> Option<SocketAddr>,
) -> InterfaceConfig {
let InterfaceParams {
network,
name,
private_key,
listen_port,
mtu,
keepalive,
} = params;
let local = overlay_address(network, &private_key.public());
let mut peer_configs: Vec<PeerConfig> = peers
.into_iter()
.filter(|key| !key.is_zero() && *key != private_key.public())
.map(|key| PeerConfig {
endpoint: endpoints(&key),
// Derived locally. This is the whole reason a hostile member
// cannot route another member's traffic to itself.
allowed_ips: vec![Cidr::host(overlay_address(network, &key))],
public_key: key,
persistent_keepalive: keepalive,
})
.collect();
peer_configs.sort_by_key(|peer| peer.public_key);
peer_configs.dedup_by(|a, b| a.public_key == b.public_key);
InterfaceConfig {
name,
private_key,
listen_port,
addresses: vec![
Cidr::host(local),
// The shared /64 gives the interface a route for the overlay.
Cidr {
addr: IpAddr::V6(overlay_prefix(network)),
prefix_len: OVERLAY_PREFIX_LEN,
},
],
mtu,
peers: peer_configs,
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![5u8; 32]).unwrap(),
)
.network_id()
}
#[test]
fn a_full_mesh_of_n_members_yields_n_minus_one_peers() {
let id = network("mesh");
let me = WgSecretKey::generate();
let others: Vec<WgPublicKey> = (0..3).map(|_| WgSecretKey::generate().public()).collect();
let config = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: me.clone(),
listen_port: 51820,
mtu: None,
keepalive: Some(25),
},
others.clone().into_iter().chain([me.public()]),
|_| None,
);
assert_eq!(config.peers.len(), 3, "our own key is never a peer");
for peer in &config.peers {
assert_eq!(peer.allowed_ips.len(), 1);
assert_eq!(
peer.allowed_ips[0],
Cidr::host(overlay_address(id, &peer.public_key))
);
assert_eq!(peer.persistent_keepalive, Some(25));
}
assert!(
config
.addresses
.contains(&Cidr::host(overlay_address(id, &me.public())))
);
}
#[test]
fn duplicate_and_zero_peer_keys_are_dropped() {
let id = network("dupes");
let me = WgSecretKey::generate();
let other = WgSecretKey::generate().public();
let config = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: me,
listen_port: 51820,
mtu: None,
keepalive: None,
},
[other, other, WgPublicKey::from_bytes([0u8; 32])],
|_| None,
);
assert_eq!(config.peers.len(), 1);
assert_eq!(config.peers[0].public_key, other);
}
#[test]
fn the_rendered_config_is_the_wg_setconf_format() {
let id = network("render");
let me = WgSecretKey::generate();
let peer = WgSecretKey::generate().public();
let config = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: me.clone(),
listen_port: 51820,
mtu: Some(1380),
keepalive: Some(25),
},
[peer],
|_| Some("10.0.0.7:51820".parse().unwrap()),
);
let rendered = config.render();
let text = rendered.as_str();
assert!(text.starts_with("[Interface]\n"));
assert!(text.contains(&format!("PrivateKey = {}", me.encode().as_str())));
assert!(text.contains("ListenPort = 51820"));
assert!(text.contains(&format!("PublicKey = {}", peer.encode())));
assert!(text.contains("Endpoint = 10.0.0.7:51820"));
assert!(text.contains("PersistentKeepalive = 25"));
assert!(text.contains(&format!(
"AllowedIPs = {}",
Cidr::host(overlay_address(id, &peer))
)));
// Address and MTU belong to the interface, not to wg's own format.
assert!(!text.contains("Address"));
assert!(!text.contains("MTU"));
}
#[test]
fn rendering_is_deterministic_regardless_of_peer_order() {
let id = network("stable");
let me = WgSecretKey::generate();
let keys: Vec<WgPublicKey> = (0..5).map(|_| WgSecretKey::generate().public()).collect();
let forward = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: me.clone(),
listen_port: 51820,
mtu: None,
keepalive: None,
},
keys.clone(),
|_| None,
);
let reversed = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: me,
listen_port: 51820,
mtu: None,
keepalive: None,
},
keys.into_iter().rev().collect::<Vec<_>>(),
|_| None,
);
assert_eq!(forward.render().as_str(), reversed.render().as_str());
assert_eq!(forward.to_state(), reversed.to_state());
}
#[test]
fn interface_names_fit_the_platform_limit_and_are_stable() {
let id = network("naming");
let name = interface_name(DEFAULT_INTERFACE_PREFIX, id).unwrap();
assert_eq!(name.len(), MAX_INTERFACE_NAME_LEN);
assert!(name.starts_with(DEFAULT_INTERFACE_PREFIX));
assert!(name.chars().all(|c| c.is_ascii_alphanumeric()));
assert_eq!(name, interface_name(DEFAULT_INTERFACE_PREFIX, id).unwrap());
assert_ne!(
name,
interface_name(DEFAULT_INTERFACE_PREFIX, network("other")).unwrap()
);
assert_ne!(name, interface_name("wg", id).unwrap());
assert!(interface_name("", id).is_err());
assert!(interface_name("has space", id).is_err());
assert!(interface_name("UPPER", id).is_err());
assert!(interface_name(&"a".repeat(MAX_INTERFACE_NAME_LEN), id).is_err());
}
#[test]
fn derived_ports_are_stable_and_inside_the_range() {
let policy = PortPolicy::default();
let id = network("ports");
let port = policy.port_for(id).unwrap();
assert_eq!(port, policy.port_for(id).unwrap());
assert!(
(51820..51884).contains(&port),
"port {port} outside the range"
);
assert_eq!(PortPolicy::Fixed(1234).port_for(id).unwrap(), 1234);
assert!(PortPolicy::Fixed(0).port_for(id).is_err());
assert!(
PortPolicy::Derived {
base: 65500,
span: 1000
}
.port_for(id)
.is_err()
);
}
#[test]
fn state_comparison_ignores_ordering_and_a_zero_keepalive() {
let peer_a = WgSecretKey::generate().public();
let peer_b = WgSecretKey::generate().public();
let make = |order: [WgPublicKey; 2], keepalive: Option<u16>| {
InterfaceState {
name: "tsun0".into(),
public_key: peer_a,
listen_port: 51820,
addresses: vec![
Cidr::host("fd00::2".parse().unwrap()),
Cidr::host("fd00::1".parse().unwrap()),
],
peers: order
.into_iter()
.map(|public_key| PeerState {
public_key,
endpoint: None,
allowed_ips: Vec::new(),
persistent_keepalive: keepalive,
})
.collect(),
}
.normalised()
};
assert_eq!(
make([peer_a, peer_b], None),
make([peer_b, peer_a], Some(0))
);
}
}
+215
View File
@@ -0,0 +1,215 @@
//! WireGuard key material.
//!
//! These keys belong to the plugin and to nothing else. They are **not**
//! derived from the iroh device key and **not** derived from the network
//! secret, so compromising or rotating one does not affect the others.
//!
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
//! with padding, 44 characters.
use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing};
use crate::dataplane::PluginError;
/// Length of a raw WireGuard key, in bytes.
pub const KEY_LEN: usize = 32;
/// Length of the base64 text form of a key.
pub const KEY_TEXT_LEN: usize = 44;
/// Applies the X25519 clamping WireGuard applies to private keys.
///
/// `wg genkey` clamps, so clamping here keeps the printed private key and the
/// derived public key byte-identical to what the WireGuard tools produce.
fn clamp(bytes: &mut [u8; KEY_LEN]) {
bytes[0] &= 248;
bytes[31] &= 127;
bytes[31] |= 64;
}
/// A WireGuard public key.
///
/// Public, safe to log, and the identity a peer is known by inside the
/// overlay.
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WgPublicKey([u8; KEY_LEN]);
impl WgPublicKey {
/// Wraps raw key bytes.
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
Self(bytes)
}
/// The raw key bytes.
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
&self.0
}
/// Whether this is the all-zero key, which is never a valid peer.
pub fn is_zero(&self) -> bool {
self.0 == [0u8; KEY_LEN]
}
/// The base64 form WireGuard uses.
pub fn encode(&self) -> String {
BASE64.encode(&self.0)
}
/// Parses the base64 form WireGuard uses.
pub fn decode(text: &str) -> Result<Self, PluginError> {
if text.len() != KEY_TEXT_LEN {
return Err(PluginError::Rejected(format!(
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
text.len()
)));
}
let raw = BASE64
.decode(text.as_bytes())
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
Ok(Self(bytes))
}
/// A short prefix for logs and diagnostics.
pub fn fmt_short(&self) -> String {
self.encode().chars().take(8).collect()
}
}
impl std::fmt::Display for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.encode())
}
}
impl std::fmt::Debug for WgPublicKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "WgPublicKey({})", self.fmt_short())
}
}
/// A WireGuard private key.
///
/// Zeroized on drop and redacted from [`Debug`]. Its base64 form is only ever
/// produced for the configuration handed to the WireGuard backend, and that
/// value is itself zeroized.
#[derive(Clone)]
pub struct WgSecretKey(Zeroizing<[u8; KEY_LEN]>);
impl WgSecretKey {
/// Generates a fresh clamped private key.
pub fn generate() -> Self {
let mut bytes = Zeroizing::new([0u8; KEY_LEN]);
rand::fill(bytes.as_mut());
clamp(&mut bytes);
Self(bytes)
}
/// Wraps stored key bytes, clamping them.
pub fn from_bytes(bytes: &[u8; KEY_LEN]) -> Self {
let mut owned = Zeroizing::new(*bytes);
clamp(&mut owned);
Self(owned)
}
/// The raw key bytes, for persistence only.
pub(crate) fn expose(&self) -> &[u8; KEY_LEN] {
&self.0
}
/// The matching public key.
pub fn public(&self) -> WgPublicKey {
let secret = x25519_dalek::StaticSecret::from(*self.0);
let public = x25519_dalek::PublicKey::from(&secret);
WgPublicKey(public.to_bytes())
}
/// The base64 form, for the WireGuard configuration. Zeroized on drop.
pub fn encode(&self) -> Zeroizing<String> {
let mut encoded = BASE64.encode(self.0.as_ref());
let out = Zeroizing::new(encoded.clone());
encoded.zeroize();
out
}
}
impl std::fmt::Debug for WgSecretKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("WgSecretKey")
.field("public", &self.public().fmt_short())
.field("secret", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn generated_keys_are_clamped_and_round_trip() {
let secret = WgSecretKey::generate();
let raw = *secret.expose();
assert_eq!(raw[0] & 7, 0, "low three bits must be cleared");
assert_eq!(raw[31] & 128, 0, "top bit must be cleared");
assert_eq!(raw[31] & 64, 64, "second-highest bit must be set");
let text = secret.encode();
assert_eq!(text.len(), KEY_TEXT_LEN);
let public = secret.public();
let parsed = WgPublicKey::decode(&public.encode()).unwrap();
assert_eq!(parsed, public);
}
#[test]
fn reloading_a_stored_key_gives_the_same_public_key() {
let secret = WgSecretKey::generate();
let reloaded = WgSecretKey::from_bytes(secret.expose());
assert_eq!(secret.public(), reloaded.public());
}
#[test]
fn the_rfc_7748_test_vector_derives_the_expected_public_key() {
// RFC 7748 section 6.1. WireGuard keys are plain X25519 keys, and
// X25519 clamps internally, so storing the clamped form must not
// change the derived public key.
let private =
hex_to_key("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
let expected =
hex_to_key("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
let secret = WgSecretKey::from_bytes(&private);
assert_eq!(secret.public(), WgPublicKey::from_bytes(expected));
assert_eq!(
secret.public().encode(),
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo="
);
}
fn hex_to_key(text: &str) -> [u8; KEY_LEN] {
let raw = hex::decode(text).unwrap();
<[u8; KEY_LEN]>::try_from(raw.as_slice()).unwrap()
}
#[test]
fn malformed_keys_are_rejected_without_panicking() {
assert!(WgPublicKey::decode("").is_err());
assert!(WgPublicKey::decode("not base64 at all!!!").is_err());
assert!(WgPublicKey::decode(&"A".repeat(KEY_TEXT_LEN)).is_err());
assert!(WgPublicKey::decode(&BASE64.encode(&[0u8; 16])).is_err());
assert!(WgPublicKey::from_bytes([0u8; KEY_LEN]).is_zero());
}
#[test]
fn secrets_are_redacted_in_debug_output() {
let secret = WgSecretKey::generate();
let rendered = format!("{secret:?}");
assert!(rendered.contains("<redacted>"));
assert!(!rendered.contains(secret.encode().as_str()));
}
}
+57
View File
@@ -0,0 +1,57 @@
//! The WireGuard data plane plugin.
//!
//! WireGuard is the first IP plugin. It creates real IP connectivity between
//! participants, while the control plane keeps doing what it does: agreeing on
//! who is in the network and carrying each participant's opaque announcement.
//!
//! The two planes stay separate:
//!
//! * **No user IP traffic goes through iroh.** iroh carries this plugin's
//! announcements and nothing else; the packets themselves travel over
//! WireGuard's own UDP sockets.
//! * **An iroh address is not a WireGuard address.** The plugin gathers its
//! own reachability and advertises that.
//! * **The core never parses these announcements.** It moves a bounded opaque
//! blob; only [`announcement`] interprets it.
//! * **Keys are separate.** The plugin has its own key per network, in its own
//! store, unrelated to the iroh device key and to the network secret.
//!
//! # How a mesh forms
//!
//! Every participant derives its own overlay address from the network id and
//! its own WireGuard public key ([`overlay`]), so no coordinator hands out
//! addresses. Because that derivation is public, each agent computes every
//! peer's `AllowedIPs` itself instead of believing what the peer claims — a
//! member cannot route another member's traffic to itself.
//!
//! Each agent then builds its own local configuration with one peer entry per
//! other participant ([`config`]) and hands it to a [`backend`]. The
//! [`backend::RecordingBackend`] applies it in memory, which is what the test
//! suite uses; [`wgtool::WgToolBackend`] drives the real `wg` and `ip` tools
//! and needs Linux with `CAP_NET_ADMIN`.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod backend;
pub mod config;
pub mod keys;
pub mod overlay;
pub mod plugin;
pub mod store;
pub mod wgtool;
pub use announcement::{ValidatedAnnouncement, WgAnnouncement};
pub use backend::{BackendCall, RecordingBackend, WireguardBackend};
pub use config::{
Cidr, InterfaceConfig, InterfaceParams, InterfaceState, PeerConfig, PeerState, PortPolicy,
build_interface, interface_name,
};
pub use keys::{WgPublicKey, WgSecretKey};
pub use overlay::{overlay_address, overlay_prefix};
pub use plugin::{
AdvertisePolicy, NetworkOverview, PeerOverview, WIREGUARD_PROTOCOL, WireguardConfig,
WireguardPlugin,
};
pub use store::WgKeyStore;
pub use wgtool::{WgToolBackend, plan_apply, plan_remove};
+143
View File
@@ -0,0 +1,143 @@
//! Deterministic overlay addressing.
//!
//! A mesh with no coordinator cannot hand out addresses, so every participant
//! derives its own from values everybody already knows. The result is an IPv6
//! unique local address (RFC 4193):
//!
//! ```text
//! prefix (/64) = 0xfd || SHA-256( LP(domain) || LP("prefix") || LP(network_id) )[0..7]
//! iid (64b) = SHA-256( LP(domain) || LP("interface") || LP(network_id) || LP(wg_public_key) )[0..8]
//! address = prefix || iid
//! ```
//!
//! Two properties matter:
//!
//! * Every member of a network derives the **same** `/64`, so the overlay is
//! one subnet without anybody allocating it.
//! * A member's address is bound to its WireGuard public key, so a peer's
//! `AllowedIPs` can be **derived locally and never taken from what the peer
//! claims**. A participant can mint many keys and therefore many addresses,
//! but it cannot choose to collide with an existing member's address without
//! finding a hash preimage.
use std::net::Ipv6Addr;
use sha2::{Digest, Sha256};
use crate::identity::NetworkId;
use super::keys::WgPublicKey;
/// Frozen domain separator for overlay address derivation.
pub const OVERLAY_DOMAIN: &str = "tsunagi-wireguard-overlay-v1";
/// Prefix length of the overlay subnet.
pub const OVERLAY_PREFIX_LEN: u8 = 64;
/// Prefix length of one member's address inside the overlay.
pub const OVERLAY_HOST_PREFIX_LEN: u8 = 128;
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
out.extend_from_slice(&len.to_be_bytes());
out.extend_from_slice(bytes);
}
fn digest(label: &str, network: NetworkId, key: Option<&WgPublicKey>) -> [u8; 32] {
let mut input = Vec::with_capacity(128);
push_lp(&mut input, OVERLAY_DOMAIN.as_bytes());
push_lp(&mut input, label.as_bytes());
push_lp(&mut input, network.as_bytes());
if let Some(key) = key {
push_lp(&mut input, key.as_bytes());
}
Sha256::digest(&input).into()
}
/// The `/64` every member of `network` shares.
///
/// Returned as the network address of the prefix, i.e. with a zero interface
/// identifier.
pub fn overlay_prefix(network: NetworkId) -> Ipv6Addr {
let hash = digest("prefix", network, None);
let mut octets = [0u8; 16];
// fd00::/8 marks a locally assigned unique local address.
octets[0] = 0xfd;
// 40 bits of global id followed by a 16 bit subnet id fill the rest of /64.
octets[1..8].copy_from_slice(&hash[0..7]);
Ipv6Addr::from(octets)
}
/// The address a member with `key` has in `network`.
pub fn overlay_address(network: NetworkId, key: &WgPublicKey) -> Ipv6Addr {
let prefix = overlay_prefix(network).octets();
let hash = digest("interface", network, Some(key));
let mut octets = [0u8; 16];
octets[0..8].copy_from_slice(&prefix[0..8]);
octets[8..16].copy_from_slice(&hash[0..8]);
// The all-zero interface identifier is the subnet-router anycast address
// and must not be handed to a host.
if octets[8..16] == [0u8; 8] {
octets[15] = 1;
}
Ipv6Addr::from(octets)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
)
.network_id()
}
#[test]
fn the_prefix_is_a_unique_local_address() {
let prefix = overlay_prefix(network("home"));
assert_eq!(prefix.octets()[0], 0xfd);
assert!(prefix.is_unique_local());
assert_eq!(&prefix.octets()[8..16], &[0u8; 8], "a /64 network address");
}
#[test]
fn everyone_in_a_network_shares_one_prefix() {
let id = network("shared");
let a = overlay_address(id, &WgPublicKey::from_bytes([1u8; 32]));
let b = overlay_address(id, &WgPublicKey::from_bytes([2u8; 32]));
assert_eq!(a.octets()[0..8], b.octets()[0..8]);
assert_ne!(a, b, "different keys get different addresses");
assert_eq!(&overlay_prefix(id).octets()[0..8], &a.octets()[0..8]);
}
#[test]
fn derivation_is_deterministic_and_network_scoped() {
let key = WgPublicKey::from_bytes([9u8; 32]);
let first = network("one");
let second = network("two");
assert_eq!(overlay_address(first, &key), overlay_address(first, &key));
assert_ne!(
overlay_address(first, &key),
overlay_address(second, &key),
"the same key in a different network gets a different address"
);
assert_ne!(overlay_prefix(first), overlay_prefix(second));
}
#[test]
fn addresses_are_never_the_subnet_router_anycast_address() {
let id = network("anycast");
for byte in 0..64u8 {
let address = overlay_address(id, &WgPublicKey::from_bytes([byte; 32]));
assert_ne!(&address.octets()[8..16], &[0u8; 8]);
}
}
}
+669
View File
@@ -0,0 +1,669 @@
//! The WireGuard IP plugin.
//!
//! Each agent builds its **own** local configuration from the set of
//! participants the control plane agreed on. For a full mesh of `N` members
//! that is `N - 1` peers locally. Nobody is handed a configuration by anybody
//! else, and no participant is authoritative.
//!
//! What the plugin owns and what it never touches:
//!
//! * it owns one WireGuard key per network, in its own store;
//! * it owns one interface per network, named deterministically from the
//! network id and its configured prefix;
//! * it never enumerates, adopts or edits an interface it did not create, and
//! it never changes routing, DNS or firewall settings.
//!
//! Reconciliation runs on every change and on a timer, so a configuration
//! edited by hand is put back the way it should be.
//!
//! A failure here is reported and retried. It never stops the control plane:
//! the agent keeps receiving state and stays manageable.
use std::collections::{BTreeSet, HashMap};
use std::net::{IpAddr, SocketAddr};
use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
use iroh::EndpointId;
use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::BoxFuture;
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use crate::identity::NetworkId;
use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
use super::backend::WireguardBackend;
use super::config::{
DEFAULT_INTERFACE_PREFIX, InterfaceConfig, InterfaceParams, PortPolicy, build_interface,
interface_name,
};
use super::keys::{WgPublicKey, WgSecretKey};
use super::overlay::{overlay_address, overlay_prefix};
use super::store::WgKeyStore;
/// The protocol identifier this plugin announces.
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
/// How the plugin advertises its own reachability.
///
/// An iroh address is an address for iroh. WireGuard needs its own, so the
/// plugin gathers its own rather than reusing the control plane's.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AdvertisePolicy {
/// Advertise nothing.
///
/// Peers can still reach this agent if they are reachable themselves:
/// WireGuard learns a peer's real source address from the first
/// authenticated packet it receives.
None,
/// Advertise exactly these addresses, combined with the listening port.
Explicit(Vec<IpAddr>),
/// Advertise the host's own non-loopback addresses.
LocalInterfaces,
}
/// Configuration of the WireGuard plugin.
#[derive(Debug, Clone)]
pub struct WireguardConfig {
/// Directory for the plugin's own key store. Separate from agent state.
pub state_dir: PathBuf,
/// Prefix of the interface names this plugin creates.
///
/// Two agents on one host in the same network need different prefixes,
/// because the rest of the name is derived from the network id.
pub interface_prefix: String,
/// How the listening port is chosen.
pub ports: PortPolicy,
/// What reachability to advertise.
pub advertise: AdvertisePolicy,
/// Keepalive interval, which holds a NAT mapping open.
pub keepalive: Option<u16>,
/// Interface MTU.
pub mtu: Option<u32>,
/// How long to coalesce changes before reconciling.
pub reconcile_debounce: Duration,
/// How often to reconcile even when nothing changed, which is what
/// corrects a configuration someone edited by hand.
pub reconcile_interval: Duration,
}
impl WireguardConfig {
/// Creates a configuration rooted at `state_dir` with sensible defaults.
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self {
state_dir: state_dir.into(),
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
ports: PortPolicy::default(),
advertise: AdvertisePolicy::LocalInterfaces,
keepalive: Some(25),
mtu: Some(1380),
reconcile_debounce: Duration::from_millis(200),
reconcile_interval: Duration::from_secs(30),
}
}
/// Sets the interface name prefix.
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
self.interface_prefix = prefix.into();
self
}
/// Sets the port policy.
pub fn with_ports(mut self, ports: PortPolicy) -> Self {
self.ports = ports;
self
}
/// Sets what reachability to advertise.
pub fn with_advertise(mut self, advertise: AdvertisePolicy) -> Self {
self.advertise = advertise;
self
}
/// Sets the reconciliation timings.
pub fn with_reconcile(mut self, debounce: Duration, interval: Duration) -> Self {
self.reconcile_debounce = debounce;
self.reconcile_interval = interval;
self
}
/// Path of the plugin's key store.
pub fn key_store_path(&self) -> PathBuf {
self.state_dir.join("wireguard.sqlite")
}
}
/// What this agent has set up for one network.
#[derive(Debug, Clone)]
pub struct NetworkOverview {
/// The network.
pub network: NetworkId,
/// Interface this plugin created for it.
pub interface: String,
/// This agent's WireGuard public key in this network.
pub public_key: WgPublicKey,
/// This agent's overlay address.
pub overlay_address: IpAddr,
/// The overlay subnet every member shares.
pub overlay_prefix: IpAddr,
/// Port the interface listens on.
pub listen_port: u16,
/// Reachability advertised to peers.
pub advertised: Vec<SocketAddr>,
/// Peers whose announcements were accepted.
pub peers: Vec<PeerOverview>,
}
/// One accepted peer.
#[derive(Debug, Clone)]
pub struct PeerOverview {
/// The peer's control plane identity.
pub endpoint_id: EndpointId,
/// The peer's WireGuard public key.
pub public_key: WgPublicKey,
/// The overlay address derived for it locally.
pub overlay_address: IpAddr,
/// Endpoint that will be configured for it, if any.
pub endpoint: Option<SocketAddr>,
}
#[derive(Debug)]
struct NetworkState {
key: WgSecretKey,
interface: String,
listen_port: u16,
advertised: Vec<SocketAddr>,
peers: HashMap<EndpointId, ValidatedAnnouncement>,
}
#[derive(Debug, Default)]
struct Shared {
networks: HashMap<NetworkId, NetworkState>,
}
#[derive(Debug)]
enum Command {
/// Make sure a network has keys, a name and a port.
Prepare(NetworkId),
/// Bring the interface in line with the known peers.
Sync(NetworkId),
/// Remove the interface for a network.
Teardown(NetworkId),
/// Tear everything down and stop.
Stop(oneshot::Sender<()>),
}
struct Worker {
config: WireguardConfig,
backend: Arc<dyn WireguardBackend>,
store: WgKeyStore,
shared: Mutex<Shared>,
context: OnceLock<PluginContext>,
}
impl std::fmt::Debug for Worker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Worker")
.field("backend", &self.backend.name())
.field("store", &self.store.path())
.finish()
}
}
/// The WireGuard data plane plugin.
#[derive(Debug)]
pub struct WireguardPlugin {
worker: Arc<Worker>,
commands: mpsc::Sender<Command>,
task: Mutex<Option<JoinHandle<()>>>,
}
impl WireguardPlugin {
/// Opens the plugin's key store and starts its reconciliation task.
///
/// Must be called from inside a tokio runtime; the plugin starts no
/// runtime of its own.
pub async fn open(
config: WireguardConfig,
backend: Arc<dyn WireguardBackend>,
) -> Result<Arc<Self>, PluginError> {
// Validate the prefix once, here, rather than failing per network.
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
let path = config.key_store_path();
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path))
.await
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
let worker = Arc::new(Worker {
config,
backend,
store,
shared: Mutex::new(Shared::default()),
context: OnceLock::new(),
});
let (commands, receiver) = mpsc::channel(64);
let task = tokio::spawn(run(Arc::clone(&worker), receiver));
Ok(Arc::new(Self {
worker,
commands,
task: Mutex::new(Some(task)),
}))
}
/// What this agent has set up for a network, if anything yet.
pub fn overview(&self, network: NetworkId) -> Option<NetworkOverview> {
let shared = self.worker.lock_shared();
let state = shared.networks.get(&network)?;
let mut peers: Vec<PeerOverview> = state
.peers
.iter()
.map(|(endpoint_id, announcement)| PeerOverview {
endpoint_id: *endpoint_id,
public_key: announcement.public_key,
overlay_address: IpAddr::V6(announcement.overlay_address),
endpoint: announcement.preferred_endpoint(),
})
.collect();
peers.sort_by_key(|peer| peer.public_key);
Some(NetworkOverview {
network,
interface: state.interface.clone(),
public_key: state.key.public(),
overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())),
overlay_prefix: IpAddr::V6(overlay_prefix(network)),
listen_port: state.listen_port,
advertised: state.advertised.clone(),
peers,
})
}
/// Asks the reconciliation task to run now, and waits for it to be queued.
///
/// Tests use it to avoid waiting for the periodic tick.
pub async fn reconcile_now(&self, network: NetworkId) {
let _ = self.commands.send(Command::Sync(network)).await;
}
fn nudge(&self, command: Command) {
if let Err(err) = self.commands.try_send(command) {
// A full queue means work is already scheduled; the periodic
// reconcile will pick anything up that was missed.
tracing::debug!(%err, "wireguard command queue is busy");
}
}
}
impl Worker {
fn lock_shared(&self) -> std::sync::MutexGuard<'_, Shared> {
match self.shared.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn report(&self, network: NetworkId, reason: impl std::fmt::Display) {
tracing::warn!(network = %network.fmt_short(), %reason, "wireguard plugin error");
if let Some(context) = self.context.get() {
context.report_error(network, WIREGUARD_PROTOCOL, reason.to_string());
}
}
fn request_reannounce(&self, network: NetworkId) {
if let Some(context) = self.context.get() {
context.request_reannounce(network);
}
}
/// Gathers the addresses to advertise for our own listening port.
async fn advertised_endpoints(&self, listen_port: u16) -> Vec<SocketAddr> {
let addresses: Vec<IpAddr> = match &self.config.advertise {
AdvertisePolicy::None => Vec::new(),
AdvertisePolicy::Explicit(addresses) => addresses.clone(),
AdvertisePolicy::LocalInterfaces => {
let state = netwatch::interfaces::State::new().await;
state.local_addresses.regular
}
};
let mut endpoints: Vec<SocketAddr> = addresses
.into_iter()
.filter(|addr| !addr.is_loopback() && !addr.is_unspecified() && !is_link_local(addr))
.map(|addr| SocketAddr::new(addr, listen_port))
.collect();
endpoints.sort();
endpoints.dedup();
endpoints.truncate(super::announcement::MAX_ENDPOINTS);
endpoints
}
/// Makes sure a network has a key, an interface name and a port.
///
/// Returns `true` when something changed and peers should be told.
async fn prepare(self: &Arc<Self>, network: NetworkId) -> Result<bool, PluginError> {
let existing = {
let shared = self.lock_shared();
shared
.networks
.get(&network)
.map(|state| (state.listen_port, state.advertised.clone()))
};
let listen_port = match existing {
Some((port, _)) => port,
None => self.config.ports.port_for(network)?,
};
let advertised = self.advertised_endpoints(listen_port).await;
if let Some((_, previous)) = existing {
if previous == advertised {
return Ok(false);
}
let mut shared = self.lock_shared();
if let Some(state) = shared.networks.get_mut(&network) {
state.advertised = advertised;
}
return Ok(true);
}
let name = interface_name(&self.config.interface_prefix, network)?;
let worker = Arc::clone(self);
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
.await
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
let mut shared = self.lock_shared();
shared.networks.entry(network).or_insert(NetworkState {
key,
interface: name,
listen_port,
advertised,
peers: HashMap::new(),
});
Ok(true)
}
/// Builds the configuration this agent wants for a network.
fn desired_config(&self, network: NetworkId) -> Option<InterfaceConfig> {
let shared = self.lock_shared();
let state = shared.networks.get(&network)?;
let endpoints: HashMap<WgPublicKey, SocketAddr> = state
.peers
.values()
.filter_map(|announcement| {
announcement
.preferred_endpoint()
.map(|endpoint| (announcement.public_key, endpoint))
})
.collect();
let keys: Vec<WgPublicKey> = state
.peers
.values()
.map(|announcement| announcement.public_key)
.collect();
Some(build_interface(
InterfaceParams {
network,
name: state.interface.clone(),
private_key: state.key.clone(),
listen_port: state.listen_port,
mtu: self.config.mtu,
keepalive: self.config.keepalive,
},
keys,
|key| endpoints.get(key).copied(),
))
}
/// Brings the interface in line with the desired configuration.
async fn sync(&self, network: NetworkId) -> Result<(), PluginError> {
let Some(desired) = self.desired_config(network) else {
return Ok(());
};
let backend = Arc::clone(&self.backend);
tokio::task::spawn_blocking(move || {
let current = backend.inspect(&desired.name)?;
// Reconciliation: anything that drifted, including an edit made by
// hand, is corrected here.
if current.as_ref() == Some(&desired.to_state()) {
return Ok(());
}
backend.apply(&desired)
})
.await
.map_err(|err| PluginError::Other(format!("wireguard apply task failed: {err}")))?
}
/// Removes the interface for a network, keeping its key.
async fn teardown(&self, network: NetworkId) -> Result<(), PluginError> {
let interface = {
let mut shared = self.lock_shared();
shared
.networks
.remove(&network)
.map(|state| state.interface)
};
let Some(interface) = interface else {
return Ok(());
};
let backend = Arc::clone(&self.backend);
tokio::task::spawn_blocking(move || backend.remove(&interface))
.await
.map_err(|err| PluginError::Other(format!("wireguard remove task failed: {err}")))?
}
fn known_networks(&self) -> Vec<NetworkId> {
self.lock_shared().networks.keys().copied().collect()
}
}
/// The reconciliation task.
///
/// Changes are coalesced over a short debounce so that a burst of peer
/// announcements produces one apply, and a periodic tick reconciles even when
/// nothing changed locally.
async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
let mut pending: BTreeSet<NetworkId> = BTreeSet::new();
let mut deadline: Option<tokio::time::Instant> = None;
let mut ticker = tokio::time::interval(worker.config.reconcile_interval);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// The first tick fires immediately and would reconcile nothing.
ticker.tick().await;
loop {
let wait_until = deadline;
tokio::select! {
biased;
command = commands.recv() => {
let Some(command) = command else { break };
match command {
Command::Prepare(network) => {
match worker.prepare(network).await {
Ok(true) => worker.request_reannounce(network),
Ok(false) => {}
Err(err) => worker.report(network, err),
}
pending.insert(network);
}
Command::Sync(network) => {
pending.insert(network);
}
Command::Teardown(network) => {
pending.remove(&network);
if let Err(err) = worker.teardown(network).await {
worker.report(network, err);
}
continue;
}
Command::Stop(reply) => {
for network in worker.known_networks() {
if let Err(err) = worker.teardown(network).await {
tracing::warn!(%err, "wireguard teardown failed during shutdown");
}
}
let _ = reply.send(());
return;
}
}
deadline = Some(tokio::time::Instant::now() + worker.config.reconcile_debounce);
}
_ = async {
match wait_until {
Some(at) => tokio::time::sleep_until(at).await,
// Never resolves; the branch is disabled by the guard.
None => std::future::pending::<()>().await,
}
}, if wait_until.is_some() => {
deadline = None;
for network in std::mem::take(&mut pending) {
if let Err(err) = worker.sync(network).await {
worker.report(network, err);
}
}
}
_ = ticker.tick() => {
// Periodic reconciliation is what corrects drift nobody told
// us about.
for network in worker.known_networks() {
if let Err(err) = worker.sync(network).await {
worker.report(network, err);
}
}
}
}
}
}
fn is_link_local(addr: &IpAddr) -> bool {
match addr {
IpAddr::V4(ip) => ip.is_link_local(),
IpAddr::V6(ip) => (ip.segments()[0] & 0xffc0) == 0xfe80,
}
}
impl IpPlugin for WireguardPlugin {
fn protocol_id(&self) -> &str {
WIREGUARD_PROTOCOL
}
fn attach(&self, context: PluginContext) {
let _ = self.worker.context.set(context);
}
fn on_network_activated(&self, network: NetworkId) {
self.nudge(Command::Prepare(network));
}
fn local_capability(
&self,
network: NetworkId,
) -> Result<Option<PluginCapability>, PluginError> {
let shared = self.worker.lock_shared();
let Some(state) = shared.networks.get(&network) else {
// Not ready yet. Ask for preparation; once it finishes the plugin
// asks the agent to re-announce, so peers are not left waiting.
drop(shared);
self.nudge(Command::Prepare(network));
return Ok(None);
};
let announcement = WgAnnouncement::new(
network,
&state.key.public(),
state.listen_port,
state.advertised.clone(),
);
Ok(Some(PluginCapability {
protocol: WIREGUARD_PROTOCOL.to_string(),
version: super::announcement::ANNOUNCEMENT_VERSION,
enabled: true,
data: announcement.encode()?,
}))
}
fn on_peer_capability(
&self,
network: NetworkId,
peer: EndpointId,
capability: &PluginCapability,
) -> Result<(), PluginError> {
let local_key = {
let shared = self.worker.lock_shared();
match shared.networks.get(&network) {
Some(state) => state.key.public(),
None => {
drop(shared);
self.nudge(Command::Prepare(network));
return Err(PluginError::Unavailable(
"WireGuard is not ready for this network yet".into(),
));
}
}
};
let validated = WgAnnouncement::decode_and_validate(&capability.data, network, &local_key)?;
let changed = {
let mut shared = self.worker.lock_shared();
match shared.networks.get_mut(&network) {
Some(state) => {
state.peers.insert(peer, validated) != state.peers.get(&peer).cloned()
}
None => false,
}
};
if changed {
self.nudge(Command::Sync(network));
}
Ok(())
}
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
let removed = {
let mut shared = self.worker.lock_shared();
shared
.networks
.get_mut(&network)
.and_then(|state| state.peers.remove(&peer))
.is_some()
};
if removed {
self.nudge(Command::Sync(network));
}
}
fn on_network_deactivated(&self, network: NetworkId) {
self.nudge(Command::Teardown(network));
}
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
Box::pin(async move {
let (reply_tx, reply_rx) = oneshot::channel();
if self.commands.send(Command::Stop(reply_tx)).await.is_ok() {
let _ = reply_rx.await;
}
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
if let Some(task) = task {
let _ = task.await;
}
})
}
}
impl Drop for WireguardPlugin {
fn drop(&mut self) {
// Safety net for a plugin dropped without an explicit shutdown.
if let Ok(mut guard) = self.task.lock()
&& let Some(task) = guard.take()
{
task.abort();
}
}
}
+222
View File
@@ -0,0 +1,222 @@
//! The plugin's own key store.
//!
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
//! keys are not the iroh identity and not the network secret, and their
//! lifecycle is the plugin's business alone.
//!
//! One key per network, so a participant presents a different WireGuard
//! identity — and therefore a different overlay address — in each network it
//! belongs to.
//!
//! A damaged key store is an error, never a silent regeneration: a new key
//! would silently move this agent to a different overlay address and orphan
//! every peer's configuration.
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params};
use crate::dataplane::PluginError;
use crate::identity::NetworkId;
use super::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1;
/// Per-network WireGuard private keys.
#[derive(Debug)]
pub struct WgKeyStore {
conn: Mutex<Connection>,
path: PathBuf,
}
impl WgKeyStore {
/// Opens, creating the file and its directory if needed.
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
crate::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
}
let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?;
crate::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5))
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
if existed {
let integrity: String = conn
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
.map_err(|err| {
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
})?;
if integrity != "ok" {
return Err(PluginError::Other(format!(
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
path.display()
)));
}
}
let found: i64 = conn
.query_row("PRAGMA user_version", [], |row| row.get(0))
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
if found > SCHEMA_VERSION {
return Err(PluginError::Other(format!(
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
)));
}
if found < SCHEMA_VERSION {
conn.execute_batch(
"BEGIN;
CREATE TABLE IF NOT EXISTS network_keys (
network_id BLOB PRIMARY KEY,
secret BLOB NOT NULL,
created_at INTEGER NOT NULL
);
PRAGMA user_version = 1;
COMMIT;",
)
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
}
Ok(Self {
conn: Mutex::new(conn),
path,
})
}
/// Path of the underlying file.
pub fn path(&self) -> &Path {
&self.path
}
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
match self.conn.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Returns this agent's key for a network, creating it on first use.
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
let conn = self.lock();
let stored: Option<Vec<u8>> = conn
.query_row(
"SELECT secret FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
|row| row.get(0),
)
.optional()
.map_err(|err| PluginError::Other(format!("cannot read the WireGuard key: {err}")))?;
if let Some(bytes) = stored {
let bytes = <[u8; KEY_LEN]>::try_from(bytes.as_slice()).map_err(|_| {
PluginError::Other(format!(
"the stored WireGuard key for network {} is not {KEY_LEN} bytes; \
refusing to replace it",
network.fmt_short()
))
})?;
return Ok(WgSecretKey::from_bytes(&bytes));
}
let key = WgSecretKey::generate();
conn.execute(
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
params![
network.as_bytes().as_slice(),
key.expose().as_slice(),
now_unix()
],
)
.map_err(|err| PluginError::Other(format!("cannot store the WireGuard key: {err}")))?;
Ok(key)
}
/// Deletes the key for a network.
///
/// Not called when a network is merely deactivated: coming back should
/// keep the same overlay address.
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
self.lock()
.execute(
"DELETE FROM network_keys WHERE network_id = ?1",
params![network.as_bytes().as_slice()],
)
.map_err(|err| PluginError::Other(format!("cannot remove the WireGuard key: {err}")))?;
Ok(())
}
}
fn now_unix() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![8u8; 32]).unwrap(),
)
.network_id()
}
#[test]
fn keys_are_per_network_and_survive_reopening() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("wireguard.sqlite");
let first = network("one");
let second = network("two");
let (key_one, key_two) = {
let store = WgKeyStore::open(&path).unwrap();
let a = store.load_or_create(first).unwrap();
let b = store.load_or_create(second).unwrap();
assert_ne!(a.public(), b.public(), "networks get separate identities");
assert_eq!(a.public(), store.load_or_create(first).unwrap().public());
(a.public(), b.public())
};
let reopened = WgKeyStore::open(&path).unwrap();
assert_eq!(reopened.load_or_create(first).unwrap().public(), key_one);
assert_eq!(reopened.load_or_create(second).unwrap().public(), key_two);
reopened.forget(first).unwrap();
assert_ne!(reopened.load_or_create(first).unwrap().public(), key_one);
}
#[test]
fn a_corrupt_key_store_is_an_error_not_a_new_key() {
let dir = tempfile::TempDir::new().unwrap();
let path = dir.path().join("wireguard.sqlite");
let original = {
let store = WgKeyStore::open(&path).unwrap();
store.load_or_create(network("keep")).unwrap().public()
};
std::fs::write(&path, [0x5a; 4096]).unwrap();
let result = WgKeyStore::open(&path);
assert!(
result.is_err(),
"a damaged key store must not silently mint a new identity (was {original})"
);
}
}
+667
View File
@@ -0,0 +1,667 @@
//! A backend that drives the standard `wg` and `ip` tools.
//!
//! This is the one part of the plugin that changes the operating system. It is
//! split in two deliberately:
//!
//! * a **pure planner** that turns a desired configuration into an exact list
//! of commands, and pure **parsers** for the tools' output — both fully
//! unit tested on every platform;
//! * a thin executor that runs the plan, which needs Linux and
//! `CAP_NET_ADMIN`.
//!
//! Nothing that arrives from the network is ever passed through as text. Peer
//! keys, endpoints, allowed prefixes and keepalives are typed values that this
//! module re-serialises itself, so an announcement cannot inject an argument
//! or a configuration directive. The only names involved are derived locally.
//!
//! The interface is created by this plugin and removed by this plugin. An
//! interface that already exists and is not a WireGuard device is refused, not
//! adopted, so the agent never takes over something it did not create.
use std::collections::BTreeSet;
use std::net::SocketAddr;
use zeroize::Zeroizing;
use crate::dataplane::PluginError;
use super::config::{Cidr, InterfaceConfig, InterfaceState, PeerState};
use super::keys::{WgPublicKey, WgSecretKey};
/// Which external programs to use.
#[derive(Debug, Clone)]
pub struct Tools {
/// The `wg` executable.
pub wg: String,
/// The `ip` executable.
pub ip: String,
}
impl Default for Tools {
fn default() -> Self {
Self {
wg: "wg".into(),
ip: "ip".into(),
}
}
}
/// One command to run, with optional data for its standard input.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WgCommand {
/// Program to execute.
pub program: String,
/// Arguments, already separated. Never a shell string.
pub args: Vec<String>,
/// Data piped to the program's standard input.
///
/// Used for the WireGuard configuration so the private key never reaches
/// the filesystem. Zeroized on drop.
pub stdin: Option<Zeroizing<String>>,
}
impl WgCommand {
fn new(program: &str, args: &[&str]) -> Self {
Self {
program: program.to_string(),
args: args.iter().map(|arg| arg.to_string()).collect(),
stdin: None,
}
}
/// A redacted rendering, safe for logs.
pub fn describe(&self) -> String {
format!("{} {}", self.program, self.args.join(" "))
}
}
/// Builds the commands that bring `desired` into being.
///
/// `current` is what the interface looks like now, or `None` if it does not
/// exist yet. The plan is minimal: an interface that already matches produces
/// only the idempotent link-up command.
pub fn plan_apply(
desired: &InterfaceConfig,
current: Option<&InterfaceState>,
tools: &Tools,
) -> Vec<WgCommand> {
let mut plan = Vec::new();
let name = desired.name.as_str();
if current.is_none() {
plan.push(WgCommand::new(
&tools.ip,
&["link", "add", "dev", name, "type", "wireguard"],
));
}
// `setconf` replaces everything, `syncconf` applies a difference without
// tearing down live peers. Use each where it belongs.
let subcommand = if current.is_none() {
"setconf"
} else {
"syncconf"
};
let mut configure = WgCommand::new(&tools.wg, &[subcommand, name, "/dev/stdin"]);
configure.stdin = Some(desired.render());
plan.push(configure);
let desired_addrs: BTreeSet<Cidr> = desired.addresses.iter().copied().collect();
let current_addrs: BTreeSet<Cidr> = current
.map(|state| state.addresses.iter().copied().collect())
.unwrap_or_default();
for addr in desired_addrs.difference(&current_addrs) {
plan.push(WgCommand::new(
&tools.ip,
&["address", "add", &addr.to_string(), "dev", name],
));
}
// Addresses on an interface this plugin owns that are not wanted any more
// were either put there by an older configuration or by hand. Either way
// reconciliation removes them.
for addr in current_addrs.difference(&desired_addrs) {
plan.push(WgCommand::new(
&tools.ip,
&["address", "del", &addr.to_string(), "dev", name],
));
}
if let Some(mtu) = desired.mtu {
plan.push(WgCommand::new(
&tools.ip,
&["link", "set", "mtu", &mtu.to_string(), "dev", name],
));
}
plan.push(WgCommand::new(
&tools.ip,
&["link", "set", "up", "dev", name],
));
plan
}
/// Builds the commands that remove an interface this plugin created.
pub fn plan_remove(interface: &str, tools: &Tools) -> Vec<WgCommand> {
vec![WgCommand::new(
&tools.ip,
&["link", "del", "dev", interface],
)]
}
/// Parses the output of `wg showconf <interface>`.
///
/// The private key present in that output is used only to derive the
/// interface's public key and is dropped immediately.
pub fn parse_showconf(interface: &str, text: &str) -> Result<InterfaceState, PluginError> {
#[derive(Default)]
struct PartialPeer {
public_key: Option<WgPublicKey>,
endpoint: Option<SocketAddr>,
allowed_ips: Vec<Cidr>,
persistent_keepalive: Option<u16>,
}
let mut public_key: Option<WgPublicKey> = None;
let mut listen_port = 0u16;
let mut peers: Vec<PeerState> = Vec::new();
let mut current: Option<PartialPeer> = None;
let finish = |peer: PartialPeer, peers: &mut Vec<PeerState>| -> Result<(), PluginError> {
let key = peer
.public_key
.ok_or_else(|| PluginError::Other("wg showconf peer without a public key".into()))?;
peers.push(
PeerState {
public_key: key,
endpoint: peer.endpoint,
allowed_ips: peer.allowed_ips,
persistent_keepalive: peer.persistent_keepalive,
}
.normalised(),
);
Ok(())
};
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if line.eq_ignore_ascii_case("[interface]") {
if let Some(peer) = current.take() {
finish(peer, &mut peers)?;
}
continue;
}
if line.eq_ignore_ascii_case("[peer]") {
if let Some(peer) = current.take() {
finish(peer, &mut peers)?;
}
current = Some(PartialPeer::default());
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let name = key.trim().to_ascii_lowercase();
let value = value.trim();
match current.as_mut() {
None => match name.as_str() {
"privatekey" => {
// Derive the public key, then let the secret drop.
let raw = data_encoding::BASE64
.decode(value.as_bytes())
.map_err(|_| {
PluginError::Other("wg showconf private key is not base64".into())
})?;
let bytes = <[u8; 32]>::try_from(raw.as_slice()).map_err(|_| {
PluginError::Other("wg showconf private key is not 32 bytes".into())
})?;
public_key = Some(WgSecretKey::from_bytes(&bytes).public());
}
"listenport" => {
listen_port = value
.parse()
.map_err(|_| PluginError::Other(format!("bad listen port {value:?}")))?;
}
_ => {}
},
Some(peer) => match name.as_str() {
"publickey" => peer.public_key = Some(WgPublicKey::decode(value)?),
"endpoint" => peer.endpoint = value.parse().ok(),
"allowedips" => {
for entry in value.split(',') {
let entry = entry.trim();
if entry.is_empty() {
continue;
}
peer.allowed_ips.push(parse_cidr(entry)?);
}
}
"persistentkeepalive" => {
peer.persistent_keepalive =
match value {
"off" => None,
other => Some(other.parse().map_err(|_| {
PluginError::Other(format!("bad keepalive {other:?}"))
})?),
};
}
_ => {}
},
}
}
if let Some(peer) = current.take() {
finish(peer, &mut peers)?;
}
let public_key = public_key
.ok_or_else(|| PluginError::Other("wg showconf did not report a private key".into()))?;
Ok(InterfaceState {
name: interface.to_string(),
public_key,
listen_port,
addresses: Vec::new(),
peers,
}
.normalised())
}
/// Parses the addresses out of `ip -o address show dev <interface>`.
pub fn parse_ip_addresses(text: &str) -> Result<Vec<Cidr>, PluginError> {
let mut out = Vec::new();
for line in text.lines() {
let mut tokens = line.split_whitespace();
while let Some(token) = tokens.next() {
if token != "inet" && token != "inet6" {
continue;
}
let Some(value) = tokens.next() else {
continue;
};
// A link-local address is added by the kernel, not by us.
let cidr = parse_cidr(value)?;
if cidr.addr.is_loopback() {
continue;
}
if let std::net::IpAddr::V6(ip) = cidr.addr
&& (ip.segments()[0] & 0xffc0) == 0xfe80
{
continue;
}
out.push(cidr);
}
}
out.sort();
out.dedup();
Ok(out)
}
fn parse_cidr(text: &str) -> Result<Cidr, PluginError> {
let (addr, prefix) = text
.split_once('/')
.ok_or_else(|| PluginError::Other(format!("{text:?} is not an address with a prefix")))?;
let addr = addr
.parse()
.map_err(|_| PluginError::Other(format!("{addr:?} is not an IP address")))?;
let prefix_len = prefix
.parse()
.map_err(|_| PluginError::Other(format!("{prefix:?} is not a prefix length")))?;
Cidr::new(addr, prefix_len)
}
pub use executor::WgToolBackend;
#[cfg(target_os = "linux")]
mod executor {
use std::io::Write;
use std::process::{Command, Stdio};
use super::*;
use crate::dataplane::wireguard::backend::WireguardBackend;
/// Drives the real `wg` and `ip` tools.
///
/// Requires Linux and `CAP_NET_ADMIN`, so it is never used by the default
/// test suite.
#[derive(Debug, Clone)]
pub struct WgToolBackend {
tools: Tools,
}
impl WgToolBackend {
/// Creates a backend using `wg` and `ip` from `PATH`.
pub fn new() -> Result<Self, PluginError> {
Self::with_tools(Tools::default())
}
/// Creates a backend using explicitly located tools.
pub fn with_tools(tools: Tools) -> Result<Self, PluginError> {
Ok(Self { tools })
}
fn run(&self, command: &WgCommand) -> Result<String, PluginError> {
let mut child = Command::new(&command.program)
.args(&command.args)
.stdin(if command.stdin.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| {
PluginError::Unavailable(format!("cannot run `{}`: {err}", command.program))
})?;
if let Some(stdin) = &command.stdin {
let mut handle = child.stdin.take().ok_or_else(|| {
PluginError::Other("could not open the child's standard input".into())
})?;
handle.write_all(stdin.as_bytes()).map_err(|err| {
PluginError::Other(format!("cannot write the WireGuard configuration: {err}"))
})?;
drop(handle);
}
let output = child.wait_with_output().map_err(|err| {
PluginError::Other(format!("`{}` did not complete: {err}", command.describe()))
})?;
if !output.status.success() {
// The configuration went to stdin, so stderr cannot contain it.
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(PluginError::Unavailable(format!(
"`{}` failed: {stderr}",
command.describe()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn link_exists(&self, interface: &str) -> bool {
Command::new(&self.tools.ip)
.args(["link", "show", "dev", interface])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|status| status.success())
.unwrap_or(false)
}
}
impl WireguardBackend for WgToolBackend {
fn name(&self) -> &str {
"wg-tools"
}
fn inspect(&self, interface: &str) -> Result<Option<InterfaceState>, PluginError> {
if !self.link_exists(interface) {
return Ok(None);
}
let showconf = WgCommand::new(&self.tools.wg, &["showconf", interface]);
let text = match self.run(&showconf) {
Ok(text) => text,
Err(_) => {
// The link exists but is not a WireGuard device. It is not
// ours, so it is refused rather than adopted or modified.
return Err(PluginError::Rejected(format!(
"interface `{interface}` already exists and is not a WireGuard device; \
refusing to touch it"
)));
}
};
let mut state = parse_showconf(interface, &text)?;
let addresses = self.run(&WgCommand::new(
&self.tools.ip,
&["-o", "address", "show", "dev", interface],
))?;
state.addresses = parse_ip_addresses(&addresses)?;
Ok(Some(state.normalised()))
}
fn apply(&self, desired: &InterfaceConfig) -> Result<(), PluginError> {
let current = self.inspect(&desired.name)?;
for command in plan_apply(desired, current.as_ref(), &self.tools) {
self.run(&command)?;
}
Ok(())
}
fn remove(&self, interface: &str) -> Result<(), PluginError> {
if !self.link_exists(interface) {
return Ok(());
}
for command in plan_remove(interface, &self.tools) {
self.run(&command)?;
}
Ok(())
}
}
}
#[cfg(not(target_os = "linux"))]
mod executor {
use super::*;
/// Placeholder on platforms where this backend is not implemented.
///
/// The planner and the parsers in this module work everywhere; only
/// applying a configuration is Linux-specific.
#[derive(Debug, Clone)]
pub struct WgToolBackend {
_private: (),
}
impl WgToolBackend {
/// Always fails: this backend drives `ip link ... type wireguard`,
/// which exists on Linux only.
pub fn new() -> Result<Self, PluginError> {
Self::with_tools(Tools::default())
}
/// Always fails, see [`WgToolBackend::new`].
pub fn with_tools(_tools: Tools) -> Result<Self, PluginError> {
Err(PluginError::Unavailable(
"the wg-tools backend is implemented for Linux only".into(),
))
}
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use crate::dataplane::wireguard::config::{InterfaceParams, build_interface};
use crate::dataplane::wireguard::keys::WgSecretKey;
use crate::dataplane::wireguard::overlay::overlay_address;
use crate::identity::{NetworkId, NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId {
NetworkKeys::derive(
&NetworkName::new(name).unwrap(),
&NetworkSecret::from_bytes(vec![4u8; 32]).unwrap(),
)
.network_id()
}
fn sample() -> (NetworkId, InterfaceConfig, WgPublicKey) {
let id = network("plan");
let peer = WgSecretKey::generate().public();
let config = build_interface(
InterfaceParams {
network: id,
name: "tsun0".into(),
private_key: WgSecretKey::generate(),
listen_port: 51820,
mtu: Some(1380),
keepalive: Some(25),
},
[peer],
|_| Some("10.0.0.5:51820".parse().unwrap()),
);
(id, config, peer)
}
#[test]
fn creating_an_interface_plans_every_step_in_order() {
let (_, config, _) = sample();
let plan = plan_apply(&config, None, &Tools::default());
let described: Vec<String> = plan.iter().map(WgCommand::describe).collect();
assert_eq!(described[0], "ip link add dev tsun0 type wireguard");
assert_eq!(described[1], "wg setconf tsun0 /dev/stdin");
assert!(plan[1].stdin.is_some(), "the config goes over stdin");
assert!(described.iter().any(|c| c.starts_with("ip address add")));
assert!(described.contains(&"ip link set mtu 1380 dev tsun0".to_string()));
assert_eq!(described.last().unwrap(), "ip link set up dev tsun0");
}
#[test]
fn updating_an_existing_interface_syncs_instead_of_replacing() {
let (_, config, _) = sample();
let current = config.to_state();
let plan = plan_apply(&config, Some(&current), &Tools::default());
let described: Vec<String> = plan.iter().map(WgCommand::describe).collect();
assert!(
!described.iter().any(|c| c.contains("link add")),
"an existing interface must not be recreated"
);
assert_eq!(described[0], "wg syncconf tsun0 /dev/stdin");
assert!(
!described.iter().any(|c| c.starts_with("ip address add")),
"matching addresses need no change: {described:?}"
);
}
#[test]
fn addresses_that_should_not_be_there_are_removed() {
let (_, config, _) = sample();
let mut current = config.to_state();
current.addresses.push(Cidr {
addr: "192.0.2.1".parse().unwrap(),
prefix_len: 32,
});
let plan = plan_apply(&config, Some(&current), &Tools::default());
let described: Vec<String> = plan.iter().map(WgCommand::describe).collect();
assert!(
described.contains(&"ip address del 192.0.2.1/32 dev tsun0".to_string()),
"{described:?}"
);
}
#[test]
fn nothing_from_the_network_reaches_an_argument_as_text() {
let (id, config, peer) = sample();
let plan = plan_apply(&config, None, &Tools::default());
for command in &plan {
for arg in &command.args {
assert!(
!arg.contains(' ') && !arg.contains(';') && !arg.contains('\n'),
"argument {arg:?} is not a single clean token"
);
}
}
// The peer's key and derived prefix travel in the piped configuration,
// which is a value this crate rendered itself.
let rendered = plan[1].stdin.as_ref().unwrap();
assert!(rendered.contains(&peer.encode()));
assert!(rendered.contains(&Cidr::host(overlay_address(id, &peer)).to_string()));
}
#[test]
fn removal_only_touches_the_named_interface() {
let plan = plan_remove("tsun0", &Tools::default());
assert_eq!(
plan.iter().map(WgCommand::describe).collect::<Vec<_>>(),
vec!["ip link del dev tsun0".to_string()]
);
}
#[test]
fn showconf_output_parses_into_comparable_state() {
let secret = WgSecretKey::generate();
let peer_a = WgSecretKey::generate().public();
let peer_b = WgSecretKey::generate().public();
let text = format!(
"[Interface]\n\
ListenPort = 51821\n\
PrivateKey = {}\n\
\n\
[Peer]\n\
PublicKey = {}\n\
AllowedIPs = fd00::2/128, fd00::3/128\n\
Endpoint = 10.0.0.9:51820\n\
PersistentKeepalive = 25\n\
\n\
[Peer]\n\
PublicKey = {}\n\
AllowedIPs = fd00::4/128\n\
PersistentKeepalive = off\n",
secret.encode().as_str(),
peer_a.encode(),
peer_b.encode(),
);
let state = parse_showconf("tsun0", &text).unwrap();
assert_eq!(state.public_key, secret.public());
assert_eq!(state.listen_port, 51821);
assert_eq!(state.peers.len(), 2);
let a = state
.peers
.iter()
.find(|peer| peer.public_key == peer_a)
.unwrap();
assert_eq!(a.endpoint, Some("10.0.0.9:51820".parse().unwrap()));
assert_eq!(a.allowed_ips.len(), 2);
assert_eq!(a.persistent_keepalive, Some(25));
let b = state
.peers
.iter()
.find(|peer| peer.public_key == peer_b)
.unwrap();
assert_eq!(b.endpoint, None);
assert_eq!(b.persistent_keepalive, None);
}
#[test]
fn malformed_tool_output_is_an_error_not_a_panic() {
assert!(parse_showconf("tsun0", "").is_err());
assert!(parse_showconf("tsun0", "[Interface]\nListenPort = nope\n").is_err());
assert!(parse_showconf("tsun0", "[Peer]\nAllowedIPs = fd00::1/128\n").is_err());
assert!(parse_showconf("tsun0", "[Interface]\nPrivateKey = zzzz\n").is_err());
assert!(parse_ip_addresses("1: tsun0 inet6 not-an-address scope global").is_err());
}
#[test]
fn interface_addresses_parse_and_skip_kernel_managed_ones() {
let text = "3: tsun0 inet6 fd12:3456::1/128 scope global \\ valid_lft forever\n\
3: tsun0 inet6 fd12:3456::/64 scope global \\ valid_lft forever\n\
3: tsun0 inet6 fe80::1/64 scope link \\ valid_lft forever\n";
let addresses = parse_ip_addresses(text).unwrap();
assert_eq!(
addresses.iter().map(Cidr::to_string).collect::<Vec<_>>(),
vec!["fd12:3456::/64".to_string(), "fd12:3456::1/128".to_string()]
);
}
#[test]
fn a_rendered_config_round_trips_through_the_parser() {
let (_, config, _) = sample();
let rendered = config.render();
let parsed = parse_showconf(&config.name, &rendered).unwrap();
let mut expected = config.to_state();
// showconf does not report interface addresses.
expected.addresses.clear();
assert_eq!(parsed, expected);
}
}