Merge branch 'master'

Carries the in-process rekey continuity test and the retirement of the
three rekey Docker suites onto the XX line.

The continuity test was written against master's IK handshake, where the
responder decided rekey-versus-fresh-dial from session age and the test
had to backdate both sessions past a 30s acceptance gate. Here the
responder classifies from the rekey marker the initiator declares in its
msg3 negotiation TLV and reads no clock at all, so the backdating is
dropped: it would age the session past nothing, and leaving it in would
tell a reader a gate exists that does not.

Checked that the test still discriminates rather than merely observing a
session index change. Suppressing the declaration so the responder falls
back to the cross-connection path reds the post-cutover assertion in 8 of
16 runs, which is the address-ordering coin flip the original data-plane
drop had; the healthy tree is 0 of 16 red. The 30s backdate makes no
difference to either rate, which is what confirms it was vestigial here.

The suite wiring keeps mixed-profile, which master does not have, and
drops the three rekey suites from the local suite list, the runner
functions, the default flow, the --only dispatch and the GitHub matrix
together, so check-ci-parity.sh stays green at 23 legs a side. The
retirement note is retargeted at this branch's coverage: the establish
decision tests live in src/proto/fmp/tests/core.rs and src/peer/machine.rs
here, not in the establish_chartests.rs file master keeps.
This commit is contained in:
Johnathan Corgan
2026-07-24 23:58:42 +00:00
4 changed files with 191 additions and 221 deletions
-109
View File
@@ -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'
+150 -2
View File
@@ -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<Vec<u8>> = 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<Vec<u8>> = 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.
+25
View File
@@ -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::<ReceivedPacket>();
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,
+16 -110
View File
@@ -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)