Add session idle timeout (90s) and identity cache expiry (60s)

Sessions in the Established state that have no activity for 90 seconds
are now automatically removed. This ensures idle sessions are torn down
before transit node coord_cache entries expire (300s TTL), so that when
traffic resumes a fresh SessionSetup re-warms transit node caches with
current coordinates.

The identity cache now stores registration timestamps and expires
entries after 60 seconds via lazy expiry on lookup. This prevents
unbounded growth while allowing natural repopulation through DNS
resolution on next use.

Timer ordering: identity (60s) < session (90s) < coord_cache (300s).

Both timeouts are configurable: node.session.idle_timeout_secs and
node.cache.identity_ttl_secs. Setting idle_timeout_secs to 0 disables
session idle purging.

Changes:
- Add idle_timeout_secs (default 90) to SessionConfig
- Add identity_ttl_secs (default 60) to CacheConfig
- Add timestamp to identity_cache entries, lazy expiry on lookup
- Add purge_idle_sessions() called from tick loop
- Remove #[cfg(test)] from SessionEntry::last_activity()
- 7 new tests covering timeout behavior and edge cases
This commit is contained in:
Johnathan Corgan
2026-02-16 13:13:05 +00:00
parent 5987cbfb69
commit 5f1c6c2c7c
7 changed files with 271 additions and 11 deletions
+1
View File
@@ -79,6 +79,7 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
self.purge_idle_sessions(now_ms);
self.process_pending_retries(now_ms).await;
self.check_tree_state().await;
self.check_bloom_state().await;
+2 -2
View File
@@ -535,7 +535,7 @@ impl Node {
// Look up in identity cache
let (dest_addr, dest_pubkey) = match self.lookup_by_fips_prefix(&prefix) {
Some(&(addr, pk)) => (addr, pk),
Some((addr, pk)) => (addr, pk),
None => {
self.send_icmpv6_dest_unreachable(&ipv6_packet);
return;
@@ -630,7 +630,7 @@ impl Node {
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&dest_addr.as_bytes()[0..15]);
let dest_pubkey = match self.lookup_by_fips_prefix(&prefix) {
Some(&(_, pk)) => pk,
Some((_, pk)) => pk,
None => {
debug!(dest = %dest_addr, "Discovery complete but no identity for session retry");
return;
+31 -2
View File
@@ -1,8 +1,8 @@
//! Timeout management for stale handshake connections.
//! Timeout management for stale handshake connections and idle sessions.
use crate::node::Node;
use crate::transport::LinkId;
use tracing::info;
use tracing::{debug, info};
impl Node {
/// Check for timed-out handshake connections and clean them up.
@@ -78,4 +78,33 @@ impl Node {
// Remove link and addr_to_link
self.remove_link(&link_id);
}
/// Remove established sessions that have been idle too long.
///
/// Only targets sessions in the Established state. Initiating/Responding
/// sessions are handled by the handshake timeout.
pub(in crate::node) fn purge_idle_sessions(&mut self, now_ms: u64) {
let timeout_ms = self.config.node.session.idle_timeout_secs * 1000;
if timeout_ms == 0 {
return; // disabled
}
let idle: Vec<_> = self.sessions.iter()
.filter(|(_, entry)| {
entry.is_established()
&& now_ms.saturating_sub(entry.last_activity()) > timeout_ms
})
.map(|(addr, _)| *addr)
.collect();
for addr in idle {
self.sessions.remove(&addr);
self.pending_tun_packets.remove(&addr);
debug!(
dest = %addr,
idle_secs = timeout_ms / 1000,
"Idle session removed"
);
}
}
}
+15 -4
View File
@@ -254,7 +254,7 @@ pub struct Node {
// === Identity Cache ===
/// Maps FipsAddress prefix bytes (bytes 1-15) to (NodeAddr, PublicKey).
/// Enables reverse lookup from IPv6 destination to session/routing identity.
identity_cache: HashMap<[u8; 15], (NodeAddr, secp256k1::PublicKey)>,
identity_cache: HashMap<[u8; 15], (NodeAddr, secp256k1::PublicKey, u64)>,
// === Pending TUN Packets ===
/// Packets queued while waiting for session establishment.
@@ -862,12 +862,23 @@ impl Node {
pub(crate) fn register_identity(&mut self, node_addr: NodeAddr, pubkey: secp256k1::PublicKey) {
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&node_addr.as_bytes()[0..15]);
self.identity_cache.insert(prefix, (node_addr, pubkey));
self.identity_cache.insert(prefix, (node_addr, pubkey, Self::now_ms()));
}
/// Look up a destination by FipsAddress prefix (bytes 1-15 of the IPv6 address).
pub(crate) fn lookup_by_fips_prefix(&self, prefix: &[u8; 15]) -> Option<&(NodeAddr, secp256k1::PublicKey)> {
self.identity_cache.get(prefix)
/// Returns None if the entry has expired (lazy expiry).
pub(crate) fn lookup_by_fips_prefix(&mut self, prefix: &[u8; 15]) -> Option<(NodeAddr, secp256k1::PublicKey)> {
let ttl_ms = self.config.node.cache.identity_ttl_secs * 1000;
let now_ms = Self::now_ms();
if let Some(&(addr, pk, registered_at)) = self.identity_cache.get(prefix) {
if now_ms.saturating_sub(registered_at) > ttl_ms {
self.identity_cache.remove(prefix);
return None;
}
Some((addr, pk))
} else {
None
}
}
/// Number of identity cache entries.
-1
View File
@@ -113,7 +113,6 @@ impl SessionEntry {
}
/// Get last activity time.
#[cfg(test)]
pub(crate) fn last_activity(&self) -> u64 {
self.last_activity
}
+211 -2
View File
@@ -925,8 +925,8 @@ fn test_identity_cache_populated_on_promote() {
let cached = node.lookup_by_fips_prefix(&prefix);
assert!(cached.is_some(), "Identity cache should contain promoted peer");
let (cached_addr, cached_pk) = cached.unwrap();
assert_eq!(*cached_addr, peer_addr);
assert_eq!(*cached_pk, peer_identity.pubkey_full());
assert_eq!(cached_addr, peer_addr);
assert_eq!(cached_pk, peer_identity.pubkey_full());
}
#[tokio::test]
@@ -1145,3 +1145,212 @@ async fn test_tun_outbound_pending_queue_flush() {
cleanup_nodes(&mut nodes).await;
}
// ============================================================================
// Unit tests: Session idle timeout
// ============================================================================
/// Helper: complete a Noise IK handshake and return the initiator's NoiseSession.
fn make_noise_session(
our_identity: &Identity,
remote_identity: &Identity,
) -> crate::noise::NoiseSession {
use crate::noise::HandshakeState;
let mut initiator = HandshakeState::new_initiator(
our_identity.keypair(),
remote_identity.pubkey_full(),
);
let mut responder = HandshakeState::new_responder(remote_identity.keypair());
let msg1 = initiator.write_message_1().unwrap();
responder.read_message_1(&msg1).unwrap();
let msg2 = responder.write_message_2().unwrap();
initiator.read_message_2(&msg2).unwrap();
initiator.into_session().unwrap()
}
#[test]
fn test_purge_idle_sessions_removes_expired() {
let mut node = make_node();
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let session = make_noise_session(node.identity(), &remote);
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
EndToEndState::Established(session),
1000, // created at t=1000ms
);
node.sessions.insert(remote_addr, entry);
assert_eq!(node.session_count(), 1);
assert!(node.get_session(&remote_addr).unwrap().is_established());
// Purge at t=92s — should exceed default 90s idle timeout
let now_ms = 1000 + 92_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 0, "Idle session should be purged");
}
#[test]
fn test_purge_idle_sessions_keeps_active() {
let mut node = make_node();
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let session = make_noise_session(node.identity(), &remote);
let mut entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
EndToEndState::Established(session),
1000,
);
// Touch at t=80s — recent activity
entry.touch(81_000);
node.sessions.insert(remote_addr, entry);
// Purge at t=92s — only 11s since last activity, well within 90s timeout
let now_ms = 92_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Active session should survive purge");
}
#[test]
fn test_purge_idle_sessions_ignores_initiating() {
use crate::noise::HandshakeState;
let mut node = make_node();
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let handshake = HandshakeState::new_initiator(
node.identity().keypair(),
remote.pubkey_full(),
);
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
EndToEndState::Initiating(handshake),
1000,
);
node.sessions.insert(remote_addr, entry);
// Purge well past the idle timeout — Initiating sessions should not be touched
let now_ms = 1000 + 200_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Initiating session should not be purged by idle timeout");
}
#[test]
fn test_purge_idle_sessions_cleans_pending_packets() {
let mut node = make_node();
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let session = make_noise_session(node.identity(), &remote);
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
EndToEndState::Established(session),
1000,
);
node.sessions.insert(remote_addr, entry);
// Insert some pending packets for this destination
let mut queue = std::collections::VecDeque::new();
queue.push_back(vec![1, 2, 3]);
node.pending_tun_packets.insert(remote_addr, queue);
assert!(node.pending_tun_packets.contains_key(&remote_addr));
// Purge after idle timeout
let now_ms = 1000 + 92_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 0);
assert!(!node.pending_tun_packets.contains_key(&remote_addr),
"Pending packets should be cleaned up with idle session");
}
#[test]
fn test_purge_idle_sessions_disabled_when_zero() {
let mut node = make_node();
node.config.node.session.idle_timeout_secs = 0;
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let session = make_noise_session(node.identity(), &remote);
let entry = crate::node::session::SessionEntry::new(
remote_addr,
remote.pubkey_full(),
EndToEndState::Established(session),
1000,
);
node.sessions.insert(remote_addr, entry);
// Even way past any timeout, sessions should survive when disabled
let now_ms = 1000 + 1_000_000;
node.purge_idle_sessions(now_ms);
assert_eq!(node.session_count(), 1, "Sessions should not be purged when idle timeout is disabled");
}
// ============================================================================
// Unit tests: Identity cache expiry
// ============================================================================
#[test]
fn test_identity_cache_expiry() {
let mut node = make_node();
// Use a short TTL (1s) and insert with an old timestamp
node.config.node.cache.identity_ttl_secs = 1;
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&remote_addr.as_bytes()[0..15]);
// Insert directly with a timestamp far in the past (time 0)
node.identity_cache.insert(prefix, (remote_addr, remote.pubkey_full(), 0));
// Lookup should find the entry expired (registered_at=0, TTL=1s, now >> 1s)
let result = node.lookup_by_fips_prefix(&prefix);
assert!(result.is_none(), "Expired identity should return None");
// Entry should have been removed from cache
assert!(!node.identity_cache.contains_key(&prefix),
"Expired entry should be removed from cache");
}
#[test]
fn test_identity_cache_survives_before_ttl() {
let mut node = make_node();
// Default 60s TTL — just registered, should be available
let remote = Identity::generate();
let remote_addr = *remote.node_addr();
node.register_identity(remote_addr, remote.pubkey_full());
let mut prefix = [0u8; 15];
prefix.copy_from_slice(&remote_addr.as_bytes()[0..15]);
let result = node.lookup_by_fips_prefix(&prefix);
assert!(result.is_some(), "Fresh identity should be available");
let (addr, pk) = result.unwrap();
assert_eq!(addr, remote_addr);
assert_eq!(pk, remote.pubkey_full());
}