Files
tsunagi/crates/tsunagi/src/overlay/provision/mock.rs
T
tsunagiandClaude Opus 5 5bee148497 Move the overlay interface to the system level
One agent, one interface, owned by the agent rather than by a protocol.
That is what makes several protocols able to be live at once: a packet
leaving the interface is routed to whichever peer owns its destination,
over whichever protocol has a link to that peer, and neither protocol
has to hold the address because the agent holds it.

This commit moves the pieces without changing behaviour: provisioning,
the TUN itself, IP header parsing and interface naming are now
crates/tsunagi/src/overlay, and the WireGuard module re-exports them
while its callers are moved over. They were never WireGuard-specific —
netlink, capabilities and `ip tuntap` have nothing to do with the
protocol running on top.

They also get their own error type. An interface that cannot be created
is not a plugin failing, and now that the two belong to different levels
they should not share a word for it.

Routing, addressing and the reduced plugin contract come next.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-21 18:09:57 +01:00

337 lines
10 KiB
Rust

//! An in-memory host, so provisioning is tested without touching this one.
//!
//! [`MockHost`] is a pretend `/sys/class/net`: a test can seed it with the
//! leftovers of a crashed run, or with somebody else's bridge, then check
//! what the provisioner did about it. It is also what the platforms that have
//! no provisioner yet are wired to in their own tests.
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use crate::BoxFuture;
use crate::overlay::OverlayError;
use super::super::config::Cidr;
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
use super::{
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned,
plan_changes,
};
/// A pretend host with interfaces on it.
#[derive(Debug, Clone, Default)]
pub struct MockHost {
links: Arc<Mutex<HashMap<String, InterfaceState>>>,
}
impl MockHost {
/// An empty host.
pub fn new() -> Self {
Self::default()
}
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, InterfaceState>> {
match self.links.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
/// Puts an interface on the host.
pub fn insert(&self, name: impl Into<String>, state: InterfaceState) {
self.lock().insert(name.into(), state);
}
/// Seeds the leftovers of a run that died: a TUN nobody holds open, still
/// carrying whatever addresses it had.
pub fn insert_stale_tun(&self, name: impl Into<String>, addresses: Vec<Cidr>) {
self.insert(
name,
InterfaceState {
kind: LinkKind::Tun,
attached: false,
up: true,
mtu: 1280,
addresses,
},
);
}
/// The state of an interface, if it exists.
pub fn get(&self, name: &str) -> Option<InterfaceState> {
self.lock().get(name).cloned()
}
/// The names currently on the host.
pub fn names(&self) -> Vec<String> {
let mut names: Vec<String> = self.lock().keys().cloned().collect();
names.sort();
names
}
}
/// Applies plans to a [`MockHost`].
#[derive(Debug, Clone)]
pub struct MockProvisioner {
host: MockHost,
ours: Arc<Mutex<Vec<String>>>,
/// The devices handed out, so a test can drive packets through them.
devices: MemoryTunFactory,
/// Set to fail every call, to exercise the error path.
failure: Option<String>,
}
impl Default for MockProvisioner {
fn default() -> Self {
Self::new(MockHost::new())
}
}
impl MockProvisioner {
/// A provisioner over a host.
pub fn new(host: MockHost) -> Self {
Self {
host,
ours: Arc::new(Mutex::new(Vec::new())),
devices: MemoryTunFactory::new(),
failure: None,
}
}
/// A provisioner that refuses everything, with this reason.
pub fn failing(reason: impl Into<String>) -> Self {
Self {
host: MockHost::new(),
ours: Arc::new(Mutex::new(Vec::new())),
devices: MemoryTunFactory::new(),
failure: Some(reason.into()),
}
}
/// The host it applies to.
pub fn host(&self) -> &MockHost {
&self.host
}
/// The device created for an interface name, if any.
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
self.devices.device(name)
}
fn owned(&self) -> std::sync::MutexGuard<'_, Vec<String>> {
match self.ours.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, OverlayError> {
if let Some(reason) = &self.failure {
return Err(OverlayError::Unavailable(reason.clone()));
}
let ours = self.owned().iter().any(|name| name == &plan.name);
let current = self
.host
.get(&plan.name)
.unwrap_or_else(InterfaceState::absent);
let changes = plan_changes(&current, plan, ours)?;
let mut state = current;
if changes.delete_link {
self.host.lock().remove(&plan.name);
state = InterfaceState::absent();
}
if changes.create_link {
state = InterfaceState {
kind: LinkKind::Tun,
// Creating it means holding it open, so it has carrier.
attached: true,
up: false,
mtu: 1500,
addresses: Vec::new(),
};
self.owned().push(plan.name.clone());
}
if let Some(mtu) = changes.set_mtu {
state.mtu = mtu;
}
if changes.bring_up {
state.up = true;
}
state
.addresses
.retain(|addr| !changes.remove.contains(addr));
state.addresses.extend(changes.add.iter().copied());
state.addresses.sort();
state.addresses.dedup();
self.host.insert(plan.name.clone(), state);
Ok(changes)
}
}
impl InterfaceProvisioner for MockProvisioner {
fn name(&self) -> &str {
"mock"
}
fn reconcile<'a>(
&'a self,
plan: &'a InterfacePlan,
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
Box::pin(async move {
let changes = self.apply(plan)?;
let device = if changes.create_link {
Some(
self.devices
.create(TunRequest::bare(plan.name.clone(), plan.mtu))
.await?,
)
} else {
None
};
Ok(Provisioned { changes, device })
})
}
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
Box::pin(async move {
self.host.lock().remove(name);
self.owned().retain(|owned| owned != name);
Ok(())
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
fn v6() -> Cidr {
Cidr {
addr: IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
prefix_len: 64,
}
}
fn v4(last: u8) -> Cidr {
Cidr {
addr: IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)),
prefix_len: 24,
}
}
fn plan(addresses: Vec<Cidr>) -> InterfacePlan {
InterfacePlan::new("tsunmock", 1280, addresses)
}
#[tokio::test]
async fn reconciling_an_empty_host_creates_a_configured_interface() {
let provisioner = MockProvisioner::default();
let plan = plan(vec![v6(), v4(69)]);
let changes = provisioner.reconcile(&plan).await.unwrap().changes;
assert!(changes.create_link);
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(state.kind, LinkKind::Tun);
assert!(state.up);
assert_eq!(state.mtu, 1280);
assert_eq!(state.addresses, vec![v4(69), v6()]);
}
#[tokio::test]
async fn reconciling_is_idempotent() {
let provisioner = MockProvisioner::default();
let plan = plan(vec![v6(), v4(69)]);
provisioner.reconcile(&plan).await.unwrap();
let second = provisioner.reconcile(&plan).await.unwrap().changes;
assert!(second.is_empty(), "{second:?}");
}
#[tokio::test]
async fn a_crashed_run_is_repaired_on_the_next_start() {
let host = MockHost::new();
// What the previous run left: the interface, with the address it had
// been allocated back then.
host.insert_stale_tun("tsunmock", vec![v4(178)]);
let provisioner = MockProvisioner::new(host);
let changes = provisioner
.reconcile(&plan(vec![v6(), v4(69)]))
.await
.unwrap()
.changes;
assert!(changes.delete_link && changes.create_link);
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(
state.addresses,
vec![v4(69), v6()],
"the stale address is gone and the current one is there"
);
assert!(state.attached, "the new interface is held open by us");
}
#[tokio::test]
async fn a_changed_allocation_is_applied_without_recreating_the_interface() {
let provisioner = MockProvisioner::default();
provisioner
.reconcile(&plan(vec![v6(), v4(69)]))
.await
.unwrap();
// The overlay agreed on a different address for us while running.
let changes = provisioner
.reconcile(&plan(vec![v6(), v4(70)]))
.await
.unwrap()
.changes;
assert!(
!changes.delete_link && !changes.create_link,
"recreating would drop every tunnel"
);
assert_eq!(changes.add, vec![v4(70)]);
assert_eq!(changes.remove, vec![v4(69)]);
assert_eq!(
provisioner.host().get("tsunmock").unwrap().addresses,
vec![v4(70), v6()]
);
}
#[tokio::test]
async fn removing_takes_the_interface_off_the_host_and_is_idempotent() {
let provisioner = MockProvisioner::default();
provisioner.reconcile(&plan(vec![v6()])).await.unwrap();
assert_eq!(provisioner.host().names(), vec!["tsunmock".to_string()]);
provisioner.remove("tsunmock").await.unwrap();
assert!(provisioner.host().names().is_empty());
provisioner.remove("tsunmock").await.unwrap();
}
#[tokio::test]
async fn a_foreign_interface_makes_reconciling_fail_and_changes_nothing() {
let host = MockHost::new();
host.insert(
"tsunmock",
InterfaceState {
kind: LinkKind::Foreign("bridge".into()),
attached: true,
up: true,
mtu: 1500,
addresses: vec![v4(1)],
},
);
let provisioner = MockProvisioner::new(host);
assert!(provisioner.reconcile(&plan(vec![v6()])).await.is_err());
let state = provisioner.host().get("tsunmock").unwrap();
assert_eq!(state.kind, LinkKind::Foreign("bridge".into()));
assert_eq!(state.addresses, vec![v4(1)], "left exactly as it was");
}
}