diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 15488fc..df4751a 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 # ── Mixed-profile node-role integration test ───────────────── - suite: mixed-profile type: mixed-profile @@ -566,105 +556,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 - # ── Mixed-profile node-role integration test ─────────────────────────── - name: Generate and inject configs (mixed-profile) if: matrix.type == 'mixed-profile' diff --git a/src/node/tests/session.rs b/src/node/tests/session.rs index 9599a87..79fb504 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, run_tree_test_with_profiles, + 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, run_tree_test_with_profiles, run_tree_test_with_profiles_leaf_smallest, verify_tree_convergence, }; use crate::proto::fsp::SessionAck; @@ -1279,6 +1280,153 @@ 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 +/// XX rekey handshake over the loopback transport so the AEAD continuity across +/// the cutover is asserted end to end. +/// +/// Deterministic, no wall-clock wait and no clock read on the responder: +/// `rekey.after_messages = 1` makes the first sent datagram cross the +/// initiator's trigger, and the responder classifies the arrival from the rekey +/// marker the initiator declares in its msg3 negotiation TLV. +/// +/// The message-count arm is what carries this test's value. It is the arm the +/// retired age-based discriminator got wrong, and suppressing the marker so the +/// responder falls back to the cross-connection path reds the post-cutover +/// assertion, which is what shows the test discriminates the two arms rather +/// than merely observing a session index change. +#[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(); + + // No session backdating here, unlike the master-branch original: on this + // line the responder classifies the arrival from the sender's msg3 + // declaration and `establish_inbound` reads no clock at all, so aging the + // session past a 30s acceptance gate would age it past nothing. + + // 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 9a49a3f..1ff3b9d 100644 --- a/src/node/tests/spanning_tree.rs +++ b/src/node/tests/spanning_tree.rs @@ -152,6 +152,31 @@ async fn make_test_node_inner_with_identity( } } +/// 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, diff --git a/testing/ci-local.sh b/testing/ci-local.sh index eb056c7..60bd86b 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, mixed-profile, gateway, +# static-mesh, static-chain, mixed-profile, gateway, # firewall, nat-cone, nat-symmetric, # nat-lan, nostr-publish-consume, stun-faults, # chaos-churn-mixed-10, chaos-ethernet-mesh, @@ -74,6 +73,21 @@ # count unchanged — plus handle_msg3_admits_existing_peer_at_cap # for the known-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 XX rekey over loopback, +# the responder classifying it from the msg3 rekey marker); +# the accept-off dual-init regression and the +# udp.outbound_only rekey loop by the should_admit_msg1 tests +# (src/node/tests/handshake.rs) and the establish_inbound / +# establish_outbound decision tests (src/proto/fmp/tests/core.rs, +# src/peer/machine.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 +147,6 @@ ONLY_SUITE="" # All integration suites matching ci.yml STATIC_SUITES=(static-mesh static-chain) -REKEY_SUITES=(rekey rekey-accept-off rekey-outbound-only) MIXED_PROFILE_SUITES=(mixed-profile) # Each entry: "display-name scenario [--flag value ...]" CHAOS_SUITES=( @@ -198,9 +211,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 " Mixed profile:" for s in "${MIXED_PROFILE_SUITES[@]}"; do echo " $s"; done echo "" @@ -551,32 +561,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 the mixed-profile integration test (Full + NonRouting + Leaf) run_mixed_profile() { local compose="testing/static/docker-compose.yml" @@ -729,73 +713,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)" @@ -925,11 +842,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 - # Mixed-profile (Full + NonRouting + Leaf) for _suite in "${MIXED_PROFILE_SUITES[@]}"; do run_mixed_profile @@ -1042,12 +954,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 ;; mixed-profile) run_mixed_profile ;; gateway)