From f44de56fd85fec2f0f884e696611cb1fc435d703 Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Fri, 24 Jul 2026 03:39:47 +0000 Subject: [PATCH 1/2] Add an in-process test that a rekey cutover preserves the data plane Drives a real IK rekey handshake to K-bit cutover between two loopback nodes and asserts an encrypted datagram sent after the cutover decodes on the new session, with no spurious peer teardown. The rekey is forced deterministically: rekey.after_messages = 1 crosses the initiator's trigger on the first sent datagram, and both sessions are backdated past the responder's 30s rekey-acceptance gate, so no wall clock is read. The cutover is proven to actually occur (the live session index changes), which is what makes the continuity assertion meaningful rather than vacuous. Adds a make_test_node_with_config loopback helper for setting the rekey thresholds. The rekey timing and choreography decisions themselves are already covered exhaustively by the sans-IO poll_rekey tests. --- src/node/tests/session.rs | 153 +++++++++++++++++++++++++++++++- src/node/tests/spanning_tree.rs | 25 ++++++ 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 4ad7c65..a671c39 100644 --- a/src/node/tests/session.rs +++ b/src/node/tests/session.rs @@ -3,8 +3,9 @@ use super::*; use crate::node::session::EndToEndState; use crate::node::tests::spanning_tree::{ - TestNode, cleanup_nodes, generate_random_edges, lock_large_network_test, - process_available_packets, run_tree_test, run_tree_test_with_mtus, verify_tree_convergence, + TestNode, cleanup_nodes, drain_all_packets, generate_random_edges, initiate_handshake, + lock_large_network_test, make_test_node_with_config, process_available_packets, run_tree_test, + run_tree_test_with_mtus, verify_tree_convergence, }; use crate::proto::fsp::SessionAck; use crate::proto::link::SessionDatagram; @@ -1094,6 +1095,154 @@ async fn test_tun_outbound_established_session() { cleanup_nodes(&mut nodes).await; } +/// A completed rekey cutover must not break the data plane: an encrypted +/// datagram sent after the K-bit cutover decodes on the new session, and the +/// peer is not spuriously torn down. +/// +/// This is the wire-continuity half of the rekey property the Docker `rekey` +/// suites exercised. The rekey timing and choreography decision itself lives in +/// the sans-IO cores and is covered exhaustively there +/// (`proto/fsp/tests/core.rs`, `proto/fmp/tests/core.rs`); this drives a real +/// IK rekey handshake over the loopback transport so the AEAD continuity across +/// the cutover is asserted end to end. +/// +/// Deterministic, no wall-clock wait: `rekey.after_messages = 1` makes the +/// first sent datagram cross the initiator's trigger, and both sessions are +/// backdated past the responder's 30s rekey-acceptance gate. +#[tokio::test] +async fn rekey_cutover_preserves_data_plane() { + // node 0 rekeys on the message counter; time never triggers it. + let mut cfg0 = crate::config::Config::new(); + cfg0.node.rekey.enabled = true; + cfg0.node.rekey.after_messages = 1; + cfg0.node.rekey.after_secs = u64::MAX; + let cfg1 = crate::config::Config::new(); + + let mut nodes = vec![ + make_test_node_with_config(cfg0).await, + make_test_node_with_config(cfg1).await, + ]; + + // FMP peering + FSP session between the two loopback nodes. + initiate_handshake(&mut nodes, 0, 1).await; + drain_all_packets(&mut nodes, false).await; + let node0_addr = *nodes[0].node.node_addr(); + let node1_addr = *nodes[1].node.node_addr(); + assert!(nodes[0].node.get_peer(&node1_addr).is_some()); + assert!(nodes[1].node.get_peer(&node0_addr).is_some()); + populate_all_coord_caches(&mut nodes); + + let node1_pubkey = nodes[1].node.identity().pubkey_full(); + nodes[0] + .node + .initiate_session(node1_addr, node1_pubkey) + .await + .unwrap(); + for _ in 0..4 { + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + } + assert!( + nodes[0] + .node + .get_session(&node1_addr) + .unwrap() + .state() + .is_established(), + "session established" + ); + + // node 1's TUN receiver observes decoded plaintext. + let (tun_tx, tun_rx) = std::sync::mpsc::channel(); + nodes[1].node.supervisor.tun_tx = Some(tun_tx); + let src_fips = crate::FipsAddress::from_node_addr(&node0_addr); + let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr); + + // Baseline: data decodes on the original session, and this send bumps the + // counter across the rekey trigger. + let pre = build_ipv6_packet(&src_fips, &dst_fips, b"pre-rekey-payload"); + nodes[0].node.handle_tun_outbound(pre.clone()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + let pre_delivered: Vec> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect(); + assert_eq!( + pre_delivered, + vec![pre.clone()], + "baseline datagram must decode before the rekey" + ); + + let idx_before = nodes[0].node.get_peer(&node1_addr).unwrap().our_index(); + + // Age both sessions past the responder's 30s rekey-acceptance gate so the + // rekey msg1 is treated as a rekey rather than a fresh connection. + nodes[0] + .node + .get_peer_mut(&node1_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + nodes[1] + .node + .get_peer_mut(&node0_addr) + .unwrap() + .test_backdate_session_established(Duration::from_secs(31)); + + // Drive the real rekey handshake (msg1/msg2/msg3 over loopback) to cutover. + for _ in 0..6 { + nodes[0].node.check_rekey().await; + nodes[1].node.check_rekey().await; + for _ in 0..3 { + tokio::time::sleep(Duration::from_millis(5)).await; + process_available_packets(&mut nodes).await; + } + } + + // The cutover actually happened: node 0's live session index changed and no + // rekey is left dangling. Guards against a vacuous pass where the rekey + // never fired. + let idx_after = nodes[0].node.get_peer(&node1_addr).unwrap().our_index(); + assert_ne!( + idx_after, idx_before, + "rekey must cut the live session over to a new index" + ); + assert!( + !nodes[0] + .node + .get_peer(&node1_addr) + .unwrap() + .rekey_in_progress(), + "rekey must have completed, not left in progress" + ); + + // Continuity: a datagram sent after the cutover decodes on the NEW session. + let post = build_ipv6_packet(&src_fips, &dst_fips, b"post-rekey-payload"); + nodes[0].node.handle_tun_outbound(post.clone()).await; + tokio::time::sleep(Duration::from_millis(10)).await; + process_available_packets(&mut nodes).await; + let post_delivered: Vec> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect(); + assert_eq!( + post_delivered, + vec![post.clone()], + "datagram sent after the cutover must decode on the new session" + ); + + // No spurious teardown across the rekey. + assert!( + nodes[0].node.get_peer(&node1_addr).is_some(), + "peer must survive the rekey" + ); + assert!( + nodes[0] + .node + .get_session(&node1_addr) + .unwrap() + .state() + .is_established(), + "session must remain established after the rekey" + ); + + cleanup_nodes(&mut nodes).await; +} + #[tokio::test] async fn test_tun_outbound_triggers_session_initiation() { // Two connected nodes, no session yet. diff --git a/src/node/tests/spanning_tree.rs b/src/node/tests/spanning_tree.rs index 84af809..d0446d9 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -100,6 +100,31 @@ pub(super) async fn make_test_node_with_mtu(mtu: u16) -> TestNode { } } +/// Create a loopback test node from an explicit `Config`, e.g. to set the +/// `node.rekey` thresholds a rekey-behaviour test needs. Otherwise identical to +/// [`make_test_node`] (default 1280 MTU, in-process loopback transport). +pub(super) async fn make_test_node_with_config(config: crate::config::Config) -> TestNode { + let mut node = make_node_with(config); + let transport_id = TransportId::new(1); + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel::(); + let addr = next_loopback_addr(); + + LOOPBACK_REGISTRY.lock().unwrap().insert(addr.clone(), tx); + + let loopback = + LoopbackTransport::with_mtu(transport_id, addr.clone(), 1280, LOOPBACK_REGISTRY.clone()); + node.transports + .insert(transport_id, TransportHandle::Loopback(loopback)); + + TestNode { + node, + transport_id, + packet_rx: rx, + addr, + } +} + /// Initiate a Noise handshake from nodes[i] to nodes[j]. /// /// Sends msg1 over UDP. The drain loop will handle msg1 processing, From 9213cce6c47a6e9255407ec1bbd3919e5e403aef Mon Sep 17 00:00:00 2001 From: Johnathan Corgan Date: Fri, 24 Jul 2026 03:51:13 +0000 Subject: [PATCH 2/2] Retire the rekey Docker suites; timing, continuity and variants are in-process The three rekey integration suites (rekey, rekey-accept-off, rekey-outbound-only) are dropped from both runners. Their coverage now lives in fast, deterministic in-process tests: - rekey timing and choreography (trigger, K-bit cutover, drain, jitter, guards) in the sans-IO poll_rekey tests (proto/fsp, proto/fmp); - data-plane continuity across a real cutover in rekey_cutover_preserves_data_plane (a real IK rekey over loopback); - the accept-off dual-init regression and the udp.outbound_only rekey loop in the should_admit_msg1 and dual-init characterization tests. Drop them from both runners in lockstep so the parity guard stays green, and record the retirement in the deliberately-not-run block with a pointer to the standalone runner. The rekey-test.sh script stays on disk. --- .github/workflows/ci.yml | 109 ---------------------------------- testing/ci-local.sh | 124 +++++---------------------------------- 2 files changed, 14 insertions(+), 219 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2899e92..15d65f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -414,16 +414,6 @@ jobs: - suite: static-chain type: static topology: chain - # ── Rekey integration test ────────────────────────────────────────── - - suite: rekey - type: rekey - topology: rekey - - suite: rekey-accept-off - type: rekey-accept-off - topology: rekey-accept-off - - suite: rekey-outbound-only - type: rekey-outbound-only - topology: rekey-outbound-only # ── Firewall baseline (fips0 nftables default-deny) ──────────── - suite: firewall type: firewall @@ -563,105 +553,6 @@ jobs: docker compose -f testing/static/docker-compose.yml \ --profile ${{ matrix.topology }} down --volumes --remove-orphans - # ── Rekey integration test ────────────────────────────────────────────── - - name: Generate and inject configs (rekey) - if: matrix.type == 'rekey' - run: | - bash testing/static/scripts/generate-configs.sh rekey - bash testing/static/scripts/rekey-test.sh inject-config - - - name: Start containers (rekey) - if: matrix.type == 'rekey' - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey up -d - - - name: Run rekey test - if: matrix.type == 'rekey' - run: bash testing/static/scripts/rekey-test.sh - - - name: Collect logs on failure (rekey) - if: matrix.type == 'rekey' && failure() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey logs --no-color - - - name: Stop containers (rekey) - if: matrix.type == 'rekey' && always() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey down --volumes --remove-orphans - - # ── Rekey + accept_connections=false variant ────────────────────────── - - name: Generate and inject configs (rekey-accept-off) - if: matrix.type == 'rekey-accept-off' - env: - REKEY_TOPOLOGY: rekey-accept-off - REKEY_ACCEPT_OFF_NODES: b - run: | - bash testing/static/scripts/generate-configs.sh rekey-accept-off - bash testing/static/scripts/rekey-test.sh inject-config - - - name: Start containers (rekey-accept-off) - if: matrix.type == 'rekey-accept-off' - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-accept-off up -d - - - name: Run rekey test (accept-off variant) - if: matrix.type == 'rekey-accept-off' - env: - REKEY_TOPOLOGY: rekey-accept-off - REKEY_ACCEPT_OFF_NODES: b - run: bash testing/static/scripts/rekey-test.sh - - - name: Collect logs on failure (rekey-accept-off) - if: matrix.type == 'rekey-accept-off' && failure() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-accept-off logs --no-color | tail -300 - - - name: Stop containers (rekey-accept-off) - if: matrix.type == 'rekey-accept-off' && always() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-accept-off down --volumes --remove-orphans - - # ── Rekey + udp.outbound_only=true variant ───────────────────────────── - - name: Generate and inject configs (rekey-outbound-only) - if: matrix.type == 'rekey-outbound-only' - env: - REKEY_TOPOLOGY: rekey-outbound-only - REKEY_OUTBOUND_ONLY_NODES: b - run: | - bash testing/static/scripts/generate-configs.sh rekey-outbound-only - bash testing/static/scripts/rekey-test.sh inject-config - - - name: Start containers (rekey-outbound-only) - if: matrix.type == 'rekey-outbound-only' - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-outbound-only up -d - - - name: Run rekey test (outbound-only variant) - if: matrix.type == 'rekey-outbound-only' - env: - REKEY_TOPOLOGY: rekey-outbound-only - REKEY_OUTBOUND_ONLY_NODES: b - run: bash testing/static/scripts/rekey-test.sh - - - name: Collect logs on failure (rekey-outbound-only) - if: matrix.type == 'rekey-outbound-only' && failure() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-outbound-only logs --no-color | tail -300 - - - name: Stop containers (rekey-outbound-only) - if: matrix.type == 'rekey-outbound-only' && always() - run: | - docker compose -f testing/static/docker-compose.yml \ - --profile rekey-outbound-only down --volumes --remove-orphans - # ── Firewall baseline integration test ───────────────────────────────── - name: Run firewall baseline integration test if: matrix.type == 'firewall' diff --git a/testing/ci-local.sh b/testing/ci-local.sh index f879ff4..9ca36a1 100755 --- a/testing/ci-local.sh +++ b/testing/ci-local.sh @@ -26,8 +26,7 @@ # -h, --help Show this help # # Integration suites (default coverage): -# static-mesh, static-chain, rekey, rekey-accept-off, -# rekey-outbound-only, gateway, +# static-mesh, static-chain, gateway, # firewall, nat-cone, nat-symmetric, # nat-lan, nostr-publish-consume, stun-faults, # chaos-churn-mixed-10, chaos-ethernet-mesh, @@ -74,6 +73,19 @@ # discriminator the Docker tcpdump used — plus a sibling # test for the existing-peer bypass. Still runnable by hand: # bash testing/static/scripts/admission-cap-test.sh +# rekey, rekey-accept-off, rekey-outbound-only +# Retired from CI 2026-07-24 as redundant, not unrunnable. +# The rekey timing/choreography decision (trigger, K-bit +# cutover, drain, jitter, guards) is covered by the sans-IO +# poll_rekey tests (src/proto/fsp/tests/core.rs, +# src/proto/fmp/tests/core.rs); the data-plane continuity +# across a real cutover by rekey_cutover_preserves_data_plane +# (src/node/tests/session.rs, a real IK rekey over loopback); +# the accept-off dual-init regression and the +# udp.outbound_only rekey loop by the should_admit_msg1 and +# dual-init chartests (src/node/tests/handshake.rs, +# establish_chartests.rs). Still runnable by hand: +# bash testing/static/scripts/rekey-test.sh # ecn-ab-on, ecn-ab-off, maelstrom, maelstrom-sparse # Chaos scenarios excluded from CHAOS_SUITES. The two # ecn-ab ones are halves of the manual comparison above. @@ -133,7 +145,6 @@ ONLY_SUITE="" # All integration suites matching ci.yml STATIC_SUITES=(static-mesh static-chain) -REKEY_SUITES=(rekey rekey-accept-off rekey-outbound-only) # Each entry: "display-name scenario [--flag value ...]" CHAOS_SUITES=( "churn-mixed-10 churn-mixed --nodes 10 --duration 120" @@ -198,9 +209,6 @@ list_suites() { echo " Static topologies:" for s in "${STATIC_SUITES[@]}"; do echo " $s"; done echo "" - echo " Rekey:" - for s in "${REKEY_SUITES[@]}"; do echo " $s"; done - echo "" echo " Gateway:" for s in "${GATEWAY_SUITES[@]}"; do echo " $s"; done echo "" @@ -548,32 +556,6 @@ run_static() { record "static-$topology" $rc } -# Run the rekey integration test -run_rekey() { - local compose="testing/static/docker-compose.yml" - local rc=0 - export COMPOSE_PROJECT_NAME="$(ci_project static)" - - info "[rekey] Generating configs" - bash testing/static/scripts/generate-configs.sh rekey || { record "rekey" 1; return; } - bash testing/static/scripts/rekey-test.sh inject-config || { record "rekey" 1; return; } - - info "[rekey] Starting containers" - docker compose -f "$compose" --profile rekey up -d || { record "rekey" 1; return; } - - info "[rekey] Running rekey test" - if bash testing/static/scripts/rekey-test.sh; then - rc=0 - else - rc=1 - info "[rekey] Collecting failure logs" - docker compose -f "$compose" --profile rekey logs --no-color 2>&1 | tail -100 - fi - - docker compose -f "$compose" --profile rekey down --volumes --remove-orphans 2>/dev/null - record "rekey" $rc -} - # Run a chaos scenario run_chaos() { local name="$1" @@ -701,73 +683,6 @@ run_sidecar() { record "sidecar" $rc } -# Run the rekey-accept-off integration variant. Same harness as run_rekey -# but on a 2-node topology with udp.accept_connections=false on node-b. -run_rekey_accept_off() { - local compose="testing/static/docker-compose.yml" - local rc=0 - export COMPOSE_PROJECT_NAME="$(ci_project static)" - - info "[rekey-accept-off] Generating configs" - bash testing/static/scripts/generate-configs.sh rekey-accept-off || \ - { record "rekey-accept-off" 1; return; } - REKEY_TOPOLOGY=rekey-accept-off REKEY_ACCEPT_OFF_NODES=b \ - bash testing/static/scripts/rekey-test.sh inject-config || \ - { record "rekey-accept-off" 1; return; } - - info "[rekey-accept-off] Starting containers" - docker compose -f "$compose" --profile rekey-accept-off up -d || \ - { record "rekey-accept-off" 1; return; } - - info "[rekey-accept-off] Running rekey test" - if REKEY_TOPOLOGY=rekey-accept-off REKEY_ACCEPT_OFF_NODES=b \ - bash testing/static/scripts/rekey-test.sh; then - rc=0 - else - rc=1 - info "[rekey-accept-off] Collecting failure logs" - docker compose -f "$compose" --profile rekey-accept-off logs --no-color 2>&1 | tail -100 - fi - - docker compose -f "$compose" --profile rekey-accept-off down --volumes --remove-orphans 2>/dev/null - record "rekey-accept-off" $rc -} - -# Run the rekey-outbound-only integration variant. Same harness as -# run_rekey but with udp.outbound_only=true on node-b plus its peer -# addrs rewritten from numeric docker IPs to docker hostnames so the -# addr_to_link key form mismatches inbound packet source addrs (the -# production trigger for the rekey-msg1 carve-out gap). -run_rekey_outbound_only() { - local compose="testing/static/docker-compose.yml" - local rc=0 - export COMPOSE_PROJECT_NAME="$(ci_project static)" - - info "[rekey-outbound-only] Generating configs" - bash testing/static/scripts/generate-configs.sh rekey-outbound-only || \ - { record "rekey-outbound-only" 1; return; } - REKEY_TOPOLOGY=rekey-outbound-only REKEY_OUTBOUND_ONLY_NODES=b \ - bash testing/static/scripts/rekey-test.sh inject-config || \ - { record "rekey-outbound-only" 1; return; } - - info "[rekey-outbound-only] Starting containers" - docker compose -f "$compose" --profile rekey-outbound-only up -d || \ - { record "rekey-outbound-only" 1; return; } - - info "[rekey-outbound-only] Running rekey test" - if REKEY_TOPOLOGY=rekey-outbound-only REKEY_OUTBOUND_ONLY_NODES=b \ - bash testing/static/scripts/rekey-test.sh; then - rc=0 - else - rc=1 - info "[rekey-outbound-only] Collecting failure logs" - docker compose -f "$compose" --profile rekey-outbound-only logs --no-color 2>&1 | tail -100 - fi - - docker compose -f "$compose" --profile rekey-outbound-only down --volumes --remove-orphans 2>/dev/null - record "rekey-outbound-only" $rc -} - # Run firewall baseline integration test run_firewall() { export COMPOSE_PROJECT_NAME="$(ci_project firewall)" @@ -897,11 +812,6 @@ run_integration() { run_static "$topology" done - # Rekey + rekey-accept-off + rekey-outbound-only variants - run_rekey - run_rekey_accept_off - run_rekey_outbound_only - # Gateway run_gateway @@ -1009,12 +919,6 @@ run_suite() { case "$suite" in static-mesh|static-chain) run_static "${suite#static-}" ;; - rekey) - run_rekey ;; - rekey-accept-off) - run_rekey_accept_off ;; - rekey-outbound-only) - run_rekey_outbound_only ;; gateway) run_gateway ;; firewall)