Split the system level and the command line into a workspace
First step of separating the layers. The library and the binary are now crates/tsunagi and crates/tsunagi-cli, which means the plugin crate to come can be told apart from the core by the compiler rather than by discipline. Falls out of it immediately: the CLI's dependencies stop being features of the library. clap, anstream and tracing-subscriber were optional dependencies behind a `cli` feature that every library user had to remember to turn off; now they belong to the crate that uses them, and the library defaults to no features at all. The one test that drives the binary moved beside it — a library cannot depend on a binary built from a crate that depends on the library — and was rewritten against the public API instead of the test harness. AGENTS.md said to prefer one crate. It now says the system level and its plugins are separate crates, for the reason above, and that everything else stays one crate. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,35 @@
|
||||
[package]
|
||||
name = "tsunagi-cli"
|
||||
version = "0.1.0"
|
||||
description = "The tsunagi command line agent."
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "tsunagi"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
tsunagi = { path = "../tsunagi", version = "0.1.0", features = ["tun-device", "dns-publish"] }
|
||||
iroh.workspace = true
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "signal"] }
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
# Already in the tree through clap. `anstream` strips the escapes when stdout
|
||||
# is not a terminal and turns on virtual terminal processing on Windows, so
|
||||
# colour is never written where it would show up as rubbish.
|
||||
anstream = "1.0"
|
||||
anstyle = "1.0"
|
||||
# Local interface addresses, for the diagnostics in `status`.
|
||||
netwatch = "0.19.3"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
simple-dns = "0.12"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,159 @@
|
||||
//! The DNS service as the binary actually runs it.
|
||||
//!
|
||||
//! The zone and the server have their own tests. What this covers is the
|
||||
//! wiring between them and the agent, which is where the interesting
|
||||
//! mistakes live: choosing an address to listen on, and deciding when to
|
||||
//! rebuild the listener. Both were wrong once, and neither was reachable
|
||||
//! from a unit test, so this runs the real binary.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{SocketAddr, UdpSocket};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use simple_dns::{Name, Packet, QCLASS, RCODE, TYPE, rdata::RData};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// A port per test, high enough to need no privileges and fixed so the query
|
||||
// knows where to look. Distinct because these tests run in parallel and each
|
||||
// starts its own agent.
|
||||
const PORT_BINDS: u16 = 15361;
|
||||
const PORT_REBIND: u16 = 15362;
|
||||
const PORT_REFUSE: u16 = 15363;
|
||||
|
||||
/// Asks, and returns the raw reply. Raw because a parsed packet borrows
|
||||
/// from the bytes it came out of.
|
||||
fn query(server: SocketAddr, name: &str, qtype: TYPE) -> Option<Vec<u8>> {
|
||||
let mut packet = Packet::new_query(0x2468);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
let bytes = packet.build_bytes_vec().unwrap();
|
||||
|
||||
let socket = UdpSocket::bind("127.0.0.1:0").ok()?;
|
||||
socket
|
||||
.set_read_timeout(Some(Duration::from_millis(500)))
|
||||
.ok()?;
|
||||
socket.send_to(&bytes, server).ok()?;
|
||||
let mut buffer = vec![0u8; 4096];
|
||||
let read = socket.recv(&mut buffer).ok()?;
|
||||
buffer.truncate(read);
|
||||
Packet::parse(&buffer).ok()?;
|
||||
Some(buffer)
|
||||
}
|
||||
|
||||
/// Whether a reply carries at least one answer record.
|
||||
fn has_answer(reply: &[u8]) -> bool {
|
||||
Packet::parse(reply).is_ok_and(|packet| !packet.answers.is_empty())
|
||||
}
|
||||
|
||||
/// Blocks until the server answers, or gives up.
|
||||
fn wait_for_answer(server: SocketAddr, name: &str) -> Vec<u8> {
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
loop {
|
||||
if let Some(reply) = query(server, name, TYPE::A)
|
||||
&& has_answer(&reply)
|
||||
{
|
||||
return reply;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the dns server never answered");
|
||||
std::thread::sleep(Duration::from_millis(200));
|
||||
}
|
||||
}
|
||||
|
||||
/// The agent, running as a real process with its DNS service on.
|
||||
struct Running {
|
||||
child: std::process::Child,
|
||||
_dir: TempDir,
|
||||
}
|
||||
|
||||
impl Drop for Running {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
fn start(zone: &str, port: u16) -> Running {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let child = std::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.args([
|
||||
"up",
|
||||
"--network",
|
||||
"dnswiring",
|
||||
"--secret",
|
||||
"a-secret-for-the-dns-test",
|
||||
])
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
// No real interface and no internet: this is about the wiring.
|
||||
.args(["--transport", "local", "--no-tun", "--wireguard", "--dns"])
|
||||
.args(["--dns-zone", zone])
|
||||
.args(["--dns-port", &port.to_string()])
|
||||
.args(["--log", "error", "--status-interval", "0"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("the agent binary starts");
|
||||
Running { child, _dir: dir }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_resolver_comes_up_even_with_no_overlay_interface_to_put_it_on() {
|
||||
// The promise is that the port is served whatever else fails. With
|
||||
// `--no-tun` the allocated overlay address is on no interface, so
|
||||
// binding to it cannot work and loopback is the answer — getting this
|
||||
// wrong left the feature silently dead.
|
||||
let _agent = start("lab.internal", PORT_BINDS);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_BINDS}").parse().unwrap();
|
||||
|
||||
let reply = wait_for_answer(server, &format!("{}.lab.internal", hostname()));
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::NoError);
|
||||
match &answer.answers[0].rdata {
|
||||
RData::A(_) => {}
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_listener_is_not_rebuilt_on_every_pass() {
|
||||
// The supervisor compares what it tried last time, not what it got. The
|
||||
// other way round it rebound on every tick, because the preferred
|
||||
// address is one that never binds here — and the port was shut for a
|
||||
// moment each time.
|
||||
let _agent = start("rebind.internal", PORT_REBIND);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REBIND}").parse().unwrap();
|
||||
let name = format!("{}.rebind.internal", hostname());
|
||||
wait_for_answer(server, &name);
|
||||
|
||||
// Long enough to cross several of the supervisor's passes.
|
||||
for round in 0..6 {
|
||||
std::thread::sleep(Duration::from_millis(900));
|
||||
assert!(
|
||||
query(server, &name, TYPE::A).is_some_and(|reply| has_answer(&reply)),
|
||||
"the server stopped answering on round {round}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
let _agent = start("refuse.internal", PORT_REFUSE);
|
||||
let server: SocketAddr = format!("127.0.0.1:{PORT_REFUSE}").parse().unwrap();
|
||||
wait_for_answer(server, &format!("{}.refuse.internal", hostname()));
|
||||
|
||||
let reply = query(server, "example.com", TYPE::A).expect("an answer");
|
||||
let answer = Packet::parse(&reply).unwrap();
|
||||
assert_eq!(answer.rcode(), RCODE::Refused);
|
||||
assert!(answer.answers.is_empty());
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
tsunagi::agent::system_hostname().unwrap_or_else(|| "unknown".into())
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! What the command line says about this device, against a running agent.
|
||||
//!
|
||||
//! These drive the real binary, which is why they live beside it rather
|
||||
//! than with the library's own tests: the library cannot depend on a binary
|
||||
//! built from a crate that depends on it.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use tempfile::TempDir;
|
||||
use tsunagi::Agent;
|
||||
use tsunagi::config::{AgentConfig, StoragePaths, TransportPolicy};
|
||||
|
||||
/// Asking who this device is must not need the directory lock.
|
||||
///
|
||||
/// The lock belongs to the one agent allowed to *write* the state. `id` only
|
||||
/// reads, so making it take the lock would mean the question could never be
|
||||
/// answered while an agent was running — which is exactly when you want to
|
||||
/// ask it.
|
||||
#[tokio::test]
|
||||
async fn identity_can_be_read_while_an_agent_holds_the_directory() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
// Hold the directory the way a running agent does.
|
||||
let agent = Agent::spawn(
|
||||
AgentConfig::new(StoragePaths::under(dir.path()))
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = agent.endpoint_id().to_string();
|
||||
|
||||
let output = tokio::process::Command::new(env!("CARGO_BIN_EXE_tsunagi"))
|
||||
.arg("id")
|
||||
// The same layout `StoragePaths::under` gives the agent above.
|
||||
.arg("--state-dir")
|
||||
.arg(dir.path().join("state"))
|
||||
.arg("--cache-dir")
|
||||
.arg(dir.path().join("cache"))
|
||||
.output()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"`id` failed while an agent was running:\n{stderr}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains(&expected),
|
||||
"expected {expected} in:\n{stdout}"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
[package]
|
||||
name = "tsunagi"
|
||||
version = "0.1.0"
|
||||
description = "Small private mesh networks: persistent agent identity, deterministic network spaces, an iroh control plane, signed state and a local DNS view."
|
||||
readme = "../../README.md"
|
||||
keywords = ["mesh", "p2p", "iroh", "networking"]
|
||||
categories = ["network-programming"]
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[features]
|
||||
default = []
|
||||
# A real TUN device, so a plugin can carry actual IP traffic. Needs
|
||||
# CAP_NET_ADMIN at run time; without it the in-memory device serves tests.
|
||||
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"]
|
||||
|
||||
[dependencies]
|
||||
iroh.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
postcard.workspace = true
|
||||
bytes.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
hex.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||
hkdf = "0.13"
|
||||
hmac = "0.13"
|
||||
sha2 = "0.11"
|
||||
subtle = "2.6"
|
||||
zeroize = { version = "1.9", features = ["derive"] }
|
||||
rand = "0.10"
|
||||
# Already in the tree through iroh. A packet codec, not a DNS server: the
|
||||
# zone logic is ours and a full server framework would be a large dependency
|
||||
# for answering A records from memory.
|
||||
simple-dns = "0.12"
|
||||
# The IANA top-level domain list, compiled in: one function, no
|
||||
# dependencies, no network. Used only to warn that a zone name shadows a
|
||||
# real public domain, never to refuse one.
|
||||
tld = "2.40"
|
||||
fs4 = { version = "1.1", features = ["sync"] }
|
||||
directories = "6.0"
|
||||
# The real system hostname, without a libc call of our own: this crate is
|
||||
# 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 }
|
||||
|
||||
# Linux-only interface provisioning. `rtnetlink` configures the interface in
|
||||
# process, so no `ip` invocation is ever needed; `caps` keeps CAP_NET_ADMIN
|
||||
# out of the effective set except during the moments it is used.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
rtnetlink = { version = "0.23", optional = true }
|
||||
# Pure Rust D-Bus, no libdbus to link against. `tokio` rather than the
|
||||
# default reactor, because the agent brings its own.
|
||||
zbus = { version = "5.19", default-features = false, features = ["tokio"], optional = true }
|
||||
caps = { version = "0.5", optional = true }
|
||||
futures-util = { version = "0.3", default-features = false, optional = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53", features = ["rt", "rt-multi-thread", "sync", "time", "macros", "process"] }
|
||||
tempfile.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Events published by a running agent.
|
||||
//!
|
||||
//! Events are delivered through a bounded [`tokio::sync::broadcast`] channel.
|
||||
//! A slow subscriber is lagged, never allowed to stall the runtime.
|
||||
//!
|
||||
//! Nothing here ever carries a secret, a derived key or a handshake proof.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::EndpointId;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
use crate::net::TransportKind;
|
||||
use crate::proto::ControlMessage;
|
||||
use crate::proto::handshake::Role;
|
||||
|
||||
/// Something that happened inside the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
#[non_exhaustive]
|
||||
pub enum Event {
|
||||
/// A network was activated locally.
|
||||
NetworkActivated {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
},
|
||||
/// A network was deactivated locally.
|
||||
///
|
||||
/// This is a local deactivation only. It is not a signed revocation of
|
||||
/// membership, and it says nothing about the network's other participants.
|
||||
NetworkDeactivated {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
},
|
||||
/// A peer completed the handshake and has an authenticated session.
|
||||
PeerConnected {
|
||||
/// The network the session belongs to.
|
||||
network: NetworkId,
|
||||
/// Authenticated peer endpoint id.
|
||||
peer: EndpointId,
|
||||
/// Which side this agent played.
|
||||
role: Role,
|
||||
/// How the connection currently reaches the peer.
|
||||
transport: TransportKind,
|
||||
/// RTT of the selected path, if iroh reported one.
|
||||
rtt: Option<Duration>,
|
||||
},
|
||||
/// A peer's session ended.
|
||||
PeerDisconnected {
|
||||
/// The network the session belonged to.
|
||||
network: NetworkId,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// Why the session ended. Free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// A control message arrived on an authenticated session.
|
||||
MessageReceived {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
/// The peer that sent it.
|
||||
peer: EndpointId,
|
||||
/// The message.
|
||||
message: ControlMessage,
|
||||
},
|
||||
/// An outbound dial failed.
|
||||
///
|
||||
/// A dead candidate produces these and nothing else; other peers keep
|
||||
/// connecting normally.
|
||||
DialFailed {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
/// The candidate that could not be reached.
|
||||
peer: EndpointId,
|
||||
/// Why it failed.
|
||||
reason: String,
|
||||
},
|
||||
/// A handshake was rejected.
|
||||
///
|
||||
/// The network is `None` when the failure happened before the peer's
|
||||
/// requested network could be resolved.
|
||||
HandshakeRejected {
|
||||
/// The network, when known.
|
||||
network: Option<NetworkId>,
|
||||
/// The peer, when known.
|
||||
peer: Option<EndpointId>,
|
||||
/// Why it was rejected.
|
||||
reason: String,
|
||||
},
|
||||
/// A message or session was rejected for violating the protocol.
|
||||
ProtocolViolation {
|
||||
/// The network, when known.
|
||||
network: Option<NetworkId>,
|
||||
/// The peer, when known.
|
||||
peer: Option<EndpointId>,
|
||||
/// What was wrong.
|
||||
reason: String,
|
||||
},
|
||||
/// The disposable cache was discarded and recreated at startup.
|
||||
CacheReset {
|
||||
/// Why it was discarded. Free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
/// A data plane link to a peer is up.
|
||||
///
|
||||
/// The data plane is a separate connection from the control plane; this
|
||||
/// says nothing about the control session, and vice versa.
|
||||
DataLinkUp {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// Plugin protocol the link carries.
|
||||
protocol: String,
|
||||
/// What the transport reports about the path in use.
|
||||
path: String,
|
||||
/// Largest datagram the link can carry.
|
||||
max_datagram: usize,
|
||||
},
|
||||
/// A data plane link went away or could not be opened.
|
||||
///
|
||||
/// Never fatal: the control plane keeps running and the link is retried.
|
||||
DataLinkDown {
|
||||
/// The network.
|
||||
network: NetworkId,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// Plugin protocol the link would have carried.
|
||||
protocol: String,
|
||||
/// Why it is not up.
|
||||
reason: String,
|
||||
},
|
||||
/// An IP plugin reported an error. Never fatal.
|
||||
PluginError {
|
||||
/// The network the call was scoped to.
|
||||
network: NetworkId,
|
||||
/// Plugin protocol id.
|
||||
protocol: String,
|
||||
/// The reported error.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,900 @@
|
||||
//! The agent runtime.
|
||||
//!
|
||||
//! An [`Agent`] owns one persistent identity, one iroh endpoint, one state
|
||||
//! directory and any number of networks. It is started explicitly with
|
||||
//! [`Agent::spawn`] and stopped explicitly with [`Agent::shutdown`]; it starts
|
||||
//! no runtime of its own, installs no global logger, handles no signals and
|
||||
//! never calls `process::exit`. Several agents can therefore run side by side in
|
||||
//! one process, which is exactly what the integration tests do.
|
||||
//!
|
||||
//! # Local readiness
|
||||
//!
|
||||
//! [`Agent::spawn`] returns as soon as the local agent is ready. It never waits
|
||||
//! for other participants to appear or for a relay to become reachable.
|
||||
//!
|
||||
//! # Failure containment
|
||||
//!
|
||||
//! A bad signature, a wrong secret, a malformed frame or an unknown version
|
||||
//! rejects that message or that session. It never stops another network and
|
||||
//! never stops the agent. There is no global, irreversible error flag.
|
||||
|
||||
mod events;
|
||||
mod network;
|
||||
mod session;
|
||||
mod shutdown;
|
||||
mod status;
|
||||
|
||||
pub use events::Event;
|
||||
pub use status::{
|
||||
AgentStatus, CandidateStatus, MemberStatus, NetworkMetrics, NetworkState, NetworkStatus,
|
||||
PeerStatus,
|
||||
};
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Weak};
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
use tokio::sync::{RwLock, broadcast, mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::config::{AgentConfig, Limits};
|
||||
use crate::dataplane::transport::PacketTransport;
|
||||
use crate::dataplane::transport::iroh_link::{IrohTransport, TransportContext};
|
||||
use crate::dataplane::{PluginContext, PluginRequest};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||
use crate::net::EndpointAdapter;
|
||||
use crate::proto::handshake;
|
||||
use crate::proto::message::ControlMessage;
|
||||
use crate::storage::{CacheOutcome, Storage};
|
||||
|
||||
use network::{InboundSession, NetCommand, NetworkHandle, RuntimeParams};
|
||||
use shutdown::Shutdown;
|
||||
|
||||
/// Summary of a configured network, whether or not it is running.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConfiguredNetwork {
|
||||
/// Public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
pub name: NetworkName,
|
||||
/// Whether it is activated automatically at startup.
|
||||
pub auto_start: bool,
|
||||
/// Whether it is currently running.
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
/// A running agent.
|
||||
///
|
||||
/// Cloning gives another handle to the same agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Agent {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
config: AgentConfig,
|
||||
limits: Arc<Limits>,
|
||||
storage: Storage,
|
||||
identity: DeviceIdentity,
|
||||
adapter: EndpointAdapter,
|
||||
/// Behind a lock because it can be changed while the agent runs.
|
||||
hostname: std::sync::RwLock<String>,
|
||||
events: broadcast::Sender<Event>,
|
||||
networks: RwLock<HashMap<NetworkId, NetworkHandle>>,
|
||||
shutdown: Shutdown,
|
||||
accept_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
plugin_task: std::sync::Mutex<Option<JoinHandle<()>>>,
|
||||
transport: std::sync::OnceLock<Arc<dyn PacketTransport>>,
|
||||
}
|
||||
|
||||
impl Inner {
|
||||
/// The name this agent currently answers to.
|
||||
fn read_hostname(&self) -> String {
|
||||
match self.hostname.read() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Replaces it. Only [`Agent::set_hostname`] does this.
|
||||
fn write_hostname(&self, hostname: String) {
|
||||
match self.hostname.write() {
|
||||
Ok(mut guard) => *guard = hostname,
|
||||
Err(poisoned) => *poisoned.into_inner() = hostname,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers the data plane transport's questions about the agent.
|
||||
///
|
||||
/// Holds a weak reference on purpose: the transport lives inside the agent, so
|
||||
/// a strong one would be a cycle and the agent — with its open databases and
|
||||
/// its directory lock — would never be released.
|
||||
#[derive(Debug)]
|
||||
struct TransportCtx(Weak<Inner>);
|
||||
|
||||
impl TransportContext for TransportCtx {
|
||||
fn snapshot(&self) -> crate::BoxFuture<'_, HashMap<NetworkId, NetworkKeys>> {
|
||||
Box::pin(async move {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return HashMap::new();
|
||||
};
|
||||
inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(id, handle)| (*id, handle.keys.clone()))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn serves<'a>(&'a self, network: NetworkId, protocol: &'a str) -> crate::BoxFuture<'a, bool> {
|
||||
Box::pin(async move {
|
||||
let Some(inner) = self.0.upgrade() else {
|
||||
return false;
|
||||
};
|
||||
if !inner.networks.read().await.contains_key(&network) {
|
||||
return false;
|
||||
}
|
||||
inner
|
||||
.config
|
||||
.plugins
|
||||
.iter()
|
||||
.any(|plugin| plugin.protocol_id() == protocol)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
/// Starts an agent.
|
||||
///
|
||||
/// Opens the state store (taking its ownership lock), restores the
|
||||
/// persistent device identity, binds the iroh endpoint and activates every
|
||||
/// configured network whose auto-start flag is set.
|
||||
pub async fn spawn(config: AgentConfig) -> Result<Self> {
|
||||
let storage = Storage::open(&config.paths)?;
|
||||
let identity = storage.device_identity().await?;
|
||||
let adapter = EndpointAdapter::bind(&config, &identity).await?;
|
||||
|
||||
let hostname = resolve_hostname(&config, &storage, identity.endpoint_id())?;
|
||||
storage.set_hostname(hostname.clone()).await?;
|
||||
let hostname = std::sync::RwLock::new(hostname);
|
||||
|
||||
let (events, _) = broadcast::channel(config.limits.event_buffer);
|
||||
let limits = Arc::new(config.limits.clone());
|
||||
|
||||
let inner = Arc::new(Inner {
|
||||
limits,
|
||||
storage,
|
||||
identity,
|
||||
adapter,
|
||||
hostname,
|
||||
events,
|
||||
networks: RwLock::new(HashMap::new()),
|
||||
shutdown: Shutdown::new(),
|
||||
accept_task: std::sync::Mutex::new(None),
|
||||
plugin_task: std::sync::Mutex::new(None),
|
||||
transport: std::sync::OnceLock::new(),
|
||||
config,
|
||||
});
|
||||
|
||||
// The data plane rides on iroh too, which is where it gets hole
|
||||
// punching and relay fallback from. It is a separate ALPN and a
|
||||
// separate connection, so the two planes stay independent.
|
||||
let transport: Arc<dyn PacketTransport> = Arc::new(IrohTransport::new(
|
||||
inner.adapter.clone(),
|
||||
Arc::clone(&inner.limits),
|
||||
Arc::new(TransportCtx(Arc::downgrade(&inner))) as Arc<dyn TransportContext>,
|
||||
));
|
||||
let _ = inner.transport.set(transport);
|
||||
|
||||
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
|
||||
let _ = inner.events.send(Event::CacheReset { reason });
|
||||
}
|
||||
|
||||
let accept = tokio::spawn(accept_loop(Arc::downgrade(&inner)));
|
||||
if let Ok(mut guard) = inner.accept_task.lock() {
|
||||
*guard = Some(accept);
|
||||
}
|
||||
|
||||
// Plugins get a handle to ask for re-announcements and report errors.
|
||||
// A bounded queue keeps a noisy plugin from growing memory without
|
||||
// bound; overflow drops the request rather than stalling the plugin.
|
||||
if !inner.config.plugins.is_empty() {
|
||||
let (plugin_tx, plugin_rx) = mpsc::channel(64);
|
||||
let context = PluginContext::new(plugin_tx, inner.identity.endpoint_id());
|
||||
for plugin in &inner.config.plugins {
|
||||
plugin.attach(context.clone());
|
||||
}
|
||||
let task = tokio::spawn(plugin_request_loop(Arc::downgrade(&inner), plugin_rx));
|
||||
if let Ok(mut guard) = inner.plugin_task.lock() {
|
||||
*guard = Some(task);
|
||||
}
|
||||
}
|
||||
|
||||
let agent = Self { inner };
|
||||
|
||||
for stored in agent.inner.storage.list_networks().await? {
|
||||
if stored.auto_start {
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
agent.activate_with_keys(keys).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(agent)
|
||||
}
|
||||
|
||||
/// This device's persistent endpoint id.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.inner.identity.endpoint_id()
|
||||
}
|
||||
|
||||
/// This endpoint's dialable address as iroh currently reports it.
|
||||
pub fn endpoint_addr(&self) -> EndpointAddr {
|
||||
self.inner.adapter.addr()
|
||||
}
|
||||
|
||||
/// An address containing only the locally bound sockets.
|
||||
///
|
||||
/// Handy when relays and address lookup are disabled and peers must be
|
||||
/// handed literal addresses, as in the test suite.
|
||||
pub fn local_addr(&self) -> EndpointAddr {
|
||||
self.inner.adapter.loopback_addr()
|
||||
}
|
||||
|
||||
/// The hostname announced to peers.
|
||||
pub fn hostname(&self) -> String {
|
||||
self.inner.read_hostname()
|
||||
}
|
||||
|
||||
/// Changes the name this agent answers to, and tells everyone.
|
||||
///
|
||||
/// The name is reduced to a canonical form first, so what is stored is
|
||||
/// what every peer will compare against; the accepted form is returned.
|
||||
///
|
||||
/// Every running network publishes a fresh signed claim, which is what
|
||||
/// gives up the previous name: there is one record per author, so a new
|
||||
/// version replaces the whole claim and no replica can keep the old name
|
||||
/// standing. A network that is not running picks it up when it starts.
|
||||
pub async fn set_hostname(&self, hostname: &str) -> Result<String> {
|
||||
let hostname = crate::state::sanitise_hostname(hostname);
|
||||
if hostname.is_empty() {
|
||||
return Err(Error::InvalidEncoding {
|
||||
kind: "hostname",
|
||||
reason: "must contain at least one letter, digit, `-`, `.` or `_`",
|
||||
});
|
||||
}
|
||||
|
||||
// Stored first: if the process dies here, the next start uses the new
|
||||
// name rather than silently reverting to the old one.
|
||||
self.inner.storage.set_hostname(hostname.clone()).await?;
|
||||
self.inner.write_hostname(hostname.clone());
|
||||
|
||||
let senders: Vec<mpsc::Sender<NetCommand>> = self
|
||||
.inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.map(|handle| handle.commands.clone())
|
||||
.collect();
|
||||
for sender in senders {
|
||||
let _ = sender.send(NetCommand::SetHostname(hostname.clone())).await;
|
||||
}
|
||||
Ok(hostname)
|
||||
}
|
||||
|
||||
/// The underlying iroh endpoint, for callers that need more detail.
|
||||
pub fn endpoint(&self) -> &iroh::Endpoint {
|
||||
self.inner.adapter.endpoint()
|
||||
}
|
||||
|
||||
/// Subscribes to agent events.
|
||||
///
|
||||
/// The channel is bounded; a subscriber that falls behind is lagged rather
|
||||
/// than allowed to stall the runtime.
|
||||
pub fn subscribe(&self) -> broadcast::Receiver<Event> {
|
||||
self.inner.events.subscribe()
|
||||
}
|
||||
|
||||
/// Makes this agent a member of a network, activating it.
|
||||
///
|
||||
/// The same `(name, secret)` always produces the same [`NetworkId`], on
|
||||
/// every device.
|
||||
///
|
||||
/// This is declarative and therefore **idempotent**: joining a network
|
||||
/// that is already active succeeds and changes nothing. That matters
|
||||
/// because a configured network is activated automatically at startup, so
|
||||
/// running the same command twice must not be an error. Use
|
||||
/// [`Agent::activate_network`] when you specifically want to know whether
|
||||
/// an inactive network was started.
|
||||
pub async fn join_network(
|
||||
&self,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
) -> Result<NetworkId> {
|
||||
let keys = NetworkKeys::derive(name, secret);
|
||||
let network_id = keys.network_id();
|
||||
self.inner
|
||||
.storage
|
||||
.upsert_network(network_id, name.clone(), secret.clone(), true)
|
||||
.await?;
|
||||
match self.activate_with_keys(keys).await {
|
||||
// Already a member of exactly this network space: nothing to do.
|
||||
Ok(()) | Err(Error::NetworkAlreadyActive(_)) => Ok(network_id),
|
||||
Err(err) => Err(err),
|
||||
}
|
||||
}
|
||||
|
||||
/// Activates a configured network that is currently inactive.
|
||||
///
|
||||
/// Fails with [`Error::NetworkAlreadyActive`] if it is already running.
|
||||
/// [`Agent::join_network`] is the forgiving version.
|
||||
pub async fn activate_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
let stored = self
|
||||
.inner
|
||||
.storage
|
||||
.list_networks()
|
||||
.await?
|
||||
.into_iter()
|
||||
.find(|stored| stored.network_id == network_id)
|
||||
.ok_or(Error::NetworkUnknown(network_id))?;
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
self.inner.storage.set_auto_start(network_id, true).await?;
|
||||
self.activate_with_keys(keys).await
|
||||
}
|
||||
|
||||
async fn activate_with_keys(&self, keys: NetworkKeys) -> Result<()> {
|
||||
if self.inner.shutdown.is_triggered() {
|
||||
return Err(Error::Stopped);
|
||||
}
|
||||
let network_id = keys.network_id();
|
||||
let mut networks = self.inner.networks.write().await;
|
||||
if networks.contains_key(&network_id) {
|
||||
return Err(Error::NetworkAlreadyActive(network_id));
|
||||
}
|
||||
|
||||
let handle = network::spawn(RuntimeParams {
|
||||
keys,
|
||||
adapter: self.inner.adapter.clone(),
|
||||
storage: self.inner.storage.clone(),
|
||||
events: self.inner.events.clone(),
|
||||
limits: Arc::clone(&self.inner.limits),
|
||||
reconnect: self.inner.config.reconnect.clone(),
|
||||
discovery: self.inner.config.discovery.clone(),
|
||||
discovery_interval: self.inner.config.discovery_interval,
|
||||
plugins: self.inner.config.plugins.clone(),
|
||||
hostname: self.inner.read_hostname(),
|
||||
transport: self.inner.transport.get().cloned(),
|
||||
device_secret: self.inner.identity.signing_key(),
|
||||
ipv4_range: self.inner.config.overlay_ipv4_range,
|
||||
});
|
||||
networks.insert(network_id, handle);
|
||||
drop(networks);
|
||||
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_activated(network_id);
|
||||
}
|
||||
|
||||
let _ = self.inner.events.send(Event::NetworkActivated {
|
||||
network: network_id,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivates a running network, leaving its configuration in place.
|
||||
///
|
||||
/// This is a local action. It is not a signed revocation of membership and
|
||||
/// it does not remove this agent from anyone else's view of the network.
|
||||
/// Other networks keep running.
|
||||
pub async fn deactivate_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
let handle = {
|
||||
let mut networks = self.inner.networks.write().await;
|
||||
networks
|
||||
.remove(&network_id)
|
||||
.ok_or(Error::NetworkNotActive(network_id))?
|
||||
};
|
||||
handle.stop().await;
|
||||
for plugin in &self.inner.config.plugins {
|
||||
plugin.on_network_deactivated(network_id);
|
||||
}
|
||||
self.inner.storage.set_auto_start(network_id, false).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Deactivates a network if it is running and removes it from the state
|
||||
/// store together with its cached hints.
|
||||
pub async fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
if self.is_active(network_id).await {
|
||||
self.deactivate_network(network_id).await?;
|
||||
}
|
||||
self.inner.storage.remove_network(network_id).await
|
||||
}
|
||||
|
||||
/// Whether a network is currently running.
|
||||
pub async fn is_active(&self, network_id: NetworkId) -> bool {
|
||||
self.inner.networks.read().await.contains_key(&network_id)
|
||||
}
|
||||
|
||||
/// Lists configured networks and whether each is running.
|
||||
pub async fn list_networks(&self) -> Result<Vec<ConfiguredNetwork>> {
|
||||
let active: Vec<NetworkId> = self.inner.networks.read().await.keys().copied().collect();
|
||||
Ok(self
|
||||
.inner
|
||||
.storage
|
||||
.list_networks()
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|stored| ConfiguredNetwork {
|
||||
network_id: stored.network_id,
|
||||
name: stored.name,
|
||||
auto_start: stored.auto_start,
|
||||
active: active.contains(&stored.network_id),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Sends a control message to one authenticated peer in one network.
|
||||
///
|
||||
/// Fails if that network is not active or if there is no authenticated
|
||||
/// session with that peer *in that network*. Being authenticated in network
|
||||
/// A never grants the right to send into network B.
|
||||
pub async fn send(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
peer: EndpointId,
|
||||
message: ControlMessage,
|
||||
) -> Result<()> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.command(
|
||||
network_id,
|
||||
NetCommand::Send {
|
||||
peer,
|
||||
message,
|
||||
reply: reply_tx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
reply_rx.await.map_err(|_| Error::Stopped)?
|
||||
}
|
||||
|
||||
/// Sends a control message to every authenticated peer in a network.
|
||||
///
|
||||
/// Returns how many sessions accepted it into their outbound queue.
|
||||
pub async fn broadcast(&self, network_id: NetworkId, message: ControlMessage) -> Result<usize> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.command(
|
||||
network_id,
|
||||
NetCommand::Broadcast {
|
||||
message,
|
||||
reply: reply_tx,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
reply_rx.await.map_err(|_| Error::Stopped)
|
||||
}
|
||||
|
||||
/// Status of one running network.
|
||||
pub async fn network_status(&self, network_id: NetworkId) -> Result<NetworkStatus> {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
self.command(network_id, NetCommand::Status { reply: reply_tx })
|
||||
.await?;
|
||||
reply_rx
|
||||
.await
|
||||
.map(|boxed| *boxed)
|
||||
.map_err(|_| Error::Stopped)
|
||||
}
|
||||
|
||||
/// Status of the whole agent, including configured but inactive networks.
|
||||
pub async fn status(&self) -> Result<AgentStatus> {
|
||||
let endpoint = self.inner.adapter.snapshot();
|
||||
let active: Vec<NetworkId> = self.inner.networks.read().await.keys().copied().collect();
|
||||
|
||||
let mut networks = Vec::new();
|
||||
for stored in self.inner.storage.list_networks().await? {
|
||||
if active.contains(&stored.network_id)
|
||||
&& let Ok(status) = self.network_status(stored.network_id).await
|
||||
{
|
||||
networks.push(status);
|
||||
continue;
|
||||
}
|
||||
let keys = NetworkKeys::derive(&stored.name, &stored.secret);
|
||||
networks.push(NetworkStatus {
|
||||
descriptor: keys.descriptor(),
|
||||
name: stored.name,
|
||||
network_id: stored.network_id,
|
||||
state: NetworkState::Inactive,
|
||||
peers: Vec::new(),
|
||||
candidates: Vec::new(),
|
||||
// An inactive network has no runtime to ask; the roster comes
|
||||
// from one. Empty, not invented.
|
||||
members: Vec::new(),
|
||||
metrics: NetworkMetrics::default(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(AgentStatus {
|
||||
endpoint_id: endpoint.endpoint_id,
|
||||
hostname: self.inner.read_hostname(),
|
||||
bound_sockets: endpoint.bound_sockets,
|
||||
observed_addrs: endpoint.observed_addrs,
|
||||
endpoint_addr: self.inner.adapter.addr(),
|
||||
cache_outcome: self.inner.storage.cache_outcome().clone(),
|
||||
cache_healthy: self.inner.storage.cache_healthy(),
|
||||
networks,
|
||||
})
|
||||
}
|
||||
|
||||
/// Resends this agent's announcement to every peer of a network.
|
||||
///
|
||||
/// Plugins normally trigger this themselves through
|
||||
/// [`crate::dataplane::PluginContext::request_reannounce`] when their
|
||||
/// capability changes.
|
||||
pub async fn reannounce(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.command(network_id, NetCommand::Reannounce).await
|
||||
}
|
||||
|
||||
/// Asks one network to re-run discovery and re-evaluate dials right now.
|
||||
///
|
||||
/// Call this when the host's network environment changed. Platform wake-up
|
||||
/// notifications can be wired to it later.
|
||||
pub async fn recheck_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.command(network_id, NetCommand::Recheck).await
|
||||
}
|
||||
|
||||
/// Asks every running network to re-run discovery right now.
|
||||
pub async fn recheck(&self) {
|
||||
let senders: Vec<mpsc::Sender<NetCommand>> = self
|
||||
.inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.values()
|
||||
.map(|handle| handle.commands.clone())
|
||||
.collect();
|
||||
for sender in senders {
|
||||
let _ = sender.send(NetCommand::Recheck).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Stops every network, the accept loop and the endpoint.
|
||||
///
|
||||
/// After this returns, the state directory can be opened by another agent.
|
||||
pub async fn shutdown(&self) {
|
||||
self.inner.shutdown.trigger();
|
||||
|
||||
let handles: Vec<NetworkHandle> = {
|
||||
let mut networks = self.inner.networks.write().await;
|
||||
networks.drain().map(|(_, handle)| handle).collect()
|
||||
};
|
||||
for handle in handles {
|
||||
handle.stop().await;
|
||||
}
|
||||
|
||||
self.inner.adapter.close().await;
|
||||
|
||||
for handle in [&self.inner.accept_task, &self.inner.plugin_task] {
|
||||
let task = handle.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugins remove whatever system objects they created. A plugin that
|
||||
// misbehaves here must not hold up the agent, so this is bounded.
|
||||
for plugin in &self.inner.config.plugins {
|
||||
if tokio::time::timeout(PLUGIN_SHUTDOWN_GRACE, plugin.shutdown())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::warn!(
|
||||
protocol = plugin.protocol_id(),
|
||||
"plugin did not shut down in time"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Release the directory so another instance can claim it right away.
|
||||
self.inner.storage.release_ownership_lock();
|
||||
}
|
||||
|
||||
async fn command(&self, network_id: NetworkId, command: NetCommand) -> Result<()> {
|
||||
let sender = {
|
||||
let networks = self.inner.networks.read().await;
|
||||
networks
|
||||
.get(&network_id)
|
||||
.map(|handle| handle.commands.clone())
|
||||
.ok_or(Error::NetworkNotActive(network_id))?
|
||||
};
|
||||
sender.send(command).await.map_err(|_| Error::Stopped)
|
||||
}
|
||||
}
|
||||
|
||||
/// How long each plugin gets to tear itself down during agent shutdown.
|
||||
const PLUGIN_SHUTDOWN_GRACE: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
|
||||
/// Serves requests plugins make of the agent.
|
||||
///
|
||||
/// Holds only a weak reference, so it exits once the agent is dropped.
|
||||
async fn plugin_request_loop(weak: Weak<Inner>, mut requests: mpsc::Receiver<PluginRequest>) {
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let shutdown = inner.shutdown.clone();
|
||||
drop(inner);
|
||||
|
||||
loop {
|
||||
let request = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait() => break,
|
||||
request = requests.recv() => match request {
|
||||
Some(request) => request,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
|
||||
match request {
|
||||
PluginRequest::Reannounce(network) => {
|
||||
let sender = {
|
||||
let networks = inner.networks.read().await;
|
||||
networks.get(&network).map(|handle| handle.commands.clone())
|
||||
};
|
||||
// A plugin asking about a network that is no longer active is
|
||||
// normal, not an error.
|
||||
if let Some(sender) = sender {
|
||||
let _ = sender.send(NetCommand::Reannounce).await;
|
||||
}
|
||||
}
|
||||
PluginRequest::Error {
|
||||
network,
|
||||
protocol,
|
||||
reason,
|
||||
} => {
|
||||
let sender = {
|
||||
let networks = inner.networks.read().await;
|
||||
networks.get(&network).map(|handle| handle.commands.clone())
|
||||
};
|
||||
match sender {
|
||||
// The runtime owns this network's counters, so the error
|
||||
// is counted and published in one place.
|
||||
Some(sender) => {
|
||||
let _ = sender
|
||||
.send(NetCommand::PluginError { protocol, reason })
|
||||
.await;
|
||||
}
|
||||
// The network is gone; there is nothing to count it
|
||||
// against, but the report is still worth publishing.
|
||||
None => {
|
||||
let _ = inner.events.send(Event::PluginError {
|
||||
network,
|
||||
protocol,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Accepts inbound connections and routes authenticated sessions to networks.
|
||||
///
|
||||
/// Holds only a weak reference, so dropping every [`Agent`] handle lets the
|
||||
/// runtime state be released and this loop exit.
|
||||
async fn accept_loop(weak: Weak<Inner>) {
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
return;
|
||||
};
|
||||
let endpoint = inner.adapter.endpoint().clone();
|
||||
let shutdown = inner.shutdown.clone();
|
||||
let permits = Arc::new(tokio::sync::Semaphore::new(
|
||||
inner.limits.max_inbound_handshakes,
|
||||
));
|
||||
drop(inner);
|
||||
|
||||
loop {
|
||||
let incoming = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait() => break,
|
||||
incoming = endpoint.accept() => match incoming {
|
||||
Some(incoming) => incoming,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
|
||||
let Some(inner) = weak.upgrade() else {
|
||||
break;
|
||||
};
|
||||
let Ok(permit) = Arc::clone(&permits).try_acquire_owned() else {
|
||||
// Too many handshakes in flight: refuse cheaply instead of queueing.
|
||||
incoming.refuse();
|
||||
continue;
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
handle_incoming(inner, incoming).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_incoming(inner: Arc<Inner>, incoming: iroh::endpoint::Incoming) {
|
||||
let connecting = match incoming.accept() {
|
||||
Ok(connecting) => connecting,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "inbound connection could not be accepted");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let conn = match connecting.await {
|
||||
Ok(conn) => conn,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "inbound connection failed during setup");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let peer = conn.remote_id();
|
||||
|
||||
// Two protocols share the endpoint; they are told apart here and never mix.
|
||||
if conn.alpn() == crate::proto::message::DATA_ALPN {
|
||||
handle_inbound_data(inner, conn).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let (mut send, mut recv) = match conn.accept_bi().await {
|
||||
Ok(streams) => streams,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "peer did not open a control stream");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Snapshot the active networks so the handshake's lookup stays synchronous.
|
||||
let known: HashMap<NetworkId, NetworkKeys> = inner
|
||||
.networks
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.map(|(id, handle)| (*id, handle.keys.clone()))
|
||||
.collect();
|
||||
|
||||
let local_id = inner.identity.endpoint_id();
|
||||
let outcome = handshake::respond(
|
||||
&conn,
|
||||
&mut send,
|
||||
&mut recv,
|
||||
local_id,
|
||||
&inner.limits,
|
||||
|network_id| known.get(&network_id).cloned(),
|
||||
)
|
||||
.await;
|
||||
|
||||
let outcome = match outcome {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
// The network id is deliberately not reported here: before a
|
||||
// successful handshake the peer's claim is unverified.
|
||||
let network = None;
|
||||
conn.close(2u32.into(), b"handshake rejected");
|
||||
let _ = inner.events.send(Event::HandshakeRejected {
|
||||
network,
|
||||
peer: Some(peer),
|
||||
reason: err.to_string(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let sender = {
|
||||
let networks = inner.networks.read().await;
|
||||
networks
|
||||
.get(&outcome.network_id)
|
||||
.map(|handle| handle.commands.clone())
|
||||
};
|
||||
let Some(sender) = sender else {
|
||||
// The network was deactivated while the handshake ran.
|
||||
conn.close(3u32.into(), b"network no longer active");
|
||||
return;
|
||||
};
|
||||
|
||||
let inbound = InboundSession {
|
||||
conn,
|
||||
send,
|
||||
recv,
|
||||
outcome,
|
||||
};
|
||||
if sender
|
||||
.send(NetCommand::Inbound(Box::new(inbound)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("network runtime stopped before the session could be installed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes an inbound data plane connection and routes it to its network.
|
||||
async fn handle_inbound_data(inner: Arc<Inner>, conn: iroh::endpoint::Connection) {
|
||||
let Some(transport) = inner.transport.get().cloned() else {
|
||||
conn.close(5u32.into(), b"data plane not ready");
|
||||
return;
|
||||
};
|
||||
// Downcasting is avoided by keeping the accept side on the concrete type.
|
||||
let Some(iroh_transport) = transport.as_ref().as_any().downcast_ref::<IrohTransport>() else {
|
||||
conn.close(5u32.into(), b"unsupported data transport");
|
||||
return;
|
||||
};
|
||||
|
||||
let inbound = match iroh_transport.accept(conn).await {
|
||||
Ok(inbound) => inbound,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "inbound data channel rejected");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let sender = {
|
||||
let networks = inner.networks.read().await;
|
||||
networks
|
||||
.get(&inbound.network)
|
||||
.map(|handle| handle.commands.clone())
|
||||
};
|
||||
let Some(sender) = sender else {
|
||||
// The network went away while the channel was being set up.
|
||||
return;
|
||||
};
|
||||
if sender
|
||||
.send(NetCommand::InboundLink(Box::new(inbound)))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
tracing::debug!("network runtime stopped before the data link was installed");
|
||||
}
|
||||
}
|
||||
|
||||
/// Picks the hostname to announce.
|
||||
///
|
||||
/// Order: an explicit choice, then one the user set earlier and the store
|
||||
/// kept, then the machine's own name, then a fallback derived from the
|
||||
/// endpoint id for the rare host that has no usable name.
|
||||
///
|
||||
/// No shell is involved at any step: the system name comes from the platform
|
||||
/// call, not from running `hostname`.
|
||||
fn resolve_hostname(
|
||||
config: &AgentConfig,
|
||||
storage: &Storage,
|
||||
endpoint_id: EndpointId,
|
||||
) -> Result<String> {
|
||||
if let Some(hostname) = &config.hostname {
|
||||
return Ok(crate::state::sanitise_hostname(hostname));
|
||||
}
|
||||
if let Some(stored) = storage.hostname_blocking()?
|
||||
&& !stored.is_empty()
|
||||
{
|
||||
return Ok(crate::state::sanitise_hostname(&stored));
|
||||
}
|
||||
if let Some(system) = system_hostname() {
|
||||
return Ok(system);
|
||||
}
|
||||
Ok(format!("tsunagi-{}", endpoint_id.fmt_short()))
|
||||
}
|
||||
|
||||
/// The machine's own name, if it has a usable one.
|
||||
///
|
||||
/// Some hosts answer with `localhost`, or with nothing at all. That is not a
|
||||
/// name that distinguishes this device from any other, so it is treated as
|
||||
/// absent and the caller falls back to something that does.
|
||||
pub fn system_hostname() -> Option<String> {
|
||||
let raw = gethostname::gethostname();
|
||||
let name = crate::state::sanitise_hostname(&raw.to_string_lossy());
|
||||
if name.is_empty() || name.eq_ignore_ascii_case("localhost") {
|
||||
return None;
|
||||
}
|
||||
Some(name)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,337 @@
|
||||
//! One authenticated session with one peer, in one network.
|
||||
//!
|
||||
//! A session owns a reader task and a writer task over a single QUIC
|
||||
//! bidirectional stream. Splitting them keeps both halves simple and avoids
|
||||
//! cancelling a partially consumed frame, which stream reads do not tolerate.
|
||||
//!
|
||||
//! Every inbound message is re-checked against the session's network id, so an
|
||||
//! authenticated session for one network can never deliver into another.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::Instant;
|
||||
|
||||
use iroh::EndpointId;
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::config::Limits;
|
||||
use crate::dataplane::PluginCapability;
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::NetworkId;
|
||||
use crate::proto::handshake::Role;
|
||||
use crate::proto::message::{ControlMessage, Envelope, decode, validate};
|
||||
use crate::proto::{read_frame, write_frame};
|
||||
|
||||
use super::shutdown::Shutdown;
|
||||
|
||||
/// What a session task reports back to its network runtime.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SessionEvent {
|
||||
/// A valid control message arrived.
|
||||
Message {
|
||||
/// Which session instance produced this.
|
||||
session_id: u64,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// The decoded message.
|
||||
message: ControlMessage,
|
||||
/// Payload bytes read off the wire.
|
||||
bytes: usize,
|
||||
},
|
||||
/// The peer sent something the protocol does not allow.
|
||||
Violation {
|
||||
/// Which session instance produced this.
|
||||
session_id: u64,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// What was wrong.
|
||||
error: ProtocolError,
|
||||
},
|
||||
/// The session ended.
|
||||
Closed {
|
||||
/// Which session instance ended.
|
||||
session_id: u64,
|
||||
/// The peer.
|
||||
peer: EndpointId,
|
||||
/// Why it ended.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A live session, as held by the network runtime.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct Session {
|
||||
pub(crate) id: u64,
|
||||
pub(crate) peer: EndpointId,
|
||||
pub(crate) role: Role,
|
||||
pub(crate) established: Instant,
|
||||
pub(crate) conn: Connection,
|
||||
/// Already encoded frame payloads, so the runtime can account for the exact
|
||||
/// number of control bytes it queues.
|
||||
pub(crate) outbound: mpsc::Sender<Vec<u8>>,
|
||||
pub(crate) hostname: Option<String>,
|
||||
pub(crate) capabilities: Vec<PluginCapability>,
|
||||
pub(crate) messages_sent: u64,
|
||||
pub(crate) messages_received: u64,
|
||||
pub(crate) bytes_sent: u64,
|
||||
pub(crate) bytes_received: u64,
|
||||
reader: JoinHandle<()>,
|
||||
writer: JoinHandle<()>,
|
||||
shutdown: Shutdown,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// Signals both tasks to stop and waits for them, with a bounded grace
|
||||
/// period.
|
||||
///
|
||||
/// A peer that stops reading must not be able to hold up shutdown, so the
|
||||
/// tasks are aborted if they do not wind down in time.
|
||||
pub(crate) async fn stop(self) {
|
||||
let Session {
|
||||
conn,
|
||||
reader,
|
||||
writer,
|
||||
shutdown,
|
||||
..
|
||||
} = self;
|
||||
shutdown.trigger();
|
||||
// Closing the connection unblocks a reader parked on the stream.
|
||||
conn.close(0u32.into(), b"session stopped by local agent");
|
||||
|
||||
let reader_abort = reader.abort_handle();
|
||||
let writer_abort = writer.abort_handle();
|
||||
let joined = tokio::time::timeout(STOP_GRACE, async move {
|
||||
let _ = reader.await;
|
||||
let _ = writer.await;
|
||||
})
|
||||
.await;
|
||||
if joined.is_err() {
|
||||
tracing::debug!("session tasks did not wind down in time; aborting them");
|
||||
reader_abort.abort();
|
||||
writer_abort.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// Aborts both tasks without waiting. Used on replacement.
|
||||
pub(crate) fn abort(&self) {
|
||||
self.shutdown.trigger();
|
||||
self.reader.abort();
|
||||
self.writer.abort();
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a stopping session may take to wind down before its tasks are
|
||||
/// aborted. Shutdown must be bounded even if a peer stops reading.
|
||||
const STOP_GRACE: std::time::Duration = std::time::Duration::from_millis(500);
|
||||
|
||||
/// Source of monotonically increasing session instance ids.
|
||||
static NEXT_SESSION_ID: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
/// Starts the reader and writer tasks for an authenticated stream.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn spawn(
|
||||
network_id: NetworkId,
|
||||
peer: EndpointId,
|
||||
role: Role,
|
||||
conn: Connection,
|
||||
send: SendStream,
|
||||
recv: RecvStream,
|
||||
limits: Arc<Limits>,
|
||||
events: mpsc::Sender<SessionEvent>,
|
||||
parent_shutdown: Shutdown,
|
||||
) -> Session {
|
||||
let id = NEXT_SESSION_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let shutdown = Shutdown::new();
|
||||
let (outbound_tx, outbound_rx) = mpsc::channel(limits.session_send_queue);
|
||||
|
||||
let writer = tokio::spawn(writer_task(
|
||||
send,
|
||||
outbound_rx,
|
||||
Arc::clone(&limits),
|
||||
shutdown.clone(),
|
||||
parent_shutdown.clone(),
|
||||
));
|
||||
|
||||
let reader = tokio::spawn(reader_task(
|
||||
id,
|
||||
network_id,
|
||||
peer,
|
||||
recv,
|
||||
limits,
|
||||
events,
|
||||
shutdown.clone(),
|
||||
parent_shutdown,
|
||||
));
|
||||
|
||||
Session {
|
||||
id,
|
||||
peer,
|
||||
role,
|
||||
established: Instant::now(),
|
||||
conn,
|
||||
outbound: outbound_tx,
|
||||
hostname: None,
|
||||
capabilities: Vec::new(),
|
||||
messages_sent: 0,
|
||||
messages_received: 0,
|
||||
bytes_sent: 0,
|
||||
bytes_received: 0,
|
||||
reader,
|
||||
writer,
|
||||
shutdown,
|
||||
}
|
||||
}
|
||||
|
||||
async fn writer_task(
|
||||
mut send: SendStream,
|
||||
mut outbound: mpsc::Receiver<Vec<u8>>,
|
||||
limits: Arc<Limits>,
|
||||
shutdown: Shutdown,
|
||||
parent: Shutdown,
|
||||
) {
|
||||
loop {
|
||||
let encoded = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait() => break,
|
||||
_ = parent.wait() => break,
|
||||
encoded = outbound.recv() => match encoded {
|
||||
Some(encoded) => encoded,
|
||||
None => break,
|
||||
},
|
||||
};
|
||||
|
||||
// The write must stay cancellable: shutting the session down cannot
|
||||
// wait for a peer that has stopped reading. Abandoning a half written
|
||||
// frame is fine, because the session is going away with it.
|
||||
let write = tokio::time::timeout(
|
||||
limits.write_timeout,
|
||||
write_frame(&mut send, &encoded, limits.max_frame_len),
|
||||
);
|
||||
let result = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait() => break,
|
||||
_ = parent.wait() => break,
|
||||
result = write => result,
|
||||
};
|
||||
match result {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(err)) => {
|
||||
tracing::debug!(%err, "control stream write failed");
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
tracing::debug!("control stream write timed out");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = send.finish();
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn reader_task(
|
||||
session_id: u64,
|
||||
network_id: NetworkId,
|
||||
peer: EndpointId,
|
||||
mut recv: RecvStream,
|
||||
limits: Arc<Limits>,
|
||||
events: mpsc::Sender<SessionEvent>,
|
||||
shutdown: Shutdown,
|
||||
parent: Shutdown,
|
||||
) {
|
||||
let reason = loop {
|
||||
let frame = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown.wait() => break "stopped locally".to_string(),
|
||||
_ = parent.wait() => break "network deactivated".to_string(),
|
||||
frame = read_frame(&mut recv, limits.max_frame_len) => frame,
|
||||
};
|
||||
|
||||
let payload = match frame {
|
||||
Ok(payload) => payload,
|
||||
Err(ProtocolError::StreamClosed) => break "peer closed the control stream".to_string(),
|
||||
Err(ProtocolError::Stream(err)) => {
|
||||
// The transport went away. That is a disconnect, not a peer
|
||||
// misbehaving, so it ends the session without being counted as
|
||||
// a protocol violation.
|
||||
break format!("control stream error: {err}");
|
||||
}
|
||||
Err(err) => {
|
||||
// A framing violation ends this session. Framing errors are not
|
||||
// recoverable mid-stream: the next bytes have no known meaning.
|
||||
let text = err.to_string();
|
||||
let _ = events
|
||||
.send(SessionEvent::Violation {
|
||||
session_id,
|
||||
peer,
|
||||
error: err,
|
||||
})
|
||||
.await;
|
||||
break text;
|
||||
}
|
||||
};
|
||||
|
||||
let bytes = payload.len();
|
||||
let envelope: Envelope = match decode(&payload) {
|
||||
Ok(envelope) => envelope,
|
||||
Err(err) => {
|
||||
let _ = events
|
||||
.send(SessionEvent::Violation {
|
||||
session_id,
|
||||
peer,
|
||||
error: err,
|
||||
})
|
||||
.await;
|
||||
break "malformed control frame".to_string();
|
||||
}
|
||||
};
|
||||
|
||||
// Network isolation: a session authenticated for one network must never
|
||||
// deliver a message belonging to another.
|
||||
if envelope.network_id != *network_id.as_bytes() {
|
||||
let _ = events
|
||||
.send(SessionEvent::Violation {
|
||||
session_id,
|
||||
peer,
|
||||
error: ProtocolError::NetworkMismatch,
|
||||
})
|
||||
.await;
|
||||
break "network id mismatch on an authenticated session".to_string();
|
||||
}
|
||||
|
||||
if let Err(err) = validate(&envelope.message, &limits) {
|
||||
let _ = events
|
||||
.send(SessionEvent::Violation {
|
||||
session_id,
|
||||
peer,
|
||||
error: err,
|
||||
})
|
||||
.await;
|
||||
continue;
|
||||
}
|
||||
|
||||
if events
|
||||
.send(SessionEvent::Message {
|
||||
session_id,
|
||||
peer,
|
||||
message: envelope.message,
|
||||
bytes,
|
||||
})
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break "network runtime stopped".to_string();
|
||||
}
|
||||
};
|
||||
|
||||
// Best effort: the runtime may already have stopped draining this channel
|
||||
// while it tears the network down, and a closing session must not block on
|
||||
// that.
|
||||
let _ = events.try_send(SessionEvent::Closed {
|
||||
session_id,
|
||||
peer,
|
||||
reason,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! A minimal cancellation primitive.
|
||||
//!
|
||||
//! Kept local so the crate does not pull in a utility dependency for one type,
|
||||
//! and so that no global state is involved: every agent and every network
|
||||
//! runtime owns its own token.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::watch;
|
||||
|
||||
/// A clonable cancellation token.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Shutdown {
|
||||
tx: Arc<watch::Sender<bool>>,
|
||||
rx: watch::Receiver<bool>,
|
||||
}
|
||||
|
||||
impl Shutdown {
|
||||
/// Creates an untriggered token.
|
||||
pub(crate) fn new() -> Self {
|
||||
let (tx, rx) = watch::channel(false);
|
||||
Self {
|
||||
tx: Arc::new(tx),
|
||||
rx,
|
||||
}
|
||||
}
|
||||
|
||||
/// Triggers cancellation. Idempotent.
|
||||
pub(crate) fn trigger(&self) {
|
||||
let _ = self.tx.send(true);
|
||||
}
|
||||
|
||||
/// Whether cancellation has been triggered.
|
||||
pub(crate) fn is_triggered(&self) -> bool {
|
||||
*self.rx.borrow()
|
||||
}
|
||||
|
||||
/// Resolves once cancellation has been triggered.
|
||||
pub(crate) async fn wait(&self) {
|
||||
let mut rx = self.rx.clone();
|
||||
{
|
||||
if *rx.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
while rx.changed().await.is_ok() {
|
||||
if *rx.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The sender is gone, which for our purposes means "stop".
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Status snapshots.
|
||||
//!
|
||||
//! Metrics are reported at the level they actually belong to. Values that
|
||||
//! genuinely cannot be attributed to a single network — everything the iroh
|
||||
//! endpoint aggregates, for instance — stay at the endpoint level rather than
|
||||
//! being split between networks with invented precision.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
|
||||
use crate::dataplane::PluginCapability;
|
||||
use crate::discovery::CandidateSource;
|
||||
use crate::identity::{NetworkDescriptor, NetworkId, NetworkName};
|
||||
use crate::net::{ConnectionCounters, PathAddr, PathInfo, TransportKind};
|
||||
use crate::proto::handshake::Role;
|
||||
use crate::storage::CacheOutcome;
|
||||
|
||||
/// Whether a configured network is running locally.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum NetworkState {
|
||||
/// Configured and running.
|
||||
Active,
|
||||
/// Configured but not running.
|
||||
Inactive,
|
||||
}
|
||||
|
||||
/// An unverified candidate as seen by a network runtime.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CandidateStatus {
|
||||
/// Candidate endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Where it came from.
|
||||
pub source: CandidateSource,
|
||||
/// Consecutive failed dial attempts since the last success.
|
||||
pub consecutive_failures: u32,
|
||||
}
|
||||
|
||||
/// A member the signed state knows about, connected or not.
|
||||
///
|
||||
/// This is the durable roster: it comes from signed records, so a member that
|
||||
/// went away last month is still here. That is what makes it possible to say
|
||||
/// "this peer is offline" rather than only "nobody is connected".
|
||||
///
|
||||
/// It is not a complete membership list, and cannot be. A member is in signed
|
||||
/// state once it has claimed something — an address, a name, or both — so a
|
||||
/// member that has joined but never published is visible only while it is
|
||||
/// connected.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MemberStatus {
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
pub overlay_address_v4: Option<std::net::Ipv4Addr>,
|
||||
/// The name it claimed, when it holds that name uncontested.
|
||||
///
|
||||
/// Signed, so it is still known while the member is away — which is what
|
||||
/// lets an absent member be named rather than shown as a bare id.
|
||||
pub hostname: Option<String>,
|
||||
}
|
||||
|
||||
/// Status of one authenticated session.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerStatus {
|
||||
/// Authenticated endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Which side this agent played in the handshake.
|
||||
pub role: Role,
|
||||
/// Hostname the peer announced, if it has announced one yet.
|
||||
///
|
||||
/// A mutable binding, not an identity.
|
||||
pub hostname: Option<String>,
|
||||
/// Capabilities the peer announced. Payloads stay opaque.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
/// How long the session has been up.
|
||||
pub connected_for: Duration,
|
||||
/// Verified paths of the underlying connection.
|
||||
pub paths: Vec<PathInfo>,
|
||||
/// How the connection currently reaches the peer.
|
||||
pub transport: TransportKind,
|
||||
/// RTT of the selected path, when iroh reported one.
|
||||
pub rtt: Option<Duration>,
|
||||
/// Per-connection counters.
|
||||
pub connection: ConnectionCounters,
|
||||
/// Control messages sent on this session.
|
||||
pub control_messages_sent: u64,
|
||||
/// Control messages received on this session.
|
||||
pub control_messages_received: u64,
|
||||
/// Control payload bytes queued for this session.
|
||||
pub control_bytes_sent: u64,
|
||||
/// Control payload bytes read from this session.
|
||||
pub control_bytes_received: u64,
|
||||
}
|
||||
|
||||
/// Counters scoped to one logical network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct NetworkMetrics {
|
||||
/// Outbound dial attempts started.
|
||||
pub dial_attempts: u64,
|
||||
/// Outbound dials that failed before or during the handshake.
|
||||
pub dial_failures: u64,
|
||||
/// Handshakes rejected, in either direction.
|
||||
pub handshake_failures: u64,
|
||||
/// Sessions that reached the authenticated state.
|
||||
pub sessions_established: u64,
|
||||
/// Sessions that ended.
|
||||
pub disconnects: u64,
|
||||
/// Control messages sent in this network.
|
||||
pub control_messages_sent: u64,
|
||||
/// Control messages received in this network.
|
||||
pub control_messages_received: u64,
|
||||
/// Control bytes sent in this network, payload only.
|
||||
pub control_bytes_sent: u64,
|
||||
/// Control bytes received in this network, payload only.
|
||||
pub control_bytes_received: u64,
|
||||
/// Messages or sessions rejected for protocol violations.
|
||||
pub protocol_violations: u64,
|
||||
/// Errors reported by IP plugins. Never fatal.
|
||||
pub plugin_errors: u64,
|
||||
/// Data plane links that were established.
|
||||
pub data_links_established: u64,
|
||||
/// Attempts to open a data plane link that failed.
|
||||
pub data_link_failures: u64,
|
||||
}
|
||||
|
||||
/// Status of one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkStatus {
|
||||
/// Immutable deterministic description of the network space.
|
||||
pub descriptor: NetworkDescriptor,
|
||||
/// Network name, for convenience.
|
||||
pub name: NetworkName,
|
||||
/// Public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Whether the network is running locally.
|
||||
pub state: NetworkState,
|
||||
/// Authenticated sessions.
|
||||
pub peers: Vec<PeerStatus>,
|
||||
/// Unverified candidates currently known. Not peers.
|
||||
pub candidates: Vec<CandidateStatus>,
|
||||
/// Members the signed state knows about, whether connected or not.
|
||||
pub members: Vec<MemberStatus>,
|
||||
/// Per-network counters.
|
||||
pub metrics: NetworkMetrics,
|
||||
}
|
||||
|
||||
impl NetworkStatus {
|
||||
/// Endpoint ids of peers with an authenticated session.
|
||||
pub fn connected_peers(&self) -> Vec<EndpointId> {
|
||||
self.peers.iter().map(|peer| peer.endpoint_id).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Status of the whole agent.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentStatus {
|
||||
/// This device's persistent endpoint id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Hostname announced to peers.
|
||||
pub hostname: String,
|
||||
/// Sockets actually bound.
|
||||
pub bound_sockets: Vec<std::net::SocketAddr>,
|
||||
/// Addresses iroh believes this endpoint has. Observed, not verified.
|
||||
pub observed_addrs: Vec<PathAddr>,
|
||||
/// The dialable address of this endpoint, as iroh currently reports it.
|
||||
pub endpoint_addr: EndpointAddr,
|
||||
/// What happened to the disposable cache at startup.
|
||||
pub cache_outcome: CacheOutcome,
|
||||
/// Whether the cache is currently usable.
|
||||
pub cache_healthy: bool,
|
||||
/// Per-network status, including configured but inactive networks.
|
||||
pub networks: Vec<NetworkStatus>,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
/// Looks up one network's status.
|
||||
pub fn network(&self, network_id: &NetworkId) -> Option<&NetworkStatus> {
|
||||
self.networks
|
||||
.iter()
|
||||
.find(|status| &status.network_id == network_id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
//! Library configuration.
|
||||
//!
|
||||
//! Everything the agent needs is passed in explicitly. The library reads no
|
||||
//! environment variables, installs no global state and picks no default
|
||||
//! directories behind the caller's back — [`StoragePaths::user_default`] exists
|
||||
//! but must be called on purpose.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::dataplane::SharedPlugin;
|
||||
use crate::discovery::NetworkDiscovery;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Qualifier/organisation/application triple used for platform directories.
|
||||
const APP_NAME: &str = "tsunagi";
|
||||
|
||||
/// Where the two stores live.
|
||||
///
|
||||
/// The mandatory state and the disposable cache are separate both logically and
|
||||
/// physically, so that the cache can be deleted at any time without touching
|
||||
/// identity or network configuration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct StoragePaths {
|
||||
/// Directory holding `state.sqlite` and the ownership lock.
|
||||
pub state_dir: PathBuf,
|
||||
/// Directory holding `cache.sqlite`.
|
||||
pub cache_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl StoragePaths {
|
||||
/// Uses explicit directories. Tests always use temporary directories.
|
||||
pub fn new(state_dir: impl Into<PathBuf>, cache_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
cache_dir: cache_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts both stores under one root, in `state/` and `cache/` subdirectories.
|
||||
pub fn under(root: impl AsRef<Path>) -> Self {
|
||||
let root = root.as_ref();
|
||||
Self {
|
||||
state_dir: root.join("state"),
|
||||
cache_dir: root.join("cache"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-user platform directories.
|
||||
///
|
||||
/// A future system service can supply its own paths instead.
|
||||
pub fn user_default() -> Result<Self> {
|
||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||
Error::Storage("no valid home directory for platform config paths".into())
|
||||
})?;
|
||||
Ok(Self {
|
||||
state_dir: dirs.data_dir().to_path_buf(),
|
||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the mandatory state database.
|
||||
pub fn state_db(&self) -> PathBuf {
|
||||
self.state_dir.join("state.sqlite")
|
||||
}
|
||||
|
||||
/// Path of the disposable cache database.
|
||||
pub fn cache_db(&self) -> PathBuf {
|
||||
self.cache_dir.join("cache.sqlite")
|
||||
}
|
||||
|
||||
/// Path of the ownership lock file.
|
||||
pub fn lock_file(&self) -> PathBuf {
|
||||
self.state_dir.join("state.lock")
|
||||
}
|
||||
}
|
||||
|
||||
/// How the iroh endpoint is allowed to reach the outside world.
|
||||
///
|
||||
/// The default is [`TransportPolicy::LocalOnly`] so that a plain
|
||||
/// `AgentConfig::new(...)` never reaches the internet by accident. Callers that
|
||||
/// want public connectivity must opt in.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum TransportPolicy {
|
||||
/// No relays, no address lookup service, no port mapping.
|
||||
///
|
||||
/// Suitable for tests and for fully local deployments.
|
||||
#[default]
|
||||
LocalOnly,
|
||||
/// Public address lookup, but no relays.
|
||||
///
|
||||
/// Enables iroh's DNS/pkarr address lookup against the public service run
|
||||
/// by Number 0 (the company behind iroh) at `dns.iroh.link`. This endpoint
|
||||
/// publishes a signed record of its own addresses there, so peers can dial
|
||||
/// it by endpoint id alone.
|
||||
DirectOnly,
|
||||
/// iroh's standard behaviour: public address lookup plus public relays.
|
||||
///
|
||||
/// Maps to iroh's own `presets::N0`. As well as the address lookup above,
|
||||
/// it uses Number 0's public relay servers as a fallback when a direct
|
||||
/// path cannot be hole punched. They are fine for development and carry no
|
||||
/// availability guarantee.
|
||||
N0Defaults,
|
||||
}
|
||||
|
||||
/// Bounds applied to everything that comes off the network.
|
||||
///
|
||||
/// Each of these is enforced before memory is allocated for the corresponding
|
||||
/// object where that is possible (notably [`Limits::max_frame_len`]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Limits {
|
||||
/// Largest accepted control frame payload, in bytes.
|
||||
pub max_frame_len: usize,
|
||||
/// Largest accepted hostname, in bytes.
|
||||
pub max_hostname_len: usize,
|
||||
/// Largest number of plugin capabilities in one announcement.
|
||||
pub max_capabilities: usize,
|
||||
/// Largest opaque plugin payload, in bytes.
|
||||
pub max_capability_data_len: usize,
|
||||
/// Largest accepted echo payload in a ping/pong exchange, in bytes.
|
||||
pub max_echo_payload_len: usize,
|
||||
/// Largest accepted free-text reason string, in bytes.
|
||||
pub max_reason_len: usize,
|
||||
/// Largest number of signed records accepted in one snapshot.
|
||||
pub max_state_records: usize,
|
||||
/// Deadline for the whole handshake.
|
||||
pub handshake_timeout: Duration,
|
||||
/// Deadline for one outbound dial attempt.
|
||||
pub dial_timeout: Duration,
|
||||
/// Deadline for writing one control frame.
|
||||
///
|
||||
/// Liveness of an established session is delegated to QUIC: iroh configures
|
||||
/// keep-alives and an idle timeout, so a dead peer surfaces as a read error
|
||||
/// rather than needing a protocol-level heartbeat here.
|
||||
pub write_timeout: Duration,
|
||||
/// Maximum simultaneous outbound dials per network.
|
||||
pub max_concurrent_dials: usize,
|
||||
/// Maximum simultaneous authenticated sessions per network.
|
||||
pub max_sessions_per_network: usize,
|
||||
/// Maximum simultaneous inbound connections being handshaken.
|
||||
pub max_inbound_handshakes: usize,
|
||||
/// Capacity of a session's outbound queue, providing backpressure.
|
||||
pub session_send_queue: usize,
|
||||
/// Capacity of the event broadcast channel.
|
||||
pub event_buffer: usize,
|
||||
/// Maximum address hints kept per peer in the cache.
|
||||
pub max_hints_per_peer: usize,
|
||||
}
|
||||
|
||||
impl Default for Limits {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_frame_len: 64 * 1024,
|
||||
max_hostname_len: 255,
|
||||
max_capabilities: 16,
|
||||
max_capability_data_len: 4 * 1024,
|
||||
max_echo_payload_len: 4 * 1024,
|
||||
max_reason_len: 256,
|
||||
max_state_records: crate::state::MAX_RECORDS_PER_MESSAGE,
|
||||
handshake_timeout: Duration::from_secs(10),
|
||||
dial_timeout: Duration::from_secs(10),
|
||||
write_timeout: Duration::from_secs(30),
|
||||
max_concurrent_dials: 8,
|
||||
max_sessions_per_network: 64,
|
||||
max_inbound_handshakes: 32,
|
||||
session_send_queue: 64,
|
||||
event_buffer: 512,
|
||||
max_hints_per_peer: 8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded exponential backoff with jitter for reconnect attempts.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ReconnectPolicy {
|
||||
/// Delay before the first retry.
|
||||
pub initial_delay: Duration,
|
||||
/// Upper bound on the delay.
|
||||
pub max_delay: Duration,
|
||||
/// Multiplier applied after each failed attempt.
|
||||
pub factor: f64,
|
||||
/// Fraction of the delay applied as random jitter, in `0.0..=1.0`.
|
||||
pub jitter: f64,
|
||||
/// Give up on a peer after this many consecutive failures until it is seen
|
||||
/// again by discovery. `None` means never give up while the network is up.
|
||||
pub max_consecutive_failures: Option<u32>,
|
||||
}
|
||||
|
||||
impl Default for ReconnectPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
initial_delay: Duration::from_millis(250),
|
||||
max_delay: Duration::from_secs(30),
|
||||
factor: 2.0,
|
||||
jitter: 0.3,
|
||||
max_consecutive_failures: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReconnectPolicy {
|
||||
/// Delay to wait before retry number `attempt` (1-based), with jitter.
|
||||
pub(crate) fn delay_for(&self, attempt: u32) -> Duration {
|
||||
let exp = self.factor.powi(attempt.saturating_sub(1).min(32) as i32);
|
||||
let base = self.initial_delay.as_secs_f64() * exp;
|
||||
let capped = base.min(self.max_delay.as_secs_f64());
|
||||
let jitter = self.jitter.clamp(0.0, 1.0);
|
||||
let factor = 1.0 - jitter + jitter * 2.0 * rand::random::<f64>();
|
||||
Duration::from_secs_f64((capped * factor).max(0.0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything needed to start an [`crate::Agent`].
|
||||
#[derive(Clone)]
|
||||
pub struct AgentConfig {
|
||||
/// Where the mandatory state and the disposable cache live.
|
||||
pub paths: StoragePaths,
|
||||
/// Explicit local bind addresses. Empty means iroh's defaults.
|
||||
///
|
||||
/// Tests bind to `127.0.0.1:0` so each agent gets a dynamic port.
|
||||
pub bind_addrs: Vec<SocketAddr>,
|
||||
/// How much external connectivity machinery the endpoint may use.
|
||||
pub transport: TransportPolicy,
|
||||
/// Hostname announced to peers. `None` keeps whatever the state store holds,
|
||||
/// falling back to the OS hostname and finally to a short endpoint id.
|
||||
pub hostname: Option<String>,
|
||||
/// Discovery backend. `None` disables discovery-driven dialling; static
|
||||
/// bootstrap candidates still work.
|
||||
pub discovery: Option<Arc<dyn NetworkDiscovery>>,
|
||||
/// How often each active network re-runs discovery and re-evaluates dials.
|
||||
pub discovery_interval: Duration,
|
||||
/// Bounds applied to network input.
|
||||
pub limits: Limits,
|
||||
/// Reconnect backoff policy.
|
||||
pub reconnect: ReconnectPolicy,
|
||||
/// IP plugins whose capabilities are announced and dispatched.
|
||||
pub plugins: Vec<SharedPlugin>,
|
||||
/// The IPv4 overlay range this agent proposes.
|
||||
///
|
||||
/// Addresses are allocated from it and recorded in signed state, so a
|
||||
/// participant keeps the same one across restarts. A network that has
|
||||
/// already settled on another range wins: a joining agent adopts what it
|
||||
/// finds rather than imposing this.
|
||||
pub overlay_ipv4_range: Option<crate::state::Ipv4Range>,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Creates a configuration with local-only transport and default limits.
|
||||
pub fn new(paths: StoragePaths) -> Self {
|
||||
Self {
|
||||
paths,
|
||||
bind_addrs: Vec::new(),
|
||||
transport: TransportPolicy::default(),
|
||||
hostname: None,
|
||||
discovery: None,
|
||||
discovery_interval: Duration::from_secs(5),
|
||||
limits: Limits::default(),
|
||||
reconnect: ReconnectPolicy::default(),
|
||||
plugins: Vec::new(),
|
||||
overlay_ipv4_range: Some(crate::state::DEFAULT_IPV4_RANGE),
|
||||
}
|
||||
}
|
||||
|
||||
/// Binds to loopback with a dynamic port. Used by the test suite.
|
||||
pub fn with_loopback_bind(mut self) -> Self {
|
||||
self.bind_addrs = vec![
|
||||
SocketAddr::from(([127, 0, 0, 1], 0)),
|
||||
SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 1], 0)),
|
||||
];
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets an explicit list of bind addresses.
|
||||
pub fn with_bind_addrs(mut self, addrs: impl IntoIterator<Item = SocketAddr>) -> Self {
|
||||
self.bind_addrs = addrs.into_iter().collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the transport policy.
|
||||
pub fn with_transport(mut self, transport: TransportPolicy) -> Self {
|
||||
self.transport = transport;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the discovery backend.
|
||||
pub fn with_discovery(mut self, discovery: Arc<dyn NetworkDiscovery>) -> Self {
|
||||
self.discovery = Some(discovery);
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets how often discovery runs.
|
||||
pub fn with_discovery_interval(mut self, interval: Duration) -> Self {
|
||||
self.discovery_interval = interval;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the announced hostname.
|
||||
pub fn with_hostname(mut self, hostname: impl Into<String>) -> Self {
|
||||
self.hostname = Some(hostname.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the IPv4 overlay range this agent proposes, or disables IPv4.
|
||||
pub fn with_overlay_ipv4_range(mut self, range: Option<crate::state::Ipv4Range>) -> Self {
|
||||
self.overlay_ipv4_range = range;
|
||||
self
|
||||
}
|
||||
|
||||
/// Registers an IP plugin.
|
||||
pub fn with_plugin(mut self, plugin: SharedPlugin) -> Self {
|
||||
self.plugins.push(plugin);
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the limits.
|
||||
pub fn with_limits(mut self, limits: Limits) -> Self {
|
||||
self.limits = limits;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the reconnect policy.
|
||||
pub fn with_reconnect(mut self, reconnect: ReconnectPolicy) -> Self {
|
||||
self.reconnect = reconnect;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentConfig {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentConfig")
|
||||
.field("paths", &self.paths)
|
||||
.field("bind_addrs", &self.bind_addrs)
|
||||
.field("transport", &self.transport)
|
||||
.field("hostname", &self.hostname)
|
||||
.field("discovery", &self.discovery.as_ref().map(|d| d.name()))
|
||||
.field("discovery_interval", &self.discovery_interval)
|
||||
.field("limits", &self.limits)
|
||||
.field("reconnect", &self.reconnect)
|
||||
.field(
|
||||
"plugins",
|
||||
&self
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|p| p.protocol_id().to_string())
|
||||
.collect::<Vec<_>>(),
|
||||
)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
//! Boundary between the control plane core and future IP plugins.
|
||||
//!
|
||||
//! The data plane is where actual IP connectivity is created. WireGuard is the
|
||||
//! first planned plugin; none is implemented here.
|
||||
//!
|
||||
//! Two rules shape this module:
|
||||
//!
|
||||
//! 1. **The core never parses plugin payloads.** A [`PluginCapability`] carries
|
||||
//! a protocol id, a version, an enabled flag and a bounded opaque blob. The
|
||||
//! core transports the blob and hands it to the matching plugin. It does not
|
||||
//! know what a WireGuard configuration looks like.
|
||||
//! 2. **Plugin keys and lifecycle are separate from iroh identity and from the
|
||||
//! network secret.** A plugin owns its own keys and its own system objects.
|
||||
//!
|
||||
//! An iroh address is *not* automatically a WireGuard address. A future plugin
|
||||
//! is expected to gather its own reachability information and ship it through
|
||||
//! the control plane as its announcement payload.
|
||||
//!
|
||||
//! A data plane failure never stops the daemon: errors returned here are
|
||||
//! recorded and surfaced, the control plane keeps running.
|
||||
|
||||
pub mod transport;
|
||||
pub mod wireguard;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
pub use transport::{PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
|
||||
/// Maximum length of a plugin protocol identifier.
|
||||
pub const MAX_PROTOCOL_ID_LEN: usize = 32;
|
||||
|
||||
/// An announcement of one IP plugin's capability.
|
||||
///
|
||||
/// `data` is opaque to the core. Nothing in it may be interpreted as a shell
|
||||
/// command, a filesystem path or an OS setting by the core; a plugin that
|
||||
/// 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`].
|
||||
pub protocol: String,
|
||||
/// Version of the plugin's announcement format.
|
||||
pub version: u16,
|
||||
/// Whether the peer currently has this plugin enabled.
|
||||
pub enabled: bool,
|
||||
/// Opaque, bounded, plugin-defined payload.
|
||||
pub data: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Errors a plugin may return. They are recorded, never fatal for the agent.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum PluginError {
|
||||
/// The plugin is not currently able to produce or apply configuration.
|
||||
#[error("plugin unavailable: {0}")]
|
||||
Unavailable(String),
|
||||
/// A peer announcement was not acceptable to the plugin.
|
||||
#[error("rejected peer announcement: {0}")]
|
||||
Rejected(String),
|
||||
/// Anything else.
|
||||
#[error("plugin error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// A request a plugin makes of the agent that owns it.
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum PluginRequest {
|
||||
/// Re-send this agent's announcement to every peer of a network.
|
||||
Reannounce(NetworkId),
|
||||
/// Surface a plugin error on the agent's event stream.
|
||||
Error {
|
||||
/// Network the error is scoped to.
|
||||
network: NetworkId,
|
||||
/// Plugin protocol id.
|
||||
protocol: String,
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// The agent-side handle a plugin is given when it is attached.
|
||||
///
|
||||
/// It is deliberately tiny: a plugin may ask for its announcement to be resent
|
||||
/// and may report an error. It cannot reach into agent state, cannot send
|
||||
/// arbitrary control messages and knows nothing about sessions.
|
||||
///
|
||||
/// All calls are non-blocking. If the agent is gone or its queue is full the
|
||||
/// request is dropped rather than stalling the plugin.
|
||||
#[derive(Clone)]
|
||||
pub struct PluginContext {
|
||||
sender: Option<mpsc::Sender<PluginRequest>>,
|
||||
local: Option<EndpointId>,
|
||||
}
|
||||
|
||||
impl PluginContext {
|
||||
pub(crate) fn new(sender: mpsc::Sender<PluginRequest>, local: EndpointId) -> Self {
|
||||
Self {
|
||||
sender: Some(sender),
|
||||
local: Some(local),
|
||||
}
|
||||
}
|
||||
|
||||
/// A context that discards everything, for plugins used outside an agent.
|
||||
pub fn detached() -> Self {
|
||||
Self {
|
||||
sender: None,
|
||||
local: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// This agent's own endpoint id, when the context is attached.
|
||||
///
|
||||
/// A plugin needs it to find itself in the agreed allocation.
|
||||
pub fn local_endpoint_id(&self) -> Option<EndpointId> {
|
||||
self.local
|
||||
}
|
||||
|
||||
fn send(&self, request: PluginRequest) {
|
||||
let Some(sender) = &self.sender else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = sender.try_send(request) {
|
||||
tracing::debug!(%err, "dropping plugin request");
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks the agent to resend this agent's announcement in `network`.
|
||||
///
|
||||
/// A plugin calls this when its own capability changed — it finished
|
||||
/// starting up, its keys or reachability changed — so that peers learn the
|
||||
/// new value without waiting for a reconnect.
|
||||
pub fn request_reannounce(&self, network: NetworkId) {
|
||||
self.send(PluginRequest::Reannounce(network));
|
||||
}
|
||||
|
||||
/// Reports a plugin error on the agent's event stream.
|
||||
///
|
||||
/// Plugin work happens in the plugin's own tasks, so errors cannot always
|
||||
/// be returned from a trait call. They are never fatal for the agent.
|
||||
pub fn report_error(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
protocol: impl Into<String>,
|
||||
reason: impl Into<String>,
|
||||
) {
|
||||
self.send(PluginRequest::Error {
|
||||
network,
|
||||
protocol: protocol.into(),
|
||||
reason: reason.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for PluginContext {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PluginContext")
|
||||
.field("attached", &self.sender.is_some())
|
||||
.field("local", &self.local.map(|id| id.fmt_short().to_string()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The contract an IP plugin implements.
|
||||
///
|
||||
/// Implementations must be cheap and non-blocking: the agent calls them from
|
||||
/// its runtime tasks. Anything slow belongs in the plugin's own tasks.
|
||||
pub trait IpPlugin: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Stable protocol identifier, e.g. `wireguard`.
|
||||
///
|
||||
/// Must be non-empty and at most [`MAX_PROTOCOL_ID_LEN`] bytes.
|
||||
fn protocol_id(&self) -> &str;
|
||||
|
||||
/// Called once, when the agent starts, before any network is activated.
|
||||
///
|
||||
/// The plugin keeps the context to ask for re-announcements and to report
|
||||
/// errors that happen in its own tasks.
|
||||
fn attach(&self, context: PluginContext) {
|
||||
let _ = context;
|
||||
}
|
||||
|
||||
/// Produces this agent's announcement for a given network.
|
||||
///
|
||||
/// Returning `Ok(None)` means "nothing to announce right now", which is
|
||||
/// different from an error.
|
||||
fn local_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
) -> std::result::Result<Option<PluginCapability>, PluginError>;
|
||||
|
||||
/// Called when a network is activated locally, before any peer appears.
|
||||
///
|
||||
/// A plugin uses it to get its per-network state ready, so that the first
|
||||
/// announcement already carries its capability.
|
||||
fn on_network_activated(&self, network: NetworkId) {
|
||||
let _ = network;
|
||||
}
|
||||
|
||||
/// Called when a peer announces a capability for this plugin's protocol.
|
||||
///
|
||||
/// The core has already bounded the payload size but has not interpreted it.
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
capability: &PluginCapability,
|
||||
) -> std::result::Result<(), PluginError>;
|
||||
|
||||
/// The overlay addresses the network has agreed on.
|
||||
///
|
||||
/// Allocated rather than derived, and backed by the signed records in
|
||||
/// [`crate::state`], so a participant keeps its address across restarts
|
||||
/// and long absences. Called whenever the agreed picture changes.
|
||||
fn on_address_allocation(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
range: crate::state::Ipv4Range,
|
||||
allocations: &[(EndpointId, std::net::Ipv4Addr)],
|
||||
) {
|
||||
let _ = (network, range, allocations);
|
||||
}
|
||||
|
||||
/// A data plane link to a peer is available for this plugin's protocol.
|
||||
///
|
||||
/// The plugin moves its packets over this link and never learns how the
|
||||
/// link is carried. A new link for a peer replaces any previous one.
|
||||
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
|
||||
let _ = (network, peer, link);
|
||||
}
|
||||
|
||||
/// Called when a peer's session in a network goes away.
|
||||
///
|
||||
/// Any link handed to the plugin for that peer must be dropped here.
|
||||
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId);
|
||||
|
||||
/// Called when a network is deactivated locally.
|
||||
///
|
||||
/// This is a local deactivation, not a signed revocation of membership.
|
||||
/// The plugin is expected to remove whatever it created for that network.
|
||||
fn on_network_deactivated(&self, network: NetworkId);
|
||||
|
||||
/// Called once when the agent shuts down.
|
||||
///
|
||||
/// The plugin removes the system objects it created and stops its tasks.
|
||||
/// It must be bounded: the agent awaits it during shutdown.
|
||||
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async {})
|
||||
}
|
||||
}
|
||||
|
||||
/// A shared handle to a plugin.
|
||||
pub type SharedPlugin = Arc<dyn IpPlugin>;
|
||||
|
||||
/// A plugin used in tests and examples.
|
||||
///
|
||||
/// It announces an explicitly test-only protocol id, so nothing in this crate
|
||||
/// ever advertises WireGuard as an available transport before it exists.
|
||||
#[derive(Debug)]
|
||||
pub struct TestCapabilityPlugin {
|
||||
protocol: String,
|
||||
payload: Vec<u8>,
|
||||
seen: std::sync::Mutex<Vec<(NetworkId, EndpointId, PluginCapability)>>,
|
||||
}
|
||||
|
||||
impl TestCapabilityPlugin {
|
||||
/// Creates a plugin announcing `protocol` with a fixed opaque payload.
|
||||
pub fn new(protocol: impl Into<String>, payload: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
protocol: protocol.into(),
|
||||
payload: payload.into(),
|
||||
seen: std::sync::Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns everything this plugin was handed so far.
|
||||
pub fn observed(&self) -> Vec<(NetworkId, EndpointId, PluginCapability)> {
|
||||
match self.seen.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IpPlugin for TestCapabilityPlugin {
|
||||
fn protocol_id(&self) -> &str {
|
||||
&self.protocol
|
||||
}
|
||||
|
||||
fn local_capability(
|
||||
&self,
|
||||
_network: NetworkId,
|
||||
) -> std::result::Result<Option<PluginCapability>, PluginError> {
|
||||
Ok(Some(PluginCapability {
|
||||
protocol: self.protocol.clone(),
|
||||
version: 1,
|
||||
enabled: true,
|
||||
data: self.payload.clone(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
capability: &PluginCapability,
|
||||
) -> std::result::Result<(), PluginError> {
|
||||
let mut guard = match self.seen.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.push((network, peer, capability.clone()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_peer_gone(&self, _network: NetworkId, _peer: EndpointId) {}
|
||||
|
||||
fn on_network_deactivated(&self, _network: NetworkId) {}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! A data plane transport built on iroh.
|
||||
//!
|
||||
//! This is where the data plane gets NAT traversal from. iroh hole punches a
|
||||
//! direct path between two peers when it can and falls back to a relay when it
|
||||
//! cannot, so every plugin inherits that without implementing STUN, ICE or a
|
||||
//! relay of its own.
|
||||
//!
|
||||
//! Data connections are separate from control connections in every way that
|
||||
//! matters: their own ALPN ([`DATA_ALPN`]), their own QUIC connection, their
|
||||
//! own congestion control. They carry one plugin protocol for one network.
|
||||
//! A data connection that breaks or floods cannot disturb the control plane.
|
||||
//!
|
||||
//! Packets travel as QUIC datagrams: unreliable and unordered, which is what a
|
||||
//! tunnelled UDP protocol wants, and free of the head-of-line blocking a
|
||||
//! stream would add.
|
||||
//!
|
||||
//! The channel is authenticated exactly like a control connection — the same
|
||||
//! membership handshake, bound to the same network — so a data link cannot be
|
||||
//! opened by someone who does not know the network secret.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::config::Limits;
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::{NetworkId, NetworkKeys};
|
||||
use crate::net::EndpointAdapter;
|
||||
use crate::proto::handshake;
|
||||
use crate::proto::message::{
|
||||
DATA_ALPN, DataOpen, DataOpenAck, MAX_DATA_PROTOCOL_LEN, decode, encode,
|
||||
};
|
||||
use crate::proto::{read_frame, write_frame};
|
||||
|
||||
use super::{InboundLink, PacketLink, PacketTransport, SharedLink, TransportError};
|
||||
|
||||
/// What the iroh transport needs from the agent.
|
||||
///
|
||||
/// Implemented by the agent, which is the only thing that knows which networks
|
||||
/// are active and which plugin protocols are served.
|
||||
pub trait TransportContext: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// Key material of every network that is active right now.
|
||||
///
|
||||
/// Taken as one snapshot because the membership handshake resolves the
|
||||
/// requested network synchronously, exactly as the control plane's accept
|
||||
/// path does.
|
||||
fn snapshot<'a>(&'a self) -> BoxFuture<'a, HashMap<NetworkId, NetworkKeys>>;
|
||||
|
||||
/// Whether a plugin protocol is served in a network.
|
||||
fn serves<'a>(&'a self, network: NetworkId, protocol: &'a str) -> BoxFuture<'a, bool>;
|
||||
}
|
||||
|
||||
/// One authenticated datagram channel over an iroh connection.
|
||||
#[derive(Debug)]
|
||||
pub struct IrohLink {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
conn: Connection,
|
||||
max_datagram: usize,
|
||||
// Kept alive so the peer sees the channel as open; the connection closes
|
||||
// when the link is dropped.
|
||||
_send: tokio::sync::Mutex<SendStream>,
|
||||
_recv: tokio::sync::Mutex<RecvStream>,
|
||||
}
|
||||
|
||||
impl IrohLink {
|
||||
fn new(
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
conn: Connection,
|
||||
peer_limit: usize,
|
||||
send: SendStream,
|
||||
recv: RecvStream,
|
||||
) -> Self {
|
||||
let local_limit = conn.max_datagram_size().unwrap_or(0);
|
||||
// Both ends must agree, so the smaller limit wins.
|
||||
let max_datagram = local_limit.min(peer_limit);
|
||||
Self {
|
||||
network,
|
||||
peer,
|
||||
conn,
|
||||
max_datagram,
|
||||
_send: tokio::sync::Mutex::new(send),
|
||||
_recv: tokio::sync::Mutex::new(recv),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketLink for IrohLink {
|
||||
fn network(&self) -> NetworkId {
|
||||
self.network
|
||||
}
|
||||
|
||||
fn peer(&self) -> EndpointId {
|
||||
self.peer
|
||||
}
|
||||
|
||||
fn max_datagram_size(&self) -> usize {
|
||||
self.max_datagram
|
||||
}
|
||||
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
|
||||
if payload.len() > self.max_datagram {
|
||||
return Err(TransportError::TooLarge {
|
||||
size: payload.len(),
|
||||
limit: self.max_datagram,
|
||||
});
|
||||
}
|
||||
self.conn.send_datagram(payload).map_err(|err| {
|
||||
use iroh::endpoint::SendDatagramError;
|
||||
match err {
|
||||
SendDatagramError::ConnectionLost(_) => TransportError::Closed,
|
||||
other => TransportError::Other(other.to_string()),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.conn.read_datagram().await.ok() })
|
||||
}
|
||||
|
||||
fn closed(&self) -> BoxFuture<'_, ()> {
|
||||
Box::pin(async move {
|
||||
let _ = self.conn.closed().await;
|
||||
})
|
||||
}
|
||||
|
||||
fn is_closed(&self) -> bool {
|
||||
self.conn.close_reason().is_some()
|
||||
}
|
||||
|
||||
fn path_description(&self) -> String {
|
||||
// Report what iroh actually knows, never a guess.
|
||||
let snapshot = crate::net::snapshot_connection(&self.conn);
|
||||
match snapshot.paths.iter().find(|path| path.is_selected) {
|
||||
Some(path) => format!("{} via {}", snapshot.transport, path.remote),
|
||||
None => format!("{}, no selected path yet", snapshot.transport),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens and accepts data plane links over iroh.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct IrohTransport {
|
||||
adapter: EndpointAdapter,
|
||||
limits: Arc<Limits>,
|
||||
lookup: Arc<dyn TransportContext>,
|
||||
}
|
||||
|
||||
impl IrohTransport {
|
||||
/// Creates a transport on an existing endpoint.
|
||||
pub fn new(
|
||||
adapter: EndpointAdapter,
|
||||
limits: Arc<Limits>,
|
||||
lookup: Arc<dyn TransportContext>,
|
||||
) -> Self {
|
||||
Self {
|
||||
adapter,
|
||||
limits,
|
||||
lookup,
|
||||
}
|
||||
}
|
||||
|
||||
/// Completes an inbound data connection that the accept loop routed here.
|
||||
///
|
||||
/// The membership handshake runs first, exactly as on a control
|
||||
/// connection, so an unauthenticated caller never reaches a plugin.
|
||||
pub async fn accept(&self, conn: Connection) -> Result<InboundLink, TransportError> {
|
||||
let peer = conn.remote_id();
|
||||
let (mut send, mut recv) = conn
|
||||
.accept_bi()
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(format!("no data channel stream: {err}")))?;
|
||||
|
||||
let local_id = self.adapter.endpoint_id();
|
||||
let known = self.lookup.snapshot().await;
|
||||
let outcome = handshake::respond(
|
||||
&conn,
|
||||
&mut send,
|
||||
&mut recv,
|
||||
local_id,
|
||||
&self.limits,
|
||||
|network| known.get(&network).cloned(),
|
||||
)
|
||||
.await
|
||||
.map_err(|err| match err {
|
||||
ProtocolError::UnknownNetwork => {
|
||||
TransportError::Other("network is not active for the data plane".into())
|
||||
}
|
||||
other => TransportError::Other(other.to_string()),
|
||||
})?;
|
||||
|
||||
let open: DataOpen = decode(
|
||||
&read_frame(&mut recv, self.limits.max_frame_len)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
if open.protocol.is_empty() || open.protocol.len() > MAX_DATA_PROTOCOL_LEN {
|
||||
return Err(TransportError::Other(
|
||||
"data channel protocol id is out of bounds".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let serves = self.lookup.serves(outcome.network_id, &open.protocol).await;
|
||||
let max_datagram = conn.max_datagram_size().unwrap_or(0);
|
||||
let ack = DataOpenAck {
|
||||
accepted: serves,
|
||||
max_datagram: max_datagram as u32,
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
&encode(&ack).map_err(|err| TransportError::Other(err.to_string()))?,
|
||||
self.limits.max_frame_len,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
if !serves {
|
||||
conn.close(4u32.into(), b"no plugin for this protocol");
|
||||
return Err(TransportError::Declined(open.protocol));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(outcome.network_id, peer, conn, usize::MAX, send, recv);
|
||||
Ok(InboundLink {
|
||||
network: outcome.network_id,
|
||||
peer,
|
||||
protocol: open.protocol,
|
||||
link: Arc::new(link),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl PacketTransport for IrohTransport {
|
||||
fn name(&self) -> &str {
|
||||
"iroh"
|
||||
}
|
||||
|
||||
fn as_any(&self) -> &dyn std::any::Any {
|
||||
self
|
||||
}
|
||||
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>> {
|
||||
Box::pin(async move {
|
||||
if protocol.is_empty() || protocol.len() > MAX_DATA_PROTOCOL_LEN {
|
||||
return Err(TransportError::Other(
|
||||
"data channel protocol id is out of bounds".into(),
|
||||
));
|
||||
}
|
||||
let keys = self
|
||||
.lookup
|
||||
.snapshot()
|
||||
.await
|
||||
.remove(&network)
|
||||
.ok_or_else(|| TransportError::Other("network is not active".into()))?;
|
||||
|
||||
let addr = iroh::EndpointAddr::new(peer);
|
||||
let conn = self
|
||||
.adapter
|
||||
.endpoint()
|
||||
.connect(addr, DATA_ALPN)
|
||||
.await
|
||||
.map_err(|err| TransportError::Unreachable(err.to_string()))?;
|
||||
let (mut send, mut recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|err| TransportError::Unreachable(err.to_string()))?;
|
||||
|
||||
handshake::initiate(
|
||||
&conn,
|
||||
&mut send,
|
||||
&mut recv,
|
||||
self.adapter.endpoint_id(),
|
||||
&keys,
|
||||
&self.limits,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
let open = DataOpen {
|
||||
protocol: protocol.to_string(),
|
||||
};
|
||||
write_frame(
|
||||
&mut send,
|
||||
&encode(&open).map_err(|err| TransportError::Other(err.to_string()))?,
|
||||
self.limits.max_frame_len,
|
||||
)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
let ack: DataOpenAck = decode(
|
||||
&read_frame(&mut recv, self.limits.max_frame_len)
|
||||
.await
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?,
|
||||
)
|
||||
.map_err(|err| TransportError::Other(err.to_string()))?;
|
||||
|
||||
if !ack.accepted {
|
||||
conn.close(4u32.into(), b"declined");
|
||||
return Err(TransportError::Declined(protocol.to_string()));
|
||||
}
|
||||
|
||||
let link = IrohLink::new(network, peer, conn, ack.max_datagram as usize, send, recv);
|
||||
Ok(Arc::new(link) as SharedLink)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! The data plane transport boundary.
|
||||
//!
|
||||
//! This is the seam that keeps the control protocol and the data plane
|
||||
//! independent. An [`IpPlugin`] never learns how its packets are moved: it is
|
||||
//! handed a [`PacketLink`] to a peer and writes datagrams into it. Whether
|
||||
//! that link runs over iroh today, a raw UDP socket, or something else
|
||||
//! entirely tomorrow is the transport's business alone.
|
||||
//!
|
||||
//! [`IpPlugin`]: crate::dataplane::IpPlugin
|
||||
//!
|
||||
//! # Why the transport may use iroh
|
||||
//!
|
||||
//! The separation between control and data is **logical**, not a ban on
|
||||
//! sharing technology. Refusing to use iroh for data would throw away exactly
|
||||
//! what iroh is good at — hole punching a direct path between two peers behind
|
||||
//! NAT, with a relay as fallback — and force the data plane to reimplement it.
|
||||
//! So the default transport is [`iroh_link::IrohTransport`], which gives every
|
||||
//! plugin that connectivity for free.
|
||||
//!
|
||||
//! What the separation does buy is that the control protocol in
|
||||
//! [`crate::proto`] knows nothing about packets, and this module knows nothing
|
||||
//! about WireGuard. Either side can be replaced on its own.
|
||||
//!
|
||||
//! # Semantics
|
||||
//!
|
||||
//! A link is an **unreliable, unordered datagram** channel, because that is
|
||||
//! what a tunnelled UDP protocol needs: no retransmission, no head-of-line
|
||||
//! blocking, loss is normal rather than an error. It is authenticated and
|
||||
//! encrypted by the transport, and scoped to exactly one network, one peer and
|
||||
//! one plugin protocol.
|
||||
|
||||
pub mod iroh_link;
|
||||
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Why a data plane link failed.
|
||||
///
|
||||
/// None of these ever stop the control plane.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum TransportError {
|
||||
/// The peer is not reachable for the data plane right now.
|
||||
#[error("peer is unreachable: {0}")]
|
||||
Unreachable(String),
|
||||
/// The peer declined to open a channel for this protocol.
|
||||
#[error("peer declined a data channel for protocol `{0}`")]
|
||||
Declined(String),
|
||||
/// The link is closed.
|
||||
#[error("data link is closed")]
|
||||
Closed,
|
||||
/// A datagram was larger than the link can carry.
|
||||
#[error("datagram of {size} bytes exceeds the {limit} byte link limit")]
|
||||
TooLarge {
|
||||
/// Size that was attempted.
|
||||
size: usize,
|
||||
/// Largest datagram this link accepts.
|
||||
limit: usize,
|
||||
},
|
||||
/// Anything else.
|
||||
#[error("data transport error: {0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// An authenticated datagram channel to one peer, for one plugin protocol.
|
||||
///
|
||||
/// Dropping the link closes it.
|
||||
pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The network this link belongs to.
|
||||
fn network(&self) -> NetworkId;
|
||||
|
||||
/// The authenticated peer on the other end.
|
||||
fn peer(&self) -> EndpointId;
|
||||
|
||||
/// The largest datagram this link can carry, in bytes.
|
||||
///
|
||||
/// A plugin must size its own packets to fit, because there is no
|
||||
/// fragmentation here.
|
||||
fn max_datagram_size(&self) -> usize;
|
||||
|
||||
/// Sends one datagram.
|
||||
///
|
||||
/// Delivery is not guaranteed. Returning `Ok` means the datagram was
|
||||
/// handed to the transport, nothing more.
|
||||
fn send(&self, payload: Bytes) -> Result<(), TransportError>;
|
||||
|
||||
/// Receives the next datagram, or `None` once the link is finished.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
/// Resolves once the link is closed, for whatever reason.
|
||||
fn closed(&self) -> BoxFuture<'_, ()>;
|
||||
|
||||
/// Whether the link is already closed.
|
||||
///
|
||||
/// Lets the owner notice a dead link and ask for a new one without
|
||||
/// keeping a task parked on [`PacketLink::closed`].
|
||||
fn is_closed(&self) -> bool;
|
||||
|
||||
/// A short description of the path in use, for diagnostics.
|
||||
///
|
||||
/// Reports what the transport actually knows. It must not invent a value.
|
||||
fn path_description(&self) -> String;
|
||||
}
|
||||
|
||||
/// A shared handle to a link.
|
||||
pub type SharedLink = std::sync::Arc<dyn PacketLink>;
|
||||
|
||||
/// An inbound link a peer opened towards us.
|
||||
#[derive(Debug)]
|
||||
pub struct InboundLink {
|
||||
/// The network it belongs to.
|
||||
pub network: NetworkId,
|
||||
/// The peer that opened it.
|
||||
pub peer: EndpointId,
|
||||
/// The plugin protocol it carries.
|
||||
pub protocol: String,
|
||||
/// The link itself.
|
||||
pub link: SharedLink,
|
||||
}
|
||||
|
||||
/// Opens and accepts data plane links.
|
||||
///
|
||||
/// The agent owns one of these and hands links to plugins; plugins never call
|
||||
/// it directly.
|
||||
pub trait PacketTransport: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Lets the agent recover the concrete transport to drive its accept side.
|
||||
///
|
||||
/// Accepting is inherently transport-specific — it starts from whatever
|
||||
/// the transport's own listener produced — so it is not part of this
|
||||
/// trait's uniform interface.
|
||||
fn as_any(&self) -> &dyn std::any::Any;
|
||||
|
||||
/// Opens a link to `peer` in `network` for `protocol`.
|
||||
fn open<'a>(
|
||||
&'a self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
protocol: &'a str,
|
||||
) -> BoxFuture<'a, Result<SharedLink, TransportError>>;
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! What a WireGuard peer tells the network about itself.
|
||||
//!
|
||||
//! This is the opaque payload the control plane carries in a
|
||||
//! [`crate::dataplane::PluginCapability`]. The agent core never parses it —
|
||||
//! only this module does, and only after bounding every field.
|
||||
//!
|
||||
//! 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
|
||||
//! addresses would be reimplementing NAT traversal badly.
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::keys::WgPublicKey;
|
||||
use super::overlay::overlay_address;
|
||||
|
||||
/// Version of the announcement format.
|
||||
///
|
||||
/// Version 3 dropped the IPv4 range again: overlay addressing moved to the
|
||||
/// signed records in [`crate::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 = 3;
|
||||
|
||||
/// What one participant advertises for the WireGuard data plane.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct WgAnnouncement {
|
||||
/// Announcement format version.
|
||||
pub version: u16,
|
||||
/// The peer's WireGuard public key. Its overlay address is derived from it.
|
||||
pub public_key: [u8; 32],
|
||||
/// The overlay address the peer believes it has.
|
||||
///
|
||||
/// Carried for diagnostics and cross-checking only. Addresses are always
|
||||
/// derived locally, never taken from this field.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
}
|
||||
|
||||
/// A peer announcement that has been validated against a specific network.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ValidatedAnnouncement {
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address derived locally for this key. Authoritative.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
}
|
||||
|
||||
impl WgAnnouncement {
|
||||
/// Builds this agent's announcement.
|
||||
pub fn new(network: NetworkId, public_key: &WgPublicKey) -> Self {
|
||||
Self {
|
||||
version: ANNOUNCEMENT_VERSION,
|
||||
public_key: *public_key.as_bytes(),
|
||||
overlay_address: overlay_address(network, public_key),
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes the announcement into the opaque capability payload.
|
||||
pub fn encode(&self) -> Result<Vec<u8>, PluginError> {
|
||||
postcard::to_stdvec(self)
|
||||
.map_err(|err| PluginError::Other(format!("cannot encode announcement: {err}")))
|
||||
}
|
||||
|
||||
/// Decodes and validates a payload received from a peer.
|
||||
///
|
||||
/// `network` and `local_key` scope the checks: an announcement is only
|
||||
/// meaningful inside one network, and a peer must not claim our own key.
|
||||
pub fn decode_and_validate(
|
||||
payload: &[u8],
|
||||
network: NetworkId,
|
||||
local_key: &WgPublicKey,
|
||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
||||
let announcement: Self = postcard::from_bytes(payload)
|
||||
.map_err(|_| PluginError::Rejected("malformed WireGuard announcement".into()))?;
|
||||
announcement.validate(network, local_key)
|
||||
}
|
||||
|
||||
fn validate(
|
||||
self,
|
||||
network: NetworkId,
|
||||
local_key: &WgPublicKey,
|
||||
) -> Result<ValidatedAnnouncement, PluginError> {
|
||||
if self.version != ANNOUNCEMENT_VERSION {
|
||||
return Err(PluginError::Rejected(format!(
|
||||
"peer speaks WireGuard announcement version {} but this build speaks \
|
||||
{ANNOUNCEMENT_VERSION}; one of the two needs updating",
|
||||
self.version
|
||||
)));
|
||||
}
|
||||
|
||||
let public_key = WgPublicKey::from_bytes(self.public_key);
|
||||
if public_key.is_zero() {
|
||||
return Err(PluginError::Rejected(
|
||||
"WireGuard public key is all zeroes".into(),
|
||||
));
|
||||
}
|
||||
if &public_key == local_key {
|
||||
return Err(PluginError::Rejected(
|
||||
"peer announced this agent's own WireGuard key".into(),
|
||||
));
|
||||
}
|
||||
// AllowedIPs are derived, never trusted. A mismatch means the peer is
|
||||
// confused or lying, and either way its own claim is discarded.
|
||||
let derived = overlay_address(network, &public_key);
|
||||
if self.overlay_address != derived {
|
||||
return Err(PluginError::Rejected(
|
||||
"announced overlay address does not match the one derived from the peer's key"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
|
||||
Ok(ValidatedAnnouncement {
|
||||
public_key,
|
||||
overlay_address: derived,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
use super::super::keys::WgSecretKey;
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![3u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_well_formed_announcement_round_trips() {
|
||||
let id = network("round-trip");
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
||||
let validated = WgAnnouncement::decode_and_validate(&payload, id, &local).unwrap();
|
||||
|
||||
assert_eq!(validated.public_key, peer);
|
||||
assert_eq!(validated.overlay_address, overlay_address(id, &peer));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_announcement_says_who_not_where() {
|
||||
// Reachability belongs to the transport. Nothing address-like is
|
||||
// carried here, so there is nothing for a peer to lie about.
|
||||
let id = network("identity-only");
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
||||
assert!(
|
||||
payload.len() < 80,
|
||||
"the announcement should stay tiny, got {} bytes",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowed_ips_are_derived_not_taken_from_the_peer() {
|
||||
let id = network("no-hijack");
|
||||
let victim = WgSecretKey::generate().public();
|
||||
let attacker = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
// An attacker claims the victim's overlay address with its own key.
|
||||
let mut forged = WgAnnouncement::new(id, &attacker);
|
||||
forged.overlay_address = overlay_address(id, &victim);
|
||||
|
||||
let result = WgAnnouncement::decode_and_validate(&forged.encode().unwrap(), id, &local);
|
||||
assert!(
|
||||
matches!(result, Err(PluginError::Rejected(ref reason)) if reason.contains("does not match")),
|
||||
"claiming another member's overlay address must be rejected: {result:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_announcement_from_another_network_does_not_validate() {
|
||||
let here = network("here");
|
||||
let there = network("there");
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let local = WgSecretKey::generate().public();
|
||||
|
||||
let payload = WgAnnouncement::new(there, &peer).encode().unwrap();
|
||||
assert!(WgAnnouncement::decode_and_validate(&payload, here, &local).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_payloads_are_rejected_without_panicking() {
|
||||
let id = network("hostile");
|
||||
let local = WgSecretKey::generate().public();
|
||||
let peer = WgSecretKey::generate().public();
|
||||
|
||||
assert!(WgAnnouncement::decode_and_validate(&[0xff; 64], id, &local).is_err());
|
||||
assert!(WgAnnouncement::decode_and_validate(&[], id, &local).is_err());
|
||||
|
||||
let wrong_version = WgAnnouncement {
|
||||
version: ANNOUNCEMENT_VERSION + 1,
|
||||
..WgAnnouncement::new(id, &peer)
|
||||
};
|
||||
assert!(
|
||||
WgAnnouncement::decode_and_validate(&wrong_version.encode().unwrap(), id, &local)
|
||||
.is_err()
|
||||
);
|
||||
|
||||
let zero_key = WgAnnouncement {
|
||||
public_key: [0u8; 32],
|
||||
..WgAnnouncement::new(id, &peer)
|
||||
};
|
||||
assert!(
|
||||
WgAnnouncement::decode_and_validate(&zero_key.encode().unwrap(), id, &local).is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_peer_cannot_claim_our_own_key() {
|
||||
let id = network("self");
|
||||
let local = WgSecretKey::generate().public();
|
||||
let payload = WgAnnouncement::new(id, &local).encode().unwrap();
|
||||
assert!(WgAnnouncement::decode_and_validate(&payload, id, &local).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn announcements_stay_well_under_the_capability_payload_limit() {
|
||||
let id = network("size");
|
||||
let peer = WgSecretKey::generate().public();
|
||||
let payload = WgAnnouncement::new(id, &peer).encode().unwrap();
|
||||
assert!(
|
||||
payload.len() < crate::config::Limits::default().max_capability_data_len,
|
||||
"announcement is {} bytes",
|
||||
payload.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! The desired local WireGuard configuration, and how it is rendered.
|
||||
//!
|
||||
//! Each agent builds its own configuration from the agreed set of
|
||||
//! participants. For a full mesh of `N` members that is `N - 1` peers locally;
|
||||
//! nobody hands out a configuration to anybody else.
|
||||
//!
|
||||
//! Nothing in here is free-form text taken from the network. Peer keys,
|
||||
//! endpoints, allowed prefixes and keepalives are typed values that this
|
||||
//! module re-serialises itself, so a hostile announcement cannot inject a
|
||||
//! configuration directive or a command argument.
|
||||
|
||||
use std::net::{IpAddr, Ipv6Addr};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::overlay::OVERLAY_HOST_PREFIX_LEN;
|
||||
|
||||
/// Longest interface name Linux accepts, excluding the terminating NUL.
|
||||
pub const MAX_INTERFACE_NAME_LEN: usize = 15;
|
||||
|
||||
/// Default prefix for interface names this plugin creates.
|
||||
pub const DEFAULT_INTERFACE_PREFIX: &str = "tsun";
|
||||
|
||||
/// An address with a prefix length.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct Cidr {
|
||||
/// The address.
|
||||
pub addr: IpAddr,
|
||||
/// The prefix length in bits.
|
||||
pub prefix_len: u8,
|
||||
}
|
||||
|
||||
impl Cidr {
|
||||
/// Builds a CIDR, rejecting an impossible prefix length.
|
||||
pub fn new(addr: IpAddr, prefix_len: u8) -> Result<Self, PluginError> {
|
||||
let max = match addr {
|
||||
IpAddr::V4(_) => 32,
|
||||
IpAddr::V6(_) => 128,
|
||||
};
|
||||
if prefix_len > max {
|
||||
return Err(PluginError::Other(format!(
|
||||
"prefix length /{prefix_len} is impossible for {addr}"
|
||||
)));
|
||||
}
|
||||
Ok(Self { addr, prefix_len })
|
||||
}
|
||||
|
||||
/// A single host address.
|
||||
pub fn host(addr: Ipv6Addr) -> Self {
|
||||
Self {
|
||||
addr: IpAddr::V6(addr),
|
||||
prefix_len: OVERLAY_HOST_PREFIX_LEN,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Cidr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}/{}", self.addr, self.prefix_len)
|
||||
}
|
||||
}
|
||||
|
||||
/// Derives this plugin's interface name for a network.
|
||||
///
|
||||
/// The name is stable across restarts and short enough for the platform. Two
|
||||
/// agents on the same host in the same network must be given different
|
||||
/// prefixes, or they would derive the same name.
|
||||
pub fn interface_name(prefix: &str, network: NetworkId) -> Result<String, PluginError> {
|
||||
if prefix.is_empty() {
|
||||
return Err(PluginError::Other(
|
||||
"interface prefix must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if !prefix
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||
{
|
||||
return Err(PluginError::Other(
|
||||
"interface prefix must be lowercase ASCII letters and digits".into(),
|
||||
));
|
||||
}
|
||||
if prefix.len() >= MAX_INTERFACE_NAME_LEN {
|
||||
return Err(PluginError::Other(format!(
|
||||
"interface prefix must be shorter than {MAX_INTERFACE_NAME_LEN} characters"
|
||||
)));
|
||||
}
|
||||
|
||||
let mut suffix = data_encoding::BASE32_NOPAD.encode(network.as_bytes());
|
||||
suffix.make_ascii_lowercase();
|
||||
let room = MAX_INTERFACE_NAME_LEN - prefix.len();
|
||||
suffix.truncate(room);
|
||||
Ok(format!("{prefix}{suffix}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![5u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interface_names_fit_the_platform_limit_and_are_stable() {
|
||||
let id = network("naming");
|
||||
let name = interface_name(DEFAULT_INTERFACE_PREFIX, id).unwrap();
|
||||
assert_eq!(name.len(), MAX_INTERFACE_NAME_LEN);
|
||||
assert!(name.starts_with(DEFAULT_INTERFACE_PREFIX));
|
||||
assert!(name.chars().all(|c| c.is_ascii_alphanumeric()));
|
||||
assert_eq!(name, interface_name(DEFAULT_INTERFACE_PREFIX, id).unwrap());
|
||||
assert_ne!(
|
||||
name,
|
||||
interface_name(DEFAULT_INTERFACE_PREFIX, network("other")).unwrap()
|
||||
);
|
||||
assert_ne!(name, interface_name("wg", id).unwrap());
|
||||
|
||||
assert!(interface_name("", id).is_err());
|
||||
assert!(interface_name("has space", id).is_err());
|
||||
assert!(interface_name("UPPER", id).is_err());
|
||||
assert!(interface_name(&"a".repeat(MAX_INTERFACE_NAME_LEN), id).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cidr_rejects_an_impossible_prefix_length() {
|
||||
assert!(Cidr::new("10.0.0.1".parse().unwrap(), 33).is_err());
|
||||
assert!(Cidr::new("fd00::1".parse().unwrap(), 129).is_err());
|
||||
assert_eq!(
|
||||
Cidr::host("fd00::1".parse().unwrap()).to_string(),
|
||||
"fd00::1/128"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,699 @@
|
||||
//! Userspace WireGuard.
|
||||
//!
|
||||
//! The protocol itself is [`boringtun::noise::Tunn`], which is pure state
|
||||
//! machine: no sockets, no TUN, no kernel module. That is what lets this work
|
||||
//! the same way on any platform and be tested end to end without privileges.
|
||||
//!
|
||||
//! ```text
|
||||
//! TunDevice (IP packets) PacketLink per peer
|
||||
//! | |
|
||||
//! v v
|
||||
//! destination address -> peer --Tunn.encapsulate--> ciphertext
|
||||
//! source address checked <--Tunn.decapsulate-- ciphertext
|
||||
//! ```
|
||||
//!
|
||||
//! # Address ownership is enforced here
|
||||
//!
|
||||
//! Kernel WireGuard enforces `AllowedIPs`; in userspace we must do it
|
||||
//! ourselves, and we do:
|
||||
//!
|
||||
//! * outbound, a packet is routed to the peer that **owns** its destination
|
||||
//! address, where ownership is the derivation in [`super::overlay`];
|
||||
//! * inbound, a decrypted packet is dropped unless its **source** is exactly
|
||||
//! the address derived for the peer whose tunnel decrypted it.
|
||||
//!
|
||||
//! So a participant cannot receive traffic addressed to someone else, and
|
||||
//! cannot forge traffic that appears to come from someone else, no matter
|
||||
//! what it announced.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use boringtun::noise::{Tunn, TunnResult};
|
||||
use bytes::Bytes;
|
||||
use iroh::EndpointId;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::dataplane::transport::{SharedLink, TransportError};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::keys::{WgPublicKey, WgSecretKey};
|
||||
use super::overlay::overlay_address;
|
||||
use super::packet::IpHeader;
|
||||
use super::tun::TunDevice;
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// How often WireGuard's own timers are driven.
|
||||
///
|
||||
/// boringtun expects this at least every few hundred milliseconds; it is what
|
||||
/// drives handshakes, rekeying and keepalives.
|
||||
const TIMER_INTERVAL: Duration = Duration::from_millis(250);
|
||||
|
||||
/// Scratch space for one encapsulate or decapsulate call.
|
||||
const SCRATCH: usize = 4096;
|
||||
|
||||
/// Counters for one peer's tunnel.
|
||||
#[derive(Debug, Default)]
|
||||
struct PeerCounters {
|
||||
tx_packets: AtomicU64,
|
||||
tx_bytes: AtomicU64,
|
||||
rx_packets: AtomicU64,
|
||||
rx_bytes: AtomicU64,
|
||||
dropped_wrong_source: AtomicU64,
|
||||
dropped_oversize: AtomicU64,
|
||||
protocol_errors: AtomicU64,
|
||||
}
|
||||
|
||||
/// A snapshot of one peer's tunnel counters.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct PeerStats {
|
||||
/// Plaintext packets encrypted and sent to this peer.
|
||||
pub tx_packets: u64,
|
||||
/// Plaintext bytes encrypted and sent to this peer.
|
||||
pub tx_bytes: u64,
|
||||
/// Plaintext packets decrypted from this peer and given to the OS.
|
||||
pub rx_packets: u64,
|
||||
/// Plaintext bytes decrypted from this peer and given to the OS.
|
||||
pub rx_bytes: u64,
|
||||
/// Packets dropped because their source was not this peer's address.
|
||||
///
|
||||
/// A non-zero value means a peer tried to use an address it does not own.
|
||||
pub dropped_wrong_source: u64,
|
||||
/// Packets dropped because they did not fit in one link datagram.
|
||||
pub dropped_oversize: u64,
|
||||
/// WireGuard protocol errors, including packets that failed to decrypt.
|
||||
pub protocol_errors: u64,
|
||||
}
|
||||
|
||||
/// Whether a peer's tunnel has completed a handshake.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct PeerHealth {
|
||||
/// Time since the last successful WireGuard handshake.
|
||||
///
|
||||
/// `None` means no handshake has completed yet, so the tunnel is not
|
||||
/// carrying traffic. This is reported as it is, never guessed.
|
||||
pub since_handshake: Option<Duration>,
|
||||
}
|
||||
|
||||
impl PeerHealth {
|
||||
/// Whether the tunnel has ever completed a handshake.
|
||||
pub fn is_up(&self) -> bool {
|
||||
self.since_handshake.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
struct Peer {
|
||||
endpoint_id: EndpointId,
|
||||
public_key: WgPublicKey,
|
||||
overlay: Ipv6Addr,
|
||||
/// The IPv4 address this peer owns, when the overlay is dual stack and
|
||||
/// nobody else derived the same one.
|
||||
overlay_v4: Mutex<Option<Ipv4Addr>>,
|
||||
tunn: Mutex<Tunn>,
|
||||
link: SharedLink,
|
||||
counters: Arc<PeerCounters>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Peer {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Peer")
|
||||
.field("peer", &self.endpoint_id.fmt_short().to_string())
|
||||
.field("public_key", &self.public_key)
|
||||
.field("overlay", &self.overlay)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Peer {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.task.lock()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Peer {
|
||||
fn stats(&self) -> PeerStats {
|
||||
PeerStats {
|
||||
tx_packets: self.counters.tx_packets.load(Ordering::Relaxed),
|
||||
tx_bytes: self.counters.tx_bytes.load(Ordering::Relaxed),
|
||||
rx_packets: self.counters.rx_packets.load(Ordering::Relaxed),
|
||||
rx_bytes: self.counters.rx_bytes.load(Ordering::Relaxed),
|
||||
dropped_wrong_source: self.counters.dropped_wrong_source.load(Ordering::Relaxed),
|
||||
dropped_oversize: self.counters.dropped_oversize.load(Ordering::Relaxed),
|
||||
protocol_errors: self.counters.protocol_errors.load(Ordering::Relaxed),
|
||||
}
|
||||
}
|
||||
|
||||
fn health(&self) -> PeerHealth {
|
||||
let guard = match self.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
PeerHealth {
|
||||
since_handshake: guard.time_since_last_handshake(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a peer's tunnel looks like from outside.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerSummary {
|
||||
/// The peer's control plane identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address this agent derived for it.
|
||||
pub overlay_address: Ipv6Addr,
|
||||
/// Its IPv4 overlay address, when the overlay is dual stack.
|
||||
///
|
||||
/// `None` with `ipv4_conflict` set means another member derived the same
|
||||
/// address and won it; that peer is still fully reachable over IPv6.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// Whether this peer lost an IPv4 address to a derivation collision.
|
||||
pub ipv4_conflict: bool,
|
||||
/// Whether the tunnel has handshaken.
|
||||
pub health: PeerHealth,
|
||||
/// Traffic counters.
|
||||
pub stats: PeerStats,
|
||||
/// What the transport reports about the path carrying this tunnel.
|
||||
pub path: String,
|
||||
/// Largest datagram the link accepts.
|
||||
pub max_datagram: usize,
|
||||
}
|
||||
|
||||
struct Inner {
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
tun: Arc<dyn TunDevice>,
|
||||
/// The IPv4 overlay range, when the overlay is dual stack.
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
peers: RwLock<HashMap<WgPublicKey, Arc<Peer>>>,
|
||||
/// Both families, so one lookup routes any packet.
|
||||
routes: RwLock<HashMap<IpAddr, WgPublicKey>>,
|
||||
next_index: AtomicU32,
|
||||
unroutable: AtomicU64,
|
||||
/// One destination nobody owned, kept so the counter can be acted on.
|
||||
unroutable_sample: Mutex<Option<IpAddr>>,
|
||||
multicast: AtomicU64,
|
||||
ipv4_conflicts: AtomicU64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Inner {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Inner")
|
||||
.field("network", &self.network.fmt_short())
|
||||
.field("tun", &self.tun.name())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// A userspace WireGuard interface for one network.
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardDevice {
|
||||
inner: Arc<Inner>,
|
||||
tasks: Vec<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl WireguardDevice {
|
||||
/// Starts a device on top of `tun`.
|
||||
pub fn start(
|
||||
network: NetworkId,
|
||||
private_key: WgSecretKey,
|
||||
tun: Arc<dyn TunDevice>,
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
) -> Self {
|
||||
let inner = Arc::new(Inner {
|
||||
network,
|
||||
private_key,
|
||||
tun,
|
||||
ipv4_range,
|
||||
peers: RwLock::new(HashMap::new()),
|
||||
routes: RwLock::new(HashMap::new()),
|
||||
next_index: AtomicU32::new(1),
|
||||
unroutable: AtomicU64::new(0),
|
||||
unroutable_sample: Mutex::new(None),
|
||||
multicast: AtomicU64::new(0),
|
||||
ipv4_conflicts: AtomicU64::new(0),
|
||||
});
|
||||
|
||||
let reader = tokio::spawn(read_from_os(Arc::clone(&inner)));
|
||||
let timers = tokio::spawn(drive_timers(Arc::clone(&inner)));
|
||||
|
||||
Self {
|
||||
inner,
|
||||
tasks: vec![reader, timers],
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface name in use.
|
||||
pub fn interface(&self) -> &str {
|
||||
self.inner.tun.name()
|
||||
}
|
||||
|
||||
/// The interface MTU.
|
||||
pub fn mtu(&self) -> u32 {
|
||||
self.inner.tun.mtu()
|
||||
}
|
||||
|
||||
/// Adds or replaces a peer and starts its tunnel.
|
||||
///
|
||||
/// `overlay_v4` is decided by the caller, because only it knows whether
|
||||
/// both sides agree on an IPv4 range. `None` means this peer is reachable
|
||||
/// over IPv6 only.
|
||||
pub fn add_peer(
|
||||
&self,
|
||||
endpoint_id: EndpointId,
|
||||
public_key: WgPublicKey,
|
||||
overlay_v4: Option<Ipv4Addr>,
|
||||
link: SharedLink,
|
||||
keepalive: Option<u16>,
|
||||
) -> Result<(), PluginError> {
|
||||
if public_key == self.inner.private_key.public() {
|
||||
return Err(PluginError::Rejected(
|
||||
"refusing to add ourselves as a WireGuard peer".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let index = self.inner.next_index.fetch_add(1, Ordering::Relaxed);
|
||||
let tunn = Tunn::new(
|
||||
self.inner.private_key.to_static_secret(),
|
||||
public_key.into_x25519(),
|
||||
None,
|
||||
keepalive,
|
||||
index,
|
||||
None,
|
||||
);
|
||||
|
||||
let overlay = overlay_address(self.inner.network, &public_key);
|
||||
let overlay_v4 = self.claim_ipv4(&public_key, overlay_v4);
|
||||
let peer = Arc::new(Peer {
|
||||
endpoint_id,
|
||||
public_key,
|
||||
overlay,
|
||||
overlay_v4: Mutex::new(overlay_v4),
|
||||
tunn: Mutex::new(tunn),
|
||||
link,
|
||||
counters: Arc::new(PeerCounters::default()),
|
||||
task: Mutex::new(None),
|
||||
});
|
||||
|
||||
let task = tokio::spawn(read_from_link(Arc::clone(&self.inner), Arc::clone(&peer)));
|
||||
if let Ok(mut guard) = peer.task.lock() {
|
||||
*guard = Some(task);
|
||||
}
|
||||
|
||||
write_lock(&self.inner.peers).insert(public_key, Arc::clone(&peer));
|
||||
write_lock(&self.inner.routes).insert(IpAddr::V6(overlay), public_key);
|
||||
if let Some(v4) = overlay_v4 {
|
||||
write_lock(&self.inner.routes).insert(IpAddr::V4(v4), public_key);
|
||||
}
|
||||
|
||||
// Start the handshake now instead of waiting for the next timer tick,
|
||||
// so the tunnel is usable as soon as the link exists.
|
||||
kick_handshake(&peer);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Decides which IPv4 address a new peer gets, if any.
|
||||
///
|
||||
/// IPv4 has far too little room for a derived address to be collision
|
||||
/// free. When two members derive the same one, the member whose public
|
||||
/// key sorts lower keeps it — a rule every member computes identically,
|
||||
/// so they all agree on the outcome without talking about it. The other
|
||||
/// member simply has no IPv4 address; it is still fully reachable over
|
||||
/// IPv6, which never collides.
|
||||
fn claim_ipv4(&self, public_key: &WgPublicKey, wanted: Option<Ipv4Addr>) -> Option<Ipv4Addr> {
|
||||
let wanted = wanted?;
|
||||
|
||||
let holder = read_lock(&self.inner.routes)
|
||||
.get(&IpAddr::V4(wanted))
|
||||
.copied();
|
||||
let Some(holder) = holder else {
|
||||
return Some(wanted);
|
||||
};
|
||||
if holder == *public_key {
|
||||
return Some(wanted);
|
||||
}
|
||||
|
||||
self.inner.ipv4_conflicts.fetch_add(1, Ordering::Relaxed);
|
||||
if holder.as_bytes() <= public_key.as_bytes() {
|
||||
// The peer already holding it wins.
|
||||
return None;
|
||||
}
|
||||
// The newcomer wins; take the address away from the other peer.
|
||||
if let Some(loser) = read_lock(&self.inner.peers).get(&holder).cloned() {
|
||||
let mut slot = match loser.overlay_v4.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*slot = None;
|
||||
}
|
||||
Some(wanted)
|
||||
}
|
||||
|
||||
/// Removes a peer and stops its tunnel.
|
||||
pub fn remove_peer(&self, public_key: &WgPublicKey) {
|
||||
if let Some(peer) = write_lock(&self.inner.peers).remove(public_key) {
|
||||
let mut routes = write_lock(&self.inner.routes);
|
||||
routes.remove(&IpAddr::V6(peer.overlay));
|
||||
let v4 = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
if let Some(v4) = v4 {
|
||||
routes.remove(&IpAddr::V4(v4));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How many IPv4 derivation collisions have been resolved.
|
||||
pub fn ipv4_conflicts(&self) -> u64 {
|
||||
self.inner.ipv4_conflicts.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Removes every peer whose key is not in `keep`.
|
||||
pub fn retain_peers(&self, keep: &[WgPublicKey]) {
|
||||
let stale: Vec<WgPublicKey> = read_lock(&self.inner.peers)
|
||||
.keys()
|
||||
.filter(|key| !keep.contains(key))
|
||||
.copied()
|
||||
.collect();
|
||||
for key in stale {
|
||||
self.remove_peer(&key);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a peer's tunnel exists.
|
||||
pub fn has_peer(&self, public_key: &WgPublicKey) -> bool {
|
||||
read_lock(&self.inner.peers).contains_key(public_key)
|
||||
}
|
||||
|
||||
/// A snapshot of every peer.
|
||||
pub fn peers(&self) -> Vec<PeerSummary> {
|
||||
let mut peers: Vec<PeerSummary> = read_lock(&self.inner.peers)
|
||||
.values()
|
||||
.map(|peer| {
|
||||
let overlay_v4 = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
PeerSummary {
|
||||
endpoint_id: peer.endpoint_id,
|
||||
public_key: peer.public_key,
|
||||
overlay_address: peer.overlay,
|
||||
overlay_address_v4: overlay_v4,
|
||||
ipv4_conflict: overlay_v4.is_none() && self.inner.ipv4_range.is_some(),
|
||||
health: peer.health(),
|
||||
stats: peer.stats(),
|
||||
path: peer.link.path_description(),
|
||||
max_datagram: peer.link.max_datagram_size(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
peers.sort_by_key(|peer| peer.public_key);
|
||||
peers
|
||||
}
|
||||
|
||||
/// Unicast packets the operating system sent to an address no peer owns.
|
||||
///
|
||||
/// A non-zero value means something tried to reach a host that is not in
|
||||
/// the overlay.
|
||||
pub fn unroutable_packets(&self) -> u64 {
|
||||
self.inner.unroutable.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// One destination that nobody owned, if there was one.
|
||||
///
|
||||
/// A bare count says something is wrong but not what; the address usually
|
||||
/// says it outright.
|
||||
pub fn unroutable_sample(&self) -> Option<IpAddr> {
|
||||
match self.inner.unroutable_sample.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Multicast packets dropped.
|
||||
///
|
||||
/// Expected and harmless: Linux emits multicast listener and router
|
||||
/// solicitation traffic on any IPv6 interface, and this overlay is
|
||||
/// unicast only. Counted separately so it does not look like a fault.
|
||||
pub fn multicast_packets(&self) -> u64 {
|
||||
self.inner.multicast.load(Ordering::Relaxed)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WireguardDevice {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
|
||||
match lock.read() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn write_lock<T>(lock: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||
match lock.write() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks boringtun for a handshake initiation and sends it.
|
||||
///
|
||||
/// Encapsulating an empty packet is how the protocol state machine is told
|
||||
/// "there is something to say"; with no session yet it answers with the
|
||||
/// handshake initiation.
|
||||
fn kick_handshake(peer: &Peer) {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let len = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.encapsulate(&[], &mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(len) = len {
|
||||
send_to_peer(peer, &scratch[..len]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sends whatever boringtun produced, without holding the tunnel lock.
|
||||
fn send_to_peer(peer: &Peer, payload: &[u8]) {
|
||||
match peer.link.send(Bytes::copy_from_slice(payload)) {
|
||||
Ok(()) => {}
|
||||
Err(TransportError::TooLarge { .. }) => {
|
||||
peer.counters
|
||||
.dropped_oversize
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
Err(TransportError::Closed) => {}
|
||||
Err(err) => {
|
||||
tracing::trace!(%err, "dropping a WireGuard packet the link refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Operating system -> peer.
|
||||
async fn read_from_os(inner: Arc<Inner>) {
|
||||
loop {
|
||||
let Some(packet) = inner.tun.recv().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Route by destination: only the peer that owns that overlay address
|
||||
// may receive it. Both families go through the same table.
|
||||
let Some(destination) = IpHeader::parse(&packet).map(|header| header.destination()) else {
|
||||
inner.unroutable.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
};
|
||||
// The kernel emits multicast on every IPv6 interface. The overlay is
|
||||
// unicast only, so this is dropped, but it is not a fault.
|
||||
if destination.is_multicast() {
|
||||
inner.multicast.fetch_add(1, Ordering::Relaxed);
|
||||
continue;
|
||||
}
|
||||
let target = read_lock(&inner.routes).get(&destination).copied();
|
||||
let Some(target) = target else {
|
||||
note_unroutable(&inner, destination);
|
||||
continue;
|
||||
};
|
||||
let peer = read_lock(&inner.peers).get(&target).cloned();
|
||||
let Some(peer) = peer else {
|
||||
note_unroutable(&inner, destination);
|
||||
continue;
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let outcome = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.encapsulate(&packet, &mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
TunnResult::Done => None,
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "wireguard encapsulation failed");
|
||||
peer.counters
|
||||
.protocol_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(len) = outcome {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
peer.counters.tx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.tx_bytes
|
||||
.fetch_add(packet.len() as u64, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Counts a packet nobody owned the destination of, keeping one example.
|
||||
fn note_unroutable(inner: &Inner, destination: IpAddr) {
|
||||
inner.unroutable.fetch_add(1, Ordering::Relaxed);
|
||||
let mut sample = match inner.unroutable_sample.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
*sample = Some(destination);
|
||||
}
|
||||
|
||||
/// Peer -> operating system.
|
||||
async fn read_from_link(inner: Arc<Inner>, peer: Arc<Peer>) {
|
||||
loop {
|
||||
let Some(datagram) = peer.link.recv().await else {
|
||||
return;
|
||||
};
|
||||
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
// boringtun may need several passes: a handshake reply first, then
|
||||
// any packets that were queued while the session was coming up.
|
||||
let mut input: Option<&[u8]> = Some(&datagram);
|
||||
loop {
|
||||
let outcome = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.decapsulate(None, input.unwrap_or(&[]), &mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Outcome::ToNetwork(out.len()),
|
||||
TunnResult::WriteToTunnelV6(out, source) => {
|
||||
Outcome::ToTunnel(out.len(), IpAddr::V6(source))
|
||||
}
|
||||
TunnResult::WriteToTunnelV4(out, source) => {
|
||||
Outcome::ToTunnel(out.len(), IpAddr::V4(source))
|
||||
}
|
||||
TunnResult::Done => Outcome::Done,
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "wireguard decapsulation failed");
|
||||
Outcome::Failed
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
match outcome {
|
||||
Outcome::ToNetwork(len) => {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
// Keep draining with an empty datagram, as boringtun asks.
|
||||
input = None;
|
||||
continue;
|
||||
}
|
||||
Outcome::ToTunnel(len, source) => {
|
||||
let payload = Bytes::copy_from_slice(&scratch[..len]);
|
||||
// Enforce address ownership: a peer may only send from an
|
||||
// address derived for its own key, in either family.
|
||||
let owned = match source {
|
||||
IpAddr::V6(addr) => addr == peer.overlay,
|
||||
IpAddr::V4(addr) => {
|
||||
let held = match peer.overlay_v4.lock() {
|
||||
Ok(guard) => *guard,
|
||||
Err(poisoned) => *poisoned.into_inner(),
|
||||
};
|
||||
held == Some(addr)
|
||||
}
|
||||
};
|
||||
if !owned {
|
||||
peer.counters
|
||||
.dropped_wrong_source
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
if inner.tun.send(payload).await.is_ok() {
|
||||
peer.counters.rx_packets.fetch_add(1, Ordering::Relaxed);
|
||||
peer.counters
|
||||
.rx_bytes
|
||||
.fetch_add(len as u64, Ordering::Relaxed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
Outcome::Failed => {
|
||||
peer.counters
|
||||
.protocol_errors
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
break;
|
||||
}
|
||||
Outcome::Done => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum Outcome {
|
||||
ToNetwork(usize),
|
||||
ToTunnel(usize, IpAddr),
|
||||
Done,
|
||||
Failed,
|
||||
}
|
||||
|
||||
/// Drives WireGuard's handshake, rekey and keepalive timers.
|
||||
async fn drive_timers(inner: Arc<Inner>) {
|
||||
let mut ticker = tokio::time::interval(TIMER_INTERVAL);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let peers: Vec<Arc<Peer>> = read_lock(&inner.peers).values().cloned().collect();
|
||||
for peer in peers {
|
||||
let mut scratch = vec![0u8; SCRATCH];
|
||||
let len = {
|
||||
let mut tunn = match peer.tunn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match tunn.update_timers(&mut scratch) {
|
||||
TunnResult::WriteToNetwork(out) => Some(out.len()),
|
||||
TunnResult::Err(err) => {
|
||||
tracing::trace!(?err, "wireguard timer produced an error");
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
if let Some(len) = len {
|
||||
send_to_peer(&peer, &scratch[..len]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
//! WireGuard key material.
|
||||
//!
|
||||
//! These keys belong to the plugin and to nothing else. They are **not**
|
||||
//! derived from the iroh device key and **not** derived from the network
|
||||
//! secret, so compromising or rotating one does not affect the others.
|
||||
//!
|
||||
//! Keys are X25519, encoded the way WireGuard encodes them: standard base64
|
||||
//! with padding, 44 characters.
|
||||
|
||||
use boringtun::x25519;
|
||||
use data_encoding::BASE64;
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
/// Length of a raw WireGuard key, in bytes.
|
||||
pub const KEY_LEN: usize = 32;
|
||||
|
||||
/// Length of the base64 text form of a key.
|
||||
pub const KEY_TEXT_LEN: usize = 44;
|
||||
|
||||
/// Applies the X25519 clamping WireGuard applies to private keys.
|
||||
///
|
||||
/// `wg genkey` clamps, so clamping here keeps the printed private key and the
|
||||
/// derived public key byte-identical to what the WireGuard tools produce.
|
||||
fn clamp(bytes: &mut [u8; KEY_LEN]) {
|
||||
bytes[0] &= 248;
|
||||
bytes[31] &= 127;
|
||||
bytes[31] |= 64;
|
||||
}
|
||||
|
||||
/// A WireGuard public key.
|
||||
///
|
||||
/// Public, safe to log, and the identity a peer is known by inside the
|
||||
/// overlay.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct WgPublicKey([u8; KEY_LEN]);
|
||||
|
||||
impl WgPublicKey {
|
||||
/// Wraps raw key bytes.
|
||||
pub fn from_bytes(bytes: [u8; KEY_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// The raw key bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; KEY_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The key in the form the WireGuard implementation expects.
|
||||
pub(crate) fn into_x25519(self) -> x25519::PublicKey {
|
||||
x25519::PublicKey::from(self.0)
|
||||
}
|
||||
|
||||
/// Whether this is the all-zero key, which is never a valid peer.
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.0 == [0u8; KEY_LEN]
|
||||
}
|
||||
|
||||
/// The base64 form WireGuard uses.
|
||||
pub fn encode(&self) -> String {
|
||||
BASE64.encode(&self.0)
|
||||
}
|
||||
|
||||
/// Parses the base64 form WireGuard uses.
|
||||
pub fn decode(text: &str) -> Result<Self, PluginError> {
|
||||
if text.len() != KEY_TEXT_LEN {
|
||||
return Err(PluginError::Rejected(format!(
|
||||
"a WireGuard key is {KEY_TEXT_LEN} base64 characters, got {}",
|
||||
text.len()
|
||||
)));
|
||||
}
|
||||
let raw = BASE64
|
||||
.decode(text.as_bytes())
|
||||
.map_err(|_| PluginError::Rejected("key is not valid base64".into()))?;
|
||||
let bytes = <[u8; KEY_LEN]>::try_from(raw.as_slice())
|
||||
.map_err(|_| PluginError::Rejected("key is not 32 bytes".into()))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
/// A short prefix for logs and diagnostics.
|
||||
pub fn fmt_short(&self) -> String {
|
||||
self.encode().chars().take(8).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for WgPublicKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.encode())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WgPublicKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "WgPublicKey({})", self.fmt_short())
|
||||
}
|
||||
}
|
||||
|
||||
/// A WireGuard private key.
|
||||
///
|
||||
/// Zeroized on drop and redacted from [`Debug`]. Its base64 form is only ever
|
||||
/// produced for the configuration handed to the WireGuard backend, and that
|
||||
/// value is itself zeroized.
|
||||
#[derive(Clone)]
|
||||
pub struct WgSecretKey(Zeroizing<[u8; KEY_LEN]>);
|
||||
|
||||
impl WgSecretKey {
|
||||
/// Generates a fresh clamped private key.
|
||||
pub fn generate() -> Self {
|
||||
let mut bytes = Zeroizing::new([0u8; KEY_LEN]);
|
||||
rand::fill(bytes.as_mut());
|
||||
clamp(&mut bytes);
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Wraps stored key bytes, clamping them.
|
||||
pub fn from_bytes(bytes: &[u8; KEY_LEN]) -> Self {
|
||||
let mut owned = Zeroizing::new(*bytes);
|
||||
clamp(&mut owned);
|
||||
Self(owned)
|
||||
}
|
||||
|
||||
/// The raw key bytes, for persistence only.
|
||||
pub(crate) fn expose(&self) -> &[u8; KEY_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The matching public key.
|
||||
pub fn public(&self) -> WgPublicKey {
|
||||
WgPublicKey(x25519::PublicKey::from(&self.to_static_secret()).to_bytes())
|
||||
}
|
||||
|
||||
/// The key in the form the WireGuard implementation expects.
|
||||
pub(crate) fn to_static_secret(&self) -> x25519::StaticSecret {
|
||||
x25519::StaticSecret::from(*self.0)
|
||||
}
|
||||
|
||||
/// The base64 form, for the WireGuard configuration. Zeroized on drop.
|
||||
pub fn encode(&self) -> Zeroizing<String> {
|
||||
let mut encoded = BASE64.encode(self.0.as_ref());
|
||||
let out = Zeroizing::new(encoded.clone());
|
||||
encoded.zeroize();
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for WgSecretKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WgSecretKey")
|
||||
.field("public", &self.public().fmt_short())
|
||||
.field("secret", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn generated_keys_are_clamped_and_round_trip() {
|
||||
let secret = WgSecretKey::generate();
|
||||
let raw = *secret.expose();
|
||||
assert_eq!(raw[0] & 7, 0, "low three bits must be cleared");
|
||||
assert_eq!(raw[31] & 128, 0, "top bit must be cleared");
|
||||
assert_eq!(raw[31] & 64, 64, "second-highest bit must be set");
|
||||
|
||||
let text = secret.encode();
|
||||
assert_eq!(text.len(), KEY_TEXT_LEN);
|
||||
|
||||
let public = secret.public();
|
||||
let parsed = WgPublicKey::decode(&public.encode()).unwrap();
|
||||
assert_eq!(parsed, public);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reloading_a_stored_key_gives_the_same_public_key() {
|
||||
let secret = WgSecretKey::generate();
|
||||
let reloaded = WgSecretKey::from_bytes(secret.expose());
|
||||
assert_eq!(secret.public(), reloaded.public());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rfc_7748_test_vector_derives_the_expected_public_key() {
|
||||
// RFC 7748 section 6.1. WireGuard keys are plain X25519 keys, and
|
||||
// X25519 clamps internally, so storing the clamped form must not
|
||||
// change the derived public key.
|
||||
let private =
|
||||
hex_to_key("77076d0a7318a57d3c16c17251b26645df4c2f87ebc0992ab177fba51db92c2a");
|
||||
let expected =
|
||||
hex_to_key("8520f0098930a754748b7ddcb43ef75a0dbf3a0d26381af4eba4a98eaa9b4e6a");
|
||||
|
||||
let secret = WgSecretKey::from_bytes(&private);
|
||||
assert_eq!(secret.public(), WgPublicKey::from_bytes(expected));
|
||||
assert_eq!(
|
||||
secret.public().encode(),
|
||||
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo="
|
||||
);
|
||||
}
|
||||
|
||||
fn hex_to_key(text: &str) -> [u8; KEY_LEN] {
|
||||
let raw = hex::decode(text).unwrap();
|
||||
<[u8; KEY_LEN]>::try_from(raw.as_slice()).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_keys_are_rejected_without_panicking() {
|
||||
assert!(WgPublicKey::decode("").is_err());
|
||||
assert!(WgPublicKey::decode("not base64 at all!!!").is_err());
|
||||
assert!(WgPublicKey::decode(&"A".repeat(KEY_TEXT_LEN)).is_err());
|
||||
assert!(WgPublicKey::decode(&BASE64.encode(&[0u8; 16])).is_err());
|
||||
assert!(WgPublicKey::from_bytes([0u8; KEY_LEN]).is_zero());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_redacted_in_debug_output() {
|
||||
let secret = WgSecretKey::generate();
|
||||
let rendered = format!("{secret:?}");
|
||||
assert!(rendered.contains("<redacted>"));
|
||||
assert!(!rendered.contains(secret.encode().as_str()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
//! The WireGuard data plane plugin.
|
||||
//!
|
||||
//! WireGuard is the first IP plugin. It creates real IP connectivity between
|
||||
//! participants, while the control plane keeps doing what it does: agreeing on
|
||||
//! who is in the network and carrying each participant's opaque announcement.
|
||||
//!
|
||||
//! The two planes stay separate:
|
||||
//!
|
||||
//! * **The plugin knows nothing about reachability.** It is handed a
|
||||
//! [`PacketLink`](crate::dataplane::transport::PacketLink) per peer and
|
||||
//! bridges the kernel WireGuard device onto it. Hole punching and relaying
|
||||
//! belong to the transport.
|
||||
//! * **The announcement says who, not where.** It carries a public key, so
|
||||
//! there is no address 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.** The plugin has its own 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 bridge is a pipe; the
|
||||
//! handshake and encryption run end to end between the two kernels.
|
||||
//!
|
||||
//! # How a mesh forms
|
||||
//!
|
||||
//! Every participant derives its own overlay address from the network id and
|
||||
//! its own WireGuard public key ([`overlay`]), so no coordinator hands out
|
||||
//! addresses. Because that derivation is public, each agent computes every
|
||||
//! peer's `AllowedIPs` itself instead of believing what the peer claims — a
|
||||
//! member cannot route another member's traffic to itself.
|
||||
//!
|
||||
//! 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 and routes packets
|
||||
//! between them and a [`tun::TunDevice`].
|
||||
//!
|
||||
//! The only part that needs privileges is the packet interface. With
|
||||
//! [`tun::MemoryTunFactory`] the whole data plane — handshake, encryption,
|
||||
//! routing, address ownership — runs and is tested with no privileges at all.
|
||||
//! For real traffic there is [`provision::ManagedTunFactory`], where the
|
||||
//! agent creates and configures the interface itself over netlink and
|
||||
//! removes it again on exit.
|
||||
//!
|
||||
//! See `docs/wireguard.md` for the full picture.
|
||||
|
||||
pub mod announcement;
|
||||
pub mod config;
|
||||
pub mod device;
|
||||
pub mod keys;
|
||||
pub mod overlay;
|
||||
pub mod packet;
|
||||
pub mod plugin;
|
||||
pub mod provision;
|
||||
pub mod store;
|
||||
pub mod tun;
|
||||
|
||||
pub use crate::state::Ipv4Range;
|
||||
pub use announcement::{ValidatedAnnouncement, WgAnnouncement};
|
||||
pub use config::{Cidr, DEFAULT_INTERFACE_PREFIX, MAX_INTERFACE_NAME_LEN, interface_name};
|
||||
pub use device::{PeerHealth, PeerStats, PeerSummary, WireguardDevice};
|
||||
pub use keys::{WgPublicKey, WgSecretKey};
|
||||
pub use overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_address_v4, overlay_prefix};
|
||||
pub use packet::IpHeader;
|
||||
pub use plugin::{
|
||||
DEFAULT_MTU, MIN_MTU, NetworkOverview, PeerOverview, WIREGUARD_OVERHEAD, WIREGUARD_PROTOCOL,
|
||||
WireguardConfig, WireguardPlugin,
|
||||
};
|
||||
pub use provision::{
|
||||
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, ManagedTunFactory,
|
||||
MockHost, MockProvisioner, Privilege, Provisioned, UnsupportedProvisioner, plan_changes,
|
||||
probe_net_admin,
|
||||
};
|
||||
pub use store::WgKeyStore;
|
||||
pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, address_is_local};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use provision::NetlinkProvisioner;
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Deterministic overlay addressing.
|
||||
//!
|
||||
//! A mesh with no coordinator cannot hand out addresses, so every participant
|
||||
//! derives its own from values everybody already knows. The result is an IPv6
|
||||
//! unique local address (RFC 4193):
|
||||
//!
|
||||
//! ```text
|
||||
//! prefix (/64) = 0xfd || SHA-256( LP(domain) || LP("prefix") || LP(network_id) )[0..7]
|
||||
//! iid (64b) = SHA-256( LP(domain) || LP("interface") || LP(network_id) || LP(wg_public_key) )[0..8]
|
||||
//! address = prefix || iid
|
||||
//! ```
|
||||
//!
|
||||
//! Two properties matter:
|
||||
//!
|
||||
//! * Every member of a network derives the **same** `/64`, so the overlay is
|
||||
//! one subnet without anybody allocating it.
|
||||
//! * A member's address is bound to its WireGuard public key, so a peer's
|
||||
//! `AllowedIPs` can be **derived locally and never taken from what the peer
|
||||
//! claims**. A participant can mint many keys and therefore many addresses,
|
||||
//! but it cannot choose to collide with an existing member's address without
|
||||
//! finding a hash preimage.
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::keys::WgPublicKey;
|
||||
|
||||
/// Frozen domain separator for overlay address derivation.
|
||||
pub const OVERLAY_DOMAIN: &str = "tsunagi-wireguard-overlay-v1";
|
||||
|
||||
/// Prefix length of the overlay subnet.
|
||||
pub const OVERLAY_PREFIX_LEN: u8 = 64;
|
||||
|
||||
/// Prefix length of one member's address inside the overlay.
|
||||
pub const OVERLAY_HOST_PREFIX_LEN: u8 = 128;
|
||||
|
||||
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
fn digest(label: &str, network: NetworkId, key: Option<&WgPublicKey>) -> [u8; 32] {
|
||||
let mut input = Vec::with_capacity(128);
|
||||
push_lp(&mut input, OVERLAY_DOMAIN.as_bytes());
|
||||
push_lp(&mut input, label.as_bytes());
|
||||
push_lp(&mut input, network.as_bytes());
|
||||
if let Some(key) = key {
|
||||
push_lp(&mut input, key.as_bytes());
|
||||
}
|
||||
Sha256::digest(&input).into()
|
||||
}
|
||||
|
||||
/// The `/64` every member of `network` shares.
|
||||
///
|
||||
/// Returned as the network address of the prefix, i.e. with a zero interface
|
||||
/// identifier.
|
||||
pub fn overlay_prefix(network: NetworkId) -> Ipv6Addr {
|
||||
let hash = digest("prefix", network, None);
|
||||
let mut octets = [0u8; 16];
|
||||
// fd00::/8 marks a locally assigned unique local address.
|
||||
octets[0] = 0xfd;
|
||||
// 40 bits of global id followed by a 16 bit subnet id fill the rest of /64.
|
||||
octets[1..8].copy_from_slice(&hash[0..7]);
|
||||
Ipv6Addr::from(octets)
|
||||
}
|
||||
|
||||
/// The address a member with `key` has in `network`.
|
||||
pub fn overlay_address(network: NetworkId, key: &WgPublicKey) -> Ipv6Addr {
|
||||
let prefix = overlay_prefix(network).octets();
|
||||
let hash = digest("interface", network, Some(key));
|
||||
|
||||
let mut octets = [0u8; 16];
|
||||
octets[0..8].copy_from_slice(&prefix[0..8]);
|
||||
octets[8..16].copy_from_slice(&hash[0..8]);
|
||||
|
||||
// The all-zero interface identifier is the subnet-router anycast address
|
||||
// and must not be handed to a host.
|
||||
if octets[8..16] == [0u8; 8] {
|
||||
octets[15] = 1;
|
||||
}
|
||||
Ipv6Addr::from(octets)
|
||||
}
|
||||
|
||||
/// The IPv4 address a member with `key` has in `network`.
|
||||
///
|
||||
/// # Why this is weaker than the IPv6 derivation
|
||||
///
|
||||
/// A 64 bit interface identifier makes an IPv6 collision impossible in
|
||||
/// practice. IPv4 has nothing like that much room, so two members *can* derive
|
||||
/// the same address. In a `/10` with 50 members the chance is roughly 0.03%,
|
||||
/// which is small but real, so it is detected and resolved rather than
|
||||
/// assumed away — see [`super::device`]. IPv6 remains the address that always
|
||||
/// works.
|
||||
///
|
||||
/// Returns `None` when the range has no room for hosts.
|
||||
pub fn overlay_address_v4(
|
||||
network: NetworkId,
|
||||
key: &WgPublicKey,
|
||||
range: Ipv4Range,
|
||||
) -> Option<Ipv4Addr> {
|
||||
let Ipv4Range { base, prefix_len } = range;
|
||||
if prefix_len > 32 {
|
||||
return None;
|
||||
}
|
||||
let host_bits = 32 - u32::from(prefix_len);
|
||||
// A usable range needs a network address, a broadcast address and at
|
||||
// least one host between them.
|
||||
if host_bits < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let hash = digest("ipv4", network, Some(key));
|
||||
let raw = u32::from_be_bytes([hash[0], hash[1], hash[2], hash[3]]);
|
||||
|
||||
let usable = (1u64 << host_bits) - 2;
|
||||
let offset = (u64::from(raw) % usable) + 1;
|
||||
|
||||
let mask = if host_bits == 32 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << host_bits
|
||||
};
|
||||
let network_part = u32::from(base) & mask;
|
||||
Some(Ipv4Addr::from(network_part | offset as u32))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_prefix_is_a_unique_local_address() {
|
||||
let prefix = overlay_prefix(network("home"));
|
||||
assert_eq!(prefix.octets()[0], 0xfd);
|
||||
assert!(prefix.is_unique_local());
|
||||
assert_eq!(&prefix.octets()[8..16], &[0u8; 8], "a /64 network address");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn everyone_in_a_network_shares_one_prefix() {
|
||||
let id = network("shared");
|
||||
let a = overlay_address(id, &WgPublicKey::from_bytes([1u8; 32]));
|
||||
let b = overlay_address(id, &WgPublicKey::from_bytes([2u8; 32]));
|
||||
assert_eq!(a.octets()[0..8], b.octets()[0..8]);
|
||||
assert_ne!(a, b, "different keys get different addresses");
|
||||
assert_eq!(&overlay_prefix(id).octets()[0..8], &a.octets()[0..8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn derivation_is_deterministic_and_network_scoped() {
|
||||
let key = WgPublicKey::from_bytes([9u8; 32]);
|
||||
let first = network("one");
|
||||
let second = network("two");
|
||||
assert_eq!(overlay_address(first, &key), overlay_address(first, &key));
|
||||
assert_ne!(
|
||||
overlay_address(first, &key),
|
||||
overlay_address(second, &key),
|
||||
"the same key in a different network gets a different address"
|
||||
);
|
||||
assert_ne!(overlay_prefix(first), overlay_prefix(second));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_addresses_land_inside_the_range_and_avoid_its_edges() {
|
||||
let id = network("v4");
|
||||
let range: Ipv4Range = "100.64.0.0/10".parse().unwrap();
|
||||
for byte in 0..64u8 {
|
||||
let key = WgPublicKey::from_bytes([byte; 32]);
|
||||
let addr = overlay_address_v4(id, &key, range).unwrap();
|
||||
let raw = u32::from(addr);
|
||||
assert_eq!(
|
||||
raw & 0xffc0_0000,
|
||||
u32::from(range.base),
|
||||
"outside 100.64.0.0/10"
|
||||
);
|
||||
// Never the network address and never the broadcast address.
|
||||
assert_ne!(raw & 0x003f_ffff, 0);
|
||||
assert_ne!(raw & 0x003f_ffff, 0x003f_ffff);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_parses_and_prints_round_trip() {
|
||||
let range: Ipv4Range = "10.77.0.0/16".parse().unwrap();
|
||||
assert_eq!(range.to_string(), "10.77.0.0/16");
|
||||
assert!(range.contains("10.77.3.4".parse().unwrap()));
|
||||
assert!(!range.contains("10.78.3.4".parse().unwrap()));
|
||||
|
||||
assert!("10.77.0.0".parse::<Ipv4Range>().is_err());
|
||||
assert!("nonsense/16".parse::<Ipv4Range>().is_err());
|
||||
assert!("10.77.0.0/zz".parse::<Ipv4Range>().is_err());
|
||||
assert!("10.77.0.0/31".parse::<Ipv4Range>().is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_derivation_is_deterministic_and_scoped_like_ipv6() {
|
||||
let key = WgPublicKey::from_bytes([9u8; 32]);
|
||||
let first = network("one");
|
||||
let second = network("two");
|
||||
let range: Ipv4Range = "100.64.0.0/10".parse().unwrap();
|
||||
|
||||
assert_eq!(
|
||||
overlay_address_v4(first, &key, range),
|
||||
overlay_address_v4(first, &key, range)
|
||||
);
|
||||
assert_ne!(
|
||||
overlay_address_v4(first, &key, range),
|
||||
overlay_address_v4(second, &key, range)
|
||||
);
|
||||
assert_ne!(
|
||||
overlay_address_v4(first, &key, range),
|
||||
overlay_address_v4(first, &WgPublicKey::from_bytes([10u8; 32]), range)
|
||||
);
|
||||
// A different range moves everybody.
|
||||
assert_ne!(
|
||||
overlay_address_v4(first, &key, range),
|
||||
overlay_address_v4(
|
||||
first,
|
||||
&key,
|
||||
Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 8).unwrap()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_with_no_room_yields_nothing() {
|
||||
let id = network("tiny");
|
||||
let key = WgPublicKey::from_bytes([1u8; 32]);
|
||||
// /31 and /32 have no usable host addresses, so they are refused.
|
||||
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 31).is_err());
|
||||
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 1), 32).is_err());
|
||||
assert!(Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 33).is_err());
|
||||
// A /30 has two usable addresses.
|
||||
let small = Ipv4Range::new(Ipv4Addr::new(10, 0, 0, 0), 30).unwrap();
|
||||
assert!(overlay_address_v4(id, &key, small).is_some());
|
||||
// A /0 must not overflow.
|
||||
let everything = Ipv4Range::new(Ipv4Addr::UNSPECIFIED, 0).unwrap();
|
||||
assert!(overlay_address_v4(id, &key, everything).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn addresses_are_never_the_subnet_router_anycast_address() {
|
||||
let id = network("anycast");
|
||||
for byte in 0..64u8 {
|
||||
let address = overlay_address(id, &WgPublicKey::from_bytes([byte; 32]));
|
||||
assert_ne!(&address.octets()[8..16], &[0u8; 8]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! The little bit of IP parsing the data plane needs.
|
||||
//!
|
||||
//! Two questions only: which peer should carry this packet, and did the packet
|
||||
//! that came back really come from that peer? Everything is bounds checked and
|
||||
//! nothing here can panic on a hostile packet.
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// The addresses of an IP packet, as far as routing cares.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum IpHeader {
|
||||
/// An IPv4 packet.
|
||||
V4 {
|
||||
/// Source address.
|
||||
source: Ipv4Addr,
|
||||
/// Destination address.
|
||||
destination: Ipv4Addr,
|
||||
},
|
||||
/// An IPv6 packet.
|
||||
V6 {
|
||||
/// Source address.
|
||||
source: Ipv6Addr,
|
||||
/// Destination address.
|
||||
destination: Ipv6Addr,
|
||||
},
|
||||
}
|
||||
|
||||
impl IpHeader {
|
||||
/// Reads the addresses out of a packet, or `None` if it is not one.
|
||||
pub fn parse(packet: &[u8]) -> Option<Self> {
|
||||
let version = packet.first()? >> 4;
|
||||
match version {
|
||||
4 => {
|
||||
let source: [u8; 4] = packet.get(12..16)?.try_into().ok()?;
|
||||
let destination: [u8; 4] = packet.get(16..20)?.try_into().ok()?;
|
||||
Some(IpHeader::V4 {
|
||||
source: Ipv4Addr::from(source),
|
||||
destination: Ipv4Addr::from(destination),
|
||||
})
|
||||
}
|
||||
6 => {
|
||||
let source: [u8; 16] = packet.get(8..24)?.try_into().ok()?;
|
||||
let destination: [u8; 16] = packet.get(24..40)?.try_into().ok()?;
|
||||
Some(IpHeader::V6 {
|
||||
source: Ipv6Addr::from(source),
|
||||
destination: Ipv6Addr::from(destination),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The destination, when the packet is IPv6.
|
||||
pub fn v6_destination(&self) -> Option<Ipv6Addr> {
|
||||
match self {
|
||||
IpHeader::V6 { destination, .. } => Some(*destination),
|
||||
IpHeader::V4 { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The source, when the packet is IPv6.
|
||||
pub fn v6_source(&self) -> Option<Ipv6Addr> {
|
||||
match self {
|
||||
IpHeader::V6 { source, .. } => Some(*source),
|
||||
IpHeader::V4 { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The destination, whichever family it is.
|
||||
pub fn destination(&self) -> std::net::IpAddr {
|
||||
match self {
|
||||
IpHeader::V4 { destination, .. } => std::net::IpAddr::V4(*destination),
|
||||
IpHeader::V6 { destination, .. } => std::net::IpAddr::V6(*destination),
|
||||
}
|
||||
}
|
||||
|
||||
/// The source, whichever family it is.
|
||||
pub fn source(&self) -> std::net::IpAddr {
|
||||
match self {
|
||||
IpHeader::V4 { source, .. } => std::net::IpAddr::V4(*source),
|
||||
IpHeader::V6 { source, .. } => std::net::IpAddr::V6(*source),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn ipv6_packet(source: Ipv6Addr, destination: Ipv6Addr) -> Vec<u8> {
|
||||
let mut packet = vec![0u8; 48];
|
||||
packet[0] = 6 << 4;
|
||||
packet[8..24].copy_from_slice(&source.octets());
|
||||
packet[24..40].copy_from_slice(&destination.octets());
|
||||
packet
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv6_addresses_are_read_correctly() {
|
||||
let source: Ipv6Addr = "fd00::1".parse().unwrap();
|
||||
let destination: Ipv6Addr = "fd00::2".parse().unwrap();
|
||||
let header = IpHeader::parse(&ipv6_packet(source, destination)).unwrap();
|
||||
assert_eq!(header.v6_source(), Some(source));
|
||||
assert_eq!(header.v6_destination(), Some(destination));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ipv4_addresses_are_read_correctly() {
|
||||
let mut packet = vec![0u8; 20];
|
||||
packet[0] = 4 << 4;
|
||||
packet[12..16].copy_from_slice(&[10, 0, 0, 1]);
|
||||
packet[16..20].copy_from_slice(&[10, 0, 0, 2]);
|
||||
let header = IpHeader::parse(&packet).unwrap();
|
||||
assert_eq!(
|
||||
header,
|
||||
IpHeader::V4 {
|
||||
source: Ipv4Addr::new(10, 0, 0, 1),
|
||||
destination: Ipv4Addr::new(10, 0, 0, 2),
|
||||
}
|
||||
);
|
||||
// The overlay is IPv6, so the v6 accessors correctly report nothing.
|
||||
assert_eq!(header.v6_destination(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncated_and_nonsense_packets_are_rejected_without_panicking() {
|
||||
assert!(IpHeader::parse(&[]).is_none());
|
||||
assert!(IpHeader::parse(&[0x60]).is_none());
|
||||
assert!(IpHeader::parse(&[0x40; 19]).is_none(), "short IPv4");
|
||||
assert!(IpHeader::parse(&[0x60; 39]).is_none(), "short IPv6");
|
||||
assert!(IpHeader::parse(&[0x00; 64]).is_none(), "version 0");
|
||||
assert!(IpHeader::parse(&[0xf0; 64]).is_none(), "version 15");
|
||||
// Every possible first byte is safe to feed in.
|
||||
for byte in 0..=u8::MAX {
|
||||
let _ = IpHeader::parse(&[byte; 64]);
|
||||
let _ = IpHeader::parse(&[byte]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
//! The WireGuard IP plugin.
|
||||
//!
|
||||
//! Each agent builds its own view of the overlay from the set of participants
|
||||
//! the control plane agreed on. For a full mesh of `N` members that is `N - 1`
|
||||
//! tunnels locally. Nobody is handed a configuration by anybody else, and no
|
||||
//! participant is authoritative.
|
||||
//!
|
||||
//! # 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
|
||||
//! a WireGuard tunnel over it. Reachability, hole punching and relaying are
|
||||
//! the transport's problem.
|
||||
//! * It owns one WireGuard key per network, in its own store, unrelated to the
|
||||
//! iroh device key and to the network secret.
|
||||
//! * It owns one packet interface per network, named deterministically.
|
||||
//! * It never touches an interface it did not create, and never changes
|
||||
//! routing, DNS or firewall settings beyond its own device.
|
||||
//!
|
||||
//! WireGuard runs in userspace via [`boringtun`], so there is no kernel module
|
||||
//! and no `wg` tool to depend on. The only privileged step is creating the
|
||||
//! packet interface, and even that is behind [`TunFactory`] so the whole data
|
||||
//! plane can run unprivileged in tests.
|
||||
//!
|
||||
//! A failure here is reported and retried. It never stops the control plane.
|
||||
|
||||
use std::collections::{BTreeSet, HashMap};
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::EndpointId;
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::transport::SharedLink;
|
||||
use crate::dataplane::{IpPlugin, PluginCapability, PluginContext, PluginError};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
|
||||
use super::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
|
||||
use super::device::{PeerSummary, WireguardDevice};
|
||||
use super::keys::{WgPublicKey, WgSecretKey};
|
||||
use super::overlay::{OVERLAY_PREFIX_LEN, overlay_address, overlay_prefix};
|
||||
use super::store::WgKeyStore;
|
||||
use super::tun::{TunFactory, TunRequest};
|
||||
use crate::state::Ipv4Range;
|
||||
|
||||
/// The protocol identifier this plugin announces.
|
||||
pub const WIREGUARD_PROTOCOL: &str = "wireguard";
|
||||
|
||||
/// Smallest interface MTU IPv6 permits, from RFC 8200.
|
||||
///
|
||||
/// This is not advice, it is a hard limit. Linux tears IPv6 down entirely on
|
||||
/// an interface whose MTU is below it — the per-device `/proc/sys/net/ipv6`
|
||||
/// entries disappear and `ip -6 address add` fails with `Invalid argument` —
|
||||
/// so the overlay address could never be assigned. Anything smaller is
|
||||
/// rejected up front instead of failing obscurely later.
|
||||
pub const MIN_MTU: u32 = 1280;
|
||||
|
||||
/// Default interface MTU.
|
||||
///
|
||||
/// Equal to [`MIN_MTU`], because the overlay is IPv6 and there is no room
|
||||
/// below it.
|
||||
pub const DEFAULT_MTU: u32 = MIN_MTU;
|
||||
|
||||
/// Bytes WireGuard adds to a packet: type and reserved, receiver index,
|
||||
/// counter and the Poly1305 tag.
|
||||
///
|
||||
/// A link therefore has to carry `mtu + WIREGUARD_OVERHEAD` bytes in one
|
||||
/// datagram for a full-size packet to get through.
|
||||
pub const WIREGUARD_OVERHEAD: u32 = 32;
|
||||
|
||||
/// Configuration of the WireGuard plugin.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WireguardConfig {
|
||||
/// Directory for the plugin's own key store. Separate from agent state.
|
||||
pub state_dir: PathBuf,
|
||||
/// Prefix of the interface names this plugin creates.
|
||||
///
|
||||
/// Two agents on one host in the same network need different prefixes,
|
||||
/// because the rest of the name is derived from the network id.
|
||||
pub interface_prefix: String,
|
||||
/// WireGuard keepalive, which keeps tunnels and their links warm.
|
||||
pub keepalive: Option<u16>,
|
||||
/// Interface MTU. See [`DEFAULT_MTU`].
|
||||
pub mtu: u32,
|
||||
/// How long to coalesce changes before reconciling.
|
||||
pub reconcile_debounce: Duration,
|
||||
/// How often to reconcile anyway, which is also when a packet interface
|
||||
/// that could not be created before is retried.
|
||||
pub reconcile_interval: Duration,
|
||||
}
|
||||
|
||||
impl WireguardConfig {
|
||||
/// Creates a configuration rooted at `state_dir` with sensible defaults.
|
||||
pub fn new(state_dir: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
state_dir: state_dir.into(),
|
||||
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
|
||||
keepalive: Some(25),
|
||||
mtu: DEFAULT_MTU,
|
||||
reconcile_debounce: Duration::from_millis(200),
|
||||
reconcile_interval: Duration::from_secs(15),
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the interface name prefix.
|
||||
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
|
||||
self.interface_prefix = prefix.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the interface MTU.
|
||||
///
|
||||
/// Validated when the plugin is opened; see [`MIN_MTU`].
|
||||
pub fn with_mtu(mut self, mtu: u32) -> Self {
|
||||
self.mtu = mtu;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the reconciliation timings.
|
||||
pub fn with_reconcile(mut self, debounce: Duration, interval: Duration) -> Self {
|
||||
self.reconcile_debounce = debounce;
|
||||
self.reconcile_interval = interval;
|
||||
self
|
||||
}
|
||||
|
||||
/// Path of the plugin's key store.
|
||||
pub fn key_store_path(&self) -> PathBuf {
|
||||
self.state_dir.join("wireguard.sqlite")
|
||||
}
|
||||
}
|
||||
|
||||
/// What this agent has set up for one network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NetworkOverview {
|
||||
/// The network.
|
||||
pub network: NetworkId,
|
||||
/// Packet interface this plugin created for it.
|
||||
pub interface: String,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
/// This agent's WireGuard public key in this network.
|
||||
pub public_key: WgPublicKey,
|
||||
/// This agent's overlay address.
|
||||
pub overlay_address: IpAddr,
|
||||
/// The overlay subnet every member shares.
|
||||
pub overlay_prefix: IpAddr,
|
||||
/// Prefix length of the overlay subnet.
|
||||
pub overlay_prefix_len: u8,
|
||||
/// This agent's IPv4 overlay address, when the overlay is dual stack.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// The IPv4 overlay range in use.
|
||||
pub ipv4_range: Option<Ipv4Range>,
|
||||
/// Peers this agent knows about.
|
||||
pub peers: Vec<PeerOverview>,
|
||||
/// Unicast packets the operating system sent to an address no peer owns.
|
||||
pub unroutable_packets: u64,
|
||||
/// Multicast packets dropped. Expected, not a fault.
|
||||
pub multicast_packets: u64,
|
||||
/// One destination nobody owned, if there was one.
|
||||
pub unroutable_sample: Option<IpAddr>,
|
||||
}
|
||||
|
||||
impl NetworkOverview {
|
||||
/// Peers whose tunnel has completed a handshake.
|
||||
pub fn established_peers(&self) -> usize {
|
||||
self.peers.iter().filter(|peer| peer.is_up()).count()
|
||||
}
|
||||
}
|
||||
|
||||
/// One peer of the overlay.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PeerOverview {
|
||||
/// The peer's control plane identity.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: WgPublicKey,
|
||||
/// The overlay address derived for it locally.
|
||||
pub overlay_address: IpAddr,
|
||||
/// Its IPv4 overlay address, once a tunnel exists and it won the address.
|
||||
pub overlay_address_v4: Option<Ipv4Addr>,
|
||||
/// Whether a data plane link to it exists.
|
||||
pub has_link: bool,
|
||||
/// The running tunnel, once there is a link.
|
||||
pub tunnel: Option<PeerSummary>,
|
||||
}
|
||||
|
||||
impl PeerOverview {
|
||||
/// Whether the tunnel to this peer has handshaken and can carry traffic.
|
||||
pub fn is_up(&self) -> bool {
|
||||
self.tunnel
|
||||
.as_ref()
|
||||
.is_some_and(|tunnel| tunnel.health.is_up())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct NetworkState {
|
||||
key: WgSecretKey,
|
||||
interface: String,
|
||||
device: Option<Arc<WireguardDevice>>,
|
||||
announcements: HashMap<EndpointId, ValidatedAnnouncement>,
|
||||
links: HashMap<EndpointId, SharedLink>,
|
||||
/// What the network agreed, pushed in by the agent. Authoritative.
|
||||
allocations: HashMap<EndpointId, Ipv4Addr>,
|
||||
/// The range those allocations came from.
|
||||
ipv4_range: Option<Ipv4Range>,
|
||||
/// The address last reported as missing, so it is said once, not forever.
|
||||
reported_missing_v4: Option<Ipv4Addr>,
|
||||
/// What was last applied to the host interface.
|
||||
///
|
||||
/// The overlay IPv4 address is allocated at run time and can change while
|
||||
/// the agent runs, so the interface has to be brought back in line
|
||||
/// without being recreated — recreating it would drop every tunnel.
|
||||
applied: Option<TunRequest>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Shared {
|
||||
networks: HashMap<NetworkId, NetworkState>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
enum Command {
|
||||
Prepare(NetworkId),
|
||||
Sync(NetworkId),
|
||||
Link {
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
link: SharedLink,
|
||||
},
|
||||
Teardown(NetworkId),
|
||||
Stop(oneshot::Sender<()>),
|
||||
}
|
||||
|
||||
struct Worker {
|
||||
config: WireguardConfig,
|
||||
/// This agent's endpoint id, learned when the plugin is attached.
|
||||
local_id: OnceLock<EndpointId>,
|
||||
tun_factory: Arc<dyn TunFactory>,
|
||||
store: WgKeyStore,
|
||||
shared: Mutex<Shared>,
|
||||
context: OnceLock<PluginContext>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Worker {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Worker")
|
||||
.field("tun", &self.tun_factory.name())
|
||||
.field("store", &self.store.path())
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// The WireGuard data plane plugin.
|
||||
#[derive(Debug)]
|
||||
pub struct WireguardPlugin {
|
||||
worker: Arc<Worker>,
|
||||
commands: mpsc::Sender<Command>,
|
||||
task: Mutex<Option<JoinHandle<()>>>,
|
||||
}
|
||||
|
||||
impl WireguardPlugin {
|
||||
/// Opens the plugin's key store and starts its reconciliation task.
|
||||
///
|
||||
/// Must be called from inside a tokio runtime; the plugin starts no
|
||||
/// runtime of its own.
|
||||
pub async fn open(
|
||||
config: WireguardConfig,
|
||||
tun_factory: Arc<dyn TunFactory>,
|
||||
) -> Result<Arc<Self>, PluginError> {
|
||||
// Validate the prefix once, here, rather than failing per network.
|
||||
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
|
||||
|
||||
if config.mtu < MIN_MTU {
|
||||
return Err(PluginError::Other(format!(
|
||||
"an MTU of {} is below the {MIN_MTU} bytes IPv6 requires (RFC 8200). \
|
||||
Linux disables IPv6 on an interface below that, so the overlay address \
|
||||
could never be assigned.",
|
||||
config.mtu
|
||||
)));
|
||||
}
|
||||
|
||||
let path = config.key_store_path();
|
||||
let store = tokio::task::spawn_blocking(move || WgKeyStore::open(path))
|
||||
.await
|
||||
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
||||
|
||||
let worker = Arc::new(Worker {
|
||||
config,
|
||||
local_id: OnceLock::new(),
|
||||
tun_factory,
|
||||
store,
|
||||
shared: Mutex::new(Shared::default()),
|
||||
context: OnceLock::new(),
|
||||
});
|
||||
|
||||
let (commands, receiver) = mpsc::channel(64);
|
||||
let task = tokio::spawn(run(Arc::clone(&worker), receiver));
|
||||
|
||||
Ok(Arc::new(Self {
|
||||
worker,
|
||||
commands,
|
||||
task: Mutex::new(Some(task)),
|
||||
}))
|
||||
}
|
||||
|
||||
/// What this agent has set up for a network, if anything yet.
|
||||
pub fn overview(&self, network: NetworkId) -> Option<NetworkOverview> {
|
||||
let shared = self.worker.lock_shared();
|
||||
let state = shared.networks.get(&network)?;
|
||||
|
||||
let tunnels: HashMap<WgPublicKey, PeerSummary> = state
|
||||
.device
|
||||
.as_ref()
|
||||
.map(|device| {
|
||||
device
|
||||
.peers()
|
||||
.into_iter()
|
||||
.map(|summary| (summary.public_key, summary))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut peers: Vec<PeerOverview> = state
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|(endpoint_id, announcement)| PeerOverview {
|
||||
endpoint_id: *endpoint_id,
|
||||
public_key: announcement.public_key,
|
||||
overlay_address: IpAddr::V6(announcement.overlay_address),
|
||||
overlay_address_v4: state.allocations.get(endpoint_id).copied(),
|
||||
has_link: state.links.contains_key(endpoint_id),
|
||||
tunnel: tunnels.get(&announcement.public_key).cloned(),
|
||||
})
|
||||
.collect();
|
||||
peers.sort_by_key(|peer| peer.public_key);
|
||||
|
||||
Some(NetworkOverview {
|
||||
network,
|
||||
interface: state.interface.clone(),
|
||||
mtu: self.worker.config.mtu,
|
||||
public_key: state.key.public(),
|
||||
overlay_address: IpAddr::V6(overlay_address(network, &state.key.public())),
|
||||
overlay_prefix: IpAddr::V6(overlay_prefix(network)),
|
||||
overlay_prefix_len: OVERLAY_PREFIX_LEN,
|
||||
overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(),
|
||||
ipv4_range: state.ipv4_range,
|
||||
peers,
|
||||
unroutable_packets: state
|
||||
.device
|
||||
.as_ref()
|
||||
.map(|device| device.unroutable_packets())
|
||||
.unwrap_or(0),
|
||||
multicast_packets: state
|
||||
.device
|
||||
.as_ref()
|
||||
.map(|device| device.multicast_packets())
|
||||
.unwrap_or(0),
|
||||
unroutable_sample: state
|
||||
.device
|
||||
.as_ref()
|
||||
.and_then(|device| device.unroutable_sample()),
|
||||
})
|
||||
}
|
||||
|
||||
/// Asks the reconciliation task to run now.
|
||||
pub async fn reconcile_now(&self, network: NetworkId) {
|
||||
let _ = self.commands.send(Command::Sync(network)).await;
|
||||
}
|
||||
|
||||
fn nudge(&self, command: Command) {
|
||||
if let Err(err) = self.commands.try_send(command) {
|
||||
// A full queue means work is already scheduled; the periodic
|
||||
// reconcile picks up anything that was missed.
|
||||
tracing::debug!(%err, "wireguard command queue is busy");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Worker {
|
||||
/// This agent's endpoint id, or a placeholder before it is attached.
|
||||
fn local_id(&self) -> EndpointId {
|
||||
self.local_id.get().copied().unwrap_or_else(|| {
|
||||
EndpointId::from_bytes(&[1u8; 32]).unwrap_or_else(|_| unreachable!("a fixed valid key"))
|
||||
})
|
||||
}
|
||||
|
||||
fn lock_shared(&self) -> std::sync::MutexGuard<'_, Shared> {
|
||||
match self.shared.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn report(&self, network: NetworkId, reason: impl std::fmt::Display) {
|
||||
tracing::warn!(network = %network.fmt_short(), %reason, "wireguard plugin error");
|
||||
if let Some(context) = self.context.get() {
|
||||
context.report_error(network, WIREGUARD_PROTOCOL, reason.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn request_reannounce(&self, network: NetworkId) {
|
||||
if let Some(context) = self.context.get() {
|
||||
context.request_reannounce(network);
|
||||
}
|
||||
}
|
||||
|
||||
/// Makes sure a network has a key, a name and a running packet interface.
|
||||
///
|
||||
/// Returns `true` when the key became available now, so peers should be
|
||||
/// told. Creating the interface may fail without privileges; the key and
|
||||
/// the announcement still work, and the interface is retried.
|
||||
async fn prepare(self: &Arc<Self>, network: NetworkId) -> Result<bool, PluginError> {
|
||||
let existing = {
|
||||
let shared = self.lock_shared();
|
||||
shared
|
||||
.networks
|
||||
.get(&network)
|
||||
.map(|state| state.device.is_some())
|
||||
};
|
||||
|
||||
if let Some(has_device) = existing {
|
||||
if has_device {
|
||||
return Ok(false);
|
||||
}
|
||||
// The key is there but the interface is not. Try again.
|
||||
self.ensure_device(network).await?;
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let name = interface_name(&self.config.interface_prefix, network)?;
|
||||
let worker = Arc::clone(self);
|
||||
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
|
||||
.await
|
||||
.map_err(|err| PluginError::Other(format!("key store task failed: {err}")))??;
|
||||
|
||||
{
|
||||
let mut shared = self.lock_shared();
|
||||
shared.networks.entry(network).or_insert(NetworkState {
|
||||
key,
|
||||
interface: name,
|
||||
device: None,
|
||||
announcements: HashMap::new(),
|
||||
links: HashMap::new(),
|
||||
allocations: HashMap::new(),
|
||||
ipv4_range: None,
|
||||
reported_missing_v4: None,
|
||||
applied: None,
|
||||
});
|
||||
}
|
||||
|
||||
// The announcement only needs the key, so peers can be told even if
|
||||
// the interface is not up yet.
|
||||
let device = self.ensure_device(network).await;
|
||||
if let Err(err) = device {
|
||||
self.report(network, err);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// What the host interface for a network should look like.
|
||||
fn desired_request(&self, state: &NetworkState, network: NetworkId) -> TunRequest {
|
||||
let own_range = state.ipv4_range;
|
||||
TunRequest {
|
||||
name: state.interface.clone(),
|
||||
address: overlay_address(network, &state.key.public()),
|
||||
prefix_len: OVERLAY_PREFIX_LEN,
|
||||
address_v4: state.allocations.get(&self.local_id()).copied(),
|
||||
prefix_len_v4: own_range.map_or(0, |range| range.prefix_len),
|
||||
mtu: self.config.mtu,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the packet interface and starts the WireGuard device.
|
||||
async fn ensure_device(&self, network: NetworkId) -> Result<(), PluginError> {
|
||||
let (request, key, own_range) = {
|
||||
let shared = self.lock_shared();
|
||||
match shared.networks.get(&network) {
|
||||
Some(state) if state.device.is_none() => (
|
||||
self.desired_request(state, network),
|
||||
state.key.clone(),
|
||||
state.ipv4_range,
|
||||
),
|
||||
_ => return Ok(()),
|
||||
}
|
||||
};
|
||||
|
||||
let applied = request.clone();
|
||||
let tun = self.tun_factory.create(request).await?;
|
||||
let device = Arc::new(WireguardDevice::start(network, key, tun, own_range));
|
||||
|
||||
let mut shared = self.lock_shared();
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.interface = device.interface().to_string();
|
||||
state.device = Some(device);
|
||||
state.applied = Some(applied);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Brings a live interface back in line after the overlay changed its
|
||||
/// mind about this agent's address.
|
||||
async fn ensure_addresses(&self, network: NetworkId) {
|
||||
let wanted = {
|
||||
let shared = self.lock_shared();
|
||||
match shared.networks.get(&network) {
|
||||
Some(state) if state.device.is_some() => {
|
||||
let wanted = self.desired_request(state, network);
|
||||
if state.applied.as_ref() == Some(&wanted) {
|
||||
return;
|
||||
}
|
||||
wanted
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
};
|
||||
|
||||
match self.tun_factory.reconfigure(wanted.clone()).await {
|
||||
Ok(()) => {
|
||||
let mut shared = self.lock_shared();
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.applied = Some(wanted);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
// Not fatal: the tunnels keep running on the addresses that
|
||||
// are there, and the next reconciliation tries again.
|
||||
tracing::warn!(%err, "cannot update the overlay interface addresses");
|
||||
self.report(network, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Brings the running tunnels in line with what is known.
|
||||
///
|
||||
/// A peer gets a tunnel once both halves have arrived: its announcement,
|
||||
/// which says who it is, and a link, which says packets can reach it.
|
||||
fn sync(&self, network: NetworkId) {
|
||||
let mut shared = self.lock_shared();
|
||||
let Some(state) = shared.networks.get_mut(&network) else {
|
||||
return;
|
||||
};
|
||||
let Some(device) = state.device.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let allocations = state.allocations.clone();
|
||||
let own_v4 = state.allocations.get(&self.local_id()).copied();
|
||||
let interface = state.device.as_ref().map(|_| state.interface.clone());
|
||||
let range = state.ipv4_range;
|
||||
let state_reported = state.reported_missing_v4;
|
||||
let mut wanted: Vec<WgPublicKey> = Vec::new();
|
||||
let mut too_small: Vec<(usize, usize)> = Vec::new();
|
||||
for (endpoint_id, announcement) in &state.announcements {
|
||||
let Some(link) = state.links.get(endpoint_id) else {
|
||||
continue;
|
||||
};
|
||||
if link.is_closed() {
|
||||
continue;
|
||||
}
|
||||
wanted.push(announcement.public_key);
|
||||
if device.has_peer(&announcement.public_key) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A link that cannot carry a full-size packet will silently drop
|
||||
// the large ones, which looks like a broken network rather than a
|
||||
// configuration problem. Say so when the tunnel is set up.
|
||||
let needed = self.config.mtu.saturating_add(WIREGUARD_OVERHEAD) as usize;
|
||||
let available = link.max_datagram_size();
|
||||
if available < needed {
|
||||
too_small.push((available, needed));
|
||||
}
|
||||
|
||||
// The address comes from the agreed signed state, not from
|
||||
// anything this peer said and not from a derivation: that is what
|
||||
// makes it survive the peer being away.
|
||||
let peer_v4 = allocations.get(endpoint_id).copied();
|
||||
|
||||
if let Err(err) = device.add_peer(
|
||||
*endpoint_id,
|
||||
announcement.public_key,
|
||||
peer_v4,
|
||||
Arc::clone(link),
|
||||
self.config.keepalive,
|
||||
) {
|
||||
tracing::debug!(%err, "cannot start a WireGuard tunnel");
|
||||
}
|
||||
}
|
||||
device.retain_peers(&wanted);
|
||||
|
||||
// The agent assigns this address itself, so finding it absent means
|
||||
// the assignment did not take — something outside removed it, or the
|
||||
// provisioner reported a success it did not achieve. Left unsaid it
|
||||
// looks like a broken network: the kernel would send packets with the
|
||||
// wrong source address and every peer would drop them. So it is
|
||||
// checked rather than assumed, because the assumption is exactly the
|
||||
// kind that has been wrong here before.
|
||||
let missing_v4 = match (own_v4, interface.as_deref(), range) {
|
||||
(Some(address), Some(interface), Some(range))
|
||||
if !super::tun::address_is_local(IpAddr::V4(address)) =>
|
||||
{
|
||||
let already = state_reported == Some(address);
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.reported_missing_v4 = Some(address);
|
||||
}
|
||||
(!already).then_some((address, interface.to_string(), range))
|
||||
}
|
||||
_ => {
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.reported_missing_v4 = None;
|
||||
}
|
||||
None
|
||||
}
|
||||
};
|
||||
drop(shared);
|
||||
|
||||
if let Some((address, interface, range)) = missing_v4 {
|
||||
self.report(
|
||||
network,
|
||||
format!(
|
||||
"this agent was allocated {address}/{} but the address is not on any \
|
||||
interface, so IPv4 cannot work: packets would leave with the wrong \
|
||||
source and every peer would drop them. It should have been assigned \
|
||||
to `{interface}` automatically; check whether something else removed \
|
||||
it.",
|
||||
range.prefix_len
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
for (available, needed) in too_small {
|
||||
self.report(
|
||||
network,
|
||||
format!(
|
||||
"this path carries only {available} byte datagrams but a {} byte MTU needs \
|
||||
{needed}; packets larger than {} bytes will be dropped. Lower the MTU only \
|
||||
if you can stay at or above {MIN_MTU}, which IPv6 requires.",
|
||||
self.config.mtu,
|
||||
available.saturating_sub(WIREGUARD_OVERHEAD as usize)
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes a network's interface and tunnels, keeping its key.
|
||||
async fn teardown(&self, network: NetworkId) {
|
||||
// Dropping the state drops the device, which stops its tasks and
|
||||
// closes the packet interface. Closing it is already enough for the
|
||||
// kernel to remove an interface this agent created; the explicit
|
||||
// destroy makes that immediate and definite rather than dependent on
|
||||
// the last reader letting go.
|
||||
let removed = self.lock_shared().networks.remove(&network);
|
||||
if let Some(state) = removed {
|
||||
drop(state.device);
|
||||
self.tun_factory.destroy(&state.interface).await;
|
||||
}
|
||||
}
|
||||
|
||||
fn known_networks(&self) -> Vec<NetworkId> {
|
||||
self.lock_shared().networks.keys().copied().collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// The reconciliation task.
|
||||
async fn run(worker: Arc<Worker>, mut commands: mpsc::Receiver<Command>) {
|
||||
let mut pending: BTreeSet<NetworkId> = BTreeSet::new();
|
||||
let mut deadline: Option<tokio::time::Instant> = None;
|
||||
let mut ticker = tokio::time::interval(worker.config.reconcile_interval);
|
||||
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
|
||||
ticker.tick().await;
|
||||
|
||||
loop {
|
||||
let wait_until = deadline;
|
||||
tokio::select! {
|
||||
biased;
|
||||
command = commands.recv() => {
|
||||
let Some(command) = command else { break };
|
||||
match command {
|
||||
Command::Prepare(network) => {
|
||||
match worker.prepare(network).await {
|
||||
Ok(true) => worker.request_reannounce(network),
|
||||
Ok(false) => {}
|
||||
Err(err) => worker.report(network, err),
|
||||
}
|
||||
pending.insert(network);
|
||||
}
|
||||
Command::Sync(network) => {
|
||||
pending.insert(network);
|
||||
}
|
||||
Command::Link { network, peer, link } => {
|
||||
{
|
||||
let mut shared = worker.lock_shared();
|
||||
if let Some(state) = shared.networks.get_mut(&network) {
|
||||
state.links.insert(peer, link);
|
||||
}
|
||||
}
|
||||
pending.insert(network);
|
||||
}
|
||||
Command::Teardown(network) => {
|
||||
pending.remove(&network);
|
||||
worker.teardown(network).await;
|
||||
continue;
|
||||
}
|
||||
Command::Stop(reply) => {
|
||||
for network in worker.known_networks() {
|
||||
worker.teardown(network).await;
|
||||
}
|
||||
let _ = reply.send(());
|
||||
return;
|
||||
}
|
||||
}
|
||||
deadline = Some(tokio::time::Instant::now() + worker.config.reconcile_debounce);
|
||||
}
|
||||
_ = async {
|
||||
match wait_until {
|
||||
Some(at) => tokio::time::sleep_until(at).await,
|
||||
None => std::future::pending::<()>().await,
|
||||
}
|
||||
}, if wait_until.is_some() => {
|
||||
deadline = None;
|
||||
for network in std::mem::take(&mut pending) {
|
||||
worker.ensure_addresses(network).await;
|
||||
worker.sync(network);
|
||||
}
|
||||
}
|
||||
_ = ticker.tick() => {
|
||||
for network in worker.known_networks() {
|
||||
// Also the retry for an interface that could not be
|
||||
// created earlier.
|
||||
if let Err(err) = worker.ensure_device(network).await {
|
||||
tracing::debug!(%err, "packet interface still unavailable");
|
||||
}
|
||||
worker.ensure_addresses(network).await;
|
||||
worker.sync(network);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IpPlugin for WireguardPlugin {
|
||||
fn protocol_id(&self) -> &str {
|
||||
WIREGUARD_PROTOCOL
|
||||
}
|
||||
|
||||
fn attach(&self, context: PluginContext) {
|
||||
if let Some(local) = context.local_endpoint_id() {
|
||||
let _ = self.worker.local_id.set(local);
|
||||
}
|
||||
let _ = self.worker.context.set(context);
|
||||
}
|
||||
|
||||
fn on_network_activated(&self, network: NetworkId) {
|
||||
self.nudge(Command::Prepare(network));
|
||||
}
|
||||
|
||||
fn local_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
) -> Result<Option<PluginCapability>, PluginError> {
|
||||
let shared = self.worker.lock_shared();
|
||||
let Some(state) = shared.networks.get(&network) else {
|
||||
// Not ready yet. Ask for preparation; once the key exists the
|
||||
// plugin asks the agent to re-announce.
|
||||
drop(shared);
|
||||
self.nudge(Command::Prepare(network));
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Identity only. Where to send packets is the transport's business.
|
||||
let announcement = WgAnnouncement::new(network, &state.key.public());
|
||||
Ok(Some(PluginCapability {
|
||||
protocol: WIREGUARD_PROTOCOL.to_string(),
|
||||
version: super::announcement::ANNOUNCEMENT_VERSION,
|
||||
enabled: true,
|
||||
data: announcement.encode()?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn on_peer_capability(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
peer: EndpointId,
|
||||
capability: &PluginCapability,
|
||||
) -> Result<(), PluginError> {
|
||||
let local_key = {
|
||||
let shared = self.worker.lock_shared();
|
||||
match shared.networks.get(&network) {
|
||||
Some(state) => state.key.public(),
|
||||
None => {
|
||||
drop(shared);
|
||||
self.nudge(Command::Prepare(network));
|
||||
return Err(PluginError::Unavailable(
|
||||
"WireGuard is not ready for this network yet".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let validated = WgAnnouncement::decode_and_validate(&capability.data, network, &local_key)?;
|
||||
|
||||
let changed = {
|
||||
let mut shared = self.worker.lock_shared();
|
||||
match shared.networks.get_mut(&network) {
|
||||
Some(state) => {
|
||||
state.announcements.insert(peer, validated.clone()) != Some(validated)
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.nudge(Command::Sync(network));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn on_address_allocation(
|
||||
&self,
|
||||
network: NetworkId,
|
||||
range: Ipv4Range,
|
||||
allocations: &[(EndpointId, Ipv4Addr)],
|
||||
) {
|
||||
let changed = {
|
||||
let mut shared = self.worker.lock_shared();
|
||||
match shared.networks.get_mut(&network) {
|
||||
Some(state) => {
|
||||
let fresh: HashMap<EndpointId, Ipv4Addr> =
|
||||
allocations.iter().copied().collect();
|
||||
let changed = state.allocations != fresh || state.ipv4_range != Some(range);
|
||||
state.allocations = fresh;
|
||||
state.ipv4_range = Some(range);
|
||||
changed
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
if changed {
|
||||
self.nudge(Command::Sync(network));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_peer_link(&self, network: NetworkId, peer: EndpointId, link: SharedLink) {
|
||||
self.nudge(Command::Link {
|
||||
network,
|
||||
peer,
|
||||
link,
|
||||
});
|
||||
}
|
||||
|
||||
fn on_peer_gone(&self, network: NetworkId, peer: EndpointId) {
|
||||
let removed = {
|
||||
let mut shared = self.worker.lock_shared();
|
||||
match shared.networks.get_mut(&network) {
|
||||
Some(state) => {
|
||||
let had_link = state.links.remove(&peer).is_some();
|
||||
state.announcements.remove(&peer).is_some() || had_link
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
};
|
||||
if removed {
|
||||
self.nudge(Command::Sync(network));
|
||||
}
|
||||
}
|
||||
|
||||
fn on_network_deactivated(&self, network: NetworkId) {
|
||||
self.nudge(Command::Teardown(network));
|
||||
}
|
||||
|
||||
fn shutdown<'a>(&'a self) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
let (reply_tx, reply_rx) = oneshot::channel();
|
||||
if self.commands.send(Command::Stop(reply_tx)).await.is_ok() {
|
||||
let _ = reply_rx.await;
|
||||
}
|
||||
let task = self.task.lock().ok().and_then(|mut guard| guard.take());
|
||||
if let Some(task) = task {
|
||||
let _ = task.await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for WireguardPlugin {
|
||||
fn drop(&mut self) {
|
||||
if let Ok(mut guard) = self.task.lock()
|
||||
&& let Some(task) = guard.take()
|
||||
{
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
//! The adapter between the plugin's view of a packet interface and the
|
||||
//! host-management view.
|
||||
//!
|
||||
//! The plugin asks a [`TunFactory`] for a device and knows nothing else. This
|
||||
//! factory answers by reconciling the host — creating the interface, fixing
|
||||
//! up whatever an earlier run left behind, assigning the addresses — and
|
||||
//! handing back the device that came out of it.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunFactory, TunRequest};
|
||||
use super::{InterfacePlan, InterfaceProvisioner};
|
||||
|
||||
/// Turns a [`TunRequest`] into the plan for a host interface.
|
||||
fn plan_for(request: &TunRequest) -> Result<InterfacePlan, PluginError> {
|
||||
let mut addresses = vec![Cidr::new(request.address.into(), request.prefix_len)?];
|
||||
if let Some(address) = request.address_v4 {
|
||||
addresses.push(Cidr::new(address.into(), request.prefix_len_v4)?);
|
||||
}
|
||||
Ok(InterfacePlan::new(
|
||||
request.name.clone(),
|
||||
request.mtu,
|
||||
addresses,
|
||||
))
|
||||
}
|
||||
|
||||
/// A [`TunFactory`] backed by an [`InterfaceProvisioner`].
|
||||
#[derive(Debug)]
|
||||
pub struct ManagedTunFactory {
|
||||
provisioner: Arc<dyn InterfaceProvisioner>,
|
||||
}
|
||||
|
||||
impl ManagedTunFactory {
|
||||
/// Wraps a provisioner.
|
||||
pub fn new(provisioner: Arc<dyn InterfaceProvisioner>) -> Self {
|
||||
Self { provisioner }
|
||||
}
|
||||
|
||||
/// The provisioner underneath.
|
||||
pub fn provisioner(&self) -> &Arc<dyn InterfaceProvisioner> {
|
||||
&self.provisioner
|
||||
}
|
||||
}
|
||||
|
||||
impl TunFactory for ManagedTunFactory {
|
||||
fn name(&self) -> &str {
|
||||
self.provisioner.name()
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
tracing::info!(
|
||||
interface = %plan.name,
|
||||
changes = %provisioned.changes.summary(),
|
||||
"overlay interface reconciled"
|
||||
);
|
||||
provisioned.device.ok_or_else(|| {
|
||||
// Reaching here would mean the interface already existed and
|
||||
// was held open by us, which cannot be true on the path that
|
||||
// creates a device.
|
||||
PluginError::Other(format!(
|
||||
"interface `{}` was reconciled but no device came back",
|
||||
plan.name
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn reconfigure<'a>(&'a self, request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move {
|
||||
let plan = plan_for(&request)?;
|
||||
let provisioned = self.provisioner.reconcile(&plan).await?;
|
||||
if !provisioned.changes.is_empty() {
|
||||
tracing::info!(
|
||||
interface = %plan.name,
|
||||
changes = %provisioned.changes.summary(),
|
||||
"overlay interface updated"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn destroy<'a>(&'a self, name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {
|
||||
match self.provisioner.remove(name).await {
|
||||
Ok(()) => tracing::info!(interface = %name, "overlay interface removed"),
|
||||
Err(err) => {
|
||||
tracing::warn!(interface = %name, %err, "cannot remove the overlay interface")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
use super::super::{LinkKind, MockHost, MockProvisioner};
|
||||
use super::*;
|
||||
|
||||
fn request(v4: Option<Ipv4Addr>) -> TunRequest {
|
||||
TunRequest {
|
||||
name: "tsunfactory".into(),
|
||||
address: Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1),
|
||||
prefix_len: 64,
|
||||
address_v4: v4,
|
||||
prefix_len_v4: 24,
|
||||
mtu: 1280,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn creating_a_device_provisions_the_host_and_returns_it() {
|
||||
let provisioner = Arc::new(MockProvisioner::default());
|
||||
let factory = ManagedTunFactory::new(provisioner.clone());
|
||||
|
||||
let device = factory
|
||||
.create(request(Some(Ipv4Addr::new(10, 13, 37, 69))))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(device.name(), "tsunfactory");
|
||||
assert_eq!(device.mtu(), 1280);
|
||||
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert_eq!(state.addresses.len(), 2, "both families are assigned");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_reallocated_address_is_applied_to_the_live_interface() {
|
||||
let provisioner = Arc::new(MockProvisioner::default());
|
||||
let factory = ManagedTunFactory::new(provisioner.clone());
|
||||
factory
|
||||
.create(request(Some(Ipv4Addr::new(10, 13, 37, 69))))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
factory
|
||||
.reconfigure(request(Some(Ipv4Addr::new(10, 13, 37, 70))))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let state = provisioner.host().get("tsunfactory").unwrap();
|
||||
assert!(state.attached, "the interface was not recreated");
|
||||
let addresses: Vec<String> = state
|
||||
.addresses
|
||||
.iter()
|
||||
.map(|entry| entry.to_string())
|
||||
.collect();
|
||||
assert!(
|
||||
addresses.contains(&"10.13.37.70/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
assert!(
|
||||
!addresses.contains(&"10.13.37.69/24".to_string()),
|
||||
"{addresses:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn destroying_takes_the_interface_off_the_host() {
|
||||
let provisioner = Arc::new(MockProvisioner::default());
|
||||
let factory = ManagedTunFactory::new(provisioner.clone());
|
||||
factory.create(request(None)).await.unwrap();
|
||||
|
||||
factory.destroy("tsunfactory").await;
|
||||
assert!(provisioner.host().names().is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_host_that_cannot_be_provisioned_fails_the_create() {
|
||||
let host = MockHost::new();
|
||||
host.insert(
|
||||
"tsunfactory",
|
||||
super::super::InterfaceState {
|
||||
kind: LinkKind::Foreign("bridge".into()),
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1500,
|
||||
addresses: Vec::new(),
|
||||
},
|
||||
);
|
||||
let factory = ManagedTunFactory::new(Arc::new(MockProvisioner::new(host)));
|
||||
let err = factory.create(request(None)).await.unwrap_err();
|
||||
assert!(err.to_string().contains("bridge"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,605 @@
|
||||
//! Managing the overlay interface on Linux, through netlink.
|
||||
//!
|
||||
//! Everything the old printed recipe did — `ip tuntap add`, `ip link set`,
|
||||
//! `ip address add` — happens here instead, in this process, over a netlink
|
||||
//! socket. No `ip` binary is invoked, so nothing this module does can be
|
||||
//! influenced by `PATH`, by a shell, or by anything a remote peer said.
|
||||
//!
|
||||
//! # The interface cleans up after itself
|
||||
//!
|
||||
//! The TUN interface is created by opening `/dev/net/tun` and is **not**
|
||||
//! made persistent, so the kernel destroys it the moment the last file
|
||||
//! descriptor closes. That covers the ordinary exit, a panic, a `SIGKILL`
|
||||
//! and a power loss equally: there is no path by which a dead agent leaves an
|
||||
//! interface behind, because keeping it alive is what needs an action, not
|
||||
//! removing it.
|
||||
//!
|
||||
//! It also means the interface has carrier for its whole life, which removes
|
||||
//! the two settings the manual recipe needed. `keep_addr_on_down` was only
|
||||
//! needed because an interface nobody held open lost carrier and had its IPv6
|
||||
//! addresses flushed; `nodad` only because duplicate address detection cannot
|
||||
//! finish without carrier.
|
||||
//!
|
||||
//! What can still be left behind is an interface from *before* this change —
|
||||
//! one created persistent by the old recipe — or one from a run killed in the
|
||||
//! window between `TUNSETIFF` and this module recording it. Those are found
|
||||
//! at startup and replaced; see
|
||||
//! [`plan_changes`](super::plan_changes) for the rules that decide it.
|
||||
//!
|
||||
//! # Threads
|
||||
//!
|
||||
//! Capabilities on Linux are per thread, and netlink checks the credentials
|
||||
//! of whichever thread calls `sendmsg` — which, with an async netlink client,
|
||||
//! is the connection task rather than the caller. Raising `CAP_NET_ADMIN`
|
||||
//! around an `await` would therefore be both wrong and unsound in the "works
|
||||
//! until the scheduler moves the task" sense.
|
||||
//!
|
||||
//! So all netlink work happens on one dedicated thread running a
|
||||
//! current-thread runtime. Nothing is polled outside a `block_on`, the
|
||||
//! capability is raised immediately before that call and lowered immediately
|
||||
//! after, and the connection task lives and dies inside it.
|
||||
|
||||
use std::net::IpAddr;
|
||||
use std::sync::{Arc, Mutex, mpsc};
|
||||
|
||||
use futures_util::TryStreamExt;
|
||||
// Through rtnetlink's own re-export, so the packet types can never drift out
|
||||
// of step with the client that sends them.
|
||||
use rtnetlink::packet_route::address::{AddressAttribute, AddressMessage};
|
||||
use rtnetlink::packet_route::link::{InfoKind, LinkAttribute, LinkFlags, LinkInfo, LinkMessage};
|
||||
use rtnetlink::{LinkMessageBuilder, LinkUnspec};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{TunDevice, TunRequest};
|
||||
use super::privilege::{NetAdmin, Privilege, probe_net_admin};
|
||||
use super::{
|
||||
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
|
||||
};
|
||||
|
||||
/// A request to the netlink thread.
|
||||
enum Command {
|
||||
/// What does this interface look like right now?
|
||||
Observe(String, Reply<InterfaceState>),
|
||||
/// Remove this interface.
|
||||
Delete(String, Reply<()>),
|
||||
/// Apply MTU, link state and addresses.
|
||||
Configure(Box<Configure>, Reply<()>),
|
||||
/// Stop the thread.
|
||||
Stop,
|
||||
}
|
||||
|
||||
type Reply<T> = mpsc::Sender<Result<T, PluginError>>;
|
||||
|
||||
/// The configuration half of a reconciliation.
|
||||
struct Configure {
|
||||
name: String,
|
||||
mtu: Option<u32>,
|
||||
bring_up: bool,
|
||||
add: Vec<Cidr>,
|
||||
remove: Vec<Cidr>,
|
||||
}
|
||||
|
||||
/// Manages the overlay interface with netlink.
|
||||
#[derive(Debug)]
|
||||
pub struct NetlinkProvisioner {
|
||||
commands: mpsc::Sender<Command>,
|
||||
worker: Mutex<Option<std::thread::JoinHandle<()>>>,
|
||||
/// Interfaces this process created, so they are adjusted rather than
|
||||
/// replaced. See [`plan_changes`].
|
||||
ours: Mutex<Vec<String>>,
|
||||
/// Devices kept alive for as long as the interface should exist. Dropping
|
||||
/// one is what removes the interface from the kernel.
|
||||
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
||||
}
|
||||
|
||||
impl NetlinkProvisioner {
|
||||
/// Starts the netlink thread, after checking this process can use it.
|
||||
pub fn new() -> Result<Self, PluginError> {
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
"{reason}. {}",
|
||||
Privilege::how_to_grant(¤t_program())
|
||||
)));
|
||||
}
|
||||
Privilege::Unsupported => {
|
||||
return Err(PluginError::Unavailable(
|
||||
"interface management is not compiled in".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let (commands, requests) = mpsc::channel();
|
||||
let worker = std::thread::Builder::new()
|
||||
.name("tsunagi-netlink".to_string())
|
||||
.spawn(move || netlink_thread(requests))
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot start the netlink thread: {err}"))
|
||||
})?;
|
||||
|
||||
Ok(Self {
|
||||
commands,
|
||||
worker: Mutex::new(Some(worker)),
|
||||
ours: Mutex::new(Vec::new()),
|
||||
held: Mutex::new(Vec::new()),
|
||||
})
|
||||
}
|
||||
|
||||
fn call<T: Send + 'static>(
|
||||
&self,
|
||||
make: impl FnOnce(Reply<T>) -> Command,
|
||||
) -> Result<T, PluginError> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.commands
|
||||
.send(make(reply_tx))
|
||||
.map_err(|_| PluginError::Unavailable("the netlink thread has stopped".to_string()))?;
|
||||
reply_rx.recv().map_err(|_| {
|
||||
PluginError::Unavailable("the netlink thread stopped mid-request".to_string())
|
||||
})?
|
||||
}
|
||||
|
||||
fn is_ours(&self, name: &str) -> bool {
|
||||
lock(&self.ours).iter().any(|owned| owned == name)
|
||||
}
|
||||
|
||||
/// Opens the TUN interface, which is what creates it.
|
||||
///
|
||||
/// Synchronous on purpose: the capability guard is raised and lowered
|
||||
/// without an `await` in between, so it cannot outlive this thread.
|
||||
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, PluginError> {
|
||||
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
||||
let _guard = NetAdmin::acquire()?;
|
||||
super::super::tun::open_tun(&request)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetlinkProvisioner {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.commands.send(Command::Stop);
|
||||
if let Some(worker) = lock(&self.worker).take() {
|
||||
let _ = worker.join();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for NetlinkProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"netlink"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
Box::pin(async move {
|
||||
let current = self.call(|reply| Command::Observe(plan.name.clone(), reply))?;
|
||||
let changes = plan_changes(¤t, plan, self.is_ours(&plan.name))?;
|
||||
|
||||
if changes.delete_link {
|
||||
tracing::info!(
|
||||
interface = %plan.name,
|
||||
"removing an abandoned interface left by an earlier run"
|
||||
);
|
||||
self.call(|reply| Command::Delete(plan.name.clone(), reply))?;
|
||||
}
|
||||
|
||||
let device = if changes.create_link {
|
||||
let device = self.create_device(plan)?;
|
||||
lock(&self.ours).push(plan.name.clone());
|
||||
lock(&self.held).push((plan.name.clone(), Arc::clone(&device)));
|
||||
Some(device)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if changes.set_mtu.is_some()
|
||||
|| changes.bring_up
|
||||
|| !changes.add.is_empty()
|
||||
|| !changes.remove.is_empty()
|
||||
{
|
||||
let configure = Box::new(Configure {
|
||||
name: plan.name.clone(),
|
||||
mtu: changes.set_mtu,
|
||||
bring_up: changes.bring_up,
|
||||
add: changes.add.clone(),
|
||||
remove: changes.remove.clone(),
|
||||
});
|
||||
// A failure here leaves an interface that exists but cannot
|
||||
// carry traffic, which is worse than none at all, so it is
|
||||
// taken back down rather than left as a trap.
|
||||
if let Err(err) = self.call(move |reply| Command::Configure(configure, reply)) {
|
||||
if changes.create_link {
|
||||
lock(&self.held).retain(|(held, _)| held != &plan.name);
|
||||
lock(&self.ours).retain(|owned| owned != &plan.name);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Provisioned { changes, device })
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move {
|
||||
// Dropping the device is what removes the interface; the explicit
|
||||
// delete is only so it is gone by the time this returns rather
|
||||
// than whenever the last reader lets go.
|
||||
lock(&self.held).retain(|(held, _)| held != name);
|
||||
lock(&self.ours).retain(|owned| owned != name);
|
||||
self.call(|reply| Command::Delete(name.to_string(), reply))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
match mutex.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn current_program() -> String {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|path| path.to_str().map(str::to_string))
|
||||
.unwrap_or_else(|| "tsunagi".to_string())
|
||||
}
|
||||
|
||||
/// The netlink thread.
|
||||
///
|
||||
/// It owns a current-thread runtime, so nothing is polled except inside the
|
||||
/// `block_on` below — which is what makes the capability window exact.
|
||||
fn netlink_thread(requests: mpsc::Receiver<Command>) {
|
||||
// A binary granted `cap_net_admin+ep` starts with the capability
|
||||
// effective. Lower it immediately so that even this thread only has it
|
||||
// during the calls that need it.
|
||||
NetAdmin::lower();
|
||||
|
||||
let runtime = match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(err) => {
|
||||
// Answer every request with the reason rather than hanging.
|
||||
while let Ok(command) = requests.recv() {
|
||||
let message = format!("the netlink thread has no runtime: {err}");
|
||||
match command {
|
||||
Command::Observe(_, reply) => {
|
||||
let _ = reply.send(Err(PluginError::Unavailable(message)));
|
||||
}
|
||||
Command::Delete(_, reply) | Command::Configure(_, reply) => {
|
||||
let _ = reply.send(Err(PluginError::Unavailable(message)));
|
||||
}
|
||||
Command::Stop => return,
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(command) = requests.recv() {
|
||||
match command {
|
||||
Command::Stop => return,
|
||||
// Reading the interface list needs no privilege at all.
|
||||
Command::Observe(name, reply) => {
|
||||
let _ = reply.send(runtime.block_on(observe(&name)));
|
||||
}
|
||||
Command::Delete(name, reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(delete_link(&name));
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Command::Configure(configure, reply) => {
|
||||
let result = NetAdmin::acquire().and_then(|guard| {
|
||||
let result = runtime.block_on(configure_link(&configure));
|
||||
drop(guard);
|
||||
result
|
||||
});
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a netlink connection and runs one unit of work over it.
|
||||
async fn with_netlink<T, F>(work: impl FnOnce(rtnetlink::Handle) -> F) -> Result<T, PluginError>
|
||||
where
|
||||
F: std::future::Future<Output = Result<T, PluginError>>,
|
||||
{
|
||||
let (connection, handle, _messages) = rtnetlink::new_connection()
|
||||
.map_err(|err| PluginError::Unavailable(format!("cannot open netlink: {err}")))?;
|
||||
let pump = tokio::spawn(connection);
|
||||
let result = work(handle).await;
|
||||
pump.abort();
|
||||
result
|
||||
}
|
||||
|
||||
/// `fe80::/10`, which the kernel assigns on its own.
|
||||
fn is_link_local(addr: IpAddr) -> bool {
|
||||
match addr {
|
||||
IpAddr::V4(addr) => addr.is_link_local(),
|
||||
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a name belongs to a TUN interface, from sysfs.
|
||||
///
|
||||
/// A fallback for the case where the kernel does not report `IFLA_LINKINFO`
|
||||
/// for the link. Reading sysfs needs no privileges.
|
||||
fn is_tun_in_sysfs(name: &str) -> bool {
|
||||
std::path::Path::new(&format!("/sys/class/net/{name}/tun_flags")).exists()
|
||||
}
|
||||
|
||||
fn link_kind(message: &LinkMessage, name: &str) -> LinkKind {
|
||||
for attribute in &message.attributes {
|
||||
if let LinkAttribute::LinkInfo(infos) = attribute {
|
||||
for info in infos {
|
||||
if let LinkInfo::Kind(kind) = info {
|
||||
return match kind {
|
||||
InfoKind::Tun => LinkKind::Tun,
|
||||
other => LinkKind::Foreign(format!("{other:?}").to_lowercase()),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if is_tun_in_sysfs(name) {
|
||||
LinkKind::Tun
|
||||
} else {
|
||||
// No `IFLA_LINKINFO` and no `tun_flags`: a plain device such as an
|
||||
// ethernet port. Unknown rather than ours, so it is left alone.
|
||||
LinkKind::Foreign("non-tun".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
async fn observe(name: &str) -> Result<InterfaceState, PluginError> {
|
||||
with_netlink(|handle| async move {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
let message = match links.try_next().await {
|
||||
Ok(Some(message)) => message,
|
||||
Ok(None) => return Ok(InterfaceState::absent()),
|
||||
Err(err) => {
|
||||
// "No such device" is the expected answer on a clean host, so
|
||||
// it is not an error; anything else is.
|
||||
if !std::path::Path::new(&format!("/sys/class/net/{name}")).exists() {
|
||||
return Ok(InterfaceState::absent());
|
||||
}
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
"cannot read interface `{name}`: {err}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let index = message.header.index;
|
||||
let flags = message.header.flags;
|
||||
let mtu = message
|
||||
.attributes
|
||||
.iter()
|
||||
.find_map(|attribute| match attribute {
|
||||
LinkAttribute::Mtu(mtu) => Some(*mtu),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut addresses = Vec::new();
|
||||
let mut stream = handle
|
||||
.address()
|
||||
.get()
|
||||
.set_link_index_filter(index)
|
||||
.execute();
|
||||
while let Some(message) = stream.try_next().await.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot read the addresses of `{name}`: {err}"))
|
||||
})? {
|
||||
if let Some(cidr) = address_of(&message)
|
||||
&& !is_link_local(cidr.addr)
|
||||
{
|
||||
addresses.push(cidr);
|
||||
}
|
||||
}
|
||||
addresses.sort();
|
||||
|
||||
Ok(InterfaceState {
|
||||
kind: link_kind(&message, name),
|
||||
// `IFF_LOWER_UP` is carrier, and a TUN has carrier exactly while
|
||||
// a process holds it open.
|
||||
attached: flags.contains(LinkFlags::LowerUp),
|
||||
up: flags.contains(LinkFlags::Up),
|
||||
mtu,
|
||||
addresses,
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// The address a message carries, preferring `IFA_LOCAL`.
|
||||
///
|
||||
/// For a point-to-point interface `IFA_ADDRESS` is the *peer* address, so
|
||||
/// taking it would compare the wrong thing.
|
||||
fn address_of(message: &AddressMessage) -> Option<Cidr> {
|
||||
let mut address = None;
|
||||
for attribute in &message.attributes {
|
||||
match attribute {
|
||||
AddressAttribute::Local(addr) => return cidr(*addr, message.header.prefix_len),
|
||||
AddressAttribute::Address(addr) => address = Some(*addr),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
address.and_then(|addr| cidr(addr, message.header.prefix_len))
|
||||
}
|
||||
|
||||
fn cidr(addr: IpAddr, prefix_len: u8) -> Option<Cidr> {
|
||||
Cidr::new(addr, prefix_len).ok()
|
||||
}
|
||||
|
||||
async fn delete_link(name: &str) -> Result<(), PluginError> {
|
||||
with_netlink(|handle| async move {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
let index = match links.try_next().await {
|
||||
Ok(Some(message)) => message.header.index,
|
||||
// Already gone, which is the outcome asked for.
|
||||
Ok(None) | Err(_) => return Ok(()),
|
||||
};
|
||||
handle.link().del(index).execute().await.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot remove interface `{name}`: {err}"))
|
||||
})
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn configure_link(configure: &Configure) -> Result<(), PluginError> {
|
||||
let name = configure.name.as_str();
|
||||
with_netlink(|handle| async move {
|
||||
let mut links = handle.link().get().match_name(name.to_string()).execute();
|
||||
let index = links
|
||||
.try_next()
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|message| message.header.index)
|
||||
.ok_or_else(|| {
|
||||
PluginError::Unavailable(format!(
|
||||
"interface `{name}` disappeared before it could be configured"
|
||||
))
|
||||
})?;
|
||||
|
||||
if configure.mtu.is_some() || configure.bring_up {
|
||||
let mut builder = LinkMessageBuilder::<LinkUnspec>::new().index(index);
|
||||
if let Some(mtu) = configure.mtu {
|
||||
builder = builder.mtu(mtu);
|
||||
}
|
||||
if configure.bring_up {
|
||||
builder = builder.up();
|
||||
}
|
||||
handle
|
||||
.link()
|
||||
.set(builder.build())
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot configure interface `{name}`: {err}"))
|
||||
})?;
|
||||
}
|
||||
|
||||
for cidr in &configure.remove {
|
||||
// Delete the exact message the kernel holds rather than a
|
||||
// reconstruction of it, so the family and flags always match.
|
||||
let mut stream = handle
|
||||
.address()
|
||||
.get()
|
||||
.set_link_index_filter(index)
|
||||
.execute();
|
||||
let mut target: Option<AddressMessage> = None;
|
||||
while let Ok(Some(message)) = stream.try_next().await {
|
||||
if address_of(&message) == Some(*cidr) {
|
||||
target = Some(message);
|
||||
break;
|
||||
}
|
||||
}
|
||||
drop(stream);
|
||||
if let Some(message) = target {
|
||||
handle
|
||||
.address()
|
||||
.del(message)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
"cannot remove {cidr} from interface `{name}`: {err}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
}
|
||||
|
||||
for cidr in &configure.add {
|
||||
handle
|
||||
.address()
|
||||
.add(index, cidr.addr, cidr.prefix_len)
|
||||
.execute()
|
||||
.await
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
"cannot add {cidr} to interface `{name}`: {err}"
|
||||
))
|
||||
})?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, Ipv6Addr};
|
||||
|
||||
#[test]
|
||||
fn link_local_addresses_are_not_ours_to_manage() {
|
||||
// The kernel assigns these itself when the link comes up. Treating
|
||||
// them as unplanned would make every reconciliation try to delete one.
|
||||
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
||||
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
|
||||
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
||||
))));
|
||||
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observing_a_name_nothing_uses_reports_it_absent() {
|
||||
let state = observe("tsunagi-no-such-interface").await.unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Absent);
|
||||
assert!(state.addresses.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn observing_the_loopback_interface_decodes_what_the_kernel_reports() {
|
||||
// Reading the interface table needs no privileges, so this runs
|
||||
// everywhere and checks the netlink decoding against a real kernel
|
||||
// rather than against a fixture.
|
||||
let state = observe("lo").await.unwrap();
|
||||
assert!(
|
||||
matches!(state.kind, LinkKind::Foreign(_)),
|
||||
"loopback is not a tun: {:?}",
|
||||
state.kind
|
||||
);
|
||||
assert!(state.up, "loopback is up");
|
||||
assert!(state.mtu >= 1280, "decoded an mtu: {}", state.mtu);
|
||||
assert!(
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|cidr| cidr.addr == IpAddr::V4(Ipv4Addr::LOCALHOST)),
|
||||
"127.0.0.1 is decoded: {:?}",
|
||||
state.addresses
|
||||
);
|
||||
assert!(
|
||||
!state.addresses.iter().any(|cidr| is_link_local(cidr.addr)),
|
||||
"link-local addresses are filtered out: {:?}",
|
||||
state.addresses
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_interface_that_is_not_a_tun_is_reported_foreign() {
|
||||
let message = LinkMessage::default();
|
||||
// No `IFLA_LINKINFO` and a name with no `tun_flags` in sysfs.
|
||||
assert!(matches!(
|
||||
link_kind(&message, "definitely-not-an-interface"),
|
||||
LinkKind::Foreign(_)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
//! An in-memory host, so provisioning is tested without touching this one.
|
||||
//!
|
||||
//! [`MockHost`] is a pretend `/sys/class/net`: a test can seed it with the
|
||||
//! leftovers of a crashed run, or with somebody else's bridge, then check
|
||||
//! what the provisioner did about it. It is also what the platforms that have
|
||||
//! no provisioner yet are wired to in their own tests.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
use super::super::config::Cidr;
|
||||
use super::super::tun::{MemoryTun, MemoryTunFactory, TunFactory, TunRequest};
|
||||
use super::{
|
||||
Changes, InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned,
|
||||
plan_changes,
|
||||
};
|
||||
|
||||
/// A pretend host with interfaces on it.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockHost {
|
||||
links: Arc<Mutex<HashMap<String, InterfaceState>>>,
|
||||
}
|
||||
|
||||
impl MockHost {
|
||||
/// An empty host.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, InterfaceState>> {
|
||||
match self.links.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Puts an interface on the host.
|
||||
pub fn insert(&self, name: impl Into<String>, state: InterfaceState) {
|
||||
self.lock().insert(name.into(), state);
|
||||
}
|
||||
|
||||
/// Seeds the leftovers of a run that died: a TUN nobody holds open, still
|
||||
/// carrying whatever addresses it had.
|
||||
pub fn insert_stale_tun(&self, name: impl Into<String>, addresses: Vec<Cidr>) {
|
||||
self.insert(
|
||||
name,
|
||||
InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: false,
|
||||
up: true,
|
||||
mtu: 1280,
|
||||
addresses,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The state of an interface, if it exists.
|
||||
pub fn get(&self, name: &str) -> Option<InterfaceState> {
|
||||
self.lock().get(name).cloned()
|
||||
}
|
||||
|
||||
/// The names currently on the host.
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
let mut names: Vec<String> = self.lock().keys().cloned().collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies plans to a [`MockHost`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockProvisioner {
|
||||
host: MockHost,
|
||||
ours: Arc<Mutex<Vec<String>>>,
|
||||
/// The devices handed out, so a test can drive packets through them.
|
||||
devices: MemoryTunFactory,
|
||||
/// Set to fail every call, to exercise the error path.
|
||||
failure: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for MockProvisioner {
|
||||
fn default() -> Self {
|
||||
Self::new(MockHost::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl MockProvisioner {
|
||||
/// A provisioner over a host.
|
||||
pub fn new(host: MockHost) -> Self {
|
||||
Self {
|
||||
host,
|
||||
ours: Arc::new(Mutex::new(Vec::new())),
|
||||
devices: MemoryTunFactory::new(),
|
||||
failure: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A provisioner that refuses everything, with this reason.
|
||||
pub fn failing(reason: impl Into<String>) -> Self {
|
||||
Self {
|
||||
host: MockHost::new(),
|
||||
ours: Arc::new(Mutex::new(Vec::new())),
|
||||
devices: MemoryTunFactory::new(),
|
||||
failure: Some(reason.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The host it applies to.
|
||||
pub fn host(&self) -> &MockHost {
|
||||
&self.host
|
||||
}
|
||||
|
||||
/// The device created for an interface name, if any.
|
||||
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
|
||||
self.devices.device(name)
|
||||
}
|
||||
|
||||
fn owned(&self) -> std::sync::MutexGuard<'_, Vec<String>> {
|
||||
match self.ours.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn apply(&self, plan: &InterfacePlan) -> Result<Changes, PluginError> {
|
||||
if let Some(reason) = &self.failure {
|
||||
return Err(PluginError::Unavailable(reason.clone()));
|
||||
}
|
||||
|
||||
let ours = self.owned().iter().any(|name| name == &plan.name);
|
||||
let current = self
|
||||
.host
|
||||
.get(&plan.name)
|
||||
.unwrap_or_else(InterfaceState::absent);
|
||||
let changes = plan_changes(¤t, plan, ours)?;
|
||||
|
||||
let mut state = current;
|
||||
if changes.delete_link {
|
||||
self.host.lock().remove(&plan.name);
|
||||
state = InterfaceState::absent();
|
||||
}
|
||||
if changes.create_link {
|
||||
state = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
// Creating it means holding it open, so it has carrier.
|
||||
attached: true,
|
||||
up: false,
|
||||
mtu: 1500,
|
||||
addresses: Vec::new(),
|
||||
};
|
||||
self.owned().push(plan.name.clone());
|
||||
}
|
||||
if let Some(mtu) = changes.set_mtu {
|
||||
state.mtu = mtu;
|
||||
}
|
||||
if changes.bring_up {
|
||||
state.up = true;
|
||||
}
|
||||
state
|
||||
.addresses
|
||||
.retain(|addr| !changes.remove.contains(addr));
|
||||
state.addresses.extend(changes.add.iter().copied());
|
||||
state.addresses.sort();
|
||||
state.addresses.dedup();
|
||||
self.host.insert(plan.name.clone(), state);
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for MockProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
Box::pin(async move {
|
||||
let changes = self.apply(plan)?;
|
||||
let device = if changes.create_link {
|
||||
Some(
|
||||
self.devices
|
||||
.create(TunRequest::bare(plan.name.clone(), plan.mtu))
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok(Provisioned { changes, device })
|
||||
})
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move {
|
||||
self.host.lock().remove(name);
|
||||
self.owned().retain(|owned| owned != name);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
fn v6() -> Cidr {
|
||||
Cidr {
|
||||
addr: IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 1)),
|
||||
prefix_len: 64,
|
||||
}
|
||||
}
|
||||
|
||||
fn v4(last: u8) -> Cidr {
|
||||
Cidr {
|
||||
addr: IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)),
|
||||
prefix_len: 24,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(addresses: Vec<Cidr>) -> InterfacePlan {
|
||||
InterfacePlan::new("tsunmock", 1280, addresses)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconciling_an_empty_host_creates_a_configured_interface() {
|
||||
let provisioner = MockProvisioner::default();
|
||||
let plan = plan(vec![v6(), v4(69)]);
|
||||
let changes = provisioner.reconcile(&plan).await.unwrap().changes;
|
||||
assert!(changes.create_link);
|
||||
|
||||
let state = provisioner.host().get("tsunmock").unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert!(state.up);
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert_eq!(state.addresses, vec![v4(69), v6()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reconciling_is_idempotent() {
|
||||
let provisioner = MockProvisioner::default();
|
||||
let plan = plan(vec![v6(), v4(69)]);
|
||||
provisioner.reconcile(&plan).await.unwrap();
|
||||
let second = provisioner.reconcile(&plan).await.unwrap().changes;
|
||||
assert!(second.is_empty(), "{second:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_crashed_run_is_repaired_on_the_next_start() {
|
||||
let host = MockHost::new();
|
||||
// What the previous run left: the interface, with the address it had
|
||||
// been allocated back then.
|
||||
host.insert_stale_tun("tsunmock", vec![v4(178)]);
|
||||
let provisioner = MockProvisioner::new(host);
|
||||
|
||||
let changes = provisioner
|
||||
.reconcile(&plan(vec![v6(), v4(69)]))
|
||||
.await
|
||||
.unwrap()
|
||||
.changes;
|
||||
assert!(changes.delete_link && changes.create_link);
|
||||
|
||||
let state = provisioner.host().get("tsunmock").unwrap();
|
||||
assert_eq!(
|
||||
state.addresses,
|
||||
vec![v4(69), v6()],
|
||||
"the stale address is gone and the current one is there"
|
||||
);
|
||||
assert!(state.attached, "the new interface is held open by us");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_changed_allocation_is_applied_without_recreating_the_interface() {
|
||||
let provisioner = MockProvisioner::default();
|
||||
provisioner
|
||||
.reconcile(&plan(vec![v6(), v4(69)]))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The overlay agreed on a different address for us while running.
|
||||
let changes = provisioner
|
||||
.reconcile(&plan(vec![v6(), v4(70)]))
|
||||
.await
|
||||
.unwrap()
|
||||
.changes;
|
||||
assert!(
|
||||
!changes.delete_link && !changes.create_link,
|
||||
"recreating would drop every tunnel"
|
||||
);
|
||||
assert_eq!(changes.add, vec![v4(70)]);
|
||||
assert_eq!(changes.remove, vec![v4(69)]);
|
||||
assert_eq!(
|
||||
provisioner.host().get("tsunmock").unwrap().addresses,
|
||||
vec![v4(70), v6()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn removing_takes_the_interface_off_the_host_and_is_idempotent() {
|
||||
let provisioner = MockProvisioner::default();
|
||||
provisioner.reconcile(&plan(vec![v6()])).await.unwrap();
|
||||
assert_eq!(provisioner.host().names(), vec!["tsunmock".to_string()]);
|
||||
|
||||
provisioner.remove("tsunmock").await.unwrap();
|
||||
assert!(provisioner.host().names().is_empty());
|
||||
provisioner.remove("tsunmock").await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_foreign_interface_makes_reconciling_fail_and_changes_nothing() {
|
||||
let host = MockHost::new();
|
||||
host.insert(
|
||||
"tsunmock",
|
||||
InterfaceState {
|
||||
kind: LinkKind::Foreign("bridge".into()),
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1500,
|
||||
addresses: vec![v4(1)],
|
||||
},
|
||||
);
|
||||
let provisioner = MockProvisioner::new(host);
|
||||
assert!(provisioner.reconcile(&plan(vec![v6()])).await.is_err());
|
||||
|
||||
let state = provisioner.host().get("tsunmock").unwrap();
|
||||
assert_eq!(state.kind, LinkKind::Foreign("bridge".into()));
|
||||
assert_eq!(state.addresses, vec![v4(1)], "left exactly as it was");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
//! Bringing a host interface into the state the overlay needs.
|
||||
//!
|
||||
//! The agent used to print a list of `ip` commands and ask a human to run
|
||||
//! them. That is fragile in exactly the way hand-held setup always is: the
|
||||
//! interface does not survive a reboot, a changed address allocation needs
|
||||
//! another manual round, and a crashed run leaves a half-configured interface
|
||||
//! that the next run then trips over.
|
||||
//!
|
||||
//! So the agent does it itself, and the shape of that is a *reconciliation*:
|
||||
//! it is handed an [`InterfacePlan`] describing what the interface should look
|
||||
//! like, it observes what is actually there, and it applies the difference.
|
||||
//! Running it twice changes nothing the second time, and running it after a
|
||||
//! crash repairs whatever was left behind.
|
||||
//!
|
||||
//! # Why this is a trait
|
||||
//!
|
||||
//! Every platform does this differently — netlink on Linux, `SystemConfiguration`
|
||||
//! on macOS, the Windows IP Helper API — while the *decision* of what to change
|
||||
//! is the same everywhere. So the decision lives in [`plan_changes`], which is
|
||||
//! pure and tested on every platform, and only the execution is behind
|
||||
//! [`InterfaceProvisioner`].
|
||||
//!
|
||||
//! Three implementations:
|
||||
//!
|
||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||
//! and a pointer at the manual route rather than pretending to work.
|
||||
//!
|
||||
//! # What it is not allowed to do
|
||||
//!
|
||||
//! Nothing here takes a name, an address or a command from the network. The
|
||||
//! interface name is derived from the network id, the addresses come from the
|
||||
//! local plugin, and an interface this agent did not create is never deleted
|
||||
//! or reconfigured — see [`plan_changes`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
use super::config::Cidr;
|
||||
use super::tun::TunDevice;
|
||||
|
||||
mod mock;
|
||||
pub use mock::{MockHost, MockProvisioner};
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux;
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux::NetlinkProvisioner;
|
||||
|
||||
mod privilege;
|
||||
pub use privilege::{Privilege, probe_net_admin};
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedProvisioner;
|
||||
|
||||
mod factory;
|
||||
pub use factory::ManagedTunFactory;
|
||||
|
||||
/// What an interface should look like.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfacePlan {
|
||||
/// Interface name. Derived from the network id, never from the network.
|
||||
pub name: String,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
/// Every address the interface should carry, and no others.
|
||||
pub addresses: Vec<Cidr>,
|
||||
}
|
||||
|
||||
impl InterfacePlan {
|
||||
/// Builds a plan, normalising the address list.
|
||||
pub fn new(name: impl Into<String>, mtu: u32, addresses: Vec<Cidr>) -> Self {
|
||||
let unique: BTreeSet<Cidr> = addresses.into_iter().collect();
|
||||
Self {
|
||||
name: name.into(),
|
||||
mtu,
|
||||
addresses: unique.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of link is sitting on a name.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum LinkKind {
|
||||
/// Nothing is there.
|
||||
Absent,
|
||||
/// A TUN interface.
|
||||
Tun,
|
||||
/// Something else entirely — a bridge, a physical device, a VPN from
|
||||
/// another program. Never ours to touch.
|
||||
Foreign(String),
|
||||
}
|
||||
|
||||
/// What an interface currently looks like.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct InterfaceState {
|
||||
/// What kind of link holds the name.
|
||||
pub kind: LinkKind,
|
||||
/// Whether a process is attached to the TUN, which the kernel reports as
|
||||
/// carrier. A leftover interface from a crashed run has none.
|
||||
pub attached: bool,
|
||||
/// Whether the link is administratively up.
|
||||
pub up: bool,
|
||||
/// The current MTU.
|
||||
pub mtu: u32,
|
||||
/// The addresses currently assigned.
|
||||
pub addresses: Vec<Cidr>,
|
||||
}
|
||||
|
||||
impl InterfaceState {
|
||||
/// The state of a name nothing is using.
|
||||
pub fn absent() -> Self {
|
||||
Self {
|
||||
kind: LinkKind::Absent,
|
||||
attached: false,
|
||||
up: false,
|
||||
mtu: 0,
|
||||
addresses: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The steps that turn an [`InterfaceState`] into an [`InterfacePlan`].
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Changes {
|
||||
/// Remove a stale interface left behind by an earlier run.
|
||||
pub delete_link: bool,
|
||||
/// Create the interface.
|
||||
pub create_link: bool,
|
||||
/// Set the MTU, when it is not already right.
|
||||
pub set_mtu: Option<u32>,
|
||||
/// Bring the link up.
|
||||
pub bring_up: bool,
|
||||
/// Addresses to add.
|
||||
pub add: Vec<Cidr>,
|
||||
/// Addresses to remove, because the plan no longer contains them.
|
||||
pub remove: Vec<Cidr>,
|
||||
}
|
||||
|
||||
impl Changes {
|
||||
/// Whether anything at all needs doing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
!self.delete_link
|
||||
&& !self.create_link
|
||||
&& self.set_mtu.is_none()
|
||||
&& !self.bring_up
|
||||
&& self.add.is_empty()
|
||||
&& self.remove.is_empty()
|
||||
}
|
||||
|
||||
/// A one-line summary for a log line.
|
||||
pub fn summary(&self) -> String {
|
||||
if self.is_empty() {
|
||||
return "already as planned".to_string();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
if self.delete_link {
|
||||
parts.push("remove a stale interface".to_string());
|
||||
}
|
||||
if self.create_link {
|
||||
parts.push("create the interface".to_string());
|
||||
}
|
||||
if let Some(mtu) = self.set_mtu {
|
||||
parts.push(format!("set mtu {mtu}"));
|
||||
}
|
||||
if self.bring_up {
|
||||
parts.push("bring it up".to_string());
|
||||
}
|
||||
for address in &self.add {
|
||||
parts.push(format!("add {address}"));
|
||||
}
|
||||
for address in &self.remove {
|
||||
parts.push(format!("remove {address}"));
|
||||
}
|
||||
parts.join(", ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides what to change, or refuses.
|
||||
///
|
||||
/// `ours` says whether this process created the interface in its current run.
|
||||
/// It is the whole reason this can be safe: an interface we made is adjusted
|
||||
/// in place, and an interface we did not make is only ever *replaced* when it
|
||||
/// is plainly abandoned.
|
||||
///
|
||||
/// The refusals matter more than the changes:
|
||||
///
|
||||
/// * A link that is not a TUN is never touched. Deriving the name from the
|
||||
/// network id makes a collision with a real device unlikely, not impossible,
|
||||
/// and destroying somebody's bridge because it happened to share a name is
|
||||
/// not a recoverable mistake.
|
||||
/// * A TUN with a process attached to it is never deleted. It is somebody
|
||||
/// else's working interface — most likely another agent on this host — and
|
||||
/// yanking it out from under them would break a running overlay.
|
||||
///
|
||||
/// What is left is a TUN with nothing attached, which is precisely the
|
||||
/// footprint of a run that died: those are removed and rebuilt.
|
||||
pub fn plan_changes(
|
||||
current: &InterfaceState,
|
||||
plan: &InterfacePlan,
|
||||
ours: bool,
|
||||
) -> Result<Changes, PluginError> {
|
||||
let mut changes = Changes::default();
|
||||
|
||||
match ¤t.kind {
|
||||
LinkKind::Foreign(kind) => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
"`{}` already exists and is a {kind} interface, not one of ours. \
|
||||
Refusing to touch it. Run with a different interface prefix.",
|
||||
plan.name
|
||||
)));
|
||||
}
|
||||
LinkKind::Tun if !ours && current.attached => {
|
||||
return Err(PluginError::Unavailable(format!(
|
||||
"`{}` already exists and another process is attached to it. \
|
||||
That is most likely a second agent on this host in the same \
|
||||
network; give one of them a different interface prefix.",
|
||||
plan.name
|
||||
)));
|
||||
}
|
||||
LinkKind::Tun if !ours => {
|
||||
// Abandoned: a TUN with no carrier is one nothing holds open. It
|
||||
// is either a leftover from a run that died or an interface made
|
||||
// by the old manual recipe. Either way it is replaced, which also
|
||||
// discards whatever stale addresses it carried.
|
||||
changes.delete_link = true;
|
||||
changes.create_link = true;
|
||||
}
|
||||
LinkKind::Tun => {}
|
||||
LinkKind::Absent => changes.create_link = true,
|
||||
}
|
||||
|
||||
if changes.create_link {
|
||||
// A fresh interface starts down, with the kernel default MTU and no
|
||||
// addresses, so everything in the plan has to be applied.
|
||||
changes.set_mtu = Some(plan.mtu);
|
||||
changes.bring_up = true;
|
||||
changes.add = plan.addresses.clone();
|
||||
return Ok(changes);
|
||||
}
|
||||
|
||||
if current.mtu != plan.mtu {
|
||||
changes.set_mtu = Some(plan.mtu);
|
||||
}
|
||||
if !current.up {
|
||||
changes.bring_up = true;
|
||||
}
|
||||
|
||||
let wanted: BTreeSet<Cidr> = plan.addresses.iter().copied().collect();
|
||||
let present: BTreeSet<Cidr> = current.addresses.iter().copied().collect();
|
||||
changes.add = wanted.difference(&present).copied().collect();
|
||||
changes.remove = present.difference(&wanted).copied().collect();
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// The result of a reconciliation.
|
||||
pub struct Provisioned {
|
||||
/// What was changed to get here.
|
||||
pub changes: Changes,
|
||||
/// The packet interface, when this call is what created it.
|
||||
///
|
||||
/// Creating the interface and configuring it are the same privileged act
|
||||
/// and belong together, so the provisioner owns both. A reconciliation
|
||||
/// that only adjusted an interface already in place returns `None`.
|
||||
pub device: Option<Arc<dyn TunDevice>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Provisioned {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Provisioned")
|
||||
.field("changes", &self.changes)
|
||||
.field("device", &self.device.as_ref().map(|device| device.name()))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies an [`InterfacePlan`] to the host.
|
||||
///
|
||||
/// Implementations are expected to be idempotent: calling [`reconcile`] twice
|
||||
/// with the same plan changes nothing the second time.
|
||||
///
|
||||
/// [`reconcile`]: InterfaceProvisioner::reconcile
|
||||
pub trait InterfaceProvisioner: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Brings the interface in line with the plan, and reports what it did.
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>>;
|
||||
|
||||
/// Removes an interface this provisioner created.
|
||||
///
|
||||
/// Removing one that is already gone succeeds: this runs on the shutdown
|
||||
/// path, where the interface having vanished is the desired outcome.
|
||||
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), PluginError>>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
fn v6(last: u16) -> Cidr {
|
||||
Cidr {
|
||||
addr: IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
||||
prefix_len: 64,
|
||||
}
|
||||
}
|
||||
|
||||
fn v4(last: u8) -> Cidr {
|
||||
Cidr {
|
||||
addr: IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)),
|
||||
prefix_len: 24,
|
||||
}
|
||||
}
|
||||
|
||||
fn plan() -> InterfacePlan {
|
||||
InterfacePlan::new("tsuntest", 1280, vec![v6(1), v4(69)])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_interface_is_created_and_fully_configured() {
|
||||
let changes = plan_changes(&InterfaceState::absent(), &plan(), false).unwrap();
|
||||
assert!(changes.create_link);
|
||||
assert!(!changes.delete_link);
|
||||
assert_eq!(changes.set_mtu, Some(1280));
|
||||
assert!(changes.bring_up);
|
||||
assert_eq!(changes.add, vec![v4(69), v6(1)]);
|
||||
assert!(changes.remove.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_abandoned_tun_from_a_crashed_run_is_replaced() {
|
||||
// The footprint of a run that died: the interface is still there, it
|
||||
// still has its old addresses, and nothing holds it open.
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: false,
|
||||
up: true,
|
||||
mtu: 1280,
|
||||
addresses: vec![v4(178)],
|
||||
};
|
||||
let changes = plan_changes(¤t, &plan(), false).unwrap();
|
||||
assert!(changes.delete_link, "the stale interface goes");
|
||||
assert!(changes.create_link);
|
||||
// Replacing it discards the stale address, so it need not be removed
|
||||
// one by one.
|
||||
assert_eq!(changes.add, vec![v4(69), v6(1)]);
|
||||
assert!(changes.remove.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_foreign_interface_is_never_touched() {
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Foreign("bridge".into()),
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1500,
|
||||
addresses: vec![v4(1)],
|
||||
};
|
||||
let err = plan_changes(¤t, &plan(), false).unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("bridge"), "{message}");
|
||||
assert!(message.contains("Refusing to touch it"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_tun_another_process_is_using_is_never_deleted() {
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1280,
|
||||
addresses: vec![v6(1)],
|
||||
};
|
||||
let err = plan_changes(¤t, &plan(), false).unwrap_err();
|
||||
assert!(err.to_string().contains("another process"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn our_own_interface_is_adjusted_in_place() {
|
||||
// The live case: our address allocation changed while running. The
|
||||
// interface must not be recreated, or every tunnel on it would drop.
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1280,
|
||||
addresses: vec![v6(1), v4(178)],
|
||||
};
|
||||
let changes = plan_changes(¤t, &plan(), true).unwrap();
|
||||
assert!(!changes.delete_link && !changes.create_link);
|
||||
assert_eq!(changes.add, vec![v4(69)]);
|
||||
assert_eq!(changes.remove, vec![v4(178)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconciling_an_interface_that_already_matches_changes_nothing() {
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1280,
|
||||
addresses: vec![v4(69), v6(1)],
|
||||
};
|
||||
let changes = plan_changes(¤t, &plan(), true).unwrap();
|
||||
assert!(changes.is_empty(), "{changes:?}");
|
||||
assert_eq!(changes.summary(), "already as planned");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_wrong_mtu_or_a_down_link_is_corrected() {
|
||||
let current = InterfaceState {
|
||||
kind: LinkKind::Tun,
|
||||
attached: true,
|
||||
up: false,
|
||||
mtu: 1500,
|
||||
addresses: vec![v4(69), v6(1)],
|
||||
};
|
||||
let changes = plan_changes(¤t, &plan(), true).unwrap();
|
||||
assert_eq!(changes.set_mtu, Some(1280));
|
||||
assert!(changes.bring_up);
|
||||
assert!(changes.add.is_empty() && changes.remove.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plan_deduplicates_and_orders_its_addresses() {
|
||||
let plan = InterfacePlan::new("x", 1280, vec![v6(1), v4(69), v6(1)]);
|
||||
assert_eq!(plan.addresses, vec![v4(69), v6(1)]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Holding `CAP_NET_ADMIN` for as short a time as possible.
|
||||
//!
|
||||
//! Creating a TUN interface and assigning addresses to it needs
|
||||
//! `CAP_NET_ADMIN`, and there is no way around that on Linux. What *is* in
|
||||
//! our control is how long the process can actually use it.
|
||||
//!
|
||||
//! Linux splits capabilities into sets. The *permitted* set is what a process
|
||||
//! may use; the *effective* set is what it may use **right now**. A process
|
||||
//! can lower a capability out of effective and raise it back later, but it
|
||||
//! can never add to permitted. So the agent keeps `CAP_NET_ADMIN` out of the
|
||||
//! effective set and raises it only around the handful of netlink calls that
|
||||
//! need it, which is a few milliseconds at startup and again whenever the
|
||||
//! address allocation changes.
|
||||
//!
|
||||
//! Grant it with:
|
||||
//!
|
||||
//! ```text
|
||||
//! sudo setcap cap_net_admin+p /usr/local/bin/tsunagi
|
||||
//! ```
|
||||
//!
|
||||
//! `+p` rather than `+ep`: with `+p` the capability is permitted but not
|
||||
//! effective at exec, which is exactly the resting state this module wants.
|
||||
//! `+ep` also works — [`NetAdmin::acquire`] lowers it on the way in.
|
||||
//!
|
||||
//! # Capabilities are per thread
|
||||
//!
|
||||
//! `capset` affects the calling thread only, so raising one inside an async
|
||||
//! block would be a bug the moment the task migrated to another worker. Every
|
||||
//! caller here runs on a single dedicated thread; see
|
||||
//! [`linux`](super::linux).
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Privilege {
|
||||
/// `CAP_NET_ADMIN` is available. The agent manages the interface itself.
|
||||
Available,
|
||||
/// It is not, with a description of what was found.
|
||||
Missing(String),
|
||||
/// This platform has no provisioner yet.
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
impl Privilege {
|
||||
/// Whether interfaces can be managed.
|
||||
pub fn is_available(&self) -> bool {
|
||||
matches!(self, Privilege::Available)
|
||||
}
|
||||
|
||||
/// How to obtain it, for a diagnostic.
|
||||
pub fn how_to_grant(program: &str) -> String {
|
||||
format!(
|
||||
"Grant it once with `sudo setcap cap_net_admin+p {program}` \
|
||||
and the agent manages its own interface. Without it, run with \
|
||||
`--no-tun`: the tunnels still form, they just do not reach the \
|
||||
operating system."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
pub use linux_impl::{NetAdmin, probe_net_admin};
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
pub use other_impl::probe_net_admin;
|
||||
|
||||
#[cfg(not(all(feature = "tun-device", target_os = "linux")))]
|
||||
mod other_impl {
|
||||
use super::Privilege;
|
||||
|
||||
/// Whether this process can configure interfaces.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
Privilege::Unsupported
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||
mod linux_impl {
|
||||
use caps::{CapSet, Capability};
|
||||
|
||||
use super::Privilege;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
/// Whether this thread holds `CAP_NET_ADMIN` in its permitted set.
|
||||
pub fn probe_net_admin() -> Privilege {
|
||||
match caps::has_cap(None, CapSet::Permitted, Capability::CAP_NET_ADMIN) {
|
||||
Ok(true) => Privilege::Available,
|
||||
Ok(false) => Privilege::Missing(
|
||||
"this process does not hold CAP_NET_ADMIN, so it cannot create \
|
||||
or configure a network interface"
|
||||
.to_string(),
|
||||
),
|
||||
Err(err) => {
|
||||
Privilege::Missing(format!("cannot read this process's capabilities: {err}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `CAP_NET_ADMIN`, raised for as long as this value is alive.
|
||||
///
|
||||
/// Dropping it lowers the capability again, including on the error paths,
|
||||
/// which is the point of it being a guard rather than a pair of calls.
|
||||
#[derive(Debug)]
|
||||
pub struct NetAdmin {
|
||||
/// Whether this guard is the one that raised it, and so the one that
|
||||
/// must lower it. Nested acquisition leaves the inner guard inert.
|
||||
raised: bool,
|
||||
}
|
||||
|
||||
impl NetAdmin {
|
||||
/// Raises `CAP_NET_ADMIN` into the effective set.
|
||||
pub fn acquire() -> Result<Self, PluginError> {
|
||||
let already = caps::has_cap(None, CapSet::Effective, Capability::CAP_NET_ADMIN)
|
||||
.map_err(|err| {
|
||||
PluginError::Unavailable(format!("cannot read capabilities: {err}"))
|
||||
})?;
|
||||
if already {
|
||||
return Ok(Self { raised: false });
|
||||
}
|
||||
caps::raise(None, CapSet::Effective, Capability::CAP_NET_ADMIN).map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
"cannot raise CAP_NET_ADMIN: {err}. {}",
|
||||
Privilege::how_to_grant("tsunagi")
|
||||
))
|
||||
})?;
|
||||
Ok(Self { raised: true })
|
||||
}
|
||||
|
||||
/// Lowers `CAP_NET_ADMIN` out of the effective set of this thread.
|
||||
///
|
||||
/// Called on the way in as well as on the way out, so that a binary
|
||||
/// granted `cap_net_admin+ep` — which starts with it effective — still
|
||||
/// spends almost all of its life unable to use it.
|
||||
pub fn lower() {
|
||||
let _ = caps::drop(None, CapSet::Effective, Capability::CAP_NET_ADMIN);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for NetAdmin {
|
||||
fn drop(&mut self) {
|
||||
if self.raised {
|
||||
Self::lower();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_grant_instructions_name_the_program() {
|
||||
let text = Privilege::how_to_grant("/usr/local/bin/tsunagi");
|
||||
assert!(text.contains("setcap cap_net_admin+p /usr/local/bin/tsunagi"));
|
||||
assert!(text.contains("--no-tun"), "the fallback is offered too");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probing_says_something_definite_about_this_host() {
|
||||
// Whatever the answer is, it must be one of the three, and a missing
|
||||
// capability must come with a reason rather than a bare `false`.
|
||||
match probe_net_admin() {
|
||||
Privilege::Available => {}
|
||||
Privilege::Missing(reason) => assert!(!reason.is_empty()),
|
||||
Privilege::Unsupported => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! The provisioner for platforms that do not have one yet.
|
||||
//!
|
||||
//! macOS and Windows both need real work here — `utun` plus the
|
||||
//! `SystemConfiguration` framework on one, the IP Helper API and a Wintun
|
||||
//! adapter on the other — and neither is written. Rather than let the agent
|
||||
//! come up and fail obscurely at the first packet, this refuses at the point
|
||||
//! of provisioning and says what to do instead.
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
use super::{InterfacePlan, InterfaceProvisioner, Provisioned};
|
||||
|
||||
/// Refuses to provision, with an explanation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsupportedProvisioner {
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
impl Default for UnsupportedProvisioner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnsupportedProvisioner {
|
||||
/// A provisioner naming the platform it is standing in for.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
platform: std::env::consts::OS,
|
||||
}
|
||||
}
|
||||
|
||||
fn refusal(&self) -> PluginError {
|
||||
PluginError::Unavailable(format!(
|
||||
"managing the overlay interface is not implemented on {} yet. \
|
||||
Run with `--no-tun` until it is: the tunnels still form, they just \
|
||||
do not reach the operating system.",
|
||||
self.platform
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl InterfaceProvisioner for UnsupportedProvisioner {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn reconcile<'a>(
|
||||
&'a self,
|
||||
_plan: &'a InterfacePlan,
|
||||
) -> BoxFuture<'a, Result<Provisioned, PluginError>> {
|
||||
Box::pin(async move { Err(self.refusal()) })
|
||||
}
|
||||
|
||||
fn remove<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
// Nothing was ever created, so there is nothing to clean up and no
|
||||
// reason to fail a shutdown path.
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_refuses_with_a_usable_pointer_and_still_cleans_up_quietly() {
|
||||
let provisioner = UnsupportedProvisioner::new();
|
||||
let plan = InterfacePlan::new("tsuntest", 1280, Vec::new());
|
||||
let err = provisioner.reconcile(&plan).await.unwrap_err();
|
||||
let message = err.to_string();
|
||||
assert!(message.contains("--no-tun"), "{message}");
|
||||
assert!(message.contains(std::env::consts::OS), "{message}");
|
||||
|
||||
provisioner.remove("tsuntest").await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
//! The plugin's own key store.
|
||||
//!
|
||||
//! Deliberately a separate SQLite file from the agent's `state.sqlite`: plugin
|
||||
//! keys are not the iroh identity and not the network secret, and their
|
||||
//! lifecycle is the plugin's business alone.
|
||||
//!
|
||||
//! One key per network, so a participant presents a different WireGuard
|
||||
//! identity — and therefore a different overlay address — in each network it
|
||||
//! belongs to.
|
||||
//!
|
||||
//! A damaged key store is an error, never a silent regeneration: a new key
|
||||
//! would silently move this agent to a different overlay address and orphan
|
||||
//! every peer's configuration.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::dataplane::PluginError;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
use super::keys::{KEY_LEN, WgSecretKey};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// Per-network WireGuard private keys.
|
||||
#[derive(Debug)]
|
||||
pub struct WgKeyStore {
|
||||
conn: Mutex<Connection>,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl WgKeyStore {
|
||||
/// Opens, creating the file and its directory if needed.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, PluginError> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create {parent:?}: {err}")))?;
|
||||
}
|
||||
let existed = path.exists();
|
||||
let conn = Connection::open(&path).map_err(|err| {
|
||||
PluginError::Other(format!("cannot open the WireGuard key store: {err}"))
|
||||
})?;
|
||||
crate::storage::restrict_path_permissions(&path)
|
||||
.map_err(|err| PluginError::Other(format!("cannot secure the key store: {err}")))?;
|
||||
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))
|
||||
.and_then(|()| conn.pragma_update(None, "journal_mode", "WAL"))
|
||||
.and_then(|()| conn.pragma_update(None, "synchronous", "NORMAL"))
|
||||
.map_err(|err| PluginError::Other(format!("cannot configure the key store: {err}")))?;
|
||||
|
||||
if existed {
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.map_err(|err| {
|
||||
PluginError::Other(format!("WireGuard key store is unusable: {err}"))
|
||||
})?;
|
||||
if integrity != "ok" {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store at {} is corrupt and will not be recreated: {integrity}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let found: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the schema version: {err}")))?;
|
||||
if found > SCHEMA_VERSION {
|
||||
return Err(PluginError::Other(format!(
|
||||
"WireGuard key store schema {found} is newer than {SCHEMA_VERSION}"
|
||||
)));
|
||||
}
|
||||
if found < SCHEMA_VERSION {
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS network_keys (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
secret BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot create the schema: {err}")))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the underlying file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||
match self.conn.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns this agent's key for a network, creating it on first use.
|
||||
pub fn load_or_create(&self, network: NetworkId) -> Result<WgSecretKey, PluginError> {
|
||||
let conn = self.lock();
|
||||
let stored: Option<Vec<u8>> = conn
|
||||
.query_row(
|
||||
"SELECT secret FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| PluginError::Other(format!("cannot read the WireGuard key: {err}")))?;
|
||||
|
||||
if let Some(bytes) = stored {
|
||||
let bytes = <[u8; KEY_LEN]>::try_from(bytes.as_slice()).map_err(|_| {
|
||||
PluginError::Other(format!(
|
||||
"the stored WireGuard key for network {} is not {KEY_LEN} bytes; \
|
||||
refusing to replace it",
|
||||
network.fmt_short()
|
||||
))
|
||||
})?;
|
||||
return Ok(WgSecretKey::from_bytes(&bytes));
|
||||
}
|
||||
|
||||
let key = WgSecretKey::generate();
|
||||
conn.execute(
|
||||
"INSERT INTO network_keys (network_id, secret, created_at) VALUES (?1, ?2, ?3)",
|
||||
params![
|
||||
network.as_bytes().as_slice(),
|
||||
key.expose().as_slice(),
|
||||
now_unix()
|
||||
],
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot store the WireGuard key: {err}")))?;
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Deletes the key for a network.
|
||||
///
|
||||
/// Not called when a network is merely deactivated: coming back should
|
||||
/// keep the same overlay address.
|
||||
pub fn forget(&self, network: NetworkId) -> Result<(), PluginError> {
|
||||
self.lock()
|
||||
.execute(
|
||||
"DELETE FROM network_keys WHERE network_id = ?1",
|
||||
params![network.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| PluginError::Other(format!("cannot remove the WireGuard key: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![8u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keys_are_per_network_and_survive_reopening() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("wireguard.sqlite");
|
||||
let first = network("one");
|
||||
let second = network("two");
|
||||
|
||||
let (key_one, key_two) = {
|
||||
let store = WgKeyStore::open(&path).unwrap();
|
||||
let a = store.load_or_create(first).unwrap();
|
||||
let b = store.load_or_create(second).unwrap();
|
||||
assert_ne!(a.public(), b.public(), "networks get separate identities");
|
||||
assert_eq!(a.public(), store.load_or_create(first).unwrap().public());
|
||||
(a.public(), b.public())
|
||||
};
|
||||
|
||||
let reopened = WgKeyStore::open(&path).unwrap();
|
||||
assert_eq!(reopened.load_or_create(first).unwrap().public(), key_one);
|
||||
assert_eq!(reopened.load_or_create(second).unwrap().public(), key_two);
|
||||
|
||||
reopened.forget(first).unwrap();
|
||||
assert_ne!(reopened.load_or_create(first).unwrap().public(), key_one);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_key_store_is_an_error_not_a_new_key() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let path = dir.path().join("wireguard.sqlite");
|
||||
let original = {
|
||||
let store = WgKeyStore::open(&path).unwrap();
|
||||
store.load_or_create(network("keep")).unwrap().public()
|
||||
};
|
||||
|
||||
std::fs::write(&path, [0x5a; 4096]).unwrap();
|
||||
let result = WgKeyStore::open(&path);
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"a damaged key store must not silently mint a new identity (was {original})"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
//! The boundary to the operating system's packet interface.
|
||||
//!
|
||||
//! The WireGuard implementation in [`super::device`] is pure userspace and
|
||||
//! needs no kernel WireGuard module and no `wg` tool. It does still need a way
|
||||
//! to hand IP packets to the operating system, which is what this trait is.
|
||||
//!
|
||||
//! Two implementations:
|
||||
//!
|
||||
//! * [`MemoryTun`] keeps packets in memory. It needs no privileges at all and
|
||||
//! is what the test suite uses, so the entire data plane — handshake,
|
||||
//! encryption, routing — is exercised without touching the host.
|
||||
//! * `SystemTun`, behind the `tun-device` feature, is a real TUN interface.
|
||||
//! Creating one needs `CAP_NET_ADMIN`, and it is
|
||||
//! [`provision`](super::provision) that holds that and creates it.
|
||||
|
||||
use std::net::Ipv6Addr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
/// What a device should look like once created.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TunRequest {
|
||||
/// Interface name to ask for.
|
||||
pub name: String,
|
||||
/// The overlay address this host answers to.
|
||||
pub address: Ipv6Addr,
|
||||
/// Prefix length of the overlay subnet, so the OS routes it here.
|
||||
pub prefix_len: u8,
|
||||
/// The IPv4 overlay address this host answers to, when dual stack.
|
||||
pub address_v4: Option<std::net::Ipv4Addr>,
|
||||
/// Prefix length of the IPv4 overlay range.
|
||||
pub prefix_len_v4: u8,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
}
|
||||
|
||||
impl TunRequest {
|
||||
/// A request carrying nothing but a name and an MTU.
|
||||
///
|
||||
/// Used where the addresses have already been applied to the host, so the
|
||||
/// device itself only needs opening.
|
||||
pub fn bare(name: impl Into<String>, mtu: u32) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
address: Ipv6Addr::UNSPECIFIED,
|
||||
prefix_len: 0,
|
||||
address_v4: None,
|
||||
prefix_len_v4: 0,
|
||||
mtu,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A packet interface.
|
||||
///
|
||||
/// `recv` yields packets the operating system wants sent; `send` delivers
|
||||
/// packets that arrived from a peer.
|
||||
pub trait TunDevice: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// The interface name the operating system actually gave us.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// The interface MTU.
|
||||
fn mtu(&self) -> u32;
|
||||
|
||||
/// The next packet the operating system wants to send, or `None` once the
|
||||
/// device is gone.
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>>;
|
||||
|
||||
/// Delivers a packet to the operating system.
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), PluginError>>;
|
||||
}
|
||||
|
||||
/// Creates packet interfaces.
|
||||
pub trait TunFactory: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Creates a device.
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>>;
|
||||
|
||||
/// Applies a changed request to an interface that already exists.
|
||||
///
|
||||
/// The overlay IPv4 address is allocated at run time, so it can change
|
||||
/// while the agent runs. A factory that manages the host applies that to
|
||||
/// the live interface, without recreating it: recreating would drop every
|
||||
/// tunnel riding on it.
|
||||
///
|
||||
/// The default does nothing, which is right for a factory that only
|
||||
/// attaches to an interface somebody else prepared.
|
||||
fn reconfigure<'a>(&'a self, _request: TunRequest) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
|
||||
/// Removes an interface this factory created.
|
||||
///
|
||||
/// Runs on the teardown path, so it reports rather than fails: there is
|
||||
/// nothing useful to do about a failure at that point, and an interface
|
||||
/// that is already gone is the desired outcome anyway.
|
||||
fn destroy<'a>(&'a self, _name: &'a str) -> BoxFuture<'a, ()> {
|
||||
Box::pin(async move {})
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-memory packet interface.
|
||||
///
|
||||
/// Nothing reaches the operating system. Packets the device "sends" can be
|
||||
/// read back with [`MemoryTun::pop_to_os`], and packets can be injected as if
|
||||
/// the operating system produced them with [`MemoryTun::push_from_os`].
|
||||
#[derive(Debug)]
|
||||
pub struct MemoryTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
from_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
from_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
to_os_tx: tokio::sync::mpsc::UnboundedSender<Bytes>,
|
||||
to_os_rx: tokio::sync::Mutex<tokio::sync::mpsc::UnboundedReceiver<Bytes>>,
|
||||
}
|
||||
|
||||
impl MemoryTun {
|
||||
/// Creates a device with the given name and MTU.
|
||||
pub fn new(name: impl Into<String>, mtu: u32) -> Arc<Self> {
|
||||
let (from_os_tx, from_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let (to_os_tx, to_os_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
Arc::new(Self {
|
||||
name: name.into(),
|
||||
mtu,
|
||||
from_os_tx,
|
||||
from_os_rx: tokio::sync::Mutex::new(from_os_rx),
|
||||
to_os_tx,
|
||||
to_os_rx: tokio::sync::Mutex::new(to_os_rx),
|
||||
})
|
||||
}
|
||||
|
||||
/// Injects a packet as if the operating system had produced it.
|
||||
pub fn push_from_os(&self, packet: Bytes) {
|
||||
let _ = self.from_os_tx.send(packet);
|
||||
}
|
||||
|
||||
/// Takes the next packet the device delivered to the operating system.
|
||||
pub async fn pop_to_os(&self) -> Option<Bytes> {
|
||||
self.to_os_rx.lock().await.recv().await
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for MemoryTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move { self.from_os_rx.lock().await.recv().await })
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move {
|
||||
let _ = self.to_os_tx.send(packet);
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an address is assigned to some interface on this host.
|
||||
///
|
||||
/// Binding a UDP socket to a specific address only succeeds when the address
|
||||
/// is local, which makes this a cheap check that needs no privileges and no
|
||||
/// platform-specific code. It does not say *which* interface has it, which is
|
||||
/// enough here: the agent chose the address, so anything else holding it is a
|
||||
/// problem in its own right.
|
||||
pub fn address_is_local(address: std::net::IpAddr) -> bool {
|
||||
std::net::UdpSocket::bind((address, 0)).is_ok()
|
||||
}
|
||||
|
||||
/// Creates [`MemoryTun`] devices.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MemoryTunFactory {
|
||||
created: Arc<std::sync::Mutex<Vec<Arc<MemoryTun>>>>,
|
||||
}
|
||||
|
||||
impl MemoryTunFactory {
|
||||
/// Creates a factory.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// The device created for an interface name, if any.
|
||||
pub fn device(&self, name: &str) -> Option<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard
|
||||
.iter()
|
||||
.find(|device| device.name() == name)
|
||||
.map(Arc::clone)
|
||||
}
|
||||
|
||||
/// Every device created so far.
|
||||
pub fn devices(&self) -> Vec<Arc<MemoryTun>> {
|
||||
let guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunFactory for MemoryTunFactory {
|
||||
fn name(&self) -> &str {
|
||||
"memory"
|
||||
}
|
||||
|
||||
fn create<'a>(
|
||||
&'a self,
|
||||
request: TunRequest,
|
||||
) -> BoxFuture<'a, Result<Arc<dyn TunDevice>, PluginError>> {
|
||||
Box::pin(async move {
|
||||
let device = MemoryTun::new(request.name, request.mtu);
|
||||
let mut guard = match self.created.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.push(Arc::clone(&device));
|
||||
Ok(device as Arc<dyn TunDevice>)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
pub(crate) use system::open_tun;
|
||||
|
||||
#[cfg(feature = "tun-device")]
|
||||
mod system {
|
||||
use std::sync::Arc;
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::{TunDevice, TunRequest};
|
||||
use crate::BoxFuture;
|
||||
use crate::dataplane::PluginError;
|
||||
|
||||
/// A real TUN interface.
|
||||
///
|
||||
/// Created by opening `/dev/net/tun`, which needs `CAP_NET_ADMIN` and is
|
||||
/// why [`open_tun`] is only ever called from
|
||||
/// [`provision`](super::super::provision), where that capability is
|
||||
/// raised for the length of the call and no longer.
|
||||
///
|
||||
/// It is deliberately **not** made persistent, so the kernel removes the
|
||||
/// interface when this value is dropped — however the process ends.
|
||||
pub struct SystemTun {
|
||||
name: String,
|
||||
mtu: u32,
|
||||
reader: Mutex<tokio::io::ReadHalf<tun::AsyncDevice>>,
|
||||
writer: Mutex<tokio::io::WriteHalf<tun::AsyncDevice>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for SystemTun {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("SystemTun")
|
||||
.field("name", &self.name)
|
||||
.field("mtu", &self.mtu)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl TunDevice for SystemTun {
|
||||
fn name(&self) -> &str {
|
||||
&self.name
|
||||
}
|
||||
|
||||
fn mtu(&self) -> u32 {
|
||||
self.mtu
|
||||
}
|
||||
|
||||
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncReadExt;
|
||||
let mut buffer = vec![0u8; self.mtu as usize + 64];
|
||||
let mut reader = self.reader.lock().await;
|
||||
match reader.read(&mut buffer).await {
|
||||
Ok(0) => None,
|
||||
Ok(read) => {
|
||||
buffer.truncate(read);
|
||||
Some(Bytes::from(buffer))
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "tun read failed");
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send<'a>(&'a self, packet: Bytes) -> BoxFuture<'a, Result<(), PluginError>> {
|
||||
Box::pin(async move {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let mut writer = self.writer.lock().await;
|
||||
writer
|
||||
.write_all(&packet)
|
||||
.await
|
||||
.map_err(|err| PluginError::Other(format!("tun write failed: {err}")))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates the TUN interface by opening it.
|
||||
///
|
||||
/// Synchronous, and deliberately so: the caller holds a capability guard
|
||||
/// across this call, and such a guard must not span an `await` because
|
||||
/// Linux capabilities are per thread.
|
||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, PluginError> {
|
||||
let mut config = tun::Configuration::default();
|
||||
config.tun_name(&request.name);
|
||||
config.platform_config(|platform| {
|
||||
// The crate's own root check is not the check we want: this holds
|
||||
// CAP_NET_ADMIN without being root. Whether the open succeeds is
|
||||
// the honest answer.
|
||||
platform.ensure_root_privileges(false);
|
||||
});
|
||||
// Packet information stays off, so reads and writes are raw IP
|
||||
// packets. `ip tuntap add ... mode tun` also defaults to no packet
|
||||
// information, so the flags match when attaching to one.
|
||||
|
||||
let device = tun::create_as_async(&config).map_err(|err| {
|
||||
PluginError::Unavailable(format!(
|
||||
"cannot create the TUN interface `{}`: {err}. Creating one needs \
|
||||
CAP_NET_ADMIN; grant it with `setcap cap_net_admin+p`, or run with \
|
||||
`--no-tun` to keep the tunnels off the operating system.",
|
||||
request.name
|
||||
))
|
||||
})?;
|
||||
|
||||
let (reader, writer) = tokio::io::split(device);
|
||||
Ok(Arc::new(SystemTun {
|
||||
name: request.name.clone(),
|
||||
mtu: request.mtu,
|
||||
reader: Mutex::new(reader),
|
||||
writer: Mutex::new(writer),
|
||||
}) as Arc<dyn TunDevice>)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
//! Finding *candidates*, and nothing more.
|
||||
//!
|
||||
//! Discovery answers one question: "which iroh endpoints might currently be
|
||||
//! participating in the network behind this [`DiscoveryKey`], and at which
|
||||
//! addresses?". Its answers are **unverified candidates**. Membership is decided
|
||||
//! later, by the control protocol handshake in [`crate::proto::handshake`].
|
||||
//!
|
||||
//! A discovery backend must not carry control messages between agents, must not
|
||||
//! confirm authentication and must not mutate agent state directly.
|
||||
//!
|
||||
//! Two concerns are kept apart, and only the first one is this module's:
|
||||
//!
|
||||
//! * *Finding members of a network* — [`NetworkDiscovery::resolve`], keyed by
|
||||
//! the secret-derived [`DiscoveryKey`]. That is what lives here, and today
|
||||
//! it is [`StaticBootstrap`] plus a test backend; a DHT backend is future
|
||||
//! work.
|
||||
//! * *Resolving the address of one iroh endpoint* — **iroh's job, not ours**.
|
||||
//! With [`crate::config::TransportPolicy::N0Defaults`] or `DirectOnly`, iroh
|
||||
//! publishes and resolves endpoint addresses through Number 0's public
|
||||
//! service, so dialling a bare [`EndpointId`] works. With `LocalOnly` there
|
||||
//! is no lookup, and a candidate must carry addresses of its own.
|
||||
//!
|
||||
//! No empty result ever proves a network is empty. It only means "nobody found
|
||||
//! yet".
|
||||
//!
|
||||
//! Mainline DHT discovery is future work and is not implemented here.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use iroh::{EndpointAddr, EndpointId};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::identity::DiscoveryKey;
|
||||
|
||||
pub use crate::BoxFuture;
|
||||
|
||||
/// Where a candidate came from. Purely informational.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum CandidateSource {
|
||||
/// A statically configured bootstrap entry.
|
||||
Bootstrap,
|
||||
/// A discovery backend lookup.
|
||||
Discovery,
|
||||
/// An address hint restored from the disposable cache.
|
||||
Cache,
|
||||
}
|
||||
|
||||
/// An unverified candidate peer.
|
||||
///
|
||||
/// Holding one grants nothing: the peer still has to pass the handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Candidate {
|
||||
/// iroh address of the candidate, including whatever addressing info exists.
|
||||
pub addr: EndpointAddr,
|
||||
/// Where this candidate came from.
|
||||
pub source: CandidateSource,
|
||||
}
|
||||
|
||||
impl Candidate {
|
||||
/// Creates a candidate.
|
||||
pub fn new(addr: EndpointAddr, source: CandidateSource) -> Self {
|
||||
Self { addr, source }
|
||||
}
|
||||
|
||||
/// The candidate's endpoint id.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.addr.id
|
||||
}
|
||||
}
|
||||
|
||||
/// A replaceable source of candidates.
|
||||
///
|
||||
/// Implementations must be cheap to clone behind an [`Arc`] and must never
|
||||
/// block the async executor.
|
||||
pub trait NetworkDiscovery: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Publishes this agent's address under `key`.
|
||||
///
|
||||
/// Backends that cannot publish (static bootstrap lists) return `Ok(())`.
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>>;
|
||||
|
||||
/// Withdraws a previously published address.
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>>;
|
||||
|
||||
/// Returns the candidates currently known for `key`.
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>>;
|
||||
}
|
||||
|
||||
/// A statically configured list of bootstrap candidates.
|
||||
///
|
||||
/// Each entry must carry enough addressing information to be dialled, i.e. an
|
||||
/// iroh endpoint id plus direct addresses or a relay URL, unless iroh's own
|
||||
/// address lookup is enabled in [`crate::config::TransportPolicy`].
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct StaticBootstrap {
|
||||
entries: Vec<EndpointAddr>,
|
||||
}
|
||||
|
||||
impl StaticBootstrap {
|
||||
/// Creates a bootstrap list.
|
||||
pub fn new(entries: impl IntoIterator<Item = EndpointAddr>) -> Self {
|
||||
Self {
|
||||
entries: entries.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for StaticBootstrap {
|
||||
fn name(&self) -> &str {
|
||||
"static-bootstrap"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, _key: DiscoveryKey, _addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
_key: DiscoveryKey,
|
||||
_endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, _key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
let candidates: Vec<Candidate> = self
|
||||
.entries
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|addr| Candidate::new(addr, CandidateSource::Bootstrap))
|
||||
.collect();
|
||||
Box::pin(async move { Ok(candidates) })
|
||||
}
|
||||
}
|
||||
|
||||
/// An in-process discovery backend used by tests and examples.
|
||||
///
|
||||
/// It stores a mapping from [`DiscoveryKey`] to endpoint addresses and nothing
|
||||
/// else. It carries no messages, performs no authentication and cannot touch an
|
||||
/// agent's state. Clone it to hand the same rendezvous table to several agents;
|
||||
/// create a new one per test so that tests stay independent — there is no global
|
||||
/// mutable state here.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SharedMemoryDiscovery {
|
||||
inner: Arc<Mutex<HashMap<DiscoveryKey, HashMap<EndpointId, EndpointAddr>>>>,
|
||||
}
|
||||
|
||||
impl SharedMemoryDiscovery {
|
||||
/// Creates an empty rendezvous table.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Number of entries published under `key`. Useful in tests.
|
||||
pub fn len(&self, key: &DiscoveryKey) -> usize {
|
||||
self.with_inner(|map| map.get(key).map_or(0, HashMap::len))
|
||||
}
|
||||
|
||||
/// Whether nothing is published under `key`.
|
||||
pub fn is_empty(&self, key: &DiscoveryKey) -> bool {
|
||||
self.len(key) == 0
|
||||
}
|
||||
|
||||
/// Removes every entry under `key`, simulating a discovery outage.
|
||||
pub fn clear(&self, key: &DiscoveryKey) {
|
||||
self.with_inner(|map| {
|
||||
map.remove(key);
|
||||
});
|
||||
}
|
||||
|
||||
/// Replaces an entry with a deliberately wrong address, simulating a stale
|
||||
/// or poisoned record.
|
||||
pub fn insert_raw(&self, key: DiscoveryKey, addr: EndpointAddr) {
|
||||
self.with_inner(|map| {
|
||||
map.entry(key).or_default().insert(addr.id, addr);
|
||||
});
|
||||
}
|
||||
|
||||
fn with_inner<T>(
|
||||
&self,
|
||||
f: impl FnOnce(&mut HashMap<DiscoveryKey, HashMap<EndpointId, EndpointAddr>>) -> T,
|
||||
) -> T {
|
||||
let mut guard = match self.inner.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&mut guard)
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for SharedMemoryDiscovery {
|
||||
fn name(&self) -> &str {
|
||||
"shared-memory"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
self.with_inner(|map| {
|
||||
map.entry(key).or_default().insert(addr.id, addr);
|
||||
});
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
self.with_inner(|map| {
|
||||
if let Some(entries) = map.get_mut(&key) {
|
||||
entries.remove(&endpoint);
|
||||
if entries.is_empty() {
|
||||
map.remove(&key);
|
||||
}
|
||||
}
|
||||
});
|
||||
Box::pin(async { Ok(()) })
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
let candidates: Vec<Candidate> = self.with_inner(|map| {
|
||||
map.get(&key)
|
||||
.map(|entries| {
|
||||
entries
|
||||
.values()
|
||||
.cloned()
|
||||
.map(|addr| Candidate::new(addr, CandidateSource::Discovery))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
});
|
||||
Box::pin(async move { Ok(candidates) })
|
||||
}
|
||||
}
|
||||
|
||||
/// Combines several backends, concatenating their candidates.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CompositeDiscovery {
|
||||
backends: Vec<Arc<dyn NetworkDiscovery>>,
|
||||
}
|
||||
|
||||
impl CompositeDiscovery {
|
||||
/// Creates a composite over the given backends.
|
||||
pub fn new(backends: impl IntoIterator<Item = Arc<dyn NetworkDiscovery>>) -> Self {
|
||||
Self {
|
||||
backends: backends.into_iter().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NetworkDiscovery for CompositeDiscovery {
|
||||
fn name(&self) -> &str {
|
||||
"composite"
|
||||
}
|
||||
|
||||
fn publish<'a>(&'a self, key: DiscoveryKey, addr: EndpointAddr) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for backend in &self.backends {
|
||||
// One failing backend must not stop the others.
|
||||
if let Err(err) = backend.publish(key, addr.clone()).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "publish failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn unpublish<'a>(
|
||||
&'a self,
|
||||
key: DiscoveryKey,
|
||||
endpoint: EndpointId,
|
||||
) -> BoxFuture<'a, Result<()>> {
|
||||
Box::pin(async move {
|
||||
for backend in &self.backends {
|
||||
if let Err(err) = backend.unpublish(key, endpoint).await {
|
||||
tracing::debug!(backend = backend.name(), %err, "unpublish failed");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve<'a>(&'a self, key: DiscoveryKey) -> BoxFuture<'a, Result<Vec<Candidate>>> {
|
||||
Box::pin(async move {
|
||||
let mut out = Vec::new();
|
||||
for backend in &self.backends {
|
||||
match backend.resolve(key).await {
|
||||
Ok(mut found) => out.append(&mut found),
|
||||
Err(err) => {
|
||||
tracing::debug!(backend = backend.name(), %err, "resolve failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! A DNS view of the overlay.
|
||||
//!
|
||||
//! The names and addresses of a network, served to the host that runs the
|
||||
//! agent, so members can be reached by name. Answered from signed state, so a
|
||||
//! member that is switched off still resolves.
|
||||
//!
|
||||
//! Three parts, kept apart on purpose:
|
||||
//!
|
||||
//! * [`zone`] decides what the answer is. Pure, and knows nothing about
|
||||
//! packets or sockets.
|
||||
//! * [`server`] puts that on the wire.
|
||||
//! * `publish` tells the operating system where to send its questions,
|
||||
//! which is the only part that differs between platforms.
|
||||
|
||||
pub mod publish;
|
||||
pub mod server;
|
||||
pub mod zone;
|
||||
|
||||
pub use publish::{DnsPublisher, PublishError, Published};
|
||||
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
/// Where the server should try to listen, best first.
|
||||
///
|
||||
/// The overlay address comes first: that is what the system resolver is
|
||||
/// pointed at, and it is reachable only over the overlay interface, so a
|
||||
/// question for these names cannot arrive from anywhere else.
|
||||
///
|
||||
/// Loopback second, and it is not merely a fallback for having no overlay
|
||||
/// address. An address this agent has been *allocated* is not necessarily an
|
||||
/// address that is *on an interface* — with no privileges, with `--no-tun`,
|
||||
/// or in the moment before the interface is configured, it is not — and
|
||||
/// binding to one that is not there fails. Trying loopback afterwards is
|
||||
/// what keeps the promise that the port comes up regardless.
|
||||
pub fn listen_addresses(overlay: Option<Ipv4Addr>, port: u16) -> Vec<SocketAddr> {
|
||||
let mut candidates = Vec::with_capacity(2);
|
||||
if let Some(overlay) = overlay {
|
||||
candidates.push(SocketAddr::from((overlay, port)));
|
||||
}
|
||||
candidates.push(SocketAddr::from((Ipv4Addr::LOCALHOST, port)));
|
||||
candidates
|
||||
}
|
||||
|
||||
pub use server::{DnsServer, SharedZone};
|
||||
pub use zone::{Answer, Query, Zone, ZoneError, ZoneName};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn the_overlay_is_preferred_and_loopback_is_always_offered() {
|
||||
// Loopback is in the list even when there is an overlay address,
|
||||
// because being allocated one is not the same as it being on an
|
||||
// interface — and binding to one that is not there fails.
|
||||
assert_eq!(
|
||||
listen_addresses(Some(Ipv4Addr::new(10, 13, 37, 69)), 5354),
|
||||
vec![
|
||||
"10.13.37.69:5354".parse::<SocketAddr>().unwrap(),
|
||||
"127.0.0.1:5354".parse::<SocketAddr>().unwrap(),
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
listen_addresses(None, 5354),
|
||||
vec!["127.0.0.1:5354".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
//! A pretend resolver, so the wiring is tested without touching this host's.
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Records what it was asked to do, and can be told to refuse.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MockPublisher {
|
||||
applied: Arc<Mutex<Option<Published>>>,
|
||||
failure: Option<PublishError>,
|
||||
}
|
||||
|
||||
impl MockPublisher {
|
||||
/// A publisher that accepts everything.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// A publisher that refuses everything, with this reason.
|
||||
pub fn failing(error: PublishError) -> Self {
|
||||
Self {
|
||||
applied: Arc::new(Mutex::new(None)),
|
||||
failure: Some(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// What is currently applied, if anything.
|
||||
pub fn applied(&self) -> Option<Published> {
|
||||
match self.applied.lock() {
|
||||
Ok(guard) => guard.clone(),
|
||||
Err(poisoned) => poisoned.into_inner().clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for MockPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"mock"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
if let Some(failure) = &self.failure {
|
||||
return Err(failure.clone());
|
||||
}
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(published.clone()),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(published.clone()),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = None,
|
||||
Err(poisoned) => *poisoned.into_inner() = None,
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn published() -> Published {
|
||||
Published {
|
||||
interface: "tsundemo".into(),
|
||||
server: "10.13.37.69:5354".parse().unwrap(),
|
||||
domains: vec!["lab".into()],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applying_and_reverting_are_both_recorded() {
|
||||
let publisher = MockPublisher::new();
|
||||
assert!(publisher.applied().is_none());
|
||||
|
||||
publisher.apply(&published()).await.unwrap();
|
||||
assert_eq!(publisher.applied(), Some(published()));
|
||||
|
||||
publisher.revert().await.unwrap();
|
||||
assert!(publisher.applied().is_none());
|
||||
// Reverting twice is not an error: shutdown must not fail here.
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_refusal_leaves_nothing_applied() {
|
||||
let publisher = MockPublisher::failing(PublishError::Refused("polkit said no".into()));
|
||||
assert!(publisher.apply(&published()).await.is_err());
|
||||
assert!(publisher.applied().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
//! Telling the operating system where to send its questions.
|
||||
//!
|
||||
//! The server answers whether or not this works. That is the whole reason it
|
||||
//! is a separate thing: if the resolver cannot be configured — no
|
||||
//! systemd-resolved, an unwilling polkit, a platform nobody has written this
|
||||
//! for — the port is still up and the user can point something at it by
|
||||
//! hand. A failure here is a degraded overlay, not a broken one.
|
||||
//!
|
||||
//! Only the *mechanics* differ between systems. What has to be arranged is
|
||||
//! the same everywhere: send questions for these suffixes to this address,
|
||||
//! through this interface, and do not make it the resolver for anything
|
||||
//! else. That is [`Published`]; the rest is behind [`DnsPublisher`].
|
||||
|
||||
use std::net::SocketAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
mod mock;
|
||||
pub use mock::MockPublisher;
|
||||
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
mod resolved;
|
||||
#[cfg(all(feature = "dns-publish", target_os = "linux"))]
|
||||
pub use resolved::ResolvedPublisher;
|
||||
|
||||
mod unsupported;
|
||||
pub use unsupported::UnsupportedPublisher;
|
||||
|
||||
/// What the operating system is asked to do.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Published {
|
||||
/// The interface questions should be sent through.
|
||||
///
|
||||
/// The server listens on an overlay address, which is only reachable
|
||||
/// over the overlay interface, so the two travel together.
|
||||
pub interface: String,
|
||||
/// Where the server is listening.
|
||||
pub server: SocketAddr,
|
||||
/// The suffixes that belong to this server.
|
||||
///
|
||||
/// Routing suffixes only: they say *which questions* come here, never
|
||||
/// that this is the resolver for anything else.
|
||||
pub domains: Vec<String>,
|
||||
}
|
||||
|
||||
/// Why the resolver could not be told.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum PublishError {
|
||||
/// There is nothing here that can be configured this way.
|
||||
#[error("{0}")]
|
||||
Unavailable(String),
|
||||
/// Something is there, and it declined.
|
||||
///
|
||||
/// Separate from a plain failure because the answer is different: this
|
||||
/// one is about who the agent is running as, not about whether the thing
|
||||
/// works.
|
||||
#[error("{0}")]
|
||||
Refused(String),
|
||||
/// It was there, it accepted the request, and it went wrong anyway.
|
||||
#[error("{0}")]
|
||||
Failed(String),
|
||||
}
|
||||
|
||||
impl PublishError {
|
||||
/// Whether waiting will fix it.
|
||||
///
|
||||
/// A refusal will not change on its own — somebody has to grant
|
||||
/// permission — so retrying it at the pace of everything else is just
|
||||
/// noise. Anything else might be a service still starting.
|
||||
pub fn needs_a_human(&self) -> bool {
|
||||
matches!(self, PublishError::Refused(_))
|
||||
}
|
||||
|
||||
/// One line for a status table.
|
||||
pub fn remedy(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
PublishError::Refused(_) => Some(
|
||||
"grant this user the `org.freedesktop.resolve1.set-*` actions in \
|
||||
/etc/polkit-1/rules.d, or run the agent as a system service",
|
||||
),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The polkit rule that lets this user configure the resolver.
|
||||
///
|
||||
/// Printed in full rather than described, because the point of this feature
|
||||
/// is that the user has as little to do as possible, and "write a polkit
|
||||
/// rule" is a great deal more work than pasting one.
|
||||
///
|
||||
/// It grants exactly the four actions this agent calls and nothing else.
|
||||
/// polkit decides by user id — a capability does not help here — so there is
|
||||
/// no way to do this from inside the process.
|
||||
pub fn polkit_recipe(user: &str) -> String {
|
||||
format!(
|
||||
"sudo tee /etc/polkit-1/rules.d/50-tsunagi-resolved.rules > /dev/null <<'RULE'\n\
|
||||
polkit.addRule(function(action, subject) {{\n\
|
||||
\x20 var allowed = [\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-dns-servers\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-domains\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.set-default-route\",\n\
|
||||
\x20 \"org.freedesktop.resolve1.revert\"\n\
|
||||
\x20 ];\n\
|
||||
\x20 if (allowed.indexOf(action.id) >= 0 && subject.user == \"{user}\") {{\n\
|
||||
\x20 return polkit.Result.YES;\n\
|
||||
\x20 }}\n\
|
||||
}});\n\
|
||||
RULE"
|
||||
)
|
||||
}
|
||||
|
||||
/// Arranges for the operating system to ask this server.
|
||||
pub trait DnsPublisher: Send + Sync + std::fmt::Debug + 'static {
|
||||
/// A short name used in diagnostics.
|
||||
fn name(&self) -> &str;
|
||||
|
||||
/// Applies the setting, replacing whatever this publisher set before.
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>>;
|
||||
|
||||
/// Undoes it.
|
||||
///
|
||||
/// Reverting something that was never applied succeeds: this runs on the
|
||||
/// shutdown path, where the setting being gone is the point.
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>>;
|
||||
}
|
||||
|
||||
/// The kernel's index for an interface.
|
||||
///
|
||||
/// From sysfs, which needs no privileges and no netlink round trip.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn interface_index(name: &str) -> Option<u32> {
|
||||
std::fs::read_to_string(format!("/sys/class/net/{name}/ifindex"))
|
||||
.ok()?
|
||||
.trim()
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub fn interface_index(_name: &str) -> Option<u32> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_suggests_what_to_do_about_it() {
|
||||
// The other two are conditions of the host, not of the user.
|
||||
assert!(PublishError::Refused("no".into()).remedy().is_some());
|
||||
assert!(PublishError::Unavailable("none".into()).remedy().is_none());
|
||||
assert!(PublishError::Failed("bang".into()).remedy().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_refusal_waits_for_a_person() {
|
||||
// The rest may come right on their own, so they are worth retrying
|
||||
// at the ordinary pace; a refusal is not.
|
||||
assert!(PublishError::Refused("no".into()).needs_a_human());
|
||||
assert!(!PublishError::Unavailable("none".into()).needs_a_human());
|
||||
assert!(!PublishError::Failed("bang".into()).needs_a_human());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_polkit_recipe_grants_what_is_called_and_no_more() {
|
||||
let recipe = polkit_recipe("ab");
|
||||
for action in [
|
||||
"set-dns-servers",
|
||||
"set-domains",
|
||||
"set-default-route",
|
||||
"revert",
|
||||
] {
|
||||
assert!(recipe.contains(action), "{action} missing from:\n{recipe}");
|
||||
}
|
||||
// Nothing beyond what the agent calls: a rule that granted the lot
|
||||
// would be handing out more than this feature needs.
|
||||
for other in ["set-dnssec", "set-mdns", "register-service", "set-llmnr"] {
|
||||
assert!(!recipe.contains(other), "{other} should not be granted");
|
||||
}
|
||||
assert!(recipe.contains("subject.user == \"ab\""));
|
||||
// ES5: the rules engine is duktape and has no `startsWith`.
|
||||
assert!(!recipe.contains("startsWith"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn an_interface_index_is_read_from_the_running_kernel() {
|
||||
assert_eq!(interface_index("lo"), Some(1));
|
||||
assert_eq!(interface_index("tsunagi-no-such-interface"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
//! Telling systemd-resolved to send some questions here.
|
||||
//!
|
||||
//! Three calls on `org.freedesktop.resolve1.Manager`, all scoped to the
|
||||
//! overlay interface:
|
||||
//!
|
||||
//! * `SetLinkDNSEx` — where to send them. The `Ex` form carries a **port**,
|
||||
//! which is why this server does not have to sit on 53 and the agent needs
|
||||
//! no `CAP_NET_BIND_SERVICE`. Systems before systemd 247 have only
|
||||
//! `SetLinkDNS`, which has no port; there the fallback only works if the
|
||||
//! server did get port 53, and it says so rather than appearing to work.
|
||||
//! * `SetLinkDomains` with `routing_only` set — a *routing* suffix, the
|
||||
//! `~domain` form. It says which questions come here and claims nothing
|
||||
//! else.
|
||||
//! * `SetLinkDefaultRoute(false)` — so this never becomes the resolver for
|
||||
//! anything outside those suffixes. Without it resolved may fall back to
|
||||
//! this link for ordinary names, and this server refuses those.
|
||||
//!
|
||||
//! # It cleans up by itself
|
||||
//!
|
||||
//! resolved keys all of this to the interface, and drops it when the
|
||||
//! interface goes. The overlay interface belongs to a file descriptor the
|
||||
//! agent holds, so it goes when the agent does — however the agent goes. The
|
||||
//! explicit `RevertLink` on shutdown only makes that immediate.
|
||||
//!
|
||||
//! # Privilege
|
||||
//!
|
||||
//! resolved asks polkit, and polkit decides by user id, not by capability.
|
||||
//! So `CAP_NET_ADMIN` does not help here: an ordinary user is prompted or
|
||||
//! refused, while a system service running as root is not. That refusal is
|
||||
//! reported as its own kind of error, because the answer to it is different
|
||||
//! from the answer to "resolved is not installed".
|
||||
|
||||
use std::net::IpAddr;
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published, interface_index};
|
||||
|
||||
/// `AF_INET`, as resolved wants it.
|
||||
const AF_INET: i32 = 2;
|
||||
/// `AF_INET6`.
|
||||
const AF_INET6: i32 = 10;
|
||||
|
||||
#[zbus::proxy(
|
||||
interface = "org.freedesktop.resolve1.Manager",
|
||||
default_service = "org.freedesktop.resolve1",
|
||||
default_path = "/org/freedesktop/resolve1"
|
||||
)]
|
||||
trait Resolved {
|
||||
/// Servers for a link, with a port and a name. systemd 247 and later.
|
||||
#[zbus(name = "SetLinkDNSEx")]
|
||||
fn set_link_dns_ex(
|
||||
&self,
|
||||
ifindex: i32,
|
||||
addresses: &[(i32, Vec<u8>, u16, String)],
|
||||
) -> zbus::Result<()>;
|
||||
|
||||
/// Servers for a link, without a port. Always port 53.
|
||||
#[zbus(name = "SetLinkDNS")]
|
||||
fn set_link_dns(&self, ifindex: i32, addresses: &[(i32, Vec<u8>)]) -> zbus::Result<()>;
|
||||
|
||||
/// Suffixes for a link. The flag makes one routing-only.
|
||||
#[zbus(name = "SetLinkDomains")]
|
||||
fn set_link_domains(&self, ifindex: i32, domains: &[(String, bool)]) -> zbus::Result<()>;
|
||||
|
||||
/// Whether this link may answer for names outside its suffixes.
|
||||
#[zbus(name = "SetLinkDefaultRoute")]
|
||||
fn set_link_default_route(&self, ifindex: i32, enable: bool) -> zbus::Result<()>;
|
||||
|
||||
/// Forgets everything set for a link.
|
||||
#[zbus(name = "RevertLink")]
|
||||
fn revert_link(&self, ifindex: i32) -> zbus::Result<()>;
|
||||
}
|
||||
|
||||
/// Configures systemd-resolved over D-Bus.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct ResolvedPublisher {
|
||||
/// The link last configured, so shutdown knows what to undo.
|
||||
applied: std::sync::Mutex<Option<u32>>,
|
||||
}
|
||||
|
||||
impl ResolvedPublisher {
|
||||
/// Creates the publisher. Nothing is contacted until [`Self::apply`].
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
async fn proxy() -> Result<ResolvedProxy<'static>, PublishError> {
|
||||
let connection = zbus::Connection::system().await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("no system D-Bus to talk to resolved on: {err}"))
|
||||
})?;
|
||||
ResolvedProxy::new(&connection).await.map_err(|err| {
|
||||
PublishError::Unavailable(format!("systemd-resolved is not answering: {err}"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns a D-Bus failure into the kind of failure it actually is.
|
||||
fn classify(err: zbus::Error, what: &str) -> PublishError {
|
||||
let name = match &err {
|
||||
zbus::Error::MethodError(name, _, _) => name.as_str().to_string(),
|
||||
_ => String::new(),
|
||||
};
|
||||
match name.as_str() {
|
||||
"org.freedesktop.DBus.Error.InteractiveAuthorizationRequired"
|
||||
| "org.freedesktop.DBus.Error.AccessDenied" => {
|
||||
PublishError::Refused(format!("systemd-resolved refused {what}: {err}"))
|
||||
}
|
||||
"org.freedesktop.DBus.Error.UnknownMethod"
|
||||
| "org.freedesktop.DBus.Error.ServiceUnknown" => {
|
||||
PublishError::Unavailable(format!("systemd-resolved cannot do {what}: {err}"))
|
||||
}
|
||||
_ => PublishError::Failed(format!("systemd-resolved failed {what}: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The address in the shape resolved wants: a family and raw octets.
|
||||
fn wire_address(address: IpAddr) -> (i32, Vec<u8>) {
|
||||
match address {
|
||||
IpAddr::V4(address) => (AF_INET, address.octets().to_vec()),
|
||||
IpAddr::V6(address) => (AF_INET6, address.octets().to_vec()),
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for ResolvedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"systemd-resolved"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let ifindex = interface_index(&published.interface).ok_or_else(|| {
|
||||
PublishError::Unavailable(format!(
|
||||
"interface `{}` is not on this host, so there is no link to configure",
|
||||
published.interface
|
||||
))
|
||||
})?;
|
||||
let proxy = Self::proxy().await?;
|
||||
let index = ifindex as i32;
|
||||
let (family, octets) = wire_address(published.server.ip());
|
||||
let port = published.server.port();
|
||||
|
||||
match proxy
|
||||
.set_link_dns_ex(index, &[(family, octets.clone(), port, String::new())])
|
||||
.await
|
||||
{
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
let classified = classify(err, "setting the link's DNS server");
|
||||
// Older systemd has no `Ex` form, and the plain one is
|
||||
// always port 53. Falling back to it when the server is
|
||||
// somewhere else would point resolved at nothing.
|
||||
if !matches!(classified, PublishError::Unavailable(_)) {
|
||||
return Err(classified);
|
||||
}
|
||||
if port != 53 {
|
||||
return Err(PublishError::Unavailable(format!(
|
||||
"this systemd-resolved cannot be given a port, and the server is on \
|
||||
{port}. Run the server on port 53, or point your resolver at \
|
||||
{} yourself.",
|
||||
published.server
|
||||
)));
|
||||
}
|
||||
proxy
|
||||
.set_link_dns(index, &[(family, octets)])
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's DNS server"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let domains: Vec<(String, bool)> = published
|
||||
.domains
|
||||
.iter()
|
||||
.map(|domain| (domain.clone(), true))
|
||||
.collect();
|
||||
proxy
|
||||
.set_link_domains(index, &domains)
|
||||
.await
|
||||
.map_err(|err| classify(err, "setting the link's search domains"))?;
|
||||
|
||||
// Last, and deliberately: until this is off, resolved may send
|
||||
// ordinary names here, and this server refuses them.
|
||||
proxy
|
||||
.set_link_default_route(index, false)
|
||||
.await
|
||||
.map_err(|err| classify(err, "clearing the link's default route"))?;
|
||||
|
||||
match self.applied.lock() {
|
||||
Ok(mut guard) => *guard = Some(ifindex),
|
||||
Err(poisoned) => *poisoned.into_inner() = Some(ifindex),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
let applied = match self.applied.lock() {
|
||||
Ok(mut guard) => guard.take(),
|
||||
Err(poisoned) => poisoned.into_inner().take(),
|
||||
};
|
||||
let Some(ifindex) = applied else {
|
||||
return Ok(());
|
||||
};
|
||||
let proxy = Self::proxy().await?;
|
||||
match proxy.revert_link(ifindex as i32).await {
|
||||
Ok(()) => Ok(()),
|
||||
// The link going away takes the setting with it, so this is
|
||||
// the outcome asked for rather than a failure.
|
||||
Err(zbus::Error::MethodError(name, _, _))
|
||||
if name.as_str().contains("NoSuchLink") =>
|
||||
{
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => Err(classify(err, "reverting the link")),
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use std::net::{Ipv4Addr, SocketAddr};
|
||||
|
||||
#[test]
|
||||
fn an_address_is_encoded_the_way_resolved_expects() {
|
||||
assert_eq!(
|
||||
wire_address(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69))),
|
||||
(AF_INET, vec![10, 13, 37, 69])
|
||||
);
|
||||
let (family, octets) = wire_address("fd55::1".parse().unwrap());
|
||||
assert_eq!(family, AF_INET6);
|
||||
assert_eq!(octets.len(), 16);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_that_is_not_here_is_unavailable_not_a_failure() {
|
||||
// Nothing is contacted: there is no link to configure, and saying so
|
||||
// is the honest answer without bothering the bus.
|
||||
let publisher = ResolvedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsunagi-no-such-interface".into(),
|
||||
server: SocketAddr::from(([10, 13, 37, 69], 5354)),
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)), "{err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reverting_without_having_applied_does_nothing_and_succeeds() {
|
||||
// The shutdown path must not fail because there was nothing to undo.
|
||||
ResolvedPublisher::new().revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! The publisher for systems nobody has written one for.
|
||||
//!
|
||||
//! macOS needs `SystemConfiguration` and Windows the IP Helper API, and
|
||||
//! neither is written. Saying so is more use than appearing to work: the
|
||||
//! server is already answering, so all that is missing is the last step, and
|
||||
//! the user can take it by hand once they know that is what is needed.
|
||||
|
||||
use crate::BoxFuture;
|
||||
|
||||
use super::{DnsPublisher, PublishError, Published};
|
||||
|
||||
/// Refuses to configure anything, with an explanation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UnsupportedPublisher {
|
||||
platform: &'static str,
|
||||
}
|
||||
|
||||
impl Default for UnsupportedPublisher {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl UnsupportedPublisher {
|
||||
/// A publisher naming the platform it stands in for.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
platform: std::env::consts::OS,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DnsPublisher for UnsupportedPublisher {
|
||||
fn name(&self) -> &str {
|
||||
"unsupported"
|
||||
}
|
||||
|
||||
fn apply<'a>(&'a self, _published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||
Box::pin(async move {
|
||||
Err(PublishError::Unavailable(format!(
|
||||
"configuring the system resolver is not implemented on {} yet",
|
||||
self.platform
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||
// Nothing was set, so there is nothing to undo and no reason to fail
|
||||
// a shutdown over it.
|
||||
Box::pin(async move { Ok(()) })
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn it_names_the_platform_and_still_reverts_quietly() {
|
||||
let publisher = UnsupportedPublisher::new();
|
||||
let published = Published {
|
||||
interface: "tsundemo".into(),
|
||||
server: "10.0.0.1:5354".parse().unwrap(),
|
||||
domains: vec!["lab".into()],
|
||||
};
|
||||
let err = publisher.apply(&published).await.unwrap_err();
|
||||
assert!(matches!(err, PublishError::Unavailable(_)));
|
||||
assert!(err.to_string().contains(std::env::consts::OS));
|
||||
publisher.revert().await.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
//! Answering DNS questions about the overlay, over UDP and TCP.
|
||||
//!
|
||||
//! Authoritative for one zone and nothing else. There is no recursion, no
|
||||
//! forwarding and no cache: a question this agent cannot answer from the
|
||||
//! signed roster is refused rather than passed anywhere, so pointing a
|
||||
//! resolver at this server can never make it a path to the outside.
|
||||
//!
|
||||
//! The decision of what to answer lives in [`super::zone`]; this module is
|
||||
//! only the wire format and the sockets. [`respond`] sits between them and
|
||||
//! takes bytes to bytes, so everything the server does to a packet is
|
||||
//! testable without opening a socket.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
use simple_dns::rdata::{A, PTR, RData, SOA};
|
||||
use simple_dns::{Name, PacketFlag, QCLASS, QTYPE, RCODE, ResourceRecord, TYPE};
|
||||
|
||||
use super::zone::{Answer, Query, Zone};
|
||||
|
||||
/// How long an answer may be cached.
|
||||
///
|
||||
/// Short, because the roster changes when members come and go and a stale
|
||||
/// answer is worse than another question.
|
||||
pub const TTL: u32 = 30;
|
||||
|
||||
/// The largest question this server will read.
|
||||
///
|
||||
/// A DNS message is 512 bytes without EDNS and 4096 with it; anything past
|
||||
/// that is not a question worth answering.
|
||||
pub const MAX_MESSAGE_LEN: usize = 4096;
|
||||
|
||||
/// The largest answer sent over UDP without the client offering EDNS.
|
||||
const CLASSIC_UDP_LIMIT: usize = 512;
|
||||
|
||||
/// How long a TCP client may take over one question.
|
||||
const TCP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
|
||||
|
||||
/// How many TCP questions may be in flight at once.
|
||||
const MAX_TCP_CONNECTIONS: usize = 32;
|
||||
|
||||
/// The zone the server answers from, swapped as the roster changes.
|
||||
///
|
||||
/// Shared rather than copied into the server so that a roster change is one
|
||||
/// write, not a restart: rebinding the socket would drop questions in flight
|
||||
/// for no reason.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SharedZone(Arc<RwLock<Arc<Zone>>>);
|
||||
|
||||
impl SharedZone {
|
||||
/// Wraps a zone.
|
||||
pub fn new(zone: Zone) -> Self {
|
||||
Self(Arc::new(RwLock::new(Arc::new(zone))))
|
||||
}
|
||||
|
||||
/// Replaces it.
|
||||
pub fn set(&self, zone: Zone) {
|
||||
match self.0.write() {
|
||||
Ok(mut guard) => *guard = Arc::new(zone),
|
||||
Err(poisoned) => *poisoned.into_inner() = Arc::new(zone),
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone as it is now.
|
||||
pub fn get(&self) -> Arc<Zone> {
|
||||
match self.0.read() {
|
||||
Ok(guard) => Arc::clone(&guard),
|
||||
Err(poisoned) => Arc::clone(&poisoned.into_inner()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the answer to one question.
|
||||
///
|
||||
/// `None` means say nothing at all: the message was not a question this
|
||||
/// server should reply to, and replying anyway would make this a useful
|
||||
/// amplifier for somebody spoofing a source address.
|
||||
pub fn respond(zone: &Zone, query: &[u8]) -> Option<Vec<u8>> {
|
||||
let packet = simple_dns::Packet::parse(query).ok()?;
|
||||
if packet.has_flags(PacketFlag::RESPONSE) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut reply = simple_dns::Packet::new_reply(packet.id());
|
||||
// Recursion is not available here and the flag says so honestly; the
|
||||
// desired bit is echoed because a resolver compares it.
|
||||
if packet.has_flags(PacketFlag::RECURSION_DESIRED) {
|
||||
reply.set_flags(PacketFlag::RECURSION_DESIRED);
|
||||
}
|
||||
|
||||
if packet.opcode() != simple_dns::OPCODE::StandardQuery {
|
||||
*reply.rcode_mut() = RCODE::NotImplemented;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
// Exactly one question. Zero is nothing to answer; more than one has no
|
||||
// agreed meaning and every real server rejects it.
|
||||
let [question] = packet.questions.as_slice() else {
|
||||
*reply.rcode_mut() = RCODE::FormatError;
|
||||
return reply.build_bytes_vec().ok();
|
||||
};
|
||||
if !matches!(question.qclass, QCLASS::CLASS(simple_dns::CLASS::IN)) {
|
||||
*reply.rcode_mut() = RCODE::Refused;
|
||||
return reply.build_bytes_vec().ok();
|
||||
}
|
||||
|
||||
let qname = question.qname.to_string();
|
||||
let answer = zone.lookup(&qname, query_kind(question.qtype));
|
||||
reply.questions.push(question.clone());
|
||||
|
||||
let name = Name::new(&qname).ok()?;
|
||||
match answer {
|
||||
Answer::NotOurs => *reply.rcode_mut() = RCODE::Refused,
|
||||
Answer::Addresses(addresses) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
for address in addresses {
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::A(A::from(address)),
|
||||
));
|
||||
}
|
||||
}
|
||||
Answer::Name(target) => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
let target = Name::new(&target).ok()?.into_owned();
|
||||
reply.answers.push(ResourceRecord::new(
|
||||
name.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::PTR(PTR(target)),
|
||||
));
|
||||
}
|
||||
Answer::Soa => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
reply.answers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoData => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
// The authority section carries the SOA so a resolver knows how
|
||||
// long it may remember that there is nothing here.
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
Answer::NoSuchName => {
|
||||
reply.set_flags(PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*reply.rcode_mut() = RCODE::NameError;
|
||||
reply.name_servers.push(soa_record(zone)?);
|
||||
}
|
||||
}
|
||||
|
||||
reply.build_bytes_vec_compressed().ok()
|
||||
}
|
||||
|
||||
/// The zone's start of authority.
|
||||
fn soa_record(zone: &Zone) -> Option<ResourceRecord<'static>> {
|
||||
let origin = Name::new(zone.origin().as_str()).ok()?.into_owned();
|
||||
Some(ResourceRecord::new(
|
||||
origin.clone(),
|
||||
simple_dns::CLASS::IN,
|
||||
TTL,
|
||||
RData::SOA(SOA {
|
||||
mname: origin.clone(),
|
||||
// There is no mailbox behind this zone and inventing one would
|
||||
// be a fiction; the origin itself is the honest answer.
|
||||
rname: origin,
|
||||
serial: zone.serial(),
|
||||
refresh: TTL as i32,
|
||||
retry: TTL as i32,
|
||||
expire: 86_400,
|
||||
minimum: TTL,
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn query_kind(qtype: QTYPE) -> Query {
|
||||
match qtype {
|
||||
QTYPE::TYPE(TYPE::A) => Query::A,
|
||||
QTYPE::TYPE(TYPE::PTR) => Query::Ptr,
|
||||
QTYPE::TYPE(TYPE::SOA) => Query::Soa,
|
||||
QTYPE::TYPE(TYPE::NS) => Query::Ns,
|
||||
// ANY is answered as an address question rather than by dumping the
|
||||
// zone: an ANY that returns everything is an amplification gift.
|
||||
QTYPE::ANY => Query::A,
|
||||
_ => Query::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the client offered EDNS, and so how large an answer it will take.
|
||||
fn udp_limit(query: &[u8]) -> usize {
|
||||
simple_dns::Packet::parse(query)
|
||||
.ok()
|
||||
.and_then(|packet| packet.opt().map(|opt| opt.udp_packet_size as usize))
|
||||
.unwrap_or(CLASSIC_UDP_LIMIT as u16 as usize)
|
||||
.clamp(CLASSIC_UDP_LIMIT, MAX_MESSAGE_LEN)
|
||||
}
|
||||
|
||||
/// Cuts an answer down to what the client said it would take.
|
||||
///
|
||||
/// The records are dropped and the truncated bit set, which tells a resolver
|
||||
/// to ask again over TCP. Sending a reply it cannot reassemble would just
|
||||
/// look like packet loss.
|
||||
fn truncate_for_udp(query: &[u8], reply: Vec<u8>) -> Vec<u8> {
|
||||
let limit = udp_limit(query);
|
||||
if reply.len() <= limit {
|
||||
return reply;
|
||||
}
|
||||
let Ok(parsed) = simple_dns::Packet::parse(&reply) else {
|
||||
return reply;
|
||||
};
|
||||
let mut short = simple_dns::Packet::new_reply(parsed.id());
|
||||
short.set_flags(PacketFlag::TRUNCATION | PacketFlag::AUTHORITATIVE_ANSWER);
|
||||
*short.rcode_mut() = parsed.rcode();
|
||||
for question in &parsed.questions {
|
||||
short.questions.push(question.clone());
|
||||
}
|
||||
short.build_bytes_vec().unwrap_or(reply)
|
||||
}
|
||||
|
||||
/// A running DNS server.
|
||||
#[derive(Debug)]
|
||||
pub struct DnsServer {
|
||||
local_addr: SocketAddr,
|
||||
tasks: Vec<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl DnsServer {
|
||||
/// Binds and starts answering.
|
||||
///
|
||||
/// Both transports on the same address and port, as a resolver expects:
|
||||
/// it falls back to TCP when an answer does not fit, and a server that
|
||||
/// only listened on UDP would leave it with nowhere to go.
|
||||
pub async fn bind(addr: SocketAddr, zone: SharedZone) -> std::io::Result<Self> {
|
||||
let udp = tokio::net::UdpSocket::bind(addr).await?;
|
||||
let local_addr = udp.local_addr()?;
|
||||
let tcp = tokio::net::TcpListener::bind(local_addr).await?;
|
||||
|
||||
let udp_zone = zone.clone();
|
||||
let udp_task = tokio::spawn(async move {
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
loop {
|
||||
let (read, from) = match udp.recv_from(&mut buffer).await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns udp receive failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let query = &buffer[..read];
|
||||
let Some(reply) = respond(&udp_zone.get(), query) else {
|
||||
continue;
|
||||
};
|
||||
let reply = truncate_for_udp(query, reply);
|
||||
if let Err(err) = udp.send_to(&reply, from).await {
|
||||
tracing::debug!(%err, "dns udp reply failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let tcp_zone = zone.clone();
|
||||
let tcp_task = tokio::spawn(async move {
|
||||
let permits = Arc::new(tokio::sync::Semaphore::new(MAX_TCP_CONNECTIONS));
|
||||
loop {
|
||||
let (stream, _) = match tcp.accept().await {
|
||||
Ok(pair) => pair,
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "dns tcp accept failed");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Ok(permit) = Arc::clone(&permits).acquire_owned().await else {
|
||||
return;
|
||||
};
|
||||
let zone = tcp_zone.clone();
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
// Bounded, so a client that connects and says nothing
|
||||
// cannot hold a slot open.
|
||||
let _ = tokio::time::timeout(TCP_TIMEOUT, serve_tcp(stream, zone)).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Ok(Self {
|
||||
local_addr,
|
||||
tasks: vec![udp_task, tcp_task],
|
||||
})
|
||||
}
|
||||
|
||||
/// The address it is answering on.
|
||||
pub fn local_addr(&self) -> SocketAddr {
|
||||
self.local_addr
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DnsServer {
|
||||
fn drop(&mut self) {
|
||||
for task in &self.tasks {
|
||||
task.abort();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn serve_tcp(mut stream: tokio::net::TcpStream, zone: SharedZone) -> std::io::Result<()> {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
loop {
|
||||
let mut header = [0u8; 2];
|
||||
if stream.read_exact(&mut header).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
// Checked before the buffer is allocated, as everywhere else that
|
||||
// reads a length off a wire.
|
||||
let len = u16::from_be_bytes(header) as usize;
|
||||
if len == 0 || len > MAX_MESSAGE_LEN {
|
||||
return Ok(());
|
||||
}
|
||||
let mut query = vec![0u8; len];
|
||||
stream.read_exact(&mut query).await?;
|
||||
|
||||
let Some(reply) = respond(&zone.get(), &query) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Ok(len) = u16::try_from(reply.len()) else {
|
||||
return Ok(());
|
||||
};
|
||||
stream.write_all(&len.to_be_bytes()).await?;
|
||||
stream.write_all(&reply).await?;
|
||||
stream.flush().await?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::dns::zone::ZoneName;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
fn zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn ask(name: &str, qtype: TYPE) -> Vec<u8> {
|
||||
let mut packet = simple_dns::Packet::new_query(0x1234);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new(name).unwrap(),
|
||||
qtype.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::IN),
|
||||
false,
|
||||
));
|
||||
packet.build_bytes_vec().unwrap()
|
||||
}
|
||||
|
||||
/// The bytes of a reply. Parsed by each caller, because a parsed packet
|
||||
/// borrows from them.
|
||||
fn answer(query: &[u8]) -> Vec<u8> {
|
||||
respond(&zone(), query).expect("a reply")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_is_answered_authoritatively() {
|
||||
let bytes = answer(&ask("music.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.has_flags(PacketFlag::RESPONSE));
|
||||
assert!(reply.has_flags(PacketFlag::AUTHORITATIVE_ANSWER));
|
||||
assert!(!reply.has_flags(PacketFlag::RECURSION_AVAILABLE));
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
assert_eq!(reply.questions.len(), 1, "the question is echoed");
|
||||
match &reply.answers[0].rdata {
|
||||
RData::A(a) => assert_eq!(Ipv4Addr::from(a.address), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
other => panic!("expected an A record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absent_name_is_denied_with_a_soa_to_cache_the_denial() {
|
||||
let bytes = answer(&ask("nobody.lab", TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NameError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(
|
||||
reply.name_servers.len(),
|
||||
1,
|
||||
"a SOA bounds the negative cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_that_exists_without_that_record_is_not_denied() {
|
||||
// NODATA, not NXDOMAIN: denying the name would stop a resolver
|
||||
// asking for the A record it could have had.
|
||||
let bytes = answer(&ask("music.lab", TYPE::AAAA));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert!(reply.answers.is_empty());
|
||||
assert_eq!(reply.name_servers.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn anything_outside_the_zone_is_refused_and_never_forwarded() {
|
||||
for name in ["example.com", "evillab", "google.com"] {
|
||||
let bytes = answer(&ask(name, TYPE::A));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::Refused, "{name}");
|
||||
assert!(reply.answers.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_is_answered_backwards() {
|
||||
let bytes = answer(&ask("237.37.13.10.in-addr.arpa", TYPE::PTR));
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
match &reply.answers[0].rdata {
|
||||
RData::PTR(ptr) => assert_eq!(ptr.0.to_string(), "music.lab"),
|
||||
other => panic!("expected a PTR record, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reply_is_never_sent_to_something_that_was_not_a_question() {
|
||||
// Answering a response would make this a reflector for anyone who
|
||||
// can spoof a source address.
|
||||
let mut packet = simple_dns::Packet::new_reply(1);
|
||||
packet.set_flags(PacketFlag::RESPONSE);
|
||||
assert!(respond(&zone(), &packet.build_bytes_vec().unwrap()).is_none());
|
||||
assert!(respond(&zone(), b"").is_none());
|
||||
assert!(respond(&zone(), b"not a dns packet at all").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_question_with_no_question_in_it_is_a_format_error() {
|
||||
let packet = simple_dns::Packet::new_query(7);
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
let reply = simple_dns::Packet::parse(&bytes).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::FormatError);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_class_other_than_internet_is_refused() {
|
||||
let mut packet = simple_dns::Packet::new_query(9);
|
||||
packet.questions.push(simple_dns::Question::new(
|
||||
Name::new("music.lab").unwrap(),
|
||||
TYPE::A.into(),
|
||||
QCLASS::CLASS(simple_dns::CLASS::CH),
|
||||
false,
|
||||
));
|
||||
let bytes = respond(&zone(), &packet.build_bytes_vec().unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&bytes).unwrap().rcode(),
|
||||
RCODE::Refused
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_answer_too_large_for_udp_is_truncated_rather_than_dropped() {
|
||||
// A resolver that gets a truncated reply asks again over TCP; one
|
||||
// that gets nothing back just waits.
|
||||
let many: Vec<(String, Ipv4Addr)> = (0..200)
|
||||
.map(|i| ("host".to_string(), Ipv4Addr::new(10, 13, 37, i as u8)))
|
||||
.collect();
|
||||
let wide = Zone::new(ZoneName::new("lab").unwrap(), many);
|
||||
let query = ask("host.lab", TYPE::A);
|
||||
let full = respond(&wide, &query).unwrap();
|
||||
assert!(
|
||||
full.len() > CLASSIC_UDP_LIMIT,
|
||||
"the test needs a big answer"
|
||||
);
|
||||
|
||||
let short = truncate_for_udp(&query, full);
|
||||
assert!(short.len() <= CLASSIC_UDP_LIMIT);
|
||||
let parsed = simple_dns::Packet::parse(&short).unwrap();
|
||||
assert!(parsed.has_flags(PacketFlag::TRUNCATION));
|
||||
assert_eq!(parsed.questions.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_server_answers_over_udp_and_tcp_on_one_address() {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
let shared = SharedZone::new(zone());
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
|
||||
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
|
||||
let query = ask("ai.lab", TYPE::A);
|
||||
stream
|
||||
.write_all(&(query.len() as u16).to_be_bytes())
|
||||
.await
|
||||
.unwrap();
|
||||
stream.write_all(&query).await.unwrap();
|
||||
let mut header = [0u8; 2];
|
||||
stream.read_exact(&mut header).await.unwrap();
|
||||
let mut body = vec![0u8; u16::from_be_bytes(header) as usize];
|
||||
stream.read_exact(&mut body).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&body).unwrap();
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replacing_the_zone_changes_what_the_running_server_answers() {
|
||||
let shared = SharedZone::new(Zone::new(ZoneName::new("lab").unwrap(), []));
|
||||
let server = DnsServer::bind("127.0.0.1:0".parse().unwrap(), shared.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let addr = server.local_addr();
|
||||
let client = tokio::net::UdpSocket::bind("127.0.0.1:0").await.unwrap();
|
||||
let mut buffer = vec![0u8; MAX_MESSAGE_LEN];
|
||||
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
assert_eq!(
|
||||
simple_dns::Packet::parse(&buffer[..read]).unwrap().rcode(),
|
||||
RCODE::NameError
|
||||
);
|
||||
|
||||
// A member joins: no rebind, no dropped socket.
|
||||
shared.set(zone());
|
||||
client
|
||||
.send_to(&ask("music.lab", TYPE::A), addr)
|
||||
.await
|
||||
.unwrap();
|
||||
let read = client.recv(&mut buffer).await.unwrap();
|
||||
let reply = simple_dns::Packet::parse(&buffer[..read]).unwrap();
|
||||
assert_eq!(reply.rcode(), RCODE::NoError);
|
||||
assert_eq!(reply.answers.len(), 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,580 @@
|
||||
//! What the overlay answers to, as a DNS zone.
|
||||
//!
|
||||
//! Nothing here touches a socket or the operating system, and it does not
|
||||
//! depend on a DNS wire library either: it takes a roster and a question and
|
||||
//! says what the answer is. That is what makes the interesting parts — which
|
||||
//! names exist, what "does not exist" means as against "exists with nothing
|
||||
//! of that type", and what is outside the zone entirely — testable on their
|
||||
//! own.
|
||||
//!
|
||||
//! # Where the names come from
|
||||
//!
|
||||
//! From signed state, which is why a member that is switched off still
|
||||
//! resolves. Its claim outlived the session, so the name and the address are
|
||||
//! both still there to answer with. Nothing is invented for a member that
|
||||
//! claimed neither.
|
||||
//!
|
||||
//! Only IPv4 is served. The IPv6 overlay address is derived from a
|
||||
//! WireGuard key that travels in live announcements and is not in signed
|
||||
//! state, so it cannot be answered for a member that is away — and answering
|
||||
//! for some members and not others depending on whether they happen to be
|
||||
//! online is worse than not answering at all.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
/// Longest a DNS name may be, in the presentation form used here.
|
||||
const MAX_NAME_LEN: usize = 253;
|
||||
/// Longest one label may be.
|
||||
const MAX_LABEL_LEN: usize = 63;
|
||||
|
||||
/// Why a zone name cannot be used.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
pub enum ZoneError {
|
||||
/// The name was empty, or became empty once normalised.
|
||||
#[error("a zone name must have at least one label")]
|
||||
Empty,
|
||||
/// One label was unusable.
|
||||
#[error("`{label}` is not a usable DNS label: {reason}")]
|
||||
Label {
|
||||
/// The offending label.
|
||||
label: String,
|
||||
/// What is wrong with it.
|
||||
reason: &'static str,
|
||||
},
|
||||
/// The whole name is too long.
|
||||
#[error("a zone name must be at most {MAX_NAME_LEN} characters")]
|
||||
TooLong,
|
||||
}
|
||||
|
||||
/// A validated, canonical zone name.
|
||||
///
|
||||
/// Held without a trailing dot and lower-cased, so comparison is a plain
|
||||
/// string comparison rather than a special case at every use.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct ZoneName(String);
|
||||
|
||||
impl ZoneName {
|
||||
/// Validates and normalises a zone name.
|
||||
pub fn new(raw: &str) -> Result<Self, ZoneError> {
|
||||
let trimmed = raw.trim().trim_end_matches('.').to_ascii_lowercase();
|
||||
if trimmed.is_empty() {
|
||||
return Err(ZoneError::Empty);
|
||||
}
|
||||
if trimmed.len() > MAX_NAME_LEN {
|
||||
return Err(ZoneError::TooLong);
|
||||
}
|
||||
for label in trimmed.split('.') {
|
||||
let reason = if label.is_empty() {
|
||||
Some("it is empty")
|
||||
} else if label.len() > MAX_LABEL_LEN {
|
||||
Some("it is longer than 63 characters")
|
||||
} else if !label
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
|
||||
{
|
||||
Some("only letters, digits, `-` and `_` are allowed")
|
||||
} else if label.starts_with('-') || label.ends_with('-') {
|
||||
Some("a label may not start or end with `-`")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(reason) = reason {
|
||||
return Err(ZoneError::Label {
|
||||
label: label.to_string(),
|
||||
reason,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(Self(trimmed))
|
||||
}
|
||||
|
||||
/// The name, without a trailing dot.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// The last label, which is what would collide with a real top-level
|
||||
/// domain.
|
||||
pub fn top_label(&self) -> &str {
|
||||
self.0.rsplit('.').next().unwrap_or(&self.0)
|
||||
}
|
||||
|
||||
/// What is worrying about this zone name, if anything.
|
||||
///
|
||||
/// A warning and never a refusal: the name is the user's to choose, and
|
||||
/// a private zone that shadows a public one is a decision, not a
|
||||
/// mistake. Saying nothing would let it be an accident.
|
||||
pub fn collision(&self) -> Option<String> {
|
||||
let top = self.top_label();
|
||||
// Reserved for exactly this use and never delegated, so nothing to
|
||||
// say. See RFC 6761 and RFC 8375.
|
||||
const RESERVED: &[&str] = &[
|
||||
"internal",
|
||||
"home",
|
||||
"test",
|
||||
"example",
|
||||
"invalid",
|
||||
"localhost",
|
||||
];
|
||||
if RESERVED.contains(&top) {
|
||||
return None;
|
||||
}
|
||||
if top == "local" {
|
||||
return Some(
|
||||
"`.local` belongs to multicast DNS: on a host running Avahi or \
|
||||
systemd-resolved's mDNS, names under it are resolved by that and \
|
||||
not by this agent"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
if tld::exist_case_insensitive(top) {
|
||||
return Some(format!(
|
||||
"`.{top}` is a real top-level domain, so every public name under it \
|
||||
becomes unreachable from this host while the overlay is up"
|
||||
));
|
||||
}
|
||||
// Not delegated today is not a promise about tomorrow.
|
||||
(!self.0.contains('.')).then(|| {
|
||||
format!(
|
||||
"`.{top}` is not a delegated top-level domain today, but it could \
|
||||
become one; `.internal` is reserved for private use and never will"
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether `name` is this zone or sits under it.
|
||||
///
|
||||
/// Compared label-wise, so `evilzone` does not count as being under
|
||||
/// `zone`.
|
||||
pub fn covers(&self, name: &str) -> bool {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
name == self.0
|
||||
|| name
|
||||
.strip_suffix(&self.0)
|
||||
.is_some_and(|rest| rest.ends_with('.'))
|
||||
}
|
||||
|
||||
/// The part of `name` below this zone, if it is under it.
|
||||
fn relative(&self, name: &str) -> Option<String> {
|
||||
let name = name.trim_end_matches('.').to_ascii_lowercase();
|
||||
if name == self.0 {
|
||||
return Some(String::new());
|
||||
}
|
||||
let rest = name.strip_suffix(&self.0)?;
|
||||
let rest = rest.strip_suffix('.')?;
|
||||
(!rest.is_empty()).then(|| rest.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// The kinds of question this zone knows how to answer.
|
||||
///
|
||||
/// Its own enum rather than the wire library's, so the decision of what to
|
||||
/// answer does not depend on how a packet is encoded.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Query {
|
||||
/// An IPv4 address.
|
||||
A,
|
||||
/// A name for an address.
|
||||
Ptr,
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The zone's name servers.
|
||||
Ns,
|
||||
/// Anything else, including AAAA.
|
||||
Other,
|
||||
}
|
||||
|
||||
/// What the zone has to say.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Answer {
|
||||
/// Addresses for the name asked about.
|
||||
Addresses(Vec<Ipv4Addr>),
|
||||
/// A name for the address asked about.
|
||||
Name(String),
|
||||
/// The zone's start of authority.
|
||||
Soa,
|
||||
/// The name exists here but has nothing of the type asked for.
|
||||
///
|
||||
/// Distinct from [`Answer::NoSuchName`] because the two are different
|
||||
/// answers on the wire: this one is a success with no records, and a
|
||||
/// resolver must not take it as proof the name is absent. Getting them
|
||||
/// the wrong way round teaches a resolver to cache the wrong thing.
|
||||
NoData,
|
||||
/// No such name in this zone.
|
||||
NoSuchName,
|
||||
/// Not a name this zone is responsible for.
|
||||
NotOurs,
|
||||
}
|
||||
|
||||
/// The names and addresses of one network, ready to answer questions.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Zone {
|
||||
origin: ZoneName,
|
||||
/// Name relative to the origin, to the addresses it answers to.
|
||||
hosts: BTreeMap<String, Vec<Ipv4Addr>>,
|
||||
/// Address to the name that holds it.
|
||||
names: BTreeMap<Ipv4Addr, String>,
|
||||
/// Changes whenever the contents do.
|
||||
serial: u32,
|
||||
}
|
||||
|
||||
impl Zone {
|
||||
/// Builds a zone from the members that have both a name and an address.
|
||||
///
|
||||
/// A member with one but not the other contributes nothing: a name with
|
||||
/// no address cannot be answered, and an address with no name has nothing
|
||||
/// to be asked about.
|
||||
pub fn new(origin: ZoneName, members: impl IntoIterator<Item = (String, Ipv4Addr)>) -> Self {
|
||||
let mut hosts: BTreeMap<String, Vec<Ipv4Addr>> = BTreeMap::new();
|
||||
let mut names: BTreeMap<Ipv4Addr, String> = BTreeMap::new();
|
||||
for (hostname, address) in members {
|
||||
let hostname = hostname.trim_matches('.').to_ascii_lowercase();
|
||||
if hostname.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let addresses = hosts.entry(hostname.clone()).or_default();
|
||||
if !addresses.contains(&address) {
|
||||
addresses.push(address);
|
||||
}
|
||||
// First name wins, and the map is ordered, so the reverse answer
|
||||
// is the same on every replica rather than depending on the order
|
||||
// records happened to arrive in.
|
||||
names.entry(address).or_insert(hostname);
|
||||
}
|
||||
for addresses in hosts.values_mut() {
|
||||
addresses.sort();
|
||||
}
|
||||
|
||||
let serial = content_serial(&hosts);
|
||||
Self {
|
||||
origin,
|
||||
hosts,
|
||||
names,
|
||||
serial,
|
||||
}
|
||||
}
|
||||
|
||||
/// The zone's origin.
|
||||
pub fn origin(&self) -> &ZoneName {
|
||||
&self.origin
|
||||
}
|
||||
|
||||
/// A number that changes whenever the contents do.
|
||||
pub fn serial(&self) -> u32 {
|
||||
self.serial
|
||||
}
|
||||
|
||||
/// How many names it answers for.
|
||||
pub fn len(&self) -> usize {
|
||||
self.hosts.len()
|
||||
}
|
||||
|
||||
/// Whether it answers for nothing.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.hosts.is_empty()
|
||||
}
|
||||
|
||||
/// Every name it answers for, with its addresses.
|
||||
pub fn entries(&self) -> impl Iterator<Item = (&str, &[Ipv4Addr])> {
|
||||
self.hosts
|
||||
.iter()
|
||||
.map(|(name, addresses)| (name.as_str(), addresses.as_slice()))
|
||||
}
|
||||
|
||||
/// The reverse zones this zone is authoritative for.
|
||||
///
|
||||
/// Only when the range lands on an octet boundary. Claiming a reverse
|
||||
/// zone larger than the range would shadow reverse lookups for addresses
|
||||
/// that are nothing to do with us, which is worse than not answering.
|
||||
pub fn reverse_origin(base: Ipv4Addr, prefix_len: u8) -> Option<String> {
|
||||
let octets = base.octets();
|
||||
match prefix_len {
|
||||
8 => Some(format!("{}.in-addr.arpa", octets[0])),
|
||||
16 => Some(format!("{}.{}.in-addr.arpa", octets[1], octets[0])),
|
||||
24 => Some(format!(
|
||||
"{}.{}.{}.in-addr.arpa",
|
||||
octets[2], octets[1], octets[0]
|
||||
)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Answers one question.
|
||||
pub fn lookup(&self, qname: &str, query: Query) -> Answer {
|
||||
if let Some(address) = reverse_address(qname) {
|
||||
return match self.names.get(&address) {
|
||||
Some(name) if query == Query::Ptr => {
|
||||
Answer::Name(format!("{name}.{}", self.origin.as_str()))
|
||||
}
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
};
|
||||
}
|
||||
|
||||
let Some(relative) = self.origin.relative(qname) else {
|
||||
return Answer::NotOurs;
|
||||
};
|
||||
|
||||
// The apex: the zone itself exists whether or not anybody is in it.
|
||||
if relative.is_empty() {
|
||||
return match query {
|
||||
Query::Soa => Answer::Soa,
|
||||
Query::Ns => Answer::NoData,
|
||||
_ => Answer::NoData,
|
||||
};
|
||||
}
|
||||
|
||||
match self.hosts.get(&relative) {
|
||||
Some(addresses) if query == Query::A => Answer::Addresses(addresses.clone()),
|
||||
// The name is here, it just has no AAAA and never will while
|
||||
// only IPv4 is served. Saying "no such name" instead would tell
|
||||
// a resolver to stop asking for the A record too.
|
||||
Some(_) => Answer::NoData,
|
||||
None => Answer::NoSuchName,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The address a reverse name asks about, if it is one.
|
||||
fn reverse_address(qname: &str) -> Option<Ipv4Addr> {
|
||||
let name = qname.trim_end_matches('.').to_ascii_lowercase();
|
||||
let rest = name.strip_suffix(".in-addr.arpa")?;
|
||||
let mut octets = [0u8; 4];
|
||||
let mut seen = 0;
|
||||
for (index, part) in rest.split('.').enumerate() {
|
||||
if index >= 4 {
|
||||
return None;
|
||||
}
|
||||
octets[3 - index] = part.parse().ok()?;
|
||||
seen += 1;
|
||||
}
|
||||
(seen == 4).then(|| Ipv4Addr::from(octets))
|
||||
}
|
||||
|
||||
/// A serial that changes with the contents and not otherwise.
|
||||
///
|
||||
/// Derived rather than counted, so two agents holding the same roster agree,
|
||||
/// and a restart does not go backwards.
|
||||
fn content_serial(hosts: &BTreeMap<String, Vec<Ipv4Addr>>) -> u32 {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
for (name, addresses) in hosts {
|
||||
name.hash(&mut hasher);
|
||||
for address in addresses {
|
||||
address.octets().hash(&mut hasher);
|
||||
}
|
||||
}
|
||||
// Never zero: a zero serial is legal but reads like "unset" in a log.
|
||||
(hasher.finish() as u32).max(1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
fn zone() -> Zone {
|
||||
Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[
|
||||
("music".to_string(), Ipv4Addr::new(10, 13, 37, 237)),
|
||||
("ai".to_string(), Ipv4Addr::new(10, 13, 37, 69)),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_is_normalised_and_checked() {
|
||||
assert_eq!(ZoneName::new("LAB.").unwrap().as_str(), "lab");
|
||||
assert_eq!(
|
||||
ZoneName::new(" lab.internal ").unwrap().as_str(),
|
||||
"lab.internal"
|
||||
);
|
||||
assert_eq!(
|
||||
ZoneName::new("lab.internal").unwrap().top_label(),
|
||||
"internal"
|
||||
);
|
||||
|
||||
assert_eq!(ZoneName::new("").unwrap_err(), ZoneError::Empty);
|
||||
assert_eq!(ZoneName::new(".").unwrap_err(), ZoneError::Empty);
|
||||
assert!(matches!(
|
||||
ZoneName::new("a..b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("-lab").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new("la b").unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
ZoneName::new(&"x".repeat(64)).unwrap_err(),
|
||||
ZoneError::Label { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zone_name_that_shadows_a_public_one_is_flagged_but_allowed() {
|
||||
// Flagged, never refused: shadowing is the user's decision to make.
|
||||
let ru = ZoneName::new("ru").unwrap();
|
||||
let warning = ru.collision().expect("a real TLD is worth mentioning");
|
||||
assert!(warning.contains("real top-level domain"), "{warning}");
|
||||
|
||||
assert!(ZoneName::new("com").unwrap().collision().is_some());
|
||||
assert!(ZoneName::new("lab.com").unwrap().collision().is_some());
|
||||
|
||||
// Reserved for private use, so nothing to say.
|
||||
for quiet in ["internal", "lab.internal", "home", "test", "invalid"] {
|
||||
assert_eq!(ZoneName::new(quiet).unwrap().collision(), None, "{quiet}");
|
||||
}
|
||||
|
||||
// `.local` is not a delegated TLD, but it is not free either.
|
||||
let local = ZoneName::new("local").unwrap().collision().unwrap();
|
||||
assert!(local.contains("multicast DNS"), "{local}");
|
||||
|
||||
// An undelegated single label is a maybe, not a yes.
|
||||
let lab = ZoneName::new("lab").unwrap().collision().unwrap();
|
||||
assert!(lab.contains("could"), "{lab}");
|
||||
// A multi-label name under something undelegated is not worth a word.
|
||||
assert_eq!(ZoneName::new("a.lab").unwrap().collision(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_neighbouring_name_is_not_inside_the_zone() {
|
||||
// `evillab` ends with `lab`, and a suffix comparison that forgot the
|
||||
// label boundary would hand it to us.
|
||||
let origin = ZoneName::new("lab").unwrap();
|
||||
assert!(origin.covers("lab"));
|
||||
assert!(origin.covers("music.lab."));
|
||||
assert!(origin.covers("a.b.lab"));
|
||||
assert!(!origin.covers("evillab"));
|
||||
assert!(!origin.covers("lab.example.com"));
|
||||
assert!(!origin.covers("example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_member_resolves_by_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("music.lab", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
// Case and a trailing dot are the same question.
|
||||
assert_eq!(
|
||||
zone.lookup("MUSIC.LAB.", Query::A),
|
||||
Answer::Addresses(vec![Ipv4Addr::new(10, 13, 37, 237)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_with_no_record_of_that_type_is_not_a_missing_name() {
|
||||
// AAAA for a member that exists must be NODATA, not NXDOMAIN: an
|
||||
// NXDOMAIN would tell the resolver the name is absent and stop it
|
||||
// asking for the A record.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("music.lab", Query::Other), Answer::NoData);
|
||||
assert_eq!(zone.lookup("nobody.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn questions_outside_the_zone_are_refused_rather_than_denied() {
|
||||
// Denying them would be a lie: this server knows nothing about them.
|
||||
let zone = zone();
|
||||
assert_eq!(zone.lookup("example.com", Query::A), Answer::NotOurs);
|
||||
assert_eq!(zone.lookup("evillab", Query::A), Answer::NotOurs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_apex_exists_even_with_nobody_in_the_network() {
|
||||
let empty = Zone::new(ZoneName::new("lab").unwrap(), []);
|
||||
assert!(empty.is_empty());
|
||||
assert_eq!(empty.lookup("lab", Query::Soa), Answer::Soa);
|
||||
assert_eq!(empty.lookup("lab", Query::A), Answer::NoData);
|
||||
assert_eq!(empty.lookup("music.lab", Query::A), Answer::NoSuchName);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_resolves_back_to_its_name() {
|
||||
let zone = zone();
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::Name("music.lab".into())
|
||||
);
|
||||
assert_eq!(
|
||||
zone.lookup("9.37.13.10.in-addr.arpa", Query::Ptr),
|
||||
Answer::NoSuchName
|
||||
);
|
||||
// A reverse name asked for the wrong type is still a name we know.
|
||||
assert_eq!(
|
||||
zone.lookup("237.37.13.10.in-addr.arpa", Query::A),
|
||||
Answer::NoData
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reverse_zone_is_claimed_only_when_it_is_exactly_ours() {
|
||||
// Claiming a reverse zone wider than the range would shadow lookups
|
||||
// for addresses that have nothing to do with this network.
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 24).as_deref(),
|
||||
Some("37.13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(
|
||||
Zone::reverse_origin(Ipv4Addr::new(10, 13, 0, 0), 16).as_deref(),
|
||||
Some("13.10.in-addr.arpa")
|
||||
);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(10, 13, 37, 0), 25), None);
|
||||
assert_eq!(Zone::reverse_origin(Ipv4Addr::new(100, 64, 0, 0), 10), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn members_with_nothing_to_say_are_left_out() {
|
||||
let zone = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[(String::new(), Ipv4Addr::new(10, 0, 0, 1))],
|
||||
);
|
||||
assert!(zone.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_members_sharing_a_name_both_answer_and_the_order_is_stable() {
|
||||
// The state layer resolves name ownership; if two records still
|
||||
// reach here, answering with both beats picking one at random.
|
||||
let build = |flip: bool| {
|
||||
let members = if flip {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 1)),
|
||||
("music".to_string(), Ipv4Addr::new(10, 0, 0, 2)),
|
||||
]
|
||||
};
|
||||
Zone::new(ZoneName::new("lab").unwrap(), members)
|
||||
};
|
||||
assert_eq!(
|
||||
build(false).lookup("music.lab", Query::A),
|
||||
build(true).lookup("music.lab", Query::A)
|
||||
);
|
||||
assert_eq!(build(false).serial(), build(true).serial());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_serial_follows_the_contents() {
|
||||
let a = zone();
|
||||
let b = zone();
|
||||
assert_eq!(a.serial(), b.serial(), "the same roster, the same serial");
|
||||
|
||||
let changed = Zone::new(
|
||||
ZoneName::new("lab").unwrap(),
|
||||
[("music".to_string(), Ipv4Addr::new(10, 13, 37, 238))],
|
||||
);
|
||||
assert_ne!(a.serial(), changed.serial());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Error types for the whole library.
|
||||
//!
|
||||
//! The library never panics on untrusted network input: every decoding and
|
||||
//! validation failure is represented as a [`ProtocolError`] and surfaced as a
|
||||
//! rejected message or session, never as a process abort.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Convenient result alias used across the crate.
|
||||
pub type Result<T, E = Error> = std::result::Result<T, E>;
|
||||
|
||||
/// Top level error type of the library.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum Error {
|
||||
/// The supplied network name does not satisfy the documented rules.
|
||||
#[error("invalid network name: {0}")]
|
||||
InvalidNetworkName(&'static str),
|
||||
|
||||
/// The supplied network secret does not satisfy the documented rules.
|
||||
///
|
||||
/// The secret itself is never included in the message.
|
||||
#[error("invalid network secret: {0}")]
|
||||
InvalidNetworkSecret(&'static str),
|
||||
|
||||
/// A textual identifier could not be parsed.
|
||||
#[error("invalid {kind}: {reason}")]
|
||||
InvalidEncoding {
|
||||
/// What was being parsed, e.g. `network id`.
|
||||
kind: &'static str,
|
||||
/// Why parsing failed.
|
||||
reason: &'static str,
|
||||
},
|
||||
|
||||
/// The mandatory state directory is already owned by another live agent.
|
||||
///
|
||||
/// This is an ownership lock, not a "file exists" check.
|
||||
#[error("state directory {path} is owned by another running agent instance")]
|
||||
StateLocked {
|
||||
/// Directory that could not be locked.
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
/// The mandatory state store is unusable. It is never silently recreated.
|
||||
#[error("mandatory state store at {path} is unusable and was NOT reset: {reason}")]
|
||||
StateCorrupted {
|
||||
/// Path of the unusable store.
|
||||
path: PathBuf,
|
||||
/// Human readable reason, free of secrets.
|
||||
reason: String,
|
||||
},
|
||||
|
||||
/// The mandatory state store has a schema this build cannot handle.
|
||||
#[error(
|
||||
"state store schema version {found} is not supported by this build (supported: {supported})"
|
||||
)]
|
||||
UnsupportedSchema {
|
||||
/// Version found on disk.
|
||||
found: i64,
|
||||
/// Version this build writes.
|
||||
supported: i64,
|
||||
},
|
||||
|
||||
/// A storage operation failed.
|
||||
#[error("storage error: {0}")]
|
||||
Storage(String),
|
||||
|
||||
/// A filesystem operation failed.
|
||||
#[error("io error at {path}: {source}")]
|
||||
Io {
|
||||
/// Path involved in the failure.
|
||||
path: PathBuf,
|
||||
/// Underlying error.
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
/// Binding or driving the iroh endpoint failed.
|
||||
#[error("iroh endpoint error: {0}")]
|
||||
Endpoint(String),
|
||||
|
||||
/// A control protocol violation.
|
||||
#[error(transparent)]
|
||||
Protocol(#[from] ProtocolError),
|
||||
|
||||
/// The requested network is not currently active on this agent.
|
||||
#[error("network {0} is not active")]
|
||||
NetworkNotActive(NetworkId),
|
||||
|
||||
/// The requested network is already active on this agent.
|
||||
#[error("network {0} is already active")]
|
||||
NetworkAlreadyActive(NetworkId),
|
||||
|
||||
/// The requested network is not configured in the state store.
|
||||
#[error("network {0} is not configured")]
|
||||
NetworkUnknown(NetworkId),
|
||||
|
||||
/// No session with that peer exists in the given network.
|
||||
#[error("no authenticated session with peer {peer} in network {network}")]
|
||||
NoSuchPeer {
|
||||
/// Network the lookup was scoped to.
|
||||
network: NetworkId,
|
||||
/// Peer that was looked up, short form.
|
||||
peer: String,
|
||||
},
|
||||
|
||||
/// The agent is shutting down or already stopped.
|
||||
#[error("agent is stopped")]
|
||||
Stopped,
|
||||
|
||||
/// Discovery backend failure. Never fatal for the agent.
|
||||
#[error("discovery error: {0}")]
|
||||
Discovery(String),
|
||||
}
|
||||
|
||||
/// Errors produced while speaking the control protocol.
|
||||
///
|
||||
/// These always result in rejecting a single message or a single session. They
|
||||
/// never stop other networks, other peers, or the agent itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum ProtocolError {
|
||||
/// The peer announced an unsupported control protocol version.
|
||||
#[error("unsupported control protocol version {found} (this build speaks {supported})")]
|
||||
UnsupportedVersion {
|
||||
/// Version announced by the peer.
|
||||
found: u16,
|
||||
/// Version this build speaks.
|
||||
supported: u16,
|
||||
},
|
||||
|
||||
/// A frame header announced more bytes than the configured limit allows.
|
||||
///
|
||||
/// Checked *before* any buffer of that size is allocated.
|
||||
#[error("frame of {announced} bytes exceeds the {limit} byte limit")]
|
||||
FrameTooLarge {
|
||||
/// Length announced in the frame header.
|
||||
announced: u64,
|
||||
/// Configured limit.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// A frame could not be decoded.
|
||||
#[error("malformed frame: {0}")]
|
||||
Malformed(&'static str),
|
||||
|
||||
/// The stream ended before a complete frame was read.
|
||||
#[error("stream closed while reading a frame")]
|
||||
StreamClosed,
|
||||
|
||||
/// An underlying stream read or write failed.
|
||||
#[error("stream error: {0}")]
|
||||
Stream(String),
|
||||
|
||||
/// The peer failed to prove knowledge of the derived network secret.
|
||||
#[error("network authentication failed")]
|
||||
AuthenticationFailed,
|
||||
|
||||
/// The peer asked for a network this agent does not have active.
|
||||
#[error("peer requested an unknown or inactive network")]
|
||||
UnknownNetwork,
|
||||
|
||||
/// A message carried a network id different from the session's network.
|
||||
#[error("message network id does not match the authenticated session network")]
|
||||
NetworkMismatch,
|
||||
|
||||
/// A regular control message arrived before the handshake completed.
|
||||
#[error("control message received before authentication completed")]
|
||||
NotAuthenticated,
|
||||
|
||||
/// A handshake step did not complete within the configured timeout.
|
||||
#[error("handshake timed out")]
|
||||
HandshakeTimeout,
|
||||
|
||||
/// A field exceeded its configured bound.
|
||||
#[error("field `{field}` exceeds its limit ({len} > {limit})")]
|
||||
FieldTooLarge {
|
||||
/// Name of the offending field.
|
||||
field: &'static str,
|
||||
/// Observed length.
|
||||
len: usize,
|
||||
/// Configured limit.
|
||||
limit: usize,
|
||||
},
|
||||
|
||||
/// The connection is not usable for deriving channel binding material.
|
||||
#[error("connection does not provide TLS exporter material: {0}")]
|
||||
NoChannelBinding(String),
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
//! Device identity and network space identity.
|
||||
//!
|
||||
//! These are two independent things and must not be confused:
|
||||
//!
|
||||
//! * [`DeviceIdentity`] wraps the persistent iroh [`SecretKey`]. Its public key
|
||||
//! *is* the iroh [`EndpointId`]. It survives restarts and survives a change of
|
||||
//! the network secret.
|
||||
//! * [`NetworkId`] identifies a network space and is derived purely from the
|
||||
//! network name and shared secret. It is unrelated to any device key.
|
||||
|
||||
mod network;
|
||||
|
||||
pub use network::{
|
||||
DiscoveryKey, IDENTITY_SCHEME, MAX_NETWORK_NAME_LEN, MAX_NETWORK_SECRET_LEN,
|
||||
MIN_NETWORK_SECRET_LEN, NetworkDescriptor, NetworkId, NetworkKeys, NetworkName, NetworkSecret,
|
||||
SECRET_TEXT_PREFIX,
|
||||
};
|
||||
|
||||
use iroh::{EndpointId, SecretKey};
|
||||
|
||||
/// The persistent identity of this device.
|
||||
///
|
||||
/// Created once and stored in the mandatory state store. Restarting the agent
|
||||
/// must not produce a new peer, so the stored secret key is always reused.
|
||||
/// Corruption of the stored key is reported as an error and never silently
|
||||
/// replaced by a fresh key.
|
||||
#[derive(Clone)]
|
||||
pub struct DeviceIdentity {
|
||||
secret: SecretKey,
|
||||
}
|
||||
|
||||
impl DeviceIdentity {
|
||||
/// Generates a brand new device identity.
|
||||
pub fn generate() -> Self {
|
||||
Self {
|
||||
secret: SecretKey::generate(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconstructs a device identity from its stored 32 secret key bytes.
|
||||
pub fn from_secret_bytes(bytes: &[u8; 32]) -> Self {
|
||||
Self {
|
||||
secret: SecretKey::from_bytes(bytes),
|
||||
}
|
||||
}
|
||||
|
||||
/// The iroh endpoint id, i.e. the public key of this device.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.secret.public()
|
||||
}
|
||||
|
||||
/// The raw secret key bytes, for persistence only.
|
||||
pub(crate) fn secret_bytes(&self) -> [u8; 32] {
|
||||
self.secret.to_bytes()
|
||||
}
|
||||
|
||||
/// A clone of the iroh secret key, for endpoint construction only.
|
||||
pub(crate) fn secret_key(&self) -> SecretKey {
|
||||
self.secret.clone()
|
||||
}
|
||||
|
||||
/// A clone of the key used to sign this device's own state records.
|
||||
///
|
||||
/// The same persistent identity the control plane authenticates, so a
|
||||
/// record signed today is still attributable after any absence.
|
||||
pub(crate) fn signing_key(&self) -> SecretKey {
|
||||
self.secret.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DeviceIdentity {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("DeviceIdentity")
|
||||
.field("endpoint_id", &self.endpoint_id().fmt_short().to_string())
|
||||
.field("secret", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,419 @@
|
||||
//! Deterministic network space identity.
|
||||
//!
|
||||
//! A *network space* is fully determined by a [`NetworkName`] and a
|
||||
//! [`NetworkSecret`]. Two agents that were given the same pair derive the same
|
||||
//! [`NetworkId`], [`DiscoveryKey`] and handshake authentication key, with no
|
||||
//! coordination, no creator, no timestamp and no leader election.
|
||||
//!
|
||||
//! # Derivation scheme (`tsunagi-network-id-v1`)
|
||||
//!
|
||||
//! All inputs are encoded with an unambiguous length-prefixed encoding, written
|
||||
//! here as `LP(x) = u32_be(x.len()) || x`. String concatenation is never used.
|
||||
//!
|
||||
//! ```text
|
||||
//! salt = SHA-256( LP("tsunagi-network-id-v1") || LP(name_utf8) )
|
||||
//! prk = HKDF-SHA256-Extract(salt, ikm = secret_bytes)
|
||||
//! info(label) = LP("tsunagi-network-id-v1") || LP(label)
|
||||
//! network_id = HKDF-Expand(prk, info("network-id"), 32)
|
||||
//! discovery_key = HKDF-Expand(prk, info("discovery-key"), 32)
|
||||
//! auth_key = HKDF-Expand(prk, info("handshake-auth"), 32)
|
||||
//! ```
|
||||
//!
|
||||
//! HKDF's `info` parameter is what separates the three derived values
|
||||
//! (RFC 5869 §3.2). Learning `discovery_key` — which is published to a
|
||||
//! discovery backend and is therefore semi-public — does not reveal `auth_key`,
|
||||
//! so the discovery key must never be used as a password or bearer token.
|
||||
//!
|
||||
//! The scheme label is versioned and frozen. Upgrading this crate or bumping
|
||||
//! the control protocol version must not change an existing [`NetworkId`].
|
||||
|
||||
use hkdf::Hkdf;
|
||||
use sha2::{Digest, Sha256};
|
||||
use zeroize::{Zeroize, Zeroizing};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// Frozen label identifying the network identity derivation scheme.
|
||||
///
|
||||
/// Changing this string creates a different, incompatible network space for the
|
||||
/// same name and secret. It must never be changed casually.
|
||||
pub const IDENTITY_SCHEME: &str = "tsunagi-network-id-v1";
|
||||
|
||||
/// Maximum length of a network name in UTF-8 bytes.
|
||||
pub const MAX_NETWORK_NAME_LEN: usize = 64;
|
||||
|
||||
/// Minimum length of a network secret in bytes.
|
||||
///
|
||||
/// The proof-of-concept targets high-entropy shared secrets. See
|
||||
/// [`NetworkSecret::generate`].
|
||||
pub const MIN_NETWORK_SECRET_LEN: usize = 16;
|
||||
|
||||
/// Maximum length of a network secret in bytes.
|
||||
pub const MAX_NETWORK_SECRET_LEN: usize = 1024;
|
||||
|
||||
/// Human-readable prefix of the canonical secret text encoding.
|
||||
pub const SECRET_TEXT_PREFIX: &str = "tsn1";
|
||||
|
||||
/// Appends `LP(bytes) = u32_be(len) || bytes` to `out`.
|
||||
///
|
||||
/// Panics are impossible here: the caller-provided slices are already bounded by
|
||||
/// [`MAX_NETWORK_NAME_LEN`] / [`MAX_NETWORK_SECRET_LEN`], and the cast is
|
||||
/// saturating for anything larger.
|
||||
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// The name half of a network space.
|
||||
///
|
||||
/// Rules, deliberately strict and never silently applied:
|
||||
///
|
||||
/// * 1..=[`MAX_NETWORK_NAME_LEN`] bytes of UTF-8.
|
||||
/// * No ASCII control characters.
|
||||
/// * No leading or trailing ASCII whitespace — such a name is **rejected**,
|
||||
/// not trimmed.
|
||||
/// * Used verbatim. No case folding and no Unicode normalisation is performed,
|
||||
/// so `Home` and `home` are different network spaces.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NetworkName(String);
|
||||
|
||||
impl NetworkName {
|
||||
/// Validates and wraps a network name.
|
||||
pub fn new(name: impl Into<String>) -> Result<Self> {
|
||||
let name = name.into();
|
||||
if name.is_empty() {
|
||||
return Err(Error::InvalidNetworkName("must not be empty"));
|
||||
}
|
||||
if name.len() > MAX_NETWORK_NAME_LEN {
|
||||
return Err(Error::InvalidNetworkName(
|
||||
"must not exceed 64 bytes of UTF-8",
|
||||
));
|
||||
}
|
||||
if name.chars().any(|c| c.is_control()) {
|
||||
return Err(Error::InvalidNetworkName(
|
||||
"must not contain control characters",
|
||||
));
|
||||
}
|
||||
let trimmed = name.trim_matches(|c: char| c.is_ascii_whitespace());
|
||||
if trimmed.len() != name.len() {
|
||||
return Err(Error::InvalidNetworkName(
|
||||
"must not have leading or trailing ASCII whitespace",
|
||||
));
|
||||
}
|
||||
Ok(Self(name))
|
||||
}
|
||||
|
||||
/// Returns the name as a string slice.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetworkName {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.0)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for NetworkName {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
Self::new(s)
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared secret half of a network space.
|
||||
///
|
||||
/// This is the single shared secret the end user configures; "password" and
|
||||
/// "secret" refer to the same value. The bytes are used **verbatim**: never
|
||||
/// trimmed, case-folded, normalised or truncated.
|
||||
///
|
||||
/// The value is zeroized on drop and redacted from [`Debug`].
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub struct NetworkSecret(Zeroizing<Vec<u8>>);
|
||||
|
||||
impl NetworkSecret {
|
||||
/// Wraps raw secret bytes.
|
||||
///
|
||||
/// Requires at least [`MIN_NETWORK_SECRET_LEN`] bytes. This crate makes no
|
||||
/// security promises for short, low-entropy human passphrases: there is no
|
||||
/// PAKE here, so an offline guessing attack against a weak secret is cheap
|
||||
/// for anyone who can reach the handshake.
|
||||
pub fn from_bytes(bytes: impl Into<Vec<u8>>) -> Result<Self> {
|
||||
let bytes = Zeroizing::new(bytes.into());
|
||||
if bytes.len() < MIN_NETWORK_SECRET_LEN {
|
||||
return Err(Error::InvalidNetworkSecret(
|
||||
"must be at least 16 bytes; use NetworkSecret::generate()",
|
||||
));
|
||||
}
|
||||
if bytes.len() > MAX_NETWORK_SECRET_LEN {
|
||||
return Err(Error::InvalidNetworkSecret("must not exceed 1024 bytes"));
|
||||
}
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
|
||||
/// Generates a fresh 32-byte random secret.
|
||||
///
|
||||
/// This is the recommended way to create a network secret.
|
||||
pub fn generate() -> Self {
|
||||
let mut buf = vec![0u8; 32];
|
||||
rand::fill(&mut buf[..]);
|
||||
Self(Zeroizing::new(buf))
|
||||
}
|
||||
|
||||
/// Parses the canonical text form produced by [`NetworkSecret::encode`].
|
||||
///
|
||||
/// The format is `tsn1` followed by lowercase unpadded RFC 4648 base32.
|
||||
/// Parsing is strict: no whitespace, no case mixing in the payload.
|
||||
pub fn decode(text: &str) -> Result<Self> {
|
||||
let payload = text
|
||||
.strip_prefix(SECRET_TEXT_PREFIX)
|
||||
.ok_or(Error::InvalidNetworkSecret(
|
||||
"canonical secrets start with `tsn1`",
|
||||
))?;
|
||||
let bytes = data_encoding::BASE32_NOPAD
|
||||
.decode(payload.to_ascii_uppercase().as_bytes())
|
||||
.map_err(|_| Error::InvalidNetworkSecret("not valid base32"))?;
|
||||
Self::from_bytes(bytes)
|
||||
}
|
||||
|
||||
/// Encodes the secret in its canonical text form.
|
||||
///
|
||||
/// The returned string is zeroized on drop. Never log it.
|
||||
pub fn encode(&self) -> Zeroizing<String> {
|
||||
let mut encoded = data_encoding::BASE32_NOPAD.encode(&self.0);
|
||||
encoded.make_ascii_lowercase();
|
||||
let out = Zeroizing::new(format!("{SECRET_TEXT_PREFIX}{encoded}"));
|
||||
encoded.zeroize();
|
||||
out
|
||||
}
|
||||
|
||||
/// Exposes the raw secret bytes.
|
||||
///
|
||||
/// Callers must not log, serialise or copy these bytes into diagnostics.
|
||||
pub(crate) fn expose(&self) -> &[u8] {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkSecret {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str("NetworkSecret(<redacted>)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes 32 bytes as lowercase unpadded base32.
|
||||
fn b32(bytes: &[u8; 32]) -> String {
|
||||
let mut s = data_encoding::BASE32_NOPAD.encode(bytes);
|
||||
s.make_ascii_lowercase();
|
||||
s
|
||||
}
|
||||
|
||||
/// Decodes lowercase unpadded base32 into 32 bytes.
|
||||
fn unb32(kind: &'static str, s: &str) -> Result<[u8; 32]> {
|
||||
let bytes = data_encoding::BASE32_NOPAD
|
||||
.decode(s.to_ascii_uppercase().as_bytes())
|
||||
.map_err(|_| Error::InvalidEncoding {
|
||||
kind,
|
||||
reason: "not valid base32",
|
||||
})?;
|
||||
<[u8; 32]>::try_from(bytes.as_slice()).map_err(|_| Error::InvalidEncoding {
|
||||
kind,
|
||||
reason: "expected 32 bytes",
|
||||
})
|
||||
}
|
||||
|
||||
/// Public, non-secret identifier of a network space.
|
||||
///
|
||||
/// Safe to log, publish and put into status output. It does not authorise
|
||||
/// anything on its own: an attacker who knows a `NetworkId` still cannot pass
|
||||
/// the handshake without the secret.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct NetworkId([u8; 32]);
|
||||
|
||||
impl NetworkId {
|
||||
/// Returns the raw 32 bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Builds a network id from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
/// Returns a short prefix useful for logs.
|
||||
pub fn fmt_short(&self) -> String {
|
||||
b32(&self.0).chars().take(10).collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for NetworkId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&b32(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkId {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "NetworkId({})", self.fmt_short())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for NetworkId {
|
||||
type Err = Error;
|
||||
fn from_str(s: &str) -> Result<Self> {
|
||||
unb32("network id", s).map(Self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Lookup key used to find candidates for a network in a discovery backend.
|
||||
///
|
||||
/// Derived from the secret, so it is not published in the clear the way a
|
||||
/// [`NetworkId`] is. It is nevertheless **not** a credential: a discovery
|
||||
/// backend, or anyone observing it, learns nothing that helps pass the
|
||||
/// handshake.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||
pub struct DiscoveryKey([u8; 32]);
|
||||
|
||||
impl DiscoveryKey {
|
||||
/// Returns the raw 32 bytes.
|
||||
pub fn as_bytes(&self) -> &[u8; 32] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Builds a discovery key from raw bytes.
|
||||
pub fn from_bytes(bytes: [u8; 32]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for DiscoveryKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&b32(&self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for DiscoveryKey {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"DiscoveryKey({}…)",
|
||||
b32(&self.0).chars().take(10).collect::<String>()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The deterministic, immutable description of a network space.
|
||||
///
|
||||
/// There is no competing genesis: this value contains no creator identity, no
|
||||
/// creation time and no owner signature, so two agents started independently
|
||||
/// with the same parameters produce byte-identical descriptors. The secret is
|
||||
/// never part of it.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct NetworkDescriptor {
|
||||
/// Frozen derivation scheme label, see [`IDENTITY_SCHEME`].
|
||||
pub scheme: &'static str,
|
||||
/// The network name.
|
||||
pub name: NetworkName,
|
||||
/// The derived public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
}
|
||||
|
||||
impl NetworkDescriptor {
|
||||
/// Canonical, unambiguous byte encoding of the descriptor.
|
||||
pub fn to_canonical_bytes(&self) -> Vec<u8> {
|
||||
let mut out = Vec::new();
|
||||
push_lp(&mut out, self.scheme.as_bytes());
|
||||
push_lp(&mut out, self.name.as_str().as_bytes());
|
||||
push_lp(&mut out, &self.network_id.0);
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// All key material derived from a [`NetworkName`] and [`NetworkSecret`].
|
||||
///
|
||||
/// The authentication key is zeroized on drop and never leaves this crate.
|
||||
#[derive(Clone)]
|
||||
pub struct NetworkKeys {
|
||||
network_id: NetworkId,
|
||||
discovery_key: DiscoveryKey,
|
||||
auth_key: Zeroizing<[u8; 32]>,
|
||||
name: NetworkName,
|
||||
}
|
||||
|
||||
impl NetworkKeys {
|
||||
/// Derives all network key material.
|
||||
///
|
||||
/// This is a pure function of `(name, secret)`. It does not depend on the
|
||||
/// device key, the hostname, the wall clock or the order in which agents
|
||||
/// start.
|
||||
pub fn derive(name: &NetworkName, secret: &NetworkSecret) -> Self {
|
||||
let mut salt_input = Vec::new();
|
||||
push_lp(&mut salt_input, IDENTITY_SCHEME.as_bytes());
|
||||
push_lp(&mut salt_input, name.as_str().as_bytes());
|
||||
let salt = Sha256::digest(&salt_input);
|
||||
|
||||
let hk = Hkdf::<Sha256>::new(Some(&salt), secret.expose());
|
||||
|
||||
let expand = |label: &str| -> [u8; 32] {
|
||||
let mut info = Vec::new();
|
||||
push_lp(&mut info, IDENTITY_SCHEME.as_bytes());
|
||||
push_lp(&mut info, label.as_bytes());
|
||||
let mut okm = [0u8; 32];
|
||||
// 32 bytes is far below HKDF-SHA256's 255*32 limit, so this cannot fail.
|
||||
match hk.expand(&info, &mut okm) {
|
||||
Ok(()) => okm,
|
||||
Err(_) => unreachable!("HKDF-SHA256 expand of 32 bytes cannot fail"),
|
||||
}
|
||||
};
|
||||
|
||||
Self {
|
||||
network_id: NetworkId(expand("network-id")),
|
||||
discovery_key: DiscoveryKey(expand("discovery-key")),
|
||||
auth_key: Zeroizing::new(expand("handshake-auth")),
|
||||
name: name.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The public network identifier.
|
||||
pub fn network_id(&self) -> NetworkId {
|
||||
self.network_id
|
||||
}
|
||||
|
||||
/// The discovery lookup key.
|
||||
pub fn discovery_key(&self) -> DiscoveryKey {
|
||||
self.discovery_key
|
||||
}
|
||||
|
||||
/// The network name.
|
||||
pub fn name(&self) -> &NetworkName {
|
||||
&self.name
|
||||
}
|
||||
|
||||
/// The immutable descriptor of this network space.
|
||||
pub fn descriptor(&self) -> NetworkDescriptor {
|
||||
NetworkDescriptor {
|
||||
scheme: IDENTITY_SCHEME,
|
||||
name: self.name.clone(),
|
||||
network_id: self.network_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The handshake authentication key. Crate-internal on purpose.
|
||||
pub(crate) fn auth_key(&self) -> &[u8; 32] {
|
||||
&self.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for NetworkKeys {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("NetworkKeys")
|
||||
.field("name", &self.name)
|
||||
.field("network_id", &self.network_id)
|
||||
.field("discovery_key", &self.discovery_key)
|
||||
.field("auth_key", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
//! The local control interface.
|
||||
//!
|
||||
//! This is how a command line tool asks a running agent what it is doing. It
|
||||
//! is deliberately **an adapter over the public API, not part of the core**:
|
||||
//! nothing in [`crate::agent`] knows this module exists, so a Windows named
|
||||
//! pipe or an authenticated loopback socket can be added beside it without
|
||||
//! touching anything else.
|
||||
//!
|
||||
//! It is also a different interface from the peer-to-peer control protocol in
|
||||
//! [`crate::proto`]. That one is between machines and is authenticated by the
|
||||
//! network secret; this one is between processes on one machine and is
|
||||
//! authorised by filesystem permissions.
|
||||
//!
|
||||
//! # Access
|
||||
//!
|
||||
//! The socket lives inside the agent's state directory, which is owner-only,
|
||||
//! and the socket itself is created with mode `0600`. There is no
|
||||
//! unauthenticated listener reachable by other local users, and nothing is
|
||||
//! exposed on the network.
|
||||
//!
|
||||
//! # Wire format
|
||||
//!
|
||||
//! Length-prefixed postcard, with the same frame bounds the network protocol
|
||||
//! uses. The report types here are a stable data transfer format of their own
|
||||
//! rather than the crate's internal structures, so internal refactors do not
|
||||
//! silently change what a client sees.
|
||||
|
||||
#[cfg(unix)]
|
||||
pub mod unix;
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// Largest accepted local control message.
|
||||
pub const MAX_MESSAGE_LEN: usize = 1024 * 1024;
|
||||
|
||||
/// Where the control socket for a state directory lives.
|
||||
///
|
||||
/// A Unix socket path is limited to around 100 bytes, which a state directory
|
||||
/// nested deeply enough will exceed. So the runtime directory is preferred
|
||||
/// when the platform provides one — which is also where a runtime socket
|
||||
/// belongs — with a short name derived from the state directory so that two
|
||||
/// agents with different state never share a socket. The state directory
|
||||
/// itself is the fallback.
|
||||
///
|
||||
/// Both the agent and the client compute this the same way, so neither has to
|
||||
/// be told where the other put it.
|
||||
pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
||||
let digest = Sha256::digest(state_dir.as_os_str().as_encoded_bytes());
|
||||
let tag = hex::encode(&digest[..8]);
|
||||
|
||||
if let Some(runtime) = std::env::var_os("XDG_RUNTIME_DIR") {
|
||||
let runtime = PathBuf::from(runtime);
|
||||
if runtime.is_absolute() {
|
||||
return runtime.join("tsunagi").join(format!("{tag}.sock"));
|
||||
}
|
||||
}
|
||||
state_dir.join("agent.sock")
|
||||
}
|
||||
|
||||
/// What a client asks for.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum Request {
|
||||
/// Report what the agent is doing.
|
||||
Status,
|
||||
/// Answer to a different name from now on.
|
||||
///
|
||||
/// Applied by the running agent rather than written behind its back, so
|
||||
/// the change takes effect and reaches peers immediately instead of
|
||||
/// waiting for a restart.
|
||||
SetHostname(String),
|
||||
}
|
||||
|
||||
/// What the agent answers.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum Response {
|
||||
/// A status report.
|
||||
Status(Box<StatusReport>),
|
||||
/// The name the agent now answers to, after reducing it to canonical form.
|
||||
Hostname(String),
|
||||
/// The request could not be served.
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Everything the agent is doing, in one snapshot.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct StatusReport {
|
||||
/// This device's persistent endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname announced to peers.
|
||||
pub hostname: String,
|
||||
/// Sockets the endpoint is bound to.
|
||||
pub bound_sockets: Vec<String>,
|
||||
/// Whether the disposable cache is usable.
|
||||
pub cache_healthy: bool,
|
||||
/// One entry per configured network.
|
||||
pub networks: Vec<NetworkReport>,
|
||||
/// The local DNS service, when one was asked for.
|
||||
pub dns: Option<DnsReport>,
|
||||
}
|
||||
|
||||
/// The local DNS service.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DnsReport {
|
||||
/// The zone it answers for.
|
||||
pub zone: String,
|
||||
/// Where it is listening, if it managed to bind.
|
||||
pub listening: Option<String>,
|
||||
/// Why it could not bind, if it did not.
|
||||
pub bind_error: Option<String>,
|
||||
/// Why the system resolver was not told, if it was not.
|
||||
///
|
||||
/// `None` means it was told. The server answers either way, so this is a
|
||||
/// degraded overlay rather than a broken one.
|
||||
pub publish_error: Option<String>,
|
||||
/// What to do about that, when there is something.
|
||||
pub publish_remedy: Option<String>,
|
||||
/// Something worth saying about the zone name itself.
|
||||
pub zone_warning: Option<String>,
|
||||
/// How many names it answers for.
|
||||
pub names: u32,
|
||||
}
|
||||
|
||||
/// One network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct NetworkReport {
|
||||
/// Network name.
|
||||
pub name: String,
|
||||
/// Public network identifier.
|
||||
pub network_id: String,
|
||||
/// Whether the network is running locally.
|
||||
pub active: bool,
|
||||
/// Authenticated control plane peers.
|
||||
pub peers: Vec<PeerReport>,
|
||||
/// Members the signed state knows about, connected or not.
|
||||
///
|
||||
/// This is what makes "offline" sayable. Without it a member that is away
|
||||
/// is indistinguishable from one that never existed, and the only thing
|
||||
/// left to report is a dial-failure counter — which describes the symptom
|
||||
/// and not the cause.
|
||||
pub members: Vec<MemberReport>,
|
||||
/// Outbound dials that failed.
|
||||
pub dial_failures: u64,
|
||||
/// Handshakes rejected in either direction.
|
||||
pub handshake_failures: u64,
|
||||
/// Control messages sent and received.
|
||||
pub control_messages: (u64, u64),
|
||||
/// The overlay, when an IP plugin is running one.
|
||||
pub overlay: Option<OverlayReport>,
|
||||
}
|
||||
|
||||
/// One member of the network, from signed state.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MemberReport {
|
||||
/// The member's device identity.
|
||||
pub endpoint_id: String,
|
||||
/// The IPv4 overlay address it claimed and signed for.
|
||||
pub overlay_address_v4: Option<String>,
|
||||
/// Consecutive failed dial attempts, when this agent is trying to reach it.
|
||||
pub failed_dials: u32,
|
||||
}
|
||||
|
||||
/// One control plane peer.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PeerReport {
|
||||
/// The peer's endpoint id.
|
||||
pub endpoint_id: String,
|
||||
/// Hostname it announced, if any.
|
||||
pub hostname: Option<String>,
|
||||
/// How the connection reaches the peer: `direct`, `relay` or `unknown`,
|
||||
/// as the transport reports it.
|
||||
pub transport: String,
|
||||
/// Round-trip time in milliseconds, when a path is selected.
|
||||
pub rtt_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// The WireGuard overlay of one network.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OverlayReport {
|
||||
/// Packet interface name.
|
||||
pub interface: String,
|
||||
/// Interface MTU.
|
||||
pub mtu: u32,
|
||||
/// This agent's overlay address.
|
||||
pub address: String,
|
||||
/// This agent's IPv4 overlay address, when the overlay is dual stack.
|
||||
pub address_v4: Option<String>,
|
||||
/// The subnet every member shares.
|
||||
pub prefix: String,
|
||||
/// Prefix length of that subnet.
|
||||
pub prefix_len: u8,
|
||||
/// One entry per overlay peer.
|
||||
pub peers: Vec<OverlayPeerReport>,
|
||||
/// Unicast packets sent to an address no peer owns.
|
||||
pub unroutable_packets: u64,
|
||||
/// Multicast packets dropped. Expected, not a fault.
|
||||
pub multicast_packets: u64,
|
||||
/// One destination nobody owned, if there was one.
|
||||
pub unroutable_sample: Option<String>,
|
||||
}
|
||||
|
||||
/// One overlay peer.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct OverlayPeerReport {
|
||||
/// The peer's control plane identity, so a tunnel can be matched to the
|
||||
/// session and the member it belongs to.
|
||||
pub endpoint_id: String,
|
||||
/// The peer's WireGuard public key.
|
||||
pub public_key: String,
|
||||
/// Its overlay address.
|
||||
pub address: String,
|
||||
/// Its IPv4 overlay address, when it has one.
|
||||
pub address_v4: Option<String>,
|
||||
/// Seconds since the last WireGuard handshake.
|
||||
///
|
||||
/// `None` means the tunnel has never handshaken and cannot carry traffic.
|
||||
pub handshake_secs_ago: Option<u64>,
|
||||
/// Packets encrypted and sent to this peer.
|
||||
pub tx_packets: u64,
|
||||
/// Packets decrypted from this peer.
|
||||
pub rx_packets: u64,
|
||||
/// Data packets dropped: wrong source address, or too large for the path.
|
||||
pub dropped: u64,
|
||||
/// WireGuard protocol errors.
|
||||
///
|
||||
/// A few are normal while a tunnel is being set up, because both ends
|
||||
/// start a handshake at once and one of the two is discarded.
|
||||
pub protocol_errors: u64,
|
||||
/// What the transport reports about the path in use.
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl OverlayPeerReport {
|
||||
/// Whether the tunnel has handshaken and can carry traffic.
|
||||
pub fn is_up(&self) -> bool {
|
||||
self.handshake_secs_ago.is_some()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
//! A Unix socket adapter for the local control interface.
|
||||
//!
|
||||
//! One of possibly several adapters; see [`super`]. It serves exactly the
|
||||
//! requests in [`Request`] and nothing else, and it is reachable only by a
|
||||
//! process that can open a file inside the agent's owner-only state
|
||||
//! directory.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::BoxFuture;
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
use super::{MAX_MESSAGE_LEN, Request, Response, StatusReport};
|
||||
|
||||
/// Builds the report that answers a status request.
|
||||
///
|
||||
/// Supplied by the caller, because only the caller knows which plugins are
|
||||
/// running and what they can report. That is what keeps this module free of
|
||||
/// any knowledge of them.
|
||||
pub trait ReportSource: Send + Sync + 'static {
|
||||
/// Produces a fresh report.
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport>;
|
||||
|
||||
/// Changes the name the agent answers to, returning the accepted form.
|
||||
///
|
||||
/// Defaulted to a refusal so that a source which only reports — the
|
||||
/// closure impl below, and every test that uses it — stays valid and
|
||||
/// says plainly that it cannot do this, rather than appearing to.
|
||||
fn set_hostname(
|
||||
&self,
|
||||
_hostname: String,
|
||||
) -> BoxFuture<'_, std::result::Result<String, String>> {
|
||||
Box::pin(async move { Err("this agent cannot change its hostname".to_string()) })
|
||||
}
|
||||
}
|
||||
|
||||
impl<F> ReportSource for F
|
||||
where
|
||||
F: Fn() -> BoxFuture<'static, StatusReport> + Send + Sync + 'static,
|
||||
{
|
||||
fn report(&self) -> BoxFuture<'_, StatusReport> {
|
||||
(self)()
|
||||
}
|
||||
}
|
||||
|
||||
/// Serves the local control interface on a Unix socket.
|
||||
#[derive(Debug)]
|
||||
pub struct ControlSocket {
|
||||
path: PathBuf,
|
||||
task: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ControlSocket {
|
||||
/// Binds the socket and starts serving.
|
||||
///
|
||||
/// A socket file left behind by a crashed agent is replaced, but only
|
||||
/// after checking that nothing is listening on it, so two live agents
|
||||
/// never fight over one path.
|
||||
pub async fn bind(path: impl AsRef<Path>, source: Arc<dyn ReportSource>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
|
||||
if let Some(parent) = path.parent() {
|
||||
crate::storage::create_dir(parent)?;
|
||||
}
|
||||
|
||||
if path.exists() {
|
||||
match UnixStream::connect(&path).await {
|
||||
Ok(_) => {
|
||||
return Err(Error::StateLocked { path: path.clone() });
|
||||
}
|
||||
// Nothing is listening, so the file is a leftover.
|
||||
Err(_) => {
|
||||
std::fs::remove_file(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(&path).map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict(&path)?;
|
||||
|
||||
let task = tokio::spawn(serve(listener, source));
|
||||
Ok(Self {
|
||||
path,
|
||||
task: Some(task),
|
||||
})
|
||||
}
|
||||
|
||||
/// The path being served.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Stops serving and removes the socket file.
|
||||
pub async fn shutdown(mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for ControlSocket {
|
||||
fn drop(&mut self) {
|
||||
if let Some(task) = self.task.take() {
|
||||
task.abort();
|
||||
}
|
||||
let _ = std::fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict(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 {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async fn serve(listener: UnixListener, source: Arc<dyn ReportSource>) {
|
||||
loop {
|
||||
let Ok((stream, _)) = listener.accept().await else {
|
||||
continue;
|
||||
};
|
||||
let source = Arc::clone(&source);
|
||||
tokio::spawn(async move {
|
||||
if let Err(err) = handle(stream, source).await {
|
||||
tracing::debug!(%err, "local control request failed");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle(mut stream: UnixStream, source: Arc<dyn ReportSource>) -> Result<()> {
|
||||
let request: Request = read_message(&mut stream).await?;
|
||||
let response = match request {
|
||||
Request::Status => Response::Status(Box::new(source.report().await)),
|
||||
Request::SetHostname(hostname) => match source.set_hostname(hostname).await {
|
||||
Ok(accepted) => Response::Hostname(accepted),
|
||||
Err(reason) => Response::Error(reason),
|
||||
},
|
||||
};
|
||||
write_message(&mut stream, &response).await
|
||||
}
|
||||
|
||||
/// Asks a running agent for its status.
|
||||
pub async fn request_status(path: impl AsRef<Path>) -> Result<StatusReport> {
|
||||
let path = path.as_ref();
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
write_message(&mut stream, &Request::Status).await?;
|
||||
match read_message::<Response>(&mut stream).await? {
|
||||
Response::Status(report) => Ok(*report),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks a running agent to answer to a different name.
|
||||
///
|
||||
/// Returns the name it accepted, which is the canonical form of what was
|
||||
/// asked for and may differ from it.
|
||||
pub async fn set_hostname(path: impl AsRef<Path>, hostname: &str) -> Result<String> {
|
||||
let path = path.as_ref();
|
||||
let mut stream = UnixStream::connect(path)
|
||||
.await
|
||||
.map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
write_message(&mut stream, &Request::SetHostname(hostname.to_string())).await?;
|
||||
match read_message::<Response>(&mut stream).await? {
|
||||
Response::Hostname(accepted) => Ok(accepted),
|
||||
Response::Error(reason) => Err(Error::Storage(reason)),
|
||||
other => Err(Error::Storage(format!("unexpected answer: {other:?}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// Marks the wire format of the local control socket.
|
||||
///
|
||||
/// `b"TSN"` followed by the version, so a mismatch is recognised as one
|
||||
/// instead of being read as a length. The encoding is postcard, which is not
|
||||
/// self-describing: adding a field to a report changes how the bytes parse,
|
||||
/// and without this a client one build ahead of its agent reports something
|
||||
/// like "Found an Option discriminant that wasn't 0 or 1" — which says
|
||||
/// nothing about the actual problem, that the two are different builds.
|
||||
///
|
||||
/// Bump it whenever [`Request`], [`Response`] or anything they contain
|
||||
/// changes shape.
|
||||
pub const CONTROL_PROTOCOL: u32 = u32::from_be_bytes([b'T', b'S', b'N', 4]);
|
||||
|
||||
async fn write_message<T: serde::Serialize>(stream: &mut UnixStream, value: &T) -> Result<()> {
|
||||
let encoded = postcard::to_stdvec(value)
|
||||
.map_err(|err| Error::Storage(format!("cannot encode a control message: {err}")))?;
|
||||
if encoded.len() > MAX_MESSAGE_LEN {
|
||||
return Err(Error::Storage("control message is too large".into()));
|
||||
}
|
||||
let len = encoded.len() as u32;
|
||||
stream
|
||||
.write_all(&CONTROL_PROTOCOL.to_be_bytes())
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
stream.write_all(&encoded).await.map_err(io_error)?;
|
||||
stream.flush().await.map_err(io_error)
|
||||
}
|
||||
|
||||
async fn read_message<T: for<'de> serde::Deserialize<'de>>(stream: &mut UnixStream) -> Result<T> {
|
||||
let mut header = [0u8; 4];
|
||||
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||
let version = u32::from_be_bytes(header);
|
||||
if version != CONTROL_PROTOCOL {
|
||||
return Err(Error::Storage(format!(
|
||||
"the other end speaks control protocol {version:#010x} and this build speaks \
|
||||
{CONTROL_PROTOCOL:#010x}; they are different builds of tsunagi, so restart the \
|
||||
agent with the binary you are running now"
|
||||
)));
|
||||
}
|
||||
|
||||
stream.read_exact(&mut header).await.map_err(io_error)?;
|
||||
let len = u32::from_be_bytes(header) as usize;
|
||||
// Checked before allocating, exactly as on the network.
|
||||
if len > MAX_MESSAGE_LEN {
|
||||
return Err(Error::Storage(format!(
|
||||
"control message of {len} bytes exceeds the {MAX_MESSAGE_LEN} byte limit"
|
||||
)));
|
||||
}
|
||||
let mut payload = vec![0u8; len];
|
||||
stream.read_exact(&mut payload).await.map_err(io_error)?;
|
||||
postcard::from_bytes(&payload)
|
||||
.map_err(|err| Error::Storage(format!("cannot decode a control message: {err}")))
|
||||
}
|
||||
|
||||
fn io_error(source: std::io::Error) -> Error {
|
||||
Error::Io {
|
||||
path: PathBuf::from("<local control socket>"),
|
||||
source,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Tsunagi: a proof-of-concept agent for small private mesh networks.
|
||||
//!
|
||||
//! See `README.md` for the exact scope of this proof of concept, and
|
||||
//! `docs/architecture.md` / `docs/protocol.md` for the design.
|
||||
//!
|
||||
//! # Shape of the library
|
||||
//!
|
||||
//! * [`identity`] — persistent device identity and deterministic network space
|
||||
//! identity.
|
||||
//! * [`storage`] — mandatory state (`state.sqlite`) and separately disposable
|
||||
//! cache (`cache.sqlite`).
|
||||
//! * [`discovery`] — pluggable sources of *candidate* addresses. Candidates are
|
||||
//! never trusted peers.
|
||||
//! * [`proto`] — the control protocol: framing, messages, handshake.
|
||||
//! * [`net`] — the iroh connectivity adapter and its observability surface.
|
||||
//! * [`agent`] — the runtime: agent lifecycle, per-network runtimes, reconnect.
|
||||
//! * [`dataplane`] — the contract IP plugins satisfy, the packet transport,
|
||||
//! and the WireGuard data plane.
|
||||
//! * [`state`] — signed records that outlive a session, and the rules for
|
||||
//! merging them between replicas.
|
||||
//! * [`ipc`] — the local control interface a command line tool talks to. An
|
||||
//! adapter over the public API; the core does not know it exists.
|
||||
//!
|
||||
//! # What this library deliberately does not do
|
||||
//!
|
||||
//! It never starts a global tokio runtime, never installs a global tracing
|
||||
//! subscriber, never handles process signals, never forks and never calls
|
||||
//! `process::exit`. Several independent agents can run in one process.
|
||||
//!
|
||||
//! Only control messages travel over iroh. User IP traffic is not tunnelled
|
||||
//! through it.
|
||||
|
||||
#![deny(rustdoc::broken_intra_doc_links)]
|
||||
|
||||
/// A boxed future, used where a trait must stay object safe.
|
||||
pub type BoxFuture<'a, T> = std::pin::Pin<Box<dyn std::future::Future<Output = T> + Send + 'a>>;
|
||||
|
||||
pub mod agent;
|
||||
pub mod config;
|
||||
pub mod dataplane;
|
||||
pub mod discovery;
|
||||
pub mod dns;
|
||||
pub mod error;
|
||||
pub mod identity;
|
||||
pub mod ipc;
|
||||
pub mod net;
|
||||
pub mod proto;
|
||||
pub mod state;
|
||||
pub mod storage;
|
||||
|
||||
pub use agent::{Agent, AgentStatus, Event, NetworkStatus, PeerStatus};
|
||||
pub use config::{AgentConfig, Limits, ReconnectPolicy, StoragePaths, TransportPolicy};
|
||||
pub use error::{Error, ProtocolError, Result};
|
||||
pub use identity::{
|
||||
DeviceIdentity, DiscoveryKey, NetworkDescriptor, NetworkId, NetworkName, NetworkSecret,
|
||||
};
|
||||
|
||||
/// Re-exported iroh types that appear in this crate's public API.
|
||||
pub mod iroh_types {
|
||||
pub use iroh::{EndpointAddr, EndpointId, RelayUrl};
|
||||
}
|
||||
|
||||
#[doc(hidden)]
|
||||
pub mod test_support {
|
||||
//! Internals exposed for this crate's own negative tests.
|
||||
//!
|
||||
//! **Not part of the stable API.** It exists so the integration tests can
|
||||
//! hand-craft handshakes — a valid proof to replay on another connection, a
|
||||
//! message sent before authentication, an oversized frame — which is the
|
||||
//! only way to test those rejections against a real agent.
|
||||
|
||||
use iroh::endpoint::Connection;
|
||||
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::NetworkKeys;
|
||||
use crate::proto::handshake;
|
||||
|
||||
/// Exposes the derived handshake authentication key.
|
||||
pub fn auth_key(keys: &NetworkKeys) -> [u8; 32] {
|
||||
*keys.auth_key()
|
||||
}
|
||||
|
||||
/// Derives this connection's channel binding material.
|
||||
pub fn channel_binding(
|
||||
conn: &Connection,
|
||||
network_id: &[u8; 32],
|
||||
) -> Result<[u8; 32], ProtocolError> {
|
||||
handshake::channel_binding_for_test(conn, network_id)
|
||||
}
|
||||
|
||||
/// Computes a handshake proof for the given role.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn compute_proof(
|
||||
auth_key: &[u8; 32],
|
||||
role: &str,
|
||||
version: u16,
|
||||
network_id: &[u8; 32],
|
||||
initiator: &[u8; 32],
|
||||
responder: &[u8; 32],
|
||||
channel_binding: &[u8],
|
||||
nonce_initiator: &[u8; 16],
|
||||
nonce_responder: &[u8; 16],
|
||||
) -> [u8; 32] {
|
||||
handshake::proof_for_test(
|
||||
auth_key,
|
||||
role,
|
||||
version,
|
||||
network_id,
|
||||
initiator,
|
||||
responder,
|
||||
channel_binding,
|
||||
nonce_initiator,
|
||||
nonce_responder,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
//! The iroh connectivity adapter and its observability surface.
|
||||
//!
|
||||
//! This module builds and owns the [`Endpoint`], and turns iroh's runtime
|
||||
//! information into plain owned snapshots that the rest of the library and the
|
||||
//! library's users can read.
|
||||
//!
|
||||
//! # Honest reporting
|
||||
//!
|
||||
//! Three different things are kept distinct and never conflated:
|
||||
//!
|
||||
//! * an **unverified candidate** — something discovery handed us
|
||||
//! ([`crate::discovery::Candidate`]);
|
||||
//! * an **observed address** — an address this endpoint believes it has
|
||||
//! ([`EndpointSnapshot::observed_addrs`]);
|
||||
//! * a **verified path** — a network path QUIC has actually validated and is
|
||||
//! using or can use ([`PathInfo`]).
|
||||
//!
|
||||
//! A value that is not available is reported as `None`. It is never invented.
|
||||
//!
|
||||
//! An iroh address is an address for *iroh*. It must not be assumed to be usable
|
||||
//! by any other protocol; a future WireGuard plugin is expected to collect its
|
||||
//! own reachability data.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use iroh::endpoint::{
|
||||
Connection, ConnectionStats, PortmapperConfig, RecvStream, SendStream, presets,
|
||||
};
|
||||
use iroh::{Endpoint, EndpointAddr, EndpointId, RelayMode};
|
||||
|
||||
use crate::config::{AgentConfig, TransportPolicy};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::DeviceIdentity;
|
||||
use crate::proto::message::{ALPN, DATA_ALPN};
|
||||
|
||||
/// A network path address as reported by iroh.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathAddr {
|
||||
/// A direct IP path.
|
||||
Ip(SocketAddr),
|
||||
/// A path through a relay server.
|
||||
Relay(String),
|
||||
/// A custom transport iroh reported but this crate does not model.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for PathAddr {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
PathAddr::Ip(addr) => write!(f, "{addr}"),
|
||||
PathAddr::Relay(url) => write!(f, "relay {url}"),
|
||||
PathAddr::Other(what) => write!(f, "{what}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One verified network path of a connection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PathInfo {
|
||||
/// Remote address of the path.
|
||||
pub remote: PathAddr,
|
||||
/// Local address of the path, when the OS reports one.
|
||||
pub local: Option<String>,
|
||||
/// Whether QUIC currently transmits application data over this path.
|
||||
pub is_selected: bool,
|
||||
/// Round-trip time estimate for this path.
|
||||
pub rtt: Duration,
|
||||
}
|
||||
|
||||
impl PathInfo {
|
||||
/// Whether this is a direct IP path.
|
||||
pub fn is_direct(&self) -> bool {
|
||||
matches!(self.remote, PathAddr::Ip(_))
|
||||
}
|
||||
|
||||
/// Whether this path goes through a relay.
|
||||
pub fn is_relay(&self) -> bool {
|
||||
matches!(self.remote, PathAddr::Relay(_))
|
||||
}
|
||||
}
|
||||
|
||||
/// How a connection currently reaches its peer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum TransportKind {
|
||||
/// A direct IP path is selected.
|
||||
Direct,
|
||||
/// A relay path is selected.
|
||||
Relay,
|
||||
/// iroh has not reported a selected path yet.
|
||||
Unknown,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TransportKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let word = match self {
|
||||
TransportKind::Direct => "direct",
|
||||
TransportKind::Relay => "relay",
|
||||
TransportKind::Unknown => "unknown",
|
||||
};
|
||||
f.write_str(word)
|
||||
}
|
||||
}
|
||||
|
||||
/// Counters for one connection.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ConnectionCounters {
|
||||
/// UDP bytes sent on this connection.
|
||||
pub udp_tx_bytes: u64,
|
||||
/// UDP bytes received on this connection.
|
||||
pub udp_rx_bytes: u64,
|
||||
/// UDP datagrams sent.
|
||||
pub udp_tx_datagrams: u64,
|
||||
/// UDP datagrams received.
|
||||
pub udp_rx_datagrams: u64,
|
||||
/// Packets declared lost.
|
||||
pub lost_packets: u64,
|
||||
}
|
||||
|
||||
impl From<ConnectionStats> for ConnectionCounters {
|
||||
fn from(stats: ConnectionStats) -> Self {
|
||||
Self {
|
||||
udp_tx_bytes: stats.udp_tx.bytes,
|
||||
udp_rx_bytes: stats.udp_rx.bytes,
|
||||
udp_tx_datagrams: stats.udp_tx.datagrams,
|
||||
udp_rx_datagrams: stats.udp_rx.datagrams,
|
||||
lost_packets: stats.lost_packets,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An owned snapshot of one live connection.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConnectionSnapshot {
|
||||
/// Authenticated endpoint id of the remote side.
|
||||
pub remote_id: EndpointId,
|
||||
/// Verified paths, as reported by iroh at snapshot time.
|
||||
pub paths: Vec<PathInfo>,
|
||||
/// How the connection currently reaches the peer.
|
||||
pub transport: TransportKind,
|
||||
/// RTT of the selected path, when there is one.
|
||||
pub rtt: Option<Duration>,
|
||||
/// Per-connection counters.
|
||||
pub counters: ConnectionCounters,
|
||||
}
|
||||
|
||||
/// Snapshot of this endpoint, independent of any particular network.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EndpointSnapshot {
|
||||
/// This endpoint's id.
|
||||
pub endpoint_id: EndpointId,
|
||||
/// Sockets actually bound locally.
|
||||
pub bound_sockets: Vec<SocketAddr>,
|
||||
/// Addresses iroh believes this endpoint is reachable at.
|
||||
///
|
||||
/// These are *observed*, not verified by any remote peer.
|
||||
pub observed_addrs: Vec<PathAddr>,
|
||||
/// Relay URLs this endpoint currently considers usable, if any.
|
||||
pub relay_urls: Vec<String>,
|
||||
}
|
||||
|
||||
fn path_addr(addr: &iroh::TransportAddr) -> PathAddr {
|
||||
match addr {
|
||||
iroh::TransportAddr::Ip(socket) => PathAddr::Ip(*socket),
|
||||
iroh::TransportAddr::Relay(url) => PathAddr::Relay(url.to_string()),
|
||||
other => PathAddr::Other(format!("{other:?}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds an owned snapshot of a live connection.
|
||||
pub fn snapshot_connection(conn: &Connection) -> ConnectionSnapshot {
|
||||
let mut paths = Vec::new();
|
||||
let mut transport = TransportKind::Unknown;
|
||||
let mut rtt = None;
|
||||
|
||||
for path in conn.paths().iter() {
|
||||
let info = PathInfo {
|
||||
remote: path_addr(path.remote_addr()),
|
||||
local: local_addr_string(path.local_addr()),
|
||||
is_selected: path.is_selected(),
|
||||
rtt: path.rtt(),
|
||||
};
|
||||
if info.is_selected {
|
||||
transport = if info.is_relay() {
|
||||
TransportKind::Relay
|
||||
} else if info.is_direct() {
|
||||
TransportKind::Direct
|
||||
} else {
|
||||
TransportKind::Unknown
|
||||
};
|
||||
rtt = Some(info.rtt);
|
||||
}
|
||||
paths.push(info);
|
||||
}
|
||||
|
||||
ConnectionSnapshot {
|
||||
remote_id: conn.remote_id(),
|
||||
paths,
|
||||
transport,
|
||||
rtt,
|
||||
counters: conn.stats().into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn local_addr_string(addr: &iroh::endpoint::LocalTransportAddr) -> Option<String> {
|
||||
match addr {
|
||||
iroh::endpoint::LocalTransportAddr::Ip(Some(ip)) => Some(ip.to_string()),
|
||||
iroh::endpoint::LocalTransportAddr::Ip(None) => None,
|
||||
iroh::endpoint::LocalTransportAddr::Relay(url) => Some(url.to_string()),
|
||||
iroh::endpoint::LocalTransportAddr::Custom(Some(custom)) => Some(format!("{custom:?}")),
|
||||
iroh::endpoint::LocalTransportAddr::Custom(None) => None,
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin wrapper around the iroh endpoint.
|
||||
///
|
||||
/// The endpoint serves two ALPNs: the control protocol and the data plane.
|
||||
/// They are separate connections with separate congestion control, so a busy
|
||||
/// or broken data plane cannot disturb control traffic.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EndpointAdapter {
|
||||
endpoint: Endpoint,
|
||||
}
|
||||
|
||||
impl EndpointAdapter {
|
||||
/// Binds an endpoint according to `config`, reusing the persistent device key.
|
||||
pub async fn bind(config: &AgentConfig, identity: &DeviceIdentity) -> Result<Self> {
|
||||
let mut builder = Endpoint::builder(presets::Minimal)
|
||||
.secret_key(identity.secret_key())
|
||||
.alpns(vec![ALPN.to_vec(), DATA_ALPN.to_vec()]);
|
||||
|
||||
builder = match config.transport {
|
||||
TransportPolicy::LocalOnly => builder
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.clear_address_lookup()
|
||||
.portmapper_config(PortmapperConfig::Disabled)
|
||||
.net_report_config(iroh::NetReportConfig::minimal()),
|
||||
TransportPolicy::DirectOnly => builder.preset(presets::N0DisableRelay),
|
||||
TransportPolicy::N0Defaults => builder.preset(presets::N0),
|
||||
};
|
||||
|
||||
if !config.bind_addrs.is_empty() {
|
||||
builder = builder.clear_ip_transports();
|
||||
for addr in &config.bind_addrs {
|
||||
builder = builder
|
||||
.bind_addr(*addr)
|
||||
.map_err(|err| Error::Endpoint(format!("invalid bind address: {err}")))?;
|
||||
}
|
||||
}
|
||||
|
||||
let endpoint = builder
|
||||
.bind()
|
||||
.await
|
||||
.map_err(|err| Error::Endpoint(format!("cannot bind endpoint: {err}")))?;
|
||||
|
||||
Ok(Self { endpoint })
|
||||
}
|
||||
|
||||
/// The underlying iroh endpoint.
|
||||
pub fn endpoint(&self) -> &Endpoint {
|
||||
&self.endpoint
|
||||
}
|
||||
|
||||
/// This endpoint's id.
|
||||
pub fn endpoint_id(&self) -> EndpointId {
|
||||
self.endpoint.id()
|
||||
}
|
||||
|
||||
/// This endpoint's dialable address, as currently known.
|
||||
pub fn addr(&self) -> EndpointAddr {
|
||||
self.endpoint.addr()
|
||||
}
|
||||
|
||||
/// Builds an address containing only the locally bound sockets.
|
||||
///
|
||||
/// Useful when address lookup and relays are disabled and peers must be
|
||||
/// given literal addresses.
|
||||
pub fn loopback_addr(&self) -> EndpointAddr {
|
||||
self.endpoint
|
||||
.bound_sockets()
|
||||
.into_iter()
|
||||
.fold(EndpointAddr::new(self.endpoint.id()), |addr, socket| {
|
||||
addr.with_ip_addr(socket)
|
||||
})
|
||||
}
|
||||
|
||||
/// Snapshot of endpoint-level information.
|
||||
pub fn snapshot(&self) -> EndpointSnapshot {
|
||||
let addr = self.endpoint.addr();
|
||||
let mut observed = Vec::new();
|
||||
let mut relays = Vec::new();
|
||||
for socket in addr.ip_addrs() {
|
||||
observed.push(PathAddr::Ip(*socket));
|
||||
}
|
||||
for url in addr.relay_urls() {
|
||||
relays.push(url.to_string());
|
||||
observed.push(PathAddr::Relay(url.to_string()));
|
||||
}
|
||||
EndpointSnapshot {
|
||||
endpoint_id: self.endpoint.id(),
|
||||
bound_sockets: self.endpoint.bound_sockets(),
|
||||
observed_addrs: observed,
|
||||
relay_urls: relays,
|
||||
}
|
||||
}
|
||||
|
||||
/// Dials a candidate and opens the control stream.
|
||||
pub async fn connect(
|
||||
&self,
|
||||
addr: EndpointAddr,
|
||||
) -> Result<(Connection, SendStream, RecvStream), Error> {
|
||||
let conn = self
|
||||
.endpoint
|
||||
.connect(addr, ALPN)
|
||||
.await
|
||||
.map_err(|err| Error::Endpoint(format!("connect failed: {err}")))?;
|
||||
let (send, recv) = conn
|
||||
.open_bi()
|
||||
.await
|
||||
.map_err(|err| Error::Endpoint(format!("cannot open control stream: {err}")))?;
|
||||
Ok((conn, send, recv))
|
||||
}
|
||||
|
||||
/// Closes the endpoint and waits for it to finish.
|
||||
pub async fn close(&self) {
|
||||
self.endpoint.close().await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
//! Length-prefixed framing over one QUIC bidirectional stream.
|
||||
//!
|
||||
//! A frame is `u32_be(len) || payload`. The announced length is validated
|
||||
//! against the configured limit **before** a buffer of that size is allocated,
|
||||
//! so a hostile peer cannot make the agent allocate arbitrary memory with a
|
||||
//! four byte header.
|
||||
//!
|
||||
//! No custom encryption is layered on top: the iroh/QUIC connection already
|
||||
//! provides confidentiality, integrity and endpoint authentication.
|
||||
|
||||
use iroh::endpoint::{RecvStream, SendStream};
|
||||
|
||||
use crate::error::ProtocolError;
|
||||
|
||||
/// Size of the frame length prefix, in bytes.
|
||||
pub const LENGTH_PREFIX_LEN: usize = 4;
|
||||
|
||||
/// Writes one frame.
|
||||
pub async fn write_frame(
|
||||
stream: &mut SendStream,
|
||||
payload: &[u8],
|
||||
max_frame_len: usize,
|
||||
) -> Result<(), ProtocolError> {
|
||||
if payload.len() > max_frame_len {
|
||||
return Err(ProtocolError::FrameTooLarge {
|
||||
announced: payload.len() as u64,
|
||||
limit: max_frame_len,
|
||||
});
|
||||
}
|
||||
let len = u32::try_from(payload.len()).map_err(|_| ProtocolError::FrameTooLarge {
|
||||
announced: payload.len() as u64,
|
||||
limit: max_frame_len,
|
||||
})?;
|
||||
stream
|
||||
.write_all(&len.to_be_bytes())
|
||||
.await
|
||||
.map_err(|err| ProtocolError::Stream(err.to_string()))?;
|
||||
stream
|
||||
.write_all(payload)
|
||||
.await
|
||||
.map_err(|err| ProtocolError::Stream(err.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads one frame, rejecting oversized headers before allocating.
|
||||
pub async fn read_frame(
|
||||
stream: &mut RecvStream,
|
||||
max_frame_len: usize,
|
||||
) -> Result<Vec<u8>, ProtocolError> {
|
||||
let mut header = [0u8; LENGTH_PREFIX_LEN];
|
||||
match stream.read_exact(&mut header).await {
|
||||
Ok(()) => {}
|
||||
Err(err) => return Err(classify_read_error(err)),
|
||||
}
|
||||
|
||||
let announced = u32::from_be_bytes(header) as u64;
|
||||
if announced > max_frame_len as u64 {
|
||||
return Err(ProtocolError::FrameTooLarge {
|
||||
announced,
|
||||
limit: max_frame_len,
|
||||
});
|
||||
}
|
||||
|
||||
// Safe: `announced` was just bounded by `max_frame_len`, a usize.
|
||||
let mut payload = vec![0u8; announced as usize];
|
||||
if !payload.is_empty() {
|
||||
match stream.read_exact(&mut payload).await {
|
||||
Ok(()) => {}
|
||||
Err(err) => return Err(classify_read_error(err)),
|
||||
}
|
||||
}
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
fn classify_read_error(err: iroh::endpoint::ReadExactError) -> ProtocolError {
|
||||
match err {
|
||||
iroh::endpoint::ReadExactError::FinishedEarly(_) => ProtocolError::StreamClosed,
|
||||
other => ProtocolError::Stream(other.to_string()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,575 @@
|
||||
//! Mutual proof that both ends belong to the same network space.
|
||||
//!
|
||||
//! # Why a successful iroh connection is not enough
|
||||
//!
|
||||
//! iroh authenticates *endpoints*: after the QUIC/TLS handshake each side knows
|
||||
//! the other's [`EndpointId`], because that id is the public key in the
|
||||
//! certificate. It says nothing about network membership — anybody can dial us.
|
||||
//! So on top of the authenticated connection we run an explicit mutual proof of
|
||||
//! knowledge of the derived network authentication key.
|
||||
//!
|
||||
//! # Channel binding is not a proof by itself
|
||||
//!
|
||||
//! iroh exposes the TLS exporter (RFC 5705) via
|
||||
//! [`Connection::export_keying_material`]. That gives both sides the same
|
||||
//! secret bytes for *this* connection, which is exactly what is needed to stop
|
||||
//! a proof being replayed on another connection. It proves nothing about the
|
||||
//! shared network secret on its own, because both ends of any connection can
|
||||
//! compute it. The proof of membership is the HMAC keyed by `auth_key`; the
|
||||
//! exporter output is only one of its inputs.
|
||||
//!
|
||||
//! # The scheme
|
||||
//!
|
||||
//! ```text
|
||||
//! cb = TLS-Exporter(label = "tsunagi/handshake/v1", context = network_id, 32)
|
||||
//! LP(x)= u32_be(len(x)) || x
|
||||
//!
|
||||
//! transcript(role) = LP("tsunagi-handshake-v1")
|
||||
//! || LP(role) // "initiator-proof" | "responder-proof"
|
||||
//! || LP(u16_be(protocol_version))
|
||||
//! || LP(network_id) // 32 bytes
|
||||
//! || LP(initiator_endpoint_id) // 32 bytes
|
||||
//! || LP(responder_endpoint_id) // 32 bytes
|
||||
//! || LP(cb) // 32 bytes
|
||||
//! || LP(nonce_initiator) // 16 bytes
|
||||
//! || LP(nonce_responder) // 16 bytes
|
||||
//!
|
||||
//! proof(role) = HMAC-SHA256(auth_key, transcript(role))
|
||||
//! ```
|
||||
//!
|
||||
//! What each input buys:
|
||||
//!
|
||||
//! * `auth_key` — membership. Derived from name+secret only, see
|
||||
//! [`crate::identity`].
|
||||
//! * `cb` — binding to this connection. A proof captured elsewhere is useless
|
||||
//! here, because `cb` differs per TLS session.
|
||||
//! * `network_id` — binding to this network space.
|
||||
//! * both endpoint ids — binding to these two identities.
|
||||
//! * distinct `role` labels — no reflection: the responder cannot bounce the
|
||||
//! initiator's own proof back at it.
|
||||
//! * both nonces — freshness contributed by each side.
|
||||
//!
|
||||
//! # Message order
|
||||
//!
|
||||
//! ```text
|
||||
//! initiator -> responder : Hello { version, network_id, nonce_i }
|
||||
//! initiator <- responder : HelloAck { version, nonce_r }
|
||||
//! initiator -> responder : AuthProof{ proof(initiator) }
|
||||
//! initiator <- responder : AuthProof{ proof(responder) } // only if the first proof verified
|
||||
//! ```
|
||||
//!
|
||||
//! The responder emits nothing derived from `auth_key` until the initiator's
|
||||
//! proof has verified, so a caller who does not know the secret learns nothing.
|
||||
//! Until both steps complete, no regular control message is accepted in either
|
||||
//! direction.
|
||||
//!
|
||||
//! [`Connection::export_keying_material`]: iroh::endpoint::Connection::export_keying_material
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use iroh::EndpointId;
|
||||
use iroh::endpoint::{Connection, RecvStream, SendStream};
|
||||
use sha2::Sha256;
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
use crate::config::Limits;
|
||||
use crate::error::ProtocolError;
|
||||
use crate::identity::{NetworkId, NetworkKeys};
|
||||
use crate::proto::frame::{read_frame, write_frame};
|
||||
use crate::proto::message::{AuthProof, Hello, HelloAck, PROTOCOL_VERSION, decode, encode};
|
||||
|
||||
/// Frozen domain separator of the handshake transcript.
|
||||
pub const TRANSCRIPT_DOMAIN: &str = "tsunagi-handshake-v1";
|
||||
|
||||
/// TLS exporter label used for channel binding.
|
||||
pub const EXPORTER_LABEL: &[u8] = b"tsunagi/handshake/v1";
|
||||
|
||||
/// Transcript role label of the side that dialled.
|
||||
pub const ROLE_INITIATOR: &str = "initiator-proof";
|
||||
|
||||
/// Transcript role label of the side that accepted.
|
||||
pub const ROLE_RESPONDER: &str = "responder-proof";
|
||||
|
||||
/// Length of the channel binding material, in bytes.
|
||||
pub const CHANNEL_BINDING_LEN: usize = 32;
|
||||
|
||||
/// Which side of the handshake this agent played.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Role {
|
||||
/// This agent dialled.
|
||||
Initiator,
|
||||
/// This agent accepted.
|
||||
Responder,
|
||||
}
|
||||
|
||||
impl Role {
|
||||
/// Short label for diagnostics.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Role::Initiator => "initiator",
|
||||
Role::Responder => "responder",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of a completed handshake.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct HandshakeOutcome {
|
||||
/// Network both sides proved membership of.
|
||||
pub network_id: NetworkId,
|
||||
/// Authenticated endpoint id of the peer, taken from the TLS certificate.
|
||||
pub peer: EndpointId,
|
||||
/// Which side this agent played.
|
||||
pub role: Role,
|
||||
}
|
||||
|
||||
fn push_lp(out: &mut Vec<u8>, bytes: &[u8]) {
|
||||
let len = u32::try_from(bytes.len()).unwrap_or(u32::MAX);
|
||||
out.extend_from_slice(&len.to_be_bytes());
|
||||
out.extend_from_slice(bytes);
|
||||
}
|
||||
|
||||
/// Builds the role-specific transcript. Pure function, unit tested.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn transcript(
|
||||
role: &str,
|
||||
version: u16,
|
||||
network_id: &[u8; 32],
|
||||
initiator: &[u8; 32],
|
||||
responder: &[u8; 32],
|
||||
channel_binding: &[u8],
|
||||
nonce_initiator: &[u8; 16],
|
||||
nonce_responder: &[u8; 16],
|
||||
) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(256);
|
||||
push_lp(&mut out, TRANSCRIPT_DOMAIN.as_bytes());
|
||||
push_lp(&mut out, role.as_bytes());
|
||||
push_lp(&mut out, &version.to_be_bytes());
|
||||
push_lp(&mut out, network_id);
|
||||
push_lp(&mut out, initiator);
|
||||
push_lp(&mut out, responder);
|
||||
push_lp(&mut out, channel_binding);
|
||||
push_lp(&mut out, nonce_initiator);
|
||||
push_lp(&mut out, nonce_responder);
|
||||
out
|
||||
}
|
||||
|
||||
/// Computes one proof.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn proof(
|
||||
auth_key: &[u8; 32],
|
||||
role: &str,
|
||||
version: u16,
|
||||
network_id: &[u8; 32],
|
||||
initiator: &[u8; 32],
|
||||
responder: &[u8; 32],
|
||||
channel_binding: &[u8],
|
||||
nonce_initiator: &[u8; 16],
|
||||
nonce_responder: &[u8; 16],
|
||||
) -> [u8; 32] {
|
||||
let message = transcript(
|
||||
role,
|
||||
version,
|
||||
network_id,
|
||||
initiator,
|
||||
responder,
|
||||
channel_binding,
|
||||
nonce_initiator,
|
||||
nonce_responder,
|
||||
);
|
||||
let mut mac = match <Hmac<Sha256> as KeyInit>::new_from_slice(auth_key) {
|
||||
Ok(mac) => mac,
|
||||
// HMAC-SHA256 accepts keys of any length, so a 32 byte key cannot fail.
|
||||
Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"),
|
||||
};
|
||||
mac.update(&message);
|
||||
let tag = mac.finalize().into_bytes();
|
||||
let mut out = [0u8; 32];
|
||||
out.copy_from_slice(&tag);
|
||||
out
|
||||
}
|
||||
|
||||
/// Verifies a proof in constant time.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn verify(
|
||||
auth_key: &[u8; 32],
|
||||
role: &str,
|
||||
version: u16,
|
||||
network_id: &[u8; 32],
|
||||
initiator: &[u8; 32],
|
||||
responder: &[u8; 32],
|
||||
channel_binding: &[u8],
|
||||
nonce_initiator: &[u8; 16],
|
||||
nonce_responder: &[u8; 16],
|
||||
candidate: &[u8; 32],
|
||||
) -> Result<(), ProtocolError> {
|
||||
let message = transcript(
|
||||
role,
|
||||
version,
|
||||
network_id,
|
||||
initiator,
|
||||
responder,
|
||||
channel_binding,
|
||||
nonce_initiator,
|
||||
nonce_responder,
|
||||
);
|
||||
let mut mac = match <Hmac<Sha256> as KeyInit>::new_from_slice(auth_key) {
|
||||
Ok(mac) => mac,
|
||||
Err(_) => unreachable!("HMAC-SHA256 accepts a 32 byte key"),
|
||||
};
|
||||
mac.update(&message);
|
||||
mac.verify_slice(candidate)
|
||||
.map_err(|_| ProtocolError::AuthenticationFailed)
|
||||
}
|
||||
|
||||
/// Extracts channel binding material from the connection.
|
||||
fn channel_binding(
|
||||
conn: &Connection,
|
||||
network_id: &[u8; 32],
|
||||
) -> Result<Zeroizing<[u8; CHANNEL_BINDING_LEN]>, ProtocolError> {
|
||||
let mut out = Zeroizing::new([0u8; CHANNEL_BINDING_LEN]);
|
||||
conn.export_keying_material(out.as_mut(), EXPORTER_LABEL, network_id)
|
||||
.map_err(|err| ProtocolError::NoChannelBinding(format!("{err:?}")))?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn fresh_nonce() -> [u8; 16] {
|
||||
let mut nonce = [0u8; 16];
|
||||
rand::fill(&mut nonce);
|
||||
nonce
|
||||
}
|
||||
|
||||
fn check_version(found: u16) -> Result<(), ProtocolError> {
|
||||
if found != PROTOCOL_VERSION {
|
||||
return Err(ProtocolError::UnsupportedVersion {
|
||||
found,
|
||||
supported: PROTOCOL_VERSION,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Runs the initiator side of the handshake.
|
||||
///
|
||||
/// Bounded by [`Limits::handshake_timeout`].
|
||||
pub async fn initiate(
|
||||
conn: &Connection,
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
local_id: EndpointId,
|
||||
keys: &NetworkKeys,
|
||||
limits: &Limits,
|
||||
) -> Result<HandshakeOutcome, ProtocolError> {
|
||||
tokio::time::timeout(
|
||||
limits.handshake_timeout,
|
||||
initiate_inner(conn, send, recv, local_id, keys, limits),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(Err(ProtocolError::HandshakeTimeout))
|
||||
}
|
||||
|
||||
async fn initiate_inner(
|
||||
conn: &Connection,
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
local_id: EndpointId,
|
||||
keys: &NetworkKeys,
|
||||
limits: &Limits,
|
||||
) -> Result<HandshakeOutcome, ProtocolError> {
|
||||
let network_id = keys.network_id();
|
||||
let network_bytes = *network_id.as_bytes();
|
||||
let peer = conn.remote_id();
|
||||
|
||||
let initiator = *local_id.as_bytes();
|
||||
let responder = *peer.as_bytes();
|
||||
let cb = channel_binding(conn, &network_bytes)?;
|
||||
|
||||
let nonce_i = fresh_nonce();
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
network_id: network_bytes,
|
||||
nonce: nonce_i,
|
||||
};
|
||||
write_frame(send, &encode(&hello)?, limits.max_frame_len).await?;
|
||||
|
||||
let ack: HelloAck = decode(&read_frame(recv, limits.max_frame_len).await?)?;
|
||||
check_version(ack.version)?;
|
||||
let nonce_r = ack.nonce;
|
||||
|
||||
let mine = proof(
|
||||
keys.auth_key(),
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&initiator,
|
||||
&responder,
|
||||
cb.as_ref(),
|
||||
&nonce_i,
|
||||
&nonce_r,
|
||||
);
|
||||
write_frame(
|
||||
send,
|
||||
&encode(&AuthProof { proof: mine })?,
|
||||
limits.max_frame_len,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?;
|
||||
verify(
|
||||
keys.auth_key(),
|
||||
ROLE_RESPONDER,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&initiator,
|
||||
&responder,
|
||||
cb.as_ref(),
|
||||
&nonce_i,
|
||||
&nonce_r,
|
||||
&theirs.proof,
|
||||
)?;
|
||||
|
||||
Ok(HandshakeOutcome {
|
||||
network_id,
|
||||
peer,
|
||||
role: Role::Initiator,
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs the responder side of the handshake.
|
||||
///
|
||||
/// `lookup` maps the network id the peer asked for to the local key material,
|
||||
/// returning `None` if this agent does not have that network active. Routing
|
||||
/// stays in the agent; the protocol stays here.
|
||||
///
|
||||
/// Bounded by [`Limits::handshake_timeout`].
|
||||
pub async fn respond<F>(
|
||||
conn: &Connection,
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
local_id: EndpointId,
|
||||
limits: &Limits,
|
||||
lookup: F,
|
||||
) -> Result<HandshakeOutcome, ProtocolError>
|
||||
where
|
||||
F: FnOnce(NetworkId) -> Option<NetworkKeys>,
|
||||
{
|
||||
tokio::time::timeout(
|
||||
limits.handshake_timeout,
|
||||
respond_inner(conn, send, recv, local_id, limits, lookup),
|
||||
)
|
||||
.await
|
||||
.unwrap_or(Err(ProtocolError::HandshakeTimeout))
|
||||
}
|
||||
|
||||
async fn respond_inner<F>(
|
||||
conn: &Connection,
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
local_id: EndpointId,
|
||||
limits: &Limits,
|
||||
lookup: F,
|
||||
) -> Result<HandshakeOutcome, ProtocolError>
|
||||
where
|
||||
F: FnOnce(NetworkId) -> Option<NetworkKeys>,
|
||||
{
|
||||
let hello: Hello = decode(&read_frame(recv, limits.max_frame_len).await?)?;
|
||||
check_version(hello.version)?;
|
||||
|
||||
let network_id = NetworkId::from_bytes(hello.network_id);
|
||||
let keys = lookup(network_id).ok_or(ProtocolError::UnknownNetwork)?;
|
||||
|
||||
let peer = conn.remote_id();
|
||||
let network_bytes = hello.network_id;
|
||||
let initiator = *peer.as_bytes();
|
||||
let responder = *local_id.as_bytes();
|
||||
let cb = channel_binding(conn, &network_bytes)?;
|
||||
|
||||
let nonce_i = hello.nonce;
|
||||
let nonce_r = fresh_nonce();
|
||||
let ack = HelloAck {
|
||||
version: PROTOCOL_VERSION,
|
||||
nonce: nonce_r,
|
||||
};
|
||||
write_frame(send, &encode(&ack)?, limits.max_frame_len).await?;
|
||||
|
||||
let theirs: AuthProof = decode(&read_frame(recv, limits.max_frame_len).await?)?;
|
||||
verify(
|
||||
keys.auth_key(),
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&initiator,
|
||||
&responder,
|
||||
cb.as_ref(),
|
||||
&nonce_i,
|
||||
&nonce_r,
|
||||
&theirs.proof,
|
||||
)?;
|
||||
|
||||
// Only now, after the peer proved membership, do we emit our own proof.
|
||||
let mine = proof(
|
||||
keys.auth_key(),
|
||||
ROLE_RESPONDER,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&initiator,
|
||||
&responder,
|
||||
cb.as_ref(),
|
||||
&nonce_i,
|
||||
&nonce_r,
|
||||
);
|
||||
write_frame(
|
||||
send,
|
||||
&encode(&AuthProof { proof: mine })?,
|
||||
limits.max_frame_len,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(HandshakeOutcome {
|
||||
network_id,
|
||||
peer,
|
||||
role: Role::Responder,
|
||||
})
|
||||
}
|
||||
|
||||
/// Test-only re-export of [`channel_binding`]. See [`crate::test_support`].
|
||||
#[doc(hidden)]
|
||||
pub fn channel_binding_for_test(
|
||||
conn: &Connection,
|
||||
network_id: &[u8; 32],
|
||||
) -> Result<[u8; 32], ProtocolError> {
|
||||
channel_binding(conn, network_id).map(|cb| *cb)
|
||||
}
|
||||
|
||||
/// Test-only re-export of the proof function. See [`crate::test_support`].
|
||||
#[doc(hidden)]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn proof_for_test(
|
||||
auth_key: &[u8; 32],
|
||||
role: &str,
|
||||
version: u16,
|
||||
network_id: &[u8; 32],
|
||||
initiator: &[u8; 32],
|
||||
responder: &[u8; 32],
|
||||
channel_binding: &[u8],
|
||||
nonce_initiator: &[u8; 16],
|
||||
nonce_responder: &[u8; 16],
|
||||
) -> [u8; 32] {
|
||||
proof(
|
||||
auth_key,
|
||||
role,
|
||||
version,
|
||||
network_id,
|
||||
initiator,
|
||||
responder,
|
||||
channel_binding,
|
||||
nonce_initiator,
|
||||
nonce_responder,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
|
||||
/// `(auth_key, network_id, initiator_id, responder_id, nonce_i, nonce_r)`
|
||||
type Fixture = ([u8; 32], [u8; 32], [u8; 32], [u8; 32], [u8; 16], [u8; 16]);
|
||||
|
||||
fn fixture() -> Fixture {
|
||||
(
|
||||
[1u8; 32], [2u8; 32], [3u8; 32], [4u8; 32], [5u8; 16], [6u8; 16],
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn role_labels_produce_different_transcripts() {
|
||||
let (key, net, ini, res, ni, nr) = fixture();
|
||||
let cb = [7u8; 32];
|
||||
let a = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
|
||||
let b = proof(&key, ROLE_RESPONDER, 1, &net, &ini, &res, &cb, &ni, &nr);
|
||||
assert_ne!(a, b, "reflecting a proof back must not verify");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_binding_changes_the_proof() {
|
||||
let (key, net, ini, res, ni, nr) = fixture();
|
||||
let a = proof(
|
||||
&key,
|
||||
ROLE_INITIATOR,
|
||||
1,
|
||||
&net,
|
||||
&ini,
|
||||
&res,
|
||||
&[7u8; 32],
|
||||
&ni,
|
||||
&nr,
|
||||
);
|
||||
let b = proof(
|
||||
&key,
|
||||
ROLE_INITIATOR,
|
||||
1,
|
||||
&net,
|
||||
&ini,
|
||||
&res,
|
||||
&[8u8; 32],
|
||||
&ni,
|
||||
&nr,
|
||||
);
|
||||
assert_ne!(a, b, "a proof must not be replayable on another connection");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identities_and_network_are_bound() {
|
||||
let (key, net, ini, res, ni, nr) = fixture();
|
||||
let cb = [7u8; 32];
|
||||
let base = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
|
||||
let other_net = proof(
|
||||
&key,
|
||||
ROLE_INITIATOR,
|
||||
1,
|
||||
&[9u8; 32],
|
||||
&ini,
|
||||
&res,
|
||||
&cb,
|
||||
&ni,
|
||||
&nr,
|
||||
);
|
||||
let swapped = proof(&key, ROLE_INITIATOR, 1, &net, &res, &ini, &cb, &ni, &nr);
|
||||
assert_ne!(base, other_net);
|
||||
assert_ne!(base, swapped);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transcript_encoding_is_unambiguous() {
|
||||
// Two different field splits that would collide under naive concatenation.
|
||||
let a = transcript(
|
||||
"ab", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"cd", &[0u8; 16], &[0u8; 16],
|
||||
);
|
||||
let b = transcript(
|
||||
"a", 1, &[0u8; 32], &[0u8; 32], &[0u8; 32], b"bcd", &[0u8; 16], &[0u8; 16],
|
||||
);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_key_fails_verification() {
|
||||
let (key, net, ini, res, ni, nr) = fixture();
|
||||
let cb = [7u8; 32];
|
||||
let tag = proof(&key, ROLE_INITIATOR, 1, &net, &ini, &res, &cb, &ni, &nr);
|
||||
let wrong = [0xAAu8; 32];
|
||||
let result = verify(
|
||||
&wrong,
|
||||
ROLE_INITIATOR,
|
||||
1,
|
||||
&net,
|
||||
&ini,
|
||||
&res,
|
||||
&cb,
|
||||
&ni,
|
||||
&nr,
|
||||
&tag,
|
||||
);
|
||||
assert!(matches!(result, Err(ProtocolError::AuthenticationFailed)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! Control message formats.
|
||||
//!
|
||||
//! This is deliberately a small, closed set of messages, not a general RPC
|
||||
//! framework. Bodies are encoded with [postcard], a compact, deterministic,
|
||||
//! non-self-describing serde format.
|
||||
//!
|
||||
//! Every message that arrives from the network passes [`validate`] against the
|
||||
//! configured [`Limits`] before it reaches anything else.
|
||||
//!
|
||||
//! [postcard]: https://docs.rs/postcard
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::config::Limits;
|
||||
use crate::dataplane::{MAX_PROTOCOL_ID_LEN, PluginCapability};
|
||||
use crate::error::ProtocolError;
|
||||
|
||||
/// ALPN of the tsunagi control plane.
|
||||
///
|
||||
/// The version in the ALPN is the wire-compatibility version of the control
|
||||
/// protocol. It is independent of the network identity scheme version, so
|
||||
/// bumping it must not change any existing [`crate::NetworkId`].
|
||||
pub const ALPN: &[u8] = b"tsunagi/ctrl/1";
|
||||
|
||||
/// ALPN of the tsunagi data plane.
|
||||
///
|
||||
/// Data plane connections are deliberately separate from control plane ones.
|
||||
/// They carry one IP plugin's packets for one network and nothing else, so a
|
||||
/// saturated or broken data plane cannot disturb control traffic, and the
|
||||
/// transport underneath can be replaced without touching the control protocol.
|
||||
pub const DATA_ALPN: &[u8] = b"tsunagi/data/1";
|
||||
|
||||
/// Largest plugin protocol identifier accepted when opening a data channel.
|
||||
pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
|
||||
|
||||
/// Largest accepted signature on a signed record, in bytes.
|
||||
pub const MAX_SIGNATURE_LEN: usize = 64;
|
||||
|
||||
/// Control protocol version carried inside the handshake.
|
||||
pub const PROTOCOL_VERSION: u16 = 1;
|
||||
|
||||
/// First message of the handshake, sent by the initiator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Hello {
|
||||
/// Control protocol version the initiator speaks.
|
||||
pub version: u16,
|
||||
/// Public network identifier the initiator wants to join.
|
||||
pub network_id: [u8; 32],
|
||||
/// Initiator's fresh handshake nonce.
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
/// Responder's reply to [`Hello`]. Carries no proof yet.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct HelloAck {
|
||||
/// Control protocol version the responder speaks.
|
||||
pub version: u16,
|
||||
/// Responder's fresh handshake nonce.
|
||||
pub nonce: [u8; 16],
|
||||
}
|
||||
|
||||
/// A handshake proof, i.e. one HMAC tag over a role-specific transcript.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AuthProof {
|
||||
/// HMAC-SHA256 tag.
|
||||
pub proof: [u8; 32],
|
||||
}
|
||||
|
||||
/// What this agent tells a peer about itself.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Announcement {
|
||||
/// Human-readable hostname. A mutable binding, not an identity.
|
||||
pub hostname: String,
|
||||
/// Announced IP plugin capabilities. Opaque to the core.
|
||||
pub capabilities: Vec<PluginCapability>,
|
||||
}
|
||||
|
||||
/// Opens a data channel, sent by the initiator right after the membership
|
||||
/// handshake on a [`DATA_ALPN`] connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DataOpen {
|
||||
/// Which IP plugin's packets this channel will carry.
|
||||
pub protocol: String,
|
||||
}
|
||||
|
||||
/// The responder's answer to [`DataOpen`].
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct DataOpenAck {
|
||||
/// Whether the channel was accepted.
|
||||
///
|
||||
/// A channel is refused when the responder has no plugin for that
|
||||
/// protocol in that network. That is an ordinary outcome, not an error.
|
||||
pub accepted: bool,
|
||||
/// Largest datagram the responder is willing to receive, in bytes.
|
||||
pub max_datagram: u32,
|
||||
}
|
||||
|
||||
/// A control message exchanged after a successful handshake.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum ControlMessage {
|
||||
/// Hostname and capability announcement.
|
||||
Announce(Announcement),
|
||||
/// A small request used to verify that the exchange works.
|
||||
Ping {
|
||||
/// Caller-chosen sequence number, echoed back.
|
||||
seq: u64,
|
||||
/// Opaque bounded payload, echoed back.
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// The reply to a [`ControlMessage::Ping`].
|
||||
Pong {
|
||||
/// Sequence number of the request being answered.
|
||||
seq: u64,
|
||||
/// Echoed payload.
|
||||
payload: Vec<u8>,
|
||||
},
|
||||
/// A snapshot of signed records this agent holds for the network.
|
||||
///
|
||||
/// A snapshot is merged into what the receiver already has, never
|
||||
/// substituted for it: an author missing from the batch is left alone,
|
||||
/// because absence is not deletion.
|
||||
State {
|
||||
/// The records. Bounded by [`crate::config::Limits::max_state_records`].
|
||||
records: Vec<crate::state::SignedRecord>,
|
||||
},
|
||||
/// Graceful goodbye.
|
||||
///
|
||||
/// A peer going away is not a revocation of anything.
|
||||
Bye {
|
||||
/// Short free-text reason.
|
||||
reason: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// A control message together with the network it belongs to.
|
||||
///
|
||||
/// Every session is bound to exactly one network at handshake time. The
|
||||
/// `network_id` here is re-checked on every message, so an authenticated
|
||||
/// session for network A can never be used to speak to network B, even over a
|
||||
/// shared physical connection.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Envelope {
|
||||
/// Network this message belongs to.
|
||||
pub network_id: [u8; 32],
|
||||
/// The message itself.
|
||||
pub message: ControlMessage,
|
||||
}
|
||||
|
||||
/// Encodes a value into a postcard byte vector.
|
||||
pub fn encode<T: Serialize>(value: &T) -> Result<Vec<u8>, ProtocolError> {
|
||||
postcard::to_stdvec(value).map_err(|_| ProtocolError::Malformed("cannot encode message"))
|
||||
}
|
||||
|
||||
/// Decodes a value from postcard bytes.
|
||||
pub fn decode<T: for<'de> Deserialize<'de>>(bytes: &[u8]) -> Result<T, ProtocolError> {
|
||||
postcard::from_bytes(bytes).map_err(|_| ProtocolError::Malformed("cannot decode message"))
|
||||
}
|
||||
|
||||
fn check_len(field: &'static str, len: usize, limit: usize) -> Result<(), ProtocolError> {
|
||||
if len > limit {
|
||||
return Err(ProtocolError::FieldTooLarge { field, len, limit });
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates a decoded capability against the configured limits.
|
||||
pub fn validate_capability(
|
||||
capability: &PluginCapability,
|
||||
limits: &Limits,
|
||||
) -> Result<(), ProtocolError> {
|
||||
if capability.protocol.is_empty() {
|
||||
return Err(ProtocolError::Malformed("empty plugin protocol id"));
|
||||
}
|
||||
check_len(
|
||||
"capability.protocol",
|
||||
capability.protocol.len(),
|
||||
MAX_PROTOCOL_ID_LEN,
|
||||
)?;
|
||||
check_len(
|
||||
"capability.data",
|
||||
capability.data.len(),
|
||||
limits.max_capability_data_len,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validates a decoded control message against the configured limits.
|
||||
///
|
||||
/// Returning an error rejects that single message. It never stops the session's
|
||||
/// network, the other networks or the agent.
|
||||
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
|
||||
match message {
|
||||
ControlMessage::Announce(announcement) => {
|
||||
check_len(
|
||||
"announce.hostname",
|
||||
announcement.hostname.len(),
|
||||
limits.max_hostname_len,
|
||||
)?;
|
||||
check_len(
|
||||
"announce.capabilities",
|
||||
announcement.capabilities.len(),
|
||||
limits.max_capabilities,
|
||||
)?;
|
||||
for capability in &announcement.capabilities {
|
||||
validate_capability(capability, limits)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Ping { payload, .. } | ControlMessage::Pong { payload, .. } => {
|
||||
check_len("echo.payload", payload.len(), limits.max_echo_payload_len)?;
|
||||
}
|
||||
ControlMessage::State { records } => {
|
||||
check_len("state.records", records.len(), limits.max_state_records)?;
|
||||
for record in records {
|
||||
check_len("state.signature", record.signature.len(), MAX_SIGNATURE_LEN)?;
|
||||
}
|
||||
}
|
||||
ControlMessage::Bye { reason } => {
|
||||
check_len("bye.reason", reason.len(), limits.max_reason_len)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A short, stable label for a message kind, for metrics and diagnostics.
|
||||
pub fn kind(message: &ControlMessage) -> &'static str {
|
||||
match message {
|
||||
ControlMessage::Announce(_) => "announce",
|
||||
ControlMessage::Ping { .. } => "ping",
|
||||
ControlMessage::Pong { .. } => "pong",
|
||||
ControlMessage::State { .. } => "state",
|
||||
ControlMessage::Bye { .. } => "bye",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//! The control protocol: framing, messages and the network membership
|
||||
//! handshake.
|
||||
//!
|
||||
//! Only control messages travel over iroh. User IP traffic is never tunnelled
|
||||
//! through this protocol.
|
||||
//!
|
||||
//! Layering, outermost first:
|
||||
//!
|
||||
//! 1. iroh/QUIC connection with ALPN [`message::ALPN`] — endpoint
|
||||
//! authentication, confidentiality and integrity.
|
||||
//! 2. One bidirectional stream per session, carrying length-prefixed frames
|
||||
//! ([`frame`]).
|
||||
//! 3. The [`handshake`], which must complete before anything else is accepted.
|
||||
//! 4. [`message::Envelope`]s carrying [`message::ControlMessage`]s, each
|
||||
//! re-checked against the session's network id.
|
||||
//!
|
||||
//! Nothing here adds its own encryption on top of iroh.
|
||||
//!
|
||||
//! # The data plane speaks a different protocol
|
||||
//!
|
||||
//! IP plugin packets never travel on a control connection. They use their own
|
||||
//! ALPN, [`message::DATA_ALPN`], with the same membership handshake followed by
|
||||
//! [`message::DataOpen`]. Keeping them apart is what lets the data plane's
|
||||
//! transport be replaced without touching anything above.
|
||||
|
||||
pub mod frame;
|
||||
pub mod handshake;
|
||||
pub mod message;
|
||||
|
||||
pub use frame::{read_frame, write_frame};
|
||||
pub use handshake::{HandshakeOutcome, Role};
|
||||
pub use message::{
|
||||
ALPN, Announcement, AuthProof, ControlMessage, DATA_ALPN, DataOpen, DataOpenAck, Envelope,
|
||||
Hello, HelloAck, PROTOCOL_VERSION,
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Choosing a free overlay address.
|
||||
//!
|
||||
//! Allocation, not derivation. Derivation needs no coordination but cannot
|
||||
//! avoid collisions in a space as small as IPv4; allocation avoids them but
|
||||
//! has to look at what everybody else already holds. The signed records in
|
||||
//! [`super`] are what makes that possible without a coordinator.
|
||||
//!
|
||||
//! The rules:
|
||||
//!
|
||||
//! * an address a participant already holds is kept, because stability across
|
||||
//! an absence is the whole point;
|
||||
//! * otherwise the search starts at a position derived from the participant's
|
||||
//! own identity, so two participants joining at once rarely start in the
|
||||
//! same place;
|
||||
//! * the search then walks the range, so a free address is found whenever one
|
||||
//! exists.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
use iroh::EndpointId;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::Ipv4Range;
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Why no address could be allocated.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum AllocationError {
|
||||
/// Every address in the range is taken.
|
||||
#[error("the overlay range {range} is full: {holders} of {usable} addresses are taken")]
|
||||
RangeFull {
|
||||
/// The range that is full.
|
||||
range: Ipv4Range,
|
||||
/// How many are held.
|
||||
holders: usize,
|
||||
/// How many the range has.
|
||||
usable: u64,
|
||||
},
|
||||
/// The range has no usable host addresses.
|
||||
#[error("the overlay range {range} has no room for hosts")]
|
||||
NoRoom {
|
||||
/// The offending range.
|
||||
range: Ipv4Range,
|
||||
},
|
||||
}
|
||||
|
||||
/// How many host addresses a range holds, excluding network and broadcast.
|
||||
pub fn usable_addresses(range: Ipv4Range) -> u64 {
|
||||
let host_bits = 32u32.saturating_sub(u32::from(range.prefix_len));
|
||||
if host_bits < 2 {
|
||||
return 0;
|
||||
}
|
||||
(1u64 << host_bits) - 2
|
||||
}
|
||||
|
||||
/// The nth host address of a range.
|
||||
fn address_at(range: Ipv4Range, offset: u64) -> Ipv4Addr {
|
||||
let host_bits = 32u32.saturating_sub(u32::from(range.prefix_len));
|
||||
let mask = if host_bits >= 32 {
|
||||
0
|
||||
} else {
|
||||
u32::MAX << host_bits
|
||||
};
|
||||
let network_part = u32::from(range.base) & mask;
|
||||
// Offsets run 1..=usable, so the network address is never handed out.
|
||||
Ipv4Addr::from(network_part | ((offset % (1u64 << host_bits)) as u32))
|
||||
}
|
||||
|
||||
/// Picks an address for `author`, keeping `current` if it is still usable.
|
||||
///
|
||||
/// `taken` is what every other participant is known to hold.
|
||||
pub fn allocate(
|
||||
network: NetworkId,
|
||||
author: EndpointId,
|
||||
range: Ipv4Range,
|
||||
taken: &HashSet<Ipv4Addr>,
|
||||
current: Option<Ipv4Addr>,
|
||||
) -> Result<Ipv4Addr, AllocationError> {
|
||||
let usable = usable_addresses(range);
|
||||
if usable == 0 {
|
||||
return Err(AllocationError::NoRoom { range });
|
||||
}
|
||||
|
||||
// Keeping what we already hold is what lets a participant come back to
|
||||
// the same address after any length of absence.
|
||||
if let Some(current) = current
|
||||
&& range.contains(current)
|
||||
&& !taken.contains(¤t)
|
||||
{
|
||||
return Ok(current);
|
||||
}
|
||||
|
||||
// Start somewhere derived from who we are, so two newcomers do not both
|
||||
// begin at the first address and collide every time.
|
||||
let mut hash = Sha256::new();
|
||||
hash.update(b"tsunagi-ipv4-allocation-v1");
|
||||
hash.update(network.as_bytes());
|
||||
hash.update(author.as_bytes());
|
||||
let digest = hash.finalize();
|
||||
let seed = u64::from_be_bytes([
|
||||
digest[0], digest[1], digest[2], digest[3], digest[4], digest[5], digest[6], digest[7],
|
||||
]);
|
||||
|
||||
for step in 0..usable {
|
||||
let offset = ((seed.wrapping_add(step)) % usable) + 1;
|
||||
let candidate = address_at(range, offset);
|
||||
if !taken.contains(&candidate) {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
Err(AllocationError::RangeFull {
|
||||
range,
|
||||
holders: taken.len(),
|
||||
usable,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use super::*;
|
||||
use crate::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use iroh::SecretKey;
|
||||
|
||||
fn network(name: &str) -> NetworkId {
|
||||
NetworkKeys::derive(
|
||||
&NetworkName::new(name).unwrap(),
|
||||
&NetworkSecret::from_bytes(vec![7u8; 32]).unwrap(),
|
||||
)
|
||||
.network_id()
|
||||
}
|
||||
|
||||
fn slash24() -> Ipv4Range {
|
||||
"10.13.37.0/24".parse().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_range_reports_its_usable_size() {
|
||||
assert_eq!(usable_addresses(slash24()), 254);
|
||||
assert_eq!(usable_addresses("10.0.0.0/16".parse().unwrap()), 65534);
|
||||
assert_eq!(usable_addresses("10.0.0.0/30".parse().unwrap()), 2);
|
||||
// The type refuses a /31, but the function is defensive anyway.
|
||||
assert!("10.0.0.0/31".parse::<Ipv4Range>().is_err());
|
||||
assert_eq!(
|
||||
usable_addresses(Ipv4Range {
|
||||
base: "10.0.0.0".parse().unwrap(),
|
||||
prefix_len: 31
|
||||
}),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_allocated_address_is_inside_the_range_and_not_its_edges() {
|
||||
let id = network("inside");
|
||||
let taken = HashSet::new();
|
||||
for _ in 0..64 {
|
||||
let author = SecretKey::generate().public();
|
||||
let address = allocate(id, author, slash24(), &taken, None).unwrap();
|
||||
assert!(slash24().contains(address));
|
||||
assert_ne!(address.octets()[3], 0, "never the network address");
|
||||
assert_ne!(address.octets()[3], 255, "never the broadcast address");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_address_already_held_is_kept() {
|
||||
let id = network("sticky");
|
||||
let author = SecretKey::generate().public();
|
||||
let mine: Ipv4Addr = "10.13.37.42".parse().unwrap();
|
||||
|
||||
// This is what lets a participant return to the same address.
|
||||
let taken = HashSet::new();
|
||||
assert_eq!(
|
||||
allocate(id, author, slash24(), &taken, Some(mine)).unwrap(),
|
||||
mine
|
||||
);
|
||||
|
||||
// Unless somebody else took it while we were away.
|
||||
let taken = HashSet::from([mine]);
|
||||
assert_ne!(
|
||||
allocate(id, author, slash24(), &taken, Some(mine)).unwrap(),
|
||||
mine
|
||||
);
|
||||
|
||||
// Or unless the range changed under us.
|
||||
let elsewhere: Ipv4Range = "10.99.0.0/16".parse().unwrap();
|
||||
let moved = allocate(id, author, elsewhere, &HashSet::new(), Some(mine)).unwrap();
|
||||
assert!(elsewhere.contains(moved));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allocation_is_deterministic_and_spread_out() {
|
||||
let id = network("spread");
|
||||
// Fixed keys, not random ones. With 40 random authors in a /24 the
|
||||
// birthday problem alone makes a handful of collisions likely, so a
|
||||
// threshold on the count was a coin flip rather than a property.
|
||||
let authors: Vec<_> = (0..40u8)
|
||||
.map(|seed| SecretKey::from_bytes(&[seed; 32]).public())
|
||||
.collect();
|
||||
|
||||
let first: Vec<_> = authors
|
||||
.iter()
|
||||
.map(|author| allocate(id, *author, slash24(), &HashSet::new(), None).unwrap())
|
||||
.collect();
|
||||
let again: Vec<_> = authors
|
||||
.iter()
|
||||
.map(|author| allocate(id, *author, slash24(), &HashSet::new(), None).unwrap())
|
||||
.collect();
|
||||
assert_eq!(first, again, "the same inputs give the same answer");
|
||||
|
||||
// Starting points are spread, so concurrent newcomers rarely clash.
|
||||
let distinct: HashSet<_> = first.iter().collect();
|
||||
assert!(
|
||||
distinct.len() >= 35,
|
||||
"only {} distinct starting points out of 40",
|
||||
distinct.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_search_walks_past_everything_taken() {
|
||||
let id = network("crowded");
|
||||
let author = SecretKey::generate().public();
|
||||
|
||||
// Everything taken except one address.
|
||||
let free: Ipv4Addr = "10.13.37.200".parse().unwrap();
|
||||
let taken: HashSet<Ipv4Addr> = (1..=254u8)
|
||||
.map(|host| Ipv4Addr::new(10, 13, 37, host))
|
||||
.filter(|addr| *addr != free)
|
||||
.collect();
|
||||
assert_eq!(allocate(id, author, slash24(), &taken, None).unwrap(), free);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_range_is_an_error_rather_than_a_duplicate() {
|
||||
let id = network("full");
|
||||
let author = SecretKey::generate().public();
|
||||
let taken: HashSet<Ipv4Addr> = (1..=254u8)
|
||||
.map(|host| Ipv4Addr::new(10, 13, 37, host))
|
||||
.collect();
|
||||
|
||||
assert!(matches!(
|
||||
allocate(id, author, slash24(), &taken, None),
|
||||
Err(AllocationError::RangeFull { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
allocate(
|
||||
id,
|
||||
author,
|
||||
Ipv4Range {
|
||||
base: "10.0.0.0".parse().unwrap(),
|
||||
prefix_len: 31
|
||||
},
|
||||
&HashSet::new(),
|
||||
None
|
||||
),
|
||||
Err(AllocationError::NoRoom { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_address_in_a_small_range_can_be_handed_out() {
|
||||
let id = network("exhaustive");
|
||||
let small: Ipv4Range = "10.13.37.0/29".parse().unwrap();
|
||||
let mut taken = HashSet::new();
|
||||
let mut handed = Vec::new();
|
||||
|
||||
for _ in 0..usable_addresses(small) {
|
||||
let author = SecretKey::generate().public();
|
||||
let address = allocate(id, author, small, &taken, None).unwrap();
|
||||
assert!(taken.insert(address), "handed out {address} twice");
|
||||
handed.push(address);
|
||||
}
|
||||
assert_eq!(handed.len(), 6);
|
||||
// And then it is genuinely full.
|
||||
let author = SecretKey::generate().public();
|
||||
assert!(allocate(id, author, small, &taken, None).is_err());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,231 @@
|
||||
//! The disposable cache store, `cache.sqlite`.
|
||||
//!
|
||||
//! Everything here is recoverable. A missing cache is recreated, a corrupt one
|
||||
//! is thrown away and recreated, and a stale one is simply wrong data that the
|
||||
//! rest of the system is expected to tolerate.
|
||||
//!
|
||||
//! Crucially, a stale cache never bypasses identity or network authentication:
|
||||
//! cached hints only produce *candidates*, which still have to pass the
|
||||
//! handshake.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::NetworkId;
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 1;
|
||||
|
||||
/// A cached address hint for one peer in one network.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AddressHint {
|
||||
/// Network the hint belongs to.
|
||||
pub network_id: NetworkId,
|
||||
/// Peer endpoint id, 32 bytes.
|
||||
pub endpoint_id: [u8; 32],
|
||||
/// Serialised address, currently `ip:<socketaddr>` or `relay:<url>`.
|
||||
pub addr: String,
|
||||
/// Unix seconds when this hint was last confirmed.
|
||||
pub last_seen: i64,
|
||||
}
|
||||
|
||||
/// Why the cache had to be recreated, if it did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum CacheOutcome {
|
||||
/// Opened normally.
|
||||
Opened,
|
||||
/// Created because nothing was there.
|
||||
Created,
|
||||
/// Discarded and recreated. The reason is free of secrets.
|
||||
Reset(String),
|
||||
}
|
||||
|
||||
/// The disposable cache store.
|
||||
#[derive(Debug)]
|
||||
pub struct CacheStore {
|
||||
conn: Connection,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl CacheStore {
|
||||
/// Opens the cache, discarding and recreating it if it is unusable.
|
||||
pub fn open_or_reset(path: impl AsRef<Path>) -> Result<(Self, CacheOutcome)> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let existed = path.exists();
|
||||
|
||||
match Self::try_open(&path, existed) {
|
||||
Ok(store) => Ok((
|
||||
store,
|
||||
if existed {
|
||||
CacheOutcome::Opened
|
||||
} else {
|
||||
CacheOutcome::Created
|
||||
},
|
||||
)),
|
||||
Err(reason) => {
|
||||
tracing::warn!(path = %path.display(), %reason, "discarding unusable cache");
|
||||
Self::remove_files(&path);
|
||||
let store = Self::try_open(&path, false)
|
||||
.map_err(|err| Error::Storage(format!("cannot recreate cache: {err}")))?;
|
||||
Ok((store, CacheOutcome::Reset(reason)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn try_open(path: &Path, check_integrity: bool) -> std::result::Result<Self, String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("cannot create cache directory: {err}"))?;
|
||||
}
|
||||
let conn = Connection::open(path).map_err(|err| format!("cannot open: {err}"))?;
|
||||
super::restrict_path_permissions(path).map_err(|err| format!("{err}"))?;
|
||||
super::apply_common_pragmas(&conn).map_err(|err| format!("cannot configure: {err}"))?;
|
||||
|
||||
if check_integrity {
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.map_err(|err| format!("integrity check failed: {err}"))?;
|
||||
if integrity != "ok" {
|
||||
return Err(format!("integrity check reported: {integrity}"));
|
||||
}
|
||||
}
|
||||
|
||||
let found: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|err| format!("cannot read schema version: {err}"))?;
|
||||
if found > SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"cache schema version {found} is newer than {SCHEMA_VERSION}"
|
||||
));
|
||||
}
|
||||
if found < SCHEMA_VERSION {
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
DROP TABLE IF EXISTS address_hints;
|
||||
CREATE TABLE address_hints (
|
||||
network_id BLOB NOT NULL,
|
||||
endpoint_id BLOB NOT NULL,
|
||||
addr TEXT NOT NULL,
|
||||
last_seen INTEGER NOT NULL,
|
||||
PRIMARY KEY (network_id, endpoint_id, addr)
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| format!("cannot create cache schema: {err}"))?;
|
||||
} else {
|
||||
conn.query_row("SELECT count(*) FROM address_hints", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.map_err(|err| format!("cache schema is unusable: {err}"))?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
conn,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_files(path: &Path) {
|
||||
for suffix in ["", "-wal", "-shm", "-journal"] {
|
||||
let mut name = path.as_os_str().to_os_string();
|
||||
name.push(suffix);
|
||||
let _ = std::fs::remove_file(PathBuf::from(name));
|
||||
}
|
||||
}
|
||||
|
||||
/// Path of the underlying file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
/// Records an address hint, keeping at most `max_per_peer` newest entries.
|
||||
pub fn record_hint(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: &[u8; 32],
|
||||
addr: &str,
|
||||
max_per_peer: usize,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO address_hints (network_id, endpoint_id, addr, last_seen)
|
||||
VALUES (?1, ?2, ?3, ?4)
|
||||
ON CONFLICT(network_id, endpoint_id, addr)
|
||||
DO UPDATE SET last_seen = excluded.last_seen",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
addr,
|
||||
super::state::now_unix()
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot record address hint: {err}")))?;
|
||||
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM address_hints
|
||||
WHERE network_id = ?1 AND endpoint_id = ?2 AND addr NOT IN (
|
||||
SELECT addr FROM address_hints
|
||||
WHERE network_id = ?1 AND endpoint_id = ?2
|
||||
ORDER BY last_seen DESC LIMIT ?3
|
||||
)",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
endpoint_id.as_slice(),
|
||||
max_per_peer as i64
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot prune address hints: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns every hint known for a network.
|
||||
pub fn hints_for_network(&self, network_id: NetworkId) -> Result<Vec<AddressHint>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT endpoint_id, addr, last_seen FROM address_hints
|
||||
WHERE network_id = ?1 ORDER BY last_seen DESC",
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot read address hints: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
let endpoint_id: Vec<u8> = row.get(0)?;
|
||||
let addr: String = row.get(1)?;
|
||||
let last_seen: i64 = row.get(2)?;
|
||||
Ok((endpoint_id, addr, last_seen))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read address hints: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (endpoint_id, addr, last_seen) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read hint row: {err}")))?;
|
||||
// A malformed row in a disposable store is skipped, not fatal.
|
||||
let Ok(endpoint_id) = <[u8; 32]>::try_from(endpoint_id.as_slice()) else {
|
||||
continue;
|
||||
};
|
||||
out.push(AddressHint {
|
||||
network_id,
|
||||
endpoint_id,
|
||||
addr,
|
||||
last_seen,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Drops all hints for a network.
|
||||
pub fn forget_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM address_hints WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot clear address hints: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Ownership lock for a state directory.
|
||||
//!
|
||||
//! One persistent state directory belongs to exactly one live agent instance.
|
||||
//! Checking whether a file exists is not enough — a stale file from a crashed
|
||||
//! process must not block a restart, and two concurrently starting agents must
|
||||
//! not both win. An advisory OS file lock gives both properties.
|
||||
|
||||
use std::fs::{File, OpenOptions};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use fs4::{FileExt, TryLockError};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
|
||||
/// An exclusive lock held for the lifetime of an agent.
|
||||
///
|
||||
/// Dropping it releases the lock, so a cleanly stopped agent leaves the
|
||||
/// directory immediately reopenable.
|
||||
#[derive(Debug)]
|
||||
pub struct DirectoryLock {
|
||||
file: File,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl DirectoryLock {
|
||||
/// Acquires the lock, failing fast if another live agent holds it.
|
||||
pub fn acquire(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let file = OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.open(&path)
|
||||
.map_err(|source| Error::Io {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?;
|
||||
super::restrict_permissions(&file, &path)?;
|
||||
|
||||
match FileExt::try_lock(&file) {
|
||||
Ok(()) => Ok(Self { file, path }),
|
||||
Err(TryLockError::WouldBlock) => Err(Error::StateLocked {
|
||||
path: path
|
||||
.parent()
|
||||
.map(Path::to_path_buf)
|
||||
.unwrap_or_else(|| path.clone()),
|
||||
}),
|
||||
Err(TryLockError::Error(source)) => Err(Error::Io { path, source }),
|
||||
}
|
||||
}
|
||||
|
||||
/// The path of the lock file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for DirectoryLock {
|
||||
fn drop(&mut self) {
|
||||
// Best effort: the OS releases the lock when the descriptor closes anyway.
|
||||
let _ = FileExt::unlock(&self.file);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
//! Persistence, split into mandatory state and a disposable cache.
|
||||
//!
|
||||
//! | store | contents | on damage |
|
||||
//! |----------------|--------------------------------------------------------|-----------|
|
||||
//! | `state.sqlite` | device identity, network configuration, hostname | hard error |
|
||||
//! | `cache.sqlite` | address hints and other recoverable data | discarded and recreated |
|
||||
//!
|
||||
//! Both files are created with owner-only permissions where the platform
|
||||
//! supports it. The state directory additionally carries an ownership lock, see
|
||||
//! [`DirectoryLock`].
|
||||
//!
|
||||
//! SQLite is synchronous. Every call that touches a database therefore runs on
|
||||
//! a blocking pool via [`tokio::task::spawn_blocking`], and no database lock is
|
||||
//! ever held across a network `await`.
|
||||
|
||||
mod cache;
|
||||
mod lock;
|
||||
mod state;
|
||||
|
||||
pub use cache::{AddressHint, CacheOutcome, CacheStore};
|
||||
pub use lock::DirectoryLock;
|
||||
pub use state::{SCHEMA_VERSION, StateStore, StoredNetwork};
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
use crate::config::StoragePaths;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::SignedRecord;
|
||||
|
||||
/// Applies the pragmas both stores share.
|
||||
fn apply_common_pragmas(conn: &rusqlite::Connection) -> rusqlite::Result<()> {
|
||||
conn.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
conn.pragma_update(None, "journal_mode", "WAL")?;
|
||||
conn.pragma_update(None, "synchronous", "NORMAL")?;
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restricts a file to the current user where the platform supports it.
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(file: &std::fs::File, path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let perms = std::fs::Permissions::from_mode(0o600);
|
||||
file.set_permissions(perms).map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
/// On Windows, files inherit the parent directory's ACL, which for the
|
||||
/// per-user application data directory is already restricted to that user.
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_permissions(_file: &std::fs::File, _path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn restrict_path_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn restrict_path_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn restrict_dir_permissions(path: &Path) -> Result<()> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err(|source| {
|
||||
Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
pub(crate) fn restrict_dir_permissions(_path: &Path) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn create_dir(path: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(path).map_err(|source| Error::Io {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
restrict_dir_permissions(path)
|
||||
}
|
||||
|
||||
/// Async facade over both stores, holding the directory ownership lock.
|
||||
///
|
||||
/// Cloning shares the same underlying connections and the same lock.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Storage {
|
||||
inner: Arc<Inner>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Inner {
|
||||
state: Mutex<StateStore>,
|
||||
cache: Mutex<Option<CacheStore>>,
|
||||
cache_outcome: CacheOutcome,
|
||||
paths: StoragePaths,
|
||||
lock: Mutex<Option<DirectoryLock>>,
|
||||
}
|
||||
|
||||
impl Storage {
|
||||
/// Opens both stores and takes the ownership lock on the state directory.
|
||||
///
|
||||
/// Fails with [`Error::StateLocked`] if another live agent owns the state
|
||||
/// directory, and with [`Error::StateCorrupted`] if the mandatory state is
|
||||
/// unusable. A broken cache is silently discarded and reported through
|
||||
/// [`Storage::cache_outcome`].
|
||||
pub fn open(paths: &StoragePaths) -> Result<Self> {
|
||||
create_dir(&paths.state_dir)?;
|
||||
create_dir(&paths.cache_dir)?;
|
||||
|
||||
let lock = DirectoryLock::acquire(paths.lock_file())?;
|
||||
let state = StateStore::open(paths.state_db())?;
|
||||
let (cache, cache_outcome) = CacheStore::open_or_reset(paths.cache_db())?;
|
||||
|
||||
Ok(Self {
|
||||
inner: Arc::new(Inner {
|
||||
state: Mutex::new(state),
|
||||
cache: Mutex::new(Some(cache)),
|
||||
cache_outcome,
|
||||
paths: paths.clone(),
|
||||
lock: Mutex::new(Some(lock)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// What happened to the cache when the agent started.
|
||||
pub fn cache_outcome(&self) -> &CacheOutcome {
|
||||
&self.inner.cache_outcome
|
||||
}
|
||||
|
||||
/// The configured paths.
|
||||
pub fn paths(&self) -> &StoragePaths {
|
||||
&self.inner.paths
|
||||
}
|
||||
|
||||
fn lock_state(&self) -> MutexGuard<'_, StateStore> {
|
||||
match self.inner.state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
fn lock_cache(&self) -> MutexGuard<'_, Option<CacheStore>> {
|
||||
match self.inner.cache.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs a closure against the mandatory state store on the blocking pool.
|
||||
async fn with_state<T, F>(&self, f: F) -> Result<T>
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&StateStore) -> Result<T> + Send + 'static,
|
||||
{
|
||||
let inner = Arc::clone(&self.inner);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let guard = match inner.state.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
f(&guard)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| Error::Storage(format!("state task failed: {err}")))?
|
||||
}
|
||||
|
||||
/// Runs a closure against the cache, tolerating an unavailable cache.
|
||||
///
|
||||
/// If the cache has been disabled because it misbehaved, the closure is
|
||||
/// skipped and `default` is returned.
|
||||
async fn with_cache<T, F>(&self, default: T, f: F) -> T
|
||||
where
|
||||
T: Send + 'static,
|
||||
F: FnOnce(&CacheStore) -> Result<T> + Send + 'static,
|
||||
{
|
||||
let inner = Arc::clone(&self.inner);
|
||||
let joined = tokio::task::spawn_blocking(move || {
|
||||
let guard = match inner.cache.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
match guard.as_ref() {
|
||||
Some(cache) => f(cache),
|
||||
None => Err(Error::Storage("cache is unavailable".into())),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
match joined {
|
||||
Ok(Ok(value)) => value,
|
||||
Ok(Err(err)) => {
|
||||
tracing::debug!(%err, "cache operation failed; continuing without it");
|
||||
default
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::debug!(%err, "cache task failed; continuing without it");
|
||||
default
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Loads or creates the persistent device identity.
|
||||
pub async fn device_identity(&self) -> Result<DeviceIdentity> {
|
||||
self.with_state(|state| state.load_or_create_device_identity())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Lists configured networks.
|
||||
pub async fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
self.with_state(|state| state.list_networks()).await
|
||||
}
|
||||
|
||||
/// Stores or updates a network configuration.
|
||||
pub async fn upsert_network(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
name: NetworkName,
|
||||
secret: NetworkSecret,
|
||||
auto_start: bool,
|
||||
) -> Result<()> {
|
||||
self.with_state(move |state| state.upsert_network(network_id, &name, &secret, auto_start))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Updates the auto-start flag of a network.
|
||||
pub async fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.with_state(move |state| state.set_auto_start(network_id, auto_start))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Removes a network configuration and its cached hints.
|
||||
pub async fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.with_state(move |state| {
|
||||
state.remove_network(network_id)?;
|
||||
state.forget_signed_records(network_id)
|
||||
})
|
||||
.await?;
|
||||
self.with_cache((), move |cache| cache.forget_network(network_id))
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads the stored hostname.
|
||||
pub async fn hostname(&self) -> Result<Option<String>> {
|
||||
self.with_state(|state| state.hostname()).await
|
||||
}
|
||||
|
||||
/// Writes the stored hostname.
|
||||
pub async fn set_hostname(&self, hostname: String) -> Result<()> {
|
||||
self.with_state(move |state| state.set_hostname(&hostname))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Records an address hint. Failures are non-fatal.
|
||||
pub async fn record_hint(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
endpoint_id: [u8; 32],
|
||||
addr: String,
|
||||
max_per_peer: usize,
|
||||
) {
|
||||
self.with_cache((), move |cache| {
|
||||
cache.record_hint(network_id, &endpoint_id, &addr, max_per_peer)
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Loads every signed record known for a network.
|
||||
pub async fn signed_records(&self, network_id: NetworkId) -> Result<Vec<SignedRecord>> {
|
||||
self.with_state(move |state| state.signed_records(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stores a record received from another replica.
|
||||
pub async fn put_signed_record(&self, record: SignedRecord) -> Result<()> {
|
||||
self.with_state(move |state| state.put_signed_record(&record))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stores one of this agent's own records and bumps its counter in one
|
||||
/// transaction, which must happen before the record is announced.
|
||||
pub async fn publish_own_record(&self, record: SignedRecord) -> Result<()> {
|
||||
self.with_state(move |state| state.publish_own_record(&record))
|
||||
.await
|
||||
}
|
||||
|
||||
/// The highest version this agent has ever published for a network.
|
||||
pub async fn own_record_version(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
author: iroh::EndpointId,
|
||||
) -> Result<u64> {
|
||||
self.with_state(move |state| state.own_record_version(network_id, author))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Reads cached address hints. Returns an empty list if the cache is gone.
|
||||
pub async fn hints_for_network(&self, network_id: NetworkId) -> Vec<AddressHint> {
|
||||
self.with_cache(Vec::new(), move |cache| cache.hints_for_network(network_id))
|
||||
.await
|
||||
}
|
||||
|
||||
/// Synchronously reads the hostname. Used only during startup.
|
||||
pub(crate) fn hostname_blocking(&self) -> Result<Option<String>> {
|
||||
self.lock_state().hostname()
|
||||
}
|
||||
|
||||
/// Whether the cache is currently usable.
|
||||
pub fn cache_healthy(&self) -> bool {
|
||||
self.lock_cache().is_some()
|
||||
}
|
||||
|
||||
/// Releases the state directory ownership lock.
|
||||
///
|
||||
/// Called by [`crate::Agent::shutdown`] so that a cleanly stopped agent
|
||||
/// leaves its directory immediately claimable by another instance. The
|
||||
/// databases stay open and readable, but this handle no longer owns the
|
||||
/// directory and must not be used to write after this point.
|
||||
pub fn release_ownership_lock(&self) {
|
||||
let mut guard = match self.inner.lock.lock() {
|
||||
Ok(guard) => guard,
|
||||
Err(poisoned) => poisoned.into_inner(),
|
||||
};
|
||||
guard.take();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,670 @@
|
||||
//! The mandatory state store, `state.sqlite`.
|
||||
//!
|
||||
//! Holds the persistent device identity, configured networks (including their
|
||||
//! shared secrets, which are needed to re-derive keys after a restart), the
|
||||
//! stored hostname and auto-start flags.
|
||||
//!
|
||||
//! Corruption is reported, never silently repaired: a damaged state store must
|
||||
//! not quietly turn into a brand new identity.
|
||||
//!
|
||||
//! Future work will add per-author record versions, accepted signed states and
|
||||
//! revocations here. When that lands, writing an event and bumping the author's
|
||||
//! own counter must happen in one SQLite transaction *before* the change is
|
||||
//! published to the network.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::identity::{DeviceIdentity, NetworkId, NetworkName, NetworkSecret};
|
||||
use crate::state::{RecordBody, SignedRecord};
|
||||
|
||||
/// Schema version written by this build.
|
||||
pub const SCHEMA_VERSION: i64 = 3;
|
||||
|
||||
/// Key of the stored hostname setting.
|
||||
const SETTING_HOSTNAME: &str = "hostname";
|
||||
|
||||
/// A network as persisted in the state store.
|
||||
///
|
||||
/// The secret is held in a [`NetworkSecret`], which redacts itself from `Debug`
|
||||
/// and zeroizes on drop.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct StoredNetwork {
|
||||
/// Derived public network identifier.
|
||||
pub network_id: NetworkId,
|
||||
/// Network name.
|
||||
pub name: NetworkName,
|
||||
/// Shared secret, needed to re-derive keys after restart.
|
||||
pub secret: NetworkSecret,
|
||||
/// Whether the network is activated automatically at agent startup.
|
||||
pub auto_start: bool,
|
||||
}
|
||||
|
||||
/// The mandatory state store.
|
||||
#[derive(Debug)]
|
||||
pub struct StateStore {
|
||||
conn: Connection,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl StateStore {
|
||||
/// Opens (creating if absent) the state store at `path`.
|
||||
///
|
||||
/// Returns [`Error::StateCorrupted`] if the file exists but is not a usable
|
||||
/// database. The file is never deleted or recreated by this function.
|
||||
pub fn open(path: impl AsRef<Path>) -> Result<Self> {
|
||||
let path = path.as_ref().to_path_buf();
|
||||
let existed = path.exists();
|
||||
// The database file is created on demand, so the directory holding
|
||||
// it has to be too — with the same restricted permissions the agent
|
||||
// would have given it, never looser.
|
||||
if let Some(parent) = path.parent()
|
||||
&& !parent.as_os_str().is_empty()
|
||||
{
|
||||
super::create_dir(parent)?;
|
||||
}
|
||||
let conn = Connection::open(&path).map_err(|err| Error::StateCorrupted {
|
||||
path: path.clone(),
|
||||
reason: format!("cannot open database: {err}"),
|
||||
})?;
|
||||
|
||||
super::restrict_path_permissions(&path)?;
|
||||
super::apply_common_pragmas(&conn).map_err(|err| Error::StateCorrupted {
|
||||
path: path.clone(),
|
||||
reason: format!("cannot configure database: {err}"),
|
||||
})?;
|
||||
|
||||
if existed {
|
||||
let integrity: String = conn
|
||||
.query_row("PRAGMA integrity_check", [], |row| row.get(0))
|
||||
.map_err(|err| Error::StateCorrupted {
|
||||
path: path.clone(),
|
||||
reason: format!("integrity check failed: {err}"),
|
||||
})?;
|
||||
if integrity != "ok" {
|
||||
return Err(Error::StateCorrupted {
|
||||
path,
|
||||
reason: format!("integrity check reported: {integrity}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let store = Self { conn, path };
|
||||
store.migrate()?;
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// Path of the underlying file.
|
||||
pub fn path(&self) -> &Path {
|
||||
&self.path
|
||||
}
|
||||
|
||||
fn corrupt(&self, reason: impl std::fmt::Display) -> Error {
|
||||
Error::StateCorrupted {
|
||||
path: self.path.clone(),
|
||||
reason: reason.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate(&self) -> Result<()> {
|
||||
let found: i64 = self
|
||||
.conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.map_err(|err| self.corrupt(format!("cannot read schema version: {err}")))?;
|
||||
|
||||
if found > SCHEMA_VERSION {
|
||||
return Err(Error::UnsupportedSchema {
|
||||
found,
|
||||
supported: SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
if found == SCHEMA_VERSION {
|
||||
return self.verify_shape();
|
||||
}
|
||||
|
||||
// Migration 2 -> 3: the record body gained a hostname, which changed
|
||||
// the signing domain, and the version counter gained an author.
|
||||
//
|
||||
// The stored records are discarded rather than carried over. They
|
||||
// were signed under a domain that no longer verifies, so keeping them
|
||||
// would mean holding rows that every read has to reject — and one of
|
||||
// those rejections could be mistaken for corruption. Each member
|
||||
// re-publishes its claim on the next run, which is the one thing here
|
||||
// that repairs itself.
|
||||
if (2..3).contains(&found) {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
DROP TABLE IF EXISTS signed_records;
|
||||
DROP TABLE IF EXISTS own_record_version;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
|
||||
self.conn
|
||||
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 3: {err}")))?;
|
||||
}
|
||||
|
||||
// Migration 1 -> 2: signed records that outlive a session.
|
||||
if (1..2).contains(&found) {
|
||||
self.conn
|
||||
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
||||
.map_err(|err| self.corrupt(format!("cannot migrate schema to 2: {err}")))?;
|
||||
}
|
||||
|
||||
// Migration 0 -> 1: initial schema.
|
||||
if found < 1 {
|
||||
self.conn
|
||||
.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE device_identity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
secret_key BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE networks (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
secret BLOB NOT NULL,
|
||||
auto_start INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
PRAGMA user_version = 1;
|
||||
COMMIT;",
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
||||
self.conn
|
||||
.execute_batch(SIGNED_RECORDS_SCHEMA)
|
||||
.map_err(|err| self.corrupt(format!("cannot create schema: {err}")))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Confirms the expected tables exist, so that a truncated or foreign
|
||||
/// database is reported rather than used.
|
||||
fn verify_shape(&self) -> Result<()> {
|
||||
for table in ["device_identity", "networks", "settings", "signed_records"] {
|
||||
let present: Option<String> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?1",
|
||||
params![table],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| self.corrupt(format!("cannot inspect schema: {err}")))?;
|
||||
if present.is_none() {
|
||||
return Err(self.corrupt(format!("table `{table}` is missing")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Loads the stored device identity, if there is one.
|
||||
///
|
||||
/// Reads and never writes, so asking who this device is does not decide
|
||||
/// it. A store that has never run an agent has no identity yet, which is
|
||||
/// `None` rather than an error.
|
||||
///
|
||||
/// A stored key of the wrong length is a corruption error, never a reason
|
||||
/// to silently mint a new identity.
|
||||
pub fn device_identity(&self) -> Result<Option<DeviceIdentity>> {
|
||||
let stored: Option<Vec<u8>> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT secret_key FROM device_identity WHERE id = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| self.corrupt(format!("cannot read device identity: {err}")))?;
|
||||
|
||||
let Some(bytes) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_| {
|
||||
self.corrupt(format!(
|
||||
"stored device key has {} bytes, expected 32; refusing to replace it",
|
||||
bytes.len()
|
||||
))
|
||||
})?;
|
||||
Ok(Some(DeviceIdentity::from_secret_bytes(&bytes)))
|
||||
}
|
||||
|
||||
/// Loads the stored device identity, creating one on first use.
|
||||
pub fn load_or_create_device_identity(&self) -> Result<DeviceIdentity> {
|
||||
if let Some(identity) = self.device_identity()? {
|
||||
return Ok(identity);
|
||||
}
|
||||
|
||||
let identity = DeviceIdentity::generate();
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)",
|
||||
params![identity.secret_bytes().as_slice(), now_unix()],
|
||||
)
|
||||
.map_err(|err| self.corrupt(format!("cannot store device identity: {err}")))?;
|
||||
Ok(identity)
|
||||
}
|
||||
|
||||
/// Replaces the device identity, giving up what the outgoing key held.
|
||||
///
|
||||
/// A device key is the author of every record this agent has signed, so
|
||||
/// replacing it makes this a different member. The addresses and names
|
||||
/// the old key claimed would otherwise stay reserved to a key nobody
|
||||
/// holds, and nothing could ever free them — there is no way to sign on
|
||||
/// another author's behalf, and by design there is no authority that
|
||||
/// could overrule one.
|
||||
///
|
||||
/// So the outgoing key signs a release for every network on its way out.
|
||||
/// That is the revocation: a positive statement, merged like any other,
|
||||
/// which frees the address and the name for whoever wants them next.
|
||||
///
|
||||
/// All of it commits together. A crash part way through must not leave an
|
||||
/// identity that has already been replaced beside releases that were
|
||||
/// never written, because the old key would then be gone and unable to
|
||||
/// sign them.
|
||||
///
|
||||
/// Returns the new identity and the networks a release was signed for.
|
||||
pub fn rotate_device_identity(&self) -> Result<(DeviceIdentity, Vec<NetworkId>)> {
|
||||
let outgoing = self.device_identity()?;
|
||||
let networks = self.list_networks()?;
|
||||
let replacement = DeviceIdentity::generate();
|
||||
|
||||
let transaction = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?;
|
||||
|
||||
let mut released = Vec::new();
|
||||
if let Some(outgoing) = &outgoing {
|
||||
let author = outgoing.endpoint_id();
|
||||
let signing = outgoing.signing_key();
|
||||
for network in &networks {
|
||||
let previous: Option<i64> = transaction
|
||||
.query_row(
|
||||
"SELECT version FROM own_record_version
|
||||
WHERE network_id = ?1 AND author = ?2",
|
||||
params![
|
||||
network.network_id.as_bytes().as_slice(),
|
||||
author.as_bytes().as_slice()
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?;
|
||||
// A key that never published anything has nothing to give up.
|
||||
let Some(previous) = previous else { continue };
|
||||
|
||||
let version = (previous.max(0) as u64).saturating_add(1);
|
||||
let record =
|
||||
SignedRecord::sign(&signing, network.network_id, version, RecordBody::Release);
|
||||
let body = postcard::to_stdvec(&record.body)
|
||||
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO signed_records (network_id, author, version, body, signature)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(network_id, author) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
body = excluded.body,
|
||||
signature = excluded.signature",
|
||||
params![
|
||||
record.network.as_slice(),
|
||||
record.author.as_slice(),
|
||||
record.version as i64,
|
||||
body,
|
||||
record.signature
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store a release: {err}")))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO own_record_version (network_id, author, version)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, author) DO UPDATE SET
|
||||
version = max(version, excluded.version)",
|
||||
params![
|
||||
record.network.as_slice(),
|
||||
record.author.as_slice(),
|
||||
record.version as i64
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
|
||||
released.push(network.network_id);
|
||||
}
|
||||
}
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO device_identity (id, secret_key, created_at) VALUES (1, ?1, ?2)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
secret_key = excluded.secret_key,
|
||||
created_at = excluded.created_at",
|
||||
params![replacement.secret_bytes().as_slice(), now_unix()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store the new identity: {err}")))?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|err| Error::Storage(format!("cannot commit the new identity: {err}")))?;
|
||||
Ok((replacement, released))
|
||||
}
|
||||
|
||||
/// Inserts or updates a network configuration.
|
||||
pub fn upsert_network(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
name: &NetworkName,
|
||||
secret: &NetworkSecret,
|
||||
auto_start: bool,
|
||||
) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO networks (network_id, name, secret, auto_start, created_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(network_id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
secret = excluded.secret,
|
||||
auto_start = excluded.auto_start",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
name.as_str(),
|
||||
secret.expose(),
|
||||
auto_start as i64,
|
||||
now_unix()
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store network: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets the auto-start flag of a configured network.
|
||||
pub fn set_auto_start(&self, network_id: NetworkId, auto_start: bool) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"UPDATE networks SET auto_start = ?2 WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice(), auto_start as i64],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot update network: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Removes a network configuration entirely.
|
||||
pub fn remove_network(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM networks WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot remove network: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lists every configured network.
|
||||
pub fn list_networks(&self) -> Result<Vec<StoredNetwork>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT network_id, name, secret, auto_start FROM networks ORDER BY name")
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
let id: Vec<u8> = row.get(0)?;
|
||||
let name: String = row.get(1)?;
|
||||
let secret: Vec<u8> = row.get(2)?;
|
||||
let auto_start: i64 = row.get(3)?;
|
||||
Ok((id, name, secret, auto_start != 0))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot list networks: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, name, secret, auto_start) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read network row: {err}")))?;
|
||||
let id: [u8; 32] = id
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| self.corrupt("stored network id is not 32 bytes"))?;
|
||||
out.push(StoredNetwork {
|
||||
network_id: NetworkId::from_bytes(id),
|
||||
name: NetworkName::new(name)?,
|
||||
secret: NetworkSecret::from_bytes(secret)?,
|
||||
auto_start,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Reads the stored hostname, if any.
|
||||
pub fn hostname(&self) -> Result<Option<String>> {
|
||||
self.get_setting(SETTING_HOSTNAME)
|
||||
}
|
||||
|
||||
/// Stores the hostname.
|
||||
///
|
||||
/// Today this is a local setting. In the future a rename must be a signed
|
||||
/// record that revokes the specific old binding and announces the new one,
|
||||
/// ideally atomically in one record.
|
||||
pub fn set_hostname(&self, hostname: &str) -> Result<()> {
|
||||
self.set_setting(SETTING_HOSTNAME, hostname)
|
||||
}
|
||||
|
||||
/// Loads every signed record known for a network.
|
||||
///
|
||||
/// Records are returned as stored; the caller verifies them, because the
|
||||
/// database is not a trust boundary — a restored backup or a copied file
|
||||
/// could contain anything.
|
||||
pub fn signed_records(&self, network_id: NetworkId) -> Result<Vec<SignedRecord>> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT author, version, body, signature FROM signed_records
|
||||
WHERE network_id = ?1",
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?;
|
||||
let rows = stmt
|
||||
.query_map(params![network_id.as_bytes().as_slice()], |row| {
|
||||
let author: Vec<u8> = row.get(0)?;
|
||||
let version: i64 = row.get(1)?;
|
||||
let body: Vec<u8> = row.get(2)?;
|
||||
let signature: Vec<u8> = row.get(3)?;
|
||||
Ok((author, version, body, signature))
|
||||
})
|
||||
.map_err(|err| Error::Storage(format!("cannot read signed records: {err}")))?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (author, version, body, signature) =
|
||||
row.map_err(|err| Error::Storage(format!("cannot read a record row: {err}")))?;
|
||||
let Ok(author) = <[u8; 32]>::try_from(author.as_slice()) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(body) = postcard::from_bytes(&body) else {
|
||||
continue;
|
||||
};
|
||||
out.push(SignedRecord {
|
||||
author,
|
||||
network: *network_id.as_bytes(),
|
||||
version: version as u64,
|
||||
body,
|
||||
signature,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Stores a record received from somebody else.
|
||||
pub fn put_signed_record(&self, record: &SignedRecord) -> Result<()> {
|
||||
let body = postcard::to_stdvec(&record.body)
|
||||
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO signed_records (network_id, author, version, body, signature)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(network_id, author) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
body = excluded.body,
|
||||
signature = excluded.signature",
|
||||
params![
|
||||
record.network.as_slice(),
|
||||
record.author.as_slice(),
|
||||
record.version as i64,
|
||||
body,
|
||||
record.signature
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store a signed record: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stores one of **our own** records and bumps our counter, atomically.
|
||||
///
|
||||
/// The model requires that a record and the author's own version counter
|
||||
/// are committed together, and **before** the record is published, so a
|
||||
/// crash can never leave us able to reuse a version number we already put
|
||||
/// on the wire.
|
||||
pub fn publish_own_record(&self, record: &SignedRecord) -> Result<()> {
|
||||
let body = postcard::to_stdvec(&record.body)
|
||||
.map_err(|err| Error::Storage(format!("cannot encode a record body: {err}")))?;
|
||||
let transaction = self
|
||||
.conn
|
||||
.unchecked_transaction()
|
||||
.map_err(|err| Error::Storage(format!("cannot begin a transaction: {err}")))?;
|
||||
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO signed_records (network_id, author, version, body, signature)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(network_id, author) DO UPDATE SET
|
||||
version = excluded.version,
|
||||
body = excluded.body,
|
||||
signature = excluded.signature",
|
||||
params![
|
||||
record.network.as_slice(),
|
||||
record.author.as_slice(),
|
||||
record.version as i64,
|
||||
body,
|
||||
record.signature
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store our record: {err}")))?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO own_record_version (network_id, author, version)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(network_id, author) DO UPDATE SET
|
||||
version = max(version, excluded.version)",
|
||||
params![
|
||||
record.network.as_slice(),
|
||||
record.author.as_slice(),
|
||||
record.version as i64
|
||||
],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot store our version: {err}")))?;
|
||||
|
||||
transaction
|
||||
.commit()
|
||||
.map_err(|err| Error::Storage(format!("cannot commit our record: {err}")))
|
||||
}
|
||||
|
||||
/// The highest version an author has ever published for a network.
|
||||
///
|
||||
/// Monotonic even if the record is later replaced by a conflicting one,
|
||||
/// so a number is never reused. Keyed by author as well: a replaced
|
||||
/// device key is a different author and starts its own sequence, while
|
||||
/// the outgoing one keeps its place so the release it signs on the way
|
||||
/// out cannot collide with something it already published.
|
||||
pub fn own_record_version(
|
||||
&self,
|
||||
network_id: NetworkId,
|
||||
author: iroh::EndpointId,
|
||||
) -> Result<u64> {
|
||||
let version: Option<i64> = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT version FROM own_record_version WHERE network_id = ?1 AND author = ?2",
|
||||
params![
|
||||
network_id.as_bytes().as_slice(),
|
||||
author.as_bytes().as_slice()
|
||||
],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| Error::Storage(format!("cannot read our version: {err}")))?;
|
||||
Ok(version.unwrap_or(0).max(0) as u64)
|
||||
}
|
||||
|
||||
/// Forgets every record of a network.
|
||||
pub fn forget_signed_records(&self, network_id: NetworkId) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM signed_records WHERE network_id = ?1",
|
||||
params![network_id.as_bytes().as_slice()],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot clear signed records: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads an arbitrary setting.
|
||||
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
|
||||
self.conn
|
||||
.query_row(
|
||||
"SELECT value FROM settings WHERE key = ?1",
|
||||
params![key],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()
|
||||
.map_err(|err| Error::Storage(format!("cannot read setting `{key}`: {err}")))
|
||||
}
|
||||
|
||||
/// Writes an arbitrary setting.
|
||||
pub fn set_setting(&self, key: &str, value: &str) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO settings (key, value) VALUES (?1, ?2)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
params![key, value],
|
||||
)
|
||||
.map_err(|err| Error::Storage(format!("cannot write setting `{key}`: {err}")))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Schema for the signed records described in [`crate::state`].
|
||||
///
|
||||
/// The version counter is keyed by author as well as network. A device key
|
||||
/// can be replaced, and the replacement is a different author: it must start
|
||||
/// its own sequence rather than inherit one, and the outgoing author's last
|
||||
/// version has to survive so its release record cannot collide with
|
||||
/// something it already published.
|
||||
const SIGNED_RECORDS_SCHEMA: &str = "BEGIN;
|
||||
CREATE TABLE IF NOT EXISTS signed_records (
|
||||
network_id BLOB NOT NULL,
|
||||
author BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
signature BLOB NOT NULL,
|
||||
PRIMARY KEY (network_id, author)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS own_record_version (
|
||||
network_id BLOB NOT NULL,
|
||||
author BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
PRIMARY KEY (network_id, author)
|
||||
);
|
||||
PRAGMA user_version = 3;
|
||||
COMMIT;";
|
||||
|
||||
/// Seconds since the Unix epoch, saturating at 0 before it.
|
||||
pub(crate) fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
@@ -0,0 +1,425 @@
|
||||
//! Scenarios 3 and 9: an attacker without the secret, and the protocol's
|
||||
//! boundaries.
|
||||
//!
|
||||
//! These tests speak the wire protocol directly against a real agent, because
|
||||
//! that is the only way to present a *correct* public network id with a wrong
|
||||
//! proof, replay a captured proof on a second connection, or send a message
|
||||
//! before authenticating.
|
||||
|
||||
#![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;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkId, NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::proto::handshake::{ROLE_INITIATOR, ROLE_RESPONDER};
|
||||
use tsunagi::proto::message::{
|
||||
ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode,
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
|
||||
/// A bare iroh endpoint with no tsunagi agent behind it.
|
||||
async fn raw_endpoint() -> Endpoint {
|
||||
Endpoint::builder(presets::Minimal)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.clear_address_lookup()
|
||||
.portmapper_config(PortmapperConfig::Disabled)
|
||||
.clear_ip_transports()
|
||||
.bind_addr("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.bind()
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn open_control_stream(
|
||||
endpoint: &Endpoint,
|
||||
target: EndpointAddr,
|
||||
) -> (Connection, SendStream, RecvStream) {
|
||||
let conn = endpoint.connect(target, ALPN).await.unwrap();
|
||||
let (send, recv) = conn.open_bi().await.unwrap();
|
||||
(conn, send, recv)
|
||||
}
|
||||
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
|
||||
/// Sends `Hello` and reads the responder's `HelloAck`.
|
||||
async fn exchange_hellos(
|
||||
send: &mut SendStream,
|
||||
recv: &mut RecvStream,
|
||||
version: u16,
|
||||
network_id: NetworkId,
|
||||
nonce: [u8; 16],
|
||||
) -> HelloAck {
|
||||
let hello = Hello {
|
||||
version,
|
||||
network_id: *network_id.as_bytes(),
|
||||
nonce,
|
||||
};
|
||||
write_frame(send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
decode(&read_frame(recv, LIMIT).await.unwrap()).unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_attacker_who_knows_the_public_network_id_is_still_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("closed-network");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// The attacker knows the address and the correct *public* network id. It
|
||||
// does not know the secret, so it cannot compute a valid proof.
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
let _ack = exchange_hellos(
|
||||
&mut send,
|
||||
&mut recv,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
[9u8; 16],
|
||||
)
|
||||
.await;
|
||||
let bogus = AuthProof { proof: [0xAB; 32] };
|
||||
write_frame(&mut send, &encode(&bogus).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// The responder must not reveal a proof of its own and must reject us.
|
||||
let reply = read_frame(&mut recv, LIMIT).await;
|
||||
assert!(
|
||||
reply.is_err(),
|
||||
"the agent must not answer an invalid proof, got {reply:?}"
|
||||
);
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
reason.contains("authentication failed"),
|
||||
"unexpected reason: {reason}"
|
||||
);
|
||||
|
||||
// The victim is unharmed: no session, and the network is still running.
|
||||
let status = victim.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert_eq!(status.metrics.sessions_established, 0);
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_secret_under_the_same_name_lands_in_a_different_space() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("same-name").unwrap();
|
||||
|
||||
let good = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let bad = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let good_id = good
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let bad_id = bad
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_ne!(good_id, bad_id);
|
||||
|
||||
// Even with discovery wired together, the two never form a session.
|
||||
//
|
||||
// This test only shows that discovery separated them; the authoritative
|
||||
// check that the secret itself gates membership is
|
||||
// `an_attacker_who_knows_the_public_network_id_is_still_rejected`.
|
||||
settle().await;
|
||||
let status = good.agent.network_status(good_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
good.agent.shutdown().await;
|
||||
bad.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_unsupported_protocol_version_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("versioned");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION + 7,
|
||||
network_id: *network_id.as_bytes(),
|
||||
nonce: [1u8; 16],
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("version"), "unexpected reason: {reason}");
|
||||
|
||||
// The agent is still alive and usable afterwards.
|
||||
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_control_message_before_authentication_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("no-early-messages");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
// A perfectly well formed control message, sent where a Hello belongs.
|
||||
let envelope = Envelope {
|
||||
network_id: *network_id.as_bytes(),
|
||||
message: ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: b"too early".to_vec(),
|
||||
},
|
||||
};
|
||||
write_frame(&mut send, &encode(&envelope).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { .. } => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let status = victim.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_oversized_frame_is_rejected_before_it_is_allocated() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("bounded-frames");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
|
||||
// Announce four gigabytes and then send nothing. The agent must reject the
|
||||
// header instead of allocating the buffer.
|
||||
send.write_all(&u32::MAX.to_be_bytes()).await.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } if reason.contains("exceeds") => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Still serving other work.
|
||||
assert!(victim.agent.network_status(network_id).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_proof_cannot_be_replayed_on_another_connection() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("channel-bound");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
let network_id = victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// A legitimate member: it knows the secret and can compute real proofs.
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
let auth_key = test_support::auth_key(&keys);
|
||||
let member = raw_endpoint().await;
|
||||
let local = *member.id().as_bytes();
|
||||
let remote = *victim.agent.endpoint_id().as_bytes();
|
||||
let network_bytes = *network_id.as_bytes();
|
||||
|
||||
// Connection one: a genuine, successful handshake.
|
||||
let (conn1, mut send1, mut recv1) =
|
||||
open_control_stream(&member, victim.agent.local_addr()).await;
|
||||
let nonce_i = [11u8; 16];
|
||||
let ack1 = exchange_hellos(
|
||||
&mut send1,
|
||||
&mut recv1,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
nonce_i,
|
||||
)
|
||||
.await;
|
||||
let cb1 = test_support::channel_binding(&conn1, &network_bytes).unwrap();
|
||||
let genuine = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&local,
|
||||
&remote,
|
||||
&cb1,
|
||||
&nonce_i,
|
||||
&ack1.nonce,
|
||||
);
|
||||
write_frame(
|
||||
&mut send1,
|
||||
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||||
LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let their_proof: AuthProof = decode(&read_frame(&mut recv1, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
// The responder proved membership too, and it used the responder role.
|
||||
let expected = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_RESPONDER,
|
||||
PROTOCOL_VERSION,
|
||||
&network_bytes,
|
||||
&local,
|
||||
&remote,
|
||||
&cb1,
|
||||
&nonce_i,
|
||||
&ack1.nonce,
|
||||
);
|
||||
assert_eq!(their_proof.proof, expected, "responder proof must verify");
|
||||
assert_ne!(
|
||||
their_proof.proof, genuine,
|
||||
"roles must not produce the same proof, or it could be reflected"
|
||||
);
|
||||
|
||||
// Connection two: replay the captured proof verbatim. The TLS exporter
|
||||
// differs per connection, so the proof no longer matches.
|
||||
let (conn2, mut send2, mut recv2) =
|
||||
open_control_stream(&member, victim.agent.local_addr()).await;
|
||||
let ack2 = exchange_hellos(
|
||||
&mut send2,
|
||||
&mut recv2,
|
||||
PROTOCOL_VERSION,
|
||||
network_id,
|
||||
nonce_i,
|
||||
)
|
||||
.await;
|
||||
let cb2 = test_support::channel_binding(&conn2, &network_bytes).unwrap();
|
||||
assert_ne!(cb1, cb2, "channel binding must differ between connections");
|
||||
let _ = ack2;
|
||||
|
||||
write_frame(
|
||||
&mut send2,
|
||||
&encode(&AuthProof { proof: genuine }).unwrap(),
|
||||
LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(
|
||||
read_frame(&mut recv2, LIMIT).await.is_err(),
|
||||
"a replayed proof must be rejected"
|
||||
);
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected { reason, .. } if reason.contains("authentication failed") => {
|
||||
Some(reason.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("authentication failed"));
|
||||
|
||||
conn1.close(0u32.into(), b"done");
|
||||
conn2.close(0u32.into(), b"done");
|
||||
member.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_hello_for_an_inactive_network_is_rejected() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("active-only");
|
||||
|
||||
let victim = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = victim.agent.subscribe();
|
||||
victim.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// A different, perfectly valid network space the victim is not in.
|
||||
let other = NetworkKeys::derive(
|
||||
&NetworkName::new("somewhere-else").unwrap(),
|
||||
&NetworkSecret::generate(),
|
||||
);
|
||||
|
||||
let attacker = raw_endpoint().await;
|
||||
let (conn, mut send, mut recv) =
|
||||
open_control_stream(&attacker, victim.agent.local_addr()).await;
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
network_id: *other.network_id().as_bytes(),
|
||||
nonce: [3u8; 16],
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(read_frame(&mut recv, LIMIT).await.is_err());
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::HandshakeRejected {
|
||||
network, reason, ..
|
||||
} => {
|
||||
// Before a successful handshake the claimed network is unverified,
|
||||
// so it must not be reported as fact.
|
||||
assert!(network.is_none());
|
||||
Some(reason.clone())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(reason.contains("unknown"), "unexpected reason: {reason}");
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
attacker.close().await;
|
||||
victim.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! Scenario 7: the disposable cache and the mandatory state store.
|
||||
|
||||
#![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::{Agent, Error};
|
||||
|
||||
/// Overwrites a file with bytes that are definitely not a SQLite database.
|
||||
fn corrupt(path: &std::path::Path) {
|
||||
let mut file = std::fs::File::create(path).unwrap();
|
||||
file.write_all(&[0x7f; 8192]).unwrap();
|
||||
file.sync_all().unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_missing_cache_is_recreated_and_does_not_block_connecting() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("missing-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
let dir = subject.stop().await;
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::remove_dir_all(&paths.cache_dir).unwrap();
|
||||
assert!(!paths.cache_db().exists());
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
restarted.status().await.unwrap().cache_outcome,
|
||||
CacheOutcome::Created
|
||||
);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_cache_is_discarded_and_does_not_block_connecting() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("corrupt-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
let device_id = subject.agent.endpoint_id();
|
||||
let dir = subject.stop().await;
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
corrupt(&paths.cache_db());
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let status = restarted.status().await.unwrap();
|
||||
assert!(
|
||||
matches!(status.cache_outcome, CacheOutcome::Reset(_)),
|
||||
"expected the cache to be discarded, got {:?}",
|
||||
status.cache_outcome
|
||||
);
|
||||
assert!(status.cache_healthy);
|
||||
assert_eq!(
|
||||
restarted.endpoint_id(),
|
||||
device_id,
|
||||
"a bad cache must not touch the identity"
|
||||
);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_stale_cache_does_not_prevent_connecting_through_discovery() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stale-cache");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
// Stop both. When they come back they bind new ports, so every cached
|
||||
// address hint is stale, and only fresh discovery can bridge the gap.
|
||||
let peer_dir = peer.stop().await;
|
||||
let subject_dir = subject.stop().await;
|
||||
assert!(
|
||||
StoragePaths::under(subject_dir.path()).cache_db().exists(),
|
||||
"hints were written, so the cache is genuinely stale now"
|
||||
);
|
||||
|
||||
let peer_again = Agent::spawn(config_with(peer_dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let subject_again = Agent::spawn(config_with(subject_dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_peers(&subject_again, network_id, 1).await;
|
||||
wait_for_peers(&peer_again, network_id, 1).await;
|
||||
|
||||
peer_again.shutdown().await;
|
||||
subject_again.shutdown().await;
|
||||
drop(peer_again);
|
||||
drop(subject_again);
|
||||
drop(peer_dir);
|
||||
drop(subject_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_corrupt_state_store_is_an_error_and_never_a_fresh_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("corrupt-state");
|
||||
|
||||
let subject = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = subject.agent.endpoint_id();
|
||||
subject.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = subject.stop().await;
|
||||
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
corrupt(&paths.state_db());
|
||||
|
||||
let result = Agent::spawn(config_with(dir.path(), &discovery)).await;
|
||||
match result {
|
||||
Err(Error::StateCorrupted { path, reason }) => {
|
||||
assert_eq!(path, paths.state_db());
|
||||
assert!(!reason.is_empty());
|
||||
}
|
||||
Err(other) => panic!("expected StateCorrupted, got {other:?}"),
|
||||
Ok(agent) => {
|
||||
let new_id = agent.endpoint_id();
|
||||
agent.shutdown().await;
|
||||
panic!(
|
||||
"a corrupt state store must not yield a working agent (id {new_id}) — the previous identity was {device_id}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_state_store_from_a_newer_build_is_refused() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::create_dir_all(&paths.state_dir).unwrap();
|
||||
|
||||
{
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
conn.pragma_update(None, "user_version", 999i64).unwrap();
|
||||
}
|
||||
|
||||
let result = Agent::spawn(local_config(dir.path())).await;
|
||||
assert!(
|
||||
matches!(result, Err(Error::UnsupportedSchema { found: 999, .. })),
|
||||
"expected UnsupportedSchema"
|
||||
);
|
||||
}
|
||||
|
||||
/// A store written by the previous schema must come up, not break.
|
||||
///
|
||||
/// The record body gained a hostname, which changed the signing domain, so
|
||||
/// records written before it can never verify again. Leaving them in place
|
||||
/// would mean every read rejecting rows that look exactly like corruption.
|
||||
#[tokio::test]
|
||||
async fn a_state_store_from_the_previous_schema_is_migrated_and_stays_usable() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
std::fs::create_dir_all(&paths.state_dir).unwrap();
|
||||
|
||||
// Build a schema-2 store by hand, with a record in it.
|
||||
{
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
conn.execute_batch(
|
||||
"BEGIN;
|
||||
CREATE TABLE device_identity (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
secret_key BLOB NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE networks (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
secret BLOB NOT NULL,
|
||||
auto_start INTEGER NOT NULL DEFAULT 1,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE signed_records (
|
||||
network_id BLOB NOT NULL,
|
||||
author BLOB NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
body BLOB NOT NULL,
|
||||
signature BLOB NOT NULL,
|
||||
PRIMARY KEY (network_id, author)
|
||||
);
|
||||
CREATE TABLE own_record_version (
|
||||
network_id BLOB PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
INSERT INTO signed_records VALUES (x'00', x'11', 7, x'2222', x'3333');
|
||||
INSERT INTO own_record_version VALUES (x'00', 7);
|
||||
PRAGMA user_version = 2;
|
||||
COMMIT;",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// An agent comes up on it, which is the whole point.
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let (name, secret) = network("migrated");
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
assert!(agent.network_status(network_id).await.is_ok());
|
||||
agent.shutdown().await;
|
||||
|
||||
let conn = rusqlite::Connection::open(paths.state_db()).unwrap();
|
||||
let version: i64 = conn
|
||||
.query_row("PRAGMA user_version", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(version, tsunagi::storage::SCHEMA_VERSION);
|
||||
|
||||
// The unverifiable record is gone rather than left to be rejected for
|
||||
// ever, and the counter is keyed by author now.
|
||||
let stale: i64 = conn
|
||||
.query_row(
|
||||
"SELECT count(*) FROM signed_records WHERE author = x'11'",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(stale, 0, "records from the old signing domain are dropped");
|
||||
conn.query_row(
|
||||
"SELECT count(*) FROM own_record_version WHERE author IS NOT NULL",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)
|
||||
.expect("the counter is keyed by author");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn secrets_never_appear_in_status_or_debug_output() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("no-leaks");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = agent.agent.status().await.unwrap();
|
||||
let rendered = format!("{status:?}");
|
||||
let encoded = secret.encode();
|
||||
assert!(!rendered.contains(encoded.as_str()));
|
||||
assert!(rendered.contains(&network_id.to_string()) || rendered.contains("NetworkId"));
|
||||
|
||||
let network_status = agent.agent.network_status(network_id).await.unwrap();
|
||||
assert!(!format!("{network_status:?}").contains(encoded.as_str()));
|
||||
|
||||
let keys = tsunagi::identity::NetworkKeys::derive(&name, &secret);
|
||||
let keys_debug = format!("{keys:?}");
|
||||
assert!(keys_debug.contains("<redacted>"));
|
||||
assert!(!keys_debug.contains(encoded.as_str()));
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//! Shared helpers for the integration tests.
|
||||
//!
|
||||
//! 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.
|
||||
//!
|
||||
//! Synchronisation is always "wait for a specific event or condition, under one
|
||||
//! overall deadline", never a fixed multi-second sleep.
|
||||
|
||||
#![allow(dead_code, 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 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.
|
||||
///
|
||||
/// The library never installs a global subscriber itself; tests opt in.
|
||||
pub fn init_tracing() {
|
||||
use std::sync::Once;
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| {
|
||||
if let Ok(filter) = std::env::var("TSUNAGI_TEST_LOG") {
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(tracing_subscriber::EnvFilter::new(filter))
|
||||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Overall deadline for anything a test waits on.
|
||||
pub const DEADLINE: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How often condition polling re-checks. Never used as the primary sync.
|
||||
const POLL_INTERVAL: Duration = Duration::from_millis(25);
|
||||
|
||||
/// Builds a fully local configuration rooted at `dir`.
|
||||
///
|
||||
/// Loopback binding with a dynamic port, no relay, no address lookup and no
|
||||
/// 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 {
|
||||
dial_timeout: Duration::from_millis(1500),
|
||||
handshake_timeout: Duration::from_secs(5),
|
||||
..Default::default()
|
||||
};
|
||||
AgentConfig::new(StoragePaths::under(dir))
|
||||
.with_transport(TransportPolicy::LocalOnly)
|
||||
.with_loopback_bind()
|
||||
.with_discovery_interval(Duration::from_millis(150))
|
||||
.with_limits(limits)
|
||||
}
|
||||
|
||||
/// Builds a local configuration wired to a shared in-memory discovery table.
|
||||
pub fn config_with(dir: &Path, discovery: &SharedMemoryDiscovery) -> AgentConfig {
|
||||
local_config(dir).with_discovery(Arc::new(discovery.clone()))
|
||||
}
|
||||
|
||||
/// A temporary directory plus the agent running on it.
|
||||
pub struct TestAgent {
|
||||
pub dir: TempDir,
|
||||
pub agent: Agent,
|
||||
}
|
||||
|
||||
impl TestAgent {
|
||||
/// Starts an agent on a fresh temporary directory.
|
||||
pub async fn spawn(discovery: &SharedMemoryDiscovery) -> Result<Self> {
|
||||
init_tracing();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let agent = Agent::spawn(config_with(dir.path(), discovery)).await?;
|
||||
Ok(Self { dir, agent })
|
||||
}
|
||||
|
||||
/// Starts an agent with a caller-supplied configuration on a fresh dir.
|
||||
pub async fn spawn_with(
|
||||
build: impl FnOnce(AgentConfig) -> AgentConfig,
|
||||
discovery: &SharedMemoryDiscovery,
|
||||
) -> Result<Self> {
|
||||
init_tracing();
|
||||
let dir = TempDir::new().expect("temp dir");
|
||||
let agent = Agent::spawn(build(config_with(dir.path(), discovery))).await?;
|
||||
Ok(Self { dir, agent })
|
||||
}
|
||||
|
||||
/// Stops the agent and returns the directory so it can be reopened.
|
||||
pub async fn stop(self) -> TempDir {
|
||||
self.agent.shutdown().await;
|
||||
self.dir
|
||||
}
|
||||
}
|
||||
|
||||
/// A network name and a fresh high-entropy secret.
|
||||
pub fn network(name: &str) -> (NetworkName, NetworkSecret) {
|
||||
(
|
||||
NetworkName::new(name).expect("valid network name"),
|
||||
NetworkSecret::generate(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Waits for an event matching `predicate`, under the global deadline.
|
||||
pub async fn wait_event<T>(
|
||||
rx: &mut tokio::sync::broadcast::Receiver<Event>,
|
||||
predicate: impl Fn(&Event) -> Option<T>,
|
||||
) -> T {
|
||||
let deadline = Instant::now() + DEADLINE;
|
||||
let mut seen: Vec<String> = Vec::new();
|
||||
loop {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.unwrap_or_default();
|
||||
assert!(!remaining.is_zero(), "timed out waiting for an event");
|
||||
|
||||
match tokio::time::timeout(remaining, rx.recv()).await {
|
||||
Ok(Ok(event)) => {
|
||||
if let Some(value) = predicate(&event) {
|
||||
return value;
|
||||
}
|
||||
if seen.len() < 12 {
|
||||
let label = format!("{event:?}");
|
||||
seen.push(label.chars().take(70).collect());
|
||||
}
|
||||
}
|
||||
Ok(Err(RecvError::Lagged(skipped))) => {
|
||||
panic!("event subscriber lagged, missed {skipped} events; first seen: {seen:#?}");
|
||||
}
|
||||
Ok(Err(RecvError::Closed)) => panic!("event channel closed while waiting"),
|
||||
Err(_) => panic!("timed out waiting for an event"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls an async condition until it returns `Some`, under the global deadline.
|
||||
pub async fn wait_until<T, F, Fut>(what: &str, mut probe: F) -> T
|
||||
where
|
||||
F: FnMut() -> Fut,
|
||||
Fut: Future<Output = Option<T>>,
|
||||
{
|
||||
let deadline = Instant::now() + DEADLINE;
|
||||
loop {
|
||||
if let Some(value) = probe().await {
|
||||
return value;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"timed out waiting for condition: {what}"
|
||||
);
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Lets a few discovery rounds pass.
|
||||
///
|
||||
/// Only ever used before asserting that something did **not** happen; waiting
|
||||
/// for success always goes through [`wait_event`] or [`wait_until`].
|
||||
pub async fn settle() {
|
||||
tokio::time::sleep(Duration::from_millis(900)).await;
|
||||
}
|
||||
|
||||
/// Waits until `agent` has `count` authenticated peers in `network`.
|
||||
pub async fn wait_for_peers(
|
||||
agent: &Agent,
|
||||
network: tsunagi::NetworkId,
|
||||
count: usize,
|
||||
) -> Vec<iroh::EndpointId> {
|
||||
wait_until(&format!("{count} peers in {network}"), || async move {
|
||||
let status = agent.network_status(network).await.ok()?;
|
||||
if status.peers.len() >= count {
|
||||
Some(status.connected_peers())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! The device's own identity: the name it answers to and the key it signs
|
||||
//! with, and what happens when either is changed.
|
||||
//!
|
||||
//! Both are things a user may reasonably change on a machine they own, and
|
||||
//! neither may leave the state store in a shape the next start cannot use.
|
||||
//! That is what these check: not that changing them is prevented, but that
|
||||
//! the store survives it and says something true afterwards.
|
||||
|
||||
#![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;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::{NetworkKeys, NetworkName, NetworkSecret};
|
||||
use tsunagi::state::{RecordBody, StateSet};
|
||||
use tsunagi::storage::StateStore;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_changed_name_reaches_peers_and_replaces_the_old_claim() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("renaming");
|
||||
|
||||
let a = TempDir::new().unwrap();
|
||||
let b = TempDir::new().unwrap();
|
||||
let agent_a = Agent::spawn(config_with(a.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let agent_b = Agent::spawn(config_with(b.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let network_id = agent_a.join_network(&name, &secret).await.unwrap();
|
||||
agent_b.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let accepted = agent_a.set_hostname("Renamed Host").await.unwrap();
|
||||
assert_eq!(accepted, "renamedhost", "reduced to a canonical form");
|
||||
assert_eq!(agent_a.hostname(), "renamedhost");
|
||||
|
||||
// The peer is told, rather than finding out on its next restart.
|
||||
wait_until("the peer learns the new name", || async {
|
||||
let status = agent_b.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.peers
|
||||
.iter()
|
||||
.any(|peer| peer.hostname.as_deref() == Some("renamedhost"))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
// And the signed claim says it, so the name outlives the session.
|
||||
wait_until("the claim carries the new name", || async {
|
||||
let status = agent_b.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| member.hostname.as_deref() == Some("renamedhost"))
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
|
||||
agent_a.shutdown().await;
|
||||
agent_b.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_name_that_reduces_to_nothing_is_refused_rather_than_stored() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let before = agent.hostname();
|
||||
assert!(agent.set_hostname("---").await.is_err());
|
||||
assert!(agent.set_hostname("").await.is_err());
|
||||
assert_eq!(agent.hostname(), before, "the old name still stands");
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
|
||||
/// Replacing the signing key must leave a store the next run can use.
|
||||
///
|
||||
/// The user is entitled to do this on a machine they own, and they lose the
|
||||
/// address and name the old key held — there is no way to sign on a dead
|
||||
/// key's behalf, and nothing here may overrule an author. What must not
|
||||
/// happen is that the store is left in a shape that breaks.
|
||||
#[tokio::test]
|
||||
async fn rotating_the_signing_key_releases_what_it_held_and_leaves_a_usable_store() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("rotation");
|
||||
let network_id = NetworkKeys::derive(&name, &secret).network_id();
|
||||
|
||||
// Run once so there is an identity, a network, and a claim to give up.
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.join_network(&name, &secret).await.unwrap();
|
||||
let before = agent.endpoint_id();
|
||||
wait_until("the agent claims an address", || async {
|
||||
let status = agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| member.endpoint_id == before && member.overlay_address_v4.is_some())
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
let claimed = agent
|
||||
.network_status(network_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.members
|
||||
.iter()
|
||||
.find(|member| member.endpoint_id == before)
|
||||
.and_then(|member| member.overlay_address_v4)
|
||||
.expect("an address was claimed");
|
||||
agent.shutdown().await;
|
||||
|
||||
let (replacement, released) = {
|
||||
let store = StateStore::open(paths.state_db()).unwrap();
|
||||
store.rotate_device_identity().unwrap()
|
||||
};
|
||||
assert_ne!(replacement.endpoint_id(), before, "a different author");
|
||||
assert_eq!(released, vec![network_id]);
|
||||
|
||||
// The outgoing key signed a release, and it still verifies: a record
|
||||
// whose author no longer runs is not thereby invalid.
|
||||
{
|
||||
let store = StateStore::open(paths.state_db()).unwrap();
|
||||
assert_eq!(
|
||||
store.device_identity().unwrap().map(|id| id.endpoint_id()),
|
||||
Some(replacement.endpoint_id())
|
||||
);
|
||||
|
||||
let mut set = StateSet::new();
|
||||
for record in store.signed_records(network_id).unwrap() {
|
||||
set.merge(network_id, record)
|
||||
.expect("every record verifies");
|
||||
}
|
||||
let old = set.get(&before).expect("the old author is still on record");
|
||||
assert!(matches!(old.body, RecordBody::Release));
|
||||
assert_eq!(
|
||||
set.address_of(&before),
|
||||
None,
|
||||
"the address it held is free again"
|
||||
);
|
||||
assert!(
|
||||
!set.address_holders().contains_key(&claimed),
|
||||
"{claimed} is no longer reserved"
|
||||
);
|
||||
}
|
||||
|
||||
// The whole point: the next run comes up on it.
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(agent.endpoint_id(), replacement.endpoint_id());
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_until("the new identity claims an address of its own", || async {
|
||||
let status = agent.network_status(network_id).await.ok()?;
|
||||
status
|
||||
.members
|
||||
.iter()
|
||||
.any(|member| {
|
||||
member.endpoint_id == replacement.endpoint_id()
|
||||
&& member.overlay_address_v4.is_some()
|
||||
})
|
||||
.then_some(())
|
||||
})
|
||||
.await;
|
||||
agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotating_a_store_that_has_never_run_just_creates_an_identity() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let paths = StoragePaths::under(dir.path());
|
||||
|
||||
let store = StateStore::open(paths.state_db()).unwrap();
|
||||
assert!(store.device_identity().unwrap().is_none());
|
||||
|
||||
let (identity, released) = store.rotate_device_identity().unwrap();
|
||||
assert!(
|
||||
released.is_empty(),
|
||||
"nothing was held, so nothing is given up"
|
||||
);
|
||||
assert_eq!(
|
||||
store.device_identity().unwrap().map(|id| id.endpoint_id()),
|
||||
Some(identity.endpoint_id())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_hostname_defaults_to_the_machines_own_name() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Whatever this machine is called, the agent uses it rather than
|
||||
// inventing a name from the key — unless the host has no usable one.
|
||||
match tsunagi::agent::system_hostname() {
|
||||
Some(system) => assert_eq!(agent.hostname(), system),
|
||||
None => assert!(agent.hostname().starts_with("tsunagi-")),
|
||||
}
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
|
||||
/// A secret must not reach the control socket, whatever else `id` prints.
|
||||
#[tokio::test]
|
||||
async fn secrets_stay_out_of_the_status_report() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = TempDir::new().unwrap();
|
||||
let (name, secret) = network("no-secrets-on-the-wire");
|
||||
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = agent.status().await.unwrap();
|
||||
let rendered = format!("{status:?}");
|
||||
assert!(!rendered.contains(secret.encode().as_str()));
|
||||
|
||||
agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_network_name_and_secret_are_unaffected_by_the_device_key() {
|
||||
// Network identity is derived from the name and secret only. Replacing
|
||||
// the device key must not move the network the device belongs to.
|
||||
let name = NetworkName::new("stable").unwrap();
|
||||
let secret = NetworkSecret::from_bytes(vec![9u8; 32]).unwrap();
|
||||
let first = NetworkKeys::derive(&name, &secret).network_id();
|
||||
let second = NetworkKeys::derive(&name, &secret).network_id();
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Discovery backends: static bootstrap candidates, composition, and the fact
|
||||
//! that discovery only ever supplies *candidates*.
|
||||
|
||||
#![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;
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_static_bootstrap_candidate_is_enough_to_join() {
|
||||
let (name, secret) = network("bootstrap-only");
|
||||
|
||||
// The listener runs with no discovery at all: it only accepts.
|
||||
let listener_dir = tempfile::TempDir::new().unwrap();
|
||||
let listener = Agent::spawn(local_config(listener_dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
let network_id = listener.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// The joiner is handed the listener's iroh id and addresses up front, which
|
||||
// is exactly what a static bootstrap entry is.
|
||||
let bootstrap = Arc::new(StaticBootstrap::new([listener.local_addr()]));
|
||||
let joiner_dir = tempfile::TempDir::new().unwrap();
|
||||
let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(bootstrap))
|
||||
.await
|
||||
.unwrap();
|
||||
joiner.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&joiner, network_id, 1).await;
|
||||
wait_for_peers(&listener, network_id, 1).await;
|
||||
|
||||
let status = joiner.network_status(network_id).await.unwrap();
|
||||
assert_eq!(
|
||||
status
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == listener.endpoint_id())
|
||||
.map(|candidate| candidate.source),
|
||||
Some(CandidateSource::Bootstrap)
|
||||
);
|
||||
|
||||
joiner.shutdown().await;
|
||||
listener.shutdown().await;
|
||||
drop(joiner);
|
||||
drop(listener);
|
||||
drop(joiner_dir);
|
||||
drop(listener_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_composite_backend_merges_its_sources() {
|
||||
let (name, secret) = network("composite");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
|
||||
let shared = SharedMemoryDiscovery::new();
|
||||
let via_shared = TestAgent::spawn(&shared).await.unwrap();
|
||||
let network_id = via_shared.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let bootstrap_dir = tempfile::TempDir::new().unwrap();
|
||||
let via_bootstrap = Agent::spawn(local_config(bootstrap_dir.path()))
|
||||
.await
|
||||
.unwrap();
|
||||
via_bootstrap.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// One backend knows the bootstrap peer, the other knows the shared-table
|
||||
// peer. Composed, the joiner reaches both.
|
||||
let composite = Arc::new(CompositeDiscovery::new([
|
||||
Arc::new(StaticBootstrap::new([via_bootstrap.local_addr()])) as Arc<dyn NetworkDiscovery>,
|
||||
Arc::new(shared.clone()) as Arc<dyn NetworkDiscovery>,
|
||||
]));
|
||||
let joiner_dir = tempfile::TempDir::new().unwrap();
|
||||
let joiner = Agent::spawn(local_config(joiner_dir.path()).with_discovery(composite))
|
||||
.await
|
||||
.unwrap();
|
||||
joiner.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&joiner, network_id, 2).await;
|
||||
|
||||
// Publishing went to the shared backend, so the other agent finds us too.
|
||||
assert!(shared.len(&keys.discovery_key()) >= 2);
|
||||
|
||||
joiner.shutdown().await;
|
||||
via_bootstrap.shutdown().await;
|
||||
via_shared.agent.shutdown().await;
|
||||
drop(joiner);
|
||||
drop(via_bootstrap);
|
||||
drop(joiner_dir);
|
||||
drop(bootstrap_dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn discovery_entries_are_withdrawn_when_a_network_stops() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("withdrawn");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
settle().await;
|
||||
assert_eq!(discovery.len(&keys.discovery_key()), 1);
|
||||
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
assert!(discovery.is_empty(&keys.discovery_key()));
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn forgetting_a_network_removes_it_from_the_state_store() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("forgettable");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(agent.agent.list_networks().await.unwrap().len(), 1);
|
||||
|
||||
agent.agent.forget_network(network_id).await.unwrap();
|
||||
assert!(agent.agent.list_networks().await.unwrap().is_empty());
|
||||
assert!(!agent.agent.is_active(network_id).await);
|
||||
|
||||
let dir = agent.stop().await;
|
||||
let restarted =
|
||||
Agent::spawn(local_config(dir.path()).with_discovery(Arc::new(discovery.clone())))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(restarted.list_networks().await.unwrap().is_empty());
|
||||
assert_eq!(restarted.status().await.unwrap().networks.len(), 0);
|
||||
|
||||
restarted.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
//! The full vertical slice: persistent identity, network space, discovery,
|
||||
//! real iroh connections, authentication and a control message exchange.
|
||||
|
||||
#![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;
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_agents_authenticate_and_exchange_messages() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vertical-slice");
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let mut events_a = a.agent.subscribe();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let same_id = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(
|
||||
network_id, same_id,
|
||||
"the same name and secret must produce the same network space"
|
||||
);
|
||||
|
||||
let peers = wait_for_peers(&a.agent, network_id, 1).await;
|
||||
assert_eq!(peers, vec![b.agent.endpoint_id()]);
|
||||
|
||||
// The exchange itself: a ping must come back as a matching pong.
|
||||
a.agent
|
||||
.send(
|
||||
network_id,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 42,
|
||||
payload: b"vertical".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let payload = wait_event(&mut events_a, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 42, payload },
|
||||
} if *network == network_id && *peer == b.agent.endpoint_id() => Some(payload.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(payload, b"vertical".to_vec());
|
||||
|
||||
// Both sides announce a hostname over the authenticated session.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
let peer = &status.peers[0];
|
||||
assert_eq!(peer.hostname.as_deref(), Some(b.agent.hostname().as_str()));
|
||||
assert!(peer.transport != tsunagi::net::TransportKind::Unknown);
|
||||
assert!(peer.rtt.is_some(), "a verified path must report an RTT");
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Scenario 1: deterministic network identity.
|
||||
//!
|
||||
//! The same name and secret must yield the same network space on different
|
||||
//! devices, and nothing else — hostname, device key, restart — may change it.
|
||||
|
||||
#![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};
|
||||
|
||||
#[test]
|
||||
fn derivation_is_a_pure_function_of_name_and_secret() {
|
||||
let name = NetworkName::new("home").unwrap();
|
||||
let other = NetworkName::new("Home").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let other_secret = NetworkSecret::generate();
|
||||
|
||||
let a = NetworkKeys::derive(&name, &secret);
|
||||
let b = NetworkKeys::derive(&name, &secret);
|
||||
assert_eq!(a.network_id(), b.network_id());
|
||||
assert_eq!(a.discovery_key(), b.discovery_key());
|
||||
assert_eq!(a.descriptor(), b.descriptor());
|
||||
|
||||
// A different name is a different space. Names are used verbatim, so case
|
||||
// matters.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&other, &secret).network_id()
|
||||
);
|
||||
// A different secret is a different space.
|
||||
assert_ne!(
|
||||
a.network_id(),
|
||||
NetworkKeys::derive(&name, &other_secret).network_id()
|
||||
);
|
||||
// Separated key material: the discovery key is not the network id.
|
||||
assert_ne!(a.network_id().as_bytes(), a.discovery_key().as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descriptor_carries_no_creator_time_or_secret() {
|
||||
let name = NetworkName::new("shared").unwrap();
|
||||
let secret = NetworkSecret::generate();
|
||||
let first = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
let second = NetworkKeys::derive(&name, &secret).descriptor();
|
||||
|
||||
// Two independently built descriptors are byte identical: no random
|
||||
// creator id, no creation timestamp, no owner signature.
|
||||
assert_eq!(first.to_canonical_bytes(), second.to_canonical_bytes());
|
||||
|
||||
let encoded = first.to_canonical_bytes();
|
||||
let secret_bytes = secret.encode();
|
||||
assert!(
|
||||
!encoded
|
||||
.windows(secret_bytes.len())
|
||||
.any(|window| window == secret_bytes.as_bytes()),
|
||||
"the secret must never appear in the public descriptor"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_are_validated_not_silently_normalised() {
|
||||
assert!(NetworkName::new("").is_err());
|
||||
assert!(NetworkName::new(" home").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("home ").is_err(), "must not be trimmed");
|
||||
assert!(NetworkName::new("ho\nme").is_err());
|
||||
assert!(NetworkName::new("a".repeat(65)).is_err());
|
||||
assert_eq!(NetworkName::new("home").unwrap().as_str(), "home");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn secrets_are_not_truncated_or_normalised() {
|
||||
let secret = NetworkSecret::generate();
|
||||
let text = secret.encode();
|
||||
let round_tripped = NetworkSecret::decode(&text).unwrap();
|
||||
assert_eq!(secret, round_tripped);
|
||||
|
||||
// Short secrets are rejected rather than stretched.
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 15]).is_err());
|
||||
assert!(NetworkSecret::from_bytes(vec![7u8; 16]).is_ok());
|
||||
|
||||
// Debug output must not leak the secret.
|
||||
let rendered = format!("{secret:?}");
|
||||
assert_eq!(rendered, "NetworkSecret(<redacted>)");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn different_devices_and_hostnames_agree_on_the_network_id() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("agreement");
|
||||
|
||||
let a = TestAgent::spawn_with(|cfg| cfg.with_hostname("alpha"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(|cfg| cfg.with_hostname("beta"), &discovery)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(
|
||||
a.agent.endpoint_id(),
|
||||
b.agent.endpoint_id(),
|
||||
"different devices must have different endpoint ids"
|
||||
);
|
||||
assert_eq!(a.agent.hostname(), "alpha");
|
||||
assert_eq!(b.agent.hostname(), "beta");
|
||||
|
||||
let id_a = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
let id_b = b.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(id_a, id_b);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restart_keeps_the_device_and_network_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stable");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let device_id = first.agent.endpoint_id();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
let dir = first.stop().await;
|
||||
|
||||
let reopened = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
reopened.endpoint_id(),
|
||||
device_id,
|
||||
"restarting must not mint a new peer"
|
||||
);
|
||||
let networks = reopened.list_networks().await.unwrap();
|
||||
assert_eq!(networks.len(), 1);
|
||||
assert_eq!(networks[0].network_id, network_id);
|
||||
assert!(networks[0].active, "auto-start networks come back up");
|
||||
|
||||
reopened.shutdown().await;
|
||||
drop(reopened);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn changing_the_secret_keeps_the_device_identity() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
let device_id = agent.endpoint_id();
|
||||
|
||||
let name = NetworkName::new("rotating").unwrap();
|
||||
let old = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_ne!(old, new, "a new secret is a new network space");
|
||||
assert_eq!(
|
||||
agent.endpoint_id(),
|
||||
device_id,
|
||||
"rotating the network secret must not change the persistent iroh id"
|
||||
);
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
//! The agent managing its own overlay interface.
|
||||
//!
|
||||
//! Everything here is real except the host: real agents, real control plane,
|
||||
//! real iroh links, the real plugin lifecycle and the real reconciliation
|
||||
//! rules. The host itself is a [`MockHost`], so what the agent would have
|
||||
//! done to a machine's interfaces is asserted instead of done — which is how
|
||||
//! this runs with no privileges and without touching the machine it is on.
|
||||
//!
|
||||
//! What the real provisioner adds on top of this is the netlink calls, and
|
||||
//! only those.
|
||||
|
||||
#![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::state::DEFAULT_IPV4_RANGE;
|
||||
use tsunagi::{Agent, NetworkStatus};
|
||||
|
||||
/// An agent whose overlay interface is applied to a pretend host.
|
||||
struct HostedAgent {
|
||||
_dir: TempDir,
|
||||
agent: Agent,
|
||||
plugin: Arc<WireguardPlugin>,
|
||||
host: MockHost,
|
||||
}
|
||||
|
||||
impl HostedAgent {
|
||||
async fn spawn(discovery: &SharedMemoryDiscovery, tag: &str, host: MockHost) -> Self {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let provisioner = Arc::new(MockProvisioner::new(host.clone()));
|
||||
let factory = Arc::new(ManagedTunFactory::new(provisioner));
|
||||
let config = WireguardConfig::new(dir.path().join("wireguard"))
|
||||
.with_interface_prefix(tag)
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(100));
|
||||
let plugin = WireguardPlugin::open(config, factory).await.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir.path(), discovery)
|
||||
.with_overlay_ipv4_range(Some(DEFAULT_IPV4_RANGE))
|
||||
.with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
_dir: dir,
|
||||
agent,
|
||||
plugin,
|
||||
host,
|
||||
}
|
||||
}
|
||||
|
||||
/// The interface name the plugin settled on for a network.
|
||||
async fn interface(&self, network: NetworkId) -> String {
|
||||
wait_until("the plugin named its interface", || async {
|
||||
self.plugin
|
||||
.overview(network)
|
||||
.map(|view| view.interface)
|
||||
.filter(|name| !name.is_empty())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Waits until the pretend host shows an interface in the given state.
|
||||
async fn wait_for_host<T>(
|
||||
&self,
|
||||
what: &str,
|
||||
name: &str,
|
||||
probe: impl Fn(Option<InterfaceState>) -> Option<T>,
|
||||
) -> T {
|
||||
wait_until(what, || async { probe(self.host.get(name)) }).await
|
||||
}
|
||||
}
|
||||
|
||||
fn v4(state: &InterfaceState) -> Vec<Ipv4Addr> {
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.filter_map(|cidr| match cidr.addr {
|
||||
IpAddr::V4(addr) => Some(addr),
|
||||
IpAddr::V6(_) => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn has_v6(state: &InterfaceState) -> bool {
|
||||
state
|
||||
.addresses
|
||||
.iter()
|
||||
.any(|cidr| matches!(cidr.addr, IpAddr::V6(_)))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_creates_and_configures_its_own_overlay_interface() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("provision-create");
|
||||
let agent = HostedAgent::spawn(&discovery, "tsunp", MockHost::new()).await;
|
||||
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
let interface = agent.interface(network_id).await;
|
||||
|
||||
let state = agent
|
||||
.wait_for_host("the interface to be created", &interface, |state| {
|
||||
state.filter(|state| !state.addresses.is_empty())
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(state.kind, LinkKind::Tun);
|
||||
assert!(state.up, "the agent brought the link up itself");
|
||||
assert_eq!(state.mtu, 1280);
|
||||
assert!(has_v6(&state), "the derived overlay address is assigned");
|
||||
|
||||
// The IPv4 address is allocated at run time, so it arrives on a later
|
||||
// reconciliation than the interface itself.
|
||||
let addresses = agent
|
||||
.wait_for_host("the allocated IPv4 address", &interface, |state| {
|
||||
state.map(|state| v4(&state)).filter(|v4| !v4.is_empty())
|
||||
})
|
||||
.await;
|
||||
assert_eq!(addresses.len(), 1);
|
||||
assert!(
|
||||
DEFAULT_IPV4_RANGE.contains(addresses[0]),
|
||||
"{:?} is outside {DEFAULT_IPV4_RANGE}",
|
||||
addresses[0]
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_left_by_a_crashed_run_is_replaced_rather_than_tripped_over() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("provision-crash");
|
||||
|
||||
// Work out the name this agent will use, then seed the host with what a
|
||||
// run that died would have left there: the interface, still carrying an
|
||||
// address from an allocation that no longer applies, with nothing holding
|
||||
// it open.
|
||||
let probe = HostedAgent::spawn(&discovery, "tsunc", MockHost::new()).await;
|
||||
let network_id = probe.agent.join_network(&name, &secret).await.unwrap();
|
||||
let interface = probe.interface(network_id).await;
|
||||
probe.agent.shutdown().await;
|
||||
|
||||
let host = MockHost::new();
|
||||
let stale = Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 178)), 24).unwrap();
|
||||
host.insert_stale_tun(&interface, vec![stale]);
|
||||
|
||||
let agent = HostedAgent::spawn(&discovery, "tsunc", host).await;
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(agent.interface(network_id).await, interface);
|
||||
|
||||
let state = agent
|
||||
.wait_for_host("the interface to be rebuilt", &interface, |state| {
|
||||
state.filter(|state| state.attached && has_v6(state))
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
!state.addresses.contains(&stale),
|
||||
"the stale address is gone: {:?}",
|
||||
state.addresses
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_interface_belonging_to_something_else_is_left_alone() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("provision-foreign");
|
||||
|
||||
let probe = HostedAgent::spawn(&discovery, "tsunf", MockHost::new()).await;
|
||||
let network_id = probe.agent.join_network(&name, &secret).await.unwrap();
|
||||
let interface = probe.interface(network_id).await;
|
||||
probe.agent.shutdown().await;
|
||||
|
||||
// Somebody else's bridge happens to hold the name.
|
||||
let host = MockHost::new();
|
||||
let theirs = InterfaceState {
|
||||
kind: LinkKind::Foreign("bridge".into()),
|
||||
attached: true,
|
||||
up: true,
|
||||
mtu: 1500,
|
||||
addresses: vec![Cidr::new(IpAddr::V4(Ipv4Addr::new(192, 168, 9, 1)), 24).unwrap()],
|
||||
};
|
||||
host.insert(&interface, theirs.clone());
|
||||
|
||||
let agent = HostedAgent::spawn(&discovery, "tsunf", host).await;
|
||||
agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Give the plugin several reconciliation rounds to do the wrong thing.
|
||||
tokio::time::sleep(Duration::from_millis(300)).await;
|
||||
assert_eq!(
|
||||
agent.host.get(&interface),
|
||||
Some(theirs),
|
||||
"the foreign interface must be untouched"
|
||||
);
|
||||
|
||||
// The control plane is unaffected by the data plane refusing.
|
||||
assert!(matches!(
|
||||
agent.agent.network_status(network_id).await,
|
||||
Ok(NetworkStatus { .. })
|
||||
));
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leaving_a_network_removes_the_interface_from_the_host() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("provision-cleanup");
|
||||
let agent = HostedAgent::spawn(&discovery, "tsunx", MockHost::new()).await;
|
||||
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
let interface = agent.interface(network_id).await;
|
||||
agent
|
||||
.wait_for_host("the interface to exist", &interface, |state| state)
|
||||
.await;
|
||||
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
agent
|
||||
.wait_for_host("the interface to be removed", &interface, |state| {
|
||||
state.is_none().then_some(())
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
agent.host.names().is_empty(),
|
||||
"nothing is left behind: {:?}",
|
||||
agent.host.names()
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
//! The local control interface: a client asking a running agent for status.
|
||||
//!
|
||||
//! Uses a real Unix socket on a temporary path, the real agent and the real
|
||||
//! WireGuard data plane, so what a `tsunagi status` client would see is what
|
||||
//! is checked here.
|
||||
|
||||
#![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::{Agent, BoxFuture};
|
||||
|
||||
/// 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> {
|
||||
Arc::new(move || -> BoxFuture<'static, StatusReport> {
|
||||
let agent = agent.clone();
|
||||
let plugin = Arc::clone(&plugin);
|
||||
Box::pin(async move {
|
||||
let status = agent.status().await.unwrap();
|
||||
let networks = status
|
||||
.networks
|
||||
.iter()
|
||||
.map(|net| tsunagi::ipc::NetworkReport {
|
||||
name: net.name.to_string(),
|
||||
network_id: net.network_id.to_string(),
|
||||
active: true,
|
||||
peers: net
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| tsunagi::ipc::PeerReport {
|
||||
endpoint_id: peer.endpoint_id.to_string(),
|
||||
hostname: peer.hostname.clone(),
|
||||
transport: format!("{:?}", peer.transport),
|
||||
rtt_ms: peer.rtt.map(|rtt| rtt.as_millis() as u64),
|
||||
})
|
||||
.collect(),
|
||||
overlay: plugin.overview(net.network_id).map(|view| {
|
||||
tsunagi::ipc::OverlayReport {
|
||||
interface: view.interface.clone(),
|
||||
mtu: view.mtu,
|
||||
address: view.overlay_address.to_string(),
|
||||
prefix: view.overlay_prefix.to_string(),
|
||||
prefix_len: view.overlay_prefix_len,
|
||||
peers: view
|
||||
.peers
|
||||
.iter()
|
||||
.map(|peer| tsunagi::ipc::OverlayPeerReport {
|
||||
public_key: peer.public_key.to_string(),
|
||||
address: peer.overlay_address.to_string(),
|
||||
handshake_secs_ago: peer
|
||||
.tunnel
|
||||
.as_ref()
|
||||
.and_then(|t| t.health.since_handshake)
|
||||
.map(|since| since.as_secs()),
|
||||
..Default::default()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}),
|
||||
..Default::default()
|
||||
})
|
||||
.collect();
|
||||
StatusReport {
|
||||
endpoint_id: status.endpoint_id.to_string(),
|
||||
hostname: status.hostname.clone(),
|
||||
bound_sockets: status
|
||||
.bound_sockets
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect(),
|
||||
cache_healthy: status.cache_healthy,
|
||||
networks,
|
||||
dns: None,
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_client_sees_the_agent_and_its_overlay() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("control-socket");
|
||||
|
||||
let dir_a = TempDir::new().unwrap();
|
||||
let tuns = MemoryTunFactory::new();
|
||||
let plugin = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir_a.path().join("wg"))
|
||||
.with_interface_prefix("tca")
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
Arc::new(tuns),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent = Agent::spawn(
|
||||
config_with(dir_a.path(), &discovery).with_plugin(plugin.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let dir_b = TempDir::new().unwrap();
|
||||
let plugin_b = WireguardPlugin::open(
|
||||
WireguardConfig::new(dir_b.path().join("wg"))
|
||||
.with_interface_prefix("tcb")
|
||||
.with_reconcile(Duration::from_millis(20), Duration::from_millis(250)),
|
||||
Arc::new(MemoryTunFactory::new()),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let agent_b = Agent::spawn(
|
||||
config_with(dir_b.path(), &discovery).with_plugin(plugin_b.clone() as Arc<dyn IpPlugin>),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
agent_b.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&agent, network_id, 1).await;
|
||||
|
||||
// A short path: a Unix socket address is limited to about 100 bytes.
|
||||
let socket_path = dir_a.path().join("agent.sock");
|
||||
let control = ControlSocket::bind(&socket_path, source(agent.clone(), plugin.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let report = wait_until("the overlay is reported as up", || {
|
||||
let socket_path = socket_path.clone();
|
||||
async move {
|
||||
let report = request_status(&socket_path).await.ok()?;
|
||||
let overlay = report.networks.first()?.overlay.as_ref()?;
|
||||
overlay
|
||||
.peers
|
||||
.iter()
|
||||
.any(|peer| peer.is_up())
|
||||
.then_some(report)
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
assert_eq!(report.endpoint_id, agent.endpoint_id().to_string());
|
||||
assert_eq!(report.networks.len(), 1);
|
||||
let net = &report.networks[0];
|
||||
assert_eq!(net.network_id, network_id.to_string());
|
||||
assert_eq!(net.peers.len(), 1);
|
||||
assert_eq!(net.peers[0].endpoint_id, agent_b.endpoint_id().to_string());
|
||||
|
||||
let overlay = net.overlay.as_ref().unwrap();
|
||||
assert!(overlay.interface.starts_with("tca"));
|
||||
assert_eq!(overlay.mtu, 1280);
|
||||
assert_eq!(overlay.peers.len(), 1);
|
||||
|
||||
// The report carries what a reader needs, in structured form: how it is
|
||||
// laid out is the CLI's business and is tested there.
|
||||
assert_eq!(report.endpoint_id, agent.endpoint_id().to_string());
|
||||
assert_eq!(
|
||||
overlay.peers.iter().filter(|peer| peer.is_up()).count(),
|
||||
1,
|
||||
"the tunnel is up: {:?}",
|
||||
overlay.peers
|
||||
);
|
||||
assert!(!overlay.address.is_empty());
|
||||
|
||||
control.shutdown().await;
|
||||
assert!(!socket_path.exists(), "the socket is removed on shutdown");
|
||||
|
||||
// With nothing listening, a client gets an error rather than hanging.
|
||||
assert!(request_status(&socket_path).await.is_err());
|
||||
|
||||
agent.shutdown().await;
|
||||
agent_b.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_leftover_socket_file_is_replaced_but_a_live_one_is_not() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let path = dir.path().join("agent.sock");
|
||||
|
||||
let empty: Arc<dyn tsunagi::ipc::unix::ReportSource> =
|
||||
Arc::new(|| -> BoxFuture<'static, StatusReport> {
|
||||
Box::pin(async { StatusReport::default() })
|
||||
});
|
||||
|
||||
// A file with nobody listening is a leftover from a crash.
|
||||
std::fs::write(&path, b"stale").unwrap();
|
||||
let first = ControlSocket::bind(&path, Arc::clone(&empty))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(request_status(&path).await.is_ok());
|
||||
|
||||
// A live socket is not stolen from the agent that owns it.
|
||||
let second = ControlSocket::bind(&path, Arc::clone(&empty)).await;
|
||||
assert!(
|
||||
matches!(second, Err(tsunagi::Error::StateLocked { .. })),
|
||||
"a second agent must not take over a live control socket"
|
||||
);
|
||||
|
||||
first.shutdown().await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_socket_path_is_derived_and_short_enough() {
|
||||
let deep = std::path::PathBuf::from(
|
||||
"/home/someone/.local/share/with/a/very/deeply/nested/directory/that/goes/on/and/on/and/on/tsunagi/state",
|
||||
);
|
||||
let path = control_socket_path(&deep);
|
||||
|
||||
// A Unix socket address is limited to roughly 100 bytes, so a deep state
|
||||
// directory must not produce a path that cannot be bound.
|
||||
if std::env::var_os("XDG_RUNTIME_DIR").is_some() {
|
||||
assert!(
|
||||
path.as_os_str().len() < 100,
|
||||
"derived path is {} bytes: {}",
|
||||
path.as_os_str().len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Deterministic, and different state directories never share a socket.
|
||||
assert_eq!(path, control_socket_path(&deep));
|
||||
assert_ne!(
|
||||
path,
|
||||
control_socket_path(&std::path::PathBuf::from("/somewhere/else"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//! Scenario 2: several agents find each other, authenticate for real and
|
||||
//! exchange distinguishable messages.
|
||||
|
||||
#![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;
|
||||
|
||||
#[tokio::test]
|
||||
async fn four_agents_form_a_mesh_and_exchange_distinguishable_messages() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("mesh-of-four");
|
||||
|
||||
let mut agents = Vec::new();
|
||||
for index in 0..4 {
|
||||
let hostname = format!("host-{index}");
|
||||
agents.push(
|
||||
TestAgent::spawn_with(move |cfg| cfg.with_hostname(hostname), &discovery)
|
||||
.await
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
let mut network_ids = HashSet::new();
|
||||
for agent in &agents {
|
||||
network_ids.insert(agent.agent.join_network(&name, &secret).await.unwrap());
|
||||
}
|
||||
assert_eq!(network_ids.len(), 1, "one deterministic network space");
|
||||
let network_id = network_ids.into_iter().next().unwrap();
|
||||
|
||||
// Full mesh: every agent must end up with the other three.
|
||||
for agent in &agents {
|
||||
wait_for_peers(&agent.agent, network_id, 3).await;
|
||||
}
|
||||
|
||||
// Each peer announced its own hostname, so sessions are distinguishable.
|
||||
// A peer counts as connected as soon as its session is authenticated, which
|
||||
// can be a round before its announcement carrying the hostname arrives, so
|
||||
// this waits for the hostnames rather than reading them straight away.
|
||||
let hostnames: HashSet<String> = wait_until("three distinct peer hostnames", || async {
|
||||
let status = agents[0].agent.network_status(network_id).await.ok()?;
|
||||
let hostnames: HashSet<String> = status
|
||||
.peers
|
||||
.iter()
|
||||
.filter_map(|peer| peer.hostname.clone())
|
||||
.collect();
|
||||
(hostnames.len() >= 3).then_some(hostnames)
|
||||
})
|
||||
.await;
|
||||
assert_eq!(
|
||||
hostnames.len(),
|
||||
3,
|
||||
"three distinct hostnames: {hostnames:?}"
|
||||
);
|
||||
|
||||
// Distinguishable request/response: each peer echoes its own sequence.
|
||||
let mut events = agents[0].agent.subscribe();
|
||||
for (index, peer) in agents.iter().skip(1).enumerate() {
|
||||
agents[0]
|
||||
.agent
|
||||
.send(
|
||||
network_id,
|
||||
peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: index as u64 + 1,
|
||||
payload: format!("to-{index}").into_bytes(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let mut seen = HashSet::new();
|
||||
while seen.len() < 3 {
|
||||
let (peer, seq, payload) = wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq, payload },
|
||||
} if *network == network_id => Some((*peer, *seq, payload.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(payload, format!("to-{}", seq - 1).into_bytes());
|
||||
seen.insert(peer);
|
||||
}
|
||||
assert_eq!(seen.len(), 3);
|
||||
|
||||
for agent in agents {
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_late_joiner_is_picked_up_by_the_existing_members() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("late-joiner");
|
||||
|
||||
let first = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = first.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Nobody else is there yet. That is "nobody found so far", not proof that
|
||||
// the network is empty, and the agent is ready regardless.
|
||||
let status = first.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
|
||||
let second = TestAgent::spawn(&discovery).await.unwrap();
|
||||
second.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&first.agent, network_id, 1).await;
|
||||
wait_for_peers(&second.agent, network_id, 1).await;
|
||||
|
||||
first.agent.shutdown().await;
|
||||
second.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn opaque_plugin_capabilities_cross_the_control_plane() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("capabilities");
|
||||
|
||||
// An explicitly test-only capability: nothing here advertises WireGuard.
|
||||
let plugin_a = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-a".to_vec()));
|
||||
let plugin_b = Arc::new(TestCapabilityPlugin::new("test-ip", b"payload-b".to_vec()));
|
||||
|
||||
let a = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let b = TestAgent::spawn_with(
|
||||
{
|
||||
let plugin = Arc::clone(&plugin_b);
|
||||
move |cfg| cfg.with_plugin(plugin)
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, network_id, 1).await;
|
||||
|
||||
let observed = wait_until("plugin a sees b's capability", || {
|
||||
let plugin = Arc::clone(&plugin_a);
|
||||
async move {
|
||||
let seen = plugin.observed();
|
||||
if seen.is_empty() { None } else { Some(seen) }
|
||||
}
|
||||
})
|
||||
.await;
|
||||
|
||||
let (seen_network, seen_peer, capability) = &observed[0];
|
||||
assert_eq!(*seen_network, network_id);
|
||||
assert_eq!(*seen_peer, b.agent.endpoint_id());
|
||||
assert_eq!(capability.protocol, "test-ip");
|
||||
assert_eq!(capability.data, b"payload-b".to_vec());
|
||||
assert!(capability.enabled);
|
||||
|
||||
// The core carried the payload without interpreting it.
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
assert_eq!(status.peers[0].capabilities[0].data, b"payload-b".to_vec());
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Scenario 4: one agent in two networks at once, with no bleed between them.
|
||||
|
||||
#![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;
|
||||
use tsunagi::discovery::SharedMemoryDiscovery;
|
||||
use tsunagi::identity::NetworkKeys;
|
||||
use tsunagi::proto::handshake::ROLE_INITIATOR;
|
||||
use tsunagi::proto::message::{
|
||||
ALPN, AuthProof, ControlMessage, Envelope, Hello, HelloAck, PROTOCOL_VERSION, decode, encode,
|
||||
};
|
||||
use tsunagi::proto::{read_frame, write_frame};
|
||||
use tsunagi::test_support;
|
||||
|
||||
const LIMIT: usize = 64 * 1024;
|
||||
|
||||
#[tokio::test]
|
||||
async fn one_agent_in_two_networks_keeps_them_apart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("alpha-net");
|
||||
let (name_b, secret_b) = network("beta-net");
|
||||
|
||||
let hub = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let alpha_peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let beta_peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
assert_ne!(alpha, beta);
|
||||
|
||||
alpha_peer
|
||||
.agent
|
||||
.join_network(&name_a, &secret_a)
|
||||
.await
|
||||
.unwrap();
|
||||
beta_peer
|
||||
.agent
|
||||
.join_network(&name_b, &secret_b)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
wait_for_peers(&hub.agent, alpha, 1).await;
|
||||
wait_for_peers(&hub.agent, beta, 1).await;
|
||||
|
||||
// Statuses do not mix: each network sees exactly its own peer.
|
||||
let status_alpha = hub.agent.network_status(alpha).await.unwrap();
|
||||
let status_beta = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(
|
||||
status_alpha.connected_peers(),
|
||||
vec![alpha_peer.agent.endpoint_id()]
|
||||
);
|
||||
assert_eq!(
|
||||
status_beta.connected_peers(),
|
||||
vec![beta_peer.agent.endpoint_id()]
|
||||
);
|
||||
|
||||
// Addressing a peer of one network through the other is refused locally.
|
||||
let wrong = hub
|
||||
.agent
|
||||
.send(
|
||||
alpha,
|
||||
beta_peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new(),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
assert!(matches!(wrong, Err(tsunagi::Error::NoSuchPeer { .. })));
|
||||
|
||||
// Messages stay in their own network.
|
||||
let mut events = hub.agent.subscribe();
|
||||
hub.agent
|
||||
.broadcast(
|
||||
alpha,
|
||||
ControlMessage::Ping {
|
||||
seq: 7,
|
||||
payload: b"alpha-only".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let from = wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 7, payload },
|
||||
} if payload == b"alpha-only" => Some((*network, *peer)),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert_eq!(from, (alpha, alpha_peer.agent.endpoint_id()));
|
||||
|
||||
let beta_status = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(
|
||||
beta_status.metrics.control_messages_received,
|
||||
beta_status.peers[0].control_messages_received,
|
||||
"beta's counters are its own"
|
||||
);
|
||||
assert!(
|
||||
beta_status
|
||||
.peers
|
||||
.iter()
|
||||
.all(|peer| peer.endpoint_id != alpha_peer.agent.endpoint_id())
|
||||
);
|
||||
|
||||
// Deactivating one network must not disturb the other.
|
||||
hub.agent.deactivate_network(alpha).await.unwrap();
|
||||
assert!(hub.agent.network_status(alpha).await.is_err());
|
||||
wait_for_peers(&hub.agent, beta, 1).await;
|
||||
hub.agent
|
||||
.send(
|
||||
beta,
|
||||
beta_peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 8,
|
||||
payload: b"still-here".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
network,
|
||||
message: ControlMessage::Pong { seq: 8, .. },
|
||||
..
|
||||
} if *network == beta => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
hub.agent.shutdown().await;
|
||||
alpha_peer.agent.shutdown().await;
|
||||
beta_peer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_authenticated_session_cannot_speak_for_another_network() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("session-scope-a");
|
||||
let (name_b, secret_b) = network("session-scope-b");
|
||||
|
||||
let hub = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = hub.agent.subscribe();
|
||||
let alpha = hub.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = hub.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
|
||||
// A genuine member of `alpha`, driven by hand so it can misbehave.
|
||||
let keys = NetworkKeys::derive(&name_a, &secret_a);
|
||||
let auth_key = test_support::auth_key(&keys);
|
||||
let member = Endpoint::builder(presets::Minimal)
|
||||
.alpns(vec![ALPN.to_vec()])
|
||||
.relay_mode(RelayMode::Disabled)
|
||||
.clear_address_lookup()
|
||||
.portmapper_config(PortmapperConfig::Disabled)
|
||||
.clear_ip_transports()
|
||||
.bind_addr("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.bind()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let conn = member.connect(hub.agent.local_addr(), ALPN).await.unwrap();
|
||||
let (mut send, mut recv) = conn.open_bi().await.unwrap();
|
||||
|
||||
let alpha_bytes = *alpha.as_bytes();
|
||||
let nonce_i = [5u8; 16];
|
||||
let hello = Hello {
|
||||
version: PROTOCOL_VERSION,
|
||||
network_id: alpha_bytes,
|
||||
nonce: nonce_i,
|
||||
};
|
||||
write_frame(&mut send, &encode(&hello).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
let ack: HelloAck = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
let cb = test_support::channel_binding(&conn, &alpha_bytes).unwrap();
|
||||
let proof = test_support::compute_proof(
|
||||
&auth_key,
|
||||
ROLE_INITIATOR,
|
||||
PROTOCOL_VERSION,
|
||||
&alpha_bytes,
|
||||
member.id().as_bytes(),
|
||||
hub.agent.endpoint_id().as_bytes(),
|
||||
&cb,
|
||||
&nonce_i,
|
||||
&ack.nonce,
|
||||
);
|
||||
write_frame(&mut send, &encode(&AuthProof { proof }).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
let _responder_proof: AuthProof = decode(&read_frame(&mut recv, LIMIT).await.unwrap()).unwrap();
|
||||
|
||||
// Authenticated for alpha. Now try to speak for beta on the same session.
|
||||
let smuggled = Envelope {
|
||||
network_id: *beta.as_bytes(),
|
||||
message: ControlMessage::Ping {
|
||||
seq: 99,
|
||||
payload: b"wrong network".to_vec(),
|
||||
},
|
||||
};
|
||||
write_frame(&mut send, &encode(&smuggled).unwrap(), LIMIT)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let reason = wait_event(&mut events, |event| match event {
|
||||
Event::ProtocolViolation {
|
||||
network, reason, ..
|
||||
} if *network == Some(alpha) => Some(reason.clone()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
reason.contains("network id does not match"),
|
||||
"unexpected reason: {reason}"
|
||||
);
|
||||
|
||||
// Beta saw nothing at all, and both networks keep running.
|
||||
let beta_status = hub.agent.network_status(beta).await.unwrap();
|
||||
assert_eq!(beta_status.metrics.control_messages_received, 0);
|
||||
assert!(beta_status.peers.is_empty());
|
||||
assert!(hub.agent.network_status(alpha).await.is_ok());
|
||||
|
||||
conn.close(0u32.into(), b"done");
|
||||
member.close().await;
|
||||
hub.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn deactivating_one_network_leaves_the_agent_and_others_running() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name_a, secret_a) = network("keep-a");
|
||||
let (name_b, secret_b) = network("keep-b");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let alpha = agent.agent.join_network(&name_a, &secret_a).await.unwrap();
|
||||
let beta = agent.agent.join_network(&name_b, &secret_b).await.unwrap();
|
||||
|
||||
agent.agent.deactivate_network(alpha).await.unwrap();
|
||||
settle().await;
|
||||
|
||||
let status = agent.agent.status().await.unwrap();
|
||||
assert_eq!(status.networks.len(), 2, "both stay configured");
|
||||
assert_eq!(
|
||||
status.network(&alpha).map(|net| net.state),
|
||||
Some(tsunagi::agent::NetworkState::Inactive)
|
||||
);
|
||||
assert_eq!(
|
||||
status.network(&beta).map(|net| net.state),
|
||||
Some(tsunagi::agent::NetworkState::Active)
|
||||
);
|
||||
|
||||
// Deactivating twice is an error, not a crash.
|
||||
assert!(agent.agent.deactivate_network(alpha).await.is_err());
|
||||
// And it can be brought back.
|
||||
agent.agent.activate_network(alpha).await.unwrap();
|
||||
assert!(agent.agent.network_status(alpha).await.is_ok());
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
//! Scenarios 8 and 10: unreachable participants, bounded retries, and the
|
||||
//! agent lifecycle including state directory ownership.
|
||||
|
||||
#![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::{Agent, Error};
|
||||
|
||||
/// An endpoint id nobody is listening for, at an address nothing answers on.
|
||||
fn dead_candidate() -> EndpointAddr {
|
||||
let unreachable: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
||||
EndpointAddr::new(SecretKey::generate().public()).with_ip_addr(unreachable)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dead_candidate_does_not_hold_up_the_reachable_ones() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("dead-candidate");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
|
||||
// Poison the rendezvous table before anybody real shows up.
|
||||
let dead = dead_candidate();
|
||||
discovery.insert_raw(keys.discovery_key(), dead.clone());
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let mut events = a.agent.subscribe();
|
||||
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// The reachable peer still connects.
|
||||
let peers = wait_for_peers(&a.agent, network_id, 1).await;
|
||||
assert_eq!(peers, vec![b.agent.endpoint_id()]);
|
||||
|
||||
// And the dead candidate is reported as a failure, not silently forgotten.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { peer, .. } if *peer == dead.id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
let status = a.agent.network_status(network_id).await.unwrap();
|
||||
assert!(
|
||||
status
|
||||
.candidates
|
||||
.iter()
|
||||
.any(|candidate| candidate.endpoint_id == dead.id
|
||||
&& candidate.consecutive_failures > 0),
|
||||
"a failing candidate must stay visible as an unverified candidate"
|
||||
);
|
||||
assert!(
|
||||
status.peers.iter().all(|peer| peer.endpoint_id != dead.id),
|
||||
"a candidate must never be reported as a peer"
|
||||
);
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_vanished_peer_is_retried_with_backoff_and_others_keep_working() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("vanishing-peer");
|
||||
|
||||
let watcher = TestAgent::spawn_with(
|
||||
|cfg| {
|
||||
cfg.with_reconnect(ReconnectPolicy {
|
||||
initial_delay: Duration::from_millis(50),
|
||||
max_delay: Duration::from_millis(200),
|
||||
factor: 1.5,
|
||||
jitter: 0.2,
|
||||
max_consecutive_failures: None,
|
||||
})
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let stayer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let leaver = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let network_id = watcher.agent.join_network(&name, &secret).await.unwrap();
|
||||
stayer.agent.join_network(&name, &secret).await.unwrap();
|
||||
leaver.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&watcher.agent, network_id, 2).await;
|
||||
|
||||
let leaver_id = leaver.agent.endpoint_id();
|
||||
let mut events = watcher.agent.subscribe();
|
||||
leaver.agent.shutdown().await;
|
||||
drop(leaver);
|
||||
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::PeerDisconnected { peer, .. } if *peer == leaver_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The other peer is untouched and still answers.
|
||||
watcher
|
||||
.agent
|
||||
.send(
|
||||
network_id,
|
||||
stayer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 3,
|
||||
payload: b"still here".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
peer,
|
||||
message: ControlMessage::Pong { seq: 3, .. },
|
||||
..
|
||||
} if *peer == stayer.agent.endpoint_id() => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// The watcher does retry the peer that went away.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { peer, .. } if *peer == leaver_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Retries are bounded by the backoff rather than spinning.
|
||||
settle().await;
|
||||
let status = watcher.agent.network_status(network_id).await.unwrap();
|
||||
let failures = status
|
||||
.candidates
|
||||
.iter()
|
||||
.find(|candidate| candidate.endpoint_id == leaver_id)
|
||||
.map(|candidate| candidate.consecutive_failures)
|
||||
.unwrap_or(0);
|
||||
assert!(
|
||||
(1..=40).contains(&failures),
|
||||
"expected bounded backed-off retries, got {failures}"
|
||||
);
|
||||
assert_eq!(
|
||||
status.connected_peers(),
|
||||
vec![stayer.agent.endpoint_id()],
|
||||
"the surviving peer keeps its session"
|
||||
);
|
||||
|
||||
watcher.agent.shutdown().await;
|
||||
stayer.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_stop_when_the_network_is_deactivated() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("stop-retrying");
|
||||
let keys = NetworkKeys::derive(&name, &secret);
|
||||
discovery.insert_raw(keys.discovery_key(), dead_candidate());
|
||||
|
||||
let agent = TestAgent::spawn_with(
|
||||
|cfg| {
|
||||
cfg.with_discovery_interval(Duration::from_millis(80))
|
||||
.with_reconnect(ReconnectPolicy {
|
||||
initial_delay: Duration::from_millis(20),
|
||||
max_delay: Duration::from_millis(60),
|
||||
factor: 1.2,
|
||||
jitter: 0.1,
|
||||
max_consecutive_failures: None,
|
||||
})
|
||||
},
|
||||
&discovery,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let mut events = agent.agent.subscribe();
|
||||
let network_id = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
// Retries are definitely happening.
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::DialFailed { .. } => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
agent.agent.deactivate_network(network_id).await.unwrap();
|
||||
|
||||
// Drain whatever was already queued, then require silence.
|
||||
while events.try_recv().is_ok() {}
|
||||
settle().await;
|
||||
let mut stragglers = 0;
|
||||
while let Ok(event) = events.try_recv() {
|
||||
if matches!(event, Event::DialFailed { .. }) {
|
||||
stragglers += 1;
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
stragglers, 0,
|
||||
"a deactivated network must stop dialling entirely"
|
||||
);
|
||||
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_second_agent_on_the_same_state_directory_is_refused() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
let first = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let second = Agent::spawn(config_with(dir.path(), &discovery)).await;
|
||||
match second {
|
||||
Err(Error::StateLocked { path }) => {
|
||||
assert!(path.starts_with(dir.path()));
|
||||
}
|
||||
Err(other) => panic!("expected StateLocked, got {other:?}"),
|
||||
Ok(agent) => {
|
||||
agent.shutdown().await;
|
||||
panic!("two live agents must not share one state directory");
|
||||
}
|
||||
}
|
||||
|
||||
// After a clean stop the directory is immediately claimable again.
|
||||
let device_id = first.endpoint_id();
|
||||
first.shutdown().await;
|
||||
drop(first);
|
||||
|
||||
let third = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(third.endpoint_id(), device_id);
|
||||
third.shutdown().await;
|
||||
drop(third);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn shutdown_releases_resources_and_rejects_further_work() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("clean-stop");
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = a.agent.join_network(&name, &secret).await.unwrap();
|
||||
b.agent.join_network(&name, &secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, network_id, 1).await;
|
||||
|
||||
a.agent.shutdown().await;
|
||||
|
||||
// The endpoint is closed and the networks are gone.
|
||||
assert!(a.agent.endpoint().is_closed());
|
||||
assert!(matches!(
|
||||
a.agent.network_status(network_id).await,
|
||||
Err(Error::NetworkNotActive(_))
|
||||
));
|
||||
assert!(
|
||||
a.agent
|
||||
.send(
|
||||
network_id,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
// Shutting down twice is harmless.
|
||||
a.agent.shutdown().await;
|
||||
|
||||
// The peer notices and carries on.
|
||||
let status = b.agent.network_status(network_id).await.unwrap();
|
||||
assert_eq!(status.state, tsunagi::agent::NetworkState::Active);
|
||||
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn several_independent_agents_coexist_in_one_process() {
|
||||
// No global state: two completely separate rendezvous tables, two networks
|
||||
// with the same name but different secrets, four agents, one process.
|
||||
let left = SharedMemoryDiscovery::new();
|
||||
let right = SharedMemoryDiscovery::new();
|
||||
let (name, left_secret) = network("same-name-different-world");
|
||||
let (_, right_secret) = network("ignored");
|
||||
|
||||
let l1 = TestAgent::spawn(&left).await.unwrap();
|
||||
let l2 = TestAgent::spawn(&left).await.unwrap();
|
||||
let r1 = TestAgent::spawn(&right).await.unwrap();
|
||||
let r2 = TestAgent::spawn(&right).await.unwrap();
|
||||
|
||||
let left_id = l1.agent.join_network(&name, &left_secret).await.unwrap();
|
||||
l2.agent.join_network(&name, &left_secret).await.unwrap();
|
||||
let right_id = r1.agent.join_network(&name, &right_secret).await.unwrap();
|
||||
r2.agent.join_network(&name, &right_secret).await.unwrap();
|
||||
assert_ne!(left_id, right_id);
|
||||
|
||||
wait_for_peers(&l1.agent, left_id, 1).await;
|
||||
wait_for_peers(&r1.agent, right_id, 1).await;
|
||||
assert_eq!(
|
||||
l1.agent
|
||||
.network_status(left_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.connected_peers(),
|
||||
vec![l2.agent.endpoint_id()]
|
||||
);
|
||||
|
||||
for agent in [l1, l2, r1, r2] {
|
||||
agent.agent.shutdown().await;
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_without_discovery_still_starts_and_serves_status() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
let agent = Agent::spawn(local_config(dir.path())).await.unwrap();
|
||||
let (name, secret) = network("no-discovery");
|
||||
let network_id = agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert!(status.candidates.is_empty());
|
||||
agent.recheck().await;
|
||||
assert!(agent.recheck_network(network_id).await.is_ok());
|
||||
|
||||
agent.shutdown().await;
|
||||
drop(agent);
|
||||
drop(dir);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
//! Scenarios 5 and 6: restart recovery and rotating the network secret.
|
||||
|
||||
#![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::{Agent, Error};
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_restarted_agent_keeps_its_identity_and_reconnects() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("survives-restart");
|
||||
|
||||
let peer = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let restarting = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
let network_id = peer.agent.join_network(&name, &secret).await.unwrap();
|
||||
restarting.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
let device_id = restarting.agent.endpoint_id();
|
||||
let old_sockets = restarting.agent.status().await.unwrap().bound_sockets;
|
||||
|
||||
let mut peer_events = peer.agent.subscribe();
|
||||
let dir = restarting.stop().await;
|
||||
|
||||
// The surviving peer notices the session ending.
|
||||
wait_event(&mut peer_events, |event| match event {
|
||||
Event::PeerDisconnected { peer, .. } if *peer == device_id => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
// Restart from the same state directory. Binding to port zero again means a
|
||||
// different local UDP port, which the refreshed discovery entry covers.
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(restarted.endpoint_id(), device_id);
|
||||
let new_sockets = restarted.status().await.unwrap().bound_sockets;
|
||||
assert_ne!(old_sockets, new_sockets, "a fresh local port is expected");
|
||||
|
||||
// The configured network came back up on its own and both sides reconnect.
|
||||
assert!(restarted.is_active(network_id).await);
|
||||
wait_for_peers(&restarted, network_id, 1).await;
|
||||
wait_for_peers(&peer.agent, network_id, 1).await;
|
||||
|
||||
// And the restored session really works.
|
||||
restarted
|
||||
.send(
|
||||
network_id,
|
||||
peer.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 5,
|
||||
payload: b"back".to_vec(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut events = restarted.subscribe();
|
||||
wait_event(&mut events, |event| match event {
|
||||
Event::MessageReceived {
|
||||
message: ControlMessage::Pong { seq: 5, payload },
|
||||
..
|
||||
} if payload == b"back" => Some(()),
|
||||
_ => None,
|
||||
})
|
||||
.await;
|
||||
|
||||
restarted.shutdown().await;
|
||||
peer.agent.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn joining_a_network_twice_is_not_an_error() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("idempotent-join");
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
|
||||
// Joining is declarative: saying it twice in one run must be fine.
|
||||
let first = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
let again = agent.agent.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(first, again);
|
||||
assert_eq!(agent.agent.list_networks().await.unwrap().len(), 1);
|
||||
|
||||
// Activating explicitly is the strict version and does report it.
|
||||
assert!(matches!(
|
||||
agent.agent.activate_network(first).await,
|
||||
Err(Error::NetworkAlreadyActive(_))
|
||||
));
|
||||
|
||||
// And after a restart, where the network came back up on its own, the
|
||||
// same command must still succeed. This is what running the CLI twice
|
||||
// does.
|
||||
let dir = agent.stop().await;
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(restarted.is_active(first).await, "auto-start brought it up");
|
||||
let rejoined = restarted.join_network(&name, &secret).await.unwrap();
|
||||
assert_eq!(rejoined, first);
|
||||
assert!(restarted.network_status(first).await.is_ok());
|
||||
|
||||
restarted.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn readiness_does_not_wait_for_anyone_else() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let (name, secret) = network("lonely");
|
||||
|
||||
// No peers exist and no relay is reachable. The agent must still come up.
|
||||
let alone = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let network_id = alone.agent.join_network(&name, &secret).await.unwrap();
|
||||
|
||||
let status = alone.agent.network_status(network_id).await.unwrap();
|
||||
assert!(status.peers.is_empty());
|
||||
assert_eq!(status.state, tsunagi::agent::NetworkState::Active);
|
||||
|
||||
alone.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rotating_the_secret_moves_everyone_to_a_new_space() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("rotate-me").unwrap();
|
||||
let old_secret = NetworkSecret::generate();
|
||||
let new_secret = NetworkSecret::generate();
|
||||
|
||||
let a = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let b = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let a_id = a.agent.endpoint_id();
|
||||
|
||||
let old = a.agent.join_network(&name, &old_secret).await.unwrap();
|
||||
b.agent.join_network(&name, &old_secret).await.unwrap();
|
||||
wait_for_peers(&a.agent, old, 1).await;
|
||||
|
||||
// Rotation through the public API: deactivate the old space, join the new.
|
||||
// No dedicated command is needed for this.
|
||||
a.agent.deactivate_network(old).await.unwrap();
|
||||
let new = a.agent.join_network(&name, &new_secret).await.unwrap();
|
||||
assert_ne!(old, new);
|
||||
assert_eq!(a.agent.endpoint_id(), a_id, "device identity is untouched");
|
||||
|
||||
// B still holds the old secret, so it must not reach the new space.
|
||||
settle().await;
|
||||
let status = a.agent.network_status(new).await.unwrap();
|
||||
assert!(
|
||||
status.peers.is_empty(),
|
||||
"the old secret must not open the new space"
|
||||
);
|
||||
assert!(
|
||||
a.agent
|
||||
.send(
|
||||
old,
|
||||
b.agent.endpoint_id(),
|
||||
ControlMessage::Ping {
|
||||
seq: 1,
|
||||
payload: Vec::new()
|
||||
}
|
||||
)
|
||||
.await
|
||||
.is_err(),
|
||||
"the deactivated network cannot be used any more"
|
||||
);
|
||||
|
||||
// Once B rotates too, they meet again in the new space.
|
||||
b.agent.deactivate_network(old).await.unwrap();
|
||||
let b_new = b.agent.join_network(&name, &new_secret).await.unwrap();
|
||||
assert_eq!(b_new, new);
|
||||
wait_for_peers(&a.agent, new, 1).await;
|
||||
|
||||
a.agent.shutdown().await;
|
||||
b.agent.shutdown().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_rotated_out_network_does_not_come_back_after_a_restart() {
|
||||
let discovery = SharedMemoryDiscovery::new();
|
||||
let name = NetworkName::new("no-resurrection").unwrap();
|
||||
|
||||
let agent = TestAgent::spawn(&discovery).await.unwrap();
|
||||
let old = agent
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
let new = agent
|
||||
.agent
|
||||
.join_network(&name, &NetworkSecret::generate())
|
||||
.await
|
||||
.unwrap();
|
||||
agent.agent.deactivate_network(old).await.unwrap();
|
||||
let dir = agent.stop().await;
|
||||
|
||||
let restarted = Agent::spawn(config_with(dir.path(), &discovery))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!restarted.is_active(old).await);
|
||||
assert!(restarted.is_active(new).await);
|
||||
assert!(matches!(
|
||||
restarted.network_status(old).await,
|
||||
Err(Error::NetworkNotActive(_))
|
||||
));
|
||||
|
||||
restarted.shutdown().await;
|
||||
drop(restarted);
|
||||
drop(dir);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user