Skip to content

Commit 88941f9

Browse files
author
yosebyte
authored
Isolate and bound QUIC UDP flows
1 parent f378499 commit 88941f9

19 files changed

Lines changed: 734 additions & 171 deletions

File tree

docs/operations.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ The Portal:
105105
of connection-level receive credit;
106106
- raises the bidirectional-stream limit to `NOW_QUIC_MAX_STREAMS` after
107107
authentication;
108+
- dispatches each DATAGRAM UDP flow to an independent bounded worker so target
109+
dialing and rate-limit waits do not block unrelated flows;
110+
- drops new DATAGRAM requests when the per-flow queue, per-connection byte
111+
budget, or per-connection flow limit is full;
108112
- raises the connection-level receive credit to 32 MiB after authentication;
109113
- uses 16 MiB per-stream receive credit;
110114
- permits up to 32 MiB of unacknowledged stream data per connection;
@@ -168,6 +172,8 @@ authentication succeeds or fails.
168172
| Variable | Default | Purpose |
169173
| --- | --- | --- |
170174
| `NOW_QUIC_MAX_STREAMS` | `1024` | Maximum concurrent QUIC bidirectional streams after authentication. |
175+
| `NOW_QUIC_MAX_UDP_FLOWS` | `256` | Maximum QUIC DATAGRAM UDP flows per authenticated connection. |
176+
| `NOW_QUIC_UDP_QUEUE_BYTES` | `4194304` | Maximum queued QUIC DATAGRAM bytes per authenticated connection. |
171177
| `NOW_TCP_DATA_BUF_SIZE` | `32768` | Buffer size for each TCP relay direction. |
172178
| `NOW_UDP_DATA_BUF_SIZE` | `65536` | UDP target-socket receive buffer size. |
173179
| `NOW_TCP_DIAL_TIMEOUT` | `15s` | TCP target connection timeout. |
@@ -180,7 +186,9 @@ authentication succeeds or fails.
180186
| `NOW_RELOAD_INTERVAL` | `3600s` | Minimum interval between PEM reload attempts. |
181187

182188
Duration values accept forms such as `500ms`, `15s`, and `2m`. Invalid values
183-
use the defaults above. Integer controls must be non-negative.
189+
use the defaults above. `NOW_QUIC_MAX_UDP_FLOWS` and
190+
`NOW_QUIC_UDP_QUEUE_BYTES` must be positive; zero or invalid values use their
191+
defaults and emit a warning. Other integer controls must be non-negative.
184192

185193
`NOW_SERVICE_COOLDOWN` also exists in the runtime defaults and currently
186194
defaults to `3s`; it is reserved for service-side retry paths.

docs/protocol.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,12 @@ Different targets with the same flow ID are distinct flows. The response uses
414414
the request's flow ID and target. The Portal closes an inactive flow after
415415
`NOW_UDP_IDLE_TIMEOUT`.
416416

417+
The reference Portal dispatches each flow independently. Target dialing,
418+
rate-limit waits, and target socket I/O for one flow do not block DATAGRAM
419+
dispatch to other flows. Each flow queues at most 64 client datagrams. New
420+
requests are dropped when that queue, the per-connection queued-byte budget, or
421+
the per-connection flow limit is full; already accepted requests remain FIFO.
422+
417423
Malformed datagrams, unsupported versions, unknown types, and response frames
418424
received by the Portal are not forwarded. A close frame for an unknown flow has
419425
no effect.
@@ -520,6 +526,8 @@ the v1 derivation or frame formats.
520526
| Variable | Default | Purpose |
521527
| --- | --- | --- |
522528
| `NOW_QUIC_MAX_STREAMS` | `1024` | Maximum concurrent QUIC bidirectional streams. |
529+
| `NOW_QUIC_MAX_UDP_FLOWS` | `256` | Maximum QUIC DATAGRAM UDP flows per authenticated connection. |
530+
| `NOW_QUIC_UDP_QUEUE_BYTES` | `4194304` | Maximum queued QUIC DATAGRAM bytes per authenticated connection. |
523531
| `NOW_TCP_DATA_BUF_SIZE` | `32768` | Buffer size for each TCP relay direction. |
524532
| `NOW_UDP_DATA_BUF_SIZE` | `65536` | UDP target-socket receive buffer size. |
525533
| `NOW_TCP_DIAL_TIMEOUT` | `15s` | TCP target connection timeout. |
@@ -532,7 +540,9 @@ the v1 derivation or frame formats.
532540
| `NOW_RELOAD_INTERVAL` | `3600s` | Minimum interval between PEM reload attempts. |
533541

534542
Duration values accept human-readable forms supported by the Portal, such as
535-
`500ms`, `15s`, or `2m`. Invalid values use the listed defaults. Integer values
543+
`500ms`, `15s`, or `2m`. Invalid values use the listed defaults.
544+
`NOW_QUIC_MAX_UDP_FLOWS` and `NOW_QUIC_UDP_QUEUE_BYTES` must be positive; zero
545+
or invalid values use their defaults and emit a warning. Other integer values
536546
must be non-negative; invalid or negative values use the listed defaults.
537547

538548
## 13. Interoperability Requirements

src/common/config.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,18 @@ pub fn env_int(name: &str, default_value: i32) -> i32 {
2222
.unwrap_or(default_value)
2323
}
2424

25+
/// Reads a positive `usize`, reporting whether a present value was invalid.
26+
pub(crate) fn env_positive_usize(name: &str, default_value: usize) -> (usize, bool) {
27+
match std::env::var(name) {
28+
Ok(value) => match value.parse::<usize>() {
29+
Ok(value) if value > 0 => (value, false),
30+
_ => (default_value, true),
31+
},
32+
Err(std::env::VarError::NotPresent) => (default_value, false),
33+
Err(std::env::VarError::NotUnicode(_)) => (default_value, true),
34+
}
35+
}
36+
2537
/// Reads a duration from the environment using humantime syntax.
2638
pub fn env_duration(name: &str, default_value: Duration) -> Duration {
2739
std::env::var(name)

src/common/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod network;
99
mod socks;
1010
mod tls;
1111

12+
pub(crate) use config::env_positive_usize;
1213
pub use config::{
1314
DEFAULT_DIALER_IP, DEFAULT_RATE_LIMIT, UDP_FRAME_SCRATCH_SIZE, env_duration, env_int,
1415
handshake_timeout, init_dialer_ip, query_int, quic_max_streams, rate_limit_bytes_per_second,
@@ -17,5 +18,5 @@ pub use config::{
1718
};
1819
pub use logger::{LogLevel, Logger};
1920
pub use network::{bind_udp_addrs, dial_tcp_from_local_ip, dial_udp_from_local_ip};
20-
pub(crate) use socks::{OutboundDialer, OutboundUdpSocket, SocksConfig};
21+
pub(crate) use socks::{OutboundDialer, SocksConfig};
2122
pub use tls::{TLSMode, new_server_configs};

src/common/socks.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@ mod udp;
1414

1515
pub(crate) use config::SocksConfig;
1616
pub(crate) use outbound::OutboundDialer;
17-
pub(crate) use udp::OutboundUdpSocket;
1817

1918
#[cfg(test)]
2019
use protocol::{

src/main.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Examples:
3535
Required URL parts:
3636
shared-key Non-empty URL username. Percent-encode reserved characters.
3737
listen-port TCP and/or UDP listen port.
38+
Password credentials are not supported.
3839
3940
Listen host:
4041
empty Bind IPv4 and IPv6 wildcard sockets.
@@ -45,6 +46,7 @@ Listen host:
4546
URL parameters:
4647
log=<level> none, debug, info, warn, error, event. Default: info.
4748
tls=1|2 TLS mode. 1 for RAM certificate; 2 for PEM files. Default: 1.
49+
tls=0 is not supported.
4850
crt=<path> PEM certificate chain for tls=2.
4951
key=<path> PEM private key for tls=2.
5052
net=mix|tcp|udp Listener mode. Default: mix.
@@ -67,6 +69,8 @@ SOCKS5 outbound:
6769
6870
Environment:
6971
NOW_QUIC_MAX_STREAMS Maximum authenticated QUIC streams.
72+
NOW_QUIC_MAX_UDP_FLOWS Maximum DATAGRAM UDP flows per QUIC connection.
73+
NOW_QUIC_UDP_QUEUE_BYTES Maximum queued DATAGRAM bytes per QUIC connection.
7074
NOW_TCP_DATA_BUF_SIZE TCP relay buffer size.
7175
NOW_UDP_DATA_BUF_SIZE UDP target receive buffer size.
7276
NOW_TCP_DIAL_TIMEOUT TCP target dial timeout.

src/portal/conn/session.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use std::sync::Arc;
1313
use std::sync::atomic::{AtomicBool, Ordering};
1414

1515
use quinn::{Connection, RecvStream, SendStream};
16-
use tokio::sync::Mutex;
16+
use tokio::sync::{Mutex, Semaphore};
1717
use tokio::time::timeout;
1818

1919
use crate::common::handshake_timeout;
@@ -28,16 +28,21 @@ pub(super) struct PortalSession {
2828
portal: Arc<PortalInner>,
2929
conn: Connection,
3030
udp_flows: Mutex<HashMap<UdpFlowKey, Arc<PortalUdpFlow>>>,
31+
udp_queue_budget: Arc<Semaphore>,
32+
udp_overload_logged: AtomicBool,
3133
closed: AtomicBool,
3234
}
3335

3436
impl PortalSession {
3537
/// Creates session state for one authenticated QUIC connection.
3638
pub(super) fn new(portal: Arc<PortalInner>, conn: Connection) -> Self {
39+
let udp_queue_budget = Arc::new(Semaphore::new(portal.udp_flow_limits.queue_bytes));
3740
Self {
3841
portal,
3942
conn,
4043
udp_flows: Mutex::new(HashMap::new()),
44+
udp_queue_budget,
45+
udp_overload_logged: AtomicBool::new(false),
4146
closed: AtomicBool::new(false),
4247
}
4348
}

src/portal/conn/session_datagram.rs

Lines changed: 82 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,16 @@ use std::sync::Arc;
88
use std::sync::atomic::Ordering;
99

1010
use bytes::Bytes;
11+
use tokio::sync::OwnedSemaphorePermit;
1112
use tokio_util::sync::CancellationToken;
1213

13-
use crate::common::udp_dial_timeout;
1414
use crate::protocol::{
15-
DATAGRAM_UDP_CLOSE, DATAGRAM_UDP_REQUEST, DATAGRAM_UDP_RESPONSE, decode_udp_datagram,
15+
DATAGRAM_UDP_CLOSE, DATAGRAM_UDP_REQUEST, DATAGRAM_UDP_RESPONSE, decode_udp_datagram_parts,
1616
new_udp_datagram_header,
1717
};
1818

1919
use super::PortalSession;
20-
use super::flow::{PortalUdpFlow, UdpFlowKey};
20+
use super::flow::{PortalUdpFlow, QueuedDatagram, UdpFlowKey};
2121

2222
impl PortalSession {
2323
/// Consumes pending and live QUIC datagrams for this authenticated session.
@@ -48,26 +48,35 @@ impl PortalSession {
4848
}
4949

5050
async fn handle_datagram(self: &Arc<Self>, data: Bytes) {
51-
let (frame_type, flow_id, target_addr, payload) =
52-
match decode_udp_datagram(&data, &self.portal.credentials.protocol_spec) {
53-
Ok(decoded) => decoded,
54-
Err(err) => {
55-
self.portal.logger.debug(format_args!(
56-
"portal::conn::datagram_loop: failed to decode datagram: {err}"
57-
));
58-
return;
59-
}
60-
};
51+
let decoded = match decode_udp_datagram_parts(&data, &self.portal.credentials.protocol_spec)
52+
{
53+
Ok(decoded) => decoded,
54+
Err(err) => {
55+
self.portal.logger.debug(format_args!(
56+
"portal::conn::datagram_loop: failed to decode datagram: {err}"
57+
));
58+
return;
59+
}
60+
};
6161

62-
if frame_type == DATAGRAM_UDP_CLOSE {
63-
self.close_udp_flow(flow_id, target_addr).await;
62+
if decoded.frame_type == DATAGRAM_UDP_CLOSE {
63+
self.close_udp_flow(decoded.flow_id, decoded.target_addr)
64+
.await;
6465
return;
6566
}
66-
if frame_type != DATAGRAM_UDP_REQUEST {
67+
if decoded.frame_type != DATAGRAM_UDP_REQUEST {
6768
return;
6869
}
6970

70-
self.handle_udp_request(flow_id, target_addr, payload).await;
71+
let retained_bytes = data.len();
72+
let payload = data.slice(decoded.payload_offset..);
73+
self.handle_udp_request(
74+
decoded.flow_id,
75+
decoded.target_addr,
76+
payload,
77+
retained_bytes,
78+
)
79+
.await;
7180
}
7281

7382
async fn close_udp_flow(&self, flow_id: u64, target_addr: String) {
@@ -82,87 +91,85 @@ impl PortalSession {
8291
self: &Arc<Self>,
8392
flow_id: u64,
8493
target_addr: String,
85-
payload: &[u8],
94+
payload: Bytes,
95+
retained_bytes: usize,
8696
) {
87-
let flow = match self.get_udp_flow(flow_id, &target_addr).await {
88-
Ok(flow) => flow,
89-
Err(err) => {
90-
self.portal.logger.error(format_args!(
91-
"portal::conn::handle_udp_request: failed to open UDP flow: {err}"
92-
));
93-
return;
94-
}
97+
let Some(budget) = self.try_reserve_udp_queue(retained_bytes) else {
98+
self.warn_udp_drop("connection queue byte limit reached");
99+
return;
95100
};
96-
flow.touch().await;
97-
98-
if let Some(limiter) = &self.portal.rate_limiter {
99-
limiter.wait_read(payload.len() as i64).await;
100-
}
101-
102-
match flow.send_to_target(payload).await {
103-
Ok(n) => {
104-
self.portal
105-
.stats
106-
.udp_rx
107-
.fetch_add(n as u64, Ordering::Relaxed);
101+
let flow = match self.get_or_create_udp_flow(flow_id, target_addr).await {
102+
Ok(Some(flow)) => flow,
103+
Ok(None) => {
104+
self.warn_udp_drop("per-connection flow limit reached");
105+
return;
108106
}
109107
Err(err) => {
110108
self.portal.logger.error(format_args!(
111-
"portal::conn::handle_udp_request: failed to write target: {err}"
109+
"portal::conn::handle_udp_request: failed to create UDP flow: {err}"
112110
));
113-
flow.close().await;
111+
return;
114112
}
113+
};
114+
if !flow.enqueue(QueuedDatagram::new(payload, budget)) {
115+
self.warn_udp_drop("per-flow datagram queue is full");
115116
}
116117
}
117118

118-
async fn get_udp_flow(
119+
async fn get_or_create_udp_flow(
119120
self: &Arc<Self>,
120121
flow_id: u64,
121-
target_addr: &str,
122-
) -> anyhow::Result<Arc<PortalUdpFlow>> {
122+
target_addr: String,
123+
) -> anyhow::Result<Option<Arc<PortalUdpFlow>>> {
123124
let key = UdpFlowKey::new(flow_id, target_addr);
124-
125-
// Reuse an open flow for repeated datagrams with the same client flow ID
126-
// and target; closed flows are replaced below.
127-
if let Some(flow) = self.udp_flows.lock().await.get(&key).cloned()
128-
&& !flow.is_closed()
129-
{
130-
return Ok(flow);
125+
let mut guard = self.udp_flows.lock().await;
126+
if self.closed.load(Ordering::Acquire) {
127+
return Ok(None);
128+
}
129+
if let Some(flow) = guard.get(&key).cloned() {
130+
if !flow.is_closed() {
131+
return Ok(Some(flow));
132+
}
133+
guard.remove(&key);
134+
}
135+
if guard.len() >= self.portal.udp_flow_limits.max_flows {
136+
return Ok(None);
131137
}
132-
133-
let socket = self
134-
.portal
135-
.outbound
136-
.dial_udp(target_addr, udp_dial_timeout())
137-
.await?;
138138
let response_header = new_udp_datagram_header(
139139
DATAGRAM_UDP_RESPONSE,
140140
flow_id,
141-
target_addr,
141+
key.target(),
142142
&self.portal.credentials.protocol_spec,
143143
)
144144
.map_err(|e| {
145-
anyhow::anyhow!("portal::conn::get_udp_flow: failed to build response header: {e}")
145+
anyhow::anyhow!(
146+
"portal::conn::get_or_create_udp_flow: failed to build response header: {e}"
147+
)
146148
})?;
147-
let flow = Arc::new(PortalUdpFlow::new(
148-
Arc::downgrade(self),
149-
key.clone(),
150-
socket,
151-
response_header,
152-
));
153-
154-
let mut guard = self.udp_flows.lock().await;
155-
if let Some(existing) = guard.get(&key).cloned() {
156-
// Another task may have inserted the flow while this task was
157-
// dialing. Prefer the existing entry to keep ownership singular.
158-
return Ok(existing);
159-
}
149+
let (flow, receiver) = PortalUdpFlow::new(Arc::downgrade(self), key.clone());
150+
let flow = Arc::new(flow);
160151
guard.insert(key, flow.clone());
152+
self.portal.stats.add_session(true);
161153
drop(guard);
162154

163-
self.portal.stats.add_session(true);
164-
tokio::spawn(flow.clone().read_loop());
165-
tokio::spawn(flow.clone().idle_loop());
166-
Ok(flow)
155+
tokio::spawn(flow.clone().run(receiver, response_header));
156+
Ok(Some(flow))
157+
}
158+
159+
fn try_reserve_udp_queue(&self, bytes: usize) -> Option<OwnedSemaphorePermit> {
160+
let permits = u32::try_from(bytes).ok()?;
161+
self.udp_queue_budget
162+
.clone()
163+
.try_acquire_many_owned(permits)
164+
.ok()
165+
}
166+
167+
fn warn_udp_drop(&self, reason: &str) {
168+
if !self.udp_overload_logged.swap(true, Ordering::AcqRel) {
169+
self.portal.logger.warn(format_args!(
170+
"portal::conn::datagram_loop: dropping UDP datagrams for {}: {reason}",
171+
self.conn.remote_address()
172+
));
173+
}
167174
}
168175
}

0 commit comments

Comments
 (0)