From 142fdf995c65a334bfea2c260ac4d1e2dfc5e037 Mon Sep 17 00:00:00 2001 From: tsunagi Date: Mon, 21 Sep 2026 19:55:30 +0100 Subject: [PATCH] Make the protocol a crate of its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- AGENTS.md | 32 ++++++--- Cargo.lock | 28 +++++++- README.md | 15 ++--- crates/tsunagi-cli/Cargo.toml | 1 + crates/tsunagi-cli/src/main.rs | 19 +++--- crates/tsunagi-wg-quic/Cargo.toml | 39 +++++++++++ .../src}/announcement.rs | 16 ++--- .../src}/device.rs | 8 +-- .../wireguard => tsunagi-wg-quic/src}/keys.rs | 2 +- crates/tsunagi-wg-quic/src/lib.rs | 50 ++++++++++++++ .../src}/plugin.rs | 36 +++++----- .../src}/store.rs | 12 ++-- .../tests/interface_provisioning.rs | 12 ++-- .../tests/local_control.rs | 7 +- .../tests/wireguard.rs | 53 ++++++++------- crates/tsunagi/Cargo.toml | 9 ++- crates/tsunagi/src/dataplane/mod.rs | 19 +----- crates/tsunagi/src/dataplane/wireguard/mod.rs | 67 ------------------- crates/tsunagi/src/lib.rs | 2 + crates/tsunagi/src/storage/mod.rs | 19 ++++-- .../{tests/common/mod.rs => src/testing.rs} | 35 ++++++---- crates/tsunagi/tests/authentication.rs | 4 +- crates/tsunagi/tests/cache_and_state.rs | 4 +- crates/tsunagi/tests/device_identity.rs | 4 +- crates/tsunagi/tests/discovery.rs | 4 +- crates/tsunagi/tests/end_to_end.rs | 4 +- crates/tsunagi/tests/identity.rs | 4 +- crates/tsunagi/tests/multi_peer.rs | 4 +- crates/tsunagi/tests/network_isolation.rs | 4 +- crates/tsunagi/tests/resilience.rs | 6 +- crates/tsunagi/tests/restart.rs | 4 +- 31 files changed, 289 insertions(+), 234 deletions(-) create mode 100644 crates/tsunagi-wg-quic/Cargo.toml rename crates/{tsunagi/src/dataplane/wireguard => tsunagi-wg-quic/src}/announcement.rs (94%) rename crates/{tsunagi/src/dataplane/wireguard => tsunagi-wg-quic/src}/device.rs (99%) rename crates/{tsunagi/src/dataplane/wireguard => tsunagi-wg-quic/src}/keys.rs (99%) create mode 100644 crates/tsunagi-wg-quic/src/lib.rs rename crates/{tsunagi/src/dataplane/wireguard => tsunagi-wg-quic/src}/plugin.rs (96%) rename crates/{tsunagi/src/dataplane/wireguard => tsunagi-wg-quic/src}/store.rs (96%) rename crates/{tsunagi => tsunagi-wg-quic}/tests/interface_provisioning.rs (98%) rename crates/{tsunagi => tsunagi-wg-quic}/tests/local_control.rs (98%) rename crates/{tsunagi => tsunagi-wg-quic}/tests/wireguard.rs (96%) delete mode 100644 crates/tsunagi/src/dataplane/wireguard/mod.rs rename crates/tsunagi/{tests/common/mod.rs => src/testing.rs} (82%) diff --git a/AGENTS.md b/AGENTS.md index 7c5188b..2a4f229 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. - **Control plane vs data plane.** The separation is **logical, not physical**. - The control protocol in `crates/tsunagi/src/proto/` knows nothing about packets, and - `crates/tsunagi/src/dataplane/` knows nothing about the control protocol; either can be - replaced on its own. Both may ride on iroh — refusing to would throw away - iroh's NAT traversal and force the data plane to reimplement it. They use - different ALPNs and different connections, so a busy or broken data plane - cannot disturb control traffic. + The control protocol in `crates/tsunagi/src/proto/` knows nothing about + packets, and a protocol crate knows nothing about the control protocol; + either can be replaced on its own. Both may ride on iroh — refusing to + would throw away iroh's NAT traversal and force the data plane to + reimplement it. They use different ALPNs and different connections, so a + 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` 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 *where*. - **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 control plane. - **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/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/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 | | `tests/` | integration tests; `tests/common/` is the shared harness | diff --git a/Cargo.lock b/Cargo.lock index f569f60..65a5dd1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3821,7 +3821,6 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" name = "tsunagi" version = "0.1.0" dependencies = [ - "boringtun", "bytes", "caps", "data-encoding", @@ -3848,6 +3847,7 @@ dependencies = [ "tokio", "tracing", "tracing-subscriber", + "tsunagi", "tun", "zbus", "zeroize", @@ -3868,6 +3868,32 @@ dependencies = [ "tracing", "tracing-subscriber", "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]] diff --git a/README.md b/README.md index 1423d57..9066d86 100644 --- a/README.md +++ b/README.md @@ -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: ```text - + peer b47c958462 connected over Direct rtt=Some(4.5ms) - + data link to b47c958462 for wireguard: Direct via Ip(…), datagram 1382 + + peer b47c958462 connected over direct rtt=Some(4.5ms) + + data link to b47c958462 for wg-quic: direct via 192.0.2.7:41234, datagram 1382 --- status --- 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 - 4jO4kx9Z fd15:1d9e:fa21:f201:… handshake 3s ago tx=0 rx=0 dropped=0 path=Direct via Ip(…) +wg-quic: tsun0 on 10.13.37.69/24 mtu 1280, 1/1 tunnel(s) established + 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. @@ -384,7 +384,6 @@ tests: ```bash 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. @@ -477,9 +476,9 @@ paths; tests always use temporary directories. | `state.sqlite` | device identity, network configuration, hostname | clear error, never reset | | `cache.sqlite` | address hints and other recoverable data | discarded and recreated | -The WireGuard plugin keeps its own keys in its own `wireguard.sqlite`, wherever -its configuration points, because plugin keys are neither the iroh identity nor -the network secret. +The `wg-quic` protocol keeps its own keys in its own store under `wg-quic/`, +because a protocol's keys are neither the iroh identity nor the network +secret. The command line agent puts everything under the platform's per-user directories by default; `--state-dir` and `--cache-dir` override them. diff --git a/crates/tsunagi-cli/Cargo.toml b/crates/tsunagi-cli/Cargo.toml index dcc4049..d4f5ede 100644 --- a/crates/tsunagi-cli/Cargo.toml +++ b/crates/tsunagi-cli/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] 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 tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] } tracing.workspace = true diff --git a/crates/tsunagi-cli/src/main.rs b/crates/tsunagi-cli/src/main.rs index 16c20ae..2ccb3a7 100644 --- a/crates/tsunagi-cli/src/main.rs +++ b/crates/tsunagi-cli/src/main.rs @@ -15,14 +15,13 @@ use clap::{Args, Parser, Subcommand, ValueEnum}; use tsunagi::agent::Event; use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy}; use tsunagi::dataplane::IpPlugin; -use tsunagi::dataplane::wireguard::{ - MemoryTunFactory, TunFactory, WireguardConfig, WireguardPlugin, -}; use tsunagi::discovery::{CompositeDiscovery, NetworkDiscovery, StaticBootstrap}; use tsunagi::identity::{NetworkName, NetworkSecret}; use tsunagi::iroh_types::EndpointAddr; +use tsunagi::overlay::{MemoryTunFactory, TunFactory}; use tsunagi::state::Ipv4Range; use tsunagi::{Agent, NetworkId}; +use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin}; /// A small agent for private mesh networks. #[derive(Debug, Parser)] @@ -775,8 +774,8 @@ struct ProtocolSpec { /// Every protocol this build has. const PROTOCOLS: &[ProtocolSpec] = &[ProtocolSpec { - name: tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL, - version: tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION, + name: tsunagi_wg_quic::WIREGUARD_PROTOCOL, + version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION, summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \ crosses NAT and survives where plain WireGuard is blocked", 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() { Privilege::Available => { host.push(Row::new(Health::Good, "privileges", "CAP_NET_ADMIN held")); @@ -2064,16 +2063,14 @@ async fn up(args: UpArgs) -> Result<(), Box> { } else { system_tun_factory()? }; - let mtu = args - .mtu - .unwrap_or(tsunagi::dataplane::wireguard::DEFAULT_MTU); + let mtu = args.mtu.unwrap_or(tsunagi_wg_quic::DEFAULT_MTU); config = config.with_interface(tun_factory, args.interface.clone(), mtu); } for spec in &wanted { let options = settings_for(spec, &settings); match spec.name { - tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL => { + tsunagi_wg_quic::WIREGUARD_PROTOCOL => { let mut wg = WireguardConfig::new(paths.state_dir.join("wg-quic")); if let Some(mtu) = args.mtu { wg = wg.with_mtu(mtu); @@ -2381,7 +2378,7 @@ async fn stop_signal() -> &'static str { /// goes away. #[cfg(target_os = "linux")] fn system_tun_factory() -> Result, Box> { - use tsunagi::dataplane::wireguard::{ManagedTunFactory, NetlinkProvisioner}; + use tsunagi::overlay::{ManagedTunFactory, NetlinkProvisioner}; let provisioner = NetlinkProvisioner::new()?; Ok(Arc::new(ManagedTunFactory::new(Arc::new(provisioner)))) } diff --git a/crates/tsunagi-wg-quic/Cargo.toml b/crates/tsunagi-wg-quic/Cargo.toml new file mode 100644 index 0000000..51fa0e6 --- /dev/null +++ b/crates/tsunagi-wg-quic/Cargo.toml @@ -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 diff --git a/crates/tsunagi/src/dataplane/wireguard/announcement.rs b/crates/tsunagi-wg-quic/src/announcement.rs similarity index 94% rename from crates/tsunagi/src/dataplane/wireguard/announcement.rs rename to crates/tsunagi-wg-quic/src/announcement.rs index 904ff1a..b676840 100644 --- a/crates/tsunagi/src/dataplane/wireguard/announcement.rs +++ b/crates/tsunagi-wg-quic/src/announcement.rs @@ -1,26 +1,26 @@ //! 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 — +//! [`tsunagi::dataplane::PluginCapability`]. The agent core never parses it — //! only this module does, and only after bounding every field. //! //! The announcement is deliberately tiny: a participant says **who it is**, //! not **where it is**. Reachability is the data plane transport's job, and //! 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. use serde::{Deserialize, Serialize}; -use crate::dataplane::PluginError; -use crate::identity::NetworkId; +use tsunagi::dataplane::PluginError; +use tsunagi::identity::NetworkId; -use super::keys::WgPublicKey; +use crate::keys::WgPublicKey; /// Version of the announcement format. /// /// 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 /// cannot read a newer announcement; the mismatch is reported, not misparsed. pub const ANNOUNCEMENT_VERSION: u16 = 4; @@ -122,7 +122,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; - use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; + use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; use super::super::keys::WgSecretKey; @@ -237,7 +237,7 @@ mod tests { let peer = WgSecretKey::generate().public(); let payload = WgAnnouncement::new(id, &peer).encode().unwrap(); assert!( - payload.len() < crate::config::Limits::default().max_capability_data_len, + payload.len() < tsunagi::config::Limits::default().max_capability_data_len, "announcement is {} bytes", payload.len() ); diff --git a/crates/tsunagi/src/dataplane/wireguard/device.rs b/crates/tsunagi-wg-quic/src/device.rs similarity index 99% rename from crates/tsunagi/src/dataplane/wireguard/device.rs rename to crates/tsunagi-wg-quic/src/device.rs index 5703a7b..9537dde 100644 --- a/crates/tsunagi/src/dataplane/wireguard/device.rs +++ b/crates/tsunagi-wg-quic/src/device.rs @@ -39,11 +39,11 @@ use bytes::Bytes; use iroh::EndpointId; use tokio::task::JoinHandle; -use crate::dataplane::transport::{SharedLink, TransportError}; -use crate::dataplane::{PacketSink, PluginError}; -use crate::identity::NetworkId; +use tsunagi::dataplane::transport::{SharedLink, TransportError}; +use tsunagi::dataplane::{PacketSink, PluginError}; +use tsunagi::identity::NetworkId; -use super::keys::{WgPublicKey, WgSecretKey}; +use crate::keys::{WgPublicKey, WgSecretKey}; /// How often WireGuard's own timers are driven. /// diff --git a/crates/tsunagi/src/dataplane/wireguard/keys.rs b/crates/tsunagi-wg-quic/src/keys.rs similarity index 99% rename from crates/tsunagi/src/dataplane/wireguard/keys.rs rename to crates/tsunagi-wg-quic/src/keys.rs index 7f7b1de..c8da6cf 100644 --- a/crates/tsunagi/src/dataplane/wireguard/keys.rs +++ b/crates/tsunagi-wg-quic/src/keys.rs @@ -11,7 +11,7 @@ use boringtun::x25519; use data_encoding::BASE64; use zeroize::{Zeroize, Zeroizing}; -use crate::dataplane::PluginError; +use tsunagi::dataplane::PluginError; /// Length of a raw WireGuard key, in bytes. pub const KEY_LEN: usize = 32; diff --git a/crates/tsunagi-wg-quic/src/lib.rs b/crates/tsunagi-wg-quic/src/lib.rs new file mode 100644 index 0000000..af7869c --- /dev/null +++ b/crates/tsunagi-wg-quic/src/lib.rs @@ -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; diff --git a/crates/tsunagi/src/dataplane/wireguard/plugin.rs b/crates/tsunagi-wg-quic/src/plugin.rs similarity index 96% rename from crates/tsunagi/src/dataplane/wireguard/plugin.rs rename to crates/tsunagi-wg-quic/src/plugin.rs index 6db8e92..c023614 100644 --- a/crates/tsunagi/src/dataplane/wireguard/plugin.rs +++ b/crates/tsunagi-wg-quic/src/plugin.rs @@ -8,7 +8,7 @@ //! # What this plugin does and does not know //! //! * 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 //! the transport's problem. //! * 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::task::JoinHandle; -use crate::BoxFuture; -use crate::dataplane::PacketSink; -use crate::dataplane::transport::SharedLink; -use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError}; -use crate::identity::NetworkId; +use tsunagi::BoxFuture; +use tsunagi::dataplane::PacketSink; +use tsunagi::dataplane::transport::SharedLink; +use tsunagi::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError}; +use tsunagi::identity::NetworkId; -use super::announcement::{ValidatedAnnouncement, WgAnnouncement}; -use super::device::{PeerSummary, WireguardDevice}; -use super::keys::{WgPublicKey, WgSecretKey}; -use super::store::WgKeyStore; -use crate::state::Ipv4Range; +use crate::announcement::{ValidatedAnnouncement, WgAnnouncement}; +use crate::device::{PeerSummary, WireguardDevice}; +use crate::keys::{WgPublicKey, WgSecretKey}; +use crate::store::WgKeyStore; +use tsunagi::state::Ipv4Range; /// The protocol identifier this plugin announces. /// The protocol id of this plugin. @@ -235,14 +235,14 @@ pub struct WireguardPlugin { impl WireguardPlugin { /// The settings this protocol accepts. - pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = &[ - crate::dataplane::ProtocolOption { + pub const OPTIONS: &'static [tsunagi::dataplane::ProtocolOption] = &[ + tsunagi::dataplane::ProtocolOption { key: "keepalive", value: "SECONDS", help: "keeps a tunnel and its link warm through a NAT; 0 turns it off", default: Some("25"), }, - crate::dataplane::ProtocolOption { + tsunagi::dataplane::ProtocolOption { key: "mtu", value: "BYTES", help: "largest packet a tunnel will carry, at least 576", @@ -470,7 +470,7 @@ impl Worker { Some(sink) => Arc::clone(sink), // Not attached to an agent: the protocol still runs, and its // packets have nowhere to go. - None => Arc::new(crate::dataplane::DiscardPackets) as Arc, + None => Arc::new(tsunagi::dataplane::DiscardPackets) as Arc, }; let device = Arc::new(WireguardDevice::start(network, key, sink)); @@ -650,10 +650,10 @@ impl IpPlugin for WireguardPlugin { } 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 } @@ -686,7 +686,7 @@ impl IpPlugin for WireguardPlugin { let announcement = WgAnnouncement::new(network, &state.key.public()); Ok(Some(PluginCapability { protocol: WIREGUARD_PROTOCOL.to_string(), - version: super::announcement::ANNOUNCEMENT_VERSION, + version: crate::announcement::ANNOUNCEMENT_VERSION, enabled: true, data: announcement.encode()?, })) diff --git a/crates/tsunagi/src/dataplane/wireguard/store.rs b/crates/tsunagi-wg-quic/src/store.rs similarity index 96% rename from crates/tsunagi/src/dataplane/wireguard/store.rs rename to crates/tsunagi-wg-quic/src/store.rs index f7a229c..d00f4dd 100644 --- a/crates/tsunagi/src/dataplane/wireguard/store.rs +++ b/crates/tsunagi-wg-quic/src/store.rs @@ -17,10 +17,10 @@ use std::sync::Mutex; use rusqlite::{Connection, OptionalExtension, params}; -use crate::dataplane::PluginError; -use crate::identity::NetworkId; +use tsunagi::dataplane::PluginError; +use tsunagi::identity::NetworkId; -use super::keys::{KEY_LEN, WgSecretKey}; +use crate::keys::{KEY_LEN, WgSecretKey}; /// Schema version written by this build. pub const SCHEMA_VERSION: i64 = 1; @@ -37,14 +37,14 @@ impl WgKeyStore { pub fn open(path: impl AsRef) -> Result { let path = path.as_ref().to_path_buf(); if let Some(parent) = path.parent() { - crate::storage::create_dir(parent) + tsunagi::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) + tsunagi::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)) @@ -169,7 +169,7 @@ mod tests { #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] use super::*; - use crate::identity::{NetworkKeys, NetworkName, NetworkSecret}; + use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; fn network(name: &str) -> NetworkId { NetworkKeys::derive( diff --git a/crates/tsunagi/tests/interface_provisioning.rs b/crates/tsunagi-wg-quic/tests/interface_provisioning.rs similarity index 98% rename from crates/tsunagi/tests/interface_provisioning.rs rename to crates/tsunagi-wg-quic/tests/interface_provisioning.rs index 5c3d0fd..f24d2df 100644 --- a/crates/tsunagi/tests/interface_provisioning.rs +++ b/crates/tsunagi-wg-quic/tests/interface_provisioning.rs @@ -11,23 +11,21 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::net::{IpAddr, Ipv4Addr}; use std::sync::Arc; use std::time::Duration; -use common::{config_with, network, wait_until}; use tempfile::TempDir; use tsunagi::dataplane::IpPlugin; -use tsunagi::dataplane::wireguard::{ - Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, WireguardConfig, - WireguardPlugin, -}; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::identity::NetworkId; +use tsunagi::overlay::{ + Cidr, InterfaceState, LinkKind, ManagedTunFactory, MockHost, MockProvisioner, +}; use tsunagi::state::DEFAULT_IPV4_RANGE; +use tsunagi::testing::{config_with, network, wait_until}; use tsunagi::{Agent, NetworkStatus}; +use tsunagi_wg_quic::{WireguardConfig, WireguardPlugin}; /// An agent whose overlay interface is applied to a pretend host. struct HostedAgent { diff --git a/crates/tsunagi/tests/local_control.rs b/crates/tsunagi-wg-quic/tests/local_control.rs similarity index 98% rename from crates/tsunagi/tests/local_control.rs rename to crates/tsunagi-wg-quic/tests/local_control.rs index 980aea3..e03619f 100644 --- a/crates/tsunagi/tests/local_control.rs +++ b/crates/tsunagi-wg-quic/tests/local_control.rs @@ -6,19 +6,18 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::sync::Arc; use std::time::Duration; -use common::{config_with, network, wait_for_peers, wait_until}; use tempfile::TempDir; use tsunagi::dataplane::IpPlugin; -use tsunagi::dataplane::wireguard::{MemoryTunFactory, WireguardConfig, WireguardPlugin}; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::ipc::unix::{ControlSocket, request_status}; 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_wg_quic::{WireguardConfig, WireguardPlugin}; /// Builds the report the way the binary does, from the agent plus the plugin. fn source(agent: Agent, plugin: Arc) -> Arc { diff --git a/crates/tsunagi/tests/wireguard.rs b/crates/tsunagi-wg-quic/tests/wireguard.rs similarity index 96% rename from crates/tsunagi/tests/wireguard.rs rename to crates/tsunagi-wg-quic/tests/wireguard.rs index 50aea88..a6d3464 100644 --- a/crates/tsunagi/tests/wireguard.rs +++ b/crates/tsunagi-wg-quic/tests/wireguard.rs @@ -8,25 +8,24 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::net::Ipv4Addr; use std::sync::Arc; use std::time::Duration; use bytes::Bytes; -use common::{config_with, network, settle, wait_event, wait_for_peers, wait_until}; use iroh::EndpointId; use tempfile::TempDir; use tsunagi::agent::Event; use tsunagi::dataplane::IpPlugin; -use tsunagi::dataplane::wireguard::{ - Ipv4Range, MemoryTun, MemoryTunFactory, WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey, - WireguardConfig, WireguardPlugin, -}; use tsunagi::discovery::SharedMemoryDiscovery; 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_wg_quic::{ + WIREGUARD_PROTOCOL, WgAnnouncement, WgSecretKey, WireguardConfig, WireguardPlugin, +}; /// An agent with a WireGuard plugin backed by an in-memory packet interface. 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"; 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 .expect("the packet should arrive") .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. 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 .expect("the reply should arrive") .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. 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 .expect("the honest packet should arrive") .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. 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 .expect("the IPv4 packet should arrive") .unwrap(); @@ -379,7 +378,7 @@ async fn an_ipv4_source_a_peer_does_not_own_is_dropped() { // The honest one still gets through. 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 .expect("the honest packet should arrive") .unwrap(); @@ -669,10 +668,13 @@ async fn a_mesh_of_three_establishes_every_tunnel() { a.tun(network_id) .await .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()) - .await - .expect("the packet should arrive") - .unwrap(); + let received = tokio::time::timeout( + tsunagi::testing::DEADLINE, + c.tun(network_id).await.pop_to_os(), + ) + .await + .expect("the packet should arrive") + .unwrap(); assert_eq!(&received[20..], b"a to c"); a.shutdown().await; @@ -773,10 +775,13 @@ async fn two_networks_share_one_interface_with_keys_and_ranges_of_their_own() { hub.tun(alpha) .await .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()) - .await - .expect("the packet should arrive") - .unwrap(); + let seen = tokio::time::timeout( + tsunagi::testing::DEADLINE, + left.tun(alpha).await.pop_to_os(), + ) + .await + .expect("the packet should arrive") + .unwrap(); assert_eq!(&seen[20..], b"alpha only"); assert!( tokio::time::timeout( @@ -1062,7 +1067,7 @@ impl IpPlugin for FromTheFuture { } fn protocol_version(&self) -> u16 { - tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION + 1 + tsunagi_wg_quic::ANNOUNCEMENT_VERSION + 1 } fn local_capability( @@ -1105,7 +1110,7 @@ impl IpPlugin for ForgingPlugin { /// The same version, so the announcement is looked at rather than /// dismissed for the wrong reason. fn protocol_version(&self) -> u16 { - tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION + tsunagi_wg_quic::ANNOUNCEMENT_VERSION } fn local_capability( @@ -1120,7 +1125,7 @@ impl IpPlugin for ForgingPlugin { protocol: WIREGUARD_PROTOCOL.to_string(), // The version it actually speaks, so the payload is examined // rather than set aside for the wrong reason. - version: tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION, + version: tsunagi_wg_quic::ANNOUNCEMENT_VERSION, enabled: true, data, })) @@ -1141,7 +1146,7 @@ impl IpPlugin for ForgingPlugin { #[tokio::test] 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 // nothing below it is worth offering. The floor used to be 1280 for diff --git a/crates/tsunagi/Cargo.toml b/crates/tsunagi/Cargo.toml index 4eb40d7..21e3ad4 100644 --- a/crates/tsunagi/Cargo.toml +++ b/crates/tsunagi/Cargo.toml @@ -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 # for now; the zone and the server work without it. 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] iroh.workspace = true @@ -50,8 +53,10 @@ directories = "6.0" # forbidden `unsafe` and will not make one. gethostname = "1.1" netwatch = "0.19.3" -boringtun = { version = "0.7.1", default-features = false } 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 # 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 } [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"] } tempfile.workspace = true tracing-subscriber.workspace = true diff --git a/crates/tsunagi/src/dataplane/mod.rs b/crates/tsunagi/src/dataplane/mod.rs index eef06df..8d6a7fb 100644 --- a/crates/tsunagi/src/dataplane/mod.rs +++ b/crates/tsunagi/src/dataplane/mod.rs @@ -20,7 +20,6 @@ //! recorded and surfaced, the control plane keeps running. pub mod transport; -pub mod wireguard; use std::sync::Arc; @@ -42,7 +41,7 @@ pub const MAX_PROTOCOL_ID_LEN: usize = 32; /// chooses to do so must validate it itself. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] 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, /// Version of the plugin's announcement format. pub version: u16, @@ -112,20 +111,6 @@ pub enum PluginError { 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 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. #[derive(Debug)] 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 /// its runtime tasks. Anything slow belongs in the plugin's own tasks. 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. fn protocol_id(&self) -> &str; diff --git a/crates/tsunagi/src/dataplane/wireguard/mod.rs b/crates/tsunagi/src/dataplane/wireguard/mod.rs deleted file mode 100644 index 8253497..0000000 --- a/crates/tsunagi/src/dataplane/wireguard/mod.rs +++ /dev/null @@ -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; diff --git a/crates/tsunagi/src/lib.rs b/crates/tsunagi/src/lib.rs index 1b96c6b..1619602 100644 --- a/crates/tsunagi/src/lib.rs +++ b/crates/tsunagi/src/lib.rs @@ -48,6 +48,8 @@ pub mod overlay; pub mod proto; pub mod state; pub mod storage; +#[cfg(feature = "testing")] +pub mod testing; pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus}; pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy}; diff --git a/crates/tsunagi/src/storage/mod.rs b/crates/tsunagi/src/storage/mod.rs index 5ca1a78..a7fda18 100644 --- a/crates/tsunagi/src/storage/mod.rs +++ b/crates/tsunagi/src/storage/mod.rs @@ -56,8 +56,13 @@ fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> { 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)] -pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> { +pub fn restrict_path_permissions(path: &Path) -> Result<()> { use std::os::unix::fs::PermissionsExt; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| { Error::Io { @@ -67,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))] -pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> { +pub fn restrict_path_permissions(_path: &Path) -> Result<()> { Ok(()) } +/// Restricts a directory to the user that owns it. #[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; std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| { 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))] -pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> { +pub fn restrict_dir_permissions(_path: &Path) -> Result<()> { 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 { path: path.to_path_buf(), source, diff --git a/crates/tsunagi/tests/common/mod.rs b/crates/tsunagi/src/testing.rs similarity index 82% rename from crates/tsunagi/tests/common/mod.rs rename to crates/tsunagi/src/testing.rs index 7d3f903..86c0b8b 100644 --- a/crates/tsunagi/tests/common/mod.rs +++ b/crates/tsunagi/src/testing.rs @@ -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 -//! files and independent agent instances. Only discovery is substituted; iroh, -//! the handshake, message passing and persistent storage are not. +//! Behind the `testing` feature, because it is only of use to a test and +//! pulls in `tempfile` and a tracing subscriber. It lives here rather than +//! 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 -//! overall deadline", never a fixed multi-second sleep. +//! Everything here builds agents that touch nothing outside a temporary +//! 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::path::Path; use std::sync::Arc; 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 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. /// @@ -50,7 +55,7 @@ const POLL_INTERVAL: Duration = Duration::from_millis(25); /// port mapping, so the suite needs neither the internet nor privileges. /// Timeouts are shortened so that failure paths finish quickly. pub fn local_config(dir: &Path) -> AgentConfig { - let limits = tsunagi::Limits { + let limits = crate::Limits { dial_timeout: Duration::from_millis(1500), handshake_timeout: Duration::from_secs(5), ..Default::default() @@ -69,7 +74,9 @@ pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig /// A temporary directory plus the agent running on it. pub struct TestAgent { + /// The directory it keeps its state in, removed when this is dropped. pub dir: TempDir, + /// The agent itself. pub agent: Agent, } @@ -170,7 +177,7 @@ pub async fn settle() { /// Waits until `agent` has `count` authenticated peers in `network`. pub async fn wait_for_peers( agent: &Agent, - network: tsunagi::NetworkId, + network: crate::NetworkId, count: usize, ) -> Vec { wait_until(&format!("{count} peers in {network}"), || async move { diff --git a/crates/tsunagi/tests/authentication.rs b/crates/tsunagi/tests/authentication.rs index 5d00454..2d23e74 100644 --- a/crates/tsunagi/tests/authentication.rs +++ b/crates/tsunagi/tests/authentication.rs @@ -8,9 +8,6 @@ #![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, EndpointAddr, RelayMode}; use tsunagi::agent::Event; @@ -22,6 +19,7 @@ use tsunagi::proto::message::{ }; use tsunagi::proto::{read_frame, write_frame}; use tsunagi::test_support; +use tsunagi::testing::{TestAgent, network, settle, wait_event}; /// A bare iroh endpoint with no tsunagi agent behind it. async fn raw_endpoint() -> Endpoint { diff --git a/crates/tsunagi/tests/cache_and_state.rs b/crates/tsunagi/tests/cache_and_state.rs index f9bf8b2..5b58464 100644 --- a/crates/tsunagi/tests/cache_and_state.rs +++ b/crates/tsunagi/tests/cache_and_state.rs @@ -2,14 +2,12 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::io::Write; -use common::{TestAgent, config_with, local_config, network, wait_for_peers}; use tsunagi::config::StoragePaths; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::storage::CacheOutcome; +use tsunagi::testing::{TestAgent, config_with, local_config, network, wait_for_peers}; use tsunagi::{Agent, Error}; /// Overwrites a file with bytes that are definitely not a SQLite database. diff --git a/crates/tsunagi/tests/device_identity.rs b/crates/tsunagi/tests/device_identity.rs index 42a7354..4cd761d 100644 --- a/crates/tsunagi/tests/device_identity.rs +++ b/crates/tsunagi/tests/device_identity.rs @@ -8,9 +8,6 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - -use common::{config_with, network, wait_until}; use tempfile::TempDir; use tsunagi::Agent; use tsunagi::config::StoragePaths; @@ -18,6 +15,7 @@ use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; use tsunagi::state::{RecordBody, StateSet}; use tsunagi::storage::StateStore; +use tsunagi::testing::{config_with, network, wait_until}; #[tokio::test] async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() { diff --git a/crates/tsunagi/tests/discovery.rs b/crates/tsunagi/tests/discovery.rs index c39cdaf..a3c6b5a 100644 --- a/crates/tsunagi/tests/discovery.rs +++ b/crates/tsunagi/tests/discovery.rs @@ -3,16 +3,14 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::sync::Arc; -use common::{TestAgent, local_config, network, settle, wait_for_peers}; use tsunagi::Agent; use tsunagi::discovery::{ CandidateSource, CompositeDiscovery, NetworkDiscovery, SharedMemoryDiscovery, StaticBootstrap, }; use tsunagi::identity::NetworkKeys; +use tsunagi::testing::{TestAgent, local_config, network, settle, wait_for_peers}; #[tokio::test] async fn a_static_bootstrap_candidate_is_enough_to_join() { diff --git a/crates/tsunagi/tests/end_to_end.rs b/crates/tsunagi/tests/end_to_end.rs index a93c044..a63945f 100644 --- a/crates/tsunagi/tests/end_to_end.rs +++ b/crates/tsunagi/tests/end_to_end.rs @@ -3,12 +3,10 @@ #![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::discovery::SharedMemoryDiscovery; use tsunagi::proto::ControlMessage; +use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers}; #[tokio::test] async fn two_agents_authenticate_and_exchange_messages() { diff --git a/crates/tsunagi/tests/identity.rs b/crates/tsunagi/tests/identity.rs index a72dec7..d7dba37 100644 --- a/crates/tsunagi/tests/identity.rs +++ b/crates/tsunagi/tests/identity.rs @@ -5,12 +5,10 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - -use common::{TestAgent, config_with, network}; use tsunagi::Agent; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret}; +use tsunagi::testing::{TestAgent, config_with, network}; #[test] fn derivation_is_a_pure_function_of_name_and_secret() { diff --git a/crates/tsunagi/tests/multi_peer.rs b/crates/tsunagi/tests/multi_peer.rs index b42011e..f7f5231 100644 --- a/crates/tsunagi/tests/multi_peer.rs +++ b/crates/tsunagi/tests/multi_peer.rs @@ -3,16 +3,14 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::collections::HashSet; use std::sync::Arc; -use common::{TestAgent, network, wait_event, wait_for_peers, wait_until}; use tsunagi::agent::Event; use tsunagi::dataplane::TestCapabilityPlugin; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::proto::ControlMessage; +use tsunagi::testing::{TestAgent, network, wait_event, wait_for_peers, wait_until}; #[tokio::test] async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() { diff --git a/crates/tsunagi/tests/network_isolation.rs b/crates/tsunagi/tests/network_isolation.rs index c679b35..8a75cec 100644 --- a/crates/tsunagi/tests/network_isolation.rs +++ b/crates/tsunagi/tests/network_isolation.rs @@ -2,9 +2,6 @@ #![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, RelayMode}; use tsunagi::agent::Event; @@ -16,6 +13,7 @@ use tsunagi::proto::message::{ }; use tsunagi::proto::{read_frame, write_frame}; use tsunagi::test_support; +use tsunagi::testing::{TestAgent, network, settle, wait_event, wait_for_peers}; const LIMIT: usize = 64 * 1024; diff --git a/crates/tsunagi/tests/resilience.rs b/crates/tsunagi/tests/resilience.rs index c6ae634..60fa93c 100644 --- a/crates/tsunagi/tests/resilience.rs +++ b/crates/tsunagi/tests/resilience.rs @@ -3,18 +3,18 @@ #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] -mod common; - use std::net::SocketAddr; use std::time::Duration; -use common::{TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers}; use iroh::{EndpointAddr, SecretKey}; use tsunagi::agent::Event; use tsunagi::config::ReconnectPolicy; use tsunagi::discovery::SharedMemoryDiscovery; use tsunagi::identity::NetworkKeys; use tsunagi::proto::ControlMessage; +use tsunagi::testing::{ + TestAgent, config_with, local_config, network, settle, wait_event, wait_for_peers, +}; use tsunagi::{Agent, Error}; /// An endpoint id nobody is listening for, at an address nothing answers on. diff --git a/crates/tsunagi/tests/restart.rs b/crates/tsunagi/tests/restart.rs index 841aa2f..8370504 100644 --- a/crates/tsunagi/tests/restart.rs +++ b/crates/tsunagi/tests/restart.rs @@ -2,13 +2,11 @@ #![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::discovery::SharedMemoryDiscovery; use tsunagi::identity::{NetworkName, NetworkSecret}; use tsunagi::proto::ControlMessage; +use tsunagi::testing::{TestAgent, config_with, network, settle, wait_event, wait_for_peers}; use tsunagi::{Agent, Error}; #[tokio::test]