feat: implement ICMP Packet Too Big and TCP MSS clamping for MTU handling

Add dual-approach MTU handling to prevent TCP connections from hanging
when packets exceed the transport MTU after FIPS encapsulation.

ICMPv6 Packet Too Big:
- Generate RFC 4443 PTB messages for oversized packets at TUN outbound
- Inject back via TUN for local delivery to the application
- Per-source rate limiting (100ms interval, 10s entry expiry)
- MTU check in handle_tun_outbound before session encapsulation

TCP MSS Clamping:
- Intercept SYN packets in run_tun_reader() (outbound)
- Intercept SYN-ACK packets in TunWriter (inbound)
- Clamp MSS option to fit within effective MTU (transport - 127 overhead)
- Recalculate TCP checksum after modification

Code organization:
- ICMP, TCP MSS, and rate limiter modules in upper/ alongside existing
  protocol-specific packet handling (dns.rs, tun.rs)
- Shared FIPS_OVERHEAD constant (127 bytes) and effective_ipv6_mtu()
  function in upper/icmp.rs
- Node::effective_ipv6_mtu() delegates to the shared function
- run_tun_reader() accepts actual transport MTU from config

Example config corrections:
- UDP transport MTU set to 1472 across all configs (correct max UDP
  payload for standard Ethernet: 1500 - 20 IPv4 - 8 UDP)
- Startup logging of effective MTU and max MSS values
This commit is contained in:
Origami74
2026-02-16 21:33:21 +00:00
committed by Johnathan Corgan
parent 5f1c6c2c7c
commit 852f561fa0
21 changed files with 888 additions and 23 deletions
+2 -2
View File
@@ -232,7 +232,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1197
peers:
- npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
@@ -320,7 +320,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1197
peers: []
```
+11 -2
View File
@@ -2,10 +2,19 @@ FROM debian:bookworm-slim
RUN apt-get update && \
apt-get install -y --no-install-recommends \
iproute2 iputils-ping dnsutils && \
iproute2 iputils-ping dnsutils openssh-client openssh-server && \
rm -rf /var/lib/apt/lists/*
# Setup SSH server with no authentication (test only!)
RUN mkdir -p /var/run/sshd && \
ssh-keygen -A && \
sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config && \
sed -i 's/#PermitEmptyPasswords no/PermitEmptyPasswords yes/' /etc/ssh/sshd_config && \
sed -i 's/UsePAM yes/UsePAM no/' /etc/ssh/sshd_config && \
passwd -d root
COPY fips /usr/local/bin/
RUN chmod +x /usr/local/bin/fips
ENTRYPOINT ["fips", "--config", "/etc/fips/fips.yaml"]
# Start SSH server in background, then run FIPS
ENTRYPOINT ["/bin/bash", "-c", "/usr/sbin/sshd && exec fips --config /etc/fips/fips.yaml"]
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
@@ -20,7 +20,7 @@ dns:
transports:
udp:
bind_addr: "0.0.0.0:4000"
mtu: 1280
mtu: 1472
peers:
- npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m"
+1 -1
View File
@@ -16,7 +16,7 @@ x-fips-common: &fips-common
- net.ipv6.conf.all.disable_ipv6=0
restart: "no"
environment:
- RUST_LOG=info
- RUST_LOG=trace
services:
# ── Mesh topology ──────────────────────────────────────────────
+52
View File
@@ -522,12 +522,23 @@ impl Node {
/// Extracts the destination FipsAddress, looks up the NodeAddr and PublicKey
/// from the identity cache, and either sends through an established session
/// or initiates a new one (queuing the packet until established).
///
/// Also performs MTU checking: if the packet (plus FIPS overhead) exceeds
/// the transport MTU, an ICMP Packet Too Big message is sent back to the
/// source and the packet is dropped.
pub(in crate::node) async fn handle_tun_outbound(&mut self, ipv6_packet: Vec<u8>) {
// Validate IPv6 header
if ipv6_packet.len() < 40 || ipv6_packet[0] >> 4 != 6 {
return;
}
// Check if packet will fit after FIPS encapsulation
let effective_mtu = self.effective_ipv6_mtu() as usize;
if ipv6_packet.len() > effective_mtu {
self.send_icmpv6_packet_too_big(&ipv6_packet, effective_mtu as u16);
return;
}
// Extract destination FipsAddress prefix (IPv6 dest bytes 1-15)
// IPv6 header: bytes 24-39 are dest addr, so prefix = bytes 25-39
let mut prefix = [0u8; 15];
@@ -586,6 +597,47 @@ impl Node {
}
}
/// 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(in crate::node) fn send_icmpv6_packet_too_big(&mut self, original_packet: &[u8], mtu: u16) {
use crate::upper::icmp::build_packet_too_big;
use crate::FipsAddress;
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;
}
let our_ipv6 = FipsAddress::from_node_addr(self.node_addr()).to_ipv6();
if let Some(response) = build_packet_too_big(original_packet, mtu, our_ipv6)
&& let Some(tun_tx) = &self.tun_tx
{
debug!(
src = %src_addr,
dst = %our_ipv6,
packet_size = original_packet.len(),
reported_mtu = mtu,
"Sending ICMP Packet Too Big (ICMP src={}, dst={})",
our_ipv6,
src_addr
);
let _ = tun_tx.send(response);
}
}
/// Queue a packet while waiting for session establishment.
fn queue_pending_packet(&mut self, dest_addr: NodeAddr, packet: Vec<u8>) {
// Reject if we already have too many pending destinations
+10 -2
View File
@@ -277,8 +277,15 @@ impl Node {
info!(" address: {}", device.address());
info!(" mtu: {}", mtu);
// Calculate max MSS for TCP clamping
let effective_mtu = self.effective_ipv6_mtu();
let max_mss = effective_mtu.saturating_sub(40).saturating_sub(20); // IPv6 + TCP headers
info!("effective MTU: {} bytes", effective_mtu);
info!(" max TCP MSS: {} bytes", max_mss);
// Create writer (dups the fd for independent write access)
let (writer, tun_tx) = device.create_writer()?;
let (writer, tun_tx) = device.create_writer(max_mss)?;
// Spawn writer thread
let writer_handle = thread::spawn(move || {
@@ -293,8 +300,9 @@ impl Node {
let (outbound_tx, outbound_rx) = tokio::sync::mpsc::channel(tun_channel_size);
// Spawn reader thread
let transport_mtu = self.transport_mtu();
let reader_handle = thread::spawn(move || {
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx);
run_tun_reader(device, mtu, our_addr, reader_tun_tx, outbound_tx, transport_mtu);
});
self.tun_state = TunState::Active;
+28
View File
@@ -26,6 +26,7 @@ use crate::transport::{
};
use crate::transport::udp::UdpTransport;
use crate::tree::TreeState;
use crate::upper::icmp_rate_limit::IcmpRateLimiter;
use crate::upper::tun::{TunError, TunOutboundRx, TunState, TunTx};
use self::wire::build_encrypted;
use crate::{Config, ConfigError, Identity, IdentityError, NodeAddr};
@@ -313,6 +314,8 @@ pub struct Node {
// === Rate Limiting ===
/// Rate limiter for msg1 processing (DoS protection).
msg1_rate_limiter: HandshakeRateLimiter,
/// Rate limiter for ICMP Packet Too Big messages.
icmp_rate_limiter: IcmpRateLimiter,
// === Tree Announce Timing ===
/// Last time we refreshed our root announcement (Unix seconds).
@@ -406,6 +409,7 @@ impl Node {
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
last_root_refresh_secs: 0,
retry_pending: HashMap::new(),
})
@@ -483,6 +487,7 @@ impl Node {
peers_by_index: HashMap::new(),
pending_outbound: HashMap::new(),
msg1_rate_limiter,
icmp_rate_limiter: IcmpRateLimiter::new(),
last_root_refresh_secs: 0,
retry_pending: HashMap::new(),
}
@@ -558,6 +563,29 @@ impl Node {
&self.config
}
/// Calculate the effective IPv6 MTU that can be sent over FIPS.
///
/// Delegates to `upper::icmp::effective_ipv6_mtu()` with this node's
/// transport MTU. Returns the maximum IPv6 packet size (including
/// IPv6 header) that can be transmitted through the FIPS mesh.
pub fn effective_ipv6_mtu(&self) -> u16 {
crate::upper::icmp::effective_ipv6_mtu(self.transport_mtu())
}
/// Get the transport MTU from configuration.
///
/// Returns the MTU of the first configured UDP transport, or 1280
/// (IPv6 minimum) as fallback.
pub fn transport_mtu(&self) -> u16 {
self.config
.transports
.udp
.iter()
.next()
.map(|(_, cfg)| cfg.mtu())
.unwrap_or(1280)
}
// === State ===
/// Get the node state.
+259
View File
@@ -59,6 +59,25 @@ const ICMPV6_HEADER_LEN: usize = 8;
/// Maximum original packet bytes to include in ICMPv6 error.
const MAX_ORIGINAL_PACKET: usize = MIN_IPV6_MTU - IPV6_HEADER_LEN - ICMPV6_HEADER_LEN;
/// Total FIPS encapsulation overhead (worst case).
///
/// Breakdown:
/// - Noise encryption tag: 16 bytes
/// - Encrypted frame header: 13 bytes
/// - SessionDatagram header: 34 bytes
/// - DataPacket header: 4 bytes
/// - Coordinates (worst case): ~60 bytes (2 coords with depth 3 each)
pub const FIPS_OVERHEAD: u16 = 16 + 13 + 34 + 4 + 60; // 127 bytes
/// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic.
///
/// Given a transport MTU (e.g., UDP payload size), returns the maximum
/// IPv6 packet size (including IPv6 header) that can be transmitted
/// through the FIPS mesh after encapsulation overhead.
pub fn effective_ipv6_mtu(transport_mtu: u16) -> u16 {
transport_mtu.saturating_sub(FIPS_OVERHEAD)
}
/// Check if we should send an ICMPv6 error for this packet.
///
/// Returns false if the packet is:
@@ -193,6 +212,124 @@ pub fn build_dest_unreachable(
Some(response)
}
/// Build an ICMPv6 Packet Too Big response.
///
/// RFC 4443 Section 3.2: Packet Too Big Message
///
/// ## Wire Format
/// ```text
/// 0 1 2 3
/// 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/// | Type=2 | Code=0 | Checksum |
/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/// | MTU |
/// +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
/// | As much of invoking packet |
/// + as possible without exceeding 1280 +
/// | |
/// ```
///
/// ## Parameters
/// - `original_packet`: The oversized IPv6 packet that triggered this error
/// - `mtu`: The MTU value to report (effective IPv6 MTU after FIPS overhead)
/// - `our_addr`: Our FIPS IPv6 address (source of ICMP message)
///
/// ## Returns
/// Complete IPv6 packet containing the ICMP Packet Too Big message,
/// ready to be written to the TUN interface.
pub fn build_packet_too_big(
original_packet: &[u8],
mtu: u16,
our_addr: Ipv6Addr,
) -> Option<Vec<u8>> {
// Validate original packet
if original_packet.len() < IPV6_HEADER_LEN {
return None;
}
// Must be IPv6
let version = original_packet[0] >> 4;
if version != 6 {
return None;
}
// Extract source address from original packet (becomes ICMP destination)
let src_addr = Ipv6Addr::from(<[u8; 16]>::try_from(&original_packet[8..24]).unwrap());
// Don't send ICMP in response to:
// - Multicast sources (ff00::/8)
// - Unspecified source (::)
if src_addr.is_unspecified() || src_addr.octets()[0] == 0xff {
return None;
}
// Don't send ICMP in response to ICMP errors (avoid loops)
let next_header = original_packet[6];
if next_header == IPPROTO_ICMPV6 && original_packet.len() > IPV6_HEADER_LEN {
let icmp_type = original_packet[IPV6_HEADER_LEN];
// ICMPv6 error messages are types 0-127
if icmp_type < 128 {
return None;
}
}
// Calculate how much of the original packet to include
// RFC 4443: "as much of invoking packet as possible without exceeding 1280"
let original_len = original_packet.len().min(MAX_ORIGINAL_PACKET);
let icmpv6_len = ICMPV6_HEADER_LEN + original_len;
let total_len = IPV6_HEADER_LEN + icmpv6_len;
let mut response = vec![0u8; total_len];
// === IPv6 Header ===
// Version (4) + Traffic Class (8) + Flow Label (20)
response[0] = 0x60; // Version 6, TC high bits = 0
// response[1..4] = 0 (TC low bits + flow label)
// Payload length (ICMPv6 header + body)
let payload_len = icmpv6_len as u16;
response[4..6].copy_from_slice(&payload_len.to_be_bytes());
// Next header = ICMPv6
response[6] = IPPROTO_ICMPV6;
// Hop limit
response[7] = 64;
// Source = our address
response[8..24].copy_from_slice(&our_addr.octets());
// Destination = original source
response[24..40].copy_from_slice(&src_addr.octets());
// === ICMPv6 Header ===
let icmp_start = IPV6_HEADER_LEN;
// Type = Packet Too Big
response[icmp_start] = Icmpv6Type::PacketTooBig as u8;
// Code = 0 (always 0 for Packet Too Big)
response[icmp_start + 1] = 0;
// Checksum placeholder (calculated below)
// response[icmp_start + 2..icmp_start + 4] = 0
// MTU (4 bytes, but only first 2 bytes used, last 2 are unused/zero)
response[icmp_start + 4..icmp_start + 6].copy_from_slice(&mtu.to_be_bytes());
// response[icmp_start + 6..icmp_start + 8] = 0 (unused)
// === ICMPv6 Body ===
// As much of original packet as fits
response[icmp_start + ICMPV6_HEADER_LEN..].copy_from_slice(&original_packet[..original_len]);
// Calculate checksum
let checksum = icmpv6_checksum(&response[icmp_start..], &our_addr, &src_addr);
response[icmp_start + 2..icmp_start + 4].copy_from_slice(&checksum.to_be_bytes());
Some(response)
}
/// Calculate ICMPv6 checksum per RFC 4443.
///
/// The checksum is calculated over a pseudo-header plus the ICMPv6 message.
@@ -393,4 +530,126 @@ mod tests {
// Response must not exceed minimum MTU
assert!(response.len() <= MIN_IPV6_MTU);
}
#[test]
fn test_build_packet_too_big() {
let src: Ipv6Addr = "fd00::1".parse().unwrap();
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
let original = make_ipv6_packet(src, dst, 17, &[0u8; 1200]); // Large UDP packet
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let mtu = 1070u16;
let response = build_packet_too_big(&original, mtu, our_addr);
assert!(response.is_some());
let response = response.unwrap();
// Check IPv6 header
assert_eq!(response[0] >> 4, 6); // Version
assert_eq!(response[6], IPPROTO_ICMPV6); // Next header
// Check source is our address
let resp_src = Ipv6Addr::from(<[u8; 16]>::try_from(&response[8..24]).unwrap());
assert_eq!(resp_src, our_addr);
// Check destination is original source
let resp_dst = Ipv6Addr::from(<[u8; 16]>::try_from(&response[24..40]).unwrap());
assert_eq!(resp_dst, src);
// Check ICMPv6 type and code
assert_eq!(response[IPV6_HEADER_LEN], 2); // Type = Packet Too Big
assert_eq!(response[IPV6_HEADER_LEN + 1], 0); // Code = 0
// Check MTU value
let reported_mtu = u16::from_be_bytes([
response[IPV6_HEADER_LEN + 4],
response[IPV6_HEADER_LEN + 5],
]);
assert_eq!(reported_mtu, mtu);
// Response must not exceed minimum MTU
assert!(response.len() <= MIN_IPV6_MTU);
}
#[test]
fn test_build_packet_too_big_invalid_input() {
let short_packet = vec![0u8; 20];
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&short_packet, 1280, our_addr);
assert!(response.is_none());
}
#[test]
fn test_build_packet_too_big_multicast_source() {
let src: Ipv6Addr = "ff02::1".parse().unwrap(); // Multicast
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
let original = make_ipv6_packet(src, dst, 17, &[0u8; 1200]);
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&original, 1280, our_addr);
// Should not send ICMP for multicast source
assert!(response.is_none());
}
#[test]
fn test_build_packet_too_big_unspecified_source() {
let src = Ipv6Addr::UNSPECIFIED;
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
let original = make_ipv6_packet(src, dst, 17, &[0u8; 1200]);
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&original, 1280, our_addr);
// Should not send ICMP for unspecified source
assert!(response.is_none());
}
#[test]
fn test_build_packet_too_big_for_icmp_error() {
let src: Ipv6Addr = "fd00::1".parse().unwrap();
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
// ICMPv6 Destination Unreachable (type 1) - an error message
let icmp_payload = [1u8, 0, 0, 0, 0, 0, 0, 0];
let original = make_ipv6_packet(src, dst, IPPROTO_ICMPV6, &icmp_payload);
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&original, 1280, our_addr);
// Should not send ICMP in response to ICMP error
assert!(response.is_none());
}
#[test]
fn test_build_packet_too_big_for_icmp_echo() {
let src: Ipv6Addr = "fd00::1".parse().unwrap();
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
// ICMPv6 Echo Request (type 128) - informational, not error
let icmp_payload = [128u8, 0, 0, 0, 0, 0, 0, 0];
let original = make_ipv6_packet(src, dst, IPPROTO_ICMPV6, &icmp_payload);
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&original, 1280, our_addr);
// Should send ICMP for informational messages
assert!(response.is_some());
}
#[test]
fn test_build_packet_too_big_truncates_large_packet() {
let src: Ipv6Addr = "fd00::1".parse().unwrap();
let dst: Ipv6Addr = "fd00::2".parse().unwrap();
// Very large payload
let original = make_ipv6_packet(src, dst, 17, &[0u8; 2000]);
let our_addr: Ipv6Addr = "fd00::ffff".parse().unwrap();
let response = build_packet_too_big(&original, 1070, our_addr);
assert!(response.is_some());
let response = response.unwrap();
// Response must not exceed minimum MTU
assert!(response.len() <= MIN_IPV6_MTU);
}
}
+182
View File
@@ -0,0 +1,182 @@
//! ICMP Packet Too Big rate limiting.
//!
//! Prevents ICMP flood from repeated oversized packets by rate-limiting
//! ICMP Packet Too Big messages per source address.
use std::collections::HashMap;
use std::net::Ipv6Addr;
use std::time::{Duration, Instant};
/// Rate limiter for ICMP Packet Too Big messages.
///
/// Tracks the last time an ICMP PTB was sent to each source address
/// and enforces a minimum interval between messages to prevent floods.
pub struct IcmpRateLimiter {
/// Maps source IPv6 address to the last time we sent ICMP PTB to it.
last_sent: HashMap<Ipv6Addr, Instant>,
/// Minimum interval between ICMP messages to the same source.
min_interval: Duration,
/// Maximum age of entries before cleanup (prevents unbounded growth).
max_age: Duration,
}
impl IcmpRateLimiter {
/// Create a new rate limiter.
///
/// Default: max 10 ICMP/sec per source (100ms interval).
pub fn new() -> Self {
Self {
last_sent: HashMap::new(),
min_interval: Duration::from_millis(100),
max_age: Duration::from_secs(10),
}
}
/// Create a rate limiter with custom interval.
pub fn with_interval(min_interval: Duration) -> Self {
Self {
last_sent: HashMap::new(),
min_interval,
max_age: Duration::from_secs(10),
}
}
/// Check if we should send an ICMP PTB to this source address.
///
/// Returns true if enough time has passed since the last ICMP to this source,
/// or if this is the first ICMP to this source.
///
/// If true is returned, the internal state is updated to record this send.
pub fn should_send(&mut self, src_addr: Ipv6Addr) -> bool {
let now = Instant::now();
// Check if we've sent to this source recently
if let Some(&last) = self.last_sent.get(&src_addr)
&& now.duration_since(last) < self.min_interval
{
return false; // Too soon, rate limit
}
// Update last sent time
self.last_sent.insert(src_addr, now);
// Cleanup old entries to prevent unbounded growth
self.cleanup(now);
true
}
/// Remove entries older than max_age.
fn cleanup(&mut self, now: Instant) {
self.last_sent.retain(|_, &mut last| {
now.duration_since(last) < self.max_age
});
}
/// Get the number of tracked sources.
#[cfg(test)]
pub fn len(&self) -> usize {
self.last_sent.len()
}
/// Check if there are no tracked sources.
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.last_sent.is_empty()
}
}
impl Default for IcmpRateLimiter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::thread;
#[test]
fn test_first_send_allowed() {
let mut limiter = IcmpRateLimiter::new();
let addr: Ipv6Addr = "fd00::1".parse().unwrap();
assert!(limiter.should_send(addr));
}
#[test]
fn test_rapid_sends_rate_limited() {
let mut limiter = IcmpRateLimiter::new();
let addr: Ipv6Addr = "fd00::1".parse().unwrap();
// First send should succeed
assert!(limiter.should_send(addr));
// Immediate second send should be rate limited
assert!(!limiter.should_send(addr));
assert!(!limiter.should_send(addr));
}
#[test]
fn test_different_sources_independent() {
let mut limiter = IcmpRateLimiter::new();
let addr1: Ipv6Addr = "fd00::1".parse().unwrap();
let addr2: Ipv6Addr = "fd00::2".parse().unwrap();
// Both sources should be allowed independently
assert!(limiter.should_send(addr1));
assert!(limiter.should_send(addr2));
// But rapid resends to same source are limited
assert!(!limiter.should_send(addr1));
assert!(!limiter.should_send(addr2));
}
#[test]
fn test_send_allowed_after_interval() {
let mut limiter = IcmpRateLimiter::with_interval(Duration::from_millis(50));
let addr: Ipv6Addr = "fd00::1".parse().unwrap();
// First send
assert!(limiter.should_send(addr));
// Wait for interval to pass
thread::sleep(Duration::from_millis(60));
// Second send should now be allowed
assert!(limiter.should_send(addr));
}
#[test]
fn test_cleanup_removes_old_entries() {
let mut limiter = IcmpRateLimiter::new();
let addr1: Ipv6Addr = "fd00::1".parse().unwrap();
let addr2: Ipv6Addr = "fd00::2".parse().unwrap();
// Send to both addresses
assert!(limiter.should_send(addr1));
assert!(limiter.should_send(addr2));
assert_eq!(limiter.len(), 2);
// Manually trigger cleanup with a future timestamp
let future = Instant::now() + Duration::from_secs(11);
limiter.cleanup(future);
// All entries should be cleaned up
assert_eq!(limiter.len(), 0);
}
#[test]
fn test_cleanup_preserves_recent_entries() {
let mut limiter = IcmpRateLimiter::new();
let addr: Ipv6Addr = "fd00::1".parse().unwrap();
assert!(limiter.should_send(addr));
assert_eq!(limiter.len(), 1);
// Cleanup with current time shouldn't remove recent entry
limiter.cleanup(Instant::now());
assert_eq!(limiter.len(), 1);
}
}
+2
View File
@@ -8,4 +8,6 @@
pub mod config;
pub mod dns;
pub mod icmp;
pub mod icmp_rate_limit;
pub mod tcp_mss;
pub mod tun;
+280
View File
@@ -0,0 +1,280 @@
//! TCP MSS (Maximum Segment Size) clamping for MTU handling.
//!
//! Intercepts TCP SYN packets and reduces the MSS option to ensure
//! TCP segments fit within the FIPS effective MTU after encapsulation.
/// TCP header minimum length (without options).
const TCP_HEADER_MIN_LEN: usize = 20;
/// TCP option kind for MSS.
const TCP_OPT_MSS: u8 = 2;
/// TCP option length for MSS (kind + length + value).
const TCP_OPT_MSS_LEN: u8 = 4;
/// TCP flags offset in header.
const TCP_FLAGS_OFFSET: usize = 13;
/// TCP SYN flag bit.
const TCP_FLAG_SYN: u8 = 0x02;
/// Check if a TCP packet is a SYN packet (has SYN flag set).
fn is_tcp_syn(tcp_header: &[u8]) -> bool {
if tcp_header.len() < TCP_HEADER_MIN_LEN {
return false;
}
(tcp_header[TCP_FLAGS_OFFSET] & TCP_FLAG_SYN) != 0
}
/// Get the TCP data offset (header length in 32-bit words).
fn get_tcp_data_offset(tcp_header: &[u8]) -> usize {
if tcp_header.len() < TCP_HEADER_MIN_LEN {
return 0;
}
((tcp_header[12] >> 4) as usize) * 4
}
/// Clamp TCP MSS in a SYN packet if needed.
///
/// Searches for the MSS option in TCP options and reduces it if it exceeds
/// the maximum safe MSS for the given MTU.
///
/// Returns true if the packet was modified (MSS was clamped).
pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool {
// Validate IPv6 header
if ipv6_packet.len() < 40 || ipv6_packet[0] >> 4 != 6 {
return false;
}
// Check if next header is TCP (6)
let next_header = ipv6_packet[6];
if next_header != 6 {
return false;
}
// Get TCP header start
let tcp_start = 40;
if ipv6_packet.len() < tcp_start + TCP_HEADER_MIN_LEN {
return false;
}
let tcp_header = &ipv6_packet[tcp_start..];
// Only process SYN packets
if !is_tcp_syn(tcp_header) {
return false;
}
// Get TCP header length
let tcp_header_len = get_tcp_data_offset(tcp_header);
if tcp_header_len < TCP_HEADER_MIN_LEN || tcp_header_len > tcp_header.len() {
return false;
}
// Parse TCP options
let options_start = tcp_start + TCP_HEADER_MIN_LEN;
let options_end = tcp_start + tcp_header_len;
if options_end > ipv6_packet.len() {
return false;
}
let mut modified = false;
let mut i = options_start;
while i < options_end {
let kind = ipv6_packet[i];
// End of options
if kind == 0 {
break;
}
// NOP (padding)
if kind == 1 {
i += 1;
continue;
}
// All other options have length field
if i + 1 >= options_end {
break;
}
let length = ipv6_packet[i + 1] as usize;
if length < 2 || i + length > options_end {
break;
}
// Check for MSS option
if kind == TCP_OPT_MSS && length == TCP_OPT_MSS_LEN as usize {
// Read current MSS value
let current_mss = u16::from_be_bytes([ipv6_packet[i + 2], ipv6_packet[i + 3]]);
// Clamp if needed
if current_mss > max_mss {
ipv6_packet[i + 2..i + 4].copy_from_slice(&max_mss.to_be_bytes());
// Recalculate TCP checksum
recalculate_tcp_checksum(ipv6_packet, tcp_start);
modified = true;
}
break; // MSS option found, no need to continue
}
i += length;
}
modified
}
/// Recalculate TCP checksum after modifying the packet.
fn recalculate_tcp_checksum(ipv6_packet: &mut [u8], tcp_start: usize) {
// Zero out existing checksum
ipv6_packet[tcp_start + 16] = 0;
ipv6_packet[tcp_start + 17] = 0;
// Extract addresses
let src = &ipv6_packet[8..24];
let dst = &ipv6_packet[24..40];
// Get TCP segment length
let payload_len = u16::from_be_bytes([ipv6_packet[4], ipv6_packet[5]]) as usize;
let tcp_segment = &ipv6_packet[tcp_start..tcp_start + payload_len];
// Calculate checksum with pseudo-header
let mut sum: u32 = 0;
// Pseudo-header: source address
for chunk in src.chunks(2) {
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
}
// Pseudo-header: destination address
for chunk in dst.chunks(2) {
sum += u16::from_be_bytes([chunk[0], chunk[1]]) as u32;
}
// Pseudo-header: TCP length
sum += payload_len as u32;
// Pseudo-header: next header (TCP = 6)
sum += 6;
// TCP segment
for chunk in tcp_segment.chunks(2) {
let value = if chunk.len() == 2 {
u16::from_be_bytes([chunk[0], chunk[1]])
} else {
u16::from_be_bytes([chunk[0], 0])
};
sum += value as u32;
}
// Fold 32-bit sum to 16 bits
while sum >> 16 != 0 {
sum = (sum & 0xffff) + (sum >> 16);
}
// One's complement
let checksum = !sum as u16;
ipv6_packet[tcp_start + 16..tcp_start + 18].copy_from_slice(&checksum.to_be_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
fn make_tcp_syn_packet(src: [u8; 16], dst: [u8; 16], mss: u16) -> Vec<u8> {
let mut packet = vec![0u8; 40 + 40]; // IPv6 + TCP with options
// IPv6 header
packet[0] = 0x60; // Version 6
packet[4..6].copy_from_slice(&40u16.to_be_bytes()); // Payload length
packet[6] = 6; // Next header = TCP
packet[7] = 64; // Hop limit
packet[8..24].copy_from_slice(&src);
packet[24..40].copy_from_slice(&dst);
// TCP header
let tcp_start = 40;
packet[tcp_start..tcp_start + 2].copy_from_slice(&12345u16.to_be_bytes()); // Source port
packet[tcp_start + 2..tcp_start + 4].copy_from_slice(&80u16.to_be_bytes()); // Dest port
packet[tcp_start + 4..tcp_start + 8].copy_from_slice(&1000u32.to_be_bytes()); // Seq
packet[tcp_start + 8..tcp_start + 12].copy_from_slice(&0u32.to_be_bytes()); // Ack
packet[tcp_start + 12] = 0xa0; // Data offset = 10 (40 bytes header)
packet[tcp_start + 13] = TCP_FLAG_SYN; // Flags = SYN
packet[tcp_start + 14..tcp_start + 16].copy_from_slice(&8192u16.to_be_bytes()); // Window
// TCP options: MSS
packet[tcp_start + 20] = TCP_OPT_MSS; // Kind
packet[tcp_start + 21] = TCP_OPT_MSS_LEN; // Length
packet[tcp_start + 22..tcp_start + 24].copy_from_slice(&mss.to_be_bytes()); // MSS value
// End of options
packet[tcp_start + 24] = 0;
// Calculate checksum
recalculate_tcp_checksum(&mut packet, tcp_start);
packet
}
#[test]
fn test_clamp_tcp_mss_reduces_large_mss() {
let src = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
let dst = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2];
let mut packet = make_tcp_syn_packet(src, dst, 1460);
let modified = clamp_tcp_mss(&mut packet, 1200);
assert!(modified);
// Check MSS was clamped
let tcp_start = 40;
let mss = u16::from_be_bytes([packet[tcp_start + 22], packet[tcp_start + 23]]);
assert_eq!(mss, 1200);
}
#[test]
fn test_clamp_tcp_mss_leaves_small_mss_unchanged() {
let src = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
let dst = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2];
let mut packet = make_tcp_syn_packet(src, dst, 1000);
let modified = clamp_tcp_mss(&mut packet, 1200);
assert!(!modified);
// Check MSS unchanged
let tcp_start = 40;
let mss = u16::from_be_bytes([packet[tcp_start + 22], packet[tcp_start + 23]]);
assert_eq!(mss, 1000);
}
#[test]
fn test_clamp_tcp_mss_ignores_non_syn() {
let src = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
let dst = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2];
let mut packet = make_tcp_syn_packet(src, dst, 1460);
// Clear SYN flag
packet[40 + 13] = 0x10; // ACK only
let modified = clamp_tcp_mss(&mut packet, 1200);
assert!(!modified);
}
#[test]
fn test_clamp_tcp_mss_ignores_non_tcp() {
let mut packet = vec![0u8; 80];
packet[0] = 0x60; // IPv6
packet[6] = 17; // UDP, not TCP
let modified = clamp_tcp_mss(&mut packet, 1200);
assert!(!modified);
}
}
+51 -6
View File
@@ -167,7 +167,9 @@ impl TunDevice {
/// This duplicates the underlying file descriptor so that reads and writes
/// can happen independently on separate threads. Returns the writer and
/// a channel sender for submitting packets to be written.
pub fn create_writer(&self) -> Result<(TunWriter, TunTx), TunError> {
///
/// The max_mss parameter is used for TCP MSS clamping on inbound packets.
pub fn create_writer(&self, max_mss: u16) -> Result<(TunWriter, TunTx), TunError> {
let fd = self.device.as_raw_fd();
// Duplicate the file descriptor for writing
@@ -187,6 +189,7 @@ impl TunDevice {
file: write_file,
rx,
name: self.name.clone(),
max_mss,
},
tx,
))
@@ -197,10 +200,13 @@ impl TunDevice {
///
/// Services a queue of outbound packets and writes them to the TUN device.
/// Multiple producers can send packets via the TunTx channel.
///
/// Also performs TCP MSS clamping on inbound SYN-ACK packets.
pub struct TunWriter {
file: File,
rx: mpsc::Receiver<Vec<u8>>,
name: String,
max_mss: u16,
}
impl TunWriter {
@@ -209,9 +215,20 @@ impl TunWriter {
/// Blocks forever, reading packets from the channel and writing them
/// to the TUN device. Returns when the channel is closed (all senders dropped).
pub fn run(mut self) {
debug!(name = %self.name, "TUN writer starting");
use super::tcp_mss::clamp_tcp_mss;
debug!(name = %self.name, max_mss = self.max_mss, "TUN writer starting");
for mut packet in self.rx {
// Clamp TCP MSS on inbound SYN-ACK packets
if clamp_tcp_mss(&mut packet, self.max_mss) {
debug!(
name = %self.name,
max_mss = self.max_mss,
"Clamped TCP MSS in inbound SYN-ACK packet"
);
}
for packet in self.rx {
if let Err(e) = self.file.write_all(&packet) {
// "Bad address" is expected during shutdown when interface is deleted
let err_str = e.to_string();
@@ -233,6 +250,8 @@ impl TunWriter {
/// encapsulation and routing. Non-FIPS packets receive ICMPv6 Destination
/// Unreachable responses.
///
/// Also performs TCP MSS clamping on SYN packets to prevent oversized segments.
///
/// This is designed to run in a dedicated thread since TUN reads are blocking.
/// The loop exits when the TUN interface is deleted (EFAULT) or an unrecoverable
/// error occurs.
@@ -242,18 +261,35 @@ pub fn run_tun_reader(
our_addr: FipsAddress,
tun_tx: TunTx,
outbound_tx: TunOutboundTx,
transport_mtu: u16,
) {
use super::icmp::{build_dest_unreachable, should_send_icmp_error, DestUnreachableCode};
use super::icmp::{build_dest_unreachable, effective_ipv6_mtu, should_send_icmp_error, DestUnreachableCode};
use super::tcp_mss::clamp_tcp_mss;
let name = device.name().to_string();
let mut buf = vec![0u8; mtu as usize + 100]; // Extra space for headers
debug!(name = %name, "TUN reader starting");
// Calculate maximum safe TCP MSS from the effective IPv6 MTU
const IPV6_HEADER: u16 = 40;
const TCP_HEADER: u16 = 20;
let effective_mtu = effective_ipv6_mtu(transport_mtu);
let max_mss = effective_mtu
.saturating_sub(IPV6_HEADER)
.saturating_sub(TCP_HEADER);
debug!(
name = %name,
tun_mtu = mtu,
transport_mtu = transport_mtu,
effective_mtu = effective_mtu,
max_mss = max_mss,
"TUN reader starting"
);
loop {
match device.read_packet(&mut buf) {
Ok(n) if n > 0 => {
let packet = &buf[..n];
let packet = &mut buf[..n];
log_ipv6_packet(packet);
// Must be a valid IPv6 packet
@@ -263,6 +299,15 @@ pub fn run_tun_reader(
// Check if destination is a FIPS address (fd::/8 prefix)
if packet[24] == crate::identity::FIPS_ADDRESS_PREFIX {
// Clamp TCP MSS if this is a SYN packet
if clamp_tcp_mss(packet, max_mss) {
debug!(
name = %name,
max_mss = max_mss,
"Clamped TCP MSS in SYN packet"
);
}
// Forward to Node for session encapsulation and routing
if outbound_tx.blocking_send(packet.to_vec()).is_err() {
break; // Channel closed, shutdown