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:
@@ -38,8 +38,24 @@ jobs:
|
|||||||
- uses: dtolnay/rust-toolchain@stable
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
- uses: Swatinem/rust-cache@v2
|
- uses: Swatinem/rust-cache@v2
|
||||||
- run: cargo test --locked --workspace --all-targets
|
- run: cargo test --locked --workspace --all-targets
|
||||||
- name: run the example
|
- name: run the examples
|
||||||
run: cargo run --locked --example two_agents
|
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:
|
docs:
|
||||||
name: rustdoc
|
name: rustdoc
|
||||||
|
|||||||
@@ -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
|
- **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
|
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
|
payload — see `src/dataplane/mod.rs`. Only
|
||||||
transport before it exists; tests use an explicitly test-only capability id.
|
`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
|
- **Device identity vs network identity.** The iroh endpoint id is the device's
|
||||||
public key. `NetworkId` is derived from name + secret only. Never conflate
|
public key. `NetworkId` is derived from name + secret only. Never conflate
|
||||||
them, and never let one change the other.
|
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/proto/` | framing, message formats, membership handshake |
|
||||||
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
| `src/net.rs` | iroh endpoint adapter and observability snapshots |
|
||||||
| `src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
|
| `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 |
|
| `tests/` | integration tests; `tests/common/` is the shared harness |
|
||||||
|
|
||||||
Add abstractions only at real substitution or testing boundaries. Do not add a
|
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.
|
happen.
|
||||||
- The default suite must pass with no internet, no DHT, no public relay, no
|
- 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
|
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
|
- Running several library instances in one process is not a test of several
|
||||||
system processes; do not describe it as one.
|
system processes; do not describe it as one.
|
||||||
|
|
||||||
|
|||||||
Generated
+13
@@ -3182,6 +3182,7 @@ dependencies = [
|
|||||||
"hkdf",
|
"hkdf",
|
||||||
"hmac",
|
"hmac",
|
||||||
"iroh",
|
"iroh",
|
||||||
|
"netwatch",
|
||||||
"postcard",
|
"postcard",
|
||||||
"rand",
|
"rand",
|
||||||
"rusqlite",
|
"rusqlite",
|
||||||
@@ -3193,6 +3194,7 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"x25519-dalek",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3759,6 +3761,17 @@ dependencies = [
|
|||||||
"web-sys",
|
"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]]
|
[[package]]
|
||||||
name = "yoke"
|
name = "yoke"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ thiserror = "2.0"
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
fs4 = { version = "1.1", features = ["sync"] }
|
fs4 = { version = "1.1", features = ["sync"] }
|
||||||
directories = "6.0"
|
directories = "6.0"
|
||||||
|
x25519-dalek = { version = "3.0.0", features = ["static_secrets"] }
|
||||||
|
netwatch = "0.19.3"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||||
|
|||||||
@@ -30,15 +30,20 @@ A working library with **real iroh connections** and integration tests:
|
|||||||
- automatic reconnect with bounded exponential backoff and jitter;
|
- automatic reconnect with bounded exponential backoff and jitter;
|
||||||
- status snapshots, an event stream and honest diagnostics;
|
- status snapshots, an event stream and honest diagnostics;
|
||||||
- configuration restored after a restart;
|
- 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
|
### What it deliberately does **not** do
|
||||||
|
|
||||||
Not implemented, and not pretended to be: WireGuard or any other IP plugin,
|
Not implemented, and not pretended to be: Mainline DHT, DNS, routing through
|
||||||
Mainline DHT, DNS, routing through intermediate participants, a full CRDT,
|
intermediate participants, a full CRDT, dynamically loaded plugins, a system
|
||||||
dynamically loaded plugins, a system service, a complete CLI, or a local
|
service, a complete CLI, or a local control socket. Snapshot synchronisation
|
||||||
control socket. Snapshot synchronisation and signed revocations are designed
|
and signed revocations are designed for but not implemented — see
|
||||||
for but not implemented — see [docs/sync-model.md](docs/sync-model.md).
|
[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
|
**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
|
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`
|
The whole suite runs offline on loopback. Set `TSUNAGI_TEST_LOG=tsunagi=debug`
|
||||||
to see agent logs while a test runs.
|
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:
|
tests:
|
||||||
|
|
||||||
```bash
|
```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
|
## Usage
|
||||||
@@ -127,12 +142,18 @@ paths; tests always use temporary directories.
|
|||||||
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
|
| `state.sqlite` | device identity, network configuration, hostname | clear error, never reset |
|
||||||
| `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
| `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
|
One state directory belongs to one live agent, enforced with a real OS file
|
||||||
lock rather than an existence check.
|
lock rather than an existence check.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
- [docs/architecture.md](docs/architecture.md) — module boundaries and runtime.
|
- [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/protocol.md](docs/protocol.md) — identity derivation, framing, handshake.
|
||||||
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
- [docs/sync-model.md](docs/sync-model.md) — the planned signed-state model and
|
||||||
what is deliberately not built yet.
|
what is deliberately not built yet.
|
||||||
|
|||||||
+12
-7
@@ -12,12 +12,16 @@ authentication, participant announcements, capability exchange and — later —
|
|||||||
state synchronisation and delivery of IP-plugin data.
|
state synchronisation and delivery of IP-plugin data.
|
||||||
|
|
||||||
**Data plane.** Separate plugins create IP connectivity. WireGuard is the first
|
**Data plane.** Separate plugins create IP connectivity. WireGuard is the first
|
||||||
planned one; none exists yet. Plugin keys, configuration and lifecycle are
|
one and is implemented — see [wireguard.md](wireguard.md). Plugin keys,
|
||||||
separate from iroh identity and from the network secret. The core moves an
|
configuration and lifecycle are separate from iroh identity and from the
|
||||||
opaque, bounded payload and never parses it.
|
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
|
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
|
A data plane failure never stops the daemon: the control plane keeps running
|
||||||
and the agent stays manageable.
|
and the agent stays manageable.
|
||||||
@@ -32,11 +36,12 @@ and the agent stays manageable.
|
|||||||
| `proto` | message format, handshake, membership proof, protocol limits |
|
| `proto` | message format, handshake, membership proof, protocol limits |
|
||||||
| `agent` | agent and per-network lifecycle, reconnect, in-process message routing |
|
| `agent` | agent and per-network lifecycle, reconnect, in-process message routing |
|
||||||
| `storage` | mandatory state and the separately recoverable cache |
|
| `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
|
Abstractions exist only where something is really substituted or really needs
|
||||||
isolating for tests: `NetworkDiscovery` and `IpPlugin`. Everything else is a
|
isolating for tests: `NetworkDiscovery`, `IpPlugin`, and `WireguardBackend`
|
||||||
concrete type.
|
(which is what lets the plugin be tested in full without root). Everything else
|
||||||
|
is a concrete type.
|
||||||
|
|
||||||
## Runtime shape
|
## Runtime shape
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
**Nothing in this document is implemented.** The proof of concept exchanges
|
**Nothing in this document is implemented.** The proof of concept exchanges
|
||||||
hostname and capability announcements over live sessions and keeps no
|
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
|
boundaries in [architecture.md](architecture.md) stay compatible with it, and so
|
||||||
nobody mistakes the current announcements for synchronisation.
|
nobody mistakes the current announcements for synchronisation.
|
||||||
|
|
||||||
|
|||||||
@@ -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` |
|
| 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` |
|
| 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
|
`tests/discovery.rs` covers the discovery contract itself: a static bootstrap
|
||||||
candidate is enough to join, several backends compose, entries are withdrawn
|
candidate is enough to join, several backends compose, entries are withdrawn
|
||||||
when a network stops, and a forgotten network stays forgotten across a restart.
|
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,
|
itself: role separation, channel binding, identity and network binding,
|
||||||
unambiguous encoding, and rejection under the wrong key.
|
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
|
`tests/end_to_end.rs` is the vertical slice: persistent identity → network
|
||||||
space → discovery → iroh → authentication → message exchange.
|
space → discovery → iroh → authentication → message exchange.
|
||||||
|
|
||||||
|
|||||||
+18
-2
@@ -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.
|
messages for network B, even over a shared physical connection.
|
||||||
- **Confidentiality and integrity in transit.** Provided by QUIC/TLS. This
|
- **Confidentiality and integrity in transit.** Provided by QUIC/TLS. This
|
||||||
crate adds no encryption of its own.
|
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,
|
- **Resource bounds.** Frame lengths are validated before allocation; strings,
|
||||||
lists, queues, concurrent dials and in-flight handshakes are all bounded;
|
lists, queues, concurrent dials and in-flight handshakes are all bounded;
|
||||||
handshakes, dials and writes have timeouts.
|
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
|
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
|
directory takes an ownership lock, but neither defends against a user who can
|
||||||
read the file or against malware running as that user.
|
read the file or against malware running as that user.
|
||||||
- **User IP traffic.** Not carried here at all. Filtering it is the operating
|
- **User IP traffic.** Carried by the WireGuard plugin, not by iroh, and
|
||||||
system's and the user's job.
|
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
|
- **Denial of service.** Bounds and timeouts stop trivial resource exhaustion
|
||||||
from a single peer. They do not make the agent resistant to a determined
|
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.
|
attacker who knows the secret, and no rate limiting per identity exists yet.
|
||||||
|
|||||||
@@ -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`.
|
||||||
@@ -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
@@ -37,6 +37,7 @@ use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
|
|
||||||
use crate::config::{AgentConfig, Limits};
|
use crate::config::{AgentConfig, Limits};
|
||||||
|
use crate::dataplane::{PluginContext, PluginRequest};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||||
use crate::net::EndpointAdapter;
|
use crate::net::EndpointAdapter;
|
||||||
@@ -80,6 +81,7 @@ struct Inner {
|
|||||||
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
||||||
shutdown: Shutdown,
|
shutdown: Shutdown,
|
||||||
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||||
|
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for Inner {
|
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
|
// Nothing here awaits; this is only a safety net for a handle that was
|
||||||
// dropped without an explicit shutdown.
|
// dropped without an explicit shutdown.
|
||||||
self.shutdown.trigger();
|
self.shutdown.trigger();
|
||||||
if let Ok(mut guard) = self.accept_task.lock()
|
for guard in [&self.accept_task, &self.plugin_task] {
|
||||||
&& let Some(task) = guard.take()
|
if let Ok(mut guard) = guard.lock()
|
||||||
{
|
&& let Some(task) = guard.take()
|
||||||
task.abort();
|
{
|
||||||
|
task.abort();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +126,7 @@ impl Agent {
|
|||||||
networks: RwLock::new(HashMap::new()),
|
networks: RwLock::new(HashMap::new()),
|
||||||
shutdown: Shutdown::new(),
|
shutdown: Shutdown::new(),
|
||||||
accept_task: std::sync::Mutex::new(None),
|
accept_task: std::sync::Mutex::new(None),
|
||||||
|
plugin_task: std::sync::Mutex::new(None),
|
||||||
config,
|
config,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,6 +139,21 @@ impl Agent {
|
|||||||
*guard = Some(accept);
|
*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 };
|
let agent = Self { inner };
|
||||||
|
|
||||||
for stored in agent.inner.storage.list_networks().await? {
|
for stored in agent.inner.storage.list_networks().await? {
|
||||||
@@ -241,6 +261,10 @@ impl Agent {
|
|||||||
networks.insert(network_id, handle);
|
networks.insert(network_id, handle);
|
||||||
drop(networks);
|
drop(networks);
|
||||||
|
|
||||||
|
for plugin in &self.inner.config.plugins {
|
||||||
|
plugin.on_network_activated(network_id);
|
||||||
|
}
|
||||||
|
|
||||||
let _ = self.inner.events.send(Event::NetworkActivated {
|
let _ = self.inner.events.send(Event::NetworkActivated {
|
||||||
network: network_id,
|
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.
|
/// 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
|
/// Call this when the host's network environment changed. Platform wake-up
|
||||||
@@ -426,14 +459,25 @@ impl Agent {
|
|||||||
|
|
||||||
self.inner.adapter.close().await;
|
self.inner.adapter.close().await;
|
||||||
|
|
||||||
let task = self
|
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
|
||||||
.inner
|
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||||
.accept_task
|
if let Some(task) = task {
|
||||||
.lock()
|
let _ = task.await;
|
||||||
.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.
|
// 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.
|
/// Accepts inbound connections and routes authenticated sessions to networks.
|
||||||
///
|
///
|
||||||
/// Holds only a weak reference, so dropping every [`Agent`] handle lets the
|
/// Holds only a weak reference, so dropping every [`Agent`] handle lets the
|
||||||
|
|||||||
@@ -54,6 +54,15 @@ pub(crate) enum NetCommand {
|
|||||||
reply: oneshot::Sender<Box<NetworkStatus>>,
|
reply: oneshot::Sender<Box<NetworkStatus>>,
|
||||||
},
|
},
|
||||||
Recheck,
|
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 {
|
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::Broadcast { message, .. } => write!(f, "Broadcast({})", kind(message)),
|
||||||
NetCommand::Status { .. } => f.write_str("Status"),
|
NetCommand::Status { .. } => f.write_str("Status"),
|
||||||
NetCommand::Recheck => f.write_str("Recheck"),
|
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()));
|
let _ = reply.send(Box::new(self.status()));
|
||||||
}
|
}
|
||||||
NetCommand::Recheck => self.discovery_round().await,
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,10 +19,14 @@
|
|||||||
//! A data plane failure never stops the daemon: errors returned here are
|
//! A data plane failure never stops the daemon: errors returned here are
|
||||||
//! recorded and surfaced, the control plane keeps running.
|
//! recorded and surfaced, the control plane keeps running.
|
||||||
|
|
||||||
|
pub mod wireguard;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use iroh::EndpointId;
|
use iroh::EndpointId;
|
||||||
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
|
use crate::BoxFuture;
|
||||||
use crate::identity::NetworkId;
|
use crate::identity::NetworkId;
|
||||||
|
|
||||||
/// Maximum length of a plugin protocol identifier.
|
/// Maximum length of a plugin protocol identifier.
|
||||||
@@ -60,7 +64,92 @@ pub enum PluginError {
|
|||||||
Other(String),
|
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
|
/// Implementations must be cheap and non-blocking: the agent calls them from
|
||||||
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
|
/// 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.
|
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
|
||||||
fn protocol_id(&self) -> &str;
|
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.
|
/// Produces this agent's announcement for a given network.
|
||||||
///
|
///
|
||||||
/// Returning `Ok(None)` means "nothing to announce right now", which is
|
/// 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,
|
network: NetworkId,
|
||||||
) -> std::result::Result<Option<PluginCapability>, PluginError>;
|
) -> 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.
|
/// 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.
|
/// 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.
|
/// Called when a network is deactivated locally.
|
||||||
///
|
///
|
||||||
/// This is a local deactivation, not a signed revocation of membership.
|
/// 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);
|
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.
|
/// A shared handle to a plugin.
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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};
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(¤t_addrs) {
|
||||||
|
plan.push(WgCommand::new(
|
||||||
|
&tools.ip,
|
||||||
|
&["address", "add", &addr.to_string(), "dev", name],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
// Addresses on an interface this plugin owns that are not wanted any more
|
||||||
|
// were either put there by an older configuration or by hand. Either way
|
||||||
|
// reconciliation removes them.
|
||||||
|
for addr in current_addrs.difference(&desired_addrs) {
|
||||||
|
plan.push(WgCommand::new(
|
||||||
|
&tools.ip,
|
||||||
|
&["address", "del", &addr.to_string(), "dev", name],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(mtu) = desired.mtu {
|
||||||
|
plan.push(WgCommand::new(
|
||||||
|
&tools.ip,
|
||||||
|
&["link", "set", "mtu", &mtu.to_string(), "dev", name],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
plan.push(WgCommand::new(
|
||||||
|
&tools.ip,
|
||||||
|
&["link", "set", "up", "dev", name],
|
||||||
|
));
|
||||||
|
plan
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the commands that remove an interface this plugin created.
|
||||||
|
pub fn plan_remove(interface: &str, tools: &Tools) -> Vec<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(¤t), &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(¤t), &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
@@ -23,8 +23,6 @@
|
|||||||
//! Mainline DHT discovery is future work and is not implemented here.
|
//! Mainline DHT discovery is future work and is not implemented here.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::future::Future;
|
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
use iroh::{EndpointAddr, EndpointId};
|
use iroh::{EndpointAddr, EndpointId};
|
||||||
@@ -32,8 +30,7 @@ use iroh::{EndpointAddr, EndpointId};
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::identity::DiscoveryKey;
|
use crate::identity::DiscoveryKey;
|
||||||
|
|
||||||
/// A boxed future, so that [`NetworkDiscovery`] stays object safe.
|
pub use crate::BoxFuture;
|
||||||
pub type BoxFuture<'a, T> = Pin<Box<dyn Future<Output = T> + Send + 'a>>;
|
|
||||||
|
|
||||||
/// Where a candidate came from. Purely informational.
|
/// Where a candidate came from. Purely informational.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
@@ -27,6 +27,9 @@
|
|||||||
|
|
||||||
#![deny(rustdoc::broken_intra_doc_links)]
|
#![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 agent;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod dataplane;
|
pub mod dataplane;
|
||||||
|
|||||||
+5
-5
@@ -56,7 +56,7 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn restrict_path_permissions(path: &Path) -> Result<()> {
|
pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||||
Error::Io {
|
Error::Io {
|
||||||
@@ -67,12 +67,12 @@ fn restrict_path_permissions(path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
|
||||||
Error::Io {
|
Error::Io {
|
||||||
@@ -83,11 +83,11 @@ fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(unix))]
|
#[cfg(not(unix))]
|
||||||
fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
||||||
Ok(())
|
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 {
|
std::fs::create_dir_all(path).map_err(|source| Error::Io {
|
||||||
path: path.to_path_buf(),
|
path: path.to_path_buf(),
|
||||||
source,
|
source,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user