Skip to content

Commit 77c9348

Browse files
author
Paul C
committed
0.5.19: subnet routes — match WolfStack-advertised CIDRs in userspace
Pairs with WolfStack v22.1.0. Pre-fix, a kernel route on a consumer node like `ip route add 10.10.0.0/16 via 10.100.10.30 dev wolfnet0` was meaningless to wolfnetd: TUN devices have no link layer so the kernel's "next-hop" hint is invisible to userspace, and packets read off the TUN destined for a remote LAN had no per-peer destination. The dispatcher would either drop them (no peer matches the LAN IP) or send them to the first auto-gateway peer (often the wrong one). Sponsor klasSponsor 2026-04-28: diagnostics all-green at the OS level but pings to a remote LAN never replied because wolfnetd was dropping the packets at the consumer side before encapsulation. Adds CIDR subnet route support: - PeerManager.subnet_routes_cidrs: Vec<(net, prefix, gateway)>, sorted longest-prefix-first. - load_subnet_routes(path): parses /var/run/wolfnet/subnet-routes.json written by WolfStack. Missing file clears the table; malformed JSON keeps the previous table (so a transient bad write doesn't blackhole traffic). - find_subnet_match(dest_ip): longest-prefix-match. Inserted into both the outbound TUN dispatch (after find_route, before find_relay_for) and the inbound forward path (when this node is the configured gateway, write to TUN so the kernel's forwarding plumbing handles delivery to the LAN). Loaded on startup, on the existing 15s periodic reload, and on SIGHUP. WolfStack writes the file and SIGHUPs wolfnet whenever subnet routes change, so the table stays current without restart. Verified: 10/10 new unit tests for find_subnet_match (exact match, no match, empty table, longest-prefix-wins, /32 host, /0 default, /24 boundaries, JSON sort verification, missing file, malformed JSON), cargo build dev + release clean.
1 parent 4684aad commit 77c9348

4 files changed

Lines changed: 287 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "wolfnet"
3-
version = "0.5.18"
3+
version = "0.5.19"
44
edition = "2021"
55
authors = ["Wolf Software Systems Ltd"]
66
description = "Secure private mesh networking over the internet"

src/main.rs

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,14 @@ fn run_daemon(config_path: &PathBuf) {
511511
let routes_path = PathBuf::from("/var/run/wolfnet/routes.json");
512512
peer_manager.load_routes(&routes_path);
513513

514+
// Load CIDR-based subnet routes from WolfStack's WolfRouter config.
515+
// Without this, packets the kernel routes via wolfnet0 toward a
516+
// remote LAN (e.g. 10.10.0.0/16 advertised by peer 10.100.10.30)
517+
// can't be encapsulated to the right peer — TUN devices have no
518+
// link layer, so the kernel's "next-hop" hint is invisible to us.
519+
let subnet_routes_path = PathBuf::from("/var/run/wolfnet/subnet-routes.json");
520+
peer_manager.load_subnet_routes(&subnet_routes_path);
521+
514522
// Gateway mode is only enabled explicitly in config — a gateway is a node
515523
// that bridges networks and relays traffic between peers that can't see each other
516524
let is_gateway = config.network.gateway;
@@ -688,6 +696,20 @@ fn run_daemon(config_path: &PathBuf) {
688696
if routed { continue; }
689697
}
690698

699+
// CIDR subnet route — longest-prefix-match against the
700+
// CIDRs WolfStack advertised in subnet-routes.json. This
701+
// is what lets a kernel route like
702+
// `ip route add 10.10.0.0/16 via 10.100.10.30 dev wolfnet0`
703+
// actually do something: the kernel's next-hop hint is
704+
// invisible to a TUN, so we have to look the dest up
705+
// ourselves and encapsulate to the configured gateway.
706+
if let Some(gw_ip) = peer_manager.find_subnet_match(&dest_ip) {
707+
let routed = peer_manager.with_peer_by_ip(&gw_ip, |peer| {
708+
encrypt_and_send(&mut send_buf, pkt, peer, &my_peer_id, &socket)
709+
}).unwrap_or(false);
710+
if routed { continue; }
711+
}
712+
691713
if let Some(relay_ip) = peer_manager.find_relay_for(&dest_ip) {
692714
peer_manager.with_peer_by_ip(&relay_ip, |peer| {
693715
encrypt_and_send(&mut send_buf, pkt, peer, &my_peer_id, &socket);
@@ -793,6 +815,7 @@ fn run_daemon(config_path: &PathBuf) {
793815
}).unwrap_or(false);
794816

795817
if !forwarded {
818+
let mut handled = false;
796819
if let Some(host_ip) = peer_manager.find_route(&dest_ip) {
797820
if host_ip == wolfnet_ip {
798821
unsafe { libc::write(tun_fd, plaintext.as_ptr() as *const _, pt_len) };
@@ -801,6 +824,24 @@ fn run_daemon(config_path: &PathBuf) {
801824
encrypt_and_send(&mut send_buf, plaintext, host_peer, &my_peer_id, &socket);
802825
});
803826
}
827+
handled = true;
828+
}
829+
if !handled {
830+
// CIDR subnet route. If we're the configured
831+
// gateway, write to TUN so the kernel +
832+
// WolfStack's iptables plumbing forward the
833+
// packet out the LAN-side iface. Otherwise
834+
// re-encapsulate to whichever peer IS the
835+
// gateway.
836+
if let Some(gw_ip) = peer_manager.find_subnet_match(&dest_ip) {
837+
if gw_ip == wolfnet_ip {
838+
unsafe { libc::write(tun_fd, plaintext.as_ptr() as *const _, pt_len) };
839+
} else {
840+
peer_manager.with_peer_by_ip(&gw_ip, |gw_peer| {
841+
encrypt_and_send(&mut send_buf, plaintext, gw_peer, &my_peer_id, &socket);
842+
});
843+
}
844+
}
804845
}
805846
// else: drop — writing an unroutable packet back to TUN
806847
// creates an infinite routing loop (kernel routes it
@@ -883,6 +924,7 @@ fn run_daemon(config_path: &PathBuf) {
883924
// from WolfStack without needing SIGHUP
884925
if last_route_reload.elapsed() > Duration::from_secs(15) {
885926
peer_manager.load_routes(&routes_path);
927+
peer_manager.load_subnet_routes(&subnet_routes_path);
886928
last_route_reload = Instant::now();
887929
}
888930

@@ -967,6 +1009,7 @@ fn run_daemon(config_path: &PathBuf) {
9671009

9681010
// Also reload subnet routes
9691011
peer_manager.load_routes(&routes_path);
1012+
peer_manager.load_subnet_routes(&subnet_routes_path);
9701013
}
9711014
Err(e) => warn!("Config reload failed: {}", e),
9721015
}

src/peer.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)