perf(data-plane): restore native throughput after host portability (#2452)

* perf(core): make data-plane idle check constant time

Avoid scanning every DashMap shard for each peer packet when no data-plane flows are active.

Publish the flow count before insertion and release it after removal so an Acquire load is a safe O(1) idle signal. Reject count overflow and underflow instead of silently saturating.

* test(perf): add repeatable two-node netns benchmark

Create isolated underlay namespaces, pin both EasyTier cores and iperf3 endpoints, and measure a single TCP flow in both directions over either UDP or TCP peer transport.

Keep every iperf3 JSON result and emit directional medians while cleaning up processes and namespaces on every exit path.

* perf(tcp): preserve native owned stream halves

Let each VirtualTcpSocket adapter consume itself into independent read and write halves. Portable adapters retain the generic shared split as a default.

Use lock-free Tokio owned halves for native TCP and Unix streams so tunnel I/O no longer takes the generic split mutex on every poll. Cover full-duplex traffic and write-half shutdown.

* perf(packet): preserve ownership across the Host seam

Introduce an opaque, move-only HostPacket that retains core packet storage while exposing only the raw IP payload. Clear private headers before handing storage back to a native TUN adapter.

Use an ownership-preserving bounded channel for native ingress and egress. Keep explicit copy adapters for Vec and WASI boundaries, and verify allocation identity, backpressure, shutdown, and end-to-end delivery.

* perf(udp): preserve packet ownership through sessions

Carry EasyTier tunnel packets through UDP session queues as owned values. Reuse the existing tunnel header for session framing instead of copying payloads into a second packet and rebuilding them on receive.

Keep completion delivery for the public datagram socket API while removing the unused completion channel from streaming tunnel sends. Avoid the unconditional receive-side clone before QUIC routing is known.

* perf(peer): publish packet filters as immutable snapshots

Replace per-packet async and synchronous registry locks with ArcSwap snapshots. Permanent filters now need no activity checks, while managed registrations retain explicit acquire/release visibility.

Closing a managed registration marks it inactive before atomically removing it. Existing snapshots keep in-flight filters alive, and registration mutations prune inactive entries while preserving newest-first order.

* perf(instance): give native hosts direct packet egress

Let the core create one bounded HostPacket channel and transfer its receiver directly to a PacketEgressHost during startup. Native TUN runtimes now consume that receiver without the intermediate PacketSink channel and forwarding task.

Keep PacketSinkEgress as the compatibility adapter for callback and test hosts, and make receiver installation one-shot across desktop, mobile, and disabled runtimes.

* perf(crypto): restore accelerated native AEAD backends

Move Ring and OpenSSL implementations behind the core Encryptor seam.
Portable builds continue selecting only supported backends.

Restore historical precedence: OpenSSL, Ring, then RustCrypto. Keep
backend availability consistent across secure transports and cover
fixed-nonce wire compatibility between implementations.

* perf(udp): receive native datagrams into owned buffers

Extend the portable UDP socket seam with an owned-datagram receive path.
Keep a compatible default for portable hosts. Native Unix sockets write
recvmsg output directly into the final BytesMut allocation.

This removes the per-packet stack-to-heap copy introduced by the portable
socket boundary without exposing native socket resources to core.

* perf(data-plane): remove portable hot-path overhead

Restore native throughput lost while generalizing the host and UDP
session layers.

Read packet policy once per send, update traffic counters through
registry guards, and preserve packet ownership while UDP dispatch
borrows stable session state.

Move UDP shutdown monitoring into a control task so forwarding avoids
a select future per packet. Bound native datagram storage to 8 KiB,
reject oversized sends, and drop truncated Unix receives.

Keep accelerated AEAD selection warning-free when portable crypto
features are also built. Cover session bounds, truncation, and idle
shutdown with regression tests.

* fix(udp): preserve portable datagram receive semantics

Keep the public portable receive capacity at the theoretical UDP
maximum instead of silently shrinking it to the native fast-path limit.

Apply the 8 KiB session boundary after a complete portable receive,
so Windows cannot turn an oversized datagram into a fatal listener
error and other adapters cannot dispatch a truncated prefix.

Cover dropping an oversized packet while the same portable socket
continues to deliver the following valid datagram.

* fix(ci): align feature gating with backend selection

Compile the Ring implementation in production only when OpenSSL is not
selected, while retaining it for cross-backend unit tests.

Remove stale test imports and assert UDP dispatch results so the strict
workspace Clippy job passes without suppressing diagnostics.
This commit is contained in:
KKRainbow
2026-07-26 22:54:43 +08:00
committed by GitHub
parent dc11298558
commit 7fb42c3b73
48 changed files with 2182 additions and 541 deletions
+16
View File
@@ -6,12 +6,28 @@ use tokio::io::{AsyncRead, AsyncWrite};
use crate::socket::{IpVersion, SocketContext, SocketListener};
pub type VirtualTcpReadHalf = Box<dyn AsyncRead + Unpin + Send + 'static>;
pub type VirtualTcpWriteHalf = Box<dyn AsyncWrite + Unpin + Send + 'static>;
pub type VirtualTcpSplit = (VirtualTcpReadHalf, VirtualTcpWriteHalf);
/// A core-visible TCP stream endpoint.
///
/// Implementations are runtime adapters over concrete TCP stream types. This
/// trait deliberately stays below tunnel framing: it only exposes stream I/O and
/// socket addresses.
pub trait VirtualTcpSocket: AsyncRead + AsyncWrite + Unpin + Send + 'static {
/// Consumes the stream into independently owned read and write halves.
///
/// Portable adapters may use the generic shared split. Native adapters
/// should override this when their runtime provides lock-free owned halves.
fn into_split(self) -> VirtualTcpSplit
where
Self: Sized,
{
let (reader, writer) = tokio::io::split(self);
(Box::new(reader), Box::new(writer))
}
fn local_addr(&self) -> io::Result<SocketAddr>;
fn peer_addr(&self) -> io::Result<SocketAddr>;
+83 -57
View File
@@ -18,8 +18,9 @@ use super::{
UDP_SESSION_CONNECT_TIMEOUT, UDP_SESSION_QUEUE_CAPACITY, UDP_SESSION_RESEND_INTERVAL,
packet::{
EasyTierUdpPacketKind, UdpDatagramClassification, UdpSessionPacketKind,
classify_udp_datagram, extract_dst_addr_from_v4_hole_punch_packet,
extract_v6_hole_punch_packet, new_sack_packet, new_syn_packet,
classify_session_udp_datagram, classify_udp_datagram,
extract_dst_addr_from_v4_hole_punch_packet, extract_v6_hole_punch_packet, new_sack_packet,
new_syn_packet,
},
session::{
ClassifiedUdpSessionAccept, ClassifiedUdpSessionAccepts, ClassifiedUdpSessionKey,
@@ -32,8 +33,9 @@ use super::{
dispatch_payload_to_session, udp_session_registry_entry,
},
virtual_socket::{
NoopUdpSessionStunResponder, PreferredIpv6Source, UdpSessionStunResponder,
UdpSocketRecvMeta, UdpSocketSendMeta, VirtualUdpSocket, VirtualUdpSocketFactory,
MAX_UDP_SESSION_DATAGRAM_SIZE, NoopUdpSessionStunResponder, PreferredIpv6Source,
UdpSessionStunResponder, UdpSocketRecvMeta, UdpSocketSendMeta, VirtualUdpSocket,
VirtualUdpSocketFactory,
},
};
@@ -443,11 +445,10 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
S: VirtualUdpSocket,
R: UdpSessionStunResponder<S>,
{
let mut buf = [0u8; 65535];
let control_permits = Arc::new(Semaphore::new(UDP_SESSION_QUEUE_CAPACITY));
loop {
let (len, remote_addr, recv_meta) = match socket.recv_from_with_meta(&mut buf).await {
Ok(ret) => ret,
let datagram = match socket.recv_datagram().await {
Ok(datagram) => datagram,
Err(err) => {
tracing::debug!(?err, "udp session recv loop stopped");
let _ = session_shutdown_tx.send(true);
@@ -457,12 +458,25 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
break;
}
};
let payload = BytesMut::from(&buf[..len]);
let datagram = UdpSessionDatagram::new(payload.clone(), recv_meta);
let payload = datagram.payload;
let remote_addr = datagram.remote_addr;
let recv_meta = datagram.meta;
if payload.len() > MAX_UDP_SESSION_DATAGRAM_SIZE {
tracing::debug!(
datagram_len = payload.len(),
max_datagram_len = MAX_UDP_SESSION_DATAGRAM_SIZE,
?remote_addr,
"dropping oversized udp session datagram"
);
continue;
}
let quic_key = ClassifiedUdpSessionKey::new(UdpSessionProtocol::Quic, remote_addr);
if classified_sessions.contains_key(&quic_key) {
dispatch_existing_classified_udp_datagram(&classified_sessions, quic_key, datagram);
dispatch_existing_classified_udp_datagram(
&classified_sessions,
quic_key,
UdpSessionDatagram::new(payload, recv_meta),
);
continue;
}
match classify_udp_datagram(payload) {
@@ -500,23 +514,24 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
kind,
conn_id,
packet,
fallback,
} => {
let consumed = dispatch_easy_tier_udp_datagram(
socket.clone(),
let unconsumed = dispatch_easy_tier_udp_datagram(
&socket,
&sessions,
&pending_connects,
&mux_accepted,
&control,
control_permits.clone(),
&control_permits,
remote_addr,
kind,
conn_id,
&packet,
packet,
recv_meta,
session_shutdown_tx.subscribe(),
&session_shutdown_tx,
);
if !consumed {
if let Some(packet) = unconsumed {
let datagram = BytesMut::from(packet.into_bytes());
let fallback = classify_session_udp_datagram(&datagram);
dispatch_session_udp_datagram(
socket.clone(),
&classified_sessions,
@@ -524,7 +539,7 @@ pub(super) async fn udp_session_layer_recv_task<S, R>(
session_shutdown_tx.subscribe(),
remote_addr,
fallback,
UdpSessionDatagram::new(packet.into_bytes().into(), recv_meta),
UdpSessionDatagram::new(datagram, recv_meta),
);
}
}
@@ -537,14 +552,14 @@ fn dispatch_existing_classified_udp_datagram(
key: ClassifiedUdpSessionKey,
datagram: UdpSessionDatagram,
) {
let Some(entry) = classified_sessions
.get(&key)
.map(|entry| entry.value().clone())
else {
let Some(entry) = classified_sessions.get(&key) else {
return;
};
if !dispatch_payload_to_session(&entry.incoming, datagram, UdpSessionEnqueuePolicy::Reliable) {
let dispatched =
dispatch_payload_to_session(&entry.incoming, datagram, UdpSessionEnqueuePolicy::Reliable);
drop(entry);
if !dispatched {
close_classified_udp_session(classified_sessions, key);
tracing::debug!(?key, "classified udp session data queue closed");
}
@@ -552,73 +567,84 @@ fn dispatch_existing_classified_udp_datagram(
#[allow(clippy::too_many_arguments)]
fn dispatch_easy_tier_udp_datagram<S>(
socket: Arc<S>,
socket: &Arc<S>,
sessions: &Arc<UdpSessionRegistry>,
pending_connects: &Arc<PendingUdpSessionConnects>,
mux_accepted: &mpsc::Sender<UdpSession>,
control: &mpsc::Sender<UdpSessionLayerControl>,
control_permits: Arc<Semaphore>,
control_permits: &Arc<Semaphore>,
remote_addr: SocketAddr,
kind: EasyTierUdpPacketKind,
conn_id: u32,
packet: &ZCPacket,
packet: ZCPacket,
recv_meta: UdpSocketRecvMeta,
session_shutdown: watch::Receiver<bool>,
) -> bool
session_shutdown: &watch::Sender<bool>,
) -> Option<ZCPacket>
where
S: VirtualUdpSocket,
{
match kind {
let consumed = match kind {
EasyTierUdpPacketKind::Data => {
dispatch_data_packet(sessions, remote_addr, conn_id, packet, recv_meta)
return dispatch_data_packet(sessions, remote_addr, conn_id, packet, recv_meta).err();
}
EasyTierUdpPacketKind::Syn => handle_new_easy_tier_mux_connect(
socket,
socket.clone(),
sessions.clone(),
mux_accepted.clone(),
remote_addr,
conn_id,
packet,
session_shutdown,
&packet,
session_shutdown.subscribe(),
),
EasyTierUdpPacketKind::Sack => {
dispatch_sack_packet(sessions, pending_connects, remote_addr, conn_id, packet)
dispatch_sack_packet(sessions, pending_connects, remote_addr, conn_id, &packet)
}
EasyTierUdpPacketKind::HolePunch => {
dispatch_hole_punch_packet(pending_connects, remote_addr)
}
EasyTierUdpPacketKind::V4HolePunch => {
dispatch_v4_hole_punch_control(socket, control_permits, control, remote_addr, packet)
}
EasyTierUdpPacketKind::V6HolePunch => {
dispatch_v6_hole_punch_control(socket, control_permits, control, remote_addr, packet)
}
}
EasyTierUdpPacketKind::V4HolePunch => dispatch_v4_hole_punch_control(
socket.clone(),
control_permits.clone(),
control,
remote_addr,
&packet,
),
EasyTierUdpPacketKind::V6HolePunch => dispatch_v6_hole_punch_control(
socket.clone(),
control_permits.clone(),
control,
remote_addr,
&packet,
),
};
if consumed { None } else { Some(packet) }
}
pub(super) fn dispatch_data_packet(
sessions: &UdpSessionRegistry,
peer_addr: SocketAddr,
conn_id: u32,
packet: &ZCPacket,
packet: ZCPacket,
recv_meta: UdpSocketRecvMeta,
) -> bool {
) -> Result<(), ZCPacket> {
let key = UdpSessionKey::new(peer_addr, conn_id);
let Some(entry) = sessions.get(&key).map(|entry| entry.value().clone()) else {
return false;
let Some(entry) = sessions.get(&key) else {
return Err(packet);
};
let payload = UdpSessionDatagram::new(BytesMut::from(packet.udp_payload()), recv_meta);
let policy = if packet.is_lossy() {
UdpSessionEnqueuePolicy::Lossy
} else {
UdpSessionEnqueuePolicy::Reliable
};
if !dispatch_payload_to_session(&entry.incoming, payload, policy) {
let payload = UdpSessionDatagram::from_easytier_packet(packet, recv_meta);
let dispatched = dispatch_payload_to_session(&entry.incoming, payload, policy);
drop(entry);
if !dispatched {
close_udp_session(sessions, key);
tracing::debug!(?key, "udp session data queue closed");
}
true
Ok(())
}
fn dispatch_session_udp_datagram<S>(
@@ -660,15 +686,14 @@ fn dispatch_classified_udp_datagram<S>(
S: VirtualUdpSocket,
{
let key = ClassifiedUdpSessionKey::new(protocol, remote_addr);
if let Some(entry) = classified_sessions
.get(&key)
.map(|entry| entry.value().clone())
{
if !dispatch_payload_to_session(
if let Some(entry) = classified_sessions.get(&key) {
let dispatched = dispatch_payload_to_session(
&entry.incoming,
datagram,
UdpSessionEnqueuePolicy::Reliable,
) {
);
drop(entry);
if !dispatched {
close_classified_udp_session(classified_sessions, key);
tracing::debug!(?key, "classified udp session data queue closed");
}
@@ -712,11 +737,12 @@ fn dispatch_classified_udp_datagram<S>(
}
dashmap::mapref::entry::Entry::Occupied(entry) => {
let entry = entry.get().clone();
if !dispatch_payload_to_session(
let dispatched = dispatch_payload_to_session(
&entry.incoming,
datagram,
UdpSessionEnqueuePolicy::Reliable,
) {
);
if !dispatched {
close_classified_udp_session(classified_sessions, key);
tracing::debug!(?key, "classified udp session data queue closed");
}
+2 -1
View File
@@ -29,7 +29,8 @@ pub(crate) use session::{
UdpSessionTunnelParts,
};
pub use virtual_socket::{
NoopUdpSessionStunResponder, PreferredIpv6Source, UdpBindOptions, UdpSessionStunResponder,
MAX_UDP_DATAGRAM_SIZE, MAX_UDP_SESSION_DATAGRAM_SIZE, NoopUdpSessionStunResponder,
PreferredIpv6Source, UdpBindOptions, UdpSessionStunResponder, UdpSocketDatagram,
UdpSocketPurpose, UdpSocketRecvMeta, UdpSocketSendMeta, VirtualUdpSocket,
VirtualUdpSocketFactory, send_v4_hole_punch_control_packet, send_v6_hole_punch_control_packet,
};
+3 -6
View File
@@ -161,7 +161,6 @@ pub(super) enum UdpDatagramClassification {
kind: EasyTierUdpPacketKind,
conn_id: u32,
packet: ZCPacket,
fallback: UdpSessionPacketKind,
},
SessionPacket {
kind: UdpSessionPacketKind,
@@ -216,7 +215,7 @@ pub(super) enum EasyTierUdpDatagramInspectError {
},
}
fn classify_session_udp_datagram(data: &[u8]) -> UdpSessionPacketKind {
pub(super) fn classify_session_udp_datagram(data: &[u8]) -> UdpSessionPacketKind {
if is_wireguard_packet(data) {
UdpSessionPacketKind::Classified(UdpSessionProtocol::WireGuard)
} else if is_quic_packet(data) {
@@ -335,12 +334,11 @@ pub(super) fn classify_udp_datagram(datagram: BytesMut) -> UdpDatagramClassifica
return UdpDatagramClassification::Stun(datagram);
}
let fallback = classify_session_udp_datagram(&datagram);
let easytier = match inspect_easytier_udp_datagram(&datagram) {
Ok(Some(easytier)) => easytier,
Ok(None) => {
return UdpDatagramClassification::SessionPacket {
kind: fallback,
kind: classify_session_udp_datagram(&datagram),
datagram,
};
}
@@ -361,7 +359,7 @@ pub(super) fn classify_udp_datagram(datagram: BytesMut) -> UdpDatagramClassifica
}
}
return UdpDatagramClassification::SessionPacket {
kind: fallback,
kind: classify_session_udp_datagram(&datagram),
datagram,
};
}
@@ -372,7 +370,6 @@ pub(super) fn classify_udp_datagram(datagram: BytesMut) -> UdpDatagramClassifica
kind: easytier.kind,
conn_id: easytier.conn_id,
packet,
fallback,
}
}
+147 -38
View File
@@ -14,33 +14,63 @@ use tokio::{
task::JoinHandle,
};
use crate::socket::ring::{RingSocket, RingSocketReceiver, RingSocketSendError, RingSocketSender};
use crate::{
packet::{UdpPacketType, ZCPacket, ZCPacketType},
socket::ring::{RingSocket, RingSocketReceiver, RingSocketSendError, RingSocketSender},
};
use super::{
UDP_SESSION_QUEUE_CAPACITY,
MAX_UDP_SESSION_DATAGRAM_SIZE, UDP_SESSION_QUEUE_CAPACITY,
packet::{new_data_packet, udp_session_payload_len},
virtual_socket::{PreferredIpv6Source, UdpBindOptions, UdpSocketRecvMeta, VirtualUdpSocket},
};
#[derive(Debug, Clone, PartialEq, Eq)]
#[derive(Debug)]
pub(crate) struct UdpSessionDatagram {
pub(crate) payload: BytesMut,
payload: UdpSessionDatagramPayload,
pub(crate) dst_ip: Option<IpAddr>,
}
#[derive(Debug)]
enum UdpSessionDatagramPayload {
Bytes(BytesMut),
EasyTierPacket(ZCPacket),
}
impl UdpSessionDatagram {
pub(crate) fn new(payload: BytesMut, meta: UdpSocketRecvMeta) -> Self {
Self {
payload,
payload: UdpSessionDatagramPayload::Bytes(payload),
dst_ip: meta.dst_ip,
}
}
pub(crate) fn from_easytier_packet(packet: ZCPacket, meta: UdpSocketRecvMeta) -> Self {
Self {
payload: UdpSessionDatagramPayload::EasyTierPacket(packet),
dst_ip: meta.dst_ip,
}
}
pub(crate) fn payload(&self) -> &[u8] {
match &self.payload {
UdpSessionDatagramPayload::Bytes(payload) => payload,
UdpSessionDatagramPayload::EasyTierPacket(packet) => packet.udp_payload(),
}
}
pub(crate) fn into_tunnel_packet(self) -> Result<ZCPacket, BytesMut> {
match self.payload {
UdpSessionDatagramPayload::Bytes(payload) => Err(payload),
UdpSessionDatagramPayload::EasyTierPacket(packet) => Ok(packet),
}
}
}
impl From<BytesMut> for UdpSessionDatagram {
fn from(payload: BytesMut) -> Self {
Self {
payload,
payload: UdpSessionDatagramPayload::Bytes(payload),
dst_ip: None,
}
}
@@ -245,9 +275,12 @@ pub struct UdpSession {
pub(super) _cleanup: UdpSessionCleanup,
}
pub(crate) struct UdpSessionOutbound {
pub(crate) payload: BytesMut,
pub(crate) completion: oneshot::Sender<io::Result<usize>>,
pub(crate) enum UdpSessionOutbound {
Datagram {
payload: BytesMut,
completion: oneshot::Sender<io::Result<usize>>,
},
TunnelPacket(ZCPacket),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -258,13 +291,38 @@ pub(crate) enum UdpSessionCodec {
impl UdpSessionCodec {
pub(crate) fn validate_payload(&self, payload: &[u8]) -> io::Result<()> {
self.validate_datagram_size(payload.len())?;
if matches!(self, Self::EasyTierData { .. }) {
udp_session_payload_len(payload)?;
}
Ok(())
}
fn validate_datagram_size(&self, payload_len: usize) -> io::Result<()> {
let header_len = match self {
Self::EasyTierData { .. } => crate::packet::UDP_TUNNEL_HEADER_SIZE,
Self::Identity => 0,
};
let datagram_len = payload_len.checked_add(header_len).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"udp session datagram size overflow",
)
})?;
if datagram_len > MAX_UDP_SESSION_DATAGRAM_SIZE {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"udp session datagram too large: {datagram_len}, max: \
{MAX_UDP_SESSION_DATAGRAM_SIZE}"
),
));
}
Ok(())
}
fn encode(&self, payload: &[u8]) -> io::Result<BytesMut> {
self.validate_payload(payload)?;
match self {
Self::EasyTierData { conn_id } => {
Ok(new_data_packet(*conn_id, payload)?.into_bytes().into())
@@ -272,6 +330,23 @@ impl UdpSessionCodec {
Self::Identity => Ok(BytesMut::from(payload)),
}
}
fn encode_tunnel_packet(&self, packet: ZCPacket) -> io::Result<bytes::Bytes> {
let mut packet = packet.convert_type(ZCPacketType::UDP);
self.validate_datagram_size(packet.udp_payload().len())?;
match self {
Self::EasyTierData { conn_id } => {
let payload_len = udp_session_payload_len(packet.udp_payload())?;
let header = packet.mut_udp_tunnel_header().unwrap();
header.conn_id.set(*conn_id);
header.msg_type = UdpPacketType::Data as u8;
header.padding = 0;
header.len.set(payload_len);
Ok(packet.into_bytes())
}
Self::Identity => Ok(packet.udp_payload_bytes().freeze()),
}
}
}
#[derive(Clone)]
@@ -387,8 +462,12 @@ impl UdpSession {
peer_addr,
codec,
rings.session_send_rx,
close.clone(),
));
let shutdown_task = tokio::spawn(close_udp_session_on_shutdown(
shutdown,
close.clone(),
send_task.abort_handle(),
));
Self {
@@ -402,7 +481,7 @@ impl UdpSession {
_cleanup: UdpSessionCleanup {
session_close: Some(close),
shutdown: None,
tasks: vec![send_task],
tasks: vec![send_task, shutdown_task],
layer_guard: None,
},
}
@@ -471,7 +550,7 @@ impl UdpSessionSocket for UdpSession {
return Err(udp_session_closed_error());
}
let (completion, sent) = oneshot::channel();
let outbound = UdpSessionOutbound {
let outbound = UdpSessionOutbound::Datagram {
payload: BytesMut::from(data),
completion,
};
@@ -508,8 +587,8 @@ impl UdpSessionSocket for UdpSession {
.ok_or_else(udp_session_closed_error)?
.map_err(ring_socket_error_to_io)?,
};
let len = payload.payload.len().min(buf.len());
buf[..len].copy_from_slice(&payload.payload[..len]);
let len = payload.payload().len().min(buf.len());
buf[..len].copy_from_slice(&payload.payload()[..len]);
Ok((
len,
UdpSessionRecvMeta {
@@ -618,56 +697,86 @@ async fn forward_udp_session_to_socket<S>(
peer_addr: SocketAddr,
codec: UdpSessionCodec,
mut outgoing: RingSocketReceiver<UdpSessionOutbound>,
mut shutdown: watch::Receiver<bool>,
close: UdpSessionClose,
) where
S: VirtualUdpSocket,
{
loop {
tokio::select! {
biased;
_ = shutdown.changed() => {
let Some(outbound) = outgoing.next().await else {
break;
};
let outbound = match outbound {
Ok(outbound) => outbound,
Err(err) => {
tracing::debug!(?err, ?peer_addr, "udp session outgoing ring closed");
close.close();
break;
}
outbound = outgoing.next() => {
let Some(outbound) = outbound else {
break;
};
let outbound = match outbound {
Ok(outbound) => outbound,
};
let (datagram, completion) = match outbound {
UdpSessionOutbound::Datagram {
payload,
completion,
} => {
let payload_len = payload.len();
let datagram = match codec.encode(&payload) {
Ok(datagram) => datagram.freeze(),
Err(err) => {
tracing::debug!(?err, ?peer_addr, "udp session outgoing ring closed");
tracing::debug!(
?err,
?peer_addr,
?codec,
"udp session datagram encode error"
);
let _ = completion.send(Err(err));
close.close();
break;
}
};
let payload_len = outbound.payload.len();
let datagram = match codec.encode(&outbound.payload) {
(datagram, Some((completion, payload_len)))
}
UdpSessionOutbound::TunnelPacket(packet) => {
let datagram = match codec.encode_tunnel_packet(packet) {
Ok(datagram) => datagram,
Err(err) => {
tracing::debug!(?err, ?peer_addr, ?codec, "udp session datagram encode error");
let _ = outbound.completion.send(Err(err));
tracing::debug!(?err, ?peer_addr, ?codec, "udp tunnel packet encode error");
close.close();
break;
}
};
match socket.send_to(&datagram, peer_addr).await {
Ok(_) => {
let _ = outbound.completion.send(Ok(payload_len));
}
Err(err) => {
tracing::debug!(?err, ?peer_addr, "udp session send error");
let _ = outbound.completion.send(Err(err));
close.close();
break;
}
(datagram, None)
}
};
match socket.send_to(&datagram, peer_addr).await {
Ok(_) => {
if let Some((completion, payload_len)) = completion {
let _ = completion.send(Ok(payload_len));
}
}
Err(err) => {
tracing::debug!(?err, ?peer_addr, "udp session send error");
if let Some((completion, _)) = completion {
let _ = completion.send(Err(err));
}
close.close();
break;
}
}
}
}
async fn close_udp_session_on_shutdown(
mut shutdown: watch::Receiver<bool>,
close: UdpSessionClose,
send_task: tokio::task::AbortHandle,
) {
if !*shutdown.borrow() {
let _ = shutdown.changed().await;
}
close.close();
send_task.abort();
}
pub(super) fn dispatch_payload_to_session(
incoming: &Arc<StdMutex<RingSocketSender<UdpSessionDatagram>>>,
payload: impl Into<UdpSessionDatagram>,
+115 -32
View File
@@ -945,6 +945,30 @@ async fn udp_layer_routes_quic_like_easytier_packet_to_existing_quic_session() {
assert_eq!(layer.active_classified_session_count(), 1);
}
#[tokio::test]
async fn udp_layer_drops_oversized_datagram_without_stopping_portable_socket() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
let peer_addr = SocketAddr::from(([127, 0, 0, 1], 12001));
let socket = Arc::new(AutoSackVirtualUdpSocket::new(local_addr));
let layer = UdpSessionLayer::new(socket.clone());
let session = layer
.open_classified_session(UdpSessionProtocol::Quic, peer_addr)
.unwrap();
socket.incoming.lock().unwrap().extend([
(vec![0xAA; MAX_UDP_SESSION_DATAGRAM_SIZE + 1], peer_addr),
(b"after-oversized".to_vec(), peer_addr),
]);
socket.incoming_notify.notify_one();
let mut buf = [0; 32];
let len = tokio::time::timeout(Duration::from_secs(1), session.recv(&mut buf))
.await
.unwrap()
.unwrap();
assert_eq!(&buf[..len], b"after-oversized");
}
#[tokio::test]
async fn udp_layer_keeps_easy_tier_syn_out_of_wireguard_session() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
@@ -1021,13 +1045,37 @@ async fn easy_tier_mux_udp_session_rejects_oversized_payload_before_enqueue() {
sessions,
);
let payload = vec![0; u16::MAX as usize + 1];
let payload = vec![0; MAX_UDP_SESSION_DATAGRAM_SIZE - UDP_TUNNEL_HEADER_SIZE + 1];
let err = session.send(&payload).await.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
assert!(socket.sent().is_empty());
}
#[test]
fn udp_session_codecs_enforce_datagram_boundary() {
let identity = UdpSessionCodec::Identity;
assert!(
identity
.validate_payload(&vec![0; MAX_UDP_SESSION_DATAGRAM_SIZE])
.is_ok()
);
let err = identity
.validate_payload(&vec![0; MAX_UDP_SESSION_DATAGRAM_SIZE + 1])
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
let easy_tier = UdpSessionCodec::EasyTierData {
conn_id: 0x1122_3344,
};
let max_payload = MAX_UDP_SESSION_DATAGRAM_SIZE - UDP_TUNNEL_HEADER_SIZE;
assert!(easy_tier.validate_payload(&vec![0; max_payload]).is_ok());
let err = easy_tier
.validate_payload(&vec![0; max_payload + 1])
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
}
#[tokio::test]
async fn easy_tier_mux_udp_session_send_failure_closes_session() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
@@ -1074,26 +1122,35 @@ async fn easy_tier_mux_udp_session_receives_only_peer_data_payloads() {
sessions.clone(),
);
dispatch_data_packet(
&sessions,
unexpected_addr,
conn_id,
&new_data_packet(conn_id, b"wrong-peer").unwrap(),
Default::default(),
assert!(
dispatch_data_packet(
&sessions,
unexpected_addr,
conn_id,
new_data_packet(conn_id, b"wrong-peer").unwrap(),
Default::default(),
)
.is_err()
);
dispatch_data_packet(
&sessions,
peer_addr,
conn_id + 1,
&new_data_packet(conn_id + 1, b"wrong-conn").unwrap(),
Default::default(),
assert!(
dispatch_data_packet(
&sessions,
peer_addr,
conn_id + 1,
new_data_packet(conn_id + 1, b"wrong-conn").unwrap(),
Default::default(),
)
.is_err()
);
dispatch_data_packet(
&sessions,
peer_addr,
conn_id,
&new_data_packet(conn_id, b"payload").unwrap(),
Default::default(),
assert!(
dispatch_data_packet(
&sessions,
peer_addr,
conn_id,
new_data_packet(conn_id, b"payload").unwrap(),
Default::default(),
)
.is_ok()
);
let mut buf = [0; 16];
@@ -1163,6 +1220,26 @@ async fn dropping_udp_session_layer_closes_session_recv() {
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
}
#[tokio::test]
async fn idle_udp_session_closes_when_shutdown_is_signaled() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
let peer_addr = SocketAddr::from(([127, 0, 0, 1], 12001));
let key = UdpSessionKey::new(peer_addr, 0x1122_3344);
let socket = Arc::new(MockVirtualUdpSocket::new(local_addr, Vec::new()));
let sessions = Arc::new(DashMap::new());
let (session, shutdown_tx) = create_test_easy_tier_mux_session(socket, key, sessions.clone());
shutdown_tx.send(true).unwrap();
let mut buf = [0; 16];
let err = tokio::time::timeout(Duration::from_secs(1), session.recv(&mut buf))
.await
.unwrap()
.unwrap_err();
assert_eq!(err.kind(), io::ErrorKind::UnexpectedEof);
assert!(!sessions.contains_key(&key));
}
#[tokio::test]
async fn udp_session_recv_loop_error_closes_registered_sessions() {
let local_addr = SocketAddr::from(([127, 0, 0, 1], 12000));
@@ -1591,12 +1668,15 @@ async fn sack_from_actual_remote_rekeys_pending_session_before_data_dispatch() {
},
);
dispatch_data_packet(
&sessions,
expected_addr,
conn_id,
&new_data_packet(conn_id, b"pre-sack").unwrap(),
Default::default(),
assert!(
dispatch_data_packet(
&sessions,
expected_addr,
conn_id,
new_data_packet(conn_id, b"pre-sack").unwrap(),
Default::default(),
)
.is_err()
);
dispatch_sack_packet(
&sessions,
@@ -1605,12 +1685,15 @@ async fn sack_from_actual_remote_rekeys_pending_session_before_data_dispatch() {
conn_id,
&new_sack_packet(conn_id, magic),
);
dispatch_data_packet(
&sessions,
actual_addr,
conn_id,
&new_data_packet(conn_id, b"payload").unwrap(),
Default::default(),
assert!(
dispatch_data_packet(
&sessions,
actual_addr,
conn_id,
new_data_packet(conn_id, b"payload").unwrap(),
Default::default(),
)
.is_ok()
);
assert!(sessions.contains_key(&actual_key));
@@ -1624,7 +1707,7 @@ async fn sack_from_actual_remote_rekeys_pending_session_before_data_dispatch() {
.await
.unwrap()
.unwrap();
assert_eq!(payload.payload, BytesMut::from(&b"payload"[..]));
assert_eq!(payload.payload(), b"payload");
}
#[tokio::test]
@@ -5,6 +5,7 @@ use std::{
};
use async_trait::async_trait;
use bytes::BytesMut;
use serde::{Deserialize, Serialize};
use crate::socket::{IpVersion, SocketContext};
@@ -22,6 +23,23 @@ pub struct UdpSocketSendMeta {
pub src_ifindex: Option<u32>,
}
/// Largest UDP datagram that portable socket implementations must receive.
pub const MAX_UDP_DATAGRAM_SIZE: usize = u16::MAX as usize;
/// Largest datagram accepted by the UDP session/multiplexer data plane.
///
/// EasyTier, WireGuard, and QUIC datagrams are bounded by their transport MTU.
/// Keeping this capacity explicit avoids allocating the theoretical UDP maximum
/// for every packet on native hosts that can detect truncation.
pub const MAX_UDP_SESSION_DATAGRAM_SIZE: usize = 8 * 1024;
#[derive(Debug)]
pub struct UdpSocketDatagram {
pub payload: BytesMut,
pub remote_addr: SocketAddr,
pub meta: UdpSocketRecvMeta,
}
#[async_trait]
pub trait VirtualUdpSocket: Send + Sync + 'static {
fn local_addr(&self) -> std::io::Result<SocketAddr>;
@@ -51,6 +69,23 @@ pub trait VirtualUdpSocket: Send + Sync + 'static {
let (len, addr) = self.recv_from(buf).await?;
Ok((len, addr, UdpSocketRecvMeta::default()))
}
/// Receives one datagram into an owned buffer.
///
/// Portable hosts can use this default implementation. Native hosts should
/// override it when their socket API can write directly into owned storage,
/// avoiding a second allocation and copy at the Host boundary.
async fn recv_datagram(&self) -> std::io::Result<UdpSocketDatagram> {
let mut payload = BytesMut::new();
payload.resize(MAX_UDP_DATAGRAM_SIZE, 0);
let (len, remote_addr, meta) = self.recv_from_with_meta(&mut payload).await?;
payload.truncate(len);
Ok(UdpSocketDatagram {
payload,
remote_addr,
meta,
})
}
}
#[async_trait]