Implement the WireGuard data plane plugin

The first IP plugin, built on the data plane boundary the core already had.

Plugin:
- one X25519 key per network in the plugin's own wireguard.sqlite, separate
  from the iroh identity and from the network secret; a damaged store is an
  error, never a silently regenerated identity
- deterministic IPv6 ULA overlay: every member derives the same /64 from the
  network id and its own /128 from its WireGuard public key, so no
  coordinator allocates addresses
- AllowedIPs are derived locally, never taken from a peer's announcement, so
  a member cannot claim another member's overlay address; a mismatched claim
  is rejected
- bounded, versioned, validated announcement carried as the existing opaque
  capability payload, which the core still never parses
- each agent builds its own full-mesh configuration (N-1 peers) and
  reconciles on every change and on a timer, repairing drift
- WireguardBackend abstraction: RecordingBackend in memory, and WgToolBackend
  driving real wg/ip on Linux, split into a pure planner plus parsers and a
  thin executor so everything interesting is testable without root

Core, three generic additions the plugin needed:
- IpPlugin::on_network_activated, so per-network state is ready before peers
- PluginContext for re-announcements and error reports from plugin tasks,
  with errors counted by the owning network runtime
- IpPlugin::shutdown, awaited with a grace period, so system objects go away

94 tests pass offline with no privileges: 35 new WireGuard unit tests and 12
integration tests over real iroh connections. The real wg/ip backend needs
root and is behind --ignored in tests/wireguard_system.rs; it was not run.

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