mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-11 17:17:54 +00:00
node: drive rekey cadence cutover and drain through the peer machine
Route each Cutover and Drain that the shell-side batch poll_rekey decides through the per-peer machine and the executor, replacing the inline effect bodies in check_rekey. The batch decision and its per-peer snapshots stay shell-side and byte-unchanged: poll_rekey phase-groups all cutovers, then all drains, then all initiations across the peer set, and that ordering governs the shared index allocator's free-then-allocate sequence that appears on the wire, so the machine only consumes the already-decided actions (a new RekeyConsume event) without re-deciding. InitiateRekey stays inline (its Noise msg1 build is a shell-side leaf) with a RekeyInitiated observation feeding the machine so its control state stays coherent for the next tick's cutover. The cutover and drain logs, which relocated into the executor in the prior commit, are pinned back to the fips::node::handlers::rekey tracing target so they stay visible under the operator's module log filter. Also clears the machine's shadow draining_index on drain so a later cross-connection resolution cannot double-free the already-freed index.
This commit is contained in:
@@ -345,6 +345,12 @@ impl Node {
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
// Pin the target to the pre-refactor module: this
|
||||
// cutover log relocated from handlers/rekey.rs into
|
||||
// the executor, but operators (and the test harness)
|
||||
// filter it under fips::node::handlers::rekey. Keeping
|
||||
// the target preserves the observable log contract.
|
||||
target: "fips::node::handlers::rekey",
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
@@ -385,6 +391,10 @@ impl Node {
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
// Pin to the pre-refactor module (see the cutover log
|
||||
// above) so the relocated drain log stays visible under
|
||||
// the operator's fips::node::handlers::rekey filter.
|
||||
target: "fips::node::handlers::rekey",
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
|
||||
+173
-57
@@ -7,13 +7,16 @@
|
||||
|
||||
use crate::NodeAddr;
|
||||
use crate::node::Node;
|
||||
use crate::node::dataplane::PeerActionCtx;
|
||||
use crate::noise::HandshakeState;
|
||||
use crate::peer::machine::PeerEvent;
|
||||
use crate::proto::fmp::wire::build_msg1;
|
||||
use crate::proto::fmp::{ConnAction, LifecycleView, PeerSnapshot, RekeyCfg, RekeyResendSnapshot};
|
||||
use crate::proto::fsp::{
|
||||
FspAction, RekeyMsg3ResendSnapshot, SessionSetup, SessionSnapshot, cutover_timer_elapsed,
|
||||
};
|
||||
use crate::proto::link::SessionDatagram;
|
||||
use crate::transport::{TransportAddr, TransportId};
|
||||
use tracing::{debug, trace, warn};
|
||||
|
||||
/// Keep previous session alive for this long after cutover.
|
||||
@@ -46,72 +49,36 @@ impl Node {
|
||||
// The shell snapshots each healthy peer's rekey ages/flags (every clock
|
||||
// read resolved here); the core decides cutover/drain/trigger with no
|
||||
// clock, phase-grouped to preserve the pre-refactor execution order.
|
||||
// The batch `poll_rekey` + snapshots STAY SHELL-SIDE and BYTE-UNCHANGED
|
||||
// (Finding B): the cross-peer phase-grouping (all Cutover → all Drain →
|
||||
// all InitiateRekey) governs the shared `index_allocator` free-then-alloc
|
||||
// SEQUENCE that appears on the wire. The machine must NOT re-poll; it
|
||||
// CONSUMES each decided `ConnAction` in the same order the batch returned.
|
||||
let snapshots = self.rekey_peers();
|
||||
for action in self.fmp.poll_rekey(snapshots, &cfg) {
|
||||
match action {
|
||||
// Execute cutover for initiator side.
|
||||
// Initiator cutover: route the decided action through the peer
|
||||
// machine + executor (C4-1). The executor's `SwapSendState` arm
|
||||
// reproduces the pre-refactor cutover body EXACTLY.
|
||||
ConnAction::Cutover { peer: node_addr } => {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(&node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index
|
||||
// during msg2 handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the
|
||||
// cache_key (transport_id, our_index) just changed, so the
|
||||
// old worker entry is stale and every packet on the new
|
||||
// session would miss the worker's HashMap lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(&node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
self.route_rekey_cadence(node_addr, ConnAction::Cutover { peer: node_addr })
|
||||
.await;
|
||||
}
|
||||
// Execute drain completion.
|
||||
// Drain completion: route through the machine + executor. The
|
||||
// executor's `CompleteDrain` arm reads the REAL previous index
|
||||
// from `complete_drain()` and frees it at the same point the old
|
||||
// inline body did (index-order preserving).
|
||||
ConnAction::Drain { peer: node_addr } => {
|
||||
// Extract the old index and transport_id under the peer
|
||||
// borrow, then drop the borrow so the cache_key cleanup
|
||||
// below can take &mut self for unregister_decrypt_worker_session.
|
||||
let drained = self.peers.get_mut(&node_addr).and_then(|peer| {
|
||||
peer.complete_drain().map(|idx| (idx, peer.transport_id()))
|
||||
});
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(&node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
self.route_rekey_cadence(node_addr, ConnAction::Drain { peer: node_addr })
|
||||
.await;
|
||||
}
|
||||
// Initiate a new rekey.
|
||||
// Initiate a new rekey: STAYS INLINE (the Noise msg1 build +
|
||||
// index allocation are a shell-side leaf, byte-unchanged). Feed
|
||||
// the machine a `RekeyInitiated` observation afterward so its
|
||||
// control state stays coherent for the next tick's Cutover/Drain.
|
||||
ConnAction::InitiateRekey { peer: node_addr } => {
|
||||
self.initiate_rekey(&node_addr).await;
|
||||
self.observe_rekey_initiated(&node_addr);
|
||||
}
|
||||
#[allow(unreachable_patterns)]
|
||||
_ => {}
|
||||
@@ -119,6 +86,155 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Route a cadence-decided `Cutover`/`Drain` `ConnAction` through the peer
|
||||
/// machine + executor (C4-1). The shell already decided (batch `poll_rekey`);
|
||||
/// the machine consumes via [`PeerEvent::RekeyConsume`] WITHOUT re-polling,
|
||||
/// preserving the phase order. The `SwapSendState`/`CompleteDrain` executor
|
||||
/// arms reproduce the pre-refactor inline effect bodies exactly.
|
||||
///
|
||||
/// Finding A: an established peer always has a `peer_machine`. If the peer
|
||||
/// vanished between snapshot and effect, the old inline body was a no-op, so
|
||||
/// we do nothing; if the machine is absent (impossible per Finding A) we fall
|
||||
/// back to the byte-identical inline body under a `debug_assert`.
|
||||
async fn route_rekey_cadence(&mut self, node_addr: NodeAddr, action: ConnAction) {
|
||||
let link = match self.peers.get(&node_addr) {
|
||||
Some(peer) => peer.link_id(),
|
||||
None => return,
|
||||
};
|
||||
if !self.peer_machines.contains_key(&link) {
|
||||
debug_assert!(
|
||||
false,
|
||||
"peer machine present for every established rekey peer (Finding A)"
|
||||
);
|
||||
match action {
|
||||
ConnAction::Cutover { peer } => self.cutover_peer_inline(&peer),
|
||||
ConnAction::Drain { peer } => self.drain_peer_inline(&peer),
|
||||
_ => {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let ambient = self.rekey_cadence_ctx(&node_addr);
|
||||
self.advance_peer_machine(
|
||||
link,
|
||||
PeerEvent::RekeyConsume { action },
|
||||
Self::now_ms(),
|
||||
&ambient,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Feed the machine the `RekeyInitiated` observation after the inline
|
||||
/// `initiate_rekey` (C4-1). The obs emits no action, so there is no executor
|
||||
/// pass — a bare `step` keeps the machine's control state coherent.
|
||||
fn observe_rekey_initiated(&mut self, node_addr: &NodeAddr) {
|
||||
let link = match self.peers.get(node_addr) {
|
||||
Some(peer) => peer.link_id(),
|
||||
None => return,
|
||||
};
|
||||
if let Some(machine) = self.peer_machines.get_mut(&link) {
|
||||
let acts = machine.step(
|
||||
PeerEvent::RekeyInitiated,
|
||||
Self::now_ms(),
|
||||
&mut self.index_allocator,
|
||||
);
|
||||
debug_assert!(acts.is_empty(), "RekeyInitiated is a pure observation");
|
||||
} else {
|
||||
debug_assert!(
|
||||
false,
|
||||
"peer machine present for every established rekey peer (Finding A)"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ambient shell facts for the routed cadence Cutover/Drain step. Only
|
||||
/// `verified_identity` is read by the `SwapSendState`/`CompleteDrain`
|
||||
/// executor arms — `SwapSendState` resolves its `NodeAddr` from it (so it must
|
||||
/// equal `node_addr`), and `CompleteDrain` carries its peer in the action
|
||||
/// payload. The transport/index/direction fields are unused by these two arms
|
||||
/// (they matter only to `PromoteToActive`, never emitted on this path) and are
|
||||
/// populated best-effort for coherence.
|
||||
fn rekey_cadence_ctx(&self, node_addr: &NodeAddr) -> PeerActionCtx {
|
||||
let peer = &self.peers[node_addr];
|
||||
PeerActionCtx {
|
||||
verified_identity: *peer.identity(),
|
||||
transport_id: peer.transport_id().unwrap_or_else(|| TransportId::new(0)),
|
||||
remote_addr: peer
|
||||
.current_addr()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| TransportAddr::new(Vec::new())),
|
||||
our_index: peer.our_index(),
|
||||
their_index: peer.their_index(),
|
||||
now_ms: Self::now_ms(),
|
||||
is_outbound: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Pre-refactor initiator cutover body, retained as the release fallback for
|
||||
/// the (Finding-A-impossible) missing-machine case. Byte-identical to the old
|
||||
/// inline `ConnAction::Cutover` arm and to the executor's `SwapSendState` arm.
|
||||
fn cutover_peer_inline(&mut self, node_addr: &NodeAddr) {
|
||||
let did_cutover = if let Some(peer) = self.peers.get_mut(node_addr) {
|
||||
if let Some(_old_our_index) = peer.cutover_to_new_session() {
|
||||
// New index was pre-registered in peers_by_index during msg2
|
||||
// handling (handshake.rs).
|
||||
debug_assert!(
|
||||
peer.transport_id().is_some()
|
||||
&& peer.our_index().is_some()
|
||||
&& self.peers_by_index.contains_key(&(
|
||||
peer.transport_id().unwrap(),
|
||||
peer.our_index().unwrap().as_u32()
|
||||
)),
|
||||
"peers_by_index should contain pre-registered new index after cutover"
|
||||
);
|
||||
debug!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
"Rekey cutover complete (initiator), K-bit flipped"
|
||||
);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
// Re-register the new session with the decrypt worker — the cache_key
|
||||
// (transport_id, our_index) just changed, so the old worker entry is
|
||||
// stale and every packet on the new session would miss the lookup.
|
||||
#[cfg(unix)]
|
||||
if did_cutover {
|
||||
self.register_decrypt_worker_session(node_addr);
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
let _ = did_cutover;
|
||||
}
|
||||
|
||||
/// Pre-refactor drain-completion body, retained as the release fallback for
|
||||
/// the (Finding-A-impossible) missing-machine case. Byte-identical to the old
|
||||
/// inline `ConnAction::Drain` arm and to the executor's `CompleteDrain` arm.
|
||||
fn drain_peer_inline(&mut self, node_addr: &NodeAddr) {
|
||||
// Extract the old index and transport_id under the peer borrow, then drop
|
||||
// the borrow so the cache_key cleanup below can take &mut self for
|
||||
// unregister_decrypt_worker_session.
|
||||
let drained = self
|
||||
.peers
|
||||
.get_mut(node_addr)
|
||||
.and_then(|peer| peer.complete_drain().map(|idx| (idx, peer.transport_id())));
|
||||
if let Some((old_our_index, transport_id)) = drained {
|
||||
if let Some(tid) = transport_id {
|
||||
let cache_key = (tid, old_our_index.as_u32());
|
||||
self.peers_by_index.remove(&cache_key);
|
||||
#[cfg(unix)]
|
||||
self.unregister_decrypt_worker_session(cache_key);
|
||||
}
|
||||
let _ = self.index_allocator.free(old_our_index);
|
||||
trace!(
|
||||
peer = %self.peer_display_name(node_addr),
|
||||
old_index = %old_our_index,
|
||||
"Drain complete, previous session erased"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Snapshot every healthy peer with a session for the rekey decision,
|
||||
/// pre-computing its monotonic ages and timer predicates so the pure core
|
||||
/// applies the thresholds without reading a clock (see [`PeerSnapshot`]).
|
||||
|
||||
Reference in New Issue
Block a user