From c70b924aa9da9e98be7d41c441b05f21d3db6b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 6 Mar 2026 17:27:41 +0100 Subject: [PATCH 01/10] Add incremental checksum utils --- gotatun/src/packet/util.rs | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/gotatun/src/packet/util.rs b/gotatun/src/packet/util.rs index 85be8b24..0da73b68 100644 --- a/gotatun/src/packet/util.rs +++ b/gotatun/src/packet/util.rs @@ -177,6 +177,43 @@ pub fn checksum_tcp_with_skip(header: H, tcp: &Tcp) -> finalize_csum(sum) } +/// RFC 1624 incremental checksum update. +/// +/// Given the old checksum and the old/new values of the changed 16-bit words, +/// compute the new checksum without scanning the entire packet. +/// +/// HC' = ~(~HC + ~m + m') where HC is the old checksum, m is old data, m' is new data. +pub fn incremental_checksum_update( + old_csum: u16, + old_addr: big_endian::U32, + new_addr: big_endian::U32, +) -> u16 { + let mut sum = u32::from(!old_csum); + sum += u32::from(!(old_addr.get() >> 16) as u16); + sum += u32::from(!(old_addr.get() & 0xffff) as u16); + sum += u32::from((new_addr.get() >> 16) as u16); + sum += u32::from((new_addr.get() & 0xffff) as u16); + finalize_csum(sum) +} + +/// RFC 1624 incremental checksum update for a 128-bit IPv6 address. +/// +/// HC' = ~(~HC + ~m + m') where HC is the old checksum, m is old data, m' is new data. +pub fn incremental_checksum_update_v6( + old_csum: u16, + old_addr: big_endian::U128, + new_addr: big_endian::U128, +) -> u16 { + let mut sum = u32::from(!old_csum); + let old = old_addr.get(); + let new = new_addr.get(); + for i in 0..8u32 { + sum += u32::from(!((old >> (i * 16)) as u16)); + sum += u32::from((new >> (i * 16)) as u16); + } + finalize_csum(sum) +} + fn checksum_payload(bytes: &[u8]) -> u32 { let (words, rest) = <[big_endian::U16]>::ref_from_prefix(bytes).unwrap(); From 1e0bf4bf7ccc4ea124c173edef9c353742bf84f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 30 Jan 2026 19:44:38 +0100 Subject: [PATCH 02/10] Add adapters to support "per-route" multihop The multihop-router demonstrates how this can be used. --- gotatun/examples/multihop-router.rs | 373 ++++++++++++++++ gotatun/src/device/tests.rs | 159 +++++++ gotatun/src/device/tests/mock.rs | 9 + gotatun/src/packet/ip.rs | 25 +- gotatun/src/packet/ipv4/mod.rs | 6 + gotatun/src/tun/merge.rs | 58 +++ gotatun/src/tun/mod.rs | 3 + gotatun/src/tun/nat.rs | 669 ++++++++++++++++++++++++++++ gotatun/src/tun/router.rs | 176 ++++++++ 9 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 gotatun/examples/multihop-router.rs create mode 100644 gotatun/src/tun/merge.rs create mode 100644 gotatun/src/tun/nat.rs create mode 100644 gotatun/src/tun/router.rs diff --git a/gotatun/examples/multihop-router.rs b/gotatun/examples/multihop-router.rs new file mode 100644 index 00000000..4da4961f --- /dev/null +++ b/gotatun/examples/multihop-router.rs @@ -0,0 +1,373 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This file incorporates work covered by the following copyright and +// permission notice: +// +// Copyright (c) Mullvad VPN AB. All rights reserved. +// +// SPDX-License-Identifier: MPL-2.0 + +//! Tunnel-in-tunnel multihop example with router. +//! +//! Chains two WireGuard devices: an **inner** device whose encrypted traffic +//! is routed through an **outer** device before reaching the network. +//! +//! # Inbound packets (simplified) +//! +//! ```text +//! Outer WG device +//! UDP ────────────decapsulate()────────────► route by source address +//! recv() | +//! ┌─────────────┴──────────────┐ +//! │ │ +//! inner tunnel everything +//! endpoint else +//! │ │ +//! ▼ ▼ +//! Inner WG device TUN device write() +//! decapsulate() (app receives stuff on its sockets) +//! │ +//! ▼ +//! DNAT: rewrite dest IP +//! (inner → outer) +//! │ +//! ▼ +//! TUN device write() +//! (app receives stuff on its sockets) +//! +//! 1. Data comes in from entry hop. It's decapsulated by the outer WG device. +//! ("Device" is fully in userspace here, not an actual TUN device.) +//! 2. If source IP of the packet is the inner WG endpoint, pass it to the inner device. +//! Otherwise, pass it to the TUN device. +//! 3. Inner WG device: Decapsulate again and NAT it (its tunnel device). +//! This replaces the tunnel IP used for the inner VPN tunnel with the IP assigned to the TUN +//! device. +//! +//! # Outgoing packets (simplified) +//! +//! TUN device +//! read() +//! (default route) +//! │ +//! ▼ +//! route by destination address +//! │ +//! ┌──────────────┴───────────────┐ +//! │ │ +//! inner tunnel everything +//! allowed IPs else +//! │ │ +//! ▼ ▼ +//! NAT: rewrite src IP Outer WG device +//! (outer → inner) encapsulate() ──► UDP +//! │ send() +//! ▼ +//! Inner WG device +//! encapsulate() +//! │ +//! ▼ +//! Outer WG device +//! encapsulate() ──► UDP send() +//! +//! 1. Packets arrive at our TUN device (route 0.0.0.0/0 to TUN). +//! 2. If an IP packet's destination is included in our inner tunnel's allowed IPs: +//! The tunnel IP is replaced (NAT'd) with that of the inner VPN. It is encapsulated by the +//! inner WG device. +//! 3. The packet is encapsulated with the outer WG device and sent to the first hop. +//! ``` +//! +//! # Running this example +//! +//! Run (requires root for TUN creation): +//! ```sh +//! cargo run --example multihop-router --features tun,device -- \ +//! --inner-endpoint relay.example.com:51820 \ +//! --outer-tun-ip 10.0.0.1 \ +//! --inner-tun-ip 172.16.0.1 \ +//! --inner-private-key \ +//! --outer-private-key \ +//! --exit-pubkey \ +//! --relay-pubkey \ +//! --relay-endpoint 1.2.3.4:51820 +//! ``` + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; + +use base64::Engine; +use clap::Parser; +use eyre::{Context, ContextCompat}; +#[cfg(feature = "pcap")] +use gotatun::tun::pcap::{PcapSniffer, PcapStream}; +use gotatun::{ + device::{DeviceBuilder, Peer}, + packet::{Ipv4Header, Ipv6Header, PacketBufPool, UdpHeader, WgData}, + tun::{ + IpRecv, merge::MergingIpRecv, router::TunRxRouter, router::TunTxRouter, + tun_async_device::TunDevice, + }, + udp::channel::new_udp_tun_channel, +}; + +/// Tunnel-in-tunnel multihop with a TUN router. +#[derive(Parser)] +struct Args { + /// Inner tunnel endpoint (hostname:port, will be DNS-resolved). + #[arg(long)] + inner_endpoint: String, + + /// Outer tunnel IP address assigned to the TUN device. + #[arg(long)] + outer_tun_ip: Ipv4Addr, + + /// Inner tunnel IP address. + #[arg(long)] + inner_tun_ip: Ipv4Addr, + + /// Inner device private key (base64-encoded). + #[arg(long)] + inner_private_key: String, + + /// Outer device private key (base64-encoded). + #[arg(long)] + outer_private_key: String, + + /// Exit server public key (base64-encoded). + #[arg(long)] + exit_pubkey: String, + + /// Relay server public key (base64-encoded). + #[arg(long)] + relay_pubkey: String, + + /// Relay server endpoint (ip:port). + #[arg(long)] + relay_endpoint: SocketAddr, + + /// Inner tunnel allowed IP range (CIDR notation). + #[arg(long, default_value = "172.16.10.0/24")] + inner_allowed_ip: String, + + /// Outer tunnel allowed IP range (CIDR notation). + #[arg(long, default_value = "0.0.0.0/0")] + outer_allowed_ip: String, + + /// TUN device name. + #[arg(long, default_value = "wg-router")] + tun_name: String, + + /// Kernel-side MTU for the TUN device. + #[arg(long, default_value = "1380")] + mtu: u16, + + /// Additional routes to add through the TUN device (CIDR notation, repeatable). + #[arg(long = "route")] + routes: Vec, + + /// PCAP unix socket path for Wireshark (requires `--features pcap`). + #[cfg(feature = "pcap")] + #[arg(long, default_value = "/tmp/multihop.pcap")] + pcap_socket: String, +} + +async fn run_ip(args: &[&str]) -> eyre::Result<()> { + let status = tokio::process::Command::new("ip") + .args(args) + .status() + .await + .with_context(|| format!("failed to run: ip {}", args.join(" ")))?; + eyre::ensure!( + status.success(), + "ip {} failed with {status}", + args.join(" ") + ); + Ok(()) +} + +async fn setup_tun_device( + name: &str, + addr: Ipv4Addr, + mtu: u16, + routes: &[String], +) -> eyre::Result<()> { + let addr_cidr = format!("{addr}/32"); + let mtu_str = mtu.to_string(); + + run_ip(&["addr", "add", "dev", name, &addr_cidr]).await?; + run_ip(&["link", "set", "dev", name, "up"]).await?; + run_ip(&["link", "set", "dev", name, "mtu", &mtu_str]).await?; + + for route in routes { + run_ip(&["route", "add", "dev", name, route]).await?; + } + + Ok(()) +} + +fn parse_key>(b64: &str) -> eyre::Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .context("invalid base64 for key")?; + let array: [u8; 32] = bytes + .as_slice() + .try_into() + .context("key must be 32 bytes")?; + Ok(T::from(array)) +} + +#[tokio::main] +async fn main() -> eyre::Result<()> { + tracing_subscriber::fmt().pretty().init(); + + let args = Args::parse(); + + let inner_tun_endpoint = tokio::net::lookup_host(&args.inner_endpoint) + .await + .context("Failed to resolve inner endpoint")? + .next() + .context("No addresses found for inner endpoint")?; + + let inner_private = + parse_key(&args.inner_private_key).context("Failed to parse inner private key")?; + let outer_private = + parse_key(&args.outer_private_key).context("Failed to parse outer private key")?; + let exit_server_pubkey = + parse_key(&args.exit_pubkey).context("Failed to parse exit server public key")?; + let relay_server_pubkey = + parse_key(&args.relay_pubkey).context("Failed to parse relay server public key")?; + + let inner_allowed_ip: ipnetwork::IpNetwork = args + .inner_allowed_ip + .parse() + .context("invalid CIDR for --inner-allowed-ip")?; + let outer_allowed_ip: ipnetwork::IpNetwork = args + .outer_allowed_ip + .parse() + .context("invalid CIDR for --outer-allowed-ip")?; + + let packet_pool = PacketBufPool::new(100); + + // Create actual TUN device + let tun = TunDevice::from_name(&args.tun_name)?; + + // Configure the TUN interface (addr, link up, mtu, routes) + let mut routes: Vec = vec![args.inner_allowed_ip.clone()]; + routes.extend(args.routes.iter().cloned()); + setup_tun_device(&args.tun_name, args.outer_tun_ip, args.mtu, &routes).await?; + + let multihop_overhead = match inner_tun_endpoint.ip() { + IpAddr::V4(..) => Ipv4Header::LEN + UdpHeader::LEN + WgData::OVERHEAD, + IpAddr::V6(..) => Ipv6Header::LEN + UdpHeader::LEN + WgData::OVERHEAD, + }; + let mtu = tun.mtu().increase(multihop_overhead as u16).unwrap(); + + // Channel bridge (inner device UDP <-> outer device TUN) + let (bridge_tun_tx, bridge_tun_rx, inner_udp) = + new_udp_tun_channel(4000, args.outer_tun_ip, Ipv6Addr::UNSPECIFIED, mtu); + + // TUN router (split real TUN reads by dest IP) + let mut router = TunRxRouter::new(tun.clone()); + let default_output = router.add_default_route(4000); + let alt_output = router.add_route(inner_allowed_ip, 4000); + let router_task = tokio::spawn(router.run(packet_pool.clone())); + + // Outer device IpRecv: merge direct + inner encrypted + let outer_ip_recv = MergingIpRecv::new(default_output, bridge_tun_rx, packet_pool); + + // Outer device IpSend: split decrypted packets into two streams + let outer_ip_send = TunTxRouter::new(bridge_tun_tx, tun.clone(), inner_tun_endpoint); + + // NAT: rewrite src/dst IPs between inner and outer tunnel addresses + let tun_send = + gotatun::tun::nat::NatIpSend::new(tun.clone(), args.inner_tun_ip, args.outer_tun_ip); + let alt_recv = + gotatun::tun::nat::NatIpRecv::new(alt_output, args.outer_tun_ip, args.inner_tun_ip); + + // Inner device + let inner_device = DeviceBuilder::new() + .with_udp(inner_udp) + .with_ip_pair(tun_send, alt_recv) + .with_private_key(inner_private) + .with_peer( + Peer::new(exit_server_pubkey) + .with_endpoint(inner_tun_endpoint) + .with_allowed_ip(inner_allowed_ip), + ) + .build() + .await?; + + #[cfg(feature = "pcap")] + let (outer_ip_send, outer_ip_recv) = + wrap_in_pcap_sniffer(outer_ip_send, outer_ip_recv, &args.pcap_socket); + + // Outer device + let outer_device = DeviceBuilder::new() + .with_default_udp() + .with_ip_pair(outer_ip_send, outer_ip_recv) + .with_private_key(outer_private) + .with_peer( + Peer::new(relay_server_pubkey) + .with_endpoint(args.relay_endpoint) + .with_allowed_ip(outer_allowed_ip), + ) + .build() + .await?; + + println!("Multihop tunnel running. Press Ctrl-C to stop."); + + tokio::signal::ctrl_c().await?; + + drop(inner_device); + drop(outer_device); + router_task.abort(); + + Ok(()) +} + +/// Wrap `ip_send` and `ip_recv` in [`PcapSniffer`]s for use with Wireshark. +/// +/// With userspace multihop, the exit device communicates with the network through the +/// entry device, without going through the kernel. That means there is no network interface +/// for Wireshark to sniff. By interposing [`PcapSniffer`]s, any packets that are sent or +/// received will _also_ be written to a unix socket, encoded using the pcap file format. +/// +/// The unix socket can be opened in Wireshark: +/// ```sh +/// wireshark -k -i /tmp/multihop.pcap +/// ``` +#[cfg(feature = "pcap")] +fn wrap_in_pcap_sniffer( + ip_send: S, + ip_recv: R, + socket_path: &str, +) -> (PcapSniffer, PcapSniffer) +where + S: gotatun::tun::IpSend, + R: gotatun::tun::IpRecv, +{ + use std::{ + fs, + os::unix::{fs::PermissionsExt, net::UnixListener}, + time::Instant, + }; + + eprintln!("Binding pcap socket to {socket_path:?}"); + let _ = fs::remove_file(socket_path); + let listener = UnixListener::bind(socket_path).unwrap(); + let _ = fs::set_permissions(socket_path, fs::Permissions::from_mode(0o777)); + + eprintln!("Waiting for Wireshark connection..."); + eprintln!(" wireshark -k -i {socket_path}"); + let (stream, _) = listener + .accept() + .expect("Error while waiting for pcap listener"); + + let writer = PcapStream::new(Box::new(stream)); + let start_time = Instant::now(); + + let ip_send = PcapSniffer::new(ip_send, writer.clone(), start_time); + let ip_recv = PcapSniffer::new(ip_recv, writer, start_time); + + (ip_send, ip_recv) +} diff --git a/gotatun/src/device/tests.rs b/gotatun/src/device/tests.rs index 27b9f1f6..4a6c0636 100644 --- a/gotatun/src/device/tests.rs +++ b/gotatun/src/device/tests.rs @@ -9,6 +9,7 @@ // // SPDX-License-Identifier: MPL-2.0 +use std::net::{Ipv4Addr, Ipv6Addr}; use std::{future::ready, time::Duration}; use futures::{StreamExt, future::pending}; @@ -17,6 +18,19 @@ use rand::{SeedableRng, rngs::StdRng}; use tokio::{join, select, time::sleep}; use zerocopy::IntoBytes; +use ipnetwork::{IpNetwork, Ipv4Network}; +use tokio::sync::mpsc; +use x25519_dalek::{PublicKey, StaticSecret}; + +use crate::device::{DeviceBuilder, Peer}; +use crate::packet::PacketBufPool; +use crate::tun::{ + IpRecv, + nat::{NatIpRecv, NatIpSend}, + router::TunRxRouter, +}; +use crate::udp::channel::{UdpChannelFactory, UdpChannelV4, UdpChannelV6}; + use crate::noise::index_table::IndexTable; pub mod mock; @@ -179,6 +193,151 @@ async fn test_endpoint_roaming() { ping_pong("1.2.3.4".parse().unwrap()).await; } +/// Test that [`TunRxRouter`] routes packets to alt or default receiver +/// based on whether their destination IP matches the configured network. +#[tokio::test] +#[test_log::test] +async fn test_tun_router() { + let (source, app_tx, _app_rx) = mock::mock_tun(); + let inner_allowed_ip: IpNetwork = "10.100.0.0/24".parse().unwrap(); + + let mut router = TunRxRouter::new(source); + let mut default_recv = router.add_default_route(10); + let mut alt_recv = router.add_route(inner_allowed_ip, 10); + + let mut pool = PacketBufPool::new(10); + + tokio::spawn(router.run(pool.clone())); + + let src: Ipv4Addr = "10.64.0.2".parse().unwrap(); + let inner_dst: Ipv4Addr = "10.100.0.1".parse().unwrap(); + let other_dst: Ipv4Addr = "8.8.8.8".parse().unwrap(); + + // Packet destined for inner allowed IP -> alt_recv + app_tx + .send(mock::packet_with_addrs(src, inner_dst, b"hello inner")) + .await; + let received = alt_recv.recv(&mut pool).await.unwrap().next().unwrap(); + assert_eq!(received.destination(), Some(inner_dst.into())); + assert_eq!(received.payload(), Some(b"hello inner".as_slice())); + + // Packet destined for other IP -> default_recv + app_tx + .send(mock::packet_with_addrs(src, other_dst, b"hello default")) + .await; + let received = default_recv.recv(&mut pool).await.unwrap().next().unwrap(); + assert_eq!(received.destination(), Some(other_dst.into())); + assert_eq!(received.payload(), Some(b"hello default".as_slice())); +} + +/// Test [`NatIpRecv`] and [`NatIpSend`] +/// +/// - Outgoing: app sends src=outer_tun_ip -> Bob receives src=inner_tun_ip +/// - Incoming: Bob sends dst=inner_tun_ip -> app receives dst=outer_tun_ip +#[tokio::test] +#[test_log::test] +async fn test_tunnel_nat() { + let outer_tun_ip: Ipv4Addr = "10.64.0.2".parse().unwrap(); + let inner_tun_ip: Ipv4Addr = "172.16.0.1".parse().unwrap(); + let some_addr: Ipv4Addr = "10.100.0.5".parse().unwrap(); + + async fn forward(mut eve_rx: mpsc::Receiver, eve_tx: mpsc::Sender) { + loop { + let Some(packet) = eve_rx.recv().await else { + break; + }; + if eve_tx.send(packet).await.is_err() { + break; + } + } + } + + let (alice_mock_tun, alice_app_tx, mut alice_app_rx) = mock::mock_tun(); + let (bob_mock_tun, bob_app_tx, mut bob_app_rx) = mock::mock_tun(); + + let port = 51820u16; + let endpoint_alice = Ipv4Addr::new(10, 0, 0, 1); + let endpoint_bob = Ipv4Addr::new(10, 0, 0, 2); + + const CHANNEL_CAPACITY: usize = 10; + let (alice_v4, alice_eve_v4) = UdpChannelV4::new_pair(CHANNEL_CAPACITY); + let (bob_v4, bob_eve_v4) = UdpChannelV4::new_pair(CHANNEL_CAPACITY); + let (alice_v6, alice_eve_v6) = UdpChannelV6::new_pair(CHANNEL_CAPACITY); + let (bob_v6, bob_eve_v6) = UdpChannelV6::new_pair(CHANNEL_CAPACITY); + + // Relay alice <-> bob (IPv4) + tokio::spawn(forward(alice_eve_v4.rx, bob_eve_v4.tx)); + tokio::spawn(forward(bob_eve_v4.rx, alice_eve_v4.tx)); + + // Relay alice <-> bob (IPv6) + tokio::spawn(forward(alice_eve_v6.rx, bob_eve_v6.tx)); + tokio::spawn(forward(bob_eve_v6.rx, alice_eve_v6.tx)); + + let udp_alice = + UdpChannelFactory::new(endpoint_alice, alice_v4, Ipv6Addr::UNSPECIFIED, alice_v6); + let udp_bob = UdpChannelFactory::new(endpoint_bob, bob_v4, Ipv6Addr::UNSPECIFIED, bob_v6); + + let privkey_alice = StaticSecret::random(); + let privkey_bob = StaticSecret::random(); + let pubkey_alice = PublicKey::from(&privkey_alice); + let pubkey_bob = PublicKey::from(&privkey_bob); + + let allow_all: IpNetwork = Ipv4Network::new(Ipv4Addr::UNSPECIFIED, 0).unwrap().into(); + + let peer_alice = Peer::new(pubkey_alice) + .with_endpoint((endpoint_alice, port).into()) + .with_allowed_ip(allow_all); + let peer_bob = Peer::new(pubkey_bob) + .with_endpoint((endpoint_bob, port).into()) + .with_allowed_ip(allow_all); + + // Alice uses NAT adapters: + // NatIpRecv: rewrites outgoing src outer_tun_ip -> inner_tun_ip + // NatIpSend: rewrites incoming dst inner_tun_ip -> outer_tun_ip + let alice_rx = NatIpRecv::new(alice_mock_tun.clone(), outer_tun_ip, inner_tun_ip); + let alice_tx = NatIpSend::new(alice_mock_tun, inner_tun_ip, outer_tun_ip); + + let _alice = DeviceBuilder::new() + .with_private_key(privkey_alice) + .with_ip_pair(alice_tx, alice_rx) + .with_udp(udp_alice) + .with_listen_port(port) + .with_peer(peer_bob) + .build() + .await + .expect("create alice device"); + + let _bob = DeviceBuilder::new() + .with_private_key(privkey_bob) + .with_ip(bob_mock_tun) + .with_udp(udp_bob) + .with_listen_port(port) + .with_peer(peer_alice) + .build() + .await + .expect("create bob device"); + + // Outgoing: Alice app sends src=outer_tun_ip; + // NatIpRecv rewrites src -> inner_tun_ip before encrypting. + // Bob should receive src=inner_tun_ip. + alice_app_tx + .send(mock::packet_with_addrs(outer_tun_ip, some_addr, b"hello")) + .await; + let received = bob_app_rx.recv().await; + assert_eq!(received.source(), Some(inner_tun_ip.into())); + assert_eq!(received.payload(), Some(b"hello".as_slice())); + + // Incoming: Bob app sends dst=inner_tun_ip; + // NatIpSend rewrites dst -> outer_tun_ip before writing to TUN. + // Alice should receive dst=outer_tun_ip. + bob_app_tx + .send(mock::packet_with_addrs(some_addr, inner_tun_ip, b"reply")) + .await; + let received = alice_app_rx.recv().await; + assert_eq!(received.destination(), Some(outer_tun_ip.into())); + assert_eq!(received.payload(), Some(b"reply".as_slice())); +} + /// The number of packets we send through the tunnel fn packet_count() -> usize { mock::packets_of_every_size().len() diff --git a/gotatun/src/device/tests/mock.rs b/gotatun/src/device/tests/mock.rs index 0dcbd707..ff22edc0 100644 --- a/gotatun/src/device/tests/mock.rs +++ b/gotatun/src/device/tests/mock.rs @@ -209,6 +209,15 @@ pub fn packet(payload: impl AsRef<[u8]>) -> Packet { packet.try_into_ip().unwrap() } +/// Create a mocked IPv4 packet with custom src/dst addresses containing `payload`. +pub fn packet_with_addrs(src: Ipv4Addr, dst: Ipv4Addr, payload: impl AsRef<[u8]>) -> Packet { + let payload = payload.as_ref(); + let header = Ipv4Header::new(src, dst, IpNextProtocol::Pup, payload); + let mut packet = Packet::copy_from(header.as_bytes()); + packet.buf_mut().extend_from_slice(payload); + packet.try_into_ip().unwrap() +} + /// Create an `Iterator` that returns one packet for every possible payload size (with respect to [`TUN_MTU`]). pub fn packets_of_every_size() -> impl ExactSizeIterator> + Clone { let tun_mtu = usize::from(TUN_MTU); diff --git a/gotatun/src/packet/ip.rs b/gotatun/src/packet/ip.rs index 6816a4c2..133a4d31 100644 --- a/gotatun/src/packet/ip.rs +++ b/gotatun/src/packet/ip.rs @@ -16,7 +16,8 @@ use either::Either; use zerocopy::{FromBytes, Immutable, IntoBytes, KnownLayout, TryFromBytes, Unaligned}; use crate::packet::{ - Decoder, Ipv4, Ipv4Decoder, Ipv4Header, Ipv4Options, Ipv6, Ipv6Decoder, Ipv6Header, + Decoder, IpNextProtocol, Ipv4, Ipv4Decoder, Ipv4Header, Ipv4Options, Ipv6, Ipv6Decoder, + Ipv6Header, }; /// A packet bitfield-struct containing the `version`-field that is shared between IPv4 and IPv6. @@ -128,6 +129,28 @@ impl Ip { Either::Right(ipv6) => ipv6.header.destination().into(), }) } + + /// Try to extract the transport-layer protocol. + /// + /// Returns `None` if the version field is not `4` or `6`, or if the packet is too small. + /// Other than that, no checks are done to ensure this is a valid ip packet. + pub fn next_protocol(&self) -> Option { + Some(match self.as_v4_or_v6()? { + Either::Left(ipv4) => ipv4.header.next_protocol(), + Either::Right(ipv6) => ipv6.header.next_protocol(), + }) + } + + /// Try to extract the payload. + /// + /// Returns `None` if the version field is not `4` or `6`, or if the packet is too small. + /// Other than that, no checks are done to ensure this is a valid ip packet. + pub fn payload(&self) -> Option<&[u8]> { + Some(match self.as_v4_or_v6()? { + Either::Left(ipv4) => &ipv4.payload, + Either::Right(ipv6) => &ipv6.payload, + }) + } } impl IpDecoder { diff --git a/gotatun/src/packet/ipv4/mod.rs b/gotatun/src/packet/ipv4/mod.rs index e79ba953..77c8c4bd 100644 --- a/gotatun/src/packet/ipv4/mod.rs +++ b/gotatun/src/packet/ipv4/mod.rs @@ -438,6 +438,12 @@ impl Ipv4Header { pub fn compute_checksum(&self) -> u16 { crate::packet::util::checksum_ipv4_with_skip(self.as_bytes()) } + + /// Update the checksum for this IP header. + pub fn recompute_checksum(&mut self) { + self.header_checksum = 0u16.into(); + self.header_checksum = crate::packet::util::checksum(&[self.as_bytes()]).into(); + } } impl Ipv4 { diff --git a/gotatun/src/tun/merge.rs b/gotatun/src/tun/merge.rs new file mode 100644 index 00000000..cb5f5ee0 --- /dev/null +++ b/gotatun/src/tun/merge.rs @@ -0,0 +1,58 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This file incorporates work covered by the following copyright and +// permission notice: +// +// Copyright (c) Mullvad VPN AB. All rights reserved. +// +// SPDX-License-Identifier: MPL-2.0 + +//! Merges two [`IpRecv`] sources into one. +//! +//! See [`MergingIpRecv`]. + +use std::io; + +use either::Either; + +use crate::packet::{Ip, Packet, PacketBufPool}; +use crate::tun::{IpRecv, MtuWatcher}; + +/// Merges packets from two [`IpRecv`] sources using [`tokio::select!`]. +/// +/// The first source's MTU is used as the merged MTU. +pub struct MergingIpRecv { + a: A, + b: B, + pool: PacketBufPool, +} + +impl MergingIpRecv { + /// Create a new `MergingIpRecv` that merges packets from `a` and `b`. + /// + /// `pool` is used as the packet buffer pool when receiving from `b`. + pub fn new(a: A, b: B, pool: PacketBufPool) -> Self { + Self { a, b, pool } + } +} + +impl IpRecv for MergingIpRecv { + async fn recv<'a>( + &'a mut self, + pool: &mut PacketBufPool, + ) -> io::Result> + Send + 'a> { + // Destructure to help the borrow checker see disjoint field borrows. + let Self { a, b, pool: pool_b } = self; + tokio::select! { + result = a.recv(pool) => result.map(Either::Left), + result = b.recv(pool_b) => result.map(Either::Right), + } + } + + fn mtu(&self) -> MtuWatcher { + // TODO + self.a.mtu() + } +} diff --git a/gotatun/src/tun/mod.rs b/gotatun/src/tun/mod.rs index f0de9c43..c9cd0092 100644 --- a/gotatun/src/tun/mod.rs +++ b/gotatun/src/tun/mod.rs @@ -22,6 +22,9 @@ use std::io; #[cfg(feature = "device")] pub(crate) mod buffer; pub mod channel; +pub mod merge; +pub mod nat; +pub mod router; #[cfg(feature = "pcap")] pub mod pcap; diff --git a/gotatun/src/tun/nat.rs b/gotatun/src/tun/nat.rs new file mode 100644 index 00000000..2e46b5fc --- /dev/null +++ b/gotatun/src/tun/nat.rs @@ -0,0 +1,669 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This file incorporates work covered by the following copyright and +// permission notice: +// +// Copyright (c) Mullvad VPN AB. All rights reserved. +// +// SPDX-License-Identifier: MPL-2.0 + +//! Source/destination NAT adapters for [`IpRecv`] and [`IpSend`]. +//! +//! [`NatIpRecv`] rewrites the source IP on TUN read. +//! [`NatIpSend`] rewrites the destination IP on TUN write. + +// TODO: May need state to make sure outer tunnel cannot talk to port "owned" by inner + +use std::io; +use std::net::{Ipv4Addr, Ipv6Addr}; + +use duplicate::duplicate_item; +use zerocopy::{FromBytes, IntoBytes, big_endian}; + +use crate::packet::{ + Ip, IpNextProtocol, Ipv4, Ipv6, Packet, PacketBufPool, Udp, incremental_checksum_update, + incremental_checksum_update_v6, +}; +use crate::tun::{IpRecv, IpSend, MtuWatcher}; + +/// Rewrites source IP on TUN-received packets (outbound NAT). +pub struct NatIpRecv { + inner: R, + /// Source IPv4 to match and replace (the outer VPN IP) + from_v4: Ipv4Addr, + /// Replacement source IPv4 (the inner tunnel IP) + to_v4: Ipv4Addr, + /// Source IPv6 to match and replace (the outer VPN IP) + from_v6: Option, + /// Replacement source IPv6 (the inner tunnel IP) + to_v6: Option, +} + +/// Rewrites destination IP on TUN-sent packets (inbound NAT). +pub struct NatIpSend { + inner: S, + /// Dest IPv4 to match and replace (the inner tunnel IP) + from_v4: Ipv4Addr, + /// Replacement dest IPv4 (the outer VPN IP) + to_v4: Ipv4Addr, + /// Dest IPv6 to match and replace (the inner tunnel IP) + from_v6: Option, + /// Replacement dest IPv6 (the outer VPN IP) + to_v6: Option, +} + +impl NatIpRecv { + pub fn new(inner: R, from: Ipv4Addr, to: Ipv4Addr) -> Self { + Self { + inner, + from_v4: from, + to_v4: to, + from_v6: None, + to_v6: None, + } + } + + pub fn with_v6(mut self, from: Ipv6Addr, to: Ipv6Addr) -> Self { + self.from_v6 = Some(from); + self.to_v6 = Some(to); + self + } +} + +impl NatIpSend { + pub fn new(inner: S, from: Ipv4Addr, to: Ipv4Addr) -> Self { + Self { + inner, + from_v4: from, + to_v4: to, + from_v6: None, + to_v6: None, + } + } + + pub fn with_v6(mut self, from: Ipv6Addr, to: Ipv6Addr) -> Self { + self.from_v6 = Some(from); + self.to_v6 = Some(to); + self + } +} + +impl IpRecv for NatIpRecv { + async fn recv<'a>( + &'a mut self, + pool: &mut PacketBufPool, + ) -> io::Result> + Send + 'a> { + let packets = self.inner.recv(pool).await?; + let from_v4 = self.from_v4; + let to_v4 = self.to_v4; + let from_v6 = self.from_v6; + let to_v6 = self.to_v6; + Ok(packets.map(move |mut packet| { + rewrite_source_ip(&mut packet, from_v4, to_v4, from_v6, to_v6); + packet + })) + } + + fn mtu(&self) -> MtuWatcher { + self.inner.mtu() + } +} + +impl IpSend for NatIpSend { + async fn send(&mut self, mut packet: Packet) -> io::Result<()> { + rewrite_dest_ip( + &mut packet, + self.from_v4, + self.to_v4, + self.from_v6, + self.to_v6, + ); + self.inner.send(packet).await + } +} + +fn rewrite_source_ip( + packet: &mut Packet, + from_v4: Ipv4Addr, + to_v4: Ipv4Addr, + from_v6: Option, + to_v6: Option, +) { + match packet.header.version() { + 4 => { + let Ok(ipv4) = Ipv4::<[u8]>::mut_from_bytes(packet.as_mut_bytes()) else { + return; + }; + rewrite_source_ipv4(ipv4, from_v4, to_v4); + } + 6 => { + if let (Some(from), Some(to)) = (from_v6, to_v6) { + let Ok(ipv6) = Ipv6::<[u8]>::mut_from_bytes(packet.as_mut_bytes()) else { + return; + }; + rewrite_source_ipv6(ipv6, from, to); + } + } + _ => {} + } +} + +fn rewrite_dest_ip( + packet: &mut Packet, + from_v4: Ipv4Addr, + to_v4: Ipv4Addr, + from_v6: Option, + to_v6: Option, +) { + match packet.header.version() { + 4 => { + let Ok(ipv4) = Ipv4::<[u8]>::mut_from_bytes(packet.as_mut_bytes()) else { + return; + }; + rewrite_dest_ipv4(ipv4, from_v4, to_v4); + } + 6 => { + if let (Some(from), Some(to)) = (from_v6, to_v6) { + let Ok(ipv6) = Ipv6::<[u8]>::mut_from_bytes(packet.as_mut_bytes()) else { + return; + }; + rewrite_dest_ipv6(ipv6, from, to); + } + } + _ => {} + } +} + +fn rewrite_source_ipv4(ipv4: &mut Ipv4<[u8]>, from: Ipv4Addr, to: Ipv4Addr) { + if ipv4.header.source() != from { + return; + } + if ipv4.header.ihl() != 5 { + return; + } + + let protocol = ipv4.header.protocol; + + ipv4.header.source_address = big_endian::U32::from_bytes(to.octets()); + ipv4.header.recompute_checksum(); + update_transport_checksum_v4(&mut ipv4.payload, protocol, &from.octets(), &to.octets()); +} + +fn rewrite_dest_ipv4(ipv4: &mut Ipv4<[u8]>, from: Ipv4Addr, to: Ipv4Addr) { + if ipv4.header.destination() != from { + return; + } + if ipv4.header.ihl() != 5 { + return; + } + + let protocol = ipv4.header.protocol; + ipv4.header.destination_address = big_endian::U32::from_bytes(to.octets()); + ipv4.header.recompute_checksum(); + + update_transport_checksum_v4(&mut ipv4.payload, protocol, &from.octets(), &to.octets()); +} + +fn rewrite_source_ipv6(ipv6: &mut Ipv6<[u8]>, from: Ipv6Addr, to: Ipv6Addr) { + if ipv6.header.source() != from { + return; + } + + let protocol = ipv6.header.next_header; + ipv6.header.source_address = big_endian::U128::from_bytes(to.octets()); + update_transport_checksum_v6(&mut ipv6.payload, protocol, &from.octets(), &to.octets()); +} + +fn rewrite_dest_ipv6(ipv6: &mut Ipv6<[u8]>, from: Ipv6Addr, to: Ipv6Addr) { + if ipv6.header.destination() != from { + return; + } + + let protocol = ipv6.header.next_header; + ipv6.header.destination_address = big_endian::U128::from_bytes(to.octets()); + update_transport_checksum_v6(&mut ipv6.payload, protocol, &from.octets(), &to.octets()); +} + +/// Incrementally update the transport-layer (TCP/UDP) checksum after an IP address change. +/// +/// `transport` is the transport-layer payload (starting at the TCP/UDP header). +/// `old_addr` and `new_addr` are the IP addresses that changed. +#[duplicate_item( + update_transport_checksum AddrIn incremental_checksum_update into_addr; + [update_transport_checksum_v4] [[u8; 4]] [incremental_checksum_update] [big_endian::U32::from_bytes]; + [update_transport_checksum_v6] [[u8; 16]] [incremental_checksum_update_v6] [big_endian::U128::from_bytes]; +)] +fn update_transport_checksum( + transport: &mut [u8], + protocol: IpNextProtocol, + old_addr: &AddrIn, + new_addr: &AddrIn, +) { + match protocol { + IpNextProtocol::Udp => { + let Ok(udp) = Udp::<[u8]>::mut_from_bytes(transport) else { + return; + }; + let old_check = udp.header.checksum.get(); + // For UDP, a checksum of 0 means "no checksum"; don't update it. + if old_check == 0 { + return; + } + udp.header.checksum = + incremental_checksum_update(old_check, into_addr(*old_addr), into_addr(*new_addr)) + .into(); + } + IpNextProtocol::Tcp => { + // TCP checksum is at byte offset 16 in the TCP header. + const CHECKSUM_OFFSET: usize = 16; + if transport.len() < CHECKSUM_OFFSET + 2 { + return; + } + let old_check = + u16::from_be_bytes([transport[CHECKSUM_OFFSET], transport[CHECKSUM_OFFSET + 1]]); + let new_check = + incremental_checksum_update(old_check, into_addr(*old_addr), into_addr(*new_addr)); + transport[CHECKSUM_OFFSET..CHECKSUM_OFFSET + 2] + .copy_from_slice(&new_check.to_be_bytes()); + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bytes::BytesMut; + use std::net::Ipv6Addr; + use zerocopy::FromBytes; + + use crate::packet::{ + IpNextProtocol, Ipv4, Ipv4Header, Ipv6, Ipv6Header, PseudoHeaderV4, PseudoHeaderV6, Udp, + UdpHeader, checksum, checksum_udp, + }; + + /// Build a minimal IPv4/UDP packet with a valid header checksum and UDP checksum. + fn make_ipv4_udp_packet(src: Ipv4Addr, dst: Ipv4Addr, udp_payload: &[u8]) -> Packet { + let udp_len = UdpHeader::LEN + udp_payload.len(); + let total_len = Ipv4Header::LEN + udp_len; + let mut buf = BytesMut::zeroed(total_len); + + // Build IPv4 and UDP headers. + { + let ipv4 = Ipv4::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + ipv4.header = Ipv4Header::new_for_length(src, dst, IpNextProtocol::Udp, udp_len as u16); + let udp = crate::packet::Udp::<[u8]>::mut_from_bytes(&mut ipv4.payload).unwrap(); + udp.header.source_port = 1234u16.into(); + udp.header.destination_port = 5678u16.into(); + udp.header.length = (udp_len as u16).into(); + udp.header.checksum = 0u16.into(); + udp.payload.copy_from_slice(udp_payload); + } + + // Compute valid IPv4 header checksum. + { + let ipv4 = Ipv4::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + ipv4.header.recompute_checksum(); + } + + // Compute valid UDP checksum (pseudo-header + UDP). + { + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(&buf).unwrap(); + let udp = Udp::<[u8]>::ref_from_bytes(&ipv4.payload).unwrap(); + let pseudo = PseudoHeaderV4::from_udp( + ipv4.header.source_address, + ipv4.header.destination_address, + udp, + ); + let cksum = checksum_udp(pseudo, &ipv4.payload); + let ipv4 = Ipv4::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + let udp = Udp::<[u8]>::mut_from_bytes(&mut ipv4.payload).unwrap(); + udp.header.checksum = cksum.into(); + } + + Packet::from_bytes(buf).try_into_ip().unwrap() + } + + /// Build a minimal IPv6/UDP packet with a valid UDP checksum. + fn make_ipv6_udp_packet(src: Ipv6Addr, dst: Ipv6Addr, udp_payload: &[u8]) -> Packet { + let udp_len = UdpHeader::LEN + udp_payload.len(); + let total_len = Ipv6Header::LEN + udp_len; + let mut buf = BytesMut::zeroed(total_len); + + { + let ipv6 = Ipv6::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + ipv6.header.set_version(6); + ipv6.header.payload_length = (udp_len as u16).into(); + ipv6.header.next_header = IpNextProtocol::Udp; + ipv6.header.hop_limit = 64; + ipv6.header.source_address = big_endian::U128::from_bytes(src.octets()); + ipv6.header.destination_address = big_endian::U128::from_bytes(dst.octets()); + + let udp = crate::packet::Udp::<[u8]>::mut_from_bytes(&mut ipv6.payload).unwrap(); + udp.header.source_port = 1234u16.into(); + udp.header.destination_port = 5678u16.into(); + udp.header.length = (udp_len as u16).into(); + udp.header.checksum = 0u16.into(); + udp.payload.copy_from_slice(udp_payload); + } + + { + let ipv6 = Ipv6::<[u8]>::ref_from_bytes(&buf).unwrap(); + let udp = Udp::<[u8]>::ref_from_bytes(&ipv6.payload).unwrap(); + let pseudo = PseudoHeaderV6::from_udp( + ipv6.header.source_address, + ipv6.header.destination_address, + udp, + ); + let cksum = checksum_udp(pseudo, &ipv6.payload); + let ipv6 = Ipv6::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + let udp = Udp::<[u8]>::mut_from_bytes(&mut ipv6.payload).unwrap(); + udp.header.checksum = cksum.into(); + } + + Packet::from_bytes(buf).try_into_ip().unwrap() + } + + fn verify_ipv4_header_checksum(buf: &[u8]) -> bool { + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + checksum(&[ipv4.header.as_bytes()]) == 0 + } + + fn verify_udp_checksum_v4(buf: &[u8]) -> bool { + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + let udp = Udp::<[u8]>::ref_from_bytes(&ipv4.payload).unwrap(); + if udp.header.checksum.get() == 0 { + return true; + } + let pseudo = PseudoHeaderV4::from_udp( + ipv4.header.source_address, + ipv4.header.destination_address, + udp, + ); + checksum(&[pseudo.as_bytes(), &ipv4.payload]) == 0 + } + + fn verify_udp_checksum_v6(buf: &[u8]) -> bool { + let ipv6 = Ipv6::<[u8]>::ref_from_bytes(buf).unwrap(); + let udp = Udp::<[u8]>::ref_from_bytes(&ipv6.payload).unwrap(); + if udp.header.checksum.get() == 0 { + return true; + } + let pseudo = PseudoHeaderV6::from_udp( + ipv6.header.source_address, + ipv6.header.destination_address, + udp, + ); + checksum(&[pseudo.as_bytes(), &ipv6.payload]) == 0 + } + + #[test] + fn rewrite_source_ip_changes_address_and_checksums() { + let src = Ipv4Addr::new(10, 64, 0, 2); + let dst = Ipv4Addr::new(192, 168, 1, 1); + let new_src = Ipv4Addr::new(10, 100, 0, 5); + + let mut packet = make_ipv4_udp_packet(src, dst, b"hello nat"); + + // Verify initial checksums are valid. + let buf = packet.as_bytes(); + assert!( + verify_ipv4_header_checksum(buf), + "initial IP checksum invalid" + ); + assert!(verify_udp_checksum_v4(buf), "initial UDP checksum invalid"); + + rewrite_source_ip(&mut packet, src, new_src, None, None); + + let buf = packet.as_bytes(); + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + // Source address should now be new_src. + assert_eq!(ipv4.header.source(), new_src); + // Destination should be unchanged. + assert_eq!(ipv4.header.destination(), dst); + // Checksums should still be valid. + assert!( + verify_ipv4_header_checksum(buf), + "IP checksum invalid after NAT" + ); + assert!( + verify_udp_checksum_v4(buf), + "UDP checksum invalid after NAT" + ); + } + + #[test] + fn rewrite_dest_ip_changes_address_and_checksums() { + let src = Ipv4Addr::new(192, 168, 1, 1); + let dst = Ipv4Addr::new(10, 100, 0, 5); + let new_dst = Ipv4Addr::new(10, 64, 0, 2); + + let mut packet = make_ipv4_udp_packet(src, dst, b"hello nat"); + + let buf = packet.as_bytes(); + assert!(verify_ipv4_header_checksum(buf)); + assert!(verify_udp_checksum_v4(buf)); + + rewrite_dest_ip(&mut packet, dst, new_dst, None, None); + + let buf = packet.as_bytes(); + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + // Destination address should now be new_dst. + assert_eq!(ipv4.header.destination(), new_dst); + // Source should be unchanged. + assert_eq!(ipv4.header.source(), src); + assert!( + verify_ipv4_header_checksum(buf), + "IP checksum invalid after NAT" + ); + assert!( + verify_udp_checksum_v4(buf), + "UDP checksum invalid after NAT" + ); + } + + #[test] + fn no_rewrite_when_address_does_not_match() { + let src = Ipv4Addr::new(10, 64, 0, 2); + let dst = Ipv4Addr::new(192, 168, 1, 1); + let wrong_from = Ipv4Addr::new(10, 64, 0, 99); + let to = Ipv4Addr::new(10, 100, 0, 5); + + let mut packet = make_ipv4_udp_packet(src, dst, b"no match"); + let original_bytes = packet.as_bytes().to_vec(); + + rewrite_source_ip(&mut packet, wrong_from, to, None, None); + + // Packet should be unchanged. + assert_eq!(packet.as_bytes(), &original_bytes[..]); + } + + #[test] + fn udp_zero_checksum_not_modified() { + let src = Ipv4Addr::new(10, 64, 0, 2); + let dst = Ipv4Addr::new(192, 168, 1, 1); + let new_src = Ipv4Addr::new(10, 100, 0, 5); + + let udp_len = UdpHeader::LEN + 4; + let total_len = Ipv4Header::LEN + udp_len; + let mut buf = BytesMut::zeroed(total_len); + + { + let ipv4 = Ipv4::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + ipv4.header = Ipv4Header::new_for_length(src, dst, IpNextProtocol::Udp, udp_len as u16); + let udp = crate::packet::Udp::<[u8]>::mut_from_bytes(&mut ipv4.payload).unwrap(); + udp.header.source_port = 1234u16.into(); + udp.header.destination_port = 5678u16.into(); + udp.header.length = (udp_len as u16).into(); + udp.header.checksum = 0u16.into(); // Explicitly zero = no checksum. + udp.payload.copy_from_slice(b"test"); + } + + { + let ipv4 = Ipv4::<[u8]>::mut_from_bytes(&mut buf).unwrap(); + ipv4.header.recompute_checksum(); + } + + let mut packet = Packet::from_bytes(buf).try_into_ip().unwrap(); + rewrite_source_ip(&mut packet, src, new_src, None, None); + + let buf = packet.as_bytes(); + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + let udp = Udp::<[u8]>::ref_from_bytes(&ipv4.payload).unwrap(); + // UDP checksum should still be zero. + assert_eq!(udp.header.checksum.get(), 0); + // But source should be rewritten. + assert_eq!(ipv4.header.source(), new_src); + // And IP checksum should be valid. + assert!(verify_ipv4_header_checksum(buf)); + } + + #[test] + fn incremental_checksum_matches_full_recompute() { + // Build a packet, NAT it, and verify the UDP checksum matches a full recompute. + let src = Ipv4Addr::new(10, 64, 0, 2); + let dst = Ipv4Addr::new(8, 8, 8, 8); + let new_src = Ipv4Addr::new(172, 16, 0, 42); + + let mut packet = make_ipv4_udp_packet(src, dst, b"checksum test payload"); + rewrite_source_ip(&mut packet, src, new_src, None, None); + + let buf = packet.as_bytes(); + let ipv4 = Ipv4::<[u8]>::ref_from_bytes(buf).unwrap(); + let incremental_cksum = Udp::<[u8]>::ref_from_bytes(&ipv4.payload) + .unwrap() + .header + .checksum + .get(); + + // Verify the checksum is valid by summing pseudo-header + UDP; should fold to 0. + let udp = Udp::<[u8]>::ref_from_bytes(&ipv4.payload).unwrap(); + let pseudo = PseudoHeaderV4::from_udp( + ipv4.header.source_address, + ipv4.header.destination_address, + udp, + ); + assert_eq!( + checksum(&[pseudo.as_bytes(), &ipv4.payload]), + 0, + "incremental checksum {incremental_cksum:#06x} is invalid" + ); + } + + #[test] + fn rewrite_source_ipv6_changes_address_and_checksum() { + let src = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1); + let dst = Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111); + let new_src = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 2); + + let mut packet = make_ipv6_udp_packet(src, dst, b"hello ipv6 nat"); + + assert!( + verify_udp_checksum_v6(packet.as_bytes()), + "initial UDP checksum invalid" + ); + + rewrite_source_ip( + &mut packet, + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::UNSPECIFIED, + Some(src), + Some(new_src), + ); + + let buf = packet.as_bytes(); + let ipv6 = Ipv6::<[u8]>::ref_from_bytes(buf).unwrap(); + assert_eq!( + ipv6.header.source(), + new_src, + "source address not rewritten" + ); + assert_eq!( + ipv6.header.destination(), + dst, + "destination address changed unexpectedly" + ); + assert!( + verify_udp_checksum_v6(buf), + "UDP checksum invalid after NAT" + ); + } + + #[test] + fn rewrite_dest_ipv6_changes_address_and_checksum() { + let src = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 1); + let dst = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 2); + let new_dst = Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 3); + + let mut packet = make_ipv6_udp_packet(src, dst, b"hello ipv6 nat dest"); + + assert!( + verify_udp_checksum_v6(packet.as_bytes()), + "initial UDP checksum invalid" + ); + + rewrite_dest_ip( + &mut packet, + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::UNSPECIFIED, + Some(dst), + Some(new_dst), + ); + + let buf = packet.as_bytes(); + let ipv6 = Ipv6::<[u8]>::ref_from_bytes(buf).unwrap(); + assert_eq!( + ipv6.header.source(), + src, + "source address changed unexpectedly" + ); + assert_eq!( + ipv6.header.destination(), + new_dst, + "destination address not rewritten" + ); + assert!( + verify_udp_checksum_v6(buf), + "UDP checksum invalid after NAT" + ); + } + + #[test] + fn ipv6_incremental_checksum_matches_full_recompute() { + let src = Ipv6Addr::new(0xfc00, 0xbbbb, 0xbbbb, 0xbb01, 0xd, 0, 0xc, 0xc2dd); + let dst = Ipv6Addr::new(0x2606, 0x4700, 0x4700, 0, 0, 0, 0, 0x1111); + let new_src = Ipv6Addr::new(0xfd00, 0x1111, 0, 0, 0, 0, 0, 0x42); + + let mut packet = make_ipv6_udp_packet(src, dst, b"ipv6 checksum test payload"); + rewrite_source_ip( + &mut packet, + Ipv4Addr::UNSPECIFIED, + Ipv4Addr::UNSPECIFIED, + Some(src), + Some(new_src), + ); + + let buf = packet.as_bytes(); + let ipv6 = Ipv6::<[u8]>::ref_from_bytes(buf).unwrap(); + let incremental_cksum = Udp::<[u8]>::ref_from_bytes(&ipv6.payload) + .unwrap() + .header + .checksum + .get(); + + // Verify the checksum is valid by summing pseudo-header + UDP; should fold to 0. + let udp = Udp::<[u8]>::ref_from_bytes(&ipv6.payload).unwrap(); + let pseudo = PseudoHeaderV6::from_udp( + ipv6.header.source_address, + ipv6.header.destination_address, + udp, + ); + assert_eq!( + checksum(&[pseudo.as_bytes(), &ipv6.payload]), + 0, + "incremental checksum {incremental_cksum:#06x} is invalid" + ); + } +} diff --git a/gotatun/src/tun/router.rs b/gotatun/src/tun/router.rs new file mode 100644 index 00000000..1a76481c --- /dev/null +++ b/gotatun/src/tun/router.rs @@ -0,0 +1,176 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. +// +// This file incorporates work covered by the following copyright and +// permission notice: +// +// Copyright (c) Mullvad VPN AB. All rights reserved. +// +// SPDX-License-Identifier: MPL-2.0 + +//! Split one [`IpRecv`] source into multiple receivers based on IP destination, +//! or combine multiple [`IpSend`]s into one sender based on IP source. +//! +//! See [`TunRxRouter`] and [`TunTxRouter`]. + +use std::io; +use std::iter; +use std::net::SocketAddr; + +use ip_network_table::IpNetworkTable; +use ipnetwork::IpNetwork; +use tokio::sync::mpsc; +use zerocopy::FromBytes; + +use crate::packet::{Ip, IpNextProtocol, Packet, PacketBufPool, Udp}; +use crate::tun::{IpRecv, IpSend, MtuWatcher}; + +/// An [`IpRecv`] that receives some subset of traffic from an unsplit [`IpRecv`]. +pub struct SplitIpRecv { + rx: mpsc::Receiver>, + mtu: MtuWatcher, +} + +impl IpRecv for SplitIpRecv { + async fn recv<'a>( + &'a mut self, + _pool: &mut PacketBufPool, + ) -> io::Result> + Send + 'a> { + match self.rx.recv().await { + Some(packet) => Ok(iter::once(packet)), + None => Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "channel closed", + )), + } + } + + fn mtu(&self) -> MtuWatcher { + self.mtu.clone() + } +} + +fn new_split_channel(capacity: usize, mtu: MtuWatcher) -> (mpsc::Sender>, SplitIpRecv) { + let (tx, rx) = mpsc::channel(capacity); + (tx, SplitIpRecv { rx, mtu }) +} + +/// A TUN router that splits a single [`IpRecv`] into multiple streams based on +/// destination IP. +pub struct TunRxRouter { + source: SourceTunRx, + table: IpNetworkTable>>, +} + +impl TunRxRouter { + /// Create a new TUN router that reads from `source` and splits it into multiple streams. + pub fn new(source: SourceTunRx) -> Self { + Self { + source, + table: IpNetworkTable::new(), + } + } + + /// Insert a default route. This will replace any previous default route. + pub fn add_default_route(&mut self, channel_capacity: usize) -> SplitIpRecv { + self.add_route("0.0.0.0/0".parse().unwrap(), channel_capacity) + } + + /// Insert a new route. This will replace any previously conflicting route. + pub fn add_route(&mut self, route: IpNetwork, channel_capacity: usize) -> SplitIpRecv { + let (tx, rx) = new_split_channel(channel_capacity, self.source.mtu()); + let net = ip_network::IpNetwork::new_truncate(route.ip(), route.prefix()) + .expect("cidr is valid length"); + self.table.insert(net, tx); + rx + } + + /// Begin forwarding `self.source` to split streams. + pub async fn run(mut self, mut pool: PacketBufPool) { + loop { + let packets = match self.source.recv(&mut pool).await { + Ok(packets) => packets, + Err(e) => { + log::error!("TUN router recv error: {e}"); + // TODO: Must stop if TUN goes down, but is this always unrecoverable? + break; + } + }; + + for packet in packets { + let Some(dest) = packet.destination() else { + continue; + }; + + // Find matching route, or drop packet + let Some((_net, tx)) = self.table.longest_match(dest) else { + continue; + }; + + if tx.send(packet).await.is_err() { + // TODO: Should we only stop if all channels have been dropped? + log::trace!("TUN router channel closed. Stopping"); + return; + } + } + } + } +} + +/// An [`IpSend`] combined from two [`IpSend`]s. If the source equals `inner_tun_endpoint`, +/// then `send` will forward the packet to `inner_tx` (e.g., a [`TunChannelTx`](crate::tun::channel::TunChannelTx)). +/// Otherwise, it is forwarded to `outer_tx` (e.g., a real TUN device). +// TODO: Can we generalize this to map any number of endpoints to other channels? +pub struct TunTxRouter { + inner_tx: Inner, + outer_tx: Outer, + inner_tun_endpoint: SocketAddr, +} + +impl TunTxRouter { + /// Create a new `DemuxIpSend`. + /// + /// # Arguments + /// * `inner_tx` - Destination for packets matching inner tunnel addresse + /// * `outer_tx` - Destination for all other packets + /// * `inner_tun_addr` - IP address of the inner WireGuard tunnel + pub fn new(inner_tx: Inner, outer_tx: Outer, inner_tun_endpoint: SocketAddr) -> Self { + Self { + inner_tx, + outer_tx, + inner_tun_endpoint, + } + } +} + +impl IpSend for TunTxRouter { + async fn send(&mut self, packet: Packet) -> io::Result<()> { + if let Some(src) = extract_udp_src(&packet) { + if self.inner_tun_endpoint == src { + return self.inner_tx.send(packet).await; + } + } + self.outer_tx.send(packet).await + } +} + +/// Extract the UDP source socket address from an IP packet without full parsing. +/// +/// Returns `None` if: +/// - The IP version is not 4 or 6 +/// - The protocol is not UDP +/// - The packet is too short to contain UDP headers +fn extract_udp_src(packet: &Ip) -> Option { + let src_ip = packet.source()?; + let transport_protocol = packet.next_protocol()?; + if transport_protocol != IpNextProtocol::Udp { + return None; + } + + let udp = Udp::<[u8]>::ref_from_bytes(packet.payload()?).ok()?; + + // NOTE: Not validating UDP header/payload + + Some(SocketAddr::new(src_ip, udp.header.source_port.into())) +} From a83c8a60c7a8db1917bc8f4b3db07ee41fc50480 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 6 Mar 2026 19:03:38 +0100 Subject: [PATCH 03/10] fixup: ignore pcap on windows --- gotatun/examples/multihop-router.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/gotatun/examples/multihop-router.rs b/gotatun/examples/multihop-router.rs index 4da4961f..bb6ea37c 100644 --- a/gotatun/examples/multihop-router.rs +++ b/gotatun/examples/multihop-router.rs @@ -98,7 +98,7 @@ use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use base64::Engine; use clap::Parser; use eyre::{Context, ContextCompat}; -#[cfg(feature = "pcap")] +#[cfg(all(feature = "pcap", unix))] use gotatun::tun::pcap::{PcapSniffer, PcapStream}; use gotatun::{ device::{DeviceBuilder, Peer}, @@ -166,7 +166,7 @@ struct Args { routes: Vec, /// PCAP unix socket path for Wireshark (requires `--features pcap`). - #[cfg(feature = "pcap")] + #[cfg(all(feature = "pcap", unix))] #[arg(long, default_value = "/tmp/multihop.pcap")] pcap_socket: String, } @@ -297,7 +297,7 @@ async fn main() -> eyre::Result<()> { .build() .await?; - #[cfg(feature = "pcap")] + #[cfg(all(feature = "pcap", unix))] let (outer_ip_send, outer_ip_recv) = wrap_in_pcap_sniffer(outer_ip_send, outer_ip_recv, &args.pcap_socket); @@ -336,7 +336,7 @@ async fn main() -> eyre::Result<()> { /// ```sh /// wireshark -k -i /tmp/multihop.pcap /// ``` -#[cfg(feature = "pcap")] +#[cfg(all(feature = "pcap", unix))] fn wrap_in_pcap_sniffer( ip_send: S, ip_recv: R, From 611c527c698f8b32332e6ef324b98bf26b6ca2b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 27 Mar 2026 09:28:16 +0100 Subject: [PATCH 04/10] fixup: deps --- Cargo.lock | 37 ++++++++++++++++++++++++++++++++++--- gotatun/Cargo.toml | 4 ++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1be9d620..64db06a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -380,11 +380,11 @@ checksum = "4ea181bf566f71cb9a5d17a59e1871af638180a18fb0035c92ae62b705207123" dependencies = [ "atty", "bitflags 1.3.2", - "clap_derive", + "clap_derive 3.2.25", "clap_lex 0.2.4", "indexmap", "once_cell", - "strsim", + "strsim 0.10.0", "termcolor", "textwrap", ] @@ -396,6 +396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c9e340e012a1bf4935f5282ed1436d1489548e8f72308207ea5df0e23d2d03f8" dependencies = [ "clap_builder", + "clap_derive 4.5.49", ] [[package]] @@ -404,8 +405,10 @@ version = "4.5.53" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d76b5d13eaa18c901fd2f7fca939fefe3a0727a953561fefdf3b2922b8569d00" dependencies = [ + "anstream", "anstyle", "clap_lex 0.7.6", + "strsim 0.11.1", ] [[package]] @@ -414,13 +417,25 @@ version = "3.2.25" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008" dependencies = [ - "heck", + "heck 0.4.1", "proc-macro-error", "proc-macro2", "quote", "syn 1.0.109", ] +[[package]] +name = "clap_derive" +version = "4.5.49" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.111", +] + [[package]] name = "clap_lex" version = "0.2.4" @@ -910,6 +925,7 @@ dependencies = [ "blake2", "bytes", "chacha20poly1305", + "clap 4.5.53", "constant_time_eq", "criterion", "duplicate", @@ -939,6 +955,9 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", + "tracing", + "tracing-appender", + "tracing-subscriber", "tun", "typed-builder", "windows-sys 0.60.2", @@ -987,6 +1006,12 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + [[package]] name = "hermit-abi" version = "0.1.19" @@ -1759,6 +1784,12 @@ version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + [[package]] name = "subtle" version = "2.6.1" diff --git a/gotatun/Cargo.toml b/gotatun/Cargo.toml index 510ae9c6..5c0cd6e5 100644 --- a/gotatun/Cargo.toml +++ b/gotatun/Cargo.toml @@ -73,9 +73,13 @@ x25519-dalek = { workspace = true, features = [ zerocopy = { workspace = true, features = ["derive", "std"] } [dev-dependencies] +clap = { version = "4", features = ["derive"] } criterion = { workspace = true, features = ["html_reports"] } etherparse = { workspace = true } test-log = { workspace = true } +tracing = { workspace = true } +tracing-appender = { workspace = true } +tracing-subscriber = { workspace = true } tokio-stream = { workspace = true, features = ["sync"] } x25519-dalek = { workspace = true, features = ["getrandom"] } From 22d266fe7a1d1321a51bc5060abbdc5d7f4f21c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Fri, 27 Mar 2026 14:52:04 +0100 Subject: [PATCH 05/10] Add add_routes function --- gotatun/src/tun/router.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/gotatun/src/tun/router.rs b/gotatun/src/tun/router.rs index 1a76481c..09ca7f02 100644 --- a/gotatun/src/tun/router.rs +++ b/gotatun/src/tun/router.rs @@ -86,6 +86,17 @@ impl TunRxRouter { rx } + /// Insert multiple new routes with shared receiver. This will replace any previously conflicting route. + pub fn add_routes(&mut self, routes: &[IpNetwork], channel_capacity: usize) -> SplitIpRecv { + let (tx, rx) = new_split_channel(channel_capacity, self.source.mtu()); + for route in routes { + let net = ip_network::IpNetwork::new_truncate(route.ip(), route.prefix()) + .expect("cidr is valid length"); + self.table.insert(net, tx.clone()); + } + rx + } + /// Begin forwarding `self.source` to split streams. pub async fn run(mut self, mut pool: PacketBufPool) { loop { From d5307f2be0095a9e655bffb8d7ced8744677b534 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 23 Apr 2026 10:01:57 +0200 Subject: [PATCH 06/10] fixup: docs for NatIp* --- gotatun/src/tun/nat.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gotatun/src/tun/nat.rs b/gotatun/src/tun/nat.rs index 2e46b5fc..a49a6f53 100644 --- a/gotatun/src/tun/nat.rs +++ b/gotatun/src/tun/nat.rs @@ -55,6 +55,7 @@ pub struct NatIpSend { } impl NatIpRecv { + /// Wrap `inner`, rewriting packets whose source IPv4 is `from` to use `to` instead. pub fn new(inner: R, from: Ipv4Addr, to: Ipv4Addr) -> Self { Self { inner, @@ -65,6 +66,7 @@ impl NatIpRecv { } } + /// Also rewrite packets whose source IPv6 is `from` to use `to` instead. pub fn with_v6(mut self, from: Ipv6Addr, to: Ipv6Addr) -> Self { self.from_v6 = Some(from); self.to_v6 = Some(to); @@ -73,6 +75,7 @@ impl NatIpRecv { } impl NatIpSend { + /// Wrap `inner`, rewriting packets whose destination IPv4 is `from` to use `to` instead. pub fn new(inner: S, from: Ipv4Addr, to: Ipv4Addr) -> Self { Self { inner, @@ -83,6 +86,7 @@ impl NatIpSend { } } + /// Also rewrite packets whose destination IPv6 is `from` to use `to` instead. pub fn with_v6(mut self, from: Ipv6Addr, to: Ipv6Addr) -> Self { self.from_v6 = Some(from); self.to_v6 = Some(to); From 54bceeab19d95952303c51cff14067232a9762aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 23 Apr 2026 10:02:09 +0200 Subject: [PATCH 07/10] fixup: unused deps --- Cargo.lock | 2 -- gotatun/Cargo.toml | 2 -- 2 files changed, 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 64db06a7..5ee372da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,8 +955,6 @@ dependencies = [ "tokio", "tokio-stream", "tokio-util", - "tracing", - "tracing-appender", "tracing-subscriber", "tun", "typed-builder", diff --git a/gotatun/Cargo.toml b/gotatun/Cargo.toml index 5c0cd6e5..0bf1fade 100644 --- a/gotatun/Cargo.toml +++ b/gotatun/Cargo.toml @@ -77,8 +77,6 @@ clap = { version = "4", features = ["derive"] } criterion = { workspace = true, features = ["html_reports"] } etherparse = { workspace = true } test-log = { workspace = true } -tracing = { workspace = true } -tracing-appender = { workspace = true } tracing-subscriber = { workspace = true } tokio-stream = { workspace = true, features = ["sync"] } x25519-dalek = { workspace = true, features = ["getrandom"] } From e201309da1aa2a3905b665e23e8c67dd01524e62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 23 Apr 2026 10:02:22 +0200 Subject: [PATCH 08/10] fixup: required feats for example --- gotatun/Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/gotatun/Cargo.toml b/gotatun/Cargo.toml index 0bf1fade..a98048ae 100644 --- a/gotatun/Cargo.toml +++ b/gotatun/Cargo.toml @@ -32,6 +32,10 @@ name = "crypto_benches" name = "throughput_benches" harness = false +[[example]] +name = "multihop-router" +required-features = ["device", "tun"] + [dependencies] aead = { workspace = true } aws-lc-rs = { workspace = true, optional = true } From 957b6203f8c84775ebd4714e523c8d01d99360be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 23 Apr 2026 10:29:45 +0200 Subject: [PATCH 09/10] Prevent outer VPN tunnel from impersonating the inner tunnel Packets whose source IPs overlap with those of the inner tunnel must be blocked, since there's only a single TUN device. Otherwise, the other tunnel can impersonate the inner tunnel. --- gotatun/examples/multihop-router.rs | 12 +++- gotatun/src/device/tests.rs | 97 ++++++++++++++++++++++++++++- gotatun/src/tun/router.rs | 39 ++++++++++-- 3 files changed, 140 insertions(+), 8 deletions(-) diff --git a/gotatun/examples/multihop-router.rs b/gotatun/examples/multihop-router.rs index bb6ea37c..3a50b348 100644 --- a/gotatun/examples/multihop-router.rs +++ b/gotatun/examples/multihop-router.rs @@ -275,8 +275,16 @@ async fn main() -> eyre::Result<()> { // Outer device IpRecv: merge direct + inner encrypted let outer_ip_recv = MergingIpRecv::new(default_output, bridge_tun_rx, packet_pool); - // Outer device IpSend: split decrypted packets into two streams - let outer_ip_send = TunTxRouter::new(bridge_tun_tx, tun.clone(), inner_tun_endpoint); + // Outer device IpSend: split decrypted packets into two streams. + // `inner_allowed_ip` is passed so that the outer peer cannot forge packets + // that *appear* to originate inside the inner tunnel (such packets must + // only ever arrive via the inner WG path). + let outer_ip_send = TunTxRouter::new( + bridge_tun_tx, + tun.clone(), + inner_tun_endpoint, + &[inner_allowed_ip], + ); // NAT: rewrite src/dst IPs between inner and outer tunnel addresses let tun_send = diff --git a/gotatun/src/device/tests.rs b/gotatun/src/device/tests.rs index 4a6c0636..ddda02b2 100644 --- a/gotatun/src/device/tests.rs +++ b/gotatun/src/device/tests.rs @@ -25,9 +25,9 @@ use x25519_dalek::{PublicKey, StaticSecret}; use crate::device::{DeviceBuilder, Peer}; use crate::packet::PacketBufPool; use crate::tun::{ - IpRecv, + IpRecv, IpSend, nat::{NatIpRecv, NatIpSend}, - router::TunRxRouter, + router::{TunRxRouter, TunTxRouter}, }; use crate::udp::channel::{UdpChannelFactory, UdpChannelV4, UdpChannelV6}; @@ -230,6 +230,99 @@ async fn test_tun_router() { assert_eq!(received.payload(), Some(b"hello default".as_slice())); } +/// Demonstrate that a malicious outer-tunnel peer can spoof packets that +/// *appear* to come from inside the inner tunnel — and that wiring +/// [`TunTxRouter`] with `inner_allowed_ips` blocks the spoof. +/// +/// Scenario: the outer WG decapsulates a packet whose source IP is inside the +/// inner tunnel's allowed-IP range, but whose UDP source-socket does *not* +/// match the inner WG endpoint. Pre-fix, the [`TunTxRouter`] would route this +/// straight to the local TUN - letting the outer peer impersonate any host +/// behind the inner tunnel. With the fix, it is dropped. +#[tokio::test] +#[test_log::test] +async fn tun_tx_router_blocks_spoofed_inner_source() { + use std::net::SocketAddr; + + use crate::packet::{Ip, Packet}; + use ipnetwork::IpNetwork; + + // A tiny `IpSend` that records every packet it receives. + #[derive(Clone)] + struct RecordingIpSend(mpsc::Sender>); + impl IpSend for RecordingIpSend { + async fn send(&mut self, packet: Packet) -> std::io::Result<()> { + self.0.send(packet).await.unwrap(); + Ok(()) + } + } + + let inner_allowed: IpNetwork = "10.100.0.0/24".parse().unwrap(); + let inner_tun_endpoint: SocketAddr = "203.0.113.1:51820".parse().unwrap(); + + // The forged packet: src is *inside* the inner allowed range. Legitimately, + // a packet with this src can only reach the local stack via the inner WG + // device (which decrypts it, then NATs the dest). If it shows up at the + // outer device's IpSend, the outer peer must have minted it. + let forged_src: Ipv4Addr = "10.100.0.5".parse().unwrap(); + let local_dst: Ipv4Addr = "10.0.0.1".parse().unwrap(); + let forged = || mock::packet_with_addrs(forged_src, local_dst, b"spoofed reply"); + + // --- Without the filter: the hijack works. --- + { + let (inner_tx, mut inner_rx) = mpsc::channel(8); + let (outer_tx, mut outer_rx) = mpsc::channel(8); + let mut router = TunTxRouter::new( + RecordingIpSend(inner_tx), + RecordingIpSend(outer_tx), + inner_tun_endpoint, + &[], // no filter — the pre-fix behavior + ); + router.send(forged()).await.unwrap(); + assert!( + outer_rx.try_recv().is_ok(), + "without inner_allowed_ips, the outer peer's spoofed packet \ + reaches the local TUN — this is the hijack" + ); + assert!(inner_rx.try_recv().is_err()); + } + + // --- With the filter: the hijack is blocked. --- + { + let (inner_tx, mut inner_rx) = mpsc::channel(8); + let (outer_tx, mut outer_rx) = mpsc::channel(8); + let mut router = TunTxRouter::new( + RecordingIpSend(inner_tx), + RecordingIpSend(outer_tx), + inner_tun_endpoint, + &[inner_allowed], + ); + router.send(forged()).await.unwrap(); + assert!( + outer_rx.try_recv().is_err(), + "spoofed packet with src inside inner_allowed_ips must NOT reach the TUN" + ); + assert!( + inner_rx.try_recv().is_err(), + "spoofed packet must also not be forwarded to the inner WG" + ); + + // Sanity: a packet with an unrelated source (a legitimate direct + // outer-tunnel response) still reaches the outer TUN. + let legit_src: Ipv4Addr = "8.8.8.8".parse().unwrap(); + router + .send(mock::packet_with_addrs( + legit_src, + local_dst, + b"legit response", + )) + .await + .unwrap(); + let received = outer_rx.try_recv().expect("legit traffic must pass"); + assert_eq!(received.source(), Some(legit_src.into())); + } +} + /// Test [`NatIpRecv`] and [`NatIpSend`] /// /// - Outgoing: app sends src=outer_tun_ip -> Bob receives src=inner_tun_ip diff --git a/gotatun/src/tun/router.rs b/gotatun/src/tun/router.rs index 09ca7f02..fb83fd9a 100644 --- a/gotatun/src/tun/router.rs +++ b/gotatun/src/tun/router.rs @@ -132,31 +132,62 @@ impl TunRxRouter { /// An [`IpSend`] combined from two [`IpSend`]s. If the source equals `inner_tun_endpoint`, /// then `send` will forward the packet to `inner_tx` (e.g., a [`TunChannelTx`](crate::tun::channel::TunChannelTx)). /// Otherwise, it is forwarded to `outer_tx` (e.g., a real TUN device). +/// +/// Packets whose source IP falls inside any of the configured `inner_allowed_ips` +/// are silently dropped. This prevents the outer tunnel's peer from forging packets +/// that *appear* to originate inside the inner tunnel: such packets must only ever +/// reach the local stack via the inner WireGuard device, never via the outer one. // TODO: Can we generalize this to map any number of endpoints to other channels? pub struct TunTxRouter { inner_tx: Inner, outer_tx: Outer, inner_tun_endpoint: SocketAddr, + inner_allowed_ips: IpNetworkTable<()>, } impl TunTxRouter { - /// Create a new `DemuxIpSend`. + /// Create a new `TunTxRouter`. /// /// # Arguments - /// * `inner_tx` - Destination for packets matching inner tunnel addresse + /// * `inner_tx` - Destination for packets matching inner tunnel addresses /// * `outer_tx` - Destination for all other packets - /// * `inner_tun_addr` - IP address of the inner WireGuard tunnel - pub fn new(inner_tx: Inner, outer_tx: Outer, inner_tun_endpoint: SocketAddr) -> Self { + /// * `inner_tun_endpoint` - UDP socket address of the inner WireGuard endpoint + /// * `inner_allowed_ips` - Source IPs the inner tunnel is authoritative for. + /// Decapsulated packets with a source IP in this set are dropped, since the + /// outer peer would otherwise be able to spoof them. Pass an empty slice only + /// if no inner tunnel is wired up to this router (no spoofing surface). + pub fn new( + inner_tx: Inner, + outer_tx: Outer, + inner_tun_endpoint: SocketAddr, + inner_allowed_ips: &[IpNetwork], + ) -> Self { + let mut table = IpNetworkTable::new(); + for net in inner_allowed_ips { + let net = ip_network::IpNetwork::new_truncate(net.ip(), net.prefix()) + .expect("cidr is valid length"); + table.insert(net, ()); + } Self { inner_tx, outer_tx, inner_tun_endpoint, + inner_allowed_ips: table, } } } impl IpSend for TunTxRouter { async fn send(&mut self, packet: Packet) -> io::Result<()> { + if let Some(src_ip) = packet.source() + && self.inner_allowed_ips.longest_match(src_ip).is_some() + { + log::trace!( + "Dropping decapsulated outer-tunnel packet with src {src_ip} \ + inside inner_allowed_ips (forgery / hijack attempt)" + ); + return Ok(()); + } if let Some(src) = extract_udp_src(&packet) { if self.inner_tun_endpoint == src { return self.inner_tx.send(packet).await; From a260fcdee174a39ddbc71ab815050d5d777dfb86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20L=C3=B6nnhager?= Date: Thu, 23 Apr 2026 14:38:24 +0200 Subject: [PATCH 10/10] fixup --- gotatun/src/tun/nat.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/gotatun/src/tun/nat.rs b/gotatun/src/tun/nat.rs index a49a6f53..9ad6fad2 100644 --- a/gotatun/src/tun/nat.rs +++ b/gotatun/src/tun/nat.rs @@ -14,8 +14,6 @@ //! [`NatIpRecv`] rewrites the source IP on TUN read. //! [`NatIpSend`] rewrites the destination IP on TUN write. -// TODO: May need state to make sure outer tunnel cannot talk to port "owned" by inner - use std::io; use std::net::{Ipv4Addr, Ipv6Addr};