Quellcode durchsuchen

backport v1.11: Recompute the transport checksum on self-forwarded packets (#1880)

* Recompute the transport checksum on self-forwarded packets (#1862)

(cherry picked from commit ec3304e3a95e0105f142d7a1ce06f36231bb5cf3)

* try to backport

* fix miss backport

* backport anyFragment logic

* restore test

---------

Co-authored-by: Caleb Jasik <calebjasik@jasik.xyz>
Wade Simmons vor 1 Monat
Ursprung
Commit
b66c9bb362
10 geänderte Dateien mit 834 neuen und 24 gelöschten Zeilen
  1. 21 0
      .github/workflows/smoke.yml
  2. 130 0
      .github/workflows/smoke/smoke-self.sh
  3. 4 1
      Makefile
  4. 1 0
      inside.go
  5. 263 0
      inside_test.go
  6. 147 0
      iputil/checksum.go
  7. 242 0
      iputil/checksum_test.go
  8. 10 9
      iputil/packet.go
  9. 15 13
      iputil/packet_test.go
  10. 1 1
      outside.go

+ 21 - 0
.github/workflows/smoke.yml

@@ -61,3 +61,24 @@ jobs:
       run: NAME="smoke-p256" ./smoke.sh
 
     timeout-minutes: 10
+
+  smoke-self:
+    name: Run self traffic smoke test on macOS
+    runs-on: macos-latest
+    steps:
+
+    - uses: actions/checkout@v7
+
+    - uses: actions/setup-go@v7
+      with:
+        go-version: '1.26'
+        check-latest: true
+
+    - name: build
+      run: make bin
+
+    - name: run smoke-self
+      working-directory: ./.github/workflows/smoke
+      run: ./smoke-self.sh
+
+    timeout-minutes: 10

+ 130 - 0
.github/workflows/smoke/smoke-self.sh

@@ -0,0 +1,130 @@
+#!/bin/bash
+
+# A host must be able to reach its own overlay address. Where the kernel sends
+# that traffic through the tun rather than over loopback, nebula sees it and
+# hands it straight back (immediatelyForwardToSelf), and whether the kernel
+# accepts what comes back is only answerable against a real kernel. Runs one
+# nebula on this machine as root and aims every probe at its own address.
+
+set -e -x
+
+set -o pipefail
+
+V4=192.0.2.1
+V6=2001:db8::1
+
+case "$(uname -s)" in
+    Darwin) TUN_DEV=utun ;;
+    *) TUN_DEV=tun0 ;;
+esac
+
+ROOT="$(cd ../../.. && pwd)"
+
+rm -rf build/self
+mkdir -p build/self
+cd build/self
+
+cleanup() {
+    echo
+    echo " *** cleanup"
+    echo
+
+    set +e
+    if [ -n "$NEBULA_PID" ]
+    then
+        sudo kill "$NEBULA_PID"
+    fi
+    { kill $(jobs -p); wait; } 2>/dev/null
+    sed 's/^/  [self]  /' nebula.log
+}
+
+trap cleanup EXIT
+
+# perl is on every platform this runs on; timeout(1) is not.
+alarm() {
+    perl -e 'alarm shift; exec @ARGV' "$@"
+}
+
+RESULTS=""
+FAILED=""
+probe() {
+    local name="$1"
+    shift
+    if "$@"
+    then
+        RESULTS="$RESULTS $name=ok"
+    else
+        RESULTS="$RESULTS $name=FAIL"
+        FAILED="$FAILED $name"
+    fi
+}
+
+# Send one datagram, then wait for the listener to have written it out.
+udp_probe() {
+    echo self | alarm 5 nc -u -w1 "$1" 3000 || true
+    set +x
+    for _ in $(seq 1 20)
+    do
+        if grep -q self "$2"
+        then
+            set -x
+            return 0
+        fi
+        sleep 0.25
+    done
+    set -x
+    return 1
+}
+
+"$ROOT/nebula-cert" ca -name "Smoke Test"
+"$ROOT/nebula-cert" sign -name self -networks "$V4/24,$V6/64"
+
+HOST=self AM_LIGHTHOUSE=true TUN_DEV="$TUN_DEV" ../../genconfig.sh >self.yml
+
+"$ROOT/nebula" -config self.yml -test
+
+sudo -v
+sudo "$ROOT/nebula" -config self.yml >nebula.log 2>&1 &
+NEBULA_PID=$!
+
+for _ in $(seq 1 40)
+do
+    ifconfig | grep "inet6 $V6 " >/dev/null && break
+    sleep 0.25
+done
+ifconfig | grep "inet $V4 "
+ifconfig | grep "inet6 $V6 "
+
+nc -l "$V4" 2000 >/dev/null &
+nc -l "$V6" 2000 >/dev/null &
+nc -u -l "$V4" 3000 >udp4.txt &
+nc -u -l "$V6" 3000 >udp6.txt &
+sleep 1
+
+set +x
+echo
+echo " *** Testing self traffic from $V4"
+echo
+set -x
+probe icmp4 alarm 5 ping -c1 "$V4"
+probe tcp4 alarm 5 nc -z "$V4" 2000
+probe udp4 udp_probe "$V4" udp4.txt
+
+set +x
+echo
+echo " *** Testing self traffic from $V6"
+echo
+set -x
+probe icmp6 alarm 5 ping6 -c1 "$V6"
+probe tcp6 alarm 5 nc -z "$V6" 2000
+probe udp6 udp_probe "$V6" udp6.txt
+
+set +x
+echo
+echo " *** self traffic:$RESULTS"
+echo
+if [ -n "$FAILED" ]
+then
+    echo "self traffic failed:$FAILED" >&2
+    exit 1
+fi

+ 4 - 1
Makefile

@@ -275,10 +275,13 @@ smoke-docker-race: BUILD_ARGS = -race
 smoke-docker-race: CGO_ENABLED = 1
 smoke-docker-race: smoke-docker
 
+smoke-self: bin
+	cd .github/workflows/smoke/ && ./smoke-self.sh
+
 smoke-vagrant/%: bin-docker build/%/nebula
 	cd .github/workflows/smoke/ && ./build.sh $*
 	cd .github/workflows/smoke/ && ./smoke-vagrant.sh $*
 
 .FORCE:
-.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race test test-cov-html smoke-vagrant/%
+.PHONY: all all-linux all-freebsd all-openbsd all-netbsd all-darwin all-windows all-cross-linux all-cross-linux-arm all-cross-linux-mips all-cross-linux-other all-cross-darwin all-cross-windows bench bench-cpu bench-cpu-long bin build-test-mobile e2e e2ev e2evv e2evvv e2evvvv proto release service smoke-docker smoke-docker-race smoke-self test test-cov-html smoke-vagrant/%
 .DEFAULT_GOAL := bin

+ 1 - 0
inside.go

@@ -37,6 +37,7 @@ func (f *Interface) consumeInsidePacket(packet []byte, fwPacket *firewall.Packet
 		// routes packets from the Nebula addr to the Nebula addr through the Nebula
 		// TUN device.
 		if immediatelyForwardToSelf {
+			iputil.SetTransportChecksum(packet)
 			_, err := f.readers[q].Write(packet)
 			if err != nil {
 				f.l.Error("Failed to forward to tun", "error", err)

+ 263 - 0
inside_test.go

@@ -0,0 +1,263 @@
+package nebula
+
+import (
+	"encoding/binary"
+	"io"
+	"net/netip"
+	"testing"
+
+	"github.com/gaissmai/bart"
+	"github.com/slackhq/nebula/firewall"
+	"github.com/slackhq/nebula/test"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+const (
+	ipv4HeaderLen = 20
+	ipv6HeaderLen = 40
+)
+
+// capturingTun is a tio.Queue that records what is written to it. A queue that
+// discards writes is indistinguishable from a packet that was never forwarded.
+type capturingTun struct {
+	writes [][]byte
+}
+
+func (c *capturingTun) Read([]byte) (int, error) { return 0, io.EOF }
+func (c *capturingTun) Close() error             { return nil }
+
+func (c *capturingTun) Write(b []byte) (int, error) {
+	c.writes = append(c.writes, append([]byte(nil), b...))
+	return len(b), nil
+}
+
+func newSelfForwardInterface(myAddrs ...netip.Addr) (*Interface, *capturingTun) {
+	vpnAddrs := &bart.Lite{}
+	for _, a := range myAddrs {
+		vpnAddrs.Insert(netip.PrefixFrom(a, a.BitLen()))
+	}
+
+	tun := &capturingTun{}
+	return &Interface{
+		l:                     test.NewLogger(),
+		myVpnAddrsTable:       vpnAddrs,
+		myBroadcastAddrsTable: &bart.Lite{},
+		readers:               []io.ReadWriteCloser{tun},
+	}, tun
+}
+
+func consumeInside(f *Interface, packet []byte) {
+	f.consumeInsidePacket(packet, &firewall.Packet{}, make([]byte, 12), make([]byte, mtu), 0, nil)
+}
+
+// l4Proto describes one upper-layer header for these tests: its IP next-header
+// value, where its checksum field sits within the header, and how to build a
+// minimal instance of it.
+type l4Proto struct {
+	name    string
+	nextHdr uint8
+	cksumAt int
+	build   func() []byte
+}
+
+var (
+	tcpSyn = l4Proto{"tcp", firewall.ProtoTCP, 16, func() []byte {
+		h := make([]byte, 20)
+		binary.BigEndian.PutUint16(h[0:2], 49152)
+		binary.BigEndian.PutUint16(h[2:4], 443)
+		binary.BigEndian.PutUint32(h[4:8], 0x11223344) // sequence
+		h[12] = 5 << 4                                 // data offset, no options
+		h[13] = 0x02                                   // SYN
+		binary.BigEndian.PutUint16(h[14:16], 65535)    // window
+		return h
+	}}
+
+	udpDatagram = l4Proto{"udp", firewall.ProtoUDP, 6, func() []byte {
+		h := make([]byte, 8+4)
+		binary.BigEndian.PutUint16(h[0:2], 49152)
+		binary.BigEndian.PutUint16(h[2:4], 53)
+		binary.BigEndian.PutUint16(h[4:6], uint16(len(h)))
+		copy(h[8:], "ping")
+		return h
+	}}
+
+	icmpEcho   = l4Proto{"icmp", firewall.ProtoICMP, 2, func() []byte { return echoRequest(8) }}
+	icmpv6Echo = l4Proto{"icmpv6", firewall.ProtoICMPv6, 2, func() []byte { return echoRequest(128) }}
+)
+
+// echoRequest builds an echo request body. The type differs between ICMP and
+// ICMPv6, the rest of the header does not.
+func echoRequest(typ uint8) []byte {
+	h := make([]byte, 8)
+	h[0] = typ
+	binary.BigEndian.PutUint16(h[4:6], 0xbeef) // identifier
+	binary.BigEndian.PutUint16(h[6:8], 1)      // sequence
+	return h
+}
+
+func buildIPv6(src, dst netip.Addr, p l4Proto) []byte {
+	l4 := p.build()
+	pkt := make([]byte, ipv6HeaderLen+len(l4))
+	pkt[0] = 0x60
+	binary.BigEndian.PutUint16(pkt[4:6], uint16(len(l4)))
+	pkt[6] = p.nextHdr
+	pkt[7] = 64
+	copy(pkt[8:24], src.AsSlice())
+	copy(pkt[24:40], dst.AsSlice())
+	copy(pkt[ipv6HeaderLen:], l4)
+	if l4 := pkt[ipv6HeaderLen:]; p.nextHdr == firewall.ProtoTCP || p.nextHdr == firewall.ProtoUDP {
+		sum := ipv6PseudoheaderSum(src, dst, uint32(p.nextHdr), uint32(len(l4)))
+		binary.BigEndian.PutUint16(l4[p.cksumAt:], ^fold(sumBytes(l4, sum)))
+	}
+	return pkt
+}
+
+func buildIPv4(src, dst netip.Addr, p l4Proto) []byte {
+	l4 := p.build()
+	pkt := make([]byte, ipv4HeaderLen+len(l4))
+	pkt[0] = 0x45
+	binary.BigEndian.PutUint16(pkt[2:4], uint16(len(pkt)))
+	pkt[8] = 64
+	pkt[9] = p.nextHdr
+	copy(pkt[12:16], src.AsSlice())
+	copy(pkt[16:20], dst.AsSlice())
+	copy(pkt[ipv4HeaderLen:], l4)
+	if l4 := pkt[ipv4HeaderLen:]; p.nextHdr == firewall.ProtoTCP || p.nextHdr == firewall.ProtoUDP {
+		sum := sumBytes(pkt[12:20], uint32(p.nextHdr)+uint32(len(l4)))
+		binary.BigEndian.PutUint16(l4[p.cksumAt:], ^fold(sumBytes(l4, sum)))
+	}
+	return pkt
+}
+
+// ipv6PseudoheaderSum is the RFC 2460 section 8.1 pseudo-header sum: source,
+// destination, a 32 bit upper-layer packet length and a 32 bit zero-padded next
+// header. Kept local to the test so these assertions do not check nebula's
+// checksum code against itself.
+func ipv6PseudoheaderSum(src, dst netip.Addr, nextHeader, length uint32) uint32 {
+	var csum uint32
+	s, d := src.AsSlice(), dst.AsSlice()
+	for i := 0; i < 16; i += 2 {
+		csum += uint32(s[i])<<8 | uint32(s[i+1])
+		csum += uint32(d[i])<<8 | uint32(d[i+1])
+	}
+	return csum + length + nextHeader
+}
+
+func sumBytes(b []byte, csum uint32) uint32 {
+	for i := 0; i+1 < len(b); i += 2 {
+		csum += uint32(b[i])<<8 | uint32(b[i+1])
+	}
+	if len(b)%2 == 1 {
+		csum += uint32(b[len(b)-1]) << 8
+	}
+	return csum
+}
+
+func fold(csum uint32) uint16 {
+	for csum > 0xffff {
+		csum = (csum >> 16) + (csum & 0xffff)
+	}
+	return uint16(csum)
+}
+
+// l4ChecksumValid6 verifies an IPv6 upper-layer checksum the way a receiver
+// does: the pseudo-header plus the whole upper-layer segment, checksum field
+// included, folds to 0xffff. The next header field is the upper-layer protocol
+// only while there are no extension headers, which is all this file builds.
+func l4ChecksumValid6(pkt []byte) bool {
+	src, _ := netip.AddrFromSlice(pkt[8:24])
+	dst, _ := netip.AddrFromSlice(pkt[24:40])
+	l4 := pkt[ipv6HeaderLen:]
+	return fold(sumBytes(l4, ipv6PseudoheaderSum(src, dst, uint32(pkt[6]), uint32(len(l4))))) == 0xffff
+}
+
+// l4ChecksumValid4 is the IPv4 counterpart: the RFC 793/768 pseudo-header is
+// source, destination, a zero byte, the protocol and the upper-layer length.
+func l4ChecksumValid4(pkt []byte) bool {
+	ihl := int(pkt[0]&0x0f) << 2
+	l4 := pkt[ihl:]
+	return fold(sumBytes(l4, sumBytes(pkt[12:20], uint32(pkt[9])+uint32(len(l4))))) == 0xffff
+}
+
+// TestConsumeInsidePacketSelfTraffic covers the self-addressed branch of
+// consumeInsidePacket, taken where immediatelyForwardToSelf is set (see
+// inside_bsd.go): the packet goes straight back to the tun, ahead of the
+// firewall and the handshake.
+func TestConsumeInsidePacketSelfTraffic(t *testing.T) {
+	v4 := netip.MustParseAddr("100.100.1.42")
+	v6 := netip.MustParseAddr("fd00::42")
+
+	tests := []struct {
+		name string
+		addr netip.Addr
+		pkt  []byte
+	}{
+		{"ipv4/tcp", v4, buildIPv4(v4, v4, tcpSyn)},
+		{"ipv4/udp", v4, buildIPv4(v4, v4, udpDatagram)},
+		{"ipv4/icmp", v4, buildIPv4(v4, v4, icmpEcho)},
+		{"ipv6/tcp", v6, buildIPv6(v6, v6, tcpSyn)},
+		{"ipv6/udp", v6, buildIPv6(v6, v6, udpDatagram)},
+		{"ipv6/icmpv6", v6, buildIPv6(v6, v6, icmpv6Echo)},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			f, tun := newSelfForwardInterface(tt.addr)
+			// consumeInsidePacket writes through the slice it is handed, so a
+			// packet that arrived with a valid checksum must come back out of
+			// bytes taken before the call, unchanged.
+			want := append([]byte(nil), tt.pkt...)
+			consumeInside(f, tt.pkt)
+
+			if immediatelyForwardToSelf {
+				require.Len(t, tun.writes, 1)
+				assert.Equal(t, want, tun.writes[0])
+			} else {
+				assert.Empty(t, tun.writes, "self traffic reaches the tun over loopback here and must be dropped")
+			}
+		})
+	}
+}
+
+// TestConsumeInsidePacketSelfTrafficChecksum shows that the self-forward
+// returns the bytes it was handed, so a packet that arrived with a wrong
+// upper-layer checksum is written back with that same wrong checksum and the
+// kernel drops it on re-entry.
+//
+// This is how a macOS host loses TCP and UDP to its own IPv6 overlay address:
+// the kernel writes only the pseudo-header sum into the checksum field and
+// defers completion to hardware offload, state that does not survive the
+// crossing into userspace. Which kernels do this, for which protocols and IP
+// versions, is a property of the kernel and belongs to a test against a live
+// one; here the checksum is simply wrong, and the forward must make it right.
+func TestConsumeInsidePacketSelfTrafficChecksum(t *testing.T) {
+	if !immediatelyForwardToSelf {
+		t.Skip("self traffic never reaches the tun on this platform")
+	}
+	versions := []struct {
+		name  string
+		addr  netip.Addr
+		build func(src, dst netip.Addr, p l4Proto) []byte
+		l4At  int
+		valid func(pkt []byte) bool
+	}{
+		{"v4", netip.MustParseAddr("100.100.1.42"), buildIPv4, ipv4HeaderLen, l4ChecksumValid4},
+		{"v6", netip.MustParseAddr("fd00::42"), buildIPv6, ipv6HeaderLen, l4ChecksumValid6},
+	}
+	for _, v := range versions {
+		for _, p := range []l4Proto{tcpSyn, udpDatagram} {
+			t.Run(v.name+"/"+p.name, func(t *testing.T) {
+				pkt := v.build(v.addr, v.addr, p)
+				binary.BigEndian.PutUint16(pkt[v.l4At+p.cksumAt:], 0x1234)
+				require.False(t, v.valid(pkt), "the packet under test must start with a wrong checksum")
+				f, tun := newSelfForwardInterface(v.addr)
+				consumeInside(f, pkt)
+				require.Len(t, tun.writes, 1)
+				assert.True(t, v.valid(tun.writes[0]),
+					"a forwarded %s packet must carry a valid checksum, got 0x%04x",
+					p.name, binary.BigEndian.Uint16(tun.writes[0][v.l4At+p.cksumAt:]))
+			})
+		}
+	}
+}

+ 147 - 0
iputil/checksum.go

@@ -0,0 +1,147 @@
+package iputil
+
+import (
+	"encoding/binary"
+
+	"github.com/google/gopacket/layers"
+	"golang.org/x/net/ipv4"
+	"golang.org/x/net/ipv6"
+	"gvisor.dev/gvisor/pkg/tcpip/checksum"
+)
+
+const udpHeaderLen = 8
+
+// SetTransportChecksum recomputes the TCP or UDP checksum of an IPv4 or IPv6
+// packet in place.
+//
+// A kernel that offloads checksums to the NIC hands a packet to a tun with the
+// transport checksum unfinished: only the pseudo-header sum is in the field and
+// the rest is left for hardware that a tun does not have. A packet written
+// straight back to that tun is dropped on re-entry unless the checksum is
+// completed first. ICMP is left alone; it arrived complete on the kernels this
+// was measured against.
+//
+// So is any packet whose transport header cannot be located: fragments, unknown
+// extension headers and truncated packets. An IPv6 fragment header is declined
+// even when it carries the whole datagram (RFC 6946 atomic fragment), because
+// the walk reports only that a fragment header was present.
+func SetTransportChecksum(packet []byte) {
+	if len(packet) < 1 {
+		return
+	}
+	switch int(packet[0] >> 4) {
+	case ipv4.Version:
+		setTransportChecksum4(packet)
+	case ipv6.Version:
+		setTransportChecksum6(packet)
+	}
+}
+
+func setTransportChecksum4(packet []byte) {
+	if len(packet) < ipv4.HeaderLen {
+		return
+	}
+	ihl := int(packet[0]&0x0f) << 2
+	end := int(binary.BigEndian.Uint16(packet[2:4]))
+	if ihl < ipv4.HeaderLen || end < ihl || end > len(packet) {
+		return
+	}
+	// The checksum covers the whole datagram, which a fragment (MF set or a
+	// non-zero offset) does not carry.
+	if binary.BigEndian.Uint16(packet[6:8])&0x3fff != 0 {
+		return
+	}
+
+	transport, ok := transportExtent(packet[ihl:end], packet[9])
+	if !ok {
+		return
+	}
+	csum := ipv4PseudoheaderChecksum(packet[12:16], packet[16:20], uint32(packet[9]), uint32(len(transport)))
+	writeTransportChecksum(transport, layers.IPProtocol(packet[9]), csum)
+}
+
+func setTransportChecksum6(packet []byte) {
+	if len(packet) < ipv6.HeaderLen {
+		return
+	}
+	end := ipv6.HeaderLen + int(binary.BigEndian.Uint16(packet[4:6]))
+	if end > len(packet) {
+		return
+	}
+
+	// The checksum covers the whole datagram, which a fragment does not carry.
+	// An unknown extension header hides where the transport header starts. A
+	// chain longer than the walk's budget ends it early, at an offset that was
+	// never checked against the packet.
+	proto, offset, _, anyFragment, err := IPv6FindUpperProtocol(packet[:end])
+	if err != nil || anyFragment || offset >= end {
+		return
+	}
+
+	transport, ok := transportExtent(packet[offset:end], proto)
+	if !ok {
+		return
+	}
+	csum := ipv6PseudoheaderChecksum(packet[8:24], packet[24:40], uint32(proto), uint32(len(transport)))
+	writeTransportChecksum(transport, layers.IPProtocol(proto), csum)
+}
+
+// transportExtent narrows a segment to the length its own header declares. UDP
+// carries a Length field, and RFC 768 and RFC 8200 section 8.1 both make that
+// field, not the IP payload extent, the length the pseudo-header counts and the
+// checksum covers; a datagram padded out to a link's minimum frame is the usual
+// way the two differ. TCP has no such field, so its segment runs to the end of
+// the IP payload. A Length that overruns the bytes IP delivered describes a
+// datagram that is not there.
+func transportExtent(transport []byte, proto uint8) ([]byte, bool) {
+	if layers.IPProtocol(proto) != layers.IPProtocolUDP {
+		return transport, true
+	}
+	if len(transport) < udpHeaderLen {
+		return nil, false
+	}
+	ulen := int(binary.BigEndian.Uint16(transport[4:6]))
+	if ulen < udpHeaderLen || ulen > len(transport) {
+		return nil, false
+	}
+	return transport[:ulen], true
+}
+
+// writeTransportChecksum stores the checksum of transport, taken over the
+// pseudo-header sum csum, in the header's checksum field. A UDP checksum that
+// computes to zero goes on the wire as 0xffff: zero means no checksum was
+// computed (RFC 768), and over IPv6 the checksum is mandatory (RFC 8200
+// section 8.1).
+func writeTransportChecksum(transport []byte, proto layers.IPProtocol, csum uint32) {
+	var at, minLen int
+	switch proto {
+	case layers.IPProtocolTCP:
+		at, minLen = 16, 20
+	case layers.IPProtocolUDP:
+		at, minLen = 6, udpHeaderLen
+	default:
+		return
+	}
+	if len(transport) < minLen {
+		return
+	}
+
+	transport[at], transport[at+1] = 0, 0
+	sum := ^checksum.Checksum(transport, fold(csum))
+	if sum == 0 && proto == layers.IPProtocolUDP {
+		sum = 0xffff
+	}
+	binary.BigEndian.PutUint16(transport[at:], sum)
+}
+
+// fold reduces a pseudo-header sum to the 16 bit seed Checksum takes. Carrying
+// the high half back into the low half is what keeps the reduction lossless, so
+// the seed sums exactly as the wider value would; 0xffff is its fixed point.
+// Every term of that sum comes from a 16 bit field, so it stays far below the
+// width at which the accumulator would wrap.
+func fold(csum uint32) uint16 {
+	for csum > 0xffff {
+		csum = (csum >> 16) + (csum & 0xffff)
+	}
+	return uint16(csum)
+}

+ 242 - 0
iputil/checksum_test.go

@@ -0,0 +1,242 @@
+package iputil
+
+import (
+	"encoding/binary"
+	"net"
+	"testing"
+
+	"github.com/google/gopacket"
+	"github.com/google/gopacket/layers"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.org/x/net/ipv6"
+)
+
+// serialize builds a packet with gopacket, whose checksums are computed
+// independently of this package.
+func serialize(t *testing.T, ls ...gopacket.SerializableLayer) []byte {
+	buf := gopacket.NewSerializeBuffer()
+	require.NoError(t, gopacket.SerializeLayers(buf, gopacket.SerializeOptions{FixLengths: true, ComputeChecksums: true}, ls...))
+	return append([]byte(nil), buf.Bytes()...)
+}
+
+// withExtensionHeader inserts an 8 byte IPv6 extension header of the given
+// type between the IPv6 header and its payload. The transport checksum does not
+// change: the pseudo-header counts only upper-layer bytes.
+func withExtensionHeader(pkt []byte, typ layers.IPProtocol, hdr [8]byte) []byte {
+	hdr[0] = pkt[6]
+	out := make([]byte, 0, len(pkt)+8)
+	out = append(out, pkt[:40]...)
+	out = append(out, hdr[:]...)
+	out = append(out, pkt[40:]...)
+	out[6] = byte(typ)
+	binary.BigEndian.PutUint16(out[4:6], binary.BigEndian.Uint16(pkt[4:6])+8)
+	return out
+}
+
+// truncate copies the first n bytes into a buffer of exactly that capacity, so
+// a read past the length panics instead of quietly succeeding.
+func truncate(pkt []byte, n int) []byte {
+	out := make([]byte, n)
+	copy(out, pkt)
+	return out
+}
+
+// extChain builds an IPv6 packet fronted by n Destination Options headers. Each
+// points at another one, so the walk spends its whole budget without reaching a
+// transport header. lastExtLen inflates the final header's declared length,
+// which is how the walk ends up past the end of the packet.
+func extChain(n int, lastExtLen byte) []byte {
+	pkt := make([]byte, ipv6.HeaderLen)
+	pkt[0], pkt[6], pkt[7] = 0x60, 60, 64
+	for i := range n {
+		h := make([]byte, 8)
+		h[0] = 60
+		if i == n-1 {
+			h[1] = lastExtLen
+		}
+		pkt = append(pkt, h...)
+	}
+	pkt = append(pkt, make([]byte, 20)...)
+	binary.BigEndian.PutUint16(pkt[4:6], uint16(len(pkt)-ipv6.HeaderLen))
+	return pkt
+}
+
+func TestSetTransportChecksum(t *testing.T) {
+	// Source and destination differ so that a pseudo-header built from the wrong
+	// one, or from the two swapped, does not land on the same checksum anyway.
+	v4 := func(proto layers.IPProtocol) *layers.IPv4 {
+		return &layers.IPv4{Version: 4, TTL: 64, Id: 0x1234, Protocol: proto, SrcIP: net.IPv4(192, 0, 2, 1).To4(), DstIP: net.IPv4(198, 51, 100, 2).To4()}
+	}
+	v6 := func(proto layers.IPProtocol) *layers.IPv6 {
+		return &layers.IPv6{Version: 6, HopLimit: 64, NextHeader: proto, SrcIP: net.ParseIP("2001:db8::1"), DstIP: net.ParseIP("2001:db8:1::2")}
+	}
+	tcp := func(ip gopacket.NetworkLayer) *layers.TCP {
+		l := &layers.TCP{SrcPort: 49152, DstPort: 443, SYN: true, Window: 65535}
+		require.NoError(t, l.SetNetworkLayerForChecksum(ip))
+		return l
+	}
+	udp := func(ip gopacket.NetworkLayer) *layers.UDP {
+		l := &layers.UDP{SrcPort: 49152, DstPort: 53}
+		require.NoError(t, l.SetNetworkLayerForChecksum(ip))
+		return l
+	}
+	payload := gopacket.Payload("self")
+	nop := layers.IPv4Option{OptionType: 1, OptionLength: 1}
+
+	ip4tcp := v4(layers.IPProtocolTCP)
+	ip4opts := v4(layers.IPProtocolTCP)
+	ip4opts.Options = []layers.IPv4Option{nop, nop, nop, nop}
+	ip4udp := v4(layers.IPProtocolUDP)
+	ip6tcp := v6(layers.IPProtocolTCP)
+	ip6udp := v6(layers.IPProtocolUDP)
+	hopByHop := [8]byte{0, 0, 1, 4} // next header, length 0, PadN of 4
+
+	// Bytes past the length the IP header declares are not part of the
+	// datagram and must not be summed.
+	trailing4 := append(serialize(t, ip4tcp, tcp(ip4tcp), payload), []byte("trailing")...)
+	trailing6 := append(serialize(t, ip6tcp, tcp(ip6tcp), payload), []byte("trailing")...)
+
+	// A datagram padded out past the length UDP declares: the pseudo-header
+	// counts the UDP Length field, so the checksum is the unpadded one.
+	padded4 := append(serialize(t, ip4udp, udp(ip4udp), payload), []byte("pad!")...)
+	binary.BigEndian.PutUint16(padded4[2:4], uint16(len(padded4)))
+	padded6 := append(serialize(t, ip6udp, udp(ip6udp), payload), []byte("pad!")...)
+	binary.BigEndian.PutUint16(padded6[4:6], uint16(len(padded6)-ipv6.HeaderLen))
+
+	// Corrupting the checksum and asking for it back must yield gopacket's
+	// packet, byte for byte.
+	recomputed := []struct {
+		name  string
+		pkt   []byte
+		cksum int
+	}{
+		{"v4 tcp", serialize(t, ip4tcp, tcp(ip4tcp), payload), 20 + 16},
+		{"v4 tcp with ip options", serialize(t, ip4opts, tcp(ip4opts), payload), 24 + 16},
+		{"v4 udp", serialize(t, ip4udp, udp(ip4udp), payload), 20 + 6},
+		{"v4 tcp header only", serialize(t, ip4tcp, tcp(ip4tcp)), 20 + 16},
+		{"v4 udp header only", serialize(t, ip4udp, udp(ip4udp)), 20 + 6},
+		{"v6 tcp", serialize(t, ip6tcp, tcp(ip6tcp), payload), 40 + 16},
+		{"v6 udp", serialize(t, ip6udp, udp(ip6udp), payload), 40 + 6},
+		{"v6 udp header only", serialize(t, ip6udp, udp(ip6udp)), 40 + 6},
+		{"v6 tcp behind hop-by-hop", withExtensionHeader(serialize(t, ip6tcp, tcp(ip6tcp), payload), layers.IPProtocolIPv6HopByHop, hopByHop), 48 + 16},
+		{"v4 tcp with bytes past the total length", trailing4, 20 + 16},
+		{"v6 tcp with bytes past the payload length", trailing6, 40 + 16},
+		{"v4 udp padded past its declared length", padded4, 20 + 6},
+		{"v6 udp padded past its declared length", padded6, 40 + 6},
+	}
+	for _, tt := range recomputed {
+		t.Run(tt.name, func(t *testing.T) {
+			got := append([]byte(nil), tt.pkt...)
+			binary.BigEndian.PutUint16(got[tt.cksum:], 0x1234)
+			require.NotEqual(t, tt.pkt, got)
+			SetTransportChecksum(got)
+			assert.Equal(t, tt.pkt, got)
+		})
+	}
+
+	ip4frag := v4(layers.IPProtocolTCP)
+	ip4frag.Flags = layers.IPv4MoreFragments
+	ip4later := v4(layers.IPProtocolTCP)
+	ip4later.FragOffset = 1
+	ip4icmp := v4(layers.IPProtocolICMPv4)
+
+	badIHL := serialize(t, ip4tcp, tcp(ip4tcp), payload)
+	badIHL[0] = 0x44 // header length 16, shorter than an ipv4 header
+	shortTotalLen := serialize(t, ip4tcp, tcp(ip4tcp), payload)
+	binary.BigEndian.PutUint16(shortTotalLen[2:4], 10) // shorter than the header it introduces
+	cutTCP := serialize(t, ip4tcp, tcp(ip4tcp), payload)
+	binary.BigEndian.PutUint16(cutTCP[2:4], 20+19) // one byte short of a tcp header
+	cutTCP = truncate(cutTCP, 20+19)
+	cutUDP := serialize(t, ip4udp, udp(ip4udp), payload)
+	binary.BigEndian.PutUint16(cutUDP[2:4], 20+7) // one byte short of a udp header
+	cutUDP = truncate(cutUDP, 20+7)
+	// Two bytes short, so a transport header survives whole and the minimum
+	// length check cannot stand in for the bounds check.
+	cutV6 := truncate(serialize(t, ip6tcp, tcp(ip6tcp), payload), 62)
+	fragment := [8]byte{0, 0, 0, 1, 0, 0, 0, 1} // next header, reserved, offset 0 with M set, id
+	overrun4 := serialize(t, ip4udp, udp(ip4udp), payload)
+	binary.BigEndian.PutUint16(overrun4[24:26], uint16(len(overrun4)-20+1)) // one byte past what ip delivered
+	overrun6 := serialize(t, ip6udp, udp(ip6udp), payload)
+	binary.BigEndian.PutUint16(overrun6[44:46], uint16(len(overrun6)-ipv6.HeaderLen+1))
+	shortUDPLen := serialize(t, ip4udp, udp(ip4udp), payload)
+	binary.BigEndian.PutUint16(shortUDPLen[24:26], 7) // shorter than the header it counts
+
+	// Where the checksum cannot be completed the packet is left as it came.
+	untouched := []struct {
+		name  string
+		pkt   []byte
+		cksum int
+	}{
+		{"v4 first fragment", serialize(t, ip4frag, tcp(ip4frag), payload), 20 + 16},
+		{"v4 later fragment", serialize(t, ip4later, tcp(ip4later), payload), 20 + 16},
+		{"v4 icmp", serialize(t, ip4icmp, &layers.ICMPv4{TypeCode: layers.CreateICMPv4TypeCode(8, 0), Id: 1, Seq: 1}, payload), 20 + 2},
+		{"v4 header length below the minimum", badIHL, 20 + 16},
+		{"v4 total length below the header length", shortTotalLen, 20 + 16},
+		{"v4 truncated below its total length", truncate(serialize(t, ip4tcp, tcp(ip4tcp), payload), 30), -1},
+		{"v4 tcp header cut short", cutTCP, 20 + 16},
+		{"v4 udp header cut short", cutUDP, -1},
+		{"v6 fragment", withExtensionHeader(serialize(t, ip6tcp, tcp(ip6tcp), payload), layers.IPProtocolIPv6Fragment, fragment), 48 + 16},
+		{"v6 truncated below its payload length", truncate(serialize(t, ip6tcp, tcp(ip6tcp), payload), 50), -1},
+		{"v6 truncated with a whole transport header still present", cutV6, 40 + 16},
+		{"v6 extension header chain longer than the walk", extChain(9, 0), 112 + 16},
+		{"v6 extension header chain running past the packet", extChain(8, 255), 104 + 16},
+		{"v4 udp length past the end of the datagram", overrun4, 20 + 6},
+		{"v6 udp length past the end of the datagram", overrun6, 40 + 6},
+		{"v4 udp length below a udp header", shortUDPLen, 20 + 6},
+	}
+	for _, tt := range untouched {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.cksum >= 0 {
+				binary.BigEndian.PutUint16(tt.pkt[tt.cksum:], 0x1234)
+			}
+			want := append([]byte(nil), tt.pkt...)
+			SetTransportChecksum(tt.pkt)
+			assert.Equal(t, want, tt.pkt)
+		})
+	}
+
+	t.Run("too short to carry a header", func(t *testing.T) {
+		for _, pkt := range [][]byte{nil, {}, {0x45}, {0x60}} {
+			assert.NotPanics(t, func() { SetTransportChecksum(pkt) })
+		}
+	})
+
+	t.Run("tcp checksum of zero goes out as zero", func(t *testing.T) {
+		pkt := serialize(t, ip4tcp, tcp(ip4tcp), gopacket.Payload{0, 0})
+		c := binary.BigEndian.Uint16(pkt[36:38])
+		require.NotZero(t, c)
+		// Only udp reserves zero to mean "not computed", so tcp keeps it.
+		binary.BigEndian.PutUint16(pkt[40:42], c)
+		SetTransportChecksum(pkt)
+		assert.Zero(t, binary.BigEndian.Uint16(pkt[36:38]))
+	})
+
+	t.Run("udp checksum of zero goes out as 0xffff", func(t *testing.T) {
+		pkt := serialize(t, ip4udp, udp(ip4udp), gopacket.Payload{0, 0})
+		c := binary.BigEndian.Uint16(pkt[26:28])
+		require.NotZero(t, c)
+		// The one's complement sum is now 0xffff - c; adding c to the payload
+		// makes it 0xffff, whose complement is zero.
+		binary.BigEndian.PutUint16(pkt[28:30], c)
+		SetTransportChecksum(pkt)
+		assert.Equal(t, uint16(0xffff), binary.BigEndian.Uint16(pkt[26:28]))
+	})
+}
+
+func TestFold(t *testing.T) {
+	// 0xffff is the fold's fixed point, so a loop bound one notch tight never
+	// terminates on it.
+	for _, tt := range []struct {
+		in   uint32
+		want uint16
+	}{
+		{0, 0},
+		{0xffff, 0xffff},
+		{0x10000, 1},
+		{0x1fffe, 0xffff},
+		{0xffffffff, 0xffff},
+	} {
+		assert.Equal(t, tt.want, fold(tt.in))
+	}
+}

+ 10 - 9
iputil/packet.go

@@ -204,7 +204,7 @@ func ipv4CreateRejectTCPPacket(packet []byte, out []byte) []byte {
 }
 
 func ipv6CreateRejectPacket(packet []byte, out []byte) []byte {
-	proto, offset, isFragment, err := IPv6FindUpperProtocol(packet)
+	proto, offset, isFragment, _, err := IPv6FindUpperProtocol(packet)
 	if err != nil || isFragment {
 		return nil
 	}
@@ -346,9 +346,9 @@ func ipv6CreateRejectTCPPacket(packet []byte, out []byte, offset int) []byte {
 // protocol and offset points at the fragment header, there is no transport header to locate. Returns
 // ErrIPv6CouldNotFindPayload if packet is smaller than an ipv6 header or the chain is truncated before a
 // terminal protocol is reached.
-func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragment bool, err error) {
+func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragment, anyFragment bool, err error) {
 	if len(packet) < ipv6.HeaderLen {
-		return 0, 0, false, ErrIPv6CouldNotFindPayload
+		return 0, 0, false, false, ErrIPv6CouldNotFindPayload
 	}
 	nextHeader = packet[6]
 	offset = ipv6.HeaderLen
@@ -357,25 +357,26 @@ func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragm
 		switch nextHeader {
 		case 0, 43, 60: // Hop-by-Hop, Routing, Destination
 			if len(packet) < offset+2 {
-				return nextHeader, offset, isFragment, ErrIPv6CouldNotFindPayload
+				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
 			}
 			nextHeader = packet[offset]
 			offset += (int(packet[offset+1]) + 1) << 3
 
 		case 44: // Fragment
 			if len(packet) < offset+8 {
-				return nextHeader, offset, isFragment, ErrIPv6CouldNotFindPayload
+				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
 			}
+			anyFragment = true
 			// Non-first fragments carry no transport header, report the fragmented protocol and stop
 			if packet[offset+2] != 0 || packet[offset+3]&0xf8 != 0 {
-				return packet[offset], offset, true, nil
+				return packet[offset], offset, true, anyFragment, nil
 			}
 			nextHeader = packet[offset]
 			offset += 8
 
 		case 51: // AH
 			if len(packet) < offset+2 {
-				return nextHeader, offset, isFragment, ErrIPv6CouldNotFindPayload
+				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
 			}
 			nextHeader = packet[offset]
 			offset += (int(packet[offset+1]) + 2) << 2
@@ -384,9 +385,9 @@ func IPv6FindUpperProtocol(packet []byte) (nextHeader uint8, offset int, isFragm
 			// A prior extension header can declare a length that advances offset past the packet. The terminal
 			// protocol's header isn't actually here, so treat the chain as truncated rather than classifying it.
 			if offset > len(packet) {
-				return nextHeader, offset, isFragment, ErrIPv6CouldNotFindPayload
+				return nextHeader, offset, isFragment, anyFragment, ErrIPv6CouldNotFindPayload
 			}
-			return nextHeader, offset, isFragment, nil
+			return nextHeader, offset, isFragment, anyFragment, nil
 		}
 	}
 }

+ 15 - 13
iputil/packet_test.go

@@ -496,26 +496,27 @@ func Test_IPv6FindUpperProtocol(t *testing.T) {
 		wantProto    uint8
 		wantOffset   int
 		wantFragment bool
+		wantAnyFrag  bool
 		wantErr      error
 	}{
-		{"plain udp", 17, transport, 17, ipv6.HeaderLen, false, nil},
-		{"hop-by-hop then tcp", 0, append(extToTCP, transport...), 6, ipv6.HeaderLen + 8, false, nil},
-		{"routing then tcp", 43, append(extToTCP, transport...), 6, ipv6.HeaderLen + 8, false, nil},
-		{"destination then udp", 60, append(extToUDP, transport...), 17, ipv6.HeaderLen + 8, false, nil},
-		{"hop-by-hop, routing, then tcp", 0, append(append(extToRouting, extToTCP...), transport...), 6, ipv6.HeaderLen + 16, false, nil},
-		{"ah then udp", 51, append(ahToUDP, transport...), 17, ipv6.HeaderLen + 8, false, nil},
-		{"first fragment walks to transport", 44, append(firstFragToUDP, transport...), 17, ipv6.HeaderLen + 8, false, nil},
-		{"non-first fragment stops", 44, append(nonFirstFrag, transport...), 17, ipv6.HeaderLen, true, nil},
-		{"unknown protocol is terminal", 132, transport, 132, ipv6.HeaderLen, false, nil}, // SCTP
-		{"truncated extension header", 0, nil, 0, ipv6.HeaderLen, false, ErrIPv6CouldNotFindPayload},
+		{"plain udp", 17, transport, 17, ipv6.HeaderLen, false, false, nil},
+		{"hop-by-hop then tcp", 0, append(extToTCP, transport...), 6, ipv6.HeaderLen + 8, false, false, nil},
+		{"routing then tcp", 43, append(extToTCP, transport...), 6, ipv6.HeaderLen + 8, false, false, nil},
+		{"destination then udp", 60, append(extToUDP, transport...), 17, ipv6.HeaderLen + 8, false, false, nil},
+		{"hop-by-hop, routing, then tcp", 0, append(append(extToRouting, extToTCP...), transport...), 6, ipv6.HeaderLen + 16, false, false, nil},
+		{"ah then udp", 51, append(ahToUDP, transport...), 17, ipv6.HeaderLen + 8, false, false, nil},
+		{"first fragment walks to transport", 44, append(firstFragToUDP, transport...), 17, ipv6.HeaderLen + 8, false, true, nil},
+		{"non-first fragment stops", 44, append(nonFirstFrag, transport...), 17, ipv6.HeaderLen, true, true, nil},
+		{"unknown protocol is terminal", 132, transport, 132, ipv6.HeaderLen, false, false, nil}, // SCTP
+		{"truncated extension header", 0, nil, 0, ipv6.HeaderLen, false, false, ErrIPv6CouldNotFindPayload},
 		// Destination Options with a declared length (255+1)*8 = 2048 that runs past the 48 byte buffer, next = SCTP
-		{"extension length past buffer", 60, []byte{132, 255, 0, 0, 0, 0, 0, 0}, 132, ipv6.HeaderLen + 2048, false, ErrIPv6CouldNotFindPayload},
+		{"extension length past buffer", 60, []byte{132, 255, 0, 0, 0, 0, 0, 0}, 132, ipv6.HeaderLen + 2048, false, false, ErrIPv6CouldNotFindPayload},
 	}
 
 	for _, tt := range tests {
 		t.Run(tt.name, func(t *testing.T) {
 			packet := makeIPv6Packet(src, dst, tt.nextHeader, tt.payload)
-			proto, offset, isFragment, err := IPv6FindUpperProtocol(packet)
+			proto, offset, isFragment, anyFragment, err := IPv6FindUpperProtocol(packet)
 			if tt.wantErr != nil {
 				assert.ErrorIs(t, err, tt.wantErr)
 				return
@@ -524,12 +525,13 @@ func Test_IPv6FindUpperProtocol(t *testing.T) {
 			assert.Equal(t, tt.wantProto, proto)
 			assert.Equal(t, tt.wantOffset, offset)
 			assert.Equal(t, tt.wantFragment, isFragment)
+			assert.Equal(t, tt.wantAnyFrag, anyFragment)
 		})
 	}
 
 	// A packet smaller than an ipv6 header must error rather than panic reading byte 6
 	t.Run("shorter than ipv6 header", func(t *testing.T) {
-		_, _, _, err := IPv6FindUpperProtocol(make([]byte, 6))
+		_, _, _, _, err := IPv6FindUpperProtocol(make([]byte, 6))
 		assert.ErrorIs(t, err, ErrIPv6CouldNotFindPayload)
 	})
 }

+ 1 - 1
outside.go

@@ -335,7 +335,7 @@ func parseV6(data []byte, incoming bool, fp *firewall.Packet) error {
 	// Walk the extension header chain to the upper layer protocol. iputil.IPv6FindUpperProtocol is the single
 	// source of truth for which headers are extension headers, so this stays in lockstep with the reject path
 	// and cannot drift into misreading an unknown protocol (SCTP, GRE, etc.) as a forged transport.
-	proto, offset, isFragment, err := iputil.IPv6FindUpperProtocol(data)
+	proto, offset, isFragment, _, err := iputil.IPv6FindUpperProtocol(data)
 	if err != nil {
 		return ErrIPv6PacketTooShort
 	}