Skip to content

Commit ee674b3

Browse files
committed
feat: add configurable subnet limit overrides for IP diversity
Introduces optional floor and ceiling overrides for IPv4 and IPv6 subnet limits: - `ipv4_limit_floor` and `ipv4_limit_ceiling` - `ipv6_limit_floor` and `ipv6_limit_ceiling` Overrides allow stricter or more permissive network configurations by adjusting computed dynamic limits. For example, `floor` ensures a minimum limit, while `ceiling` enforces a strict upper cap. Includes updates to the `IPDiversityConfig` structure, relevant test cases, and dynamic adjustment logic with the new `clamp_limit` utility function.
1 parent 1048aaf commit ee674b3

5 files changed

Lines changed: 332 additions & 76 deletions

File tree

src/adaptive/trust.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -175,10 +175,10 @@ impl TrustEngine {
175175
#[cfg(test)]
176176
pub async fn simulate_elapsed(&self, node_id: &PeerId, elapsed: std::time::Duration) {
177177
let mut peers = self.peers.write().await;
178-
if let Some(trust) = peers.get_mut(node_id) {
179-
if let Some(past) = Instant::now().checked_sub(elapsed) {
180-
trust.last_updated = past;
181-
}
178+
if let Some(trust) = peers.get_mut(node_id)
179+
&& let Some(past) = Instant::now().checked_sub(elapsed)
180+
{
181+
trust.last_updated = past;
182182
}
183183
}
184184
}

src/bootstrap/manager.rs

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -534,12 +534,16 @@ mod tests {
534534
// Very restrictive rate limiting - only 2 joins per /24 subnet per hour
535535
// Use permissive diversity config to isolate rate limiting behavior
536536
let diversity_config = IPDiversityConfig {
537-
max_nodes_per_64: 100,
538-
max_nodes_per_48: 100,
539-
max_nodes_per_32: 100,
537+
max_nodes_per_ipv6_64: 100,
538+
max_nodes_per_ipv6_48: 100,
539+
max_nodes_per_ipv6_32: 100,
540540
max_nodes_per_ipv4_32: None, // No static cap for rate limit test
541541
max_nodes_per_ipv4_24: None,
542542
max_nodes_per_ipv4_16: None,
543+
ipv4_limit_floor: None,
544+
ipv4_limit_ceiling: None,
545+
ipv6_limit_floor: None,
546+
ipv6_limit_ceiling: None,
543547
max_per_ip_cap: 100,
544548
max_network_fraction: 1.0,
545549
max_nodes_per_asn: 1000,

src/dht/core_engine.rs

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,20 @@ fn mask_ipv6(addr: Ipv6Addr, prefix_len: u8) -> Ipv6Addr {
299299
Ipv6Addr::from(bits & mask)
300300
}
301301

302+
/// Apply optional floor/ceiling overrides to a computed subnet limit.
303+
/// Floor is applied first (raising the value), then ceiling (lowering it).
304+
/// When both are set, ceiling wins if floor > ceiling.
305+
fn clamp_limit(limit: usize, floor: Option<usize>, ceiling: Option<usize>) -> usize {
306+
let mut result = limit;
307+
if let Some(f) = floor {
308+
result = result.max(f);
309+
}
310+
if let Some(c) = ceiling {
311+
result = result.min(c);
312+
}
313+
result
314+
}
315+
302316
/// Default maximum nodes per geographic region. Matches
303317
/// `GeographicRoutingConfig::max_nodes_per_region` default.
304318
const GEO_DEFAULT_MAX_PER_REGION: usize = 50;
@@ -546,19 +560,28 @@ impl DhtCoreEngine {
546560
match candidate_ip {
547561
IpAddr::V4(v4) => {
548562
let cfg = &self.ip_diversity_config;
549-
let limit_32 = cfg
550-
.max_nodes_per_ipv4_32
551-
.map_or(per_ip, |cap| cap.min(per_ip));
552-
let limit_24 = cfg
553-
.max_nodes_per_ipv4_24
554-
.map_or(per_ip * SUBNET_NARROW_MULTIPLIER, |cap| {
555-
cap.min(per_ip * SUBNET_NARROW_MULTIPLIER)
556-
});
557-
let limit_16 = cfg
558-
.max_nodes_per_ipv4_16
559-
.map_or(per_ip * SUBNET_MEDIUM_MULTIPLIER, |cap| {
560-
cap.min(per_ip * SUBNET_MEDIUM_MULTIPLIER)
561-
});
563+
let limit_32 = clamp_limit(
564+
cfg.max_nodes_per_ipv4_32
565+
.map_or(per_ip, |cap| cap.min(per_ip)),
566+
cfg.ipv4_limit_floor,
567+
cfg.ipv4_limit_ceiling,
568+
);
569+
let limit_24 = clamp_limit(
570+
cfg.max_nodes_per_ipv4_24
571+
.map_or(per_ip * SUBNET_NARROW_MULTIPLIER, |cap| {
572+
cap.min(per_ip * SUBNET_NARROW_MULTIPLIER)
573+
}),
574+
cfg.ipv4_limit_floor,
575+
cfg.ipv4_limit_ceiling,
576+
);
577+
let limit_16 = clamp_limit(
578+
cfg.max_nodes_per_ipv4_16
579+
.map_or(per_ip * SUBNET_MEDIUM_MULTIPLIER, |cap| {
580+
cap.min(per_ip * SUBNET_MEDIUM_MULTIPLIER)
581+
}),
582+
cfg.ipv4_limit_floor,
583+
cfg.ipv4_limit_ceiling,
584+
);
562585

563586
if v4_counts.exact >= limit_32 {
564587
return Err(anyhow!(
@@ -580,11 +603,21 @@ impl DhtCoreEngine {
580603
}
581604
IpAddr::V6(_) => {
582605
let cfg = &self.ip_diversity_config;
583-
let limit_64 =
584-
std::cmp::min(cfg.max_nodes_per_64, per_ip * SUBNET_NARROW_MULTIPLIER);
585-
let limit_48 =
586-
std::cmp::min(cfg.max_nodes_per_48, per_ip * SUBNET_MEDIUM_MULTIPLIER);
587-
let limit_32 = std::cmp::min(cfg.max_nodes_per_32, per_ip * SUBNET_WIDE_MULTIPLIER);
606+
let limit_64 = clamp_limit(
607+
std::cmp::min(cfg.max_nodes_per_ipv6_64, per_ip * SUBNET_NARROW_MULTIPLIER),
608+
cfg.ipv6_limit_floor,
609+
cfg.ipv6_limit_ceiling,
610+
);
611+
let limit_48 = clamp_limit(
612+
std::cmp::min(cfg.max_nodes_per_ipv6_48, per_ip * SUBNET_MEDIUM_MULTIPLIER),
613+
cfg.ipv6_limit_floor,
614+
cfg.ipv6_limit_ceiling,
615+
);
616+
let limit_32 = clamp_limit(
617+
std::cmp::min(cfg.max_nodes_per_ipv6_32, per_ip * SUBNET_WIDE_MULTIPLIER),
618+
cfg.ipv6_limit_floor,
619+
cfg.ipv6_limit_ceiling,
620+
);
588621

589622
if v6_counts.slash_64 >= limit_64 {
590623
return Err(anyhow!("IP diversity: /64 limit ({limit_64}) exceeded"));

src/dht/security_tests.rs

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use crate::PeerId;
22
use crate::dht::core_engine::{DhtCoreEngine, NodeCapacity, NodeInfo};
3+
use crate::security::IPDiversityConfig;
34
use std::time::SystemTime;
45

56
#[tokio::test]
@@ -241,3 +242,181 @@ async fn test_geographic_diversity_counts_region_nodes() -> anyhow::Result<()> {
241242
// All should succeed since geographic limit is 50 and we're only adding 3
242243
Ok(())
243244
}
245+
246+
#[tokio::test]
247+
async fn test_ipv4_floor_override_raises_limit() -> anyhow::Result<()> {
248+
// Default dynamic limit for a small network is 1 per /32.
249+
// Setting ipv4_limit_floor = 3 should allow 3 nodes on the same IP.
250+
let mut engine = DhtCoreEngine::new_for_tests(PeerId::random())?;
251+
engine.set_ip_diversity_config(IPDiversityConfig {
252+
ipv4_limit_floor: Some(3),
253+
..IPDiversityConfig::default()
254+
});
255+
256+
for i in 0..3u8 {
257+
let node = NodeInfo {
258+
id: PeerId::random(),
259+
address: format!("/ip4/192.168.1.1/udp/{}/quic", 9000 + u16::from(i))
260+
.parse()
261+
.unwrap(),
262+
last_seen: SystemTime::now(),
263+
capacity: NodeCapacity::default(),
264+
};
265+
engine.add_node(node).await?;
266+
}
267+
268+
// Fourth node should fail (floor is 3, so limit is 3)
269+
let node4 = NodeInfo {
270+
id: PeerId::random(),
271+
address: "/ip4/192.168.1.1/udp/9003/quic".parse().unwrap(),
272+
last_seen: SystemTime::now(),
273+
capacity: NodeCapacity::default(),
274+
};
275+
let result = engine.add_node(node4).await;
276+
assert!(result.is_err());
277+
assert!(result.unwrap_err().to_string().contains("IP diversity:"));
278+
279+
Ok(())
280+
}
281+
282+
#[tokio::test]
283+
async fn test_ipv4_ceiling_override_lowers_limit() -> anyhow::Result<()> {
284+
// With a large network, dynamic per-IP would be high.
285+
// Setting ipv4_limit_ceiling = 1 should cap all subnet limits at 1.
286+
let mut engine = DhtCoreEngine::new_for_tests(PeerId::random())?;
287+
engine.set_ip_diversity_config(IPDiversityConfig {
288+
ipv4_limit_ceiling: Some(1),
289+
// Raise the dynamic limit so ceiling actually constrains it
290+
max_per_ip_cap: 100,
291+
max_network_fraction: 1.0,
292+
..IPDiversityConfig::default()
293+
});
294+
295+
// First node on 10.0.1.1
296+
let node1 = NodeInfo {
297+
id: PeerId::random(),
298+
address: "/ip4/10.0.1.1/udp/9000/quic".parse().unwrap(),
299+
last_seen: SystemTime::now(),
300+
capacity: NodeCapacity::default(),
301+
};
302+
engine.add_node(node1).await?;
303+
304+
// Second node on different IP but same /24 — should fail because ceiling=1
305+
let node2 = NodeInfo {
306+
id: PeerId::random(),
307+
address: "/ip4/10.0.1.2/udp/9000/quic".parse().unwrap(),
308+
last_seen: SystemTime::now(),
309+
capacity: NodeCapacity::default(),
310+
};
311+
let result = engine.add_node(node2).await;
312+
assert!(result.is_err());
313+
assert!(result.unwrap_err().to_string().contains("IP diversity:"));
314+
315+
Ok(())
316+
}
317+
318+
#[tokio::test]
319+
async fn test_ipv6_floor_override_raises_limit() -> anyhow::Result<()> {
320+
// Default IPv6 /64 limit is 1. Setting ipv6_limit_floor = 5 should allow
321+
// 5 nodes in the same /64 subnet.
322+
let mut engine = DhtCoreEngine::new_for_tests(PeerId::random())?;
323+
engine.set_ip_diversity_config(IPDiversityConfig {
324+
ipv6_limit_floor: Some(5),
325+
..IPDiversityConfig::default()
326+
});
327+
328+
for i in 1..=5u128 {
329+
let node = NodeInfo {
330+
id: PeerId::random(),
331+
address: format!("/ip6/2001:db8::{i}/udp/9000/quic").parse().unwrap(),
332+
last_seen: SystemTime::now(),
333+
capacity: NodeCapacity::default(),
334+
};
335+
engine.add_node(node).await?;
336+
}
337+
338+
// Sixth node should fail
339+
let node6 = NodeInfo {
340+
id: PeerId::random(),
341+
address: "/ip6/2001:db8::6/udp/9000/quic".parse().unwrap(),
342+
last_seen: SystemTime::now(),
343+
capacity: NodeCapacity::default(),
344+
};
345+
let result = engine.add_node(node6).await;
346+
assert!(result.is_err());
347+
assert!(result.unwrap_err().to_string().contains("IP diversity:"));
348+
349+
Ok(())
350+
}
351+
352+
#[tokio::test]
353+
async fn test_ipv6_ceiling_override_lowers_limit() -> anyhow::Result<()> {
354+
// With permissive IPv6 config (max_nodes_per_ipv6_64 = usize::MAX),
355+
// setting ipv6_limit_ceiling = 2 should cap at 2.
356+
let mut engine = DhtCoreEngine::new_for_tests(PeerId::random())?;
357+
engine.set_ip_diversity_config(IPDiversityConfig {
358+
max_nodes_per_ipv6_64: usize::MAX,
359+
max_nodes_per_ipv6_48: usize::MAX,
360+
max_nodes_per_ipv6_32: usize::MAX,
361+
max_per_ip_cap: 100,
362+
max_network_fraction: 1.0,
363+
ipv6_limit_ceiling: Some(2),
364+
..IPDiversityConfig::default()
365+
});
366+
367+
let node1 = NodeInfo {
368+
id: PeerId::random(),
369+
address: "/ip6/2001:db8::1/udp/9000/quic".parse().unwrap(),
370+
last_seen: SystemTime::now(),
371+
capacity: NodeCapacity::default(),
372+
};
373+
engine.add_node(node1).await?;
374+
375+
let node2 = NodeInfo {
376+
id: PeerId::random(),
377+
address: "/ip6/2001:db8::2/udp/9000/quic".parse().unwrap(),
378+
last_seen: SystemTime::now(),
379+
capacity: NodeCapacity::default(),
380+
};
381+
engine.add_node(node2).await?;
382+
383+
// Third should fail due to ceiling
384+
let node3 = NodeInfo {
385+
id: PeerId::random(),
386+
address: "/ip6/2001:db8::3/udp/9000/quic".parse().unwrap(),
387+
last_seen: SystemTime::now(),
388+
capacity: NodeCapacity::default(),
389+
};
390+
let result = engine.add_node(node3).await;
391+
assert!(result.is_err());
392+
assert!(result.unwrap_err().to_string().contains("IP diversity:"));
393+
394+
Ok(())
395+
}
396+
397+
#[tokio::test]
398+
async fn test_no_override_preserves_dynamic_behavior() -> anyhow::Result<()> {
399+
// When no overrides are set, behavior should be identical to before.
400+
// Default dynamic limit for small network = 1 per /32, 3 per /24.
401+
let mut engine = DhtCoreEngine::new_for_tests(PeerId::random())?;
402+
403+
let node1 = NodeInfo {
404+
id: PeerId::random(),
405+
address: "/ip4/192.168.1.1/udp/9000/quic".parse().unwrap(),
406+
last_seen: SystemTime::now(),
407+
capacity: NodeCapacity::default(),
408+
};
409+
engine.add_node(node1).await?;
410+
411+
// Same IP should fail (dynamic limit = 1)
412+
let node2 = NodeInfo {
413+
id: PeerId::random(),
414+
address: "/ip4/192.168.1.1/udp/9001/quic".parse().unwrap(),
415+
last_seen: SystemTime::now(),
416+
capacity: NodeCapacity::default(),
417+
};
418+
let result = engine.add_node(node2).await;
419+
assert!(result.is_err());
420+
421+
Ok(())
422+
}

0 commit comments

Comments
 (0)