mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-08 10:56:13 -08:00
fix(quic): bind proxy packet checksum to packet number via ETQ1 version (#2565)
* fix(quic): bind proxy packet checksum to packet number via ETQ1 version QUIC proxy connections die with quinn PROTOCOL_VIOLATION "unsent packet acked" under bursty traffic with reordering, and the affected peer pair keeps failing for every new connection until the source node restarts. Root cause: the custom crypto checksums the packet bytes but not the packet number, while quinn decodes truncated packet numbers by proximity to the largest received (RFC 9000 Appendix A). A 1-byte encoded packet delayed beyond the +/-128 decode window is decoded as a future packet number, still passes the checksum, gets ACKed, and the peer aborts because it never sent that number. Real QUIC survives this because the AEAD nonce is derived from the packet number, so a misdecode fails authentication. Fix: negotiate a custom QUIC version ETQ1 (0x45545131) for the proxy. Connections on ETQ1 mix the packet number into the SeaHash checksum, so an out-of-window misdecode fails authentication and the packet is handled as ordinary loss. The mode is derived statelessly from the negotiated version in QuicSession and ServerConfig::initial_keys. Compatibility: the proxy endpoint accepts both ETQ1 and version 1. NatDstQuicConnector dials ETQ1 first; on ConnectionError::VersionMismatch from a legacy peer it retries with version 1 and remembers the peer in legacy_version_peers to skip the rejected version afterwards. The quic:// tunnel keeps version 1 only. Verified with 13 docker nodes under netem jitter and bursty iperf load: the previously-poisoned pair survived 16 minutes on ETQ1 with zero violations while all legacy-version pairs kept dying; mixed new-to-old and old-to-new connections work. * fix(quic): extend the ETQ1 checksum fix to the quic:// tunnel The quic:// tunnel shares CryptoKey with the proxy, so after the proxy moved to ETQ1 the tunnel still carried the unsent-packet-acked exposure. Make endpoint_config() dual-version so tunnel listeners accept both ETQ1 and legacy peers, and dial ETQ1 first in upgrade_connected with a transparent fallback to version 1 on VersionMismatch, via a shared connect_with_etq1 helper. The proxy keeps its hedged dialer with the per-peer legacy memory; tunnel connections are established once per session, so the fallback there costs a single extra round trip.
This commit is contained in:
1 parent
a7383114d0
commit
e0bdb516b6
5 files changed
+481
-92
No files matched your search
@@ -1,14 +1,12 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
hash::Hash,
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::{Arc, Weak},
|
||||
time::{Duration, Instant as StdInstant},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use dashmap::DashMap;
|
||||
use quanta::Instant;
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -26,6 +24,7 @@ use crate::{
|
||||
},
|
||||
transport::{self, ConnectedTransport, UdpSessionMode},
|
||||
},
|
||||
foundation::expiring_set::ExpiringSet,
|
||||
foundation::task::{PeerTaskLauncher, PeerTaskManager},
|
||||
host::dns::DnsResolver,
|
||||
peers::{
|
||||
@@ -157,50 +156,6 @@ impl DirectConnectorOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
entries: DashMap<K, StdInstant>,
|
||||
}
|
||||
|
||||
impl<K> Default for ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
fn insert(&self, key: K, ttl: Duration) {
|
||||
self.entries.insert(key, StdInstant::now() + ttl);
|
||||
}
|
||||
|
||||
fn contains(&self, key: &K) -> bool {
|
||||
let active = self
|
||||
.entries
|
||||
.get(key)
|
||||
.is_some_and(|expires_at| *expires_at > StdInstant::now());
|
||||
if !active {
|
||||
self.entries.remove(key);
|
||||
}
|
||||
active
|
||||
}
|
||||
|
||||
fn cleanup(&self) {
|
||||
let now = StdInstant::now();
|
||||
self.entries.retain(|_, expires_at| *expires_at > now);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Hash, Eq, PartialEq, Clone)]
|
||||
struct ListenerBlacklistKey(PeerId, String);
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
use std::{hash::Hash, time::Duration, time::Instant};
|
||||
|
||||
use dashmap::DashMap;
|
||||
|
||||
/// A thread-safe set whose entries expire after a per-insert TTL.
|
||||
///
|
||||
/// `contains` lazily removes expired entries, so periodic `cleanup` calls
|
||||
/// are only needed to reclaim memory for keys that stop being read.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
entries: DashMap<K, Instant>,
|
||||
}
|
||||
|
||||
impl<K> Default for ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash,
|
||||
{
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
entries: DashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<K> ExpiringSet<K>
|
||||
where
|
||||
K: Eq + Hash + Clone,
|
||||
{
|
||||
pub fn insert(&self, key: K, ttl: Duration) {
|
||||
self.entries.insert(key, Instant::now() + ttl);
|
||||
}
|
||||
|
||||
pub fn contains(&self, key: &K) -> bool {
|
||||
match self
|
||||
.entries
|
||||
.remove_if(key, |_, expires_at| *expires_at <= Instant::now())
|
||||
{
|
||||
// Existed and expired: removed while holding the shard lock, so a
|
||||
// concurrent insert of the same key cannot be dropped by us.
|
||||
Some(_) => false,
|
||||
// Not removed: either absent, or still fresh.
|
||||
None => self.entries.contains_key(key),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup(&self) {
|
||||
let now = Instant::now();
|
||||
self.entries.retain(|_, expires_at| *expires_at > now);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::Duration;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn expired_entries_are_reported_absent() {
|
||||
let set: ExpiringSet<u32> = ExpiringSet::default();
|
||||
set.insert(1, Duration::ZERO);
|
||||
set.insert(2, Duration::from_secs(3600));
|
||||
assert!(!set.contains(&1));
|
||||
assert!(set.contains(&2));
|
||||
set.cleanup();
|
||||
assert!(!set.entries.contains_key(&1));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
//! Everything in `foundation` may be used by any layer, and nothing here may
|
||||
//! depend on a domain Module. See `CONTEXT.md` "Module layers".
|
||||
|
||||
pub mod expiring_set;
|
||||
#[cfg(any(
|
||||
feature = "proxy-smoltcp-stack",
|
||||
test,
|
||||
|
||||
Reference in new issue
Block a user