diff --git a/docs/design/fips-configuration.md b/docs/design/fips-configuration.md index ff30a54..18669d7 100644 --- a/docs/design/fips-configuration.md +++ b/docs/design/fips-configuration.md @@ -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: [] ``` diff --git a/examples/docker-network/Dockerfile b/examples/docker-network/Dockerfile index 6c4f042..e7cf8d3 100644 --- a/examples/docker-network/Dockerfile +++ b/examples/docker-network/Dockerfile @@ -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"] diff --git a/examples/docker-network/configs/chain/node-a.yaml b/examples/docker-network/configs/chain/node-a.yaml index c8b9344..b76290e 100644 --- a/examples/docker-network/configs/chain/node-a.yaml +++ b/examples/docker-network/configs/chain/node-a.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" diff --git a/examples/docker-network/configs/chain/node-b.yaml b/examples/docker-network/configs/chain/node-b.yaml index 5d272ef..d6f6646 100644 --- a/examples/docker-network/configs/chain/node-b.yaml +++ b/examples/docker-network/configs/chain/node-b.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" diff --git a/examples/docker-network/configs/chain/node-c.yaml b/examples/docker-network/configs/chain/node-c.yaml index e15174d..ca1457a 100644 --- a/examples/docker-network/configs/chain/node-c.yaml +++ b/examples/docker-network/configs/chain/node-c.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" diff --git a/examples/docker-network/configs/chain/node-d.yaml b/examples/docker-network/configs/chain/node-d.yaml index f7e76f5..aba5b89 100644 --- a/examples/docker-network/configs/chain/node-d.yaml +++ b/examples/docker-network/configs/chain/node-d.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" diff --git a/examples/docker-network/configs/chain/node-e.yaml b/examples/docker-network/configs/chain/node-e.yaml index 82e287c..3fdb7ab 100644 --- a/examples/docker-network/configs/chain/node-e.yaml +++ b/examples/docker-network/configs/chain/node-e.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" diff --git a/examples/docker-network/configs/mesh/node-a.yaml b/examples/docker-network/configs/mesh/node-a.yaml index 30893a8..1433d8e 100644 --- a/examples/docker-network/configs/mesh/node-a.yaml +++ b/examples/docker-network/configs/mesh/node-a.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1n9lpnv0592cc2ps6nm0ca3qls642vx7yjsv35rkxqzj2vgds52sqgpverl" diff --git a/examples/docker-network/configs/mesh/node-b.yaml b/examples/docker-network/configs/mesh/node-b.yaml index f4e25e7..13150d1 100644 --- a/examples/docker-network/configs/mesh/node-b.yaml +++ b/examples/docker-network/configs/mesh/node-b.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1cld9yay0u24davpu6c35l4vldrhzvaq66pcqtg9a0j2cnjrn9rtsxx2pe6" diff --git a/examples/docker-network/configs/mesh/node-c.yaml b/examples/docker-network/configs/mesh/node-c.yaml index bd03d9e..98c4c2c 100644 --- a/examples/docker-network/configs/mesh/node-c.yaml +++ b/examples/docker-network/configs/mesh/node-c.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1tdwa4vjrjl33pcjdpf2t4p027nl86xrx24g4d3avg4vwvayr3g8qhd84le" diff --git a/examples/docker-network/configs/mesh/node-d.yaml b/examples/docker-network/configs/mesh/node-d.yaml index 57f2eb0..d564d1d 100644 --- a/examples/docker-network/configs/mesh/node-d.yaml +++ b/examples/docker-network/configs/mesh/node-d.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" diff --git a/examples/docker-network/configs/mesh/node-e.yaml b/examples/docker-network/configs/mesh/node-e.yaml index 33494e6..3c1dea5 100644 --- a/examples/docker-network/configs/mesh/node-e.yaml +++ b/examples/docker-network/configs/mesh/node-e.yaml @@ -20,7 +20,7 @@ dns: transports: udp: bind_addr: "0.0.0.0:4000" - mtu: 1280 + mtu: 1472 peers: - npub: "npub1sjlh2c3x9w7kjsqg2ay080n2lff2uvt325vpan33ke34rn8l5jcqawh57m" diff --git a/examples/docker-network/docker-compose.yml b/examples/docker-network/docker-compose.yml index 9c04f0b..3038153 100644 --- a/examples/docker-network/docker-compose.yml +++ b/examples/docker-network/docker-compose.yml @@ -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 ────────────────────────────────────────────── diff --git a/src/node/handlers/session.rs b/src/node/handlers/session.rs index a993bf2..7851707 100644 --- a/src/node/handlers/session.rs +++ b/src/node/handlers/session.rs @@ -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) { // 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) { // Reject if we already have too many pending destinations diff --git a/src/node/lifecycle.rs b/src/node/lifecycle.rs index 2ef1913..44f46f7 100644 --- a/src/node/lifecycle.rs +++ b/src/node/lifecycle.rs @@ -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; diff --git a/src/node/mod.rs b/src/node/mod.rs index 1df41d1..bbb38ff 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -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. diff --git a/src/upper/icmp.rs b/src/upper/icmp.rs index 53866e1..aac682e 100644 --- a/src/upper/icmp.rs +++ b/src/upper/icmp.rs @@ -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> { + // 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); + } } diff --git a/src/upper/icmp_rate_limit.rs b/src/upper/icmp_rate_limit.rs new file mode 100644 index 0000000..39e452a --- /dev/null +++ b/src/upper/icmp_rate_limit.rs @@ -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, + /// 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); + } +} \ No newline at end of file diff --git a/src/upper/mod.rs b/src/upper/mod.rs index c3d57dd..9a04e2f 100644 --- a/src/upper/mod.rs +++ b/src/upper/mod.rs @@ -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; diff --git a/src/upper/tcp_mss.rs b/src/upper/tcp_mss.rs new file mode 100644 index 0000000..129f3f6 --- /dev/null +++ b/src/upper/tcp_mss.rs @@ -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 { + 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); + } +} \ No newline at end of file diff --git a/src/upper/tun.rs b/src/upper/tun.rs index c54b39e..d7a31d6 100644 --- a/src/upper/tun.rs +++ b/src/upper/tun.rs @@ -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>, 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