Make the protocol a crate of its own

tsunagi-wg-quic. The line between a protocol and the system level is now
drawn by the compiler: nothing in it can reach into tsunagi beyond what
tsunagi makes public, and it carries its own version — which is not the
version peers compare.

Two things the compiler found the moment the boundary was real. The key
store was reaching into the core's `pub(crate)` file-permission helpers;
those are a legitimate service of the system level, because a protocol
keeping keys on disk has the same obligation the agent does, so they are
public now with that said. And the test harness was about to be copied
into a second crate, which is how two copies start to drift; it is a
`testing` feature of the core instead, which is also what anybody writing
a protocol would need.

The bridges put up while things were moving are gone: the error
conversion between the two levels, and the re-exports of the system
level's types from the protocol crate. Imports now say which level they
come from, which is the point.

One deliberate deviation, stated rather than hidden. The authenticated
transport stayed in the core. Moving it would have meant handing a
protocol the network's keys so it could prove membership itself, and a
plugin that can authenticate on the control plane is a worse trade than
a module boundary is worth. So the core proves who is at the other end
and the protocol owns what is said over it — the same separation, without
the secret crossing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 19:55:30 +01:00
co-authored by Claude Opus 5
parent ff7e235414
commit 142fdf995c
31 changed files with 289 additions and 234 deletions
+24 -8
View File
@@ -18,18 +18,31 @@ Design accordingly: a majority is not a root of trust.
Keep these separate. Crossing them is the main thing to review for. Keep these separate. Crossing them is the main thing to review for.
- **Control plane vs data plane.** The separation is **logical, not physical**. - **Control plane vs data plane.** The separation is **logical, not physical**.
The control protocol in `crates/tsunagi/src/proto/` knows nothing about packets, and The control protocol in `crates/tsunagi/src/proto/` knows nothing about
`crates/tsunagi/src/dataplane/` knows nothing about the control protocol; either can be packets, and a protocol crate knows nothing about the control protocol;
replaced on its own. Both may ride on iroh — refusing to would throw away either can be replaced on its own. Both may ride on iroh — refusing to
iroh's NAT traversal and force the data plane to reimplement it. They use would throw away iroh's NAT traversal and force the data plane to
different ALPNs and different connections, so a busy or broken data plane reimplement it. They use different ALPNs and different connections, so a
cannot disturb control traffic. busy or broken data plane cannot disturb control traffic.
- **One agent, one interface.** It belongs to the system level, along with
the addresses on it and the decision of whose packet is whose. Several
protocols may be carrying traffic at once and none of them owns the thing
they carry it for. A protocol is handed a routed packet and hands back a
decrypted one; it never creates an interface and never picks an address.
- **A protocol is a separate crate with its own version.** The version peers
compare is the *wire* version, never the software version: two peers on
different releases work together for as long as the bytes between them
have not changed. Nothing negotiated may be derived from anything that
moves with a release.
- **A protocol never sees the network secret.** It proves who is at the other
end of a tunnel; proving membership of a network stays in the core, which
is why the authenticated transport does too.
- **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink` - **Plugins never learn reachability.** An `IpPlugin` is handed a `PacketLink`
per peer and moves datagrams over it. Addresses, hole punching and relays per peer and moves datagrams over it. Addresses, hole punching and relays
belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never belong to `crates/tsunagi/src/dataplane/transport/`. A plugin announcement says *who*, never
*where*. *where*.
- **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only - **The core never parses a plugin payload.** See `crates/tsunagi/src/dataplane/mod.rs`. Only
`crates/tsunagi/src/dataplane/wireguard/announcement.rs` interprets WireGuard payloads, and `crates/tsunagi-wg-quic/src/announcement.rs` interprets `wg-quic` payloads, and
only after bounding every field. A data plane failure must never stop the only after bounding every field. A data plane failure must never stop the
control plane. control plane.
- **Derived, not claimed.** A peer's overlay address is derived from its public - **Derived, not claimed.** A peer's overlay address is derived from its public
@@ -106,7 +119,10 @@ Keep these separate. Crossing them is the main thing to review for.
| `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots | | `crates/tsunagi/src/net.rs` | iroh endpoint adapter and observability snapshots |
| `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status | | `crates/tsunagi/src/agent/` | agent lifecycle, per-network runtimes, sessions, events, status |
| `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation | | `crates/tsunagi/src/state/` | signed records that outlive a session, their merge rules and address allocation |
| `crates/tsunagi/src/dataplane/` | the plugin contract, the packet transport, and the WireGuard plugin | | `crates/tsunagi/src/overlay/` | the one interface an agent owns: provisioning, the TUN, routing, source checks |
| `crates/tsunagi/src/dataplane/` | the protocol contract and the authenticated packet transport |
| `crates/tsunagi/src/dns/` | the DNS view of a network: zone, server, resolver publication |
| `crates/tsunagi-wg-quic/` | the `wg-quic` protocol: its keys, its announcement, its tunnels |
| `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals | | `crates/tsunagi-cli/` | the command line agent; the only place that owns a runtime, a logger and signals |
| `tests/` | integration tests; `tests/common/` is the shared harness | | `tests/` | integration tests; `tests/common/` is the shared harness |
Generated
+27 -1
View File
@@ -3821,7 +3821,6 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
name = "tsunagi" name = "tsunagi"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"boringtun",
"bytes", "bytes",
"caps", "caps",
"data-encoding", "data-encoding",
@@ -3848,6 +3847,7 @@ dependencies = [
"tokio", "tokio",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tsunagi",
"tun", "tun",
"zbus", "zbus",
"zeroize", "zeroize",
@@ -3868,6 +3868,32 @@ dependencies = [
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
"tsunagi", "tsunagi",
"tsunagi-wg-quic",
]
[[package]]
name = "tsunagi-wg-quic"
version = "0.1.0"
dependencies = [
"boringtun",
"bytes",
"data-encoding",
"hex",
"hkdf",
"iroh",
"postcard",
"rand 0.10.3",
"rusqlite",
"serde",
"sha2",
"subtle",
"tempfile",
"thiserror 2.0.20",
"tokio",
"tracing",
"tracing-subscriber",
"tsunagi",
"zeroize",
] ]
[[package]] [[package]]
+7 -8
View File
@@ -86,13 +86,13 @@ It prints its endpoint id and then waits. On the second machine, pass that id:
Within a few seconds both print something like: Within a few seconds both print something like:
```text ```text
+ peer b47c958462 connected over Direct rtt=Some(4.5ms) + peer b47c958462 connected over direct rtt=Some(4.5ms)
+ data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382 + data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382
--- status --- --- status ---
control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s) control: 1 peer(s), 0 dial failure(s), 0 handshake failure(s)
wireguard: tsunkkcp43lmdje on fd15:1d9e:fa21:f201:…/64 mtu 1280, 1/1 tunnel(s) established wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established
4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…) 4jO4kx9Z 10.13.37.237 handshake 3s ago tx=0 rx=0 dropped=0 path=direct via 192.0.2.7:41234
``` ```
`1/1 tunnel(s) established` means a real WireGuard handshake completed. `1/1 tunnel(s) established` means a real WireGuard handshake completed.
@@ -384,7 +384,6 @@ tests:
```bash ```bash
cargo run --example two_agents # control plane only cargo run --example two_agents # control plane only
cargo run --example wireguard_mesh # a WireGuard overlay carrying a real packet
``` ```
Both run with no privileges and change nothing on the host. Both run with no privileges and change nothing on the host.
@@ -477,9 +476,9 @@ 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 The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`,
its configuration points, because plugin keys are neither the iroh identity nor because a protocol's keys are neither the iroh identity nor the network
the network secret. secret.
The command line agent puts everything under the platform's per-user The command line agent puts everything under the platform's per-user
directories by default; `--state-dir` and `--cache-dir` override them. directories by default; `--state-dir` and `--cache-dir` override them.
+1
View File
@@ -13,6 +13,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] } tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
tsunagi-wg-quic = { path = "../tsunagi-wg-quic", version = "0.1.0" }
iroh.workspace = true iroh.workspace = true
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] } tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
tracing.workspace = true tracing.workspace = true
+8 -11
View File
@@ -15,14 +15,13 @@ use clap::{Args, Parser, Subcommand, ValueEnum};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::dataplane::IpPlugin; use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin,
};
use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap}; use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap};
use tsunagi::identity::{NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::iroh_types::EndpointAddr; use tsunagi::iroh_types::EndpointAddr;
use tsunagi::overlay::{MemoryTunFactory, TunFactory};
use tsunagi::state::Ipv4Range; use tsunagi::state::Ipv4Range;
use tsunagi::{Agent, NetworkId}; use tsunagi::{Agent, NetworkId};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// A small agent for private mesh networks. /// A small agent for private mesh networks.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
@@ -775,8 +774,8 @@ struct ProtocolSpec {
/// Every protocol this build has. /// Every protocol this build has.
const PROTOCOLS: &[ProtocolSpec] = &[ProtocolSpec { const PROTOCOLS: &[ProtocolSpec] = &[ProtocolSpec {
name: tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL, name: tsunagi_wg_quic::WIREGUARD_PROTOCOL,
version: tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION, version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \ summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \
crosses NAT and survives where plain WireGuard is blocked", crosses NAT and survives where plain WireGuard is blocked",
options: WireguardPlugin::OPTIONS, options: WireguardPlugin::OPTIONS,
@@ -1509,7 +1508,7 @@ fn host_section() -> report::Section {
}); });
} }
use tsunagi::dataplane::wireguard::{Privilege, probe_net_admin}; use tsunagi::overlay::{Privilege, probe_net_admin};
match probe_net_admin() { match probe_net_admin() {
Privilege::Available => { Privilege::Available => {
host.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held")); host.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held"));
@@ -2064,16 +2063,14 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
} else { } else {
system_tun_factory()? system_tun_factory()?
}; };
let mtu = args let mtu = args.mtu.unwrap_or(tsunagi_wg_quic::DEFAULT_MTU);
.mtu
.unwrap_or(tsunagi::dataplane::wireguard::DEFAULT_MTU);
config = config.with_interface(tun_factory, args.interface.clone(), mtu); config = config.with_interface(tun_factory, args.interface.clone(), mtu);
} }
for spec in &wanted { for spec in &wanted {
let options = settings_for(spec, &settings); let options = settings_for(spec, &settings);
match spec.name { match spec.name {
tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL => { tsunagi_wg_quic::WIREGUARD_PROTOCOL => {
let mut wg = WireguardConfig::new(paths.state_dir.join("wg-quic")); let mut wg = WireguardConfig::new(paths.state_dir.join("wg-quic"));
if let Some(mtu) = args.mtu { if let Some(mtu) = args.mtu {
wg = wg.with_mtu(mtu); wg = wg.with_mtu(mtu);
@@ -2381,7 +2378,7 @@ async fn stop_signal() -> &'static str {
/// goes away. /// goes away.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> { fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner}; use tsunagi::overlay::{ManagedTunFactory, NetlinkProvisioner};
let provisioner = NetlinkProvisioner::new()?; let provisioner = NetlinkProvisioner::new()?;
Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner)))) Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner))))
} }
+39
View File
@@ -0,0 +1,39 @@
[package]
name = "tsunagi-wg-quic"
# Its own version, and deliberately separate from the wire version it
# negotiates: a release here does not stop it talking to a peer on an older
# build, because what peers compare is `ANNOUNCEMENT_VERSION`.
version = "0.1.0"
description = "The wg-quic protocol for tsunagi: WireGuard's cryptography carried in iroh's QUIC datagrams."
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
tsunagi = { path = "../tsunagi", version = "0.1.0" }
iroh.workspace = true
tokio.workspace = true
serde.workspace = true
postcard.workspace = true
bytes.workspace = true
thiserror.workspace = true
tracing.workspace = true
data-encoding.workspace = true
hex.workspace = true
rusqlite = { version = "0.40", features = ["bundled"] }
sha2 = "0.11"
hkdf = "0.13"
subtle = "2.6"
zeroize = { version = "1.9", features = ["derive"] }
rand = "0.10"
boringtun = { version = "0.7.1", default-features = false }
[dev-dependencies]
tsunagi = { path = "../tsunagi", features = ["testing"] }
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros"] }
tempfile.workspace = true
tracing-subscriber.workspace = true
[lints]
workspace = true
@@ -1,26 +1,26 @@
//! What a WireGuard peer tells the network about itself. //! What a WireGuard peer tells the network about itself.
//! //!
//! This is the opaque payload the control plane carries in a //! This is the opaque payload the control plane carries in a
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it — //! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it —
//! only this module does, and only after bounding every field. //! only this module does, and only after bounding every field.
//! //!
//! The announcement is deliberately tiny: a participant says **who it is**, //! The announcement is deliberately tiny: a participant says **who it is**,
//! not **where it is**. Reachability is the data plane transport's job, and //! not **where it is**. Reachability is the data plane transport's job, and
//! the transport already solves it — see //! the transport already solves it — see
//! [`crate::dataplane::transport`]. A plugin that also tried to advertise //! [`tsunagi::dataplane::transport`]. A plugin that also tried to advertise
//! addresses would be reimplementing NAT traversal badly. //! addresses would be reimplementing NAT traversal badly.
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::dataplane::PluginError; use tsunagi::dataplane::PluginError;
use crate::identity::NetworkId; use tsunagi::identity::NetworkId;
use super::keys::WgPublicKey; use crate::keys::WgPublicKey;
/// Version of the announcement format. /// Version of the announcement format.
/// ///
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the /// Version 3 dropped the IPv4 range again: overlay addressing moved to the
/// signed records in [`crate::state`], which carry the range and survive a /// signed records in [`tsunagi::state`], which carry the range and survive a
/// participant being away. postcard is not self-describing, so an older peer /// participant being away. postcard is not self-describing, so an older peer
/// cannot read a newer announcement; the mismatch is reported, not misparsed. /// cannot read a newer announcement; the mismatch is reported, not misparsed.
pub const ANNOUNCEMENT_VERSION: u16 = 4; pub const ANNOUNCEMENT_VERSION: u16 = 4;
@@ -122,7 +122,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*; use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use super::super::keys::WgSecretKey; use super::super::keys::WgSecretKey;
@@ -237,7 +237,7 @@ mod tests {
let peer = WgSecretKey::generate().public(); let peer = WgSecretKey::generate().public();
let payload = WgAnnouncement::new(id, &peer).encode().unwrap(); let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
assert!( assert!(
payload.len() < crate::config::Limits::default().max_capability_data_len, payload.len() < tsunagi::config::Limits::default().max_capability_data_len,
"announcement is {} bytes", "announcement is {} bytes",
payload.len() payload.len()
); );
@@ -39,11 +39,11 @@ use bytes::Bytes;
use iroh::EndpointId; use iroh::EndpointId;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::dataplane::transport::{SharedLink, TransportError}; use tsunagi::dataplane::transport::{SharedLink, TransportError};
use crate::dataplane::{PacketSink, PluginError}; use tsunagi::dataplane::{PacketSink, PluginError};
use crate::identity::NetworkId; use tsunagi::identity::NetworkId;
use super::keys::{WgPublicKey, WgSecretKey}; use crate::keys::{WgPublicKey, WgSecretKey};
/// How often WireGuard's own timers are driven. /// How often WireGuard's own timers are driven.
/// ///
@@ -11,7 +11,7 @@ use boringtun::x25519;
use data_encoding::BASE64; use data_encoding::BASE64;
use zeroize::{Zeroize, Zeroizing}; use zeroize::{Zeroize, Zeroizing};
use crate::dataplane::PluginError; use tsunagi::dataplane::PluginError;
/// Length of a raw WireGuard key, in bytes. /// Length of a raw WireGuard key, in bytes.
pub const KEY_LEN: usize = 32; pub const KEY_LEN: usize = 32;
+50
View File
@@ -0,0 +1,50 @@
//! The `wg-quic` protocol: WireGuard's cryptography in iroh's QUIC datagrams.
//!
//! A crate of its own, so the line between a protocol and the system level
//! is drawn by the compiler rather than by discipline: nothing here can
//! reach into `tsunagi` beyond what it makes public. It also carries its own
//! version, which is **not** the version peers compare — that is
//! [`ANNOUNCEMENT_VERSION`], and it moves only when the bytes on the wire
//! do, so two peers on different releases keep working.
//!
//! That it uses iroh is a convenience, not a requirement of the design: the
//! system level already has an iroh endpoint that crosses NAT, and plain
//! WireGuard is blocked on some networks where this is not.
//!
//! Where the line falls:
//!
//! * **It knows nothing about reachability.** It is handed a
//! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and
//! moves datagrams over it. Hole punching and relaying are the transport's.
//! * **It knows nothing about addresses, and owns no interface.** One agent
//! has one interface at the system level, and every protocol carries
//! traffic for the same addresses on it. A packet arrives here already
//! routed and leaves here already decrypted, to be checked against the
//! signed claim by the level that holds those.
//! * **Its announcement says who, not where.** A public key and the network
//! it is for, so there is nothing about reachability to lie about.
//! * **The core never parses that announcement.** It moves a bounded opaque
//! blob; only [`announcement`] reads it.
//! * **Its keys are its own.** One per network, in its own store, unrelated
//! to the iroh device key and to the network secret — which it never sees.
//! * **WireGuard's own crypto is untouched.** The handshake and encryption
//! run end to end between the two ends of a tunnel, via [`boringtun`]'s
//! state machine in this process: no kernel module, no `wg` tool, the same
//! code on every platform.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub use announcement::{ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WgAnnouncement};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
WireguardConfig, WireguardPlugin,
};
pub use store::WgKeyStore;
@@ -8,7 +8,7 @@
//! # What this plugin does and does not know //! # What this plugin does and does not know
//! //!
//! * It does **not** know where a peer is. It is handed a //! * It does **not** know where a peer is. It is handed a
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and runs //! [`PacketLink`](tsunagi::dataplane::transport::PacketLink) per peer and runs
//! a WireGuard tunnel over it. Reachability, hole punching and relaying are //! a WireGuard tunnel over it. Reachability, hole punching and relaying are
//! the transport's problem. //! the transport's problem.
//! * It does **not** know which addresses anybody holds, and owns no //! * It does **not** know which addresses anybody holds, and owns no
@@ -34,17 +34,17 @@ use iroh::EndpointId;
use tokio::sync::{mpsc, oneshot}; use tokio::sync::{mpsc, oneshot};
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use crate::BoxFuture; use tsunagi::BoxFuture;
use crate::dataplane::PacketSink; use tsunagi::dataplane::PacketSink;
use crate::dataplane::transport::SharedLink; use tsunagi::dataplane::transport::SharedLink;
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError}; use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
use crate::identity::NetworkId; use tsunagi::identity::NetworkId;
use super::announcement::{ValidatedAnnouncement, WgAnnouncement}; use crate::announcement::{ValidatedAnnouncement, WgAnnouncement};
use super::device::{PeerSummary, WireguardDevice}; use crate::device::{PeerSummary, WireguardDevice};
use super::keys::{WgPublicKey, WgSecretKey}; use crate::keys::{WgPublicKey, WgSecretKey};
use super::store::WgKeyStore; use crate::store::WgKeyStore;
use crate::state::Ipv4Range; use tsunagi::state::Ipv4Range;
/// The protocol identifier this plugin announces. /// The protocol identifier this plugin announces.
/// The protocol id of this plugin. /// The protocol id of this plugin.
@@ -235,14 +235,14 @@ pub struct WireguardPlugin {
impl WireguardPlugin { impl WireguardPlugin {
/// The settings this protocol accepts. /// The settings this protocol accepts.
pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = &[ pub const OPTIONS: &'static [tsunagi::dataplane::ProtocolOption] = &[
crate::dataplane::ProtocolOption { tsunagi::dataplane::ProtocolOption {
key: "keepalive", key: "keepalive",
value: "SECONDS", value: "SECONDS",
help: "keeps a tunnel and its link warm through a NAT; 0 turns it off", help: "keeps a tunnel and its link warm through a NAT; 0 turns it off",
default: Some("25"), default: Some("25"),
}, },
crate::dataplane::ProtocolOption { tsunagi::dataplane::ProtocolOption {
key: "mtu", key: "mtu",
value: "BYTES", value: "BYTES",
help: "largest packet a tunnel will carry, at least 576", help: "largest packet a tunnel will carry, at least 576",
@@ -470,7 +470,7 @@ impl Worker {
Some(sink) => Arc::clone(sink), Some(sink) => Arc::clone(sink),
// Not attached to an agent: the protocol still runs, and its // Not attached to an agent: the protocol still runs, and its
// packets have nowhere to go. // packets have nowhere to go.
None => Arc::new(crate::dataplane::DiscardPackets) as Arc<dyn PacketSink>, None => Arc::new(tsunagi::dataplane::DiscardPackets) as Arc<dyn PacketSink>,
}; };
let device = Arc::new(WireguardDevice::start(network, key, sink)); let device = Arc::new(WireguardDevice::start(network, key, sink));
@@ -650,10 +650,10 @@ impl IpPlugin for WireguardPlugin {
} }
fn protocol_version(&self) -> u16 { fn protocol_version(&self) -> u16 {
super::announcement::ANNOUNCEMENT_VERSION crate::announcement::ANNOUNCEMENT_VERSION
} }
fn options(&self) -> &'static [crate::dataplane::ProtocolOption] { fn options(&self) -> &'static [tsunagi::dataplane::ProtocolOption] {
Self::OPTIONS Self::OPTIONS
} }
@@ -686,7 +686,7 @@ impl IpPlugin for WireguardPlugin {
let announcement = WgAnnouncement::new(network, &state.key.public()); let announcement = WgAnnouncement::new(network, &state.key.public());
Ok(Some(PluginCapability { Ok(Some(PluginCapability {
protocol: WIREGUARD_PROTOCOL.to_string(), protocol: WIREGUARD_PROTOCOL.to_string(),
version: super::announcement::ANNOUNCEMENT_VERSION, version: crate::announcement::ANNOUNCEMENT_VERSION,
enabled: true, enabled: true,
data: announcement.encode()?, data: announcement.encode()?,
})) }))
@@ -17,10 +17,10 @@ use std::sync::Mutex;
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use crate::dataplane::PluginError; use tsunagi::dataplane::PluginError;
use crate::identity::NetworkId; use tsunagi::identity::NetworkId;
use super::keys::{KEY_LEN, WgSecretKey}; use crate::keys::{KEY_LEN, WgSecretKey};
/// Schema version written by this build. /// Schema version written by this build.
pub const SCHEMA_VERSION: i64 = 1; pub const SCHEMA_VERSION: i64 = 1;
@@ -37,14 +37,14 @@ impl WgKeyStore {
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> { pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
let path = path.as_ref().to_path_buf(); let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() { if let Some(parent) = path.parent() {
crate::storage::create_dir(parent) tsunagi::storage::create_dir(parent)
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?; .map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
} }
let existed = path.exists(); let existed = path.exists();
let conn = Connection::open(&path).map_err(|err| { let conn = Connection::open(&path).map_err(|err| {
PluginError::Other(format!("cannot open the WireGuard key store: {err}")) PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
})?; })?;
crate::storage::restrict_path_permissions(&path) tsunagi::storage::restrict_path_permissions(&path)
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?; .map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
conn.busy_timeout(std::time::Duration::from_secs(5)) conn.busy_timeout(std::time::Duration::from_secs(5))
@@ -169,7 +169,7 @@ mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*; use super::*;
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
fn network(name: &str) -> NetworkId { fn network(name: &str) -> NetworkId {
NetworkKeys::derive( NetworkKeys::derive(
@@ -11,23 +11,21 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::net::{IpAddr, Ipv4Addr}; use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use common::{config_with, network, wait_until};
use tempfile::TempDir; use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin; use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig,
WireguardPlugin,
};
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkId; use tsunagi::identity::NetworkId;
use tsunagi::overlay::{
Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner,
};
use tsunagi::state::DEFAULT_IPV4_RANGE; use tsunagi::state::DEFAULT_IPV4_RANGE;
use tsunagi::testing::{config_with, network, wait_until};
use tsunagi::{Agent, NetworkStatus}; use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// An agent whose overlay interface is applied to a pretend host. /// An agent whose overlay interface is applied to a pretend host.
struct HostedAgent { struct HostedAgent {
@@ -6,19 +6,18 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use common::{config_with, network, wait_for_peers, wait_until};
use tempfile::TempDir; use tempfile::TempDir;
use tsunagi::dataplane::IpPlugin; use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin};
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::ipc::unix::{ControlSocket, request_status}; use tsunagi::ipc::unix::{ControlSocket, request_status};
use tsunagi::ipc::{StatusReport, control_socket_path}; use tsunagi::ipc::{StatusReport, control_socket_path};
use tsunagi::overlay::MemoryTunFactory;
use tsunagi::testing::{config_with, network, wait_for_peers, wait_until};
use tsunagi::{Agent, BoxFuture}; use tsunagi::{Agent, BoxFuture};
use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin};
/// Builds the report the way the binary does, from the agent plus the plugin. /// Builds the report the way the binary does, from the agent plus the plugin.
fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> { fn source(agent: Agent, plugin: Arc<WireguardPlugin>) -> Arc<dyn tsunagi::ipc::unix::ReportSource> {
@@ -8,25 +8,24 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration; use std::time::Duration;
use bytes::Bytes; use bytes::Bytes;
use common::{config_with, network, settle, wait_event, wait_for_peers, wait_until};
use iroh::EndpointId; use iroh::EndpointId;
use tempfile::TempDir; use tempfile::TempDir;
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::dataplane::IpPlugin; use tsunagi::dataplane::IpPlugin;
use tsunagi::dataplane::wireguard::{
Ipv4Range, MemoryTun, MemoryTunFactory, WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey,
WireguardConfig, WireguardPlugin,
};
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkId, NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkId, NetworkName, NetworkSecret};
use tsunagi::overlay::{MemoryTun, MemoryTunFactory};
use tsunagi::state::Ipv4Range;
use tsunagi::testing::{config_with, network, settle, wait_event, wait_for_peers, wait_until};
use tsunagi::{Agent, NetworkStatus}; use tsunagi::{Agent, NetworkStatus};
use tsunagi_wg_quic::{
WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey, WireguardConfig, WireguardPlugin,
};
/// An agent with a WireGuard plugin backed by an in-memory packet interface. /// An agent with a WireGuard plugin backed by an in-memory packet interface.
struct WgAgent { struct WgAgent {
@@ -217,7 +216,7 @@ async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() {
let payload = b"hello over the overlay"; let payload = b"hello over the overlay";
tun_a.push_from_os(ipv4_packet(addr_a, addr_b, payload)); tun_a.push_from_os(ipv4_packet(addr_a, addr_b, payload));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
.await .await
.expect("the packet should arrive") .expect("the packet should arrive")
.expect("the interface should still be open"); .expect("the interface should still be open");
@@ -227,7 +226,7 @@ async fn two_agents_carry_real_ip_packets_through_a_wireguard_tunnel() {
// And back the other way. // And back the other way.
tun_b.push_from_os(ipv4_packet(addr_b, addr_a, b"and back")); tun_b.push_from_os(ipv4_packet(addr_b, addr_a, b"and back"));
let back = tokio::time::timeout(common::DEADLINE, tun_a.pop_to_os()) let back = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_a.pop_to_os())
.await .await
.expect("the reply should arrive") .expect("the reply should arrive")
.unwrap(); .unwrap();
@@ -281,7 +280,7 @@ async fn a_peer_cannot_send_from_an_address_it_does_not_own() {
// A legitimate packet still goes through, so the tunnel is not broken. // A legitimate packet still goes through, so the tunnel is not broken.
tun_a.push_from_os(ipv4_packet(addr_a, addr_b, b"honest")); tun_a.push_from_os(ipv4_packet(addr_a, addr_b, b"honest"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
.await .await
.expect("the honest packet should arrive") .expect("the honest packet should arrive")
.unwrap(); .unwrap();
@@ -327,7 +326,7 @@ async fn the_overlay_uses_a_range_the_network_was_told_to_use() {
// A real IPv4 packet through the same tunnel. // A real IPv4 packet through the same tunnel.
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"ipv4 over the overlay")); tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"ipv4 over the overlay"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
.await .await
.expect("the IPv4 packet should arrive") .expect("the IPv4 packet should arrive")
.unwrap(); .unwrap();
@@ -379,7 +378,7 @@ async fn an_ipv4_source_a_peer_does_not_own_is_dropped() {
// The honest one still gets through. // The honest one still gets through.
tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"honest v4")); tun_a.push_from_os(ipv4_packet(v4_a, v4_b, b"honest v4"));
let received = tokio::time::timeout(common::DEADLINE, tun_b.pop_to_os()) let received = tokio::time::timeout(tsunagi::testing::DEADLINE, tun_b.pop_to_os())
.await .await
.expect("the honest packet should arrive") .expect("the honest packet should arrive")
.unwrap(); .unwrap();
@@ -669,7 +668,10 @@ async fn a_mesh_of_three_establishes_every_tunnel() {
a.tun(network_id) a.tun(network_id)
.await .await
.push_from_os(ipv4_packet(addr_a, addr_c, b"a to c")); .push_from_os(ipv4_packet(addr_a, addr_c, b"a to c"));
let received = tokio::time::timeout(common::DEADLINE, c.tun(network_id).await.pop_to_os()) let received = tokio::time::timeout(
tsunagi::testing::DEADLINE,
c.tun(network_id).await.pop_to_os(),
)
.await .await
.expect("the packet should arrive") .expect("the packet should arrive")
.unwrap(); .unwrap();
@@ -773,7 +775,10 @@ async fn two_networks_share_one_interface_with_keys_and_ranges_of_their_own() {
hub.tun(alpha) hub.tun(alpha)
.await .await
.push_from_os(ipv4_packet(hub_alpha, left_addr, b"alpha only")); .push_from_os(ipv4_packet(hub_alpha, left_addr, b"alpha only"));
let seen = tokio::time::timeout(common::DEADLINE, left.tun(alpha).await.pop_to_os()) let seen = tokio::time::timeout(
tsunagi::testing::DEADLINE,
left.tun(alpha).await.pop_to_os(),
)
.await .await
.expect("the packet should arrive") .expect("the packet should arrive")
.unwrap(); .unwrap();
@@ -1062,7 +1067,7 @@ impl IpPlugin for FromTheFuture {
} }
fn protocol_version(&self) -> u16 { fn protocol_version(&self) -> u16 {
tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION + 1 tsunagi_wg_quic::ANNOUNCEMENT_VERSION + 1
} }
fn local_capability( fn local_capability(
@@ -1105,7 +1110,7 @@ impl IpPlugin for ForgingPlugin {
/// The same version, so the announcement is looked at rather than /// The same version, so the announcement is looked at rather than
/// dismissed for the wrong reason. /// dismissed for the wrong reason.
fn protocol_version(&self) -> u16 { fn protocol_version(&self) -> u16 {
tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION tsunagi_wg_quic::ANNOUNCEMENT_VERSION
} }
fn local_capability( fn local_capability(
@@ -1120,7 +1125,7 @@ impl IpPlugin for ForgingPlugin {
protocol: WIREGUARD_PROTOCOL.to_string(), protocol: WIREGUARD_PROTOCOL.to_string(),
// The version it actually speaks, so the payload is examined // The version it actually speaks, so the payload is examined
// rather than set aside for the wrong reason. // rather than set aside for the wrong reason.
version: tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION, version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION,
enabled: true, enabled: true,
data, data,
})) }))
@@ -1141,7 +1146,7 @@ impl IpPlugin for ForgingPlugin {
#[tokio::test] #[tokio::test]
async fn an_mtu_below_what_ipv4_guarantees_is_refused() { async fn an_mtu_below_what_ipv4_guarantees_is_refused() {
use tsunagi::dataplane::wireguard::{DEFAULT_MTU, MIN_MTU, WIREGUARD_OVERHEAD}; use tsunagi_wg_quic::{DEFAULT_MTU, MIN_MTU, WIREGUARD_OVERHEAD};
// 576 bytes is what every IPv4 host must be able to reassemble, so // 576 bytes is what every IPv4 host must be able to reassemble, so
// nothing below it is worth offering. The floor used to be 1280 for // nothing below it is worth offering. The floor used to be 1280 for
+8 -1
View File
@@ -18,6 +18,9 @@ tun-device = ["dep:tun", "dep:rtnetlink", "dep:caps", "dep:futures-util"]
# Telling the operating system where to send its DNS questions. Linux only # Telling the operating system where to send its DNS questions. Linux only
# for now; the zone and the server work without it. # for now; the zone and the server work without it.
dns-publish = ["dep:zbus"] dns-publish = ["dep:zbus"]
# The test harness in `testing`, for this crate's own tests and for a
# protocol crate's. Not on by default: it is only of use to a test.
testing = ["dep:tempfile", "dep:tracing-subscriber"]
[dependencies] [dependencies]
iroh.workspace = true iroh.workspace = true
@@ -50,8 +53,10 @@ directories = "6.0"
# forbidden `unsafe` and will not make one. # forbidden `unsafe` and will not make one.
gethostname = "1.1" gethostname = "1.1"
netwatch = "0.19.3" netwatch = "0.19.3"
boringtun = { version = "0.7.1", default-features = false }
tun = { version = "0.8", features = ["async"], optional = true } tun = { version = "0.8", features = ["async"], optional = true }
# Only for the `testing` harness, which a protocol crate's tests use too.
tempfile = { version = "3.24", optional = true }
tracing-subscriber = { version = "0.3", features = ["env-filter"], optional = true }
# Linux-only interface provisioning. `rtnetlink` configures the interface in # Linux-only interface provisioning. `rtnetlink` configures the interface in
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN # process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
@@ -65,6 +70,8 @@ caps = { version = "0.5", optional = true }
futures-util = { version = "0.3", default-features = false, optional = true } futures-util = { version = "0.3", default-features = false, optional = true }
[dev-dependencies] [dev-dependencies]
# Its own tests use the harness it publishes.
tsunagi = { path = ".", features = ["testing"] }
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"] }
tempfile.workspace = true tempfile.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
+2 -17
View File
@@ -20,7 +20,6 @@
//! recorded and surfaced, the control plane keeps running. //! recorded and surfaced, the control plane keeps running.
pub mod transport; pub mod transport;
pub mod wireguard;
use std::sync::Arc; use std::sync::Arc;
@@ -42,7 +41,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32;
/// chooses to do so must validate it itself. /// chooses to do so must validate it itself.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PluginCapability { pub struct PluginCapability {
/// Protocol identifier, e.g. `wireguard`. Bounded by [`MAX_PROTOCOL_ID_LEN`]. /// Protocol identifier, e.g. `wg-quic`. Bounded by [`MAX_PROTOCOL_ID_LEN`].
pub protocol: String, pub protocol: String,
/// Version of the plugin's announcement format. /// Version of the plugin's announcement format.
pub version: u16, pub version: u16,
@@ -112,20 +111,6 @@ pub enum PluginError {
Other(String), Other(String),
} }
/// Scaffolding while the interface moves out of the plugin.
///
/// The overlay interface belongs to the system level now, so a plugin has no
/// business failing because of it. This exists only for the callers that
/// have not been moved over yet and goes when the last of them does.
impl From<crate::overlay::OverlayError> for PluginError {
fn from(err: crate::overlay::OverlayError) -> Self {
match err {
crate::overlay::OverlayError::Unavailable(reason) => PluginError::Unavailable(reason),
crate::overlay::OverlayError::Other(reason) => PluginError::Other(reason),
}
}
}
/// A request a plugin makes of the agent that owns it. /// A request a plugin makes of the agent that owns it.
#[derive(Debug)] #[derive(Debug)]
pub(crate) enum PluginRequest { pub(crate) enum PluginRequest {
@@ -249,7 +234,7 @@ impl std::fmt::Debug for PluginContext {
/// 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.
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static { pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
/// Stable protocol identifier, e.g. `wireguard`. /// Stable protocol identifier, e.g. `wg-quic`.
/// ///
/// 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;
@@ -1,67 +0,0 @@
//! The WireGuard protocol plugin.
//!
//! The first of them. It carries IP packets between participants; deciding
//! who is in the network, what addresses they hold and which interface those
//! sit on belongs to the system level, which configures this and then leaves
//! it to get on with it.
//!
//! Where the line falls:
//!
//! * **The plugin knows nothing about reachability.** It is handed a
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and
//! moves datagrams over it. Hole punching and relaying belong to the
//! transport.
//! * **The plugin knows nothing about addresses either.** They are allocated
//! and signed at the system level, the same ones whichever protocol is
//! moving the packets. Nothing here derives one.
//! * **The announcement says who, not where.** A public key and the network
//! it is for, so there is nothing about reachability for a peer to lie
//! about.
//! * **The core never parses these announcements.** It moves a bounded
//! opaque blob; only [`announcement`] interprets it.
//! * **Keys are separate.** One key per network, in its own store, unrelated
//! to the iroh device key and to the network secret.
//! * **WireGuard's own crypto is untouched.** The handshake and encryption
//! run end to end between the two ends of a tunnel.
//!
//! WireGuard itself is [`boringtun`]'s protocol state machine, running in
//! this process: no kernel module, no `wg` tool, the same code on every
//! platform. [`device::WireguardDevice`] drives one tunnel per peer.
//!
//! The interface underneath comes from [`crate::overlay`], and with its
//! in-memory implementation the whole data plane — handshake, encryption,
//! routing, address ownership — runs and is tested with no privileges at
//! all.
//!
//! See `docs/wireguard.md` for the full picture.
pub mod announcement;
pub mod device;
pub mod keys;
pub mod plugin;
pub mod store;
pub use crate::state::Ipv4Range;
pub use announcement::{ANNOUNCEMENT_VERSION, ValidatedAnnouncement, WgAnnouncement};
// The interface, its addresses and how it is created belong to the system
// level now: one agent has one interface, and no protocol owns it. Re-exported
// here while callers are moved over.
pub use crate::overlay::provision::{
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, ManagedTunFactory,
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
probe_net_admin,
};
pub use crate::overlay::{
Cidr, DEFAULT_INTERFACE_PREFIX, IpHeader, MAX_INTERFACE_NAME_LEN, MemoryTun, MemoryTunFactory,
TunDevice, TunFactory, TunRequest, address_is_local, interface_name,
};
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
pub use keys::{WgPublicKey, WgSecretKey};
pub use plugin::{
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
WireguardConfig, WireguardPlugin,
};
pub use store::WgKeyStore;
#[cfg(all(feature = "tun-device", target_os = "linux"))]
pub use crate::overlay::NetlinkProvisioner;
+2
View File
@@ -48,6 +48,8 @@ pub mod overlay;
pub mod proto; pub mod proto;
pub mod state; pub mod state;
pub mod storage; pub mod storage;
#[cfg(feature = "testing")]
pub mod testing;
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus}; pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy}; pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
+14 -5
View File
@@ -56,8 +56,13 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Restricts a file to the user that owns it.
///
/// Part of what the system level offers a protocol: a plugin keeping keys of
/// its own on disk has the same obligation as the agent, and should not have
/// to work out the platform details again to meet it.
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> { pub 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,13 +72,15 @@ pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
}) })
} }
/// Restricts a file to the user that owns it. A no-op off Unix.
#[cfg(not(unix))] #[cfg(not(unix))]
pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> { pub fn restrict_path_permissions(_path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
/// Restricts a directory to the user that owns it.
#[cfg(unix)] #[cfg(unix)]
pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> { pub 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,12 +90,14 @@ pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
}) })
} }
/// Restricts a directory to the user that owns it. A no-op off Unix.
#[cfg(not(unix))] #[cfg(not(unix))]
pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> { pub fn restrict_dir_permissions(_path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
pub(crate) fn create_dir(path: &Path) -> Result<()> { /// Creates a directory and every parent, restricted to this user.
pub 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,
@@ -1,26 +1,31 @@
//! Shared helpers for the integration tests. //! A harness for testing against a real agent.
//! //!
//! Every test uses real iroh endpoints on loopback, its own temporary SQLite //! Behind the `testing` feature, because it is only of use to a test and
//! files and independent agent instances. Only discovery is substituted; iroh, //! pulls in `tempfile` and a tracing subscriber. It lives here rather than
//! the handshake, message passing and persistent storage are not. //! being copied into each crate's `tests/` directory: a protocol plugin in
//! its own crate needs exactly the same harness, and two copies of it would
//! drift.
//! //!
//! Synchronisation is always "wait for a specific event or condition, under one //! Everything here builds agents that touch nothing outside a temporary
//! overall deadline", never a fixed multi-second sleep. //! directory: loopback only, no relay, no address publication.
#![allow(dead_code, clippy::unwrap_used, clippy::expect_used, clippy::panic)] // A harness fails a test loudly and at once; that is the whole job. The
// crate forbids these elsewhere for the opposite reason — nothing off the
// network may panic — and this module never sees anything off the network.
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use std::future::Future; use std::future::Future;
use std::path::Path; use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use crate::agent::Event;
use crate::config::{AgentConfig, StoragePaths, TransportPolicy};
use crate::discovery::SharedMemoryDiscovery;
use crate::identity::{NetworkName, NetworkSecret};
use crate::{Agent, Result};
use tempfile::TempDir; use tempfile::TempDir;
use tokio::sync::broadcast::error::RecvError; use tokio::sync::broadcast::error::RecvError;
use tsunagi::agent::Event;
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::{Agent, Result};
/// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set. /// Installs a tracing subscriber when `TSUNAGI_TEST_LOG` is set.
/// ///
@@ -50,7 +55,7 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25);
/// port mapping, so the suite needs neither the internet nor privileges. /// port mapping, so the suite needs neither the internet nor privileges.
/// Timeouts are shortened so that failure paths finish quickly. /// Timeouts are shortened so that failure paths finish quickly.
pub fn local_config(dir: &Path) -> AgentConfig { pub fn local_config(dir: &Path) -> AgentConfig {
let limits = tsunagi::Limits { let limits = crate::Limits {
dial_timeout: Duration::from_millis(1500), dial_timeout: Duration::from_millis(1500),
handshake_timeout: Duration::from_secs(5), handshake_timeout: Duration::from_secs(5),
..Default::default() ..Default::default()
@@ -69,7 +74,9 @@ pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig
/// A temporary directory plus the agent running on it. /// A temporary directory plus the agent running on it.
pub struct TestAgent { pub struct TestAgent {
/// The directory it keeps its state in, removed when this is dropped.
pub dir: TempDir, pub dir: TempDir,
/// The agent itself.
pub agent: Agent, pub agent: Agent,
} }
@@ -170,7 +177,7 @@ pub async fn settle() {
/// Waits until `agent` has `count` authenticated peers in `network`. /// Waits until `agent` has `count` authenticated peers in `network`.
pub async fn wait_for_peers( pub async fn wait_for_peers(
agent: &Agent, agent: &Agent,
network: tsunagi::NetworkId, network: crate::NetworkId,
count: usize, count: usize,
) -> Vec<iroh::EndpointId> { ) -> Vec<iroh::EndpointId> {
wait_until(&format!("{count} peers in {network}"), || async move { wait_until(&format!("{count} peers in {network}"), || async move {
+1 -3
View File
@@ -8,9 +8,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, network, settle, wait_event};
use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets}; use iroh::endpoint::{Connection, PortmapperConfig, RecvStream, SendStream, presets};
use iroh::{Endpoint, EndpointAddr, RelayMode}; use iroh::{Endpoint, EndpointAddr, RelayMode};
use tsunagi::agent::Event; use tsunagi::agent::Event;
@@ -22,6 +19,7 @@ use tsunagi::proto::message::{
}; };
use tsunagi::proto::{read_frame, write_frame}; use tsunagi::proto::{read_frame, write_frame};
use tsunagi::test_support; use tsunagi::test_support;
use tsunagi::testing::{TestAgent, network, settle, wait_event};
/// A bare iroh endpoint with no tsunagi agent behind it. /// A bare iroh endpoint with no tsunagi agent behind it.
async fn raw_endpoint() -> Endpoint { async fn raw_endpoint() -> Endpoint {
+1 -3
View File
@@ -2,14 +2,12 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::io::Write; use std::io::Write;
use common::{TestAgent, config_with, local_config, network, wait_for_peers};
use tsunagi::config::StoragePaths; use tsunagi::config::StoragePaths;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::storage::CacheOutcome; use tsunagi::storage::CacheOutcome;
use tsunagi::testing::{TestAgent, config_with, local_config, network, wait_for_peers};
use tsunagi::{Agent, Error}; use tsunagi::{Agent, Error};
/// Overwrites a file with bytes that are definitely not a SQLite database. /// Overwrites a file with bytes that are definitely not a SQLite database.
+1 -3
View File
@@ -8,9 +8,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{config_with, network, wait_until};
use tempfile::TempDir; use tempfile::TempDir;
use tsunagi::Agent; use tsunagi::Agent;
use tsunagi::config::StoragePaths; use tsunagi::config::StoragePaths;
@@ -18,6 +15,7 @@ use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::state::{RecordBody, StateSet}; use tsunagi::state::{RecordBody, StateSet};
use tsunagi::storage::StateStore; use tsunagi::storage::StateStore;
use tsunagi::testing::{config_with, network, wait_until};
#[tokio::test] #[tokio::test]
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() { async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
+1 -3
View File
@@ -3,16 +3,14 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::sync::Arc; use std::sync::Arc;
use common::{TestAgent, local_config, network, settle, wait_for_peers};
use tsunagi::Agent; use tsunagi::Agent;
use tsunagi::discovery::{ use tsunagi::discovery::{
CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap, CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap,
}; };
use tsunagi::identity::NetworkKeys; use tsunagi::identity::NetworkKeys;
use tsunagi::testing::{TestAgent, local_config, network, settle, wait_for_peers};
#[tokio::test] #[tokio::test]
async fn a_static_bootstrap_candidate_is_enough_to_join() { async fn a_static_bootstrap_candidate_is_enough_to_join() {
+1 -3
View File
@@ -3,12 +3,10 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, network, wait_event, wait_for_peers};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::proto::ControlMessage; use tsunagi::proto::ControlMessage;
use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers};
#[tokio::test] #[tokio::test]
async fn two_agents_authenticate_and_exchange_messages() { async fn two_agents_authenticate_and_exchange_messages() {
+1 -3
View File
@@ -5,12 +5,10 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, config_with, network};
use tsunagi::Agent; use tsunagi::Agent;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
use tsunagi::testing::{TestAgent, config_with, network};
#[test] #[test]
fn derivation_is_a_pure_function_of_name_and_secret() { fn derivation_is_a_pure_function_of_name_and_secret() {
+1 -3
View File
@@ -3,16 +3,14 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc; use std::sync::Arc;
use common::{TestAgent, network, wait_event, wait_for_peers, wait_until};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::dataplane::TestCapabilityPlugin; use tsunagi::dataplane::TestCapabilityPlugin;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::proto::ControlMessage; use tsunagi::proto::ControlMessage;
use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers, wait_until};
#[tokio::test] #[tokio::test]
async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() { async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
+1 -3
View File
@@ -2,9 +2,6 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, network, settle, wait_event, wait_for_peers};
use iroh::endpoint::{PortmapperConfig, presets}; use iroh::endpoint::{PortmapperConfig, presets};
use iroh::{Endpoint, RelayMode}; use iroh::{Endpoint, RelayMode};
use tsunagi::agent::Event; use tsunagi::agent::Event;
@@ -16,6 +13,7 @@ use tsunagi::proto::message::{
}; };
use tsunagi::proto::{read_frame, write_frame}; use tsunagi::proto::{read_frame, write_frame};
use tsunagi::test_support; use tsunagi::test_support;
use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers};
const LIMIT: usize = 64 * 1024; const LIMIT: usize = 64 * 1024;
+3 -3
View File
@@ -3,18 +3,18 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use std::net::SocketAddr; use std::net::SocketAddr;
use std::time::Duration; use std::time::Duration;
use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers};
use iroh::{EndpointAddr, SecretKey}; use iroh::{EndpointAddr, SecretKey};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::config::ReconnectPolicy; use tsunagi::config::ReconnectPolicy;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::NetworkKeys; use tsunagi::identity::NetworkKeys;
use tsunagi::proto::ControlMessage; use tsunagi::proto::ControlMessage;
use tsunagi::testing::{
TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers,
};
use tsunagi::{Agent, Error}; use tsunagi::{Agent, Error};
/// An endpoint id nobody is listening for, at an address nothing answers on. /// An endpoint id nobody is listening for, at an address nothing answers on.
+1 -3
View File
@@ -2,13 +2,11 @@
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
mod common;
use common::{TestAgent, config_with, network, settle, wait_event, wait_for_peers};
use tsunagi::agent::Event; use tsunagi::agent::Event;
use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::discovery::SharedMemoryDiscovery;
use tsunagi::identity::{NetworkName, NetworkSecret}; use tsunagi::identity::{NetworkName, NetworkSecret};
use tsunagi::proto::ControlMessage; use tsunagi::proto::ControlMessage;
use tsunagi::testing::{TestAgent, config_with, network, settle, wait_event, wait_for_peers};
use tsunagi::{Agent, Error}; use tsunagi::{Agent, Error};
#[tokio::test] #[tokio::test]