@@ -21,7 +21,7 @@ const UDP_DROP_LOG_INTERVAL_MILLIS: u64 = 1_000;
2121const UDP_RESPONSE_RATE_TRACKED_SOURCES_FLOOR : usize = 4_096 ;
2222
2323type UdpSourceSessions = Arc < Mutex < HashMap < IpAddr , usize > > > ;
24- type UdpResponseRateWindows = Arc < Mutex < HashMap < IpAddr , UdpResponseRateWindow > > > ;
24+ type UdpResponseRateWindows = Arc < Mutex < UdpResponseRateState > > ;
2525
2626pub ( 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 ) ]
629642struct 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