Add ephemeral pairwise Amnezia transport and runtime fallback

Plain WG and QUIC can be blocked or severely degraded while the iroh control
connection still works. Add an awg carrier with one independent UDP socket,
WireGuard engine and randomized masking profile per network/peer attempt.
The smaller endpoint id negotiates fresh outer keys, PSK, header protection,
header ranges, padding and signature templates through private authenticated
control messages. Restart or fallback reentry generates a new profile;
ordinary rekeying preserves the current profile. No pair parameters enter
signed state, capabilities, persistent storage or public message events.
Private payloads and serialized control buffers redact and zeroize themselves.

Adapt the bounded packet masking engine from RunaRoo/AmneziaWG-RS at commit
51579a7d6e8882d1d8d6d00822f09e18a9423b73, retaining its Apache license and
provenance. Declare WTFPL AND Apache-2.0 in package metadata to cover both
new glue and the adapted source. Reuse boringtun instead of upstream's Node/global executors and
interface machinery. This is Tsunagi AWG wire version 1, with standard WG
headers restored before authentication; external Amnezia server compatibility
is not implemented. DNS/SIP signature generators vary fields instead of
replaying captures. Each attempt closes independently, bounds allocation,
queues, candidates and fragment assemblies, and supports the full 64 KiB
carrier contract using UDP packets at most 1200 bytes. Yield between fragment
batches so large messages do not starve local socket readers.

Keep carrier encryption separate from end-to-end tunnels so multihop relays
still forward opaque ciphertext. The system owns the sole interface, signed
addresses and admission. Plugins receive neither network secrets nor paths.
Expose a bounded opaque control sender at the transport boundary; core control
messages remain outside protocol crates. Optional STUN on the pair's actual
socket adds IPv4 public candidates in CLI direct/relay mode, with a finite
query deadline and an off/alternate-server option. Local mode remains offline.
Symmetric NAT, IPv6-only paths and arbitrary UDP blocking can still require
fallback; no AWG-over-QUIC or hidden AWG relay is introduced.

Default carrier order is wg, awg, wg-quic, tcp-tls. Advance the fallback cycle
after setup failures and sustained runtime loss, even after earlier cooldowns
expire. Authenticated delivery probes traverse the actual encrypted tunnel,
fit the interface MTU and never enter the TUN. Retire after a 20-second warmup
and at least 60% loss over at least 15 completed recent observations. Preserve
logical tunnels and control sessions while explicitly closing physical links,
including TCP reader/writer tasks. Retain the newest private probe settings
across independently queued control negotiation and inbound installation.
CLI/tray reports keep end-to-end protocol and actual carrier separate.

Control ALPN/version becomes tsunagi/ctrl/6 for the new private message;
upgrade all members together. Data ALPN/envelopes remain version 5. Network
identity derivation, transcript encoding, device identities, signed records,
saved network configuration and release versions are unchanged.

Validation on Linux through nix develop:
- cargo fmt --all -- --check
- cargo clippy --locked --workspace --all-targets -- -D warnings
- cargo test --locked --workspace --all-targets, with the inherited
  TSUNAGI_CONTROL_SOCKET override removed: 504 passed, 2 pre-existing ignored

Tests use real iroh membership, SQLite, UDP sockets and WireGuard encryption
with memory TUNs. Cover three independent pairs, full-size IP and carrier
frames, restart freshness/socket release, 80% carrier datagram loss, fallback
to real iroh, fresh retries with working delivery checks, unequal small MTUs,
malformed negotiations/profiles, role and resource bounds, probe forgery and
STUN parsing. Earlier compile/lint iterations and focused tests caught RNG
API/initializer mistakes, a retry assertion racing path installation, and a
large-fragment burst overflowing loopback buffers; corrected before these
successful checks. Normalized upstream license line endings for diff checks.
Public NAT/STUN connectivity, actual DPI effectiveness, WAN performance,
per-pair memory measurement, macOS/Windows, privileged TUN operations and
manual tray interaction were not exercised.
This commit is contained in:
Aleksandr Bogomiakov committed 2026-10-11 13:52:10 +01:00
1 parent ed28e86748
commit e701986a39
35 files changed
+3977 -41

No files matched your search

+45
View File
@@ -169,6 +169,21 @@ impl PacketSink for Sink {
let Some(inner) = self.0.upgrade() else {
return;
};
if let Some(payload) = crate::dataplane::transport::health::ip_payload(&packet) {
if let Some(header) = crate::overlay::IpHeader::parse(&packet)
&& let (std::net::IpAddr::V4(source), std::net::IpAddr::V4(destination)) =
(header.source(), header.destination())
&& inner.routes.may_send_from(network, peer, source)
&& inner.routes.is_local_destination(network, destination)
&& let Some(handle) = inner.networks.read().await.get(&network)
{
let _ = handle.commands.try_send(NetCommand::HealthProbe {
peer,
packet: bytes::Bytes::copy_from_slice(payload),
});
}
return;
}
let Some(interface) = inner.interface.get() else {
return;
};
@@ -187,6 +202,35 @@ impl PacketSink for Sink {
#[derive(Debug)]
struct TransportCtx(Weak<Inner>);
impl crate::dataplane::transport::ControlSender for TransportCtx {
fn send<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
payload: crate::proto::message::TransportPayload,
) -> crate::BoxFuture<'a, std::result::Result<(), crate::dataplane::transport::TransportError>>
{
Box::pin(async move {
let inner = self
.0
.upgrade()
.ok_or(crate::dataplane::transport::TransportError::Closed)?;
Agent { inner }
.send(
network,
peer,
crate::proto::message::ControlMessage::Transport {
protocol: protocol.into(),
payload,
},
)
.await
.map_err(|err| crate::dataplane::transport::TransportError::Other(err.to_string()))
})
}
}
impl TransportContext for TransportCtx {
fn snapshot(&self) -> crate::BoxFuture<'_, HashMap<NetworkId, NetworkKeys>> {
Box::pin(async move {
@@ -302,6 +346,7 @@ impl Agent {
inner.config.custom_transports.clone(),
))
};
transport.attach_control(Arc::new(TransportCtx(Arc::downgrade(&inner))));
let _ = inner.transport.set(transport);
if let CacheOutcome::Reset(reason) = inner.storage.cache_outcome().clone() {
+170 -15
View File
@@ -15,6 +15,7 @@ use tokio::sync::{broadcast, mpsc, oneshot};
use tokio::task::JoinHandle;
use crate::config::{DiscoveryPolicy, Limits, ReconnectPolicy};
use crate::dataplane::transport::health::{HealthLink, ProbeRoute, Settings as HealthSettings};
use crate::dataplane::transport::{InboundLink, PacketLink, PacketTransport, SharedLink};
use crate::dataplane::{PluginCapability, SharedPlugin};
use crate::discovery::worker::DiscoveryWorker;
@@ -59,6 +60,10 @@ pub(crate) enum NetCommand {
},
/// A peer opened a data plane link towards us.
InboundLink(Box<InboundLink>),
HealthProbe {
peer: EndpointId,
packet: bytes::Bytes,
},
Status {
reply: oneshot::Sender<Box<NetworkStatus>>,
},
@@ -123,6 +128,7 @@ impl std::fmt::Debug for NetCommand {
NetCommand::SetHostname(_) => f.write_str("SetHostname"),
NetCommand::Release { .. } => f.write_str("Release"),
NetCommand::PluginError { protocol, .. } => write!(f, "PluginError({protocol})"),
NetCommand::HealthProbe { peer, .. } => write!(f, "HealthProbe({})", peer.fmt_short()),
NetCommand::ProtocolFailure { peer, protocol, .. } => {
write!(f, "ProtocolFailure({}, {protocol})", peer.fmt_short())
}
@@ -261,6 +267,12 @@ struct ProtocolDialState {
const DATA_LINK_BASE_BACKOFF: Duration = Duration::from_secs(3);
const DATA_LINK_MAX_BACKOFF: Duration = Duration::from_secs(60);
fn next_protocol_index(supported: &[String], last_failed: Option<&str>) -> usize {
last_failed
.and_then(|failed| supported.iter().position(|p| p == failed))
.map_or(0, |i| (i + 1) % supported.len().max(1))
}
fn data_link_backoff(failures: u32) -> Duration {
if failures == 0 {
return Duration::ZERO;
@@ -292,7 +304,9 @@ struct Runtime {
///
/// The direct ones. What a protocol holds is the hub's link for that
/// peer, which outlives any particular path.
links: HashMap<(EndpointId, String), SharedLink>,
links: HashMap<(EndpointId, String), Arc<HealthLink>>,
pending_health: HashMap<(EndpointId, String), HealthSettings>,
last_failed_protocol: HashMap<EndpointId, String>,
/// Every data link of this network, and what is reached through what.
hub: Arc<crate::dataplane::relay::RelayHub>,
/// Which peers each peer says it has a live link with, and when it
@@ -404,6 +418,8 @@ impl Runtime {
dial_results_tx,
dial_results_rx,
links: HashMap::new(),
pending_health: HashMap::new(),
last_failed_protocol: HashMap::new(),
hub,
reachable: HashMap::new(),
local_capabilities: Vec::new(),
@@ -491,7 +507,12 @@ impl Runtime {
// any more, and a sender blocked on a full queue would stall shutdown.
self.session_events_rx.close();
self.hub.close();
for link in self.links.values() {
link.close();
}
self.links.clear();
self.pending_health.clear();
self.last_failed_protocol.clear();
self.protocol_dial_states.clear();
let peers: Vec<EndpointId> = self.sessions.keys().copied().collect();
for peer in peers {
@@ -595,12 +616,20 @@ impl Runtime {
reason,
});
}
NetCommand::HealthProbe { peer, packet } => {
for ((p, _), link) in &self.links {
if *p == peer {
link.receive_probe(packet.clone());
}
}
}
NetCommand::ProtocolFailure {
peer,
protocol,
reason,
} => {
if self.links.remove(&(peer, protocol.clone())).is_some() {
if let Some(link) = self.links.remove(&(peer, protocol.clone())) {
link.close();
self.hub.clear_direct(peer, &protocol);
}
self.metrics.data_link_failures += 1;
@@ -655,7 +684,7 @@ impl Runtime {
peer: peer.fmt_short().to_string(),
})?;
match session.outbound.try_send(encoded) {
match session.outbound.try_send(zeroize::Zeroizing::new(encoded)) {
Ok(()) => {
session.messages_sent += 1;
session.bytes_sent += bytes;
@@ -1423,6 +1452,7 @@ impl Runtime {
}
fn record_protocol_failure(&mut self, peer: EndpointId, protocol: &str) {
self.last_failed_protocol.insert(peer, protocol.to_string());
let key = (peer, protocol.to_string());
let entry = self
.protocol_dial_states
@@ -1461,7 +1491,17 @@ impl Runtime {
}
}
for (peer, protocol) in dead {
self.links.remove(&(peer, protocol.clone()));
let reason = self
.links
.remove(&(peer, protocol.clone()))
.map(|link| {
let reason = link
.failure_reason()
.unwrap_or_else(|| "link closed".into());
link.close();
reason
})
.unwrap_or_else(|| "link closed".into());
// Only the direct path is gone. Whether the peer is still
// reachable through somebody is decided below; the protocol's
// link stays either way.
@@ -1471,7 +1511,7 @@ impl Runtime {
network: self.network_id,
peer,
protocol,
reason: "link closed".into(),
reason,
});
}
@@ -1512,12 +1552,21 @@ impl Runtime {
continue;
}
let chosen = supported.iter().find(|proto| {
match self.protocol_dial_states.get(&(peer, (*proto).clone())) {
Some(state) => state.cooldown_until <= now,
None => true,
}
});
let start = next_protocol_index(
&supported,
self.last_failed_protocol.get(&peer).map(String::as_str),
);
let chosen = supported
.iter()
.cycle()
.skip(start)
.take(supported.len())
.find(
|proto| match self.protocol_dial_states.get(&(peer, (*proto).clone())) {
Some(state) => state.cooldown_until <= now,
None => true,
},
);
if let Some(proto) = chosen {
wanted.push((peer, proto.clone()));
@@ -1831,7 +1880,11 @@ impl Runtime {
}
fn install_link(&mut self, inbound: InboundLink) {
if self.no_direct_data(inbound.peer) {
if self.no_direct_data(inbound.peer)
|| inbound.link.is_closed()
|| !self.sessions.contains_key(&inbound.peer)
{
inbound.link.close();
return;
}
self.record_protocol_success(inbound.peer, &inbound.protocol);
@@ -1842,6 +1895,14 @@ impl Runtime {
/// Hands a link to the plugin that owns its protocol.
fn adopt_link(&mut self, peer: EndpointId, protocol: String, link: SharedLink) {
if link.is_closed()
|| link.network() != self.network_id
|| link.peer() != peer
|| !self.sessions.contains_key(&peer)
{
link.close();
return;
}
if !self
.params
.plugins
@@ -1851,10 +1912,42 @@ impl Runtime {
return;
}
let key = (peer, protocol.clone());
let settings = if self.local_id.as_bytes() < peer.as_bytes() {
let settings = HealthSettings::fresh();
if let Ok(bytes) = postcard::to_stdvec(&(protocol.clone(), settings.clone())) {
let _ = self.send_to(
peer,
ControlMessage::Transport {
protocol: "link-health".into(),
payload: crate::proto::message::TransportPayload::new(bytes),
},
);
}
Some(settings)
} else {
self.pending_health.remove(&key)
};
let route = self
.sessions
.get(&peer)
.and_then(|s| self.tunnel_protocol(s))
.and_then(|name| self.params.plugins.iter().find(|p| p.protocol_id() == name))
.map(|plugin| ProbeRoute {
plugin: plugin.clone(),
routes: self.params.routes.clone(),
network: self.network_id,
peer,
max_probe_size: self.params.interface.as_ref().map_or(540, |interface| {
(interface.mtu() as usize).saturating_sub(36)
}),
});
let link = HealthLink::new(link, self.local_id, settings, route);
let path = link.path_description();
let max_datagram = link.max_datagram_size();
self.links
.insert((peer, protocol.clone()), Arc::clone(&link));
if let Some(old) = self.links.insert(key, Arc::clone(&link)) {
old.close();
}
// What this is, is a way to reach the peer. Which protocol's
// tunnel runs over it is decided in `update_paths`, and that tunnel
// is handed the hub's link, not this one: the same object for as
@@ -1878,6 +1971,8 @@ impl Runtime {
self.hub.remove_peer(peer);
self.reachable.remove(&peer);
self.protocol_dial_states.retain(|(p, _), _| *p != peer);
self.last_failed_protocol.remove(&peer);
self.pending_health.retain(|(p, _), _| *p != peer);
let keys: Vec<(EndpointId, String)> = self
.links
.keys()
@@ -1885,7 +1980,9 @@ impl Runtime {
.cloned()
.collect();
for key in keys {
self.links.remove(&key);
if let Some(link) = self.links.remove(&key) {
link.close();
}
self.emit(Event::DataLinkDown {
network: self.network_id,
peer,
@@ -2134,6 +2231,50 @@ impl Runtime {
fn dispatch_message(&mut self, peer: EndpointId, message: ControlMessage) {
match &message {
ControlMessage::Transport { protocol, payload } => {
if protocol == "link-health" {
// Only the elected initiator supplies a profile, for an enabled,
// mutually supported physical protocol in this authenticated session.
if peer.as_bytes() >= self.local_id.as_bytes() {
return;
}
let Ok((name, settings)) =
postcard::from_bytes::<(String, HealthSettings)>(payload.as_bytes())
else {
return;
};
if name.len() > crate::dataplane::MAX_PROTOCOL_ID_LEN
|| !self.served_protocols().iter().any(|(p, v)| {
p == &name
&& self.sessions.get(&peer).is_some_and(|s| {
s.capabilities
.iter()
.any(|c| c.enabled && c.protocol == name && c.version == *v)
})
})
{
return;
}
let key = (peer, name);
// Negotiation and inbound installation are separate queues.
// Keep the newest settings even if the previous physical
// link is still in the map when this control frame arrives.
if let Some(link) = self.links.get(&key)
&& !link.is_closed()
{
link.configure(settings.clone());
}
self.pending_health.insert(key, settings);
return;
}
if let Some(transport) = &self.params.transport
&& let Err(err) =
transport.on_control(self.network_id, peer, protocol, payload.as_bytes())
{
tracing::debug!(%err, "transport negotiation rejected");
}
return;
}
ControlMessage::Announce(announcement) => {
let capabilities = announcement.capabilities.clone();
if let Some(session) = self.sessions.get_mut(&peer) {
@@ -2652,3 +2793,17 @@ mod relay_host_tests {
assert_eq!(relay_host("https://bad host/").as_deref(), None);
}
}
#[cfg(test)]
mod fallback_order_tests {
use super::next_protocol_index;
#[test]
fn degradation_advances_the_cycle_even_after_earlier_cooldowns_expire() {
let protocols = ["wg", "awg", "wg-quic", "tcp-tls"].map(str::to_owned);
assert_eq!(next_protocol_index(&protocols, None), 0);
for (i, p) in protocols.iter().enumerate() {
assert_eq!(next_protocol_index(&protocols, Some(p)), (i + 1) % 4);
}
assert_eq!(next_protocol_index(&protocols, Some("removed")), 0);
}
}
+3 -2
View File
@@ -70,7 +70,7 @@ pub(crate) struct Session {
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) outbound: mpsc::Sender<zeroize::Zeroizing<Vec<u8>>>,
pub(crate) hostname: Option<String>,
pub(crate) capabilities: Vec<PluginCapability>,
pub(crate) broadcast: bool,
@@ -244,7 +244,7 @@ pub(crate) fn spawn(
async fn writer_task(
mut send: SendStream,
mut outbound: mpsc::Receiver<Vec<u8>>,
mut outbound: mpsc::Receiver<zeroize::Zeroizing<Vec<u8>>>,
limits: Arc<Limits>,
shutdown: Shutdown,
parent: Shutdown,
@@ -331,6 +331,7 @@ async fn reader_task(
}
};
let payload = zeroize::Zeroizing::new(payload);
let bytes = payload.len();
let envelope: Envelope = match decode(&payload) {
Ok(envelope) => envelope,
+3
View File
@@ -166,6 +166,8 @@ pub struct Limits {
pub max_capabilities: usize,
/// Largest opaque plugin payload, in bytes.
pub max_capability_data_len: usize,
/// Largest private transport negotiation payload (also hard-capped at 4096 bytes).
pub max_transport_payload_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.
@@ -204,6 +206,7 @@ impl Default for Limits {
max_frame_len: 64 * 1024,
max_hostname_len: 255,
max_capabilities: 16,
max_transport_payload_len: 4096,
max_capability_data_len: 4 * 1024,
max_echo_payload_len: 4 * 1024,
max_reason_len: 256,
@@ -0,0 +1,350 @@
//! Authenticated probes of the actual data link, independent of control RTT.
//! Keys are negotiated privately for each physical link. No fixed wire magic.
use super::{PacketLink, SharedLink, TransportError};
use crate::dataplane::SharedPlugin;
use crate::overlay::RoutingTable;
use crate::{BoxFuture, NetworkId};
use bytes::Bytes;
use hmac::{Hmac, KeyInit, Mac};
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::VecDeque;
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tokio::sync::{mpsc, watch};
#[derive(Clone, Serialize, Deserialize, zeroize::ZeroizeOnDrop)]
pub(crate) struct Settings {
marker: [u8; 16],
key: [u8; 32],
}
impl Settings {
pub(crate) fn fresh() -> Self {
Self {
marker: rand::random(),
key: rand::random(),
}
}
}
impl std::fmt::Debug for Settings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("HealthSettings([REDACTED])")
}
}
struct Samples {
pending: VecDeque<(u64, Instant)>,
recent: VecDeque<bool>,
}
impl Samples {
fn new() -> Self {
Self {
pending: VecDeque::new(),
recent: VecDeque::new(),
}
}
fn sent(&mut self, seq: u64, now: Instant) {
self.pending.push_back((seq, now));
}
fn acknowledge(&mut self, seq: u64) {
if let Some(i) = self.pending.iter().position(|(n, _)| *n == seq) {
self.pending.remove(i);
self.recent.push_back(true);
}
}
fn expire(&mut self, now: Instant) {
while self
.pending
.front()
.is_some_and(|(_, at)| now.duration_since(*at) >= Duration::from_secs(3))
{
self.pending.pop_front();
self.recent.push_back(false);
}
while self.recent.len() > 20 {
self.recent.pop_front();
}
}
fn degraded(&self) -> bool {
self.recent.len() >= 15
&& self.recent.iter().filter(|ok| !**ok).count() * 100 >= self.recent.len() * 60
}
}
// This prefix is inside end-to-end encryption. It never appears on the UDP wire.
const IP_MAGIC: &[u8; 16] = b"tsunagi-health01";
pub(crate) fn ip_payload(packet: &[u8]) -> Option<&[u8]> {
(packet.len() >= 36 && packet[0] == 0x45 && packet[9] == 253 && &packet[20..36] == IP_MAGIC)
.then(|| &packet[36..])
}
/// Inject probes through the same encrypted tunnel as application IP packets.
/// This matters for pure WG: raw UDP probes could pass DPI while WG is blocked.
pub(crate) struct ProbeRoute {
pub(crate) plugin: SharedPlugin,
pub(crate) routes: Arc<RoutingTable>,
pub(crate) network: NetworkId,
pub(crate) peer: EndpointId,
pub(crate) max_probe_size: usize,
}
impl ProbeRoute {
fn send(&self, payload: Bytes) -> bool {
let Some((source, destination)) = self.routes.probe_addresses(self.network, self.peer)
else {
return false;
};
let mut packet = vec![0; 36];
packet[0] = 0x45;
packet[8] = 64;
packet[9] = 253;
packet[12..16].copy_from_slice(&source.octets());
packet[16..20].copy_from_slice(&destination.octets());
packet[20..36].copy_from_slice(IP_MAGIC);
let length = (packet.len() + payload.len()) as u16;
packet[2..4].copy_from_slice(&length.to_be_bytes());
let sum = packet[..20]
.as_chunks::<2>()
.0
.iter()
.map(|b| u32::from(u16::from_be_bytes(*b)))
.sum::<u32>();
let sum = (sum & 0xffff) + (sum >> 16);
packet[10..12].copy_from_slice(&(!(sum as u16)).to_be_bytes());
packet.extend_from_slice(&payload);
self.plugin
.carry(self.network, self.peer, Bytes::from(packet))
}
}
/// A monitored physical link. It forwards application bytes unchanged.
pub(crate) struct HealthLink {
probes: mpsc::Sender<Bytes>,
raw: SharedLink,
settings: Mutex<Option<Settings>>,
rx: tokio::sync::Mutex<mpsc::Receiver<Bytes>>,
stop: watch::Sender<bool>,
reason: Mutex<Option<String>>,
}
impl std::fmt::Debug for HealthLink {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HealthLink")
.field("raw", &self.raw)
.finish_non_exhaustive()
}
}
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
match m.lock() {
Ok(g) => g,
Err(p) => p.into_inner(),
}
}
impl HealthLink {
pub(crate) fn new(
raw: SharedLink,
local: EndpointId,
settings: Option<Settings>,
route: Option<ProbeRoute>,
) -> Arc<Self> {
let (tx, rx) = mpsc::channel(64);
let (probe_tx, mut probe_rx) = mpsc::channel(64);
let (stop, _) = watch::channel(false);
let link = Arc::new(Self {
probes: probe_tx,
raw: raw.clone(),
settings: Mutex::new(settings),
rx: tokio::sync::Mutex::new(rx),
stop,
reason: Mutex::new(None),
});
let weak = Arc::downgrade(&link);
let mut stopped = link.stop.subscribe();
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(1));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut samples = Samples::new();
let mut sequence = rand::random::<u64>();
let mut since: Option<Instant> = None;
let mut marker = None;
loop {
tokio::select! {
biased;
_=stopped.changed()=>break,
_=interval.tick()=>{
let Some(link)=weak.upgrade()else{break;};let Some(settings)=lock(&link.settings).clone()else{continue;};
if marker != Some(settings.marker) { marker = Some(settings.marker); samples = Samples::new(); since = None; }
let start=*since.get_or_insert_with(Instant::now);let now=Instant::now();samples.expire(now);
if now.duration_since(start)>=Duration::from_secs(20) && samples.degraded() {
*lock(&link.reason)=Some("data delivery checks: at least 60% loss over the last 20 probes".into());break;
}
sequence=sequence.wrapping_add(1);
let size=route.as_ref().map_or(raw.max_datagram_size(),|route|route.max_probe_size);
let packet=probe(&settings,local,1,sequence,size.min(1000));
if let Some(route)=&route { if route.send(packet) {samples.sent(sequence,now);} }
else { let _=raw.send(packet); samples.sent(sequence,now); }
}
packet=raw.recv()=>{
let Some(packet)=packet else{break;};let Some(link)=weak.upgrade()else{break;};
let settings=lock(&link.settings).clone();
if route.is_none() && let Some(settings)=settings && packet.starts_with(&settings.marker) {
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
if kind==1 {let _=raw.send(probe(&settings,local,2,seq,packet.len()));}
else if kind==2 {samples.acknowledge(seq);}
}
} else {let _=tx.try_send(packet);}
}
packet=probe_rx.recv(),if route.is_some()=>{
let Some(packet)=packet else {break;};let Some(link)=weak.upgrade()else{break;};
let Some(settings)=lock(&link.settings).clone()else{continue;};
if let Some((kind,seq))=verify(&settings,raw.peer(),&packet) {
if kind==1 { if let Some(route)=&route { route.send(probe(&settings,local,2,seq,packet.len().min(route.max_probe_size))); } }
else if kind==2 {samples.acknowledge(seq);}
}
}
}
}
raw.close();
if let Some(link) = weak.upgrade() {
link.stop.send_replace(true);
}
});
link
}
pub(crate) fn receive_probe(&self, packet: Bytes) {
let _ = self.probes.try_send(packet);
}
pub(crate) fn configure(&self, settings: Settings) {
*lock(&self.settings) = Some(settings);
}
}
fn probe(settings: &Settings, sender: EndpointId, kind: u8, seq: u64, size: usize) -> Bytes {
let mut bytes = vec![0; size.max(57)];
bytes[..16].copy_from_slice(&settings.marker);
bytes[16] = kind;
bytes[17..25].copy_from_slice(&seq.to_be_bytes());
rand::fill(&mut bytes[25..size.max(57) - 32]);
let tail = bytes.len() - 32;
if let Ok(mut mac) = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key) {
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
bytes[tail..].copy_from_slice(&mac.finalize().into_bytes());
}
Bytes::from(bytes)
}
fn verify(settings: &Settings, sender: EndpointId, bytes: &[u8]) -> Option<(u8, u64)> {
if bytes.len() < 57 || !bytes.starts_with(&settings.marker) {
return None;
}
let mut mac = <Hmac<Sha256> as KeyInit>::new_from_slice(&settings.key).ok()?;
let tail = bytes.len() - 32;
mac.update(sender.as_bytes());
mac.update(&bytes[..tail]);
mac.verify_slice(&bytes[tail..]).ok()?;
Some((
bytes[16],
u64::from_be_bytes(bytes.get(17..25)?.try_into().ok()?),
))
}
impl PacketLink for HealthLink {
fn network(&self) -> NetworkId {
self.raw.network()
}
fn peer(&self) -> EndpointId {
self.raw.peer()
}
fn max_datagram_size(&self) -> usize {
self.raw.max_datagram_size()
}
fn send(&self, payload: Bytes) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send(payload)
}
}
fn send_flow(
&self,
payload: Bytes,
flow: super::super::routing::FlowId,
) -> Result<(), TransportError> {
if self.is_closed() {
Err(TransportError::Closed)
} else {
self.raw.send_flow(payload, flow)
}
}
fn recv(&self) -> BoxFuture<'_, Option<Bytes>> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if *stop.borrow() {
return None;
}
let mut rx = self.rx.lock().await;
tokio::select! {biased;_=stop.changed()=>None,packet=rx.recv()=>packet}
})
}
fn closed(&self) -> BoxFuture<'_, ()> {
Box::pin(async move {
let mut stop = self.stop.subscribe();
if !*stop.borrow() {
let _ = stop.changed().await;
}
})
}
fn is_closed(&self) -> bool {
*self.stop.borrow() || self.raw.is_closed()
}
fn close(&self) {
self.stop.send_replace(true);
self.raw.close();
}
fn failure_reason(&self) -> Option<String> {
lock(&self.reason)
.clone()
.or_else(|| self.raw.failure_reason())
}
fn path_description(&self) -> String {
self.raw.path_description()
}
}
impl Drop for HealthLink {
fn drop(&mut self) {
self.stop.send_replace(true);
self.raw.close();
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
#[test]
fn no_false_failure_before_window_but_persistent_loss_is_detected() {
let mut samples = Samples::new();
let now = Instant::now();
for seq in 0..20 {
samples.sent(seq, now);
if seq % 5 == 0 {
samples.acknowledge(seq);
}
}
assert!(!samples.degraded());
samples.expire(now + Duration::from_secs(4));
assert!(samples.degraded());
for seq in 20..40 {
samples.sent(seq, now);
samples.acknowledge(seq);
}
samples.expire(now + Duration::from_secs(4));
assert!(!samples.degraded());
}
#[test]
fn a_forged_or_reflected_probe_cannot_claim_delivery() {
let a = crate::identity::DeviceIdentity::generate().endpoint_id();
let b = crate::identity::DeviceIdentity::generate().endpoint_id();
let settings = Settings::fresh();
let packet = probe(&settings, a, 1, 42, 1100);
assert_eq!(verify(&settings, a, &packet), Some((1, 42)));
assert!(verify(&settings, b, &packet).is_none());
let mut forged = packet.to_vec();
forged[16] = 2;
assert!(verify(&settings, a, &forged).is_none());
}
}
@@ -97,6 +97,9 @@ impl IrohLink {
}
impl PacketLink for IrohLink {
fn close(&self) {
self.conn.close(0u32.into(), b"data link retired");
}
fn network(&self) -> NetworkId {
self.network
}
@@ -30,12 +30,14 @@
//! one plugin protocol.
mod fragments;
pub(crate) mod health;
pub mod iroh_link;
use std::sync::Arc;
use bytes::Bytes;
use iroh::EndpointId;
use serde::{Deserialize, Serialize};
use crate::BoxFuture;
use crate::identity::NetworkId;
@@ -112,6 +114,14 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// keeping a task parked on [`PacketLink::closed`].
fn is_closed(&self) -> bool;
/// Explicitly retires a physical link. Implementations should wake readers.
fn close(&self) {}
/// Observed reason for retiring this link, if available.
fn failure_reason(&self) -> Option<String> {
None
}
/// A short description of the path in use, for diagnostics.
///
/// Reports what the transport actually knows. It must not invent a value.
@@ -121,6 +131,76 @@ pub trait PacketLink: Send + Sync + std::fmt::Debug + 'static {
/// A shared handle to a link.
pub type SharedLink = std::sync::Arc<dyn PacketLink>;
/// Maximum opaque pair-negotiation payload.
pub const MAX_TRANSPORT_PAYLOAD: usize = 4096;
/// Private transport bytes, never included in Debug output.
#[derive(Clone, PartialEq, Eq, Serialize, zeroize::ZeroizeOnDrop)]
pub struct TransportPayload(Vec<u8>);
impl<'de> Deserialize<'de> for TransportPayload {
fn deserialize<D: serde::Deserializer<'de>>(
deserializer: D,
) -> std::result::Result<Self, D::Error> {
struct Visitor;
impl<'de> serde::de::Visitor<'de> for Visitor {
type Value = TransportPayload;
fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "at most {MAX_TRANSPORT_PAYLOAD} private transport bytes")
}
fn visit_seq<A: serde::de::SeqAccess<'de>>(
self,
mut sequence: A,
) -> std::result::Result<Self::Value, A::Error> {
if sequence
.size_hint()
.is_some_and(|size| size > MAX_TRANSPORT_PAYLOAD)
{
return Err(serde::de::Error::custom("transport payload too large"));
}
let mut payload = TransportPayload(Vec::with_capacity(
sequence.size_hint().unwrap_or(0).min(MAX_TRANSPORT_PAYLOAD),
));
while let Some(byte) = sequence.next_element::<u8>()? {
if payload.0.len() == MAX_TRANSPORT_PAYLOAD {
return Err(serde::de::Error::custom("transport payload too large"));
}
payload.0.push(byte);
}
Ok(payload)
}
}
deserializer.deserialize_seq(Visitor)
}
}
impl TransportPayload {
/// Wraps bytes for one authenticated control session.
pub fn new(bytes: Vec<u8>) -> Self {
Self(bytes)
}
/// Borrows private bytes; callers must not log them.
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
}
impl std::fmt::Debug for TransportPayload {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("TransportPayload([REDACTED])")
}
}
/// Private control-plane sender for transports. Contains no network secret.
pub trait ControlSender: Send + Sync + std::fmt::Debug + 'static {
/// Queues a bounded message to one authenticated member of one network.
fn send<'a>(
&'a self,
network: NetworkId,
peer: EndpointId,
protocol: &'a str,
payload: TransportPayload,
) -> BoxFuture<'a, Result<(), TransportError>>;
}
/// An inbound link a peer opened towards us.
#[derive(Debug)]
pub struct InboundLink {
@@ -139,6 +219,18 @@ pub struct InboundLink {
/// 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 {
/// Supplies a private sender before network activation.
fn attach_control(&self, _control: Arc<dyn ControlSender>) {}
/// Validates and queues private negotiation received from an authenticated member.
fn on_control(
&self,
_network: NetworkId,
_peer: EndpointId,
_protocol: &str,
_payload: &[u8],
) -> Result<(), TransportError> {
Err(TransportError::Declined(self.name().into()))
}
/// A short name used in diagnostics.
fn name(&self) -> &str;
@@ -184,6 +276,24 @@ impl MultiPacketTransport {
}
impl PacketTransport for MultiPacketTransport {
fn attach_control(&self, control: Arc<dyn ControlSender>) {
self.default.attach_control(control.clone());
for transport in self.by_protocol.values() {
transport.attach_control(control.clone());
}
}
fn on_control(
&self,
network: NetworkId,
peer: EndpointId,
protocol: &str,
payload: &[u8],
) -> Result<(), TransportError> {
match self.by_protocol.get(protocol) {
Some(transport) => transport.on_control(network, peer, protocol, payload),
None => Err(TransportError::Declined(protocol.into())),
}
}
fn name(&self) -> &str {
"multi"
}
+18
View File
@@ -384,6 +384,24 @@ impl RoutingTable {
conflicts
}
/// Current signed allocations used for encrypted data delivery checks.
pub(crate) fn probe_addresses(
&self,
network: NetworkId,
peer: EndpointId,
) -> Option<(Ipv4Addr, Ipv4Addr)> {
let table = self.read();
let routes = table.get(&network)?;
Some((
routes.local?,
routes
.peers
.iter()
.find(|(_, holder)| *holder == peer)
.map(|(address, _)| *address)?,
))
}
/// How many networks the table covers.
pub fn len(&self) -> usize {
self.read().len()
+23 -2
View File
@@ -20,7 +20,7 @@ use crate::error::ProtocolError;
/// 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/5";
pub const ALPN: &[u8] = b"tsunagi/ctrl/6";
/// ALPN of the tsunagi data plane.
///
@@ -43,7 +43,9 @@ pub const MAX_DATA_PROTOCOL_LEN: usize = 32;
pub const MAX_SIGNATURE_LEN: usize = 64;
/// Control protocol version carried inside the handshake.
pub const PROTOCOL_VERSION: u16 = 5;
pub const PROTOCOL_VERSION: u16 = 6;
pub use crate::dataplane::transport::{MAX_TRANSPORT_PAYLOAD, TransportPayload};
/// First message of the handshake, sent by the initiator.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -114,6 +116,13 @@ pub struct DataOpenAck {
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ControlMessage {
/// Private transport negotiation; never persisted or rebroadcast.
Transport {
/// Transport protocol id; the payload stays opaque to the core.
protocol: String,
/// Secret negotiation data, redacted in diagnostics.
payload: TransportPayload,
},
/// Hostname and capability announcement.
Announce(Announcement),
/// A small request used to verify that the exchange works.
@@ -264,6 +273,17 @@ pub fn validate_capability(
/// network, the other networks or the agent.
pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), ProtocolError> {
match message {
ControlMessage::Transport { protocol, payload } => {
if protocol.is_empty() {
return Err(ProtocolError::Malformed("empty transport protocol"));
}
check_len("transport.protocol", protocol.len(), MAX_PROTOCOL_ID_LEN)?;
check_len(
"transport.payload",
payload.as_bytes().len(),
limits.max_transport_payload_len.min(MAX_TRANSPORT_PAYLOAD),
)?;
}
ControlMessage::Peers { peers } => {
check_len("peers", peers.len(), limits.max_state_records)?;
for hint in peers {
@@ -320,6 +340,7 @@ pub fn validate(message: &ControlMessage, limits: &Limits) -> Result<(), Protoco
/// A short, stable label for a message kind, for metrics and diagnostics.
pub fn kind(message: &ControlMessage) -> &'static str {
match message {
ControlMessage::Transport { .. } => "transport",
ControlMessage::Announce(_) => "announce",
ControlMessage::Ping { .. } => "ping",
ControlMessage::Pong { .. } => "pong",