@@ -125,6 +125,18 @@ pub struct PeerManager {
125125 endpoint_to_ip : Arc < RwLock < HashMap < SocketAddr , Ipv4Addr > > > ,
126126 /// Subnet routes: container/VM IP → host peer IP (for routing to containers on remote nodes)
127127 subnet_routes : Arc < RwLock < HashMap < Ipv4Addr , Ipv4Addr > > > ,
128+ /// CIDR subnet routes: (network, prefix, gateway WolfNet IP). Sorted
129+ /// longest-prefix first so find_subnet_match can return on the
130+ /// first hit. Populated from /var/run/wolfnet/subnet-routes.json,
131+ /// which WolfStack writes when its WolfRouter SubnetRoute config
132+ /// changes. Without this, packets read from the TUN whose dest IP
133+ /// matches a kernel route via wolfnet0 (e.g. a remote LAN like
134+ /// 10.10.0.0/16 reachable via peer 10.100.10.30) have no per-peer
135+ /// destination — TUN devices have no link layer, so the kernel's
136+ /// "next-hop" hint is meaningless to userspace — and the packet
137+ /// would either get dropped (no peer match) or sent to the first
138+ /// auto-gateway peer (often the wrong one).
139+ subnet_routes_cidrs : Arc < RwLock < Vec < ( Ipv4Addr , u8 , Ipv4Addr ) > > > ,
128140 /// IPs purged via SIGHUP — blocked from PEX re-addition until daemon restart
129141 purged_ips : Arc < RwLock < std:: collections:: HashSet < Ipv4Addr > > > ,
130142}
@@ -136,6 +148,7 @@ impl PeerManager {
136148 id_to_ip : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
137149 endpoint_to_ip : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
138150 subnet_routes : Arc :: new ( RwLock :: new ( HashMap :: new ( ) ) ) ,
151+ subnet_routes_cidrs : Arc :: new ( RwLock :: new ( Vec :: new ( ) ) ) ,
139152 purged_ips : Arc :: new ( RwLock :: new ( std:: collections:: HashSet :: new ( ) ) ) ,
140153 }
141154 }
@@ -359,6 +372,87 @@ impl PeerManager {
359372 }
360373 }
361374
375+ /// Load CIDR-based subnet routes from a JSON file (cidr → gateway WolfNet IP).
376+ /// Called on startup and on SIGHUP. WolfStack writes this file from its
377+ /// WolfRouter SubnetRoute config so userspace can do longest-prefix
378+ /// matching for packets whose dest doesn't match any peer or any
379+ /// container exact-IP route.
380+ ///
381+ /// Behaviour:
382+ /// • File missing → clear the table (nothing should be routed).
383+ /// • JSON parse fails → keep the previous table (don't blackhole
384+ /// traffic on a transient bad write). Matches load_routes.
385+ /// • Parse OK → replace the table with the parsed contents,
386+ /// sorted longest-prefix-first.
387+ pub fn load_subnet_routes ( & self , path : & std:: path:: Path ) {
388+ let content = match std:: fs:: read_to_string ( path) {
389+ Ok ( c) => c,
390+ Err ( _) => {
391+ // File missing — clear the table so deleted CIDRs go
392+ // away. (load_routes returns without clearing here, but
393+ // for subnet routes "no file" really does mean "no
394+ // routes configured", and stale entries would cause
395+ // wrong routing decisions.)
396+ self . subnet_routes_cidrs . write ( ) . unwrap ( ) . clear ( ) ;
397+ return ;
398+ }
399+ } ;
400+ let map: HashMap < String , String > = match serde_json:: from_str ( & content) {
401+ Ok ( m) => m,
402+ Err ( _) => return , // keep existing table on parse failure
403+ } ;
404+
405+ let mut parsed: Vec < ( Ipv4Addr , u8 , Ipv4Addr ) > = Vec :: new ( ) ;
406+ for ( cidr, gw_str) in & map {
407+ let ( net_str, prefix_str) = match cidr. split_once ( '/' ) {
408+ Some ( p) => p,
409+ None => continue ,
410+ } ;
411+ let net: Ipv4Addr = match net_str. parse ( ) {
412+ Ok ( n) => n,
413+ Err ( _) => continue ,
414+ } ;
415+ let prefix: u8 = match prefix_str. parse ( ) {
416+ Ok ( p) if p <= 32 => p,
417+ _ => continue ,
418+ } ;
419+ let gateway: Ipv4Addr = match gw_str. parse ( ) {
420+ Ok ( g) => g,
421+ Err ( _) => continue ,
422+ } ;
423+ parsed. push ( ( net, prefix, gateway) ) ;
424+ }
425+ // Sort longest prefix first so find_subnet_match returns on the
426+ // first hit (longest-prefix-match semantics, like the kernel).
427+ parsed. sort_by ( |a, b| b. 1 . cmp ( & a. 1 ) ) ;
428+ * self . subnet_routes_cidrs . write ( ) . unwrap ( ) = parsed;
429+ }
430+
431+ /// Longest-prefix-match against the loaded CIDR subnet routes.
432+ /// Returns the gateway WolfNet IP for the most specific configured
433+ /// CIDR that contains dest_ip, or None if no CIDR matches.
434+ pub fn find_subnet_match ( & self , dest_ip : & Ipv4Addr ) -> Option < Ipv4Addr > {
435+ let dest_u32 = u32:: from_be_bytes ( dest_ip. octets ( ) ) ;
436+ let table = self . subnet_routes_cidrs . read ( ) . unwrap ( ) ;
437+ for ( net, prefix, gw) in table. iter ( ) {
438+ // /0 means "match everything" — useful as a default route.
439+ // /32 is a single host. Anything in between uses a normal
440+ // network mask.
441+ let mask: u32 = if * prefix == 0 {
442+ 0
443+ } else if * prefix >= 32 {
444+ u32:: MAX
445+ } else {
446+ u32:: MAX << ( 32 - * prefix as u32 )
447+ } ;
448+ let net_u32 = u32:: from_be_bytes ( net. octets ( ) ) ;
449+ if ( dest_u32 & mask) == ( net_u32 & mask) {
450+ return Some ( * gw) ;
451+ }
452+ }
453+ None
454+ }
455+
362456 /// Get peer count
363457 pub fn count ( & self ) -> usize {
364458 self . peers_by_ip . read ( ) . unwrap ( ) . len ( )
@@ -494,3 +588,151 @@ impl PeerManager {
494588 } ) . collect ( )
495589 }
496590}
591+
592+ #[ cfg( test) ]
593+ mod subnet_match_tests {
594+ use super :: * ;
595+
596+ /// Inject a hand-built CIDR table for testing without touching disk.
597+ fn install ( pm : & PeerManager , entries : Vec < ( & str , u8 , & str ) > ) {
598+ let mut parsed: Vec < ( Ipv4Addr , u8 , Ipv4Addr ) > = entries
599+ . into_iter ( )
600+ . map ( |( net, prefix, gw) | ( net. parse ( ) . unwrap ( ) , prefix, gw. parse ( ) . unwrap ( ) ) )
601+ . collect ( ) ;
602+ parsed. sort_by ( |a, b| b. 1 . cmp ( & a. 1 ) ) ;
603+ * pm. subnet_routes_cidrs . write ( ) . unwrap ( ) = parsed;
604+ }
605+
606+ #[ test]
607+ fn matches_exact_ip_in_slash16 ( ) {
608+ let pm = PeerManager :: new ( ) ;
609+ install ( & pm, vec ! [ ( "10.10.0.0" , 16 , "10.100.10.30" ) ] ) ;
610+ let gw = pm. find_subnet_match ( & "10.10.10.10" . parse ( ) . unwrap ( ) ) ;
611+ assert_eq ! ( gw, Some ( "10.100.10.30" . parse( ) . unwrap( ) ) ) ;
612+ }
613+
614+ #[ test]
615+ fn no_match_when_outside_subnet ( ) {
616+ let pm = PeerManager :: new ( ) ;
617+ install ( & pm, vec ! [ ( "10.10.0.0" , 16 , "10.100.10.30" ) ] ) ;
618+ let gw = pm. find_subnet_match ( & "10.11.0.5" . parse ( ) . unwrap ( ) ) ;
619+ assert_eq ! ( gw, None ) ;
620+ }
621+
622+ #[ test]
623+ fn empty_table_returns_none ( ) {
624+ let pm = PeerManager :: new ( ) ;
625+ let gw = pm. find_subnet_match ( & "10.10.10.10" . parse ( ) . unwrap ( ) ) ;
626+ assert_eq ! ( gw, None ) ;
627+ }
628+
629+ #[ test]
630+ fn longest_prefix_wins ( ) {
631+ let pm = PeerManager :: new ( ) ;
632+ // /16 covers 10.10.0.0/16 → gw A. A more-specific /24 inside it
633+ // (10.10.5.0/24) → gw B. Anything in 10.10.5.x must hit B,
634+ // anything else in 10.10.x.x must hit A.
635+ install ( & pm, vec ! [
636+ ( "10.10.0.0" , 16 , "10.100.0.1" ) ,
637+ ( "10.10.5.0" , 24 , "10.100.0.2" ) ,
638+ ] ) ;
639+ assert_eq ! (
640+ pm. find_subnet_match( & "10.10.5.10" . parse( ) . unwrap( ) ) ,
641+ Some ( "10.100.0.2" . parse( ) . unwrap( ) )
642+ ) ;
643+ assert_eq ! (
644+ pm. find_subnet_match( & "10.10.99.99" . parse( ) . unwrap( ) ) ,
645+ Some ( "10.100.0.1" . parse( ) . unwrap( ) )
646+ ) ;
647+ }
648+
649+ #[ test]
650+ fn slash_32_exact_host ( ) {
651+ let pm = PeerManager :: new ( ) ;
652+ install ( & pm, vec ! [ ( "192.168.5.42" , 32 , "10.100.0.5" ) ] ) ;
653+ assert_eq ! (
654+ pm. find_subnet_match( & "192.168.5.42" . parse( ) . unwrap( ) ) ,
655+ Some ( "10.100.0.5" . parse( ) . unwrap( ) )
656+ ) ;
657+ assert_eq ! (
658+ pm. find_subnet_match( & "192.168.5.43" . parse( ) . unwrap( ) ) ,
659+ None
660+ ) ;
661+ }
662+
663+ #[ test]
664+ fn slash_zero_default_route ( ) {
665+ let pm = PeerManager :: new ( ) ;
666+ // /0 = match everything. Useful as a full-tunnel default.
667+ install ( & pm, vec ! [ ( "0.0.0.0" , 0 , "10.100.0.99" ) ] ) ;
668+ assert_eq ! (
669+ pm. find_subnet_match( & "8.8.8.8" . parse( ) . unwrap( ) ) ,
670+ Some ( "10.100.0.99" . parse( ) . unwrap( ) )
671+ ) ;
672+ }
673+
674+ #[ test]
675+ fn slash_24_boundary ( ) {
676+ let pm = PeerManager :: new ( ) ;
677+ install ( & pm, vec ! [ ( "192.168.1.0" , 24 , "10.100.0.7" ) ] ) ;
678+ assert_eq ! (
679+ pm. find_subnet_match( & "192.168.1.0" . parse( ) . unwrap( ) ) ,
680+ Some ( "10.100.0.7" . parse( ) . unwrap( ) )
681+ ) ;
682+ assert_eq ! (
683+ pm. find_subnet_match( & "192.168.1.255" . parse( ) . unwrap( ) ) ,
684+ Some ( "10.100.0.7" . parse( ) . unwrap( ) )
685+ ) ;
686+ assert_eq ! (
687+ pm. find_subnet_match( & "192.168.2.0" . parse( ) . unwrap( ) ) ,
688+ None
689+ ) ;
690+ }
691+
692+ #[ test]
693+ fn loaded_table_is_sorted_longest_first ( ) {
694+ // Verifies load_subnet_routes really does sort. Even if the
695+ // JSON happens to list /16 before /24, find_subnet_match must
696+ // still pick the /24 for IPs inside it.
697+ let pm = PeerManager :: new ( ) ;
698+ let tmp = std:: env:: temp_dir ( ) . join ( "wolfnet-subnet-test.json" ) ;
699+ std:: fs:: write (
700+ & tmp,
701+ r#"{"10.10.0.0/16":"10.100.0.1","10.10.5.0/24":"10.100.0.2"}"# ,
702+ ) . unwrap ( ) ;
703+ pm. load_subnet_routes ( & tmp) ;
704+ assert_eq ! (
705+ pm. find_subnet_match( & "10.10.5.5" . parse( ) . unwrap( ) ) ,
706+ Some ( "10.100.0.2" . parse( ) . unwrap( ) )
707+ ) ;
708+ assert_eq ! (
709+ pm. find_subnet_match( & "10.10.99.5" . parse( ) . unwrap( ) ) ,
710+ Some ( "10.100.0.1" . parse( ) . unwrap( ) )
711+ ) ;
712+ let _ = std:: fs:: remove_file ( & tmp) ;
713+ }
714+
715+ #[ test]
716+ fn missing_file_clears_table ( ) {
717+ let pm = PeerManager :: new ( ) ;
718+ install ( & pm, vec ! [ ( "10.10.0.0" , 16 , "10.100.0.1" ) ] ) ;
719+ let nonexistent = std:: path:: PathBuf :: from ( "/tmp/wolfnet-this-file-does-not-exist-zzz.json" ) ;
720+ pm. load_subnet_routes ( & nonexistent) ;
721+ assert_eq ! ( pm. find_subnet_match( & "10.10.10.10" . parse( ) . unwrap( ) ) , None ) ;
722+ }
723+
724+ #[ test]
725+ fn malformed_json_keeps_table ( ) {
726+ let pm = PeerManager :: new ( ) ;
727+ install ( & pm, vec ! [ ( "10.10.0.0" , 16 , "10.100.0.1" ) ] ) ;
728+ let tmp = std:: env:: temp_dir ( ) . join ( "wolfnet-subnet-malformed.json" ) ;
729+ std:: fs:: write ( & tmp, "{ this is not json }" ) . unwrap ( ) ;
730+ pm. load_subnet_routes ( & tmp) ;
731+ // Existing table preserved.
732+ assert_eq ! (
733+ pm. find_subnet_match( & "10.10.10.10" . parse( ) . unwrap( ) ) ,
734+ Some ( "10.100.0.1" . parse( ) . unwrap( ) )
735+ ) ;
736+ let _ = std:: fs:: remove_file ( & tmp) ;
737+ }
738+ }
0 commit comments