mirror of
https://github.com/jmcorgan/fips.git
synced 2026-07-30 19:46:15 +00:00
Merge branch 'master' into next
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -209,6 +209,20 @@ with v0.4.x or earlier peers.
|
||||
when `pending_new_session().is_some()`, applying the same
|
||||
smaller-NodeAddr resolution rule. Mirrored to the FSP rekey msg1
|
||||
path for symmetry.
|
||||
- `SessionDatagram` hop-limit handling now follows IP semantics. Delivery to
|
||||
the addressed node is no longer TTL-gated, and a forwarder decrements before
|
||||
deciding rather than after, so a datagram that would leave with a TTL of zero
|
||||
is dropped instead of transmitted. Previously the TTL check ran ahead of the
|
||||
local-delivery test, so a datagram addressed to this node that arrived with
|
||||
TTL 0 was dropped, and a forwarder receiving a transit datagram at TTL 1
|
||||
transmitted it at TTL 0 for the next hop to discard, wasting one transmission
|
||||
per expiring datagram. The reachable radius is unchanged, because the two
|
||||
behaviors compensated exactly: a path of `h` links still delivers for any
|
||||
source TTL of `h` or more. During a rolling upgrade, an unupgraded forwarder
|
||||
feeding an upgraded destination delivers one hop further than either version
|
||||
does on its own; no version mix delivers less far. The `TtlExhausted` reject
|
||||
counter now charges at the node that makes the decision rather than at the
|
||||
hop after it.
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
//! SessionDatagram forwarding handler.
|
||||
//!
|
||||
//! Handles incoming SessionDatagram (0x00) link messages: decodes the
|
||||
//! envelope, enforces hop limits, performs coordinate cache warming from
|
||||
//! plaintext session-layer headers, routes to the next hop or delivers
|
||||
//! locally, and generates error signals on routing failure.
|
||||
//! envelope, performs coordinate cache warming from plaintext session-layer
|
||||
//! headers, pre-resolves the next hop for forwardable transit datagrams, and
|
||||
//! drives the routing core's outcome — local delivery when the datagram is
|
||||
//! addressed to this node, the transit hop-limit drop, the forward, or the
|
||||
//! error signal generated on routing failure.
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::reject::ForwardingReject;
|
||||
@@ -44,19 +46,21 @@ impl Node {
|
||||
|
||||
let my_addr = *self.node_addr();
|
||||
|
||||
// Coordinate cache warming from plaintext session-layer headers. Gated
|
||||
// on a non-exhausted TTL so a datagram the core will drop as
|
||||
// TTL-exhausted does not warm the cache, matching the pre-refactor
|
||||
// ordering (warming ran only after the TTL early-return).
|
||||
if datagram_ref.ttl != 0 {
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
}
|
||||
// Coordinate cache warming from plaintext session-layer headers. Runs
|
||||
// ahead of both the delivery and the TTL decisions the core makes: the
|
||||
// coords a peer put on the wire are equally valid whichever way those
|
||||
// go, and the only arrivals this newly warms from are those with an
|
||||
// exhausted TTL, whose every insert is already achievable at TTL 1.
|
||||
self.try_warm_coord_cache_ref(&datagram_ref);
|
||||
|
||||
// Pre-resolve the next hop only for genuine transit packets (TTL > 0
|
||||
// and not locally destined) so `find_next_hop`'s coord-cache LRU-touch
|
||||
// side effect keeps the same scope it had inline. Warming above has
|
||||
// already run, so the resolution observes freshly cached coords.
|
||||
let next_hop = if datagram_ref.ttl != 0 && datagram_ref.dest_addr != my_addr {
|
||||
// Pre-resolve the next hop only for datagrams the core can actually
|
||||
// forward: not locally destined, and carrying a TTL that survives the
|
||||
// decrement (`ttl > 1` — the shell-side mirror of the core's
|
||||
// would-leave-zero drop). This keeps `find_next_hop`'s coord-cache
|
||||
// LRU-touch side effect scoped to genuine forwards, as it was when the
|
||||
// TTL test ran inline ahead of it. Warming above has already run, so
|
||||
// the resolution observes freshly cached coords.
|
||||
let next_hop = if datagram_ref.dest_addr != my_addr && datagram_ref.ttl > 1 {
|
||||
self.resolve_next_hop(&datagram_ref.dest_addr)
|
||||
} else {
|
||||
None
|
||||
@@ -93,6 +97,7 @@ impl Node {
|
||||
debug!(
|
||||
src = %datagram_ref.src_addr,
|
||||
dest = %datagram_ref.dest_addr,
|
||||
ttl = datagram_ref.ttl,
|
||||
"SessionDatagram TTL exhausted, dropping"
|
||||
);
|
||||
}
|
||||
|
||||
+323
-10
@@ -42,22 +42,114 @@ async fn test_forwarding_hop_limit_exhausted() {
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
// No panic, no send (node has no peers)
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
1,
|
||||
"transit ttl=0 should be charged to TtlExhausted"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
0,
|
||||
"transit ttl=0 should never reach the routing step"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_hop_limit_one_drops_at_transit() {
|
||||
// ttl=1 means after decrement it becomes 0 — the datagram can
|
||||
// still be delivered this hop but would be dropped at the next.
|
||||
// decrement_ttl returns true (1 > 0), so the handler proceeds.
|
||||
async fn test_forwarding_ttl_one_local_delivery_is_not_gated() {
|
||||
// dest == self, so this is local delivery, not transit: the TTL gate
|
||||
// does not apply and the datagram is handed to the session layer.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let my_addr = *node.node_addr();
|
||||
let src = make_node_addr(0x01);
|
||||
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
// Should succeed — ttl=1 decrements to 0 but packet is still processed
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(fwd.delivered_packets.get(), 1, "ttl=1 should be delivered");
|
||||
assert_eq!(fwd.ttl_exhausted_packets.get(), 0);
|
||||
}
|
||||
|
||||
/// The shell's half of the acceptance: a datagram addressed to this node with
|
||||
/// ttl=0 reaches the session layer and is charged to `delivered`, not to the
|
||||
/// `TtlExhausted` reject. The shell has its own TTL-shaped gates ahead of the
|
||||
/// core, so the core-level test alone would not pin this.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_zero_local_delivery_is_not_gated() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let my_addr = *node.node_addr();
|
||||
let src = make_node_addr(0x01);
|
||||
let dg = SessionDatagram::new(src, my_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(0);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.delivered_packets.get(),
|
||||
1,
|
||||
"ttl=0 addressed to this node must still be delivered locally"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
0,
|
||||
"local delivery must not be charged to the TtlExhausted reject"
|
||||
);
|
||||
assert_eq!(fwd.drop_no_route_packets.get(), 0);
|
||||
}
|
||||
|
||||
/// The shell's half of the transit boundary: ttl=1 is charged to
|
||||
/// `TtlExhausted` and never reaches the routing step, and ttl=2 clears the
|
||||
/// gate — visible here as the no-route charge this peerless node produces
|
||||
/// once the datagram gets that far.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_one_transit_dropped_before_routing() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src = make_node_addr(0x01);
|
||||
let dest = make_node_addr(0x02);
|
||||
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
1,
|
||||
"transit ttl=1 must be dropped as TTL-exhausted, not forwarded"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
0,
|
||||
"transit ttl=1 must not reach the routing step"
|
||||
);
|
||||
assert_eq!(fwd.forwarded_packets.get(), 0);
|
||||
assert_eq!(fwd.delivered_packets.get(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_two_transit_clears_the_gate() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src = make_node_addr(0x01);
|
||||
let dest = make_node_addr(0x02);
|
||||
let dg = SessionDatagram::new(src, dest, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(2);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
let fwd = &node.metrics().forwarding;
|
||||
assert_eq!(
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
0,
|
||||
"transit ttl=2 must clear the TTL gate"
|
||||
);
|
||||
assert_eq!(
|
||||
fwd.drop_no_route_packets.get(),
|
||||
1,
|
||||
"transit ttl=2 should have reached the routing step and found no route"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Local delivery ---
|
||||
@@ -274,6 +366,104 @@ async fn test_coord_cache_warming_encrypted_msg_no_coords() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Cache warming is not gated on the TTL, case 1 of 2: a datagram addressed
|
||||
/// to this node that arrives already exhausted is delivered, and its
|
||||
/// plaintext coordinates still reach the cache.
|
||||
///
|
||||
/// The warming call sits ahead of the routing decision precisely so that it
|
||||
/// is unconditional. The other warming tests all run at the default TTL of 64
|
||||
/// and so cannot see a TTL-shaped gate around it; this one runs at zero.
|
||||
/// `SessionSetup` is used rather than a CP-flagged encrypted message because
|
||||
/// the local-delivery path caches coords from the latter itself, which would
|
||||
/// mask a suppressed warming call.
|
||||
#[tokio::test]
|
||||
async fn test_coord_cache_warming_ttl_zero_local_delivery() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let my_addr = *node.node_addr();
|
||||
let src_addr = make_node_addr(0x01);
|
||||
let root_addr = make_node_addr(0xF0);
|
||||
|
||||
let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![my_addr, root_addr]).unwrap();
|
||||
let setup = SessionSetup::new(src_coords, dest_coords);
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, my_addr, setup.encode()).with_ttl(0);
|
||||
let encoded = dg.encode();
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
|
||||
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.delivered_packets.get(),
|
||||
1,
|
||||
"ttl=0 addressed to this node must be delivered, not dropped"
|
||||
);
|
||||
let cached = node.coord_cache().get(&src_addr, now_ms);
|
||||
assert!(
|
||||
cached.is_some(),
|
||||
"warming must not be gated on the TTL: a delivered ttl=0 datagram \
|
||||
still carries usable coords"
|
||||
);
|
||||
assert_eq!(cached.unwrap().root_id(), &root_addr);
|
||||
}
|
||||
|
||||
/// Cache warming is not gated on the TTL, case 2 of 2: a transit datagram
|
||||
/// that is dropped for hop limit still contributes its plaintext coordinates.
|
||||
///
|
||||
/// This is the case the gate suppressed most visibly — the datagram never
|
||||
/// reaches any other code that could cache coords, so the assertion below is
|
||||
/// only satisfiable by the unconditional warming call. The `TtlExhausted`
|
||||
/// charge is asserted alongside it to show the drop did happen, so the test
|
||||
/// cannot be satisfied by the datagram merely surviving the TTL gate.
|
||||
#[tokio::test]
|
||||
async fn test_coord_cache_warming_ttl_zero_transit_drop() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src_addr = make_node_addr(0x01);
|
||||
let dest_addr = make_node_addr(0x02);
|
||||
let root_addr = make_node_addr(0xF0);
|
||||
|
||||
let src_coords = TreeCoordinate::from_addrs(vec![src_addr, root_addr]).unwrap();
|
||||
let dest_coords = TreeCoordinate::from_addrs(vec![dest_addr, root_addr]).unwrap();
|
||||
let setup = SessionSetup::new(src_coords, dest_coords);
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, dest_addr, setup.encode()).with_ttl(0);
|
||||
let encoded = dg.encode();
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
assert!(node.coord_cache().get(&src_addr, now_ms).is_none());
|
||||
assert!(node.coord_cache().get(&dest_addr, now_ms).is_none());
|
||||
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.ttl_exhausted_packets.get(),
|
||||
1,
|
||||
"the transit datagram should have been dropped for hop limit"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&src_addr, now_ms).is_some(),
|
||||
"a transit datagram dropped for hop limit must still warm the cache \
|
||||
with its source coords"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&dest_addr, now_ms).is_some(),
|
||||
"a transit datagram dropped for hop limit must still warm the cache \
|
||||
with its destination coords"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Integration Tests
|
||||
// ============================================================================
|
||||
@@ -394,9 +584,9 @@ async fn test_forwarding_multi_hop() {
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
// 3-node chain: 0 -- 1 -- 2
|
||||
// Send a datagram with ttl=1. It should be forwarded by node 1
|
||||
// (decrement to 0) and delivered at node 2 (local delivery). If node 2
|
||||
// tried to forward further, the 0 ttl would prevent it.
|
||||
// Send a datagram with ttl=2. Node 1 forwards it as transit (2 -> 1) and
|
||||
// node 2 delivers it locally, which is not TTL-gated. Had node 2 been
|
||||
// transit instead, the arriving ttl=1 would have stopped it there.
|
||||
let edges = vec![(0, 1), (1, 2)];
|
||||
let mut nodes = run_tree_test(3, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
@@ -411,7 +601,7 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
node2_addr,
|
||||
vec![0x10, 0x00, 0x04, 0x00, 1, 2, 3, 4],
|
||||
)
|
||||
.with_ttl(2); // Enough for 0->1 (decrement to 1) and 1->2 (decrement to 0, local delivery)
|
||||
.with_ttl(2); // Node 1 forwards with ttl=1; node 2 is the destination
|
||||
|
||||
let encoded = dg.encode();
|
||||
|
||||
@@ -426,7 +616,130 @@ async fn test_forwarding_hop_limit_prevents_infinite_loops() {
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
|
||||
// No panic, no infinite loop
|
||||
// The datagram must actually have traversed the chain rather than dying
|
||||
// somewhere en route: node 1 forwarded it and node 2 delivered it. These
|
||||
// hold on both sides of the hop-limit change — the per-hop decrement is
|
||||
// pinned by `test_forwarding_ttl_decrement_is_one_per_hop` below — but
|
||||
// without them the test asserts nothing and would pass on a chain that
|
||||
// dropped the datagram at the first hop.
|
||||
assert_eq!(
|
||||
nodes[1].node.metrics().forwarding.forwarded_packets.get(),
|
||||
1,
|
||||
"node 1 should have forwarded the transit datagram"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[2].node.metrics().forwarding.delivered_packets.get(),
|
||||
1,
|
||||
"node 2 should have delivered the datagram addressed to it"
|
||||
);
|
||||
assert_eq!(
|
||||
nodes[2]
|
||||
.node
|
||||
.metrics()
|
||||
.forwarding
|
||||
.ttl_exhausted_packets
|
||||
.get(),
|
||||
0,
|
||||
"the destination must not charge a TTL drop for a datagram it delivers"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
/// Acceptance for the hop limit as the *composed* system sees it: the shell's
|
||||
/// TTL-shaped predicates in `node::dataplane::forwarding` driving the
|
||||
/// authoritative rule in `Router::route`, across real links.
|
||||
///
|
||||
/// The decision is split across those two files on this branch, and the
|
||||
/// core-level tests exercise only one side of that seam. This is the coverage
|
||||
/// that runs both together over a hop.
|
||||
///
|
||||
/// The TTL a node puts on the wire is not directly observable, so it is
|
||||
/// bracketed from both sides by where the datagram comes to rest on a live
|
||||
/// 3-node chain (0 -- 1 -- 2). Both injections are handed to node 0 as
|
||||
/// transit — an external source, addressed to node 2 — so nodes 0 and 1 are
|
||||
/// both forwarders and only node 2 is the addressed destination.
|
||||
///
|
||||
/// - ttl=2 must reach node 1 as ttl=1 and stop there, because forwarding it
|
||||
/// again would put it on the wire at zero. Emitting ttl=2 unchanged would
|
||||
/// instead show up as a delivery at node 2.
|
||||
/// - ttl=3 must survive both forwarders and be delivered at node 2, which
|
||||
/// receives it at ttl=1 — delivery is not TTL-gated. Decrementing by more
|
||||
/// than one per hop would have stopped it at node 1.
|
||||
#[tokio::test]
|
||||
async fn test_forwarding_ttl_decrement_is_one_per_hop() {
|
||||
/// `(forwarded, ttl_exhausted, delivered)` for one node.
|
||||
fn counts(node: &TestNode) -> (u64, u64, u64) {
|
||||
let fwd = &node.node.metrics().forwarding;
|
||||
(
|
||||
fwd.forwarded_packets.get(),
|
||||
fwd.ttl_exhausted_packets.get(),
|
||||
fwd.delivered_packets.get(),
|
||||
)
|
||||
}
|
||||
|
||||
let edges = vec![(0, 1), (1, 2)];
|
||||
let mut nodes = run_tree_test(3, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
populate_all_coord_caches(&mut nodes);
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let node2_addr = *nodes[2].node.node_addr();
|
||||
let external_src = make_node_addr(0xEE);
|
||||
|
||||
/// Hand a transit datagram to node 0 and let the chain settle.
|
||||
async fn inject(nodes: &mut [TestNode], from: &NodeAddr, dg: SessionDatagram) {
|
||||
let encoded = dg.encode();
|
||||
nodes[0]
|
||||
.node
|
||||
.handle_session_datagram(from, &encoded[1..], false)
|
||||
.await;
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
process_available_packets(nodes).await;
|
||||
}
|
||||
}
|
||||
|
||||
let transit = |ttl: u8| {
|
||||
SessionDatagram::new(external_src, node2_addr, vec![0x10, 0x00, 0x00, 0x00]).with_ttl(ttl)
|
||||
};
|
||||
|
||||
// ttl=2 in: node 0 emits 1, node 1 has nothing left to emit.
|
||||
inject(&mut nodes, &node0_addr, transit(2)).await;
|
||||
assert_eq!(
|
||||
counts(&nodes[0]),
|
||||
(1, 0, 0),
|
||||
"node 0 should have forwarded the ttl=2 datagram at ttl=1"
|
||||
);
|
||||
assert_eq!(
|
||||
counts(&nodes[1]),
|
||||
(0, 1, 0),
|
||||
"node 1 should have received ttl=1 and dropped it rather than sending at ttl=0"
|
||||
);
|
||||
assert_eq!(
|
||||
counts(&nodes[2]),
|
||||
(0, 0, 0),
|
||||
"node 2 must never see a datagram that started two hops away at ttl=2"
|
||||
);
|
||||
|
||||
// ttl=3 in: node 0 emits 2, node 1 emits 1, node 2 delivers at ttl=1.
|
||||
inject(&mut nodes, &node0_addr, transit(3)).await;
|
||||
assert_eq!(
|
||||
counts(&nodes[0]),
|
||||
(2, 0, 0),
|
||||
"node 0 should have forwarded the ttl=3 datagram too"
|
||||
);
|
||||
assert_eq!(
|
||||
counts(&nodes[1]),
|
||||
(1, 1, 0),
|
||||
"node 1 should have forwarded the ttl=2 it received, and dropped nothing new"
|
||||
);
|
||||
assert_eq!(
|
||||
counts(&nodes[2]),
|
||||
(0, 0, 1),
|
||||
"node 2 should have delivered the datagram that arrived at ttl=1"
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
|
||||
+31
-12
@@ -64,7 +64,10 @@ pub(crate) struct NextHop {
|
||||
|
||||
/// Why a datagram was dropped without forwarding or delivering.
|
||||
pub(crate) enum DropReason {
|
||||
/// Received TTL was already exhausted (0) — cannot decrement further.
|
||||
/// The datagram would leave this node with a TTL of zero, so it is not
|
||||
/// transmittable. Covers both the already-exhausted arrival (ttl=0) and
|
||||
/// the last-hop arrival (ttl=1). Transit only: delivery to the addressed
|
||||
/// node is never TTL-gated.
|
||||
TtlExhausted,
|
||||
}
|
||||
|
||||
@@ -100,14 +103,20 @@ pub(crate) enum RouteAction {
|
||||
}
|
||||
|
||||
impl Router {
|
||||
/// Decide the fate of an inbound SessionDatagram: drop (TTL), local
|
||||
/// delivery, transit forward, or no-route. Pure over the datagram, the
|
||||
/// Decide the fate of an inbound SessionDatagram: local delivery, drop
|
||||
/// (TTL), transit forward, or no-route. Pure over the datagram, the
|
||||
/// shell-resolved next hop, and the [`RoutingView`] reads.
|
||||
///
|
||||
/// The shell pre-resolves `next_hop` only for genuine transit packets
|
||||
/// (TTL > 0 and dest not local), so `find_next_hop`'s LRU-touch side
|
||||
/// effect keeps the same scope it has today. `route` still re-checks TTL
|
||||
/// and local delivery authoritatively.
|
||||
/// Follows IP hop-limit semantics: the TTL governs forwarding, not
|
||||
/// delivery to the addressed host, so the local-delivery test precedes
|
||||
/// the TTL gate; and the decrement precedes the drop decision, so a
|
||||
/// datagram that would leave with a TTL of zero is not transmitted.
|
||||
///
|
||||
/// The shell pre-resolves `next_hop` only for datagrams this can actually
|
||||
/// forward (dest not local and TTL surviving the decrement), so
|
||||
/// `find_next_hop`'s LRU-touch side effect stays scoped to genuine
|
||||
/// forwards. `route` still re-checks local delivery and the TTL
|
||||
/// authoritatively.
|
||||
pub(crate) fn route(
|
||||
&mut self,
|
||||
dg: &SessionDatagramRef<'_>,
|
||||
@@ -116,14 +125,24 @@ impl Router {
|
||||
next_hop: Option<NextHop>,
|
||||
rv: &impl RoutingView,
|
||||
) -> RouteOutcome {
|
||||
if dg.ttl == 0 {
|
||||
// Delivery to the addressed node is *not* TTL-gated — under IP
|
||||
// semantics the TTL governs forwarding, not delivery to the addressed
|
||||
// host — so this test precedes the TTL gate below.
|
||||
if dg.dest_addr == *my_addr {
|
||||
return RouteOutcome::DeliverLocal;
|
||||
}
|
||||
|
||||
// TTL enforcement on the transit path: decrement first, then drop if
|
||||
// the datagram would leave with a TTL of zero. `saturating_sub` folds
|
||||
// the already-exhausted arrival (ttl=0) into the same test as the
|
||||
// last-hop arrival (ttl=1); neither is transmitted.
|
||||
let forwarded_ttl = dg.ttl.saturating_sub(1);
|
||||
if forwarded_ttl == 0 {
|
||||
return RouteOutcome::Drop {
|
||||
reason: DropReason::TtlExhausted,
|
||||
};
|
||||
}
|
||||
if dg.dest_addr == *my_addr {
|
||||
return RouteOutcome::DeliverLocal;
|
||||
}
|
||||
|
||||
let nh = match next_hop {
|
||||
Some(nh) => nh,
|
||||
None => return RouteOutcome::NoRoute,
|
||||
@@ -133,7 +152,7 @@ impl Router {
|
||||
// the outgoing link. This is the single owned copy + encode the shell
|
||||
// performed inline today.
|
||||
let mut datagram = SessionDatagram::new(dg.src_addr, dg.dest_addr, dg.payload.to_vec());
|
||||
datagram.ttl = dg.ttl - 1;
|
||||
datagram.ttl = forwarded_ttl;
|
||||
datagram.path_mtu = dg.path_mtu.min(nh.link_mtu);
|
||||
let outgoing_ce = incoming_ce || rv.is_congested(&nh.addr);
|
||||
let bytes = datagram.encode();
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
//!
|
||||
//! - `core.rs` — the `RoutingView` read-seam trait, the `NextHop` /
|
||||
//! `RouteOutcome` types, `Router::route`, the pure transit-forward
|
||||
//! decision (TTL, local-vs-forward, path-MTU min-fold, ECN CE), and the
|
||||
//! decision (local-vs-forward, transit TTL, path-MTU min-fold, ECN CE), and the
|
||||
//! pure candidate assembly + hop-selection / route-classification helpers
|
||||
//! (`Candidate`, `RouteClass`, `routing_candidates`, `select_best_candidate`,
|
||||
//! `classify_forward`). The assembly reads raw per-peer data through the
|
||||
|
||||
@@ -15,13 +15,22 @@ fn decode_forward(bytes: &[u8]) -> SessionDatagramRef<'_> {
|
||||
SessionDatagramRef::decode(&bytes[1..]).expect("forwarded datagram re-decodes")
|
||||
}
|
||||
|
||||
/// A transit datagram that arrived already exhausted is dropped and charged
|
||||
/// to `TtlExhausted`. A next hop is supplied so the drop is evidence of the
|
||||
/// TTL gate rather than of an absent route.
|
||||
#[test]
|
||||
fn ttl_zero_drops_as_exhausted() {
|
||||
let mut router = Router::new();
|
||||
let my_addr = make_node_addr(0x10);
|
||||
let dg = make_datagram_ref(0, make_node_addr(0x20));
|
||||
let rv = MockRoutingView::new(false);
|
||||
let out = router.route(&dg, &my_addr, false, None, &rv);
|
||||
let out = router.route(
|
||||
&dg,
|
||||
&my_addr,
|
||||
false,
|
||||
Some(make_next_hop(make_node_addr(0x30), 1400)),
|
||||
&rv,
|
||||
);
|
||||
assert!(matches!(
|
||||
out,
|
||||
RouteOutcome::Drop {
|
||||
@@ -30,6 +39,78 @@ fn ttl_zero_drops_as_exhausted() {
|
||||
));
|
||||
}
|
||||
|
||||
/// Acceptance: a datagram addressed to this node with ttl=0 is delivered
|
||||
/// locally. The TTL governs forwarding, not delivery to the addressed host,
|
||||
/// so the gate sits after the local-delivery test — and `TtlExhausted` is
|
||||
/// not charged for a delivered datagram.
|
||||
#[test]
|
||||
fn ttl_zero_to_self_delivers_local() {
|
||||
let mut router = Router::new();
|
||||
let my_addr = make_node_addr(0x10);
|
||||
let dg = make_datagram_ref(0, my_addr);
|
||||
let rv = MockRoutingView::new(false);
|
||||
let out = router.route(&dg, &my_addr, false, None, &rv);
|
||||
assert!(
|
||||
matches!(out, RouteOutcome::DeliverLocal),
|
||||
"ttl=0 addressed to this node must still be delivered locally"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance: a transit datagram arriving with ttl=1 would leave with ttl=0,
|
||||
/// so it is dropped here rather than transmitted. A next hop is supplied, so
|
||||
/// a `Forward` outcome would mean it had been put on the wire at ttl=0.
|
||||
#[test]
|
||||
fn ttl_one_transit_drops_before_forwarding() {
|
||||
let mut router = Router::new();
|
||||
let my_addr = make_node_addr(0x10);
|
||||
let dg = make_datagram_ref(1, make_node_addr(0x20));
|
||||
let rv = MockRoutingView::new(false);
|
||||
let out = router.route(
|
||||
&dg,
|
||||
&my_addr,
|
||||
false,
|
||||
Some(make_next_hop(make_node_addr(0x30), 1400)),
|
||||
&rv,
|
||||
);
|
||||
assert!(
|
||||
matches!(
|
||||
out,
|
||||
RouteOutcome::Drop {
|
||||
reason: DropReason::TtlExhausted
|
||||
}
|
||||
),
|
||||
"transit ttl=1 must be dropped as TTL-exhausted, not forwarded at ttl=0"
|
||||
);
|
||||
}
|
||||
|
||||
/// Acceptance: the other side of the same boundary — a transit datagram
|
||||
/// arriving with ttl=2 clears the gate and leaves with ttl=1.
|
||||
#[test]
|
||||
fn ttl_two_transit_forwards_at_one() {
|
||||
let mut router = Router::new();
|
||||
let my_addr = make_node_addr(0x10);
|
||||
let nh_addr = make_node_addr(0x30);
|
||||
let dg = make_datagram_ref(2, make_node_addr(0x20));
|
||||
let rv = MockRoutingView::new(false);
|
||||
let out = router.route(
|
||||
&dg,
|
||||
&my_addr,
|
||||
false,
|
||||
Some(make_next_hop(nh_addr, 1400)),
|
||||
&rv,
|
||||
);
|
||||
match out {
|
||||
RouteOutcome::Forward { bytes, .. } => {
|
||||
assert_eq!(
|
||||
decode_forward(&bytes).ttl,
|
||||
1,
|
||||
"transit ttl=2 must leave with ttl=1"
|
||||
);
|
||||
}
|
||||
_ => panic!("expected Forward"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn destination_is_self_delivers_local() {
|
||||
let mut router = Router::new();
|
||||
|
||||
Reference in New Issue
Block a user