util/linuxfw: clamp MSS to PMTU in both forward directions (#20077)

ClampMSSToPMTU only added a rule matching the output interface (-o tun /
OIFNAME), which clamps the SYN forwarded out towards the tailnet peer but
not the SYN-ACK that arrives on tun and is forwarded back towards the
originating endpoint. As a result only one side of a forwarded handshake
had its MSS clamped; the endpoint on the other side of the proxy kept
advertising an MSS based on its own (larger) MTU.

When path MTU discovery is broken (e.g. proxies created by the Tailscale
Kubernetes operator, where tailscale0 has a 1280 MTU), the unclamped
endpoint's large segments exceed the tun MTU and are silently dropped,
causing TCP connections through proxy group pods to stall mid-stream on
large payloads. The earlier proxy-group fix (#19686) wired ClampMSSToPMTU
into the HA code paths but inherited this single-direction limitation, so
connections could still hang.

Add a second rule matching the input interface (-i tun / IIFNAME) in both
the iptables and nftables runners so both directions of the forwarded
handshake negotiate a PMTU-safe MSS.

Updates #19812

Signed-off-by: Samy Djemaï <53857555+SamyDjemai@users.noreply.github.com>
This commit is contained in:
Samy Djemaï
2026-06-22 11:25:15 +01:00
committed by GitHub
parent 59159d9180
commit 6a275c01db
2 changed files with 90 additions and 61 deletions
+10 -1
View File
@@ -322,7 +322,16 @@ func (i *iptablesRunner) DNATWithLoadBalancer(origDst netip.Addr, dsts []netip.A
func (i *iptablesRunner) ClampMSSToPMTU(tun string, addr netip.Addr) error {
table := i.getIPTByAddr(addr)
return table.Append("mangle", "FORWARD", "-o", tun, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
// Clamp MSS on forwarded TCP handshakes in both directions: the SYN
// leaving via tun towards the tailnet peer, and the SYN-ACK arriving on
// tun and being forwarded back out towards the originating endpoint. A
// single -o tun rule only clamps one side of the handshake, leaving the
// endpoint on the other side advertising an MSS that is too large for the
// tun MTU, which black-holes large segments when PMTU discovery is broken.
if err := table.Append("mangle", "FORWARD", "-o", tun, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu"); err != nil {
return err
}
return table.Append("mangle", "FORWARD", "-i", tun, "-p", "tcp", "--tcp-flags", "SYN,RST", "SYN", "-j", "TCPMSS", "--clamp-mss-to-pmtu")
}
// addBase6 adds some basic IPv6 processing rules to be
+80 -60
View File
@@ -248,11 +248,16 @@ func (n *nftablesRunner) EnsureSNATForDst(src, dst netip.Addr) error {
// ClampMSSToPMTU ensures that all packets with TCP flags (SYN, ACK, RST) set
// being forwarded via the given interface (tun) have MSS set to <MTU of the
// interface> - 40 (IP and TCP headers). This can be useful if this tailscale
// instance is expected to run as a forwarding proxy, forwarding packets from an
// endpoint with higher MTU in an environment where path MTU discovery is
// expected to not work (such as the proxies created by the Tailscale Kubernetes
// operator). ClamMSSToPMTU creates a new base-chain ts-clamp in the filter
// interface> - 40 (IP and TCP headers). It clamps both directions of a
// forwarded TCP handshake: the SYN leaving via tun and the SYN-ACK arriving on
// tun. Clamping only the output direction would leave the endpoint on the other
// side of the proxy advertising an MSS that is too large for the tun MTU,
// black-holing large segments when path MTU discovery is broken. This can be
// useful if this tailscale instance is expected to run as a forwarding proxy,
// forwarding packets from an endpoint with higher MTU in an environment where
// path MTU discovery is expected to not work (such as the proxies created by
// the Tailscale Kubernetes operator). ClampMSSToPMTU creates a new base-chain
// ts-clamp in the filter
// table with accept policy and priority -150. In practice, this means that for
// SYN packets the clamp rule in this chain will likely run first and accept the
// packet. This is fine because 1) nftables run ALL chains with the same hook
@@ -289,61 +294,75 @@ func (n *nftablesRunner) ClampMSSToPMTU(tun string, addr netip.Addr) error {
return fmt.Errorf("error ensuring forward chain: %w", err)
}
clampRule := &nftables.Rule{
Table: filterTable,
Chain: fwChain,
Exprs: []expr.Any{
&expr.Meta{Key: expr.MetaKeyOIFNAME, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte(tun),
// clampRuleForIface builds a rule that clamps the MSS of forwarded TCP
// handshake packets matching tun on the given interface-name meta key.
// ifaceKey is either expr.MetaKeyOIFNAME (packets leaving via tun) or
// expr.MetaKeyIIFNAME (packets arriving on tun).
clampRuleForIface := func(ifaceKey expr.MetaKey) *nftables.Rule {
return &nftables.Rule{
Table: filterTable,
Chain: fwChain,
Exprs: []expr.Any{
&expr.Meta{Key: ifaceKey, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte(tun),
},
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte{unix.IPPROTO_TCP},
},
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseTransportHeader,
Offset: 13,
Len: 1,
},
&expr.Bitwise{
DestRegister: 1,
SourceRegister: 1,
Len: 1,
Mask: []byte{0x02},
Xor: []byte{0x00},
},
&expr.Cmp{
Op: expr.CmpOpNeq, // match any packet with a TCP flag set (SYN, ACK, RST)
Register: 1,
Data: []byte{0x00},
},
&expr.Rt{
Register: 1,
Key: expr.RtTCPMSS,
},
&expr.Byteorder{
DestRegister: 1,
SourceRegister: 1,
Op: expr.ByteorderHton,
Len: 2,
Size: 2,
},
&expr.Exthdr{
SourceRegister: 1,
Type: 2,
Offset: 2,
Len: 2,
Op: expr.ExthdrOpTcpopt,
},
},
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
&expr.Cmp{
Op: expr.CmpOpEq,
Register: 1,
Data: []byte{unix.IPPROTO_TCP},
},
&expr.Payload{
DestRegister: 1,
Base: expr.PayloadBaseTransportHeader,
Offset: 13,
Len: 1,
},
&expr.Bitwise{
DestRegister: 1,
SourceRegister: 1,
Len: 1,
Mask: []byte{0x02},
Xor: []byte{0x00},
},
&expr.Cmp{
Op: expr.CmpOpNeq, // match any packet with a TCP flag set (SYN, ACK, RST)
Register: 1,
Data: []byte{0x00},
},
&expr.Rt{
Register: 1,
Key: expr.RtTCPMSS,
},
&expr.Byteorder{
DestRegister: 1,
SourceRegister: 1,
Op: expr.ByteorderHton,
Len: 2,
Size: 2,
},
&expr.Exthdr{
SourceRegister: 1,
Type: 2,
Offset: 2,
Len: 2,
Op: expr.ExthdrOpTcpopt,
},
},
}
}
n.conn.AddRule(clampRule)
// Clamp both directions of the forwarded handshake: the SYN leaving via
// tun towards the tailnet peer, and the SYN-ACK arriving on tun and being
// forwarded back out towards the originating endpoint. Matching only the
// output interface leaves the endpoint on the other side advertising an MSS
// that is too large for the tun MTU, which black-holes large segments when
// PMTU discovery is broken.
n.conn.AddRule(clampRuleForIface(expr.MetaKeyOIFNAME))
n.conn.AddRule(clampRuleForIface(expr.MetaKeyIIFNAME))
return n.conn.Flush()
}
@@ -584,8 +603,9 @@ type NetfilterRunner interface {
DeleteSvc(svc, tun string, targetIPs []netip.Addr, pm []PortMap) error
// ClampMSSToPMTU adds a rule to the mangle/FORWARD chain to clamp MSS for
// traffic destined for the provided tun interface.
// ClampMSSToPMTU adds rules to clamp the MSS of forwarded TCP handshake
// packets in both directions (entering and leaving the provided tun
// interface), so both endpoints negotiate an MSS that fits the tun MTU.
ClampMSSToPMTU(tun string, addr netip.Addr) error
// AddMagicsockPortRule adds a rule to the ts-input chain to accept