Move ICMPv6 error sending toward the host into ipv6tun

Building a Destination Unreachable or Packet Too Big and writing it to
the TUN is host-side IPv6 work, but the send logic lived in the node's
session handler. Move it to ipv6tun::icmp behind IcmpContext, which
borrows the TUN channel, our address and the Packet Too Big rate
limiter for the length of one use. Borrowing rather than holding a
clone of the TUN sender keeps teardown able to close the channel.

The logic is unchanged. Packet Too Big still consults the per-source
limiter before anything else, and Destination Unreachable is still
unlimited. The discovery lookup timeout now hands its queued packets to
the context as one no-route report instead of sending one reply per
packet itself. Node keeps send_icmpv6_dest_unreachable and
send_icmpv6_packet_too_big as wrappers over the context, so the outbound
handler and the tests that drive it are unchanged; the Destination
Unreachable wrapper now takes &mut self because the context borrows the
limiter.

The two Packet Too Big debug lines now log under fips::ipv6tun::icmp
instead of fips::node::handlers::session. Add that target to the test
harness filters that relied on fips::node=debug or the session trace
filter to show them, and note the rename in the changelog.
This commit is contained in:
Johnathan Corgan
2026-09-24 14:12:26 +00:00
parent 26201a4fe1
commit f444457270
9 changed files with 128 additions and 63 deletions
+7 -3
View File
@@ -332,9 +332,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`fips::upper::*` to `fips::ipv6tun::*`, because the module they log from is
now `ipv6tun` (for example `fips::upper::tun` becomes `fips::ipv6tun::tun`).
The hosts-file loader and reloader log as `fips::hosts` rather than
`fips::upper::hosts`, since the hosts file is now a top-level module. An
existing `RUST_LOG` filter naming an old target still parses and simply
stops matching, so the symptom is missing log lines rather than an error.
`fips::upper::hosts`, since the hosts file is now a top-level module. The
ICMPv6 Packet Too Big debug lines ("Sending ICMP Packet Too Big", "Rate
limiting ICMP Packet Too Big") log as `fips::ipv6tun::icmp` rather than
`fips::node::handlers::session`, so a `fips::node=debug` filter no longer
shows them. An existing `RUST_LOG` filter naming an old target still parses
and simply stops matching, so the symptom is missing log lines rather than an
error.
Update `RUST_LOG` filters, journal-watch recipes and any log-scraping alert
accordingly. The library path `fips::upper` still resolves.
+97
View File
@@ -4,7 +4,10 @@
//! Currently supports Destination Unreachable (Type 1) for
//! packets that cannot be routed.
use super::icmp_rate_limit::IcmpRateLimiter;
use super::tun::TunTx;
use std::net::Ipv6Addr;
use tracing::debug;
/// ICMPv6 message types.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -370,6 +373,100 @@ pub fn build_packet_too_big(
Some(response)
}
/// Sends ICMPv6 errors back to the host through the TUN writer.
///
/// Holds what both replies need: the channel to the TUN writer, this
/// node's address, and the per-source Packet Too Big limiter. It borrows
/// all three for the duration of one use, so it never keeps the TUN
/// channel open past the adapter's teardown. With no TUN channel the
/// replies are built and dropped.
pub(crate) struct IcmpContext<'a> {
/// Channel to the TUN writer, `None` when no TUN is up.
tun_tx: Option<&'a TunTx>,
/// This node's address, the source of Destination Unreachable replies.
our_addr: Ipv6Addr,
/// Per-source limiter, applied to Packet Too Big only.
limiter: &'a mut IcmpRateLimiter,
}
impl<'a> IcmpContext<'a> {
/// Borrow the TUN channel and limiter, with our address as `our_addr`.
pub(crate) fn new(
tun_tx: Option<&'a TunTx>,
our_addr: Ipv6Addr,
limiter: &'a mut IcmpRateLimiter,
) -> Self {
Self {
tun_tx,
our_addr,
limiter,
}
}
/// Send ICMPv6 Destination Unreachable back through TUN.
///
/// Not rate limited: only Packet Too Big goes through the limiter.
pub(crate) fn dest_unreachable(&self, original_packet: &[u8]) {
if !should_send_icmp_error(original_packet) {
return;
}
if let Some(response) =
build_dest_unreachable(original_packet, DestUnreachableCode::NoRoute, self.our_addr)
&& let Some(tun_tx) = self.tun_tx
{
let _ = tun_tx.send(response);
}
}
/// Answer packets that were queued for a destination found to have no
/// route, with one Destination Unreachable each.
pub(crate) fn no_route<P: AsRef<[u8]>>(&self, packets: impl IntoIterator<Item = P>) {
for packet in packets {
self.dest_unreachable(packet.as_ref());
}
}
/// Send ICMPv6 Packet Too Big back through TUN.
///
/// Rate-limited per source address to prevent ICMP floods from
/// misconfigured applications sending repeated oversized packets.
pub(crate) fn packet_too_big(&mut self, original_packet: &[u8], mtu: u32) {
// Extract source address for rate limiting
if original_packet.len() < 40 {
return;
}
let src_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[8..24]).unwrap());
// Rate limit ICMP PTB messages per source
if !self.limiter.should_send(src_addr) {
debug!(
src = %src_addr,
"Rate limiting ICMP Packet Too Big"
);
return;
}
// Use the original packet's *destination* as the ICMP source so the
// kernel sees the PTB coming from a remote router, not from itself.
// Linux ignores PTBs whose source matches a local address, which
// causes a PMTUD blackhole when both src and ICMP-src are local.
let dest_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[24..40]).unwrap());
if let Some(response) = build_packet_too_big(original_packet, mtu, dest_addr)
&& let Some(tun_tx) = self.tun_tx
{
debug!(
original_src = %src_addr,
original_dst = %dest_addr,
packet_size = original_packet.len(),
reported_mtu = mtu,
"Sending ICMP Packet Too Big"
);
let _ = tun_tx.send(response);
}
}
}
/// Calculate ICMPv6 checksum per RFC 4443.
///
/// The checksum is calculated over a pseudo-header plus the ICMPv6 message.
+1 -3
View File
@@ -793,9 +793,7 @@ impl Node {
"Discovery lookup timed out, destination unreachable"
);
if let Some(packets) = queued {
for pkt in &packets {
self.send_icmpv6_dest_unreachable(pkt);
}
self.host_icmp().no_route(&packets);
}
}
}
+15 -52
View File
@@ -6,6 +6,7 @@
//! encrypted data, and error signals (CoordsRequired, PathBroken).
use crate::NodeAddr;
use crate::ipv6tun::icmp::IcmpContext;
use crate::node::handlers::mmp::format_throughput;
use crate::node::rate_limit::Msg1Class;
use crate::node::reject::{RejectReason, SessionReject};
@@ -3143,24 +3144,20 @@ impl Node {
self.queue_pending_packet(dest_addr, ipv6_packet);
}
/// Borrow the host-facing ICMPv6 sender: the TUN channel, our address
/// and the Packet Too Big rate limiter.
pub(in crate::node) fn host_icmp(&mut self) -> IcmpContext<'_> {
let our_ipv6 = crate::FipsAddress::from_node_addr(self.node_addr()).to_ipv6();
IcmpContext::new(
self.supervisor.tun_tx.as_ref(),
our_ipv6,
&mut self.icmp_rate_limiter,
)
}
/// Send ICMPv6 Destination Unreachable back through TUN.
pub(in crate::node) fn send_icmpv6_dest_unreachable(&self, original_packet: &[u8]) {
use crate::FipsAddress;
use crate::upper::icmp::{
DestUnreachableCode, build_dest_unreachable, should_send_icmp_error,
};
if !should_send_icmp_error(original_packet) {
return;
}
let our_ipv6 = FipsAddress::from_node_addr(self.node_addr()).to_ipv6();
if let Some(response) =
build_dest_unreachable(original_packet, DestUnreachableCode::NoRoute, our_ipv6)
&& let Some(tun_tx) = &self.supervisor.tun_tx
{
let _ = tun_tx.send(response);
}
pub(in crate::node) fn send_icmpv6_dest_unreachable(&mut self, original_packet: &[u8]) {
self.host_icmp().dest_unreachable(original_packet);
}
/// Send ICMPv6 Packet Too Big back through TUN.
@@ -3168,41 +3165,7 @@ impl Node {
/// Rate-limited per source address to prevent ICMP floods from
/// misconfigured applications sending repeated oversized packets.
pub(in crate::node) fn send_icmpv6_packet_too_big(&mut self, original_packet: &[u8], mtu: u32) {
use crate::upper::icmp::build_packet_too_big;
use std::net::Ipv6Addr;
// Extract source address for rate limiting
if original_packet.len() < 40 {
return;
}
let src_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[8..24]).unwrap());
// Rate limit ICMP PTB messages per source
if !self.icmp_rate_limiter.should_send(src_addr) {
debug!(
src = %src_addr,
"Rate limiting ICMP Packet Too Big"
);
return;
}
// Use the original packet's *destination* as the ICMP source so the
// kernel sees the PTB coming from a remote router, not from itself.
// Linux ignores PTBs whose source matches a local address, which
// causes a PMTUD blackhole when both src and ICMP-src are local.
let dest_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[24..40]).unwrap());
if let Some(response) = build_packet_too_big(original_packet, mtu, dest_addr)
&& let Some(tun_tx) = &self.supervisor.tun_tx
{
debug!(
original_src = %src_addr,
original_dst = %dest_addr,
packet_size = original_packet.len(),
reported_mtu = mtu,
"Sending ICMP Packet Too Big"
);
let _ = tun_tx.send(response);
}
self.host_icmp().packet_too_big(original_packet, mtu);
}
/// Queue a packet while waiting for session establishment.
+1 -1
View File
@@ -157,7 +157,7 @@ Rejected peer by ACL ... context=inbound_handshake decision=denylist match
```
Those messages are now emitted at debug level. This harness enables
`RUST_LOG=info,fips::node=debug` so the ACL rejection details stay visible in
`fips::node=debug` in `RUST_LOG` so the ACL rejection details stay visible in
test logs, and operators can temporarily raise log level the same way when
diagnosing ACL issues locally.
+1 -1
View File
@@ -35,7 +35,7 @@ x-fips-common: &fips-common
restart: "no"
environment:
- FIPS_TEST_MODE=default
- RUST_LOG=info,fips::node=debug
- RUST_LOG=info,fips::node=debug,fips::ipv6tun::icmp=debug
volumes:
- ../docker/resolv.conf:/etc/resolv.conf:ro
+1 -1
View File
@@ -36,7 +36,7 @@ x-fips-common: &fips-common
restart: "no"
environment:
- FIPS_TEST_MODE=default
- RUST_LOG=info,fips::node=debug
- RUST_LOG=info,fips::node=debug,fips::ipv6tun::icmp=debug
services:
service-a:
+1 -1
View File
@@ -33,7 +33,7 @@ x-fips-common: &fips-common
# the daemon, which is precisely the workaround this mechanism retires. The
# daemon must do its own waiting here or the suite proves nothing.
- FIPS_TEST_MODE=default
- RUST_LOG=info,fips::transport::ethernet=debug,fips::node=debug
- RUST_LOG=info,fips::transport::ethernet=debug,fips::node=debug,fips::ipv6tun::icmp=debug
networks:
- ifb-net
+4 -1
View File
@@ -9,6 +9,9 @@
# - fips::node::handlers::session — FSP K-bit cutover, drain
# - fips::node::dataplane::encrypted — FMP K-bit flip detection
# - fips::node::handlers::mmp — link liveness, SRTT, ETX
# - fips::ipv6tun::icmp — ICMPv6 Packet Too Big toward
# the host (formerly logged
# under handlers::session)
#
# Other modules stay at info to keep log volume manageable. The base
# docker-compose.yml's per-service RUST_LOG values (currently
@@ -33,7 +36,7 @@
# environment variable FIPS_MESH_LAB_TRACE=1 is set.
x-trace-rust-log: &trace-rust-log
RUST_LOG: "info,fips::node::handlers::rekey=trace,fips::node::handlers::handshake=trace,fips::node::dataplane::forwarding=trace,fips::node::handlers::session=trace,fips::node::dataplane::encrypted=trace,fips::node::handlers::mmp=trace"
RUST_LOG: "info,fips::node::handlers::rekey=trace,fips::node::handlers::handshake=trace,fips::node::dataplane::forwarding=trace,fips::node::handlers::session=trace,fips::node::dataplane::encrypted=trace,fips::node::handlers::mmp=trace,fips::ipv6tun::icmp=trace"
services:
# rekey profile