fix: correct hairpin NAT rules and bind hairpin check to gateway interface

The POSTROUTING MASQUERADE rules in forward-port failed to handle two
hairpin scenarios:

1. Host-to-target hairpin (OUTPUT DNAT): when sip is a WAN IP (tunnel
   case), the old rule matched `-s sip` but the actual source of
   locally-originated packets is a local interface IP, not the WAN IP.
   Fix: use `-m addrtype --src-type LOCAL -m conntrack --ctorigdst sip`
   to match any local source while tying the rule to the specific sip.

2. Same-subnet self-hairpin (PREROUTING DNAT): when a WireGuard peer
   connects to itself via the tunnel's public IP, traffic is DNAT'd back
   to the peer. Without MASQUERADE the response takes a loopback shortcut,
   bypassing the tunnel server's conntrack and breaking NAT reversal.
   Fix: add `-s dip/dprefix -d dip` to masquerade same-subnet traffic,
   which also subsumes the old bridge_subnet rule.

Also bind the hairpin detection socket to the gateway interface and local
IP for consistency with the echoip client.
This commit is contained in:
Aiden McClelland
2026-03-30 11:52:53 -06:00
parent c96b38f915
commit f46cdc6ee5
2 changed files with 26 additions and 14 deletions

View File

@@ -58,15 +58,18 @@ iptables -t nat -A ${NAME}_OUTPUT -d "$sip" -p udp --dport "$sport" -j DNAT --to
iptables -A ${NAME}_FORWARD -d $dip -p tcp --dport $dport -m state --state NEW -j ACCEPT
iptables -A ${NAME}_FORWARD -d $dip -p udp --dport $dport -m state --state NEW -j ACCEPT
# NAT hairpin: masquerade traffic from the bridge subnet or host to the DNAT
# target, so replies route back through the host for proper NAT reversal.
# Container-to-container hairpin (source is on the bridge subnet)
if [ -n "$bridge_subnet" ]; then
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$bridge_subnet" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
fi
# Host-to-container hairpin (host connects to its own gateway IP, source is sip)
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
# NAT hairpin: masquerade so replies route back through this host for proper
# NAT reversal instead of taking a direct path that bypasses conntrack.
# Host-to-target hairpin: locally-originated packets whose original destination
# was sip (before OUTPUT DNAT rewrote it to dip). Using --ctorigdst ties the
# rule to this specific sip, so multiple WAN IPs forwarding the same port to
# different targets each get their own masquerade.
iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -m addrtype --src-type LOCAL -m conntrack --ctorigdst "$sip" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
# Same-subnet hairpin: when traffic originates from the same subnet as the DNAT
# target (e.g. a container reaching another container, or a WireGuard peer
# connecting to itself via the tunnel's public IP).
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p tcp --dport "$dport" -j MASQUERADE
iptables -t nat -A ${NAME}_POSTROUTING -s "$dip/$dprefix" -d "$dip" -p udp --dport "$dport" -j MASQUERADE
exit $err

View File

@@ -290,10 +290,19 @@ pub async fn check_port(
));
};
let hairpinning = tokio::time::timeout(
Duration::from_secs(5),
tokio::net::TcpStream::connect(SocketAddr::new(ip.into(), port)),
)
let hairpinning = tokio::time::timeout(Duration::from_secs(5), async {
let dest = SocketAddr::new(ip.into(), port);
let socket = socket2::Socket::new(socket2::Domain::IPV4, socket2::Type::STREAM, None)?;
socket.bind_device(Some(gateway.as_str().as_bytes()))?;
socket.bind(&SocketAddr::new(IpAddr::V4(local_ipv4), 0).into())?;
socket.set_nonblocking(true)?;
#[cfg(unix)]
let socket = unsafe {
use std::os::fd::{FromRawFd, IntoRawFd};
tokio::net::TcpSocket::from_raw_fd(socket.into_raw_fd())
};
socket.connect(dest).await.map(|_| ())
})
.await
.map_or(false, |r| r.is_ok());