Skip to content

Commit 8b83c9c

Browse files
committed
Harden UDP beta hot paths
1 parent b0ebf53 commit 8b83c9c

3 files changed

Lines changed: 75 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ behavior when the change improves security or project direction.
3333
oversized downstream datagrams are dropped before reaching the upstream.
3434
- Added optional stable-gate UDP beta smoke via `FLUXHEIM_GATE_UDP=1`, with
3535
`FLUXHEIM_UDP_SMOKE_ITERATIONS` for longer local soak runs.
36+
- Hardened UDP beta hot paths by removing per-response full-map scans from
37+
source response-rate limiting and bounding passive-health fallback selection
38+
by upstream count instead of total configured weight.
3639

3740
## 1.5.20 - 2026-06-13
3841

release-notes/RELEASE_NOTES_1.5.21.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ controls for safer testing.
3333
- The stable release gate can run the UDP beta smoke with
3434
`FLUXHEIM_GATE_UDP=1`; set `FLUXHEIM_UDP_SMOKE_ITERATIONS` for longer local
3535
soak runs.
36+
- UDP response-rate limiting no longer scans the full tracked-source map on
37+
every response. The route now uses a current-window generation and clears it
38+
only when the second changes.
39+
- UDP passive-health fallback selection now scans at most the configured
40+
upstream count instead of the total weighted slot count when members are
41+
ejected.
3642

3743
## Compatibility
3844

src/udp_proxy.rs

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const UDP_DROP_LOG_INTERVAL_MILLIS: u64 = 1_000;
2121
const UDP_RESPONSE_RATE_TRACKED_SOURCES_FLOOR: usize = 4_096;
2222

2323
type UdpSourceSessions = Arc<Mutex<HashMap<IpAddr, usize>>>;
24-
type UdpResponseRateWindows = Arc<Mutex<HashMap<IpAddr, UdpResponseRateWindow>>>;
24+
type UdpResponseRateWindows = Arc<Mutex<UdpResponseRateState>>;
2525

2626
pub(crate) fn udp_services_from_config(config: &Config) -> io::Result<Vec<UdpProxyService>> {
2727
if !config.udp.enabled {
@@ -253,12 +253,12 @@ impl UdpProxyApp {
253253
.iter()
254254
.filter_map(|listen| listen.parse::<SocketAddr>().ok())
255255
.any(|listen| !listen.ip().is_loopback())
256-
&& route.mode == UdpRouteMode::DnsLoadBalance
257256
{
258257
log::warn!(
259258
target: "fluxheim::security",
260-
"UDP route {} listens on a non-loopback address in dns-load-balance mode; verify ingress filtering and response-rate limits before public exposure",
261-
route.name
259+
"UDP route {} ({:?} mode) listens on a non-loopback address; verify ingress filtering and response-rate limits before public exposure",
260+
route.name,
261+
route.mode
262262
);
263263
}
264264

@@ -278,7 +278,7 @@ impl UdpProxyApp {
278278
response_rate_tracked_sources,
279279
active_sessions: Arc::new(AtomicUsize::new(0)),
280280
source_sessions: Arc::new(Mutex::new(HashMap::new())),
281-
response_rate_windows: Arc::new(Mutex::new(HashMap::new())),
281+
response_rate_windows: Arc::new(Mutex::new(UdpResponseRateState::default())),
282282
datagrams_total: AtomicU64::new(0),
283283
responses_total: AtomicU64::new(0),
284284
drops_total: AtomicU64::new(0),
@@ -370,24 +370,28 @@ impl UdpProxyApp {
370370
fn select_upstream(&self) -> &RuntimeUdpUpstream {
371371
let start = self.next_upstream.fetch_add(1, Ordering::Relaxed) % self.weight_total;
372372
let now = udp_log_millis();
373-
for offset in 0..self.weight_total {
374-
let slot = (start + offset) % self.weight_total;
375-
let upstream = self.upstream_for_weight_slot(slot);
376-
if upstream.ready(now) {
377-
return upstream;
373+
let start_index = self.upstream_index_for_weight_slot(start);
374+
for offset in 0..self.upstreams.len() {
375+
let index = (start_index + offset) % self.upstreams.len();
376+
if self.upstreams[index].ready(now) {
377+
return &self.upstreams[index];
378378
}
379379
}
380380
self.upstream_for_weight_slot(start)
381381
}
382382

383-
fn upstream_for_weight_slot(&self, mut slot: usize) -> &RuntimeUdpUpstream {
384-
for upstream in self.upstreams.iter() {
383+
fn upstream_for_weight_slot(&self, slot: usize) -> &RuntimeUdpUpstream {
384+
&self.upstreams[self.upstream_index_for_weight_slot(slot)]
385+
}
386+
387+
fn upstream_index_for_weight_slot(&self, mut slot: usize) -> usize {
388+
for (index, upstream) in self.upstreams.iter().enumerate() {
385389
if slot < upstream.weight {
386-
return upstream;
390+
return index;
387391
}
388392
slot -= upstream.weight;
389393
}
390-
&self.upstreams[0]
394+
0
391395
}
392396

393397
async fn forward_request_response(
@@ -469,19 +473,20 @@ impl UdpProxyApp {
469473
return true;
470474
}
471475
let now_secs = udp_log_millis() / 1_000;
472-
let mut windows = self.lock_response_rate_windows();
473-
windows.retain(|_, window| window.window_secs == now_secs);
474-
if !windows.contains_key(&source) && windows.len() >= self.response_rate_tracked_sources {
475-
return false;
476+
let mut state = self.lock_response_rate_windows();
477+
if state.window_secs != now_secs {
478+
state.window_secs = now_secs;
479+
state.windows.clear();
476480
}
477-
let window = windows.entry(source).or_insert(UdpResponseRateWindow {
478-
window_secs: now_secs,
479-
count: 0,
480-
});
481-
if window.window_secs != now_secs {
482-
window.window_secs = now_secs;
483-
window.count = 0;
481+
if !state.windows.contains_key(&source)
482+
&& state.windows.len() >= self.response_rate_tracked_sources
483+
{
484+
return false;
484485
}
486+
let window = state
487+
.windows
488+
.entry(source)
489+
.or_insert(UdpResponseRateWindow { count: 0 });
485490
if window.count >= self.max_responses_per_source_per_second {
486491
return false;
487492
}
@@ -517,6 +522,8 @@ impl UdpProxyApp {
517522
}
518523

519524
fn lock_source_sessions(&self) -> std::sync::MutexGuard<'_, HashMap<IpAddr, usize>> {
525+
// Poisoning would mean per-source UDP accounting is no longer
526+
// trustworthy. Keep this critical section free of panic-capable work.
520527
match self.source_sessions.lock() {
521528
Ok(guard) => guard,
522529
Err(_) => {
@@ -530,9 +537,9 @@ impl UdpProxyApp {
530537
}
531538
}
532539

533-
fn lock_response_rate_windows(
534-
&self,
535-
) -> std::sync::MutexGuard<'_, HashMap<IpAddr, UdpResponseRateWindow>> {
540+
fn lock_response_rate_windows(&self) -> std::sync::MutexGuard<'_, UdpResponseRateState> {
541+
// Poisoning would mean UDP response-rate accounting is no longer
542+
// trustworthy. Keep this critical section free of panic-capable work.
536543
match self.response_rate_windows.lock() {
537544
Ok(guard) => guard,
538545
Err(_) => {
@@ -625,9 +632,14 @@ impl RuntimeUdpUpstream {
625632
}
626633
}
627634

635+
#[derive(Debug, Default)]
636+
struct UdpResponseRateState {
637+
window_secs: u64,
638+
windows: HashMap<IpAddr, UdpResponseRateWindow>,
639+
}
640+
628641
#[derive(Debug, Clone, Copy)]
629642
struct UdpResponseRateWindow {
630-
window_secs: u64,
631643
count: usize,
632644
}
633645

@@ -654,6 +666,8 @@ impl Drop for UdpSessionSlot {
654666
);
655667
}
656668
if let Some((sessions, source)) = &self.source_counter {
669+
// Poisoning would mean per-source UDP accounting is no longer
670+
// trustworthy. Keep this critical section free of panic-capable work.
657671
let mut sessions = match sessions.lock() {
658672
Ok(guard) => guard,
659673
Err(_) => {
@@ -894,6 +908,29 @@ mod tests {
894908
assert!(!app.allow_response_to_source(source));
895909
}
896910

911+
#[test]
912+
fn udp_response_rate_limit_rolls_generation_without_per_packet_scan() {
913+
let mut route = route("127.0.0.1:53".to_owned(), UdpRouteMode::DnsLoadBalance);
914+
route.max_responses_per_source_per_second = 2;
915+
let app = UdpProxyApp::from_config(&route).unwrap();
916+
let old_source = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 2));
917+
let new_source = IpAddr::V4(Ipv4Addr::LOCALHOST);
918+
919+
{
920+
let mut state = app.lock_response_rate_windows();
921+
state.window_secs = (super::udp_log_millis() / 1_000).saturating_sub(1);
922+
state
923+
.windows
924+
.insert(old_source, super::UdpResponseRateWindow { count: 2 });
925+
}
926+
927+
assert!(app.allow_response_to_source(new_source));
928+
let state = app.lock_response_rate_windows();
929+
assert_eq!(state.windows.len(), 1);
930+
assert!(state.windows.contains_key(&new_source));
931+
assert!(!state.windows.contains_key(&old_source));
932+
}
933+
897934
#[test]
898935
fn udp_selection_skips_passively_ejected_upstream() {
899936
let mut route = route("127.0.0.1:53".to_owned(), UdpRouteMode::DnsLoadBalance);

0 commit comments

Comments
 (0)