diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index afdb6c2..1a4855f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,8 +38,24 @@ jobs: - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo test --locked --workspace --all-targets - - name: run the example - run: cargo run --locked --example two_agents + - name: run the examples + run: | + cargo run --locked --example two_agents + cargo run --locked --example wireguard_mesh + + # The real wg/ip backend changes the host's network and needs + # CAP_NET_ADMIN, so it is never part of the default job above. It is run on + # demand only. + wireguard-system: + name: wireguard system backend (opt-in) + if: github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: sudo apt-get update && sudo apt-get install -y wireguard-tools + - run: sudo -E $(which cargo) test --locked --test wireguard_system -- --ignored --test-threads=1 docs: name: rustdoc diff --git a/AGENTS.md b/AGENTS.md index dbd564f..c183dc1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,8 +19,16 @@ Keep these separate. Crossing them is the main thing to review for. - **Control plane vs data plane.** iroh carries control messages only. User IP traffic is never tunnelled through it. The core must never parse a plugin's - payload — see `src/dataplane.rs`. Do not advertise WireGuard as available - transport before it exists; tests use an explicitly test-only capability id. + payload — see `src/dataplane/mod.rs`. Only + `src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and + only after bounding every field. A data plane failure must never stop the + control plane. +- **Derived, not claimed.** A WireGuard peer's `AllowedIPs` are always derived + locally from its public key. Never take them from what the peer announces, or + a member can route another member's traffic to itself. +- **Plugins own their system objects.** A plugin creates and removes its own + interface and nothing else. An interface that already exists and is not ours + is refused, never adopted. Never touch routing, DNS or firewall settings. - **Device identity vs network identity.** The iroh endpoint id is the device's public key. `NetworkId` is derived from name + secret only. Never conflate them, and never let one change the other. @@ -80,7 +88,7 @@ Keep these separate. Crossing them is the main thing to review for. | `src/proto/` | framing, message formats, membership handshake | | `src/net.rs` | iroh endpoint adapter and observability snapshots | | `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status | -| `src/dataplane.rs` | the contract future IP plugins implement | +| `src/dataplane/` | the contract IP plugins implement, and the WireGuard plugin | | `tests/` | integration tests; `tests/common/` is the shared harness | Add abstractions only at real substitution or testing boundaries. Do not add a @@ -98,7 +106,11 @@ trait per struct. Prefer one crate with clear modules over many small crates. happen. - The default suite must pass with no internet, no DHT, no public relay, no administrator rights and no changes to OS network settings. Anything needing - the internet stays out of the default set. + the internet, or root, stays out of the default set — the real WireGuard + backend's tests live in `tests/wireguard_system.rs` behind `--ignored`. +- The WireGuard backend may be substituted (`RecordingBackend`). Its key + handling, announcements, derived addressing, configuration builder and + reconciliation may not. - Running several library instances in one process is not a test of several system processes; do not describe it as one. diff --git a/Cargo.lock b/Cargo.lock index 8d19ad6..e206f1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3182,6 +3182,7 @@ dependencies = [ "hkdf", "hmac", "iroh", + "netwatch", "postcard", "rand", "rusqlite", @@ -3193,6 +3194,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "x25519-dalek", "zeroize", ] @@ -3759,6 +3761,17 @@ dependencies = [ "web-sys", ] +[[package]] +name = "x25519-dalek" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" +dependencies = [ + "curve25519-dalek", + "rand_core", + "zeroize", +] + [[package]] name = "yoke" version = "0.8.3" diff --git a/Cargo.toml b/Cargo.toml index 3e32342..c76465a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,6 +28,8 @@ thiserror = "2.0" tracing = "0.1" fs4 = { version = "1.1", features = ["sync"] } directories = "6.0" +x25519-dalek = { version = "3.0.0", features = ["static_secrets"] } +netwatch = "0.19.3" [dev-dependencies] tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] } diff --git a/README.md b/README.md index 6883699..8891c08 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,20 @@ A working library with **real iroh connections** and integration tests: - automatic reconnect with bounded exponential backoff and jitter; - status snapshots, an event stream and honest diagnostics; - configuration restored after a restart; -- correct behaviour when the disposable cache is missing or corrupt. +- correct behaviour when the disposable cache is missing or corrupt; +- a **WireGuard data plane plugin**: its own key per network, deterministic + IPv6 overlay addressing, a full-mesh configuration built locally, and + reconciliation that repairs drift. ### What it deliberately does **not** do -Not implemented, and not pretended to be: WireGuard or any other IP plugin, -Mainline DHT, DNS, routing through intermediate participants, a full CRDT, -dynamically loaded plugins, a system service, a complete CLI, or a local -control socket. Snapshot synchronisation and signed revocations are designed -for but not implemented — see [docs/sync-model.md](docs/sync-model.md). +Not implemented, and not pretended to be: Mainline DHT, DNS, routing through +intermediate participants, a full CRDT, dynamically loaded plugins, a system +service, a complete CLI, or a local control socket. Snapshot synchronisation +and signed revocations are designed for but not implemented — see +[docs/sync-model.md](docs/sync-model.md). The WireGuard plugin's own limits, +including that its system backend is Linux-only, are in +[docs/wireguard.md](docs/wireguard.md#limits-and-future-work). **Only control messages travel over iroh. User IP traffic is not tunnelled through it.** Filtering user traffic is the operating system's and the user's @@ -61,11 +66,21 @@ cargo test --locked --workspace --all-targets The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug` to see agent logs while a test runs. -There is also a runnable demo, which is a demo and not a substitute for the +There are also two runnable demos, which are demos and not substitutes for the tests: ```bash -cargo run --example two_agents +cargo run --example two_agents # control plane only +cargo run --example wireguard_mesh # two agents forming a WireGuard overlay +``` + +Both run with no privileges and change nothing on the host. + +The one part that does change the host's network — the real `wg`/`ip` backend — +is behind `--ignored` and needs Linux, wireguard-tools and `CAP_NET_ADMIN`: + +```bash +sudo -E cargo test --test wireguard_system -- --ignored --test-threads=1 ``` ## Usage @@ -127,12 +142,18 @@ paths; tests always use temporary directories. | `state.sqlite` | device identity, network configuration, hostname | clear error, never reset | | `cache.sqlite` | address hints and other recoverable data | discarded and recreated | +The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever +its configuration points, because plugin keys are neither the iroh identity nor +the network secret. + One state directory belongs to one live agent, enforced with a real OS file lock rather than an existence check. ## Documentation - [docs/architecture.md](docs/architecture.md) — module boundaries and runtime. +- [docs/wireguard.md](docs/wireguard.md) — the WireGuard plugin: overlay + addressing, announcements, backends, reconciliation. - [docs/protocol.md](docs/protocol.md) — identity derivation, framing, handshake. - [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and what is deliberately not built yet. diff --git a/docs/architecture.md b/docs/architecture.md index 6f5f1c2..ca5f85f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,12 +12,16 @@ authentication, participant announcements, capability exchange and — later — state synchronisation and delivery of IP-plugin data. **Data plane.** Separate plugins create IP connectivity. WireGuard is the first -planned one; none exists yet. Plugin keys, configuration and lifecycle are -separate from iroh identity and from the network secret. The core moves an -opaque, bounded payload and never parses it. +one and is implemented — see [wireguard.md](wireguard.md). Plugin keys, +configuration and lifecycle are separate from iroh identity and from the +network secret. The core moves an opaque, bounded payload and never parses it. Only control messages travel over iroh. User IP traffic is not tunnelled -through it. +through it; WireGuard packets travel over WireGuard's own UDP sockets. + +A plugin talks to the core through three narrow hooks — `on_network_activated`, +a `PluginContext` for re-announcements and error reports, and a bounded +`shutdown` — so the core never learns anything protocol-specific. A data plane failure never stops the daemon: the control plane keeps running and the agent stays manageable. @@ -32,11 +36,12 @@ and the agent stays manageable. | `proto` | message format, handshake, membership proof, protocol limits | | `agent` | agent and per-network lifecycle, reconnect, in-process message routing | | `storage` | mandatory state and the separately recoverable cache | -| `dataplane` | the minimal contract future IP plugins implement | +| `dataplane` | the contract IP plugins implement, plus the WireGuard plugin | Abstractions exist only where something is really substituted or really needs -isolating for tests: `NetworkDiscovery` and `IpPlugin`. Everything else is a -concrete type. +isolating for tests: `NetworkDiscovery`, `IpPlugin`, and `WireguardBackend` +(which is what lets the plugin be tested in full without root). Everything else +is a concrete type. ## Runtime shape diff --git a/docs/sync-model.md b/docs/sync-model.md index e040d2b..4c11f11 100644 --- a/docs/sync-model.md +++ b/docs/sync-model.md @@ -2,7 +2,9 @@ **Nothing in this document is implemented.** The proof of concept exchanges hostname and capability announcements over live sessions and keeps no -replicated history. This file records the intended direction so the module +replicated history. That is also why WireGuard peer membership is +session-scoped today: a peer disappears from the overlay configuration when its +control session ends, because there is no agreed durable state to keep it. This file records the intended direction so the module boundaries in [architecture.md](architecture.md) stay compatible with it, and so nobody mistakes the current announcements for synchronisation. diff --git a/docs/testing.md b/docs/testing.md index 925968c..7e0a9ed 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -38,6 +38,19 @@ of several system processes, and is not presented as one. | 9 | wrong version, a message before authentication, a proof replayed on another connection, an oversized frame and a `Hello` for an inactive network are all rejected without taking the agent down | `tests/authentication.rs` | | 10 | a second agent on the same state directory gets a clear error; after a clean stop the directory reopens; shutdown ends background tasks and refuses further work; independent agents coexist in one process | `tests/resilience.rs` | +`tests/wireguard.rs` drives the WireGuard plugin over real iroh connections +with the in-memory backend: a pair and a three-agent mesh converge to `N - 1` +peers with locally derived `AllowedIPs`, a departing peer is removed, two +networks get separate interfaces, keys and overlays, reconciliation repairs a +configuration edited by hand, a backend failure leaves the control plane +untouched, a restart keeps the WireGuard identity, shutdown removes every +interface, a member claiming another member's overlay address is rejected, and +the core carries the payload without interpreting it. + +`tests/wireguard_system.rs` exercises the real `wg`/`ip` backend. It is +**ignored by default** because it changes the host's network and needs Linux, +wireguard-tools and `CAP_NET_ADMIN`. + `tests/discovery.rs` covers the discovery contract itself: a static bootstrap candidate is enough to join, several backends compose, entries are withdrawn when a network stops, and a forgotten network stays forgotten across a restart. @@ -46,6 +59,12 @@ Unit tests in `src/proto/handshake.rs` cover the transcript construction itself: role separation, channel binding, identity and network binding, unambiguous encoding, and rejection under the wrong key. +Unit tests in `src/dataplane/wireguard/` cover the parts that would otherwise +need root: key clamping against the RFC 7748 vector, overlay derivation, +announcement validation including the address-hijack attempt, configuration +building and rendering, the exact command plan the real backend would run, and +parsing `wg showconf` and `ip address show` output. + `tests/end_to_end.rs` is the vertical slice: persistent identity → network space → discovery → iroh → authentication → message exchange. diff --git a/docs/threat-model.md b/docs/threat-model.md index e5c0469..1e3e807 100644 --- a/docs/threat-model.md +++ b/docs/threat-model.md @@ -19,6 +19,10 @@ Read this before relying on anything here. The protocol is in messages for network B, even over a shared physical connection. - **Confidentiality and integrity in transit.** Provided by QUIC/TLS. This crate adds no encryption of its own. +- **Overlay address ownership.** A WireGuard peer's `AllowedIPs` are derived + from its public key, not taken from its announcement, so a member cannot + claim another member's overlay address and receive its traffic. See + [wireguard.md](wireguard.md#deterministic-overlay-addressing). - **Resource bounds.** Frame lengths are validated before allocation; strings, lists, queues, concurrent dials and in-flight handshakes are all bounded; handshakes, dials and writes have timeouts. @@ -45,8 +49,20 @@ Read this before relying on anything here. The protocol is in permissions are owner-only where the platform supports it, and the state directory takes an ownership lock, but neither defends against a user who can read the file or against malware running as that user. -- **User IP traffic.** Not carried here at all. Filtering it is the operating - system's and the user's job. +- **User IP traffic.** Carried by the WireGuard plugin, not by iroh, and + encrypted by WireGuard itself. *Filtering* it is still the operating system's + and the user's job: the plugin creates connectivity between members and does + not police what flows over it. +- **Overlay address squatting.** A member can mint many WireGuard keys and + therefore occupy many overlay addresses. It cannot pick which ones, but it + can consume them and appear as many participants. +- **Plugin keys on disk.** The WireGuard private keys live in the plugin's own + `wireguard.sqlite`, owner-only where the platform supports it. Copying that + file copies this agent's overlay identity, exactly as copying `state.sqlite` + copies its control plane identity. +- **What the data plane does not police.** The plugin sets `AllowedIPs` per + peer, which stops a member impersonating another member's overlay address. + It does not stop a member sending whatever it likes *from its own* address. - **Denial of service.** Bounds and timeouts stop trivial resource exhaustion from a single peer. They do not make the agent resistant to a determined attacker who knows the secret, and no rate limiting per identity exists yet. diff --git a/docs/wireguard.md b/docs/wireguard.md new file mode 100644 index 0000000..6f455dd --- /dev/null +++ b/docs/wireguard.md @@ -0,0 +1,196 @@ +# The WireGuard plugin + +WireGuard is the first IP plugin. It creates real IP connectivity between +participants while the control plane keeps doing its own job: deciding who is +in the network and carrying each participant's opaque announcement. + +Module boundaries are in [architecture.md](architecture.md), the control +protocol in [protocol.md](protocol.md), and the security consequences in +[threat-model.md](threat-model.md). + +## What stays separate + +| | | +|---|---| +| **User IP traffic** | Never goes through iroh. iroh carries announcements; packets travel over WireGuard's own UDP sockets. | +| **Addresses** | An iroh address is an address for iroh. The plugin gathers and advertises its own reachability. | +| **Payloads** | The core moves a bounded opaque blob. Only `dataplane::wireguard::announcement` interprets it. | +| **Keys** | One WireGuard key per network, in the plugin's own store. Unrelated to the iroh device key and to the network secret. | +| **Failures** | A data plane error is reported and retried. The control plane keeps running and the agent stays manageable. | + +## Deterministic overlay addressing + +A mesh with no coordinator cannot hand out addresses, so everyone derives their +own. 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 +``` + +with `domain = "tsunagi-wireguard-overlay-v1"` and `LP(x) = u32_be(len(x)) || x`, +the same unambiguous encoding the rest of the project uses. + +Two consequences matter: + +* every member of a network derives the **same `/64`**, so the overlay is one + subnet that nobody had to allocate; +* a member's address is bound to its WireGuard public key, so + **`AllowedIPs` are derived locally and never taken from what a peer claims**. + +That second point is the plugin's central security property. A participant who +knows the network secret can mint as many WireGuard keys — and therefore as +many overlay addresses — as it likes, but it cannot choose to collide with an +existing member's address without finding a hash preimage. An announcement +whose claimed address does not match the derivation is rejected outright. + +## The announcement + +Carried as the opaque `PluginCapability { protocol: "wireguard", .. }` payload, +encoded with postcard: + +| field | meaning | +|---|---| +| `version` | announcement format version, currently 1 | +| `public_key` | the peer's X25519 WireGuard key | +| `listen_port` | the UDP port its interface listens on | +| `endpoints` | reachability the plugin gathered for itself, at most 8 | +| `overlay_address` | what the peer believes its address is — cross-checked, never used | + +Validation, all before anything reaches a configuration: the version must +match, the key must not be zero and must not be our own, the port must not be +zero, the endpoint list must be within bounds, unusable endpoints +(unspecified, multicast, broadcast, documentation, port zero) are dropped, and +the claimed overlay address must equal the derived one. + +## Building the configuration + +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 a configuration to anybody else and no participant is +authoritative. + +* **Interface name** — `prefix + base32(network_id)`, truncated to the + platform's 15 characters. Stable across restarts. Two agents on one host in + the same network need different prefixes. +* **Port** — `PortPolicy::Derived` picks a stable port from the network id + inside a range, so a peer's cached endpoint keeps working across restarts and + two networks on one host do not collide. `PortPolicy::Fixed` pins it. +* **Addresses** — the agent's own `/128` plus the shared `/64`. +* **Peer entries** — public key, derived `AllowedIPs`, the peer's first usable + advertised endpoint, and a keepalive. + +Nothing free-form from the network reaches a command argument or a +configuration directive: peer keys, endpoints, prefixes and keepalives are +typed values that the plugin re-serialises itself. + +## Backends + +The plugin computes *what* the interface should look like; a backend makes it +so. Splitting them is what keeps every interesting decision testable without +root. + +* **`RecordingBackend`** — applies configurations in memory. The default test + suite and the example use it, so neither needs privileges nor touches the + host. It can also be told to fail, or to report drift. +* **`WgToolBackend`** — drives the real `wg` and `ip` tools. Linux only, + requires `CAP_NET_ADMIN`. It is split into a **pure planner** and pure + parsers, which are unit tested on every platform, plus a thin executor. The + WireGuard configuration is piped to `wg setconf` / `wg syncconf` on standard + input, so the private key never reaches the filesystem. + +Ownership is explicit: the plugin creates the interface and the plugin removes +it. 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. It changes no routing, DNS or firewall settings. + +## Reconciliation + +The plugin reconciles on every change — a peer announcement, a peer leaving — +coalesced over a short debounce, and again on a timer. Each pass reads the +interface back, compares it with the desired state, and applies only if they +differ. A configuration edited by hand is therefore put back the way it should +be, which is exactly what `reconciliation_repairs_a_configuration_edited_by_hand` +in `tests/wireguard.rs` checks. + +Deactivating a network removes its interface but **keeps its key**, so coming +back later keeps the same overlay address. Agent shutdown removes every +interface the plugin created. + +## What the plugin needs from the core + +Three small additions to the `IpPlugin` contract, all generic rather than +WireGuard-specific: + +* `on_network_activated` — prepare per-network state before any peer appears; +* `attach(PluginContext)` — a handle to ask for a re-announcement when the + plugin's own capability changes, and to report an error from its own tasks; +* `shutdown` — remove system objects during a bounded agent shutdown. + +Errors reported through the context are counted by the owning network's +runtime, so `NetworkMetrics::plugin_errors` and `Event::PluginError` always +agree. + +## Using it + +```rust,no_run +use std::sync::Arc; +use std::time::Duration; +use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; +use tsunagi::dataplane::IpPlugin; +use tsunagi::dataplane::wireguard::{WgToolBackend, WireguardConfig, WireguardPlugin}; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::{Agent, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + let paths = StoragePaths::user_default()?; + + // The plugin's own state, separate from the agent's. + let wireguard = WireguardConfig::new(paths.state_dir.join("wireguard")); + let backend = WgToolBackend::new().expect("Linux with wg and CAP_NET_ADMIN"); + let plugin = WireguardPlugin::open(wireguard, Arc::new(backend)) + .await + .expect("wireguard plugin"); + + let agent = Agent::spawn( + AgentConfig::new(paths) + .with_transport(TransportPolicy::N0Defaults) + .with_plugin(plugin.clone() as Arc), + ) + .await?; + + let network = agent + .join_network(&NetworkName::new("kitchen-table")?, &NetworkSecret::generate()) + .await?; + + if let Some(view) = plugin.overview(network) { + println!("{} on {} at {}", view.interface, view.overlay_prefix, view.overlay_address); + } + + tokio::time::sleep(Duration::from_secs(60)).await; + agent.shutdown().await; // removes the interface + Ok(()) +} +``` + +There is a runnable version in `examples/wireguard_mesh.rs`, which uses the +in-memory backend by default and the real one with `--real`. + +## Limits and future work + +* **Full mesh only.** Every member configures every other member. Routing + through an intermediate participant is not implemented. +* **No IPv4 overlay.** Addressing is IPv6 ULA, because it can be derived + collision-free. An IPv4 overlay would need an allocator, which needs the + agreed state described in [sync-model.md](sync-model.md). +* **Peer membership is session-scoped.** A peer disappears from the + configuration when its control session ends. Persisting membership across a + long absence is part of the same future work. +* **`WgToolBackend` is Linux only.** A netlink backend, and backends for macOS + and Windows, are not implemented. `WgToolBackend::new()` fails with a clear + message elsewhere. +* **No MTU or path discovery.** The MTU is a configured constant. +* **The real backend is not exercised by the default suite.** It needs root, + so its tests live in `tests/wireguard_system.rs` behind `--ignored`. diff --git a/examples/wireguard_mesh.rs b/examples/wireguard_mesh.rs new file mode 100644 index 0000000..98545ea --- /dev/null +++ b/examples/wireguard_mesh.rs @@ -0,0 +1,202 @@ +//! Two agents forming a WireGuard overlay, printed step by step. +//! +//! ```text +//! cargo run --example wireguard_mesh +//! ``` +//! +//! By default it uses the in-memory backend, so it needs no privileges and +//! changes nothing on the host: it shows the configuration each agent *would* +//! apply. Pass `--real` to drive the actual `wg` and `ip` tools instead, which +//! needs Linux and `CAP_NET_ADMIN`. + +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; +use tsunagi::dataplane::IpPlugin; +use tsunagi::dataplane::wireguard::{ + AdvertisePolicy, PortPolicy, RecordingBackend, WireguardBackend, WireguardConfig, + WireguardPlugin, +}; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkName, NetworkSecret}; +use tsunagi::{Agent, NetworkId, Result}; + +struct Node { + agent: Agent, + plugin: Arc, + backend: Option, +} + +async fn start( + root: &std::path::Path, + discovery: &SharedMemoryDiscovery, + prefix: &str, + advertise: IpAddr, + real: bool, +) -> Result { + let recording = (!real).then(RecordingBackend::new); + let backend: Arc = match &recording { + Some(backend) => Arc::new(backend.clone()), + None => Arc::new( + tsunagi::dataplane::wireguard::WgToolBackend::new() + .map_err(|err| tsunagi::Error::Discovery(err.to_string()))?, + ), + }; + + let wireguard = WireguardConfig::new(root.join("wireguard")) + .with_interface_prefix(prefix) + .with_advertise(AdvertisePolicy::Explicit(vec![advertise])) + .with_ports(PortPolicy::Fixed(if real { 51820 } else { 51821 })); + let plugin = WireguardPlugin::open(wireguard, backend) + .await + .map_err(|err| tsunagi::Error::Discovery(err.to_string()))?; + + let agent = Agent::spawn( + AgentConfig::new(StoragePaths::under(root)) + .with_transport(TransportPolicy::LocalOnly) + .with_loopback_bind() + .with_discovery(Arc::new(discovery.clone())) + .with_discovery_interval(Duration::from_millis(200)) + .with_plugin(plugin.clone() as Arc), + ) + .await?; + + Ok(Node { + agent, + plugin, + backend: recording, + }) +} + +fn report(label: &str, node: &Node, network: NetworkId) { + let Some(view) = node.plugin.overview(network) else { + println!("{label}: the plugin has not prepared this network yet"); + return; + }; + println!("\n{label}"); + println!(" interface {}", view.interface); + println!(" public key {}", view.public_key); + println!( + " overlay {} in {}", + view.overlay_address, view.overlay_prefix + ); + println!(" listening on :{}", view.listen_port); + println!(" advertising {:?}", view.advertised); + for peer in &view.peers { + println!( + " peer {} -> {} via {:?}", + peer.public_key.fmt_short(), + peer.overlay_address, + peer.endpoint + ); + } + if let Some(backend) = &node.backend + && let Some(state) = backend.state(&view.interface) + { + println!(" applied {} peer(s)", state.peers.len()); + for peer in &state.peers { + println!( + " AllowedIPs for {} = {:?}", + peer.public_key.fmt_short(), + peer.allowed_ips + .iter() + .map(ToString::to_string) + .collect::>() + ); + } + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")), + ) + .init(); + + let real = std::env::args().any(|arg| arg == "--real"); + if real { + println!("driving the real wg/ip tools; this needs Linux and CAP_NET_ADMIN\n"); + } else { + println!("using the in-memory backend; nothing on this host is changed\n"); + } + + let root = match tempfile::TempDir::new() { + Ok(root) => root, + Err(err) => { + eprintln!("cannot create a temporary directory: {err}"); + return Ok(()); + } + }; + let discovery = SharedMemoryDiscovery::new(); + + let alice = start( + &root.path().join("alice"), + &discovery, + "wga", + "10.88.0.1".parse().unwrap_or(IpAddr::from([10, 88, 0, 1])), + real, + ) + .await?; + let bob = start( + &root.path().join("bob"), + &discovery, + "wgb", + "10.88.0.2".parse().unwrap_or(IpAddr::from([10, 88, 0, 2])), + real, + ) + .await?; + + let name = NetworkName::new("wireguard-demo")?; + let secret = NetworkSecret::generate(); + println!( + "network secret (keep it safe): {}", + secret.encode().as_str() + ); + + let network = alice.agent.join_network(&name, &secret).await?; + bob.agent.join_network(&name, &secret).await?; + println!("network id: {network}"); + + // Wait until both sides configured one peer, under a bounded deadline. + let deadline = std::time::Instant::now() + Duration::from_secs(20); + loop { + let ready = [&alice, &bob].iter().all(|node| { + let Some(view) = node.plugin.overview(network) else { + return false; + }; + if view.peers.len() != 1 { + return false; + } + // With the in-memory backend we can also wait for the + // configuration to actually be applied. + match &node.backend { + Some(backend) => backend + .state(&view.interface) + .map(|state| state.peers.len() == 1) + .unwrap_or(false), + None => true, + } + }); + if ready { + break; + } + if std::time::Instant::now() > deadline { + println!("\nthe overlay did not converge in time"); + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + + report("alice", &alice, network); + report("bob", &bob, network); + + println!("\nshutting down; the plugin removes what it created"); + alice.agent.shutdown().await; + bob.agent.shutdown().await; + Ok(()) +} diff --git a/src/agent/mod.rs b/src/agent/mod.rs index 820959b..123940e 100644 --- a/src/agent/mod.rs +++ b/src/agent/mod.rs @@ -37,6 +37,7 @@ use tokio::sync::{RwLock, broadcast, mpsc, oneshot}; use tokio::task::JoinHandle; use crate::config::{AgentConfig, Limits}; +use crate::dataplane::{PluginContext, PluginRequest}; use crate::error::{Error, Result}; use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret}; use crate::net::EndpointAdapter; @@ -80,6 +81,7 @@ struct Inner { networks: RwLock>, shutdown: Shutdown, accept_task: std::sync::Mutex>>, + plugin_task: std::sync::Mutex>>, } impl Drop for Inner { @@ -87,10 +89,12 @@ impl Drop for Inner { // Nothing here awaits; this is only a safety net for a handle that was // dropped without an explicit shutdown. self.shutdown.trigger(); - if let Ok(mut guard) = self.accept_task.lock() - && let Some(task) = guard.take() - { - task.abort(); + for guard in [&self.accept_task, &self.plugin_task] { + if let Ok(mut guard) = guard.lock() + && let Some(task) = guard.take() + { + task.abort(); + } } } } @@ -122,6 +126,7 @@ impl Agent { networks: RwLock::new(HashMap::new()), shutdown: Shutdown::new(), accept_task: std::sync::Mutex::new(None), + plugin_task: std::sync::Mutex::new(None), config, }); @@ -134,6 +139,21 @@ impl Agent { *guard = Some(accept); } + // Plugins get a handle to ask for re-announcements and report errors. + // A bounded queue keeps a noisy plugin from growing memory without + // bound; overflow drops the request rather than stalling the plugin. + if !inner.config.plugins.is_empty() { + let (plugin_tx, plugin_rx) = mpsc::channel(64); + let context = PluginContext::new(plugin_tx); + for plugin in &inner.config.plugins { + plugin.attach(context.clone()); + } + let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx)); + if let Ok(mut guard) = inner.plugin_task.lock() { + *guard = Some(task); + } + } + let agent = Self { inner }; for stored in agent.inner.storage.list_networks().await? { @@ -241,6 +261,10 @@ impl Agent { networks.insert(network_id, handle); drop(networks); + for plugin in &self.inner.config.plugins { + plugin.on_network_activated(network_id); + } + let _ = self.inner.events.send(Event::NetworkActivated { network: network_id, }); @@ -387,6 +411,15 @@ impl Agent { }) } + /// Resends this agent's announcement to every peer of a network. + /// + /// Plugins normally trigger this themselves through + /// [`crate::dataplane::PluginContext::request_reannounce`] when their + /// capability changes. + pub async fn reannounce(&self, network_id: NetworkId) -> Result<()> { + self.command(network_id, NetCommand::Reannounce).await + } + /// Asks one network to re-run discovery and re-evaluate dials right now. /// /// Call this when the host's network environment changed. Platform wake-up @@ -426,14 +459,25 @@ impl Agent { self.inner.adapter.close().await; - let task = self - .inner - .accept_task - .lock() - .ok() - .and_then(|mut guard| guard.take()); - if let Some(task) = task { - let _ = task.await; + for handle in [&self.inner.accept_task, &self.inner.plugin_task] { + let task = handle.lock().ok().and_then(|mut guard| guard.take()); + if let Some(task) = task { + let _ = task.await; + } + } + + // Plugins remove whatever system objects they created. A plugin that + // misbehaves here must not hold up the agent, so this is bounded. + for plugin in &self.inner.config.plugins { + if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown()) + .await + .is_err() + { + tracing::warn!( + protocol = plugin.protocol_id(), + "plugin did not shut down in time" + ); + } } // Release the directory so another instance can claim it right away. @@ -452,6 +496,77 @@ impl Agent { } } +/// How long each plugin gets to tear itself down during agent shutdown. +const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10); + +/// Serves requests plugins make of the agent. +/// +/// Holds only a weak reference, so it exits once the agent is dropped. +async fn plugin_request_loop(weak: Weak, mut requests: mpsc::Receiver) { + let Some(inner) = weak.upgrade() else { + return; + }; + let shutdown = inner.shutdown.clone(); + drop(inner); + + loop { + let request = tokio::select! { + biased; + _ = shutdown.wait() => break, + request = requests.recv() => match request { + Some(request) => request, + None => break, + }, + }; + + let Some(inner) = weak.upgrade() else { + break; + }; + + match request { + PluginRequest::Reannounce(network) => { + let sender = { + let networks = inner.networks.read().await; + networks.get(&network).map(|handle| handle.commands.clone()) + }; + // A plugin asking about a network that is no longer active is + // normal, not an error. + if let Some(sender) = sender { + let _ = sender.send(NetCommand::Reannounce).await; + } + } + PluginRequest::Error { + network, + protocol, + reason, + } => { + let sender = { + let networks = inner.networks.read().await; + networks.get(&network).map(|handle| handle.commands.clone()) + }; + match sender { + // The runtime owns this network's counters, so the error + // is counted and published in one place. + Some(sender) => { + let _ = sender + .send(NetCommand::PluginError { protocol, reason }) + .await; + } + // The network is gone; there is nothing to count it + // against, but the report is still worth publishing. + None => { + let _ = inner.events.send(Event::PluginError { + network, + protocol, + reason, + }); + } + } + } + } + } +} + /// Accepts inbound connections and routes authenticated sessions to networks. /// /// Holds only a weak reference, so dropping every [`Agent`] handle lets the diff --git a/src/agent/network.rs b/src/agent/network.rs index c140d09..efb5c68 100644 --- a/src/agent/network.rs +++ b/src/agent/network.rs @@ -54,6 +54,15 @@ pub(crate) enum NetCommand { reply: oneshot::Sender>, }, Recheck, + /// Resend this agent's announcement to every peer of this network. + Reannounce, + /// An IP plugin reported an error from one of its own tasks. + PluginError { + /// Plugin protocol id. + protocol: String, + /// Human readable reason, free of secrets. + reason: String, + }, } impl std::fmt::Debug for NetCommand { @@ -66,6 +75,8 @@ impl std::fmt::Debug for NetCommand { NetCommand::Broadcast { message, .. } => write!(f, "Broadcast({})", kind(message)), NetCommand::Status { .. } => f.write_str("Status"), NetCommand::Recheck => f.write_str("Recheck"), + NetCommand::Reannounce => f.write_str("Reannounce"), + NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"), } } } @@ -270,6 +281,31 @@ impl Runtime { let _ = reply.send(Box::new(self.status())); } NetCommand::Recheck => self.discovery_round().await, + NetCommand::Reannounce => self.reannounce(), + NetCommand::PluginError { protocol, reason } => { + // Counted here so that the per-network metric and the event + // always agree, wherever the error came from. + self.metrics.plugin_errors += 1; + self.emit(Event::PluginError { + network: self.network_id, + protocol, + reason, + }); + } + } + } + + /// Rebuilds this agent's announcement and pushes it to every session. + /// + /// Used when a plugin's capability changed, so peers do not have to wait + /// for a reconnect to learn about it. + fn reannounce(&mut self) { + let announcement = ControlMessage::Announce(self.local_announcement()); + let peers: Vec = self.sessions.keys().copied().collect(); + for peer in peers { + if let Err(err) = self.send_to(peer, announcement.clone()) { + tracing::debug!(%err, "could not queue re-announcement"); + } } } diff --git a/src/dataplane.rs b/src/dataplane/mod.rs similarity index 60% rename from src/dataplane.rs rename to src/dataplane/mod.rs index 9d4ee86..d9a6fcf 100644 --- a/src/dataplane.rs +++ b/src/dataplane/mod.rs @@ -19,10 +19,14 @@ //! A data plane failure never stops the daemon: errors returned here are //! recorded and surfaced, the control plane keeps running. +pub mod wireguard; + use std::sync::Arc; use iroh::EndpointId; +use tokio::sync::mpsc; +use crate::BoxFuture; use crate::identity::NetworkId; /// Maximum length of a plugin protocol identifier. @@ -60,7 +64,92 @@ pub enum PluginError { Other(String), } -/// The minimal contract a future IP plugin implements. +/// A request a plugin makes of the agent that owns it. +#[derive(Debug)] +pub(crate) enum PluginRequest { + /// Re-send this agent's announcement to every peer of a network. + Reannounce(NetworkId), + /// Surface a plugin error on the agent's event stream. + Error { + /// Network the error is scoped to. + network: NetworkId, + /// Plugin protocol id. + protocol: String, + /// Human readable reason, free of secrets. + reason: String, + }, +} + +/// The agent-side handle a plugin is given when it is attached. +/// +/// It is deliberately tiny: a plugin may ask for its announcement to be resent +/// and may report an error. It cannot reach into agent state, cannot send +/// arbitrary control messages and knows nothing about sessions. +/// +/// All calls are non-blocking. If the agent is gone or its queue is full the +/// request is dropped rather than stalling the plugin. +#[derive(Clone)] +pub struct PluginContext { + sender: Option>, +} + +impl PluginContext { + pub(crate) fn new(sender: mpsc::Sender) -> Self { + Self { + sender: Some(sender), + } + } + + /// A context that discards everything, for plugins used outside an agent. + pub fn detached() -> Self { + Self { sender: None } + } + + fn send(&self, request: PluginRequest) { + let Some(sender) = &self.sender else { + return; + }; + if let Err(err) = sender.try_send(request) { + tracing::debug!(%err, "dropping plugin request"); + } + } + + /// Asks the agent to resend this agent's announcement in `network`. + /// + /// A plugin calls this when its own capability changed — it finished + /// starting up, its keys or reachability changed — so that peers learn the + /// new value without waiting for a reconnect. + pub fn request_reannounce(&self, network: NetworkId) { + self.send(PluginRequest::Reannounce(network)); + } + + /// Reports a plugin error on the agent's event stream. + /// + /// Plugin work happens in the plugin's own tasks, so errors cannot always + /// be returned from a trait call. They are never fatal for the agent. + pub fn report_error( + &self, + network: NetworkId, + protocol: impl Into, + reason: impl Into, + ) { + self.send(PluginRequest::Error { + network, + protocol: protocol.into(), + reason: reason.into(), + }); + } +} + +impl std::fmt::Debug for PluginContext { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PluginContext") + .field("attached", &self.sender.is_some()) + .finish() + } +} + +/// The contract an IP plugin implements. /// /// Implementations must be cheap and non-blocking: the agent calls them from /// its runtime tasks. Anything slow belongs in the plugin's own tasks. @@ -70,6 +159,14 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static { /// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes. fn protocol_id(&self) -> &str; + /// Called once, when the agent starts, before any network is activated. + /// + /// The plugin keeps the context to ask for re-announcements and to report + /// errors that happen in its own tasks. + fn attach(&self, context: PluginContext) { + let _ = context; + } + /// Produces this agent's announcement for a given network. /// /// Returning `Ok(None)` means "nothing to announce right now", which is @@ -79,6 +176,14 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static { network: NetworkId, ) -> std::result::Result, PluginError>; + /// Called when a network is activated locally, before any peer appears. + /// + /// A plugin uses it to get its per-network state ready, so that the first + /// announcement already carries its capability. + fn on_network_activated(&self, network: NetworkId) { + let _ = network; + } + /// Called when a peer announces a capability for this plugin's protocol. /// /// The core has already bounded the payload size but has not interpreted it. @@ -95,7 +200,16 @@ pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static { /// Called when a network is deactivated locally. /// /// This is a local deactivation, not a signed revocation of membership. + /// The plugin is expected to remove whatever it created for that network. fn on_network_deactivated(&self, network: NetworkId); + + /// Called once when the agent shuts down. + /// + /// The plugin removes the system objects it created and stops its tasks. + /// It must be bounded: the agent awaits it during shutdown. + fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> { + Box::pin(async {}) + } } /// A shared handle to a plugin. diff --git a/src/dataplane/wireguard/announcement.rs b/src/dataplane/wireguard/announcement.rs new file mode 100644 index 0000000..e350b8c --- /dev/null +++ b/src/dataplane/wireguard/announcement.rs @@ -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, + /// 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, + /// 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 { + self.endpoints.first().copied() + } +} + +impl WgAnnouncement { + /// Builds this agent's announcement. + pub fn new( + network: NetworkId, + public_key: &WgPublicKey, + listen_port: u16, + endpoints: Vec, + ) -> 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, 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 { + 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 { + 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 = 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() + ); + } +} diff --git a/src/dataplane/wireguard/backend.rs b/src/dataplane/wireguard/backend.rs new file mode 100644 index 0000000..5406123 --- /dev/null +++ b/src/dataplane/wireguard/backend.rs @@ -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, 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>, +} + +#[derive(Debug, Default)] +struct Recorded { + interfaces: HashMap, + calls: Vec, + fail_next_apply: Option, +} + +impl RecordingBackend { + /// Creates an empty backend. + pub fn new() -> Self { + Self::default() + } + + fn with(&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 { + self.with(|recorded| recorded.interfaces.get(interface).cloned()) + } + + /// Every interface currently configured. + pub fn interfaces(&self) -> Vec { + self.with(|recorded| { + let mut names: Vec = recorded.interfaces.keys().cloned().collect(); + names.sort(); + names + }) + } + + /// Everything the backend was asked to do, in order. + pub fn calls(&self) -> Vec { + 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) { + 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, 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); + } +} diff --git a/src/dataplane/wireguard/config.rs b/src/dataplane/wireguard/config.rs new file mode 100644 index 0000000..57ab6c8 --- /dev/null +++ b/src/dataplane/wireguard/config.rs @@ -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 { + 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, + /// 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, + /// Keepalive interval, needed to hold a NAT mapping open. + pub persistent_keepalive: Option, +} + +/// 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, + /// Interface MTU, when one is configured. + pub mtu: Option, + /// Remote participants. + pub peers: Vec, +} + +/// 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, + /// Peers currently configured, sorted by public key. + pub peers: Vec, +} + +/// 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, + /// Allowed prefixes currently configured, sorted. + pub allowed_ips: Vec, + /// Keepalive currently configured. + pub persistent_keepalive: Option, +} + +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 { + 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 = 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 { + 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 { + 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, + /// Keepalive applied to every peer. + pub keepalive: Option, +} + +/// 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, + endpoints: impl Fn(&WgPublicKey) -> Option, +) -> 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 = 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 = (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 = (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::>(), + |_| 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| { + 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)) + ); + } +} diff --git a/src/dataplane/wireguard/keys.rs b/src/dataplane/wireguard/keys.rs new file mode 100644 index 0000000..a6f0ab6 --- /dev/null +++ b/src/dataplane/wireguard/keys.rs @@ -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 { + 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 { + 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", &"") + .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("")); + assert!(!rendered.contains(secret.encode().as_str())); + } +} diff --git a/src/dataplane/wireguard/mod.rs b/src/dataplane/wireguard/mod.rs new file mode 100644 index 0000000..9b7529d --- /dev/null +++ b/src/dataplane/wireguard/mod.rs @@ -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}; diff --git a/src/dataplane/wireguard/overlay.rs b/src/dataplane/wireguard/overlay.rs new file mode 100644 index 0000000..33b05bf --- /dev/null +++ b/src/dataplane/wireguard/overlay.rs @@ -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, 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]); + } + } +} diff --git a/src/dataplane/wireguard/plugin.rs b/src/dataplane/wireguard/plugin.rs new file mode 100644 index 0000000..4c88187 --- /dev/null +++ b/src/dataplane/wireguard/plugin.rs @@ -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), + /// 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, + /// Interface MTU. + pub mtu: Option, + /// 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) -> 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) -> 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, + /// Peers whose announcements were accepted. + pub peers: Vec, +} + +/// 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, +} + +#[derive(Debug)] +struct NetworkState { + key: WgSecretKey, + interface: String, + listen_port: u16, + advertised: Vec, + peers: HashMap, +} + +#[derive(Debug, Default)] +struct Shared { + networks: HashMap, +} + +#[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, + store: WgKeyStore, + shared: Mutex, + context: OnceLock, +} + +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, + commands: mpsc::Sender, + task: Mutex>>, +} + +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, + ) -> Result, 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 { + let shared = self.worker.lock_shared(); + let state = shared.networks.get(&network)?; + let mut peers: Vec = 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 { + let addresses: Vec = 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 = 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, network: NetworkId) -> Result { + 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 { + let shared = self.lock_shared(); + let state = shared.networks.get(&network)?; + + let endpoints: HashMap = state + .peers + .values() + .filter_map(|announcement| { + announcement + .preferred_endpoint() + .map(|endpoint| (announcement.public_key, endpoint)) + }) + .collect(); + let keys: Vec = 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 { + 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, mut commands: mpsc::Receiver) { + let mut pending: BTreeSet = BTreeSet::new(); + let mut deadline: Option = 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, 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(); + } + } +} diff --git a/src/dataplane/wireguard/store.rs b/src/dataplane/wireguard/store.rs new file mode 100644 index 0000000..f7a229c --- /dev/null +++ b/src/dataplane/wireguard/store.rs @@ -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, + path: PathBuf, +} + +impl WgKeyStore { + /// Opens, creating the file and its directory if needed. + pub fn open(path: impl AsRef) -> Result { + 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 { + let conn = self.lock(); + let stored: Option> = 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})" + ); + } +} diff --git a/src/dataplane/wireguard/wgtool.rs b/src/dataplane/wireguard/wgtool.rs new file mode 100644 index 0000000..db3be84 --- /dev/null +++ b/src/dataplane/wireguard/wgtool.rs @@ -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, + /// 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>, +} + +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 { + 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 = desired.addresses.iter().copied().collect(); + let current_addrs: BTreeSet = current + .map(|state| state.addresses.iter().copied().collect()) + .unwrap_or_default(); + + for addr in desired_addrs.difference(¤t_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 { + vec![WgCommand::new( + &tools.ip, + &["link", "del", "dev", interface], + )] +} + +/// Parses the output of `wg showconf `. +/// +/// 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 { + #[derive(Default)] + struct PartialPeer { + public_key: Option, + endpoint: Option, + allowed_ips: Vec, + persistent_keepalive: Option, + } + + let mut public_key: Option = None; + let mut listen_port = 0u16; + let mut peers: Vec = Vec::new(); + let mut current: Option = None; + + let finish = |peer: PartialPeer, peers: &mut Vec| -> 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 `. +pub fn parse_ip_addresses(text: &str) -> Result, 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 { + 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::with_tools(Tools::default()) + } + + /// Creates a backend using explicitly located tools. + pub fn with_tools(tools: Tools) -> Result { + Ok(Self { tools }) + } + + fn run(&self, command: &WgCommand) -> Result { + 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, 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::with_tools(Tools::default()) + } + + /// Always fails, see [`WgToolBackend::new`]. + pub fn with_tools(_tools: Tools) -> Result { + 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 = 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(¤t), &Tools::default()); + let described: Vec = 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(¤t), &Tools::default()); + let described: Vec = 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!["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!["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); + } +} diff --git a/src/discovery.rs b/src/discovery.rs index ea3d3b6..7dbe4bb 100644 --- a/src/discovery.rs +++ b/src/discovery.rs @@ -23,8 +23,6 @@ //! Mainline DHT discovery is future work and is not implemented here. use std::collections::HashMap; -use std::future::Future; -use std::pin::Pin; use std::sync::{Arc, Mutex}; use iroh::{EndpointAddr, EndpointId}; @@ -32,8 +30,7 @@ use iroh::{EndpointAddr, EndpointId}; use crate::error::Result; use crate::identity::DiscoveryKey; -/// A boxed future, so that [`NetworkDiscovery`] stays object safe. -pub type BoxFuture<'a, T> = Pin + Send + 'a>>; +pub use crate::BoxFuture; /// Where a candidate came from. Purely informational. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] diff --git a/src/lib.rs b/src/lib.rs index 56a2df9..559fcd8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,9 @@ #![deny(rustdoc::broken_intra_doc_links)] +/// A boxed future, used where a trait must stay object safe. +pub type BoxFuture<'a, T> = std::pin::Pin + Send + 'a>>; + pub mod agent; pub mod config; pub mod dataplane; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index e2af374..086d461 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -56,7 +56,7 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> { } #[cfg(unix)] -fn restrict_path_permissions(path: &Path) -> Result<()> { +pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| { Error::Io { @@ -67,12 +67,12 @@ fn restrict_path_permissions(path: &Path) -> Result<()> { } #[cfg(not(unix))] -fn restrict_path_permissions(_path: &Path) -> Result<()> { +pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> { Ok(()) } #[cfg(unix)] -fn restrict_dir_permissions(path: &Path) -> Result<()> { +pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| { Error::Io { @@ -83,11 +83,11 @@ fn restrict_dir_permissions(path: &Path) -> Result<()> { } #[cfg(not(unix))] -fn restrict_dir_permissions(_path: &Path) -> Result<()> { +pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> { Ok(()) } -fn create_dir(path: &Path) -> Result<()> { +pub(crate) fn create_dir(path: &Path) -> Result<()> { std::fs::create_dir_all(path).map_err(|source| Error::Io { path: path.to_path_buf(), source, diff --git a/tests/wireguard.rs b/tests/wireguard.rs new file mode 100644 index 0000000..1bae3b0 --- /dev/null +++ b/tests/wireguard.rs @@ -0,0 +1,724 @@ +//! The WireGuard data plane plugin, driven over real iroh connections. +//! +//! Every agent here is a real agent with a real control plane. Only the part +//! that would change the host's network is substituted: the plugin runs +//! against [`RecordingBackend`], so the suite needs no root and touches no +//! interfaces, while the key handling, the announcements, the derived +//! addressing, the configuration builder and reconciliation are all the real +//! ones. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +mod common; + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use common::{DEADLINE, config_with, network, settle, wait_event, wait_for_peers, wait_until}; +use iroh::EndpointId; +use tempfile::TempDir; +use tsunagi::agent::Event; +use tsunagi::config::AgentConfig; +use tsunagi::dataplane::wireguard::{ + AdvertisePolicy, Cidr, InterfaceState, PortPolicy, RecordingBackend, WIREGUARD_PROTOCOL, + WgAnnouncement, WgPublicKey, WgSecretKey, WireguardConfig, WireguardPlugin, overlay_address, + overlay_prefix, +}; +use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginError}; +use tsunagi::discovery::SharedMemoryDiscovery; +use tsunagi::identity::{NetworkId, NetworkName, NetworkSecret}; +use tsunagi::{Agent, NetworkStatus}; + +/// An agent, its WireGuard plugin and the backend that plugin drives. +struct WgAgent { + dir: TempDir, + agent: Agent, + plugin: Arc, + backend: RecordingBackend, +} + +impl WgAgent { + /// Starts an agent whose WireGuard plugin writes into an in-memory backend. + /// + /// Each agent gets its own interface prefix, because the rest of the name + /// is derived from the network id and these all share one host. + async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str, advertise: IpAddr) -> Self { + Self::spawn_with(discovery, tag, advertise, |config| config).await + } + + async fn spawn_with( + discovery: &SharedMemoryDiscovery, + tag: &str, + advertise: IpAddr, + tune: impl FnOnce(WireguardConfig) -> WireguardConfig, + ) -> Self { + let dir = TempDir::new().unwrap(); + let (agent, plugin, backend) = + Self::open(dir.path(), discovery, tag, advertise, tune).await; + Self { + dir, + agent, + plugin, + backend, + } + } + + async fn open( + root: &std::path::Path, + discovery: &SharedMemoryDiscovery, + tag: &str, + advertise: IpAddr, + tune: impl FnOnce(WireguardConfig) -> WireguardConfig, + ) -> (Agent, Arc, RecordingBackend) { + let backend = RecordingBackend::new(); + let wg = tune( + WireguardConfig::new(root.join("wireguard")) + .with_interface_prefix(tag) + .with_advertise(AdvertisePolicy::Explicit(vec![advertise])) + .with_ports(PortPolicy::Fixed(51820)) + .with_reconcile(Duration::from_millis(20), Duration::from_millis(250)), + ); + let plugin = WireguardPlugin::open(wg, Arc::new(backend.clone())) + .await + .unwrap(); + let config: AgentConfig = + config_with(root, discovery).with_plugin(plugin.clone() as Arc); + let agent = Agent::spawn(config).await.unwrap(); + (agent, plugin, backend) + } + + fn endpoint_id(&self) -> EndpointId { + self.agent.endpoint_id() + } + + /// The interface this agent's plugin owns for a network. + async fn interface(&self, network: NetworkId) -> String { + wait_until("the plugin prepared the network", || async { + self.plugin.overview(network).map(|view| view.interface) + }) + .await + } + + /// Waits until the applied configuration has exactly `count` peers. + async fn wait_for_wg_peers(&self, network: NetworkId, count: usize) -> InterfaceState { + let interface = self.interface(network).await; + wait_until( + &format!("{count} WireGuard peers on {interface}"), + || async { + let state = self.backend.state(&interface)?; + (state.peers.len() == count).then_some(state) + }, + ) + .await + } + + async fn shutdown(self) -> TempDir { + self.agent.shutdown().await; + self.dir + } +} + +fn addr(text: &str) -> IpAddr { + text.parse().unwrap() +} + +/// The overlay address a peer should have been given, derived independently. +fn expected_allowed_ips(network: NetworkId, key: &WgPublicKey) -> Vec { + vec![Cidr::host(overlay_address(network, key))] +} + +#[tokio::test] +async fn two_agents_build_each_others_wireguard_configuration() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-pair"); + + let a = WgAgent::spawn(&discovery, "ta", addr("10.77.0.1")).await; + let b = WgAgent::spawn(&discovery, "tb", addr("10.77.0.2")).await; + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&a.agent, network_id, 1).await; + + let state_a = a.wait_for_wg_peers(network_id, 1).await; + let state_b = b.wait_for_wg_peers(network_id, 1).await; + + let key_a = a.plugin.overview(network_id).unwrap().public_key; + let key_b = b.plugin.overview(network_id).unwrap().public_key; + assert_ne!(key_a, key_b); + + // Each side configured exactly the other, with locally derived AllowedIPs. + assert_eq!(state_a.peers[0].public_key, key_b); + assert_eq!( + state_a.peers[0].allowed_ips, + expected_allowed_ips(network_id, &key_b) + ); + assert_eq!(state_b.peers[0].public_key, key_a); + assert_eq!( + state_b.peers[0].allowed_ips, + expected_allowed_ips(network_id, &key_a) + ); + + // The endpoint each side uses is the one the *plugin* advertised, not an + // iroh address. + assert_eq!( + state_a.peers[0].endpoint, + Some(SocketAddr::new(addr("10.77.0.2"), 51820)) + ); + let iroh_addrs: Vec = b + .agent + .status() + .await + .unwrap() + .bound_sockets + .iter() + .map(SocketAddr::ip) + .collect(); + assert!( + !iroh_addrs.contains(&addr("10.77.0.2")), + "the WireGuard endpoint must not come from iroh's addresses" + ); + + // Both derive the same overlay subnet and their own distinct address. + let view_a = a.plugin.overview(network_id).unwrap(); + let view_b = b.plugin.overview(network_id).unwrap(); + assert_eq!(view_a.overlay_prefix, view_b.overlay_prefix); + assert_eq!( + view_a.overlay_prefix, + IpAddr::V6(overlay_prefix(network_id)) + ); + assert_ne!(view_a.overlay_address, view_b.overlay_address); + assert_eq!( + view_a.overlay_address, + IpAddr::V6(overlay_address(network_id, &key_a)) + ); + + a.shutdown().await; + b.shutdown().await; +} + +#[tokio::test] +async fn a_mesh_of_three_gives_every_agent_two_peers() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-mesh"); + + let a = WgAgent::spawn(&discovery, "ta", addr("10.77.1.1")).await; + let b = WgAgent::spawn(&discovery, "tb", addr("10.77.1.2")).await; + let c = WgAgent::spawn(&discovery, "tc", addr("10.77.1.3")).await; + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + c.agent.join_network(&name, &secret).await.unwrap(); + + for agent in [&a, &b, &c] { + wait_for_peers(&agent.agent, network_id, 2).await; + // N - 1 remote peers in the local configuration. + let state = agent.wait_for_wg_peers(network_id, 2).await; + assert_eq!(state.listen_port, 51820); + let own_key = agent.plugin.overview(network_id).unwrap().public_key; + assert_eq!(state.public_key, own_key); + assert!( + state.peers.iter().all(|peer| peer.public_key != own_key), + "an agent must never configure itself as a peer" + ); + } + + // Everybody agrees on who is in the overlay and at which address. + let keys: Vec = [&a, &b, &c] + .iter() + .map(|agent| agent.plugin.overview(network_id).unwrap().public_key) + .collect(); + for agent in [&a, &b, &c] { + let own = agent.plugin.overview(network_id).unwrap().public_key; + let state = agent + .backend + .state(&agent.interface(network_id).await) + .unwrap(); + for peer in &state.peers { + assert!(keys.contains(&peer.public_key)); + assert_eq!( + peer.allowed_ips, + expected_allowed_ips(network_id, &peer.public_key) + ); + assert_ne!(peer.public_key, own); + } + } + + a.shutdown().await; + b.shutdown().await; + c.shutdown().await; +} + +#[tokio::test] +async fn a_departing_peer_is_removed_from_the_configuration() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-departure"); + + let stayer = WgAgent::spawn(&discovery, "ta", addr("10.77.2.1")).await; + let leaver = WgAgent::spawn(&discovery, "tb", addr("10.77.2.2")).await; + + let network_id = stayer.agent.join_network(&name, &secret).await.unwrap(); + leaver.agent.join_network(&name, &secret).await.unwrap(); + stayer.wait_for_wg_peers(network_id, 1).await; + + let leaver_id = leaver.endpoint_id(); + let mut events = stayer.agent.subscribe(); + leaver.shutdown().await; + + wait_event(&mut events, |event| match event { + Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()), + _ => None, + }) + .await; + + // The interface stays, the peer goes. + let state = stayer.wait_for_wg_peers(network_id, 0).await; + assert!(state.peers.is_empty()); + assert_eq!( + state.public_key, + stayer.plugin.overview(network_id).unwrap().public_key + ); + + stayer.shutdown().await; +} + +#[tokio::test] +async fn two_networks_get_separate_interfaces_keys_and_overlays() { + let discovery = SharedMemoryDiscovery::new(); + let (name_a, secret_a) = network("wg-left"); + let (name_b, secret_b) = network("wg-right"); + + let hub = WgAgent::spawn(&discovery, "th", addr("10.77.3.1")).await; + let left = WgAgent::spawn(&discovery, "tl", addr("10.77.3.2")).await; + let right = WgAgent::spawn(&discovery, "tr", addr("10.77.3.3")).await; + + let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap(); + let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap(); + left.agent.join_network(&name_a, &secret_a).await.unwrap(); + right.agent.join_network(&name_b, &secret_b).await.unwrap(); + + hub.wait_for_wg_peers(alpha, 1).await; + hub.wait_for_wg_peers(beta, 1).await; + + let view_alpha = hub.plugin.overview(alpha).unwrap(); + let view_beta = hub.plugin.overview(beta).unwrap(); + + assert_ne!(view_alpha.interface, view_beta.interface); + assert_ne!( + view_alpha.public_key, view_beta.public_key, + "one identity per network, not one per host" + ); + assert_ne!(view_alpha.overlay_prefix, view_beta.overlay_prefix); + assert_eq!(hub.backend.interfaces().len(), 2); + + // Neither interface knows anything about the other network's member. + let left_key = left.plugin.overview(alpha).unwrap().public_key; + let right_key = right.plugin.overview(beta).unwrap().public_key; + let state_alpha = hub.backend.state(&view_alpha.interface).unwrap(); + let state_beta = hub.backend.state(&view_beta.interface).unwrap(); + assert_eq!(state_alpha.peers[0].public_key, left_key); + assert_eq!(state_beta.peers[0].public_key, right_key); + assert!(state_alpha.peers.iter().all(|p| p.public_key != right_key)); + assert!(state_beta.peers.iter().all(|p| p.public_key != left_key)); + + // Deactivating one network removes only its interface. + hub.agent.deactivate_network(alpha).await.unwrap(); + wait_until("the alpha interface is gone", || async { + hub.backend + .state(&view_alpha.interface) + .is_none() + .then_some(()) + }) + .await; + assert!(hub.backend.state(&view_beta.interface).is_some()); + + hub.shutdown().await; + left.shutdown().await; + right.shutdown().await; +} + +#[tokio::test] +async fn reconciliation_repairs_a_configuration_edited_by_hand() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-drift"); + + let a = WgAgent::spawn(&discovery, "ta", addr("10.77.4.1")).await; + let b = WgAgent::spawn(&discovery, "tb", addr("10.77.4.2")).await; + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + + let good = a.wait_for_wg_peers(network_id, 1).await; + let interface = a.interface(network_id).await; + + // Someone edits the interface: the peer's allowed prefix is widened to + // everything and a bogus address is added. + let mut tampered = good.clone(); + tampered.peers[0].allowed_ips = vec![Cidr::new(addr("::"), 0).unwrap()]; + tampered + .addresses + .push(Cidr::new(addr("192.0.2.1"), 32).unwrap()); + a.backend.inject_drift(&interface, tampered.clone()); + assert_ne!(a.backend.state(&interface).unwrap(), good); + + // The periodic reconcile puts it back without anybody asking. + let repaired = wait_until("the drift is corrected", || async { + let state = a.backend.state(&interface)?; + (state == good).then_some(state) + }) + .await; + assert_eq!(repaired.peers[0].allowed_ips, good.peers[0].allowed_ips); + assert!( + !repaired + .addresses + .contains(&Cidr::new(addr("192.0.2.1"), 32).unwrap()) + ); + + a.shutdown().await; + b.shutdown().await; +} + +#[tokio::test] +async fn a_data_plane_failure_does_not_stop_the_control_plane() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-failure"); + + let a = WgAgent::spawn(&discovery, "ta", addr("10.77.5.1")).await; + let b = WgAgent::spawn(&discovery, "tb", addr("10.77.5.2")).await; + + let mut events = a.agent.subscribe(); + a.backend + .fail_next_apply("simulated: no permission to configure the device"); + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + + // The failure is surfaced on the agent's event stream. + let reason = wait_event(&mut events, |event| match event { + Event::PluginError { + network, + protocol, + reason, + } if *network == network_id && protocol == WIREGUARD_PROTOCOL => Some(reason.clone()), + _ => None, + }) + .await; + assert!(reason.contains("simulated"), "unexpected reason: {reason}"); + + // The control plane is untouched: peers stay authenticated and messages + // keep flowing. + wait_for_peers(&a.agent, network_id, 1).await; + a.agent + .send( + network_id, + b.endpoint_id(), + tsunagi::proto::ControlMessage::Ping { + seq: 1, + payload: b"alive".to_vec(), + }, + ) + .await + .unwrap(); + wait_event(&mut events, |event| match event { + Event::MessageReceived { + message: tsunagi::proto::ControlMessage::Pong { seq: 1, .. }, + .. + } => Some(()), + _ => None, + }) + .await; + + // And the plugin retries, so the interface converges anyway. + a.wait_for_wg_peers(network_id, 1).await; + let status: NetworkStatus = a.agent.network_status(network_id).await.unwrap(); + assert!(status.metrics.plugin_errors >= 1); + + a.shutdown().await; + b.shutdown().await; +} + +#[tokio::test] +async fn restarting_keeps_the_wireguard_identity_and_overlay_address() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-restart"); + + let peer = WgAgent::spawn(&discovery, "tp", addr("10.77.6.9")).await; + let subject = WgAgent::spawn(&discovery, "ts", addr("10.77.6.1")).await; + + let network_id = peer.agent.join_network(&name, &secret).await.unwrap(); + subject.agent.join_network(&name, &secret).await.unwrap(); + peer.wait_for_wg_peers(network_id, 1).await; + + let before = subject.plugin.overview(network_id).unwrap(); + let dir = subject.shutdown().await; + + let (agent, plugin, backend) = + WgAgent::open(dir.path(), &discovery, "ts", addr("10.77.6.1"), |config| { + config + }) + .await; + + let after = wait_until("the restarted plugin is ready", || async { + plugin.overview(network_id) + }) + .await; + assert_eq!( + after.public_key, before.public_key, + "the WireGuard key must survive a restart" + ); + assert_eq!(after.overlay_address, before.overlay_address); + assert_eq!(after.interface, before.interface); + + // The peer reconnects and both configurations come back. + wait_for_peers(&agent, network_id, 1).await; + wait_until("the restarted interface has its peer", || async { + backend + .state(&after.interface) + .filter(|state| state.peers.len() == 1) + }) + .await; + + agent.shutdown().await; + peer.shutdown().await; + drop(agent); + drop(dir); +} + +#[tokio::test] +async fn shutdown_removes_every_interface_the_plugin_created() { + let discovery = SharedMemoryDiscovery::new(); + let (name_a, secret_a) = network("wg-teardown-a"); + let (name_b, secret_b) = network("wg-teardown-b"); + + let agent = WgAgent::spawn(&discovery, "ta", addr("10.77.7.1")).await; + let alpha = agent.agent.join_network(&name_a, &secret_a).await.unwrap(); + let beta = agent.agent.join_network(&name_b, &secret_b).await.unwrap(); + + agent.interface(alpha).await; + agent.interface(beta).await; + wait_until("both interfaces exist", || async { + (agent.backend.interfaces().len() == 2).then_some(()) + }) + .await; + + agent.agent.shutdown().await; + assert!( + agent.backend.interfaces().is_empty(), + "a clean shutdown must not leave interfaces behind: {:?}", + agent.backend.interfaces() + ); +} + +/// A plugin that announces whatever bytes it is told to, under the WireGuard +/// protocol id. Used to test what a hostile member can do. +#[derive(Debug)] +struct ForgingPlugin { + payload: std::sync::Mutex>>, +} + +impl IpPlugin for ForgingPlugin { + fn protocol_id(&self) -> &str { + WIREGUARD_PROTOCOL + } + + fn local_capability( + &self, + _network: NetworkId, + ) -> Result, PluginError> { + let payload = match self.payload.lock() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + }; + Ok(payload.map(|data| PluginCapability { + protocol: WIREGUARD_PROTOCOL.to_string(), + version: 1, + enabled: true, + data, + })) + } + + fn on_peer_capability( + &self, + _network: NetworkId, + _peer: EndpointId, + _capability: &PluginCapability, + ) -> Result<(), PluginError> { + Ok(()) + } + + fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {} + fn on_network_deactivated(&self, _network: NetworkId) {} +} + +#[tokio::test] +async fn a_member_cannot_claim_another_members_overlay_address() { + let discovery = SharedMemoryDiscovery::new(); + let name = NetworkName::new("wg-hijack").unwrap(); + let secret = NetworkSecret::generate(); + + let victim = WgAgent::spawn(&discovery, "tv", addr("10.77.8.1")).await; + let network_id = victim.agent.join_network(&name, &secret).await.unwrap(); + let victim_view = wait_until("the victim is ready", || async { + victim.plugin.overview(network_id) + }) + .await; + + // The attacker knows the secret — it is a legitimate member — and tries to + // take over the victim's overlay address with its own WireGuard key. + let attacker_key = WgSecretKey::generate().public(); + let mut forged = WgAnnouncement::new(network_id, &attacker_key, 51820, Vec::new()); + let IpAddr::V6(victim_address) = victim_view.overlay_address else { + panic!("overlay addresses are IPv6"); + }; + forged.overlay_address = victim_address; + + let forger = Arc::new(ForgingPlugin { + payload: std::sync::Mutex::new(Some(forged.encode().unwrap())), + }); + let attacker_dir = TempDir::new().unwrap(); + let attacker = Agent::spawn( + config_with(attacker_dir.path(), &discovery) + .with_plugin(forger.clone() as Arc), + ) + .await + .unwrap(); + + let mut events = victim.agent.subscribe(); + attacker.join_network(&name, &secret).await.unwrap(); + wait_for_peers(&victim.agent, network_id, 1).await; + + // The victim rejects the claim and says why. + let reason = wait_event(&mut events, |event| match event { + Event::PluginError { + protocol, reason, .. + } if protocol == WIREGUARD_PROTOCOL => Some(reason.clone()), + _ => None, + }) + .await; + assert!( + reason.contains("does not match"), + "unexpected reason: {reason}" + ); + + // Nothing was configured for the attacker, and the victim keeps its own + // address. + settle().await; + let interface = victim.interface(network_id).await; + let state = victim.backend.state(&interface); + assert!( + state + .map(|state| state + .peers + .iter() + .all(|peer| peer.public_key != attacker_key)) + .unwrap_or(true), + "a rejected announcement must not reach the configuration" + ); + assert_eq!( + victim.plugin.overview(network_id).unwrap().overlay_address, + victim_view.overlay_address + ); + + attacker.shutdown().await; + victim.shutdown().await; + drop(attacker_dir); +} + +#[tokio::test] +async fn the_core_carries_the_payload_without_interpreting_it() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-opaque"); + + let a = WgAgent::spawn(&discovery, "ta", addr("10.77.9.1")).await; + let b = WgAgent::spawn(&discovery, "tb", addr("10.77.9.2")).await; + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + a.wait_for_wg_peers(network_id, 1).await; + + // What the control plane stored for the peer is exactly the bytes the + // peer's plugin produced, and the core never looked inside. + let capability = wait_until("the peer's capability arrived", || async { + let status = a.agent.network_status(network_id).await.ok()?; + status + .peers + .first() + .and_then(|peer| peer.capabilities.first().cloned()) + }) + .await; + assert_eq!(capability.protocol, WIREGUARD_PROTOCOL); + assert!(capability.enabled); + + let view_b = b.plugin.overview(network_id).unwrap(); + let expected = WgAnnouncement::new( + network_id, + &view_b.public_key, + view_b.listen_port, + view_b.advertised.clone(), + ) + .encode() + .unwrap(); + assert_eq!(capability.data, expected); + + // And the payload stays well inside the control protocol's bound. + assert!(capability.data.len() < tsunagi::Limits::default().max_capability_data_len); + + a.shutdown().await; + b.shutdown().await; +} + +#[tokio::test] +async fn local_interface_advertising_produces_usable_endpoints() { + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-advertise"); + + let agent = WgAgent::spawn_with(&discovery, "ta", addr("10.77.10.1"), |config| { + config.with_advertise(AdvertisePolicy::LocalInterfaces) + }) + .await; + let network_id = agent.agent.join_network(&name, &secret).await.unwrap(); + + let view = wait_until("the plugin is ready", || async { + agent.plugin.overview(network_id) + }) + .await; + + // Whatever this host has, nothing loopback, unspecified or link-local may + // be advertised, and every entry carries the WireGuard port. + for endpoint in &view.advertised { + assert_eq!(endpoint.port(), view.listen_port); + assert!(!endpoint.ip().is_loopback()); + assert!(!endpoint.ip().is_unspecified()); + } + assert!(view.advertised.len() <= 8, "the list stays bounded"); + + agent.shutdown().await; +} + +#[tokio::test] +async fn the_plugin_converges_within_the_test_deadline() { + // A guard against the announce/re-announce handshake regressing into + // something that only converges on the slow periodic timer. + let discovery = SharedMemoryDiscovery::new(); + let (name, secret) = network("wg-timing"); + + let a = WgAgent::spawn_with(&discovery, "ta", addr("10.77.11.1"), |config| { + // A deliberately slow periodic reconcile: convergence must come from + // the event path, not from the timer. + config.with_reconcile(Duration::from_millis(20), DEADLINE * 2) + }) + .await; + let b = WgAgent::spawn_with(&discovery, "tb", addr("10.77.11.2"), |config| { + config.with_reconcile(Duration::from_millis(20), DEADLINE * 2) + }) + .await; + + let network_id = a.agent.join_network(&name, &secret).await.unwrap(); + b.agent.join_network(&name, &secret).await.unwrap(); + + a.wait_for_wg_peers(network_id, 1).await; + b.wait_for_wg_peers(network_id, 1).await; + + a.shutdown().await; + b.shutdown().await; +} diff --git a/tests/wireguard_system.rs b/tests/wireguard_system.rs new file mode 100644 index 0000000..0b9f18e --- /dev/null +++ b/tests/wireguard_system.rs @@ -0,0 +1,120 @@ +//! Opt-in tests for the real `wg` / `ip` backend. +//! +//! These are the only tests in this repository that change the host's network, +//! so they are **ignored by default** and are not part of the standard suite. +//! +//! They need Linux, the `wireguard` kernel module, `wg` from wireguard-tools, +//! `ip` from iproute2, and `CAP_NET_ADMIN` (in practice: root): +//! +//! ```text +//! sudo -E cargo test --test wireguard_system -- --ignored --test-threads=1 +//! ``` +//! +//! Everything they exercise — the command plan, the parsers, the configuration +//! builder and reconciliation — is already covered without root by the unit +//! tests in `src/dataplane/wireguard/` and by `tests/wireguard.rs`. What these +//! add is confirmation that the plan the planner produces is one the real +//! tools accept. + +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::net::SocketAddr; + +use tsunagi::dataplane::wireguard::wgtool::WgToolBackend; +use tsunagi::dataplane::wireguard::{ + Cidr, InterfaceParams, WgSecretKey, WireguardBackend, build_interface, overlay_address, +}; +use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; + +/// A name unlikely to collide with anything already on the host. +const TEST_INTERFACE: &str = "tsunagitest0"; + +fn test_network() -> tsunagi::NetworkId { + NetworkKeys::derive( + &NetworkName::new("system-test").unwrap(), + &NetworkSecret::from_bytes(vec![11u8; 32]).unwrap(), + ) + .network_id() +} + +#[test] +#[ignore = "changes the host's network; needs Linux, wg, ip and CAP_NET_ADMIN"] +fn the_real_backend_applies_reads_back_and_removes_a_configuration() { + let backend = WgToolBackend::new().expect("the wg-tools backend should be available on Linux"); + let network = test_network(); + let local = WgSecretKey::generate(); + let peer = WgSecretKey::generate().public(); + + // Start from a clean slate even if a previous run was interrupted. + backend.remove(TEST_INTERFACE).unwrap(); + assert_eq!(backend.inspect(TEST_INTERFACE).unwrap(), None); + + let desired = build_interface( + InterfaceParams { + network, + name: TEST_INTERFACE.to_string(), + private_key: local.clone(), + listen_port: 51899, + mtu: Some(1380), + keepalive: Some(25), + }, + [peer], + |_| Some("10.99.0.2:51899".parse::().unwrap()), + ); + + backend.apply(&desired).unwrap(); + + let observed = backend + .inspect(TEST_INTERFACE) + .unwrap() + .expect("the interface should exist after apply"); + assert_eq!(observed.public_key, local.public()); + assert_eq!(observed.listen_port, 51899); + assert_eq!(observed.peers.len(), 1); + assert_eq!(observed.peers[0].public_key, peer); + assert_eq!( + observed.peers[0].allowed_ips, + vec![Cidr::host(overlay_address(network, &peer))] + ); + assert!( + observed + .addresses + .contains(&Cidr::host(overlay_address(network, &local.public()))), + "the overlay address should be assigned: {:?}", + observed.addresses + ); + + // Applying the same configuration again must be a no-op, not a rebuild. + backend.apply(&desired).unwrap(); + assert_eq!(backend.inspect(TEST_INTERFACE).unwrap(), Some(observed)); + + backend.remove(TEST_INTERFACE).unwrap(); + assert_eq!(backend.inspect(TEST_INTERFACE).unwrap(), None); + // Removing something that is not there is not an error. + backend.remove(TEST_INTERFACE).unwrap(); +} + +#[test] +#[ignore = "changes the host's network; needs Linux, wg, ip and CAP_NET_ADMIN"] +fn the_real_backend_refuses_an_interface_it_did_not_create() { + let backend = WgToolBackend::new().expect("the wg-tools backend should be available on Linux"); + let name = "tsunagitest1"; + + // A plain dummy link, not a WireGuard device: something that belongs to + // somebody else. + let created = std::process::Command::new("ip") + .args(["link", "add", "dev", name, "type", "dummy"]) + .status() + .expect("ip should be available"); + assert!(created.success(), "could not create the dummy link"); + + let result = backend.inspect(name); + let _ = std::process::Command::new("ip") + .args(["link", "del", "dev", name]) + .status(); + + assert!( + result.is_err(), + "an interface the plugin did not create must be refused, not adopted" + ); +}