From 48a5a806645aceef16fbf85c6e2d1c284ef0c065 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Wed, 5 Aug 2026 20:58:54 +0800 Subject: [PATCH 1/8] atunnel: support IPv6 original destination lookup --- internal/atunnel/original_dst_linux.go | 68 +++- internal/atunnel/original_dst_linux_test.go | 395 ++++++++++++++++++++ 2 files changed, 444 insertions(+), 19 deletions(-) create mode 100644 internal/atunnel/original_dst_linux_test.go diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index 07dd0f9344..8b4309119d 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -18,6 +18,7 @@ package atunnel import ( "encoding/binary" + "errors" "fmt" "net" "strconv" @@ -26,10 +27,12 @@ import ( "golang.org/x/sys/unix" ) -// TCPOriginalDestination reads the IPv4 destination preserved by a Linux -// REDIRECT rule. Actor networking is currently IPv4-only. -// TODO(liorlieberman) add the IPv6 IP6T_SO_ORIGINAL_DST variant -// when actor veth setup gains dual-stack support. +// IP6T_SO_ORIGINAL_DST is not generated by golang.org/x/sys/unix. It is +// defined as 80 in linux/netfilter_ipv6/ip6_tables.h. +const ip6tSOOriginalDst = 80 + +// TCPOriginalDestination reads the IPv4 or IPv6 destination preserved by a +// Linux REDIRECT rule. func TCPOriginalDestination(conn net.Conn) (string, error) { tcpConn, ok := conn.(*net.TCPConn) if !ok { @@ -40,21 +43,15 @@ func TCPOriginalDestination(conn net.Conn) (string, error) { return "", fmt.Errorf("atunnel: acquiring TCP syscall connection: %w", err) } - var addr unix.RawSockaddrInet4 var sockoptErr error + var destination string if err := rawConn.Control(func(fd uintptr) { - size := uint32(unsafe.Sizeof(addr)) - _, _, errno := unix.Syscall6( - unix.SYS_GETSOCKOPT, - fd, - unix.SOL_IP, - unix.SO_ORIGINAL_DST, - uintptr(unsafe.Pointer(&addr)), - uintptr(unsafe.Pointer(&size)), - 0, - ) - if errno != 0 { - sockoptErr = errno + destination, sockoptErr = originalIPv4Destination(fd) + // Linux returns ENOENT when the IPv4 original-destination option is + // queried on a redirected IPv6 connection. Only then try the IPv6 + // equivalent, so unrelated IPv4 failures retain their original error. + if errors.Is(sockoptErr, unix.ENOENT) { + destination, sockoptErr = originalIPv6Destination(fd) } }); err != nil { return "", fmt.Errorf("atunnel: accessing TCP socket: %w", err) @@ -62,11 +59,44 @@ func TCPOriginalDestination(conn net.Conn) (string, error) { if sockoptErr != nil { return "", fmt.Errorf("atunnel: reading original TCP destination: %w", sockoptErr) } + return destination, nil +} + +func originalIPv4Destination(fd uintptr) (string, error) { + var addr unix.RawSockaddrInet4 + if errno := getOriginalDestination(fd, unix.SOL_IP, unix.SO_ORIGINAL_DST, unsafe.Pointer(&addr), unsafe.Sizeof(addr)); errno != 0 { + return "", errno + } + return formatOriginalDestination(addr.Addr[:], addr.Port) +} + +func originalIPv6Destination(fd uintptr) (string, error) { + var addr unix.RawSockaddrInet6 + if errno := getOriginalDestination(fd, unix.SOL_IPV6, ip6tSOOriginalDst, unsafe.Pointer(&addr), unsafe.Sizeof(addr)); errno != 0 { + return "", errno + } + return formatOriginalDestination(addr.Addr[:], addr.Port) +} + +func getOriginalDestination(fd uintptr, level, option int, addr unsafe.Pointer, addrSize uintptr) unix.Errno { + size := uint32(addrSize) + _, _, errno := unix.Syscall6( + unix.SYS_GETSOCKOPT, + fd, + uintptr(level), + uintptr(option), + uintptr(addr), + uintptr(unsafe.Pointer(&size)), + 0, + ) + return errno +} - portBytes := (*[2]byte)(unsafe.Pointer(&addr.Port)) +func formatOriginalDestination(ip []byte, rawPort uint16) (string, error) { + portBytes := (*[2]byte)(unsafe.Pointer(&rawPort)) port := binary.BigEndian.Uint16(portBytes[:]) if port == 0 { return "", fmt.Errorf("atunnel: original TCP destination has port zero") } - return net.JoinHostPort(net.IP(addr.Addr[:]).String(), strconv.Itoa(int(port))), nil + return net.JoinHostPort(net.IP(ip).String(), strconv.Itoa(int(port))), nil } diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go new file mode 100644 index 0000000000..4f26d98055 --- /dev/null +++ b/internal/atunnel/original_dst_linux_test.go @@ -0,0 +1,395 @@ +//go:build linux + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package atunnel + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "strings" + "testing" + "time" + + "github.com/google/nftables" + "github.com/google/nftables/binaryutil" + "github.com/google/nftables/expr" + "github.com/vishvananda/netlink" + "github.com/vishvananda/netns" + "golang.org/x/sys/unix" + + "github.com/agent-substrate/substrate/internal/ateomnet" + "github.com/agent-substrate/substrate/internal/roottest" +) + +func TestTCPOriginalDestination(t *testing.T) { + roottest.Require(t, "CAP_NET_ADMIN + CAP_SYS_ADMIN for an actor-like network namespace and nftables REDIRECT rule") + + // Model the production path rather than redirecting a locally generated + // connection through OUTPUT. Actor egress enters the worker netns through a + // veth and is redirected in PREROUTING; that is the path on which Linux + // preserves SO_ORIGINAL_DST for atunnel. + actorNS := newTestNetNS(t) + actorIP, hostIP := setupTestVeth(t, actorNS) + // targetListener reserves the port the actor intends to reach. The NAT rule + // below must prevent connections from reaching it. + // + // redirectListener represents atunnel's local egress listener. It receives + // the redirected connection and is therefore the connection on which we ask + // Linux for the original destination. + redirectListener := listenTCP(t, hostIP) + defer redirectListener.Close() + targetListener := listenTCP(t, hostIP) + defer targetListener.Close() + targetPort := targetListener.Addr().(*net.TCPAddr).Port + + table := &nftables.Table{Family: nftables.TableFamilyIPv4, Name: fmt.Sprintf("atunnel_original_dst_test_%d", os.Getpid())} + installOriginalDstRedirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) + + clientDone := make(chan error, 1) + go func() { + // From the actor's perspective this is an ordinary connection to + // hostIP:targetPort. The worker's PREROUTING rule redirects it before + // it reaches the host network stack's local delivery path. + clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + conn, err := net.DialTimeout("tcp4", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), time.Second) + if err == nil { + _ = conn.Close() + } + return err + }) + }() + + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + redirected, err := redirectListener.Accept() + if err != nil { + t.Fatalf("accepting redirected connection: %v", err) + } + defer redirected.Close() + + // The accepted socket is addressed to redirectListener, but the kernel's + // SO_ORIGINAL_DST record must still contain the destination chosen by the + // actor before nftables rewrote it. + got, err := TCPOriginalDestination(redirected) + if err != nil { + t.Fatalf("TCPOriginalDestination: %v", err) + } + want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) + if got != want { + t.Errorf("original destination = %q, want %q", got, want) + } + if err := <-clientDone; err != nil { + t.Fatalf("dialing redirected connection: %v", err) + } +} + +func TestTCPOriginalDestinationIPv6(t *testing.T) { + roottest.Require(t, "CAP_NET_ADMIN + CAP_SYS_ADMIN for an actor-like network namespace and nftables REDIRECT rule") + + actorNS := newTestNetNS(t) + actorIP, hostIP := setupTestIPv6Veth(t, actorNS) + redirectListener := listenTCP6(t, hostIP) + defer redirectListener.Close() + targetListener := listenTCP6(t, hostIP) + defer targetListener.Close() + targetPort := targetListener.Addr().(*net.TCPAddr).Port + + table := &nftables.Table{Family: nftables.TableFamilyIPv6, Name: fmt.Sprintf("atunnel_original_dst_ipv6_test_%d", os.Getpid())} + installOriginalDstIPv6Redirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) + + clientDone := make(chan error, 1) + go func() { + clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + conn, err := net.DialTimeout("tcp6", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), time.Second) + if err == nil { + _ = conn.Close() + } + return err + }) + }() + + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + redirected, err := redirectListener.Accept() + if err != nil { + t.Fatalf("accepting redirected IPv6 connection: %v", err) + } + defer redirected.Close() + + // This assertion captures the IPv6 behavior required by #686. + got, err := TCPOriginalDestination(redirected) + if err != nil { + t.Fatalf("TCPOriginalDestination: %v", err) + } + want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) + if got != want { + t.Errorf("original IPv6 destination = %q, want %q", got, want) + } + if err := <-clientDone; err != nil { + t.Fatalf("dialing redirected IPv6 connection: %v", err) + } +} + +func newTestNetNS(t *testing.T) netns.NsHandle { + t.Helper() + name := fmt.Sprintf("atunnel-original-dst-%d", os.Getpid()) + ns, err := ateomnet.CreateNetNSWithoutSwitching(name) + if err != nil { + if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("needs CAP_SYS_ADMIN to create network namespace: %v", err) + } + t.Fatal(err) + } + t.Cleanup(func() { + _ = ns.Close() + if err := netns.DeleteNamed(name); err != nil { + t.Errorf("deleting test network namespace: %v", err) + } + }) + return ns +} + +func setupTestVeth(t *testing.T, actorNS netns.NsHandle) (actorIP, hostIP net.IP) { + t.Helper() + hostName := fmt.Sprintf("atod%d", os.Getpid()) + peerName := fmt.Sprintf("atop%d", os.Getpid()) + if err := netlink.LinkAdd(&netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: hostName}, PeerName: peerName}); err != nil { + if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("needs CAP_NET_ADMIN to create veth: %v", err) + } + t.Fatal(err) + } + t.Cleanup(func() { + if link, err := netlink.LinkByName(hostName); err == nil { + if err := netlink.LinkDel(link); err != nil { + t.Errorf("deleting test veth: %v", err) + } + } + }) + hostLink, err := netlink.LinkByName(hostName) + if err != nil { + t.Fatal(err) + } + // Allocate one of the /30s in 198.18.0.0/16 from the PID so concurrent + // test processes do not try to use the same host-side address. + network := uint16(os.Getpid() % (1 << 14)) + thirdOctet := byte(network >> 6) + fourthOctet := byte(network&0x3f) << 2 + hostIP = net.IPv4(198, 18, thirdOctet, fourthOctet+1) + actorIP = net.IPv4(198, 18, thirdOctet, fourthOctet+2) + if err := netlink.AddrAdd(hostLink, &netlink.Addr{IPNet: &net.IPNet{IP: hostIP, Mask: net.CIDRMask(30, 32)}}); err != nil { + t.Fatal(err) + } + if err := netlink.LinkSetUp(hostLink); err != nil { + t.Fatal(err) + } + peer, err := netlink.LinkByName(peerName) + if err != nil { + t.Fatal(err) + } + if err := netlink.LinkSetNsFd(peer, int(actorNS)); err != nil { + t.Fatal(err) + } + // Complete the actor end of the point-to-point link inside its own netns. + if err := ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + lo, err := netlink.LinkByName("lo") + if err != nil { + return err + } + if err := netlink.LinkSetUp(lo); err != nil { + return err + } + link, err := netlink.LinkByName(peerName) + if err != nil { + return err + } + if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &net.IPNet{IP: actorIP, Mask: net.CIDRMask(30, 32)}}); err != nil { + return err + } + return netlink.LinkSetUp(link) + }); err != nil { + t.Fatal(err) + } + return actorIP, hostIP +} + +func listenTCP(t *testing.T, hostIP net.IP) net.Listener { + t.Helper() + listener, err := net.ListenTCP("tcp4", &net.TCPAddr{IP: hostIP, Port: 0}) + if err != nil { + t.Fatal(err) + } + return listener +} + +func setupTestIPv6Veth(t *testing.T, actorNS netns.NsHandle) (actorIP, hostIP net.IP) { + t.Helper() + hostName := fmt.Sprintf("atod6%d", os.Getpid()) + peerName := fmt.Sprintf("atop6%d", os.Getpid()) + if err := netlink.LinkAdd(&netlink.Veth{LinkAttrs: netlink.LinkAttrs{Name: hostName}, PeerName: peerName}); err != nil { + if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("needs CAP_NET_ADMIN to create veth: %v", err) + } + t.Fatal(err) + } + t.Cleanup(func() { + if link, err := netlink.LinkByName(hostName); err == nil { + if err := netlink.LinkDel(link); err != nil { + t.Errorf("deleting test IPv6 veth: %v", err) + } + } + }) + hostLink, err := netlink.LinkByName(hostName) + if err != nil { + t.Fatal(err) + } + prefix := uint16(os.Getpid()) + hostIP = net.ParseIP(fmt.Sprintf("fd00:198:18:%x::1", prefix)) + actorIP = net.ParseIP(fmt.Sprintf("fd00:198:18:%x::2", prefix)) + // This isolated veth has no competing IPv6 peers. Suppress DAD so the + // address can be bound immediately instead of remaining tentative while + // the test is trying to start its listener. + if err := netlink.AddrAdd(hostLink, &netlink.Addr{IPNet: &net.IPNet{IP: hostIP, Mask: net.CIDRMask(64, 128)}, Flags: unix.IFA_F_NODAD}); err != nil { + t.Fatal(err) + } + if err := netlink.LinkSetUp(hostLink); err != nil { + t.Fatal(err) + } + peer, err := netlink.LinkByName(peerName) + if err != nil { + t.Fatal(err) + } + if err := netlink.LinkSetNsFd(peer, int(actorNS)); err != nil { + t.Fatal(err) + } + if err := ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + lo, err := netlink.LinkByName("lo") + if err != nil { + return err + } + if err := netlink.LinkSetUp(lo); err != nil { + return err + } + link, err := netlink.LinkByName(peerName) + if err != nil { + return err + } + if err := netlink.AddrAdd(link, &netlink.Addr{IPNet: &net.IPNet{IP: actorIP, Mask: net.CIDRMask(64, 128)}, Flags: unix.IFA_F_NODAD}); err != nil { + return err + } + return netlink.LinkSetUp(link) + }); err != nil { + t.Fatal(err) + } + return actorIP, hostIP +} + +func listenTCP6(t *testing.T, hostIP net.IP) net.Listener { + t.Helper() + listener, err := net.ListenTCP("tcp6", &net.TCPAddr{IP: hostIP, Port: 0}) + if err != nil { + t.Fatal(err) + } + return listener +} + +func installOriginalDstRedirect(t *testing.T, table *nftables.Table, actorIP net.IP, targetPort, redirectPort int) { + t.Helper() + c := &nftables.Conn{} + c.AddTable(table) + chain := c.AddChain(&nftables.Chain{ + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }) + c.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}}, + // Restrict the rule to this test's actor so the temporary table cannot + // affect unrelated local TCP traffic. + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: actorIP.To4()}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(uint16(targetPort))}, + &expr.Immediate{Register: 1, Data: binaryutil.BigEndian.PutUint16(uint16(redirectPort))}, + &expr.Redir{RegisterProtoMin: 1}, + }, + }) + if err := c.Flush(); err != nil { + if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("needs CAP_NET_ADMIN to install nftables rule: %v", err) + } + t.Fatalf("installing nftables redirect: %v", err) + } + t.Cleanup(func() { + cleanup := &nftables.Conn{} + cleanup.DelTable(table) + if err := cleanup.Flush(); err != nil { + t.Errorf("removing nftables redirect: %v", err) + } + }) +} + +func installOriginalDstIPv6Redirect(t *testing.T, table *nftables.Table, actorIP net.IP, targetPort, redirectPort int) { + t.Helper() + c := &nftables.Conn{} + c.AddTable(table) + chain := c.AddChain(&nftables.Chain{ + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }) + c.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{unix.IPPROTO_TCP}}, + // An IPv6 source address begins eight bytes into the IPv6 header. + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 8, Len: 16}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: actorIP.To16()}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(uint16(targetPort))}, + &expr.Immediate{Register: 1, Data: binaryutil.BigEndian.PutUint16(uint16(redirectPort))}, + &expr.Redir{RegisterProtoMin: 1}, + }, + }) + if err := c.Flush(); err != nil { + if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { + t.Skipf("needs CAP_NET_ADMIN to install IPv6 nftables rule: %v", err) + } + t.Fatalf("installing IPv6 nftables redirect: %v", err) + } + t.Cleanup(func() { + cleanup := &nftables.Conn{} + cleanup.DelTable(table) + if err := cleanup.Flush(); err != nil { + t.Errorf("removing IPv6 nftables redirect: %v", err) + } + }) +} From 89267440f069000525c609d7e46d644ae0cab0bb Mon Sep 17 00:00:00 2001 From: lubingtan Date: Fri, 21 Aug 2026 11:46:21 +0800 Subject: [PATCH 2/8] atunnel: preserve IPv4 original destination errors --- internal/atunnel/original_dst_linux.go | 16 +++++-- internal/atunnel/original_dst_linux_test.go | 47 +++++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index 8b4309119d..b9d1717479 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -42,15 +42,23 @@ func TCPOriginalDestination(conn net.Conn) (string, error) { if err != nil { return "", fmt.Errorf("atunnel: acquiring TCP syscall connection: %w", err) } + // The IPv6 option is only meaningful on an AF_INET6 socket: on AF_INET the + // kernel returns EOPNOTSUPP, which would mask the real IPv4 error. A + // v4-mapped local address still means an IPv4 flow, so To4 is the test. + local, ok := tcpConn.LocalAddr().(*net.TCPAddr) + if !ok { + return "", fmt.Errorf("atunnel: original destination requires a TCP local address, got %T", tcpConn.LocalAddr()) + } + isIPv6 := local.IP.To4() == nil var sockoptErr error var destination string if err := rawConn.Control(func(fd uintptr) { destination, sockoptErr = originalIPv4Destination(fd) - // Linux returns ENOENT when the IPv4 original-destination option is - // queried on a redirected IPv6 connection. Only then try the IPv6 - // equivalent, so unrelated IPv4 failures retain their original error. - if errors.Is(sockoptErr, unix.ENOENT) { + // A pure-IPv6 socket leaves the inet addresses zeroed, so the IPv4 + // conntrack lookup always misses with ENOENT. That is the redirected + // IPv6 connection, and the only case worth retrying. + if isIPv6 && errors.Is(sockoptErr, unix.ENOENT) { destination, sockoptErr = originalIPv6Destination(fd) } }); err != nil { diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go index 4f26d98055..ef647c331b 100644 --- a/internal/atunnel/original_dst_linux_test.go +++ b/internal/atunnel/original_dst_linux_test.go @@ -37,6 +37,53 @@ import ( "github.com/agent-substrate/substrate/internal/roottest" ) +// TestTCPOriginalDestinationPreservesErrno covers the failure path on an +// ordinary connection that no REDIRECT rule touched. The IPv4 lookup misses +// and reports ENOENT; that error must reach the caller. Retrying the IPv6 +// option on an AF_INET socket would replace it with EOPNOTSUPP, which says +// nothing about why the lookup failed. +// +// It runs in a fresh namespace because conntrack tracks loopback in any +// namespace that has nftables rules — including the one Docker runs in — and a +// tracked connection returns its real destination instead of missing. +func TestTCPOriginalDestinationPreservesErrno(t *testing.T) { + roottest.Require(t, "CAP_SYS_ADMIN for a network namespace with no conntrack hooks") + + ns := newTestNetNS(t) + if err := ateomnet.NetNSDo(context.Background(), ns, func(context.Context) error { + loopback, err := netlink.LinkByName("lo") + if err != nil { + return err + } + if err := netlink.LinkSetUp(loopback); err != nil { + return err + } + + listener, err := net.Listen("tcp4", "127.0.0.1:0") + if err != nil { + return err + } + defer listener.Close() + client, err := net.DialTimeout("tcp4", listener.Addr().String(), time.Second) + if err != nil { + return err + } + defer client.Close() + server, err := listener.Accept() + if err != nil { + return err + } + defer server.Close() + + if _, err := TCPOriginalDestination(server); !errors.Is(err, unix.ENOENT) { + return fmt.Errorf("want the IPv4 lookup's ENOENT, got %w", err) + } + return nil + }); err != nil { + t.Fatal(err) + } +} + func TestTCPOriginalDestination(t *testing.T) { roottest.Require(t, "CAP_NET_ADMIN + CAP_SYS_ADMIN for an actor-like network namespace and nftables REDIRECT rule") From 0ad466012cf4c738dd53f5f78c7994b4f9011c30 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Fri, 21 Aug 2026 11:50:58 +0800 Subject: [PATCH 3/8] atunnel: stabilize original destination tests --- internal/atunnel/original_dst_linux_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go index ef647c331b..3a9dbfca2a 100644 --- a/internal/atunnel/original_dst_linux_test.go +++ b/internal/atunnel/original_dst_linux_test.go @@ -114,7 +114,7 @@ func TestTCPOriginalDestination(t *testing.T) { // hostIP:targetPort. The worker's PREROUTING rule redirects it before // it reaches the host network stack's local delivery path. clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { - conn, err := net.DialTimeout("tcp4", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), time.Second) + conn, err := net.DialTimeout("tcp4", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) if err == nil { _ = conn.Close() } @@ -122,7 +122,7 @@ func TestTCPOriginalDestination(t *testing.T) { }) }() - if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second)); err != nil { + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { t.Fatal(err) } redirected, err := redirectListener.Accept() @@ -164,7 +164,7 @@ func TestTCPOriginalDestinationIPv6(t *testing.T) { clientDone := make(chan error, 1) go func() { clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { - conn, err := net.DialTimeout("tcp6", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), time.Second) + conn, err := net.DialTimeout("tcp6", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) if err == nil { _ = conn.Close() } @@ -172,7 +172,7 @@ func TestTCPOriginalDestinationIPv6(t *testing.T) { }) }() - if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(time.Second)); err != nil { + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { t.Fatal(err) } redirected, err := redirectListener.Accept() From 2f2da367aa9ebc21ad601aa6ac08d7eb2416ddbe Mon Sep 17 00:00:00 2001 From: lubingtan Date: Mon, 24 Aug 2026 12:58:45 +0800 Subject: [PATCH 4/8] atunnel: select original destination by family --- internal/atunnel/original_dst_linux.go | 9 +-- internal/atunnel/original_dst_linux_test.go | 87 +++++++++++++-------- 2 files changed, 56 insertions(+), 40 deletions(-) diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index b9d1717479..09e2e23140 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -18,7 +18,6 @@ package atunnel import ( "encoding/binary" - "errors" "fmt" "net" "strconv" @@ -54,13 +53,11 @@ func TCPOriginalDestination(conn net.Conn) (string, error) { var sockoptErr error var destination string if err := rawConn.Control(func(fd uintptr) { - destination, sockoptErr = originalIPv4Destination(fd) - // A pure-IPv6 socket leaves the inet addresses zeroed, so the IPv4 - // conntrack lookup always misses with ENOENT. That is the redirected - // IPv6 connection, and the only case worth retrying. - if isIPv6 && errors.Is(sockoptErr, unix.ENOENT) { + if isIPv6 { destination, sockoptErr = originalIPv6Destination(fd) + return } + destination, sockoptErr = originalIPv4Destination(fd) }); err != nil { return "", fmt.Errorf("atunnel: accessing TCP socket: %w", err) } diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go index 3a9dbfca2a..c409ace4c4 100644 --- a/internal/atunnel/original_dst_linux_test.go +++ b/internal/atunnel/original_dst_linux_test.go @@ -38,10 +38,10 @@ import ( ) // TestTCPOriginalDestinationPreservesErrno covers the failure path on an -// ordinary connection that no REDIRECT rule touched. The IPv4 lookup misses -// and reports ENOENT; that error must reach the caller. Retrying the IPv6 -// option on an AF_INET socket would replace it with EOPNOTSUPP, which says -// nothing about why the lookup failed. +// ordinary connection that no REDIRECT rule touched. Each IPv4 lookup misses +// and reports ENOENT; that error must reach the caller. A dual-stack listener +// receives the IPv4 connection with a v4-mapped local address, so it must also +// select the IPv4 option. // // It runs in a fresh namespace because conntrack tracks loopback in any // namespace that has nftables rules — including the one Docker runs in — and a @@ -49,38 +49,57 @@ import ( func TestTCPOriginalDestinationPreservesErrno(t *testing.T) { roottest.Require(t, "CAP_SYS_ADMIN for a network namespace with no conntrack hooks") - ns := newTestNetNS(t) - if err := ateomnet.NetNSDo(context.Background(), ns, func(context.Context) error { - loopback, err := netlink.LinkByName("lo") - if err != nil { - return err - } - if err := netlink.LinkSetUp(loopback); err != nil { - return err - } + for _, test := range []struct { + name string + listenNetwork string + listenAddress string + }{ + {name: "IPv4", listenNetwork: "tcp4", listenAddress: "127.0.0.1:0"}, + // An unspecified "tcp" listener is a dual-stack AF_INET6 socket on + // Linux. A tcp4 client reaches it through a v4-mapped local address, + // so TCPOriginalDestination must select the IPv4 socket option via + // local.IP.To4(), rather than the listener's socket domain. + {name: "dual-stack v4-mapped", listenNetwork: "tcp", listenAddress: ":0"}, + } { + t.Run(test.name, func(t *testing.T) { + ns := newTestNetNS(t) + if err := ateomnet.NetNSDo(context.Background(), ns, func(context.Context) error { + loopback, err := netlink.LinkByName("lo") + if err != nil { + return err + } + if err := netlink.LinkSetUp(loopback); err != nil { + return err + } - listener, err := net.Listen("tcp4", "127.0.0.1:0") - if err != nil { - return err - } - defer listener.Close() - client, err := net.DialTimeout("tcp4", listener.Addr().String(), time.Second) - if err != nil { - return err - } - defer client.Close() - server, err := listener.Accept() - if err != nil { - return err - } - defer server.Close() + listener, err := net.Listen(test.listenNetwork, test.listenAddress) + if err != nil { + return err + } + defer listener.Close() + _, port, err := net.SplitHostPort(listener.Addr().String()) + if err != nil { + return err + } + client, err := net.DialTimeout("tcp4", net.JoinHostPort("127.0.0.1", port), time.Second) + if err != nil { + return err + } + defer client.Close() + server, err := listener.Accept() + if err != nil { + return err + } + defer server.Close() - if _, err := TCPOriginalDestination(server); !errors.Is(err, unix.ENOENT) { - return fmt.Errorf("want the IPv4 lookup's ENOENT, got %w", err) - } - return nil - }); err != nil { - t.Fatal(err) + if _, err := TCPOriginalDestination(server); !errors.Is(err, unix.ENOENT) { + return fmt.Errorf("want the IPv4 lookup's ENOENT, got %w", err) + } + return nil + }); err != nil { + t.Fatal(err) + } + }) } } From 8a9b380832874dc618df6666193f5c6b5b8ee535 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Mon, 24 Aug 2026 13:05:54 +0800 Subject: [PATCH 5/8] atunnel: identify original destination family in errors --- internal/atunnel/original_dst_linux.go | 6 +++++- internal/atunnel/original_dst_linux_test.go | 8 ++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index 09e2e23140..5021269c75 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -62,7 +62,11 @@ func TCPOriginalDestination(conn net.Conn) (string, error) { return "", fmt.Errorf("atunnel: accessing TCP socket: %w", err) } if sockoptErr != nil { - return "", fmt.Errorf("atunnel: reading original TCP destination: %w", sockoptErr) + family := "IPv4" + if isIPv6 { + family = "IPv6" + } + return "", fmt.Errorf("atunnel: reading original %s TCP destination: %w", family, sockoptErr) } return destination, nil } diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go index c409ace4c4..ccd97fc8df 100644 --- a/internal/atunnel/original_dst_linux_test.go +++ b/internal/atunnel/original_dst_linux_test.go @@ -92,8 +92,12 @@ func TestTCPOriginalDestinationPreservesErrno(t *testing.T) { } defer server.Close() - if _, err := TCPOriginalDestination(server); !errors.Is(err, unix.ENOENT) { - return fmt.Errorf("want the IPv4 lookup's ENOENT, got %w", err) + _, lookupErr := TCPOriginalDestination(server) + if !errors.Is(lookupErr, unix.ENOENT) { + return fmt.Errorf("want the IPv4 lookup's ENOENT, got %v", lookupErr) + } + if !strings.Contains(lookupErr.Error(), "original IPv4 TCP destination") { + return fmt.Errorf("want the error to name the IPv4 lookup, got %v", lookupErr) } return nil }); err != nil { From a2a1adca0050b853a7af88f3e0c9e8dc317775c9 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Mon, 24 Aug 2026 13:35:13 +0800 Subject: [PATCH 6/8] atunnel: isolate original destination tests Run the worker side of the IPv4 and IPv6 redirect tests in a private network namespace. This keeps the veth, listeners, and nftables PREROUTING rule outside host INPUT policies such as ufw or firewalld default-deny rules. The private namespace owns the nftables tables, so its teardown also releases the tables, chains, and veths. Remove the explicit cleanup handlers, which otherwise run after returning to the host namespace and fail to find the private tables. --- internal/atunnel/original_dst_linux_test.go | 209 ++++++++++---------- 1 file changed, 108 insertions(+), 101 deletions(-) diff --git a/internal/atunnel/original_dst_linux_test.go b/internal/atunnel/original_dst_linux_test.go index ccd97fc8df..44d53a6cdf 100644 --- a/internal/atunnel/original_dst_linux_test.go +++ b/internal/atunnel/original_dst_linux_test.go @@ -23,6 +23,7 @@ import ( "net" "os" "strings" + "sync/atomic" "testing" "time" @@ -37,6 +38,8 @@ import ( "github.com/agent-substrate/substrate/internal/roottest" ) +var testNetNSSequence uint64 + // TestTCPOriginalDestinationPreservesErrno covers the failure path on an // ordinary connection that no REDIRECT rule touched. Each IPv4 lookup misses // and reports ENOENT; that error must reach the caller. A dual-stack listener @@ -115,112 +118,130 @@ func TestTCPOriginalDestination(t *testing.T) { // veth and is redirected in PREROUTING; that is the path on which Linux // preserves SO_ORIGINAL_DST for atunnel. actorNS := newTestNetNS(t) - actorIP, hostIP := setupTestVeth(t, actorNS) - // targetListener reserves the port the actor intends to reach. The NAT rule - // below must prevent connections from reaching it. - // - // redirectListener represents atunnel's local egress listener. It receives - // the redirected connection and is therefore the connection on which we ask - // Linux for the original destination. - redirectListener := listenTCP(t, hostIP) - defer redirectListener.Close() - targetListener := listenTCP(t, hostIP) - defer targetListener.Close() - targetPort := targetListener.Addr().(*net.TCPAddr).Port + withTestWorkerNS(t, func() { + actorIP, hostIP := setupTestVeth(t, actorNS) + // targetListener reserves the port the actor intends to reach. The NAT rule + // below must prevent connections from reaching it. + // + // redirectListener represents atunnel's local egress listener. It receives + // the redirected connection and is therefore the connection on which we ask + // Linux for the original destination. + redirectListener := listenTCP(t, hostIP) + defer redirectListener.Close() + targetListener := listenTCP(t, hostIP) + defer targetListener.Close() + targetPort := targetListener.Addr().(*net.TCPAddr).Port - table := &nftables.Table{Family: nftables.TableFamilyIPv4, Name: fmt.Sprintf("atunnel_original_dst_test_%d", os.Getpid())} - installOriginalDstRedirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) + table := &nftables.Table{Family: nftables.TableFamilyIPv4, Name: fmt.Sprintf("atunnel_original_dst_test_%d", os.Getpid())} + installOriginalDstRedirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) - clientDone := make(chan error, 1) - go func() { - // From the actor's perspective this is an ordinary connection to - // hostIP:targetPort. The worker's PREROUTING rule redirects it before - // it reaches the host network stack's local delivery path. - clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { - conn, err := net.DialTimeout("tcp4", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) - if err == nil { - _ = conn.Close() - } - return err - }) - }() + clientDone := make(chan error, 1) + go func() { + // From the actor's perspective this is an ordinary connection to + // hostIP:targetPort. The worker's PREROUTING rule redirects it before + // it reaches the host network stack's local delivery path. + clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + conn, err := net.DialTimeout("tcp4", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) + if err == nil { + _ = conn.Close() + } + return err + }) + }() - if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { - t.Fatal(err) - } - redirected, err := redirectListener.Accept() - if err != nil { - t.Fatalf("accepting redirected connection: %v", err) - } - defer redirected.Close() + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatal(err) + } + redirected, err := redirectListener.Accept() + if err != nil { + t.Fatalf("accepting redirected connection: %v", err) + } + defer redirected.Close() - // The accepted socket is addressed to redirectListener, but the kernel's - // SO_ORIGINAL_DST record must still contain the destination chosen by the - // actor before nftables rewrote it. - got, err := TCPOriginalDestination(redirected) - if err != nil { - t.Fatalf("TCPOriginalDestination: %v", err) - } - want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) - if got != want { - t.Errorf("original destination = %q, want %q", got, want) - } - if err := <-clientDone; err != nil { - t.Fatalf("dialing redirected connection: %v", err) - } + // The accepted socket is addressed to redirectListener, but the kernel's + // SO_ORIGINAL_DST record must still contain the destination chosen by the + // actor before nftables rewrote it. + got, err := TCPOriginalDestination(redirected) + if err != nil { + t.Fatalf("TCPOriginalDestination: %v", err) + } + want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) + if got != want { + t.Errorf("original destination = %q, want %q", got, want) + } + if err := <-clientDone; err != nil { + t.Fatalf("dialing redirected connection: %v", err) + } + }) } func TestTCPOriginalDestinationIPv6(t *testing.T) { roottest.Require(t, "CAP_NET_ADMIN + CAP_SYS_ADMIN for an actor-like network namespace and nftables REDIRECT rule") actorNS := newTestNetNS(t) - actorIP, hostIP := setupTestIPv6Veth(t, actorNS) - redirectListener := listenTCP6(t, hostIP) - defer redirectListener.Close() - targetListener := listenTCP6(t, hostIP) - defer targetListener.Close() - targetPort := targetListener.Addr().(*net.TCPAddr).Port + withTestWorkerNS(t, func() { + actorIP, hostIP := setupTestIPv6Veth(t, actorNS) + redirectListener := listenTCP6(t, hostIP) + defer redirectListener.Close() + targetListener := listenTCP6(t, hostIP) + defer targetListener.Close() + targetPort := targetListener.Addr().(*net.TCPAddr).Port - table := &nftables.Table{Family: nftables.TableFamilyIPv6, Name: fmt.Sprintf("atunnel_original_dst_ipv6_test_%d", os.Getpid())} - installOriginalDstIPv6Redirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) + table := &nftables.Table{Family: nftables.TableFamilyIPv6, Name: fmt.Sprintf("atunnel_original_dst_ipv6_test_%d", os.Getpid())} + installOriginalDstIPv6Redirect(t, table, actorIP, targetPort, redirectListener.Addr().(*net.TCPAddr).Port) - clientDone := make(chan error, 1) - go func() { - clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { - conn, err := net.DialTimeout("tcp6", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) - if err == nil { - _ = conn.Close() - } - return err - }) - }() + clientDone := make(chan error, 1) + go func() { + clientDone <- ateomnet.NetNSDo(context.Background(), actorNS, func(context.Context) error { + conn, err := net.DialTimeout("tcp6", net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)), 10*time.Second) + if err == nil { + _ = conn.Close() + } + return err + }) + }() - if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { - t.Fatal(err) - } - redirected, err := redirectListener.Accept() - if err != nil { - t.Fatalf("accepting redirected IPv6 connection: %v", err) - } - defer redirected.Close() + if err := redirectListener.(*net.TCPListener).SetDeadline(time.Now().Add(10 * time.Second)); err != nil { + t.Fatal(err) + } + redirected, err := redirectListener.Accept() + if err != nil { + t.Fatalf("accepting redirected IPv6 connection: %v", err) + } + defer redirected.Close() - // This assertion captures the IPv6 behavior required by #686. - got, err := TCPOriginalDestination(redirected) - if err != nil { - t.Fatalf("TCPOriginalDestination: %v", err) - } - want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) - if got != want { - t.Errorf("original IPv6 destination = %q, want %q", got, want) - } - if err := <-clientDone; err != nil { - t.Fatalf("dialing redirected IPv6 connection: %v", err) + // This assertion captures the IPv6 behavior required by #686. + got, err := TCPOriginalDestination(redirected) + if err != nil { + t.Fatalf("TCPOriginalDestination: %v", err) + } + want := net.JoinHostPort(hostIP.String(), fmt.Sprint(targetPort)) + if got != want { + t.Errorf("original IPv6 destination = %q, want %q", got, want) + } + if err := <-clientDone; err != nil { + t.Fatalf("dialing redirected IPv6 connection: %v", err) + } + }) +} + +// withTestWorkerNS runs the worker half of the test in a private namespace. +// The worker's listeners and nftables PREROUTING rule then cannot be affected +// by default-deny INPUT rules in the host namespace. +func withTestWorkerNS(t *testing.T, fn func()) { + t.Helper() + workerNS := newTestNetNS(t) + if err := ateomnet.NetNSDo(context.Background(), workerNS, func(context.Context) error { + fn() + return nil + }); err != nil { + t.Fatal(err) } } func newTestNetNS(t *testing.T) netns.NsHandle { t.Helper() - name := fmt.Sprintf("atunnel-original-dst-%d", os.Getpid()) + name := fmt.Sprintf("atunnel-original-dst-%d-%d", os.Getpid(), atomic.AddUint64(&testNetNSSequence, 1)) ns, err := ateomnet.CreateNetNSWithoutSwitching(name) if err != nil { if errors.Is(err, unix.EPERM) || strings.Contains(err.Error(), "operation not permitted") { @@ -414,13 +435,6 @@ func installOriginalDstRedirect(t *testing.T, table *nftables.Table, actorIP net } t.Fatalf("installing nftables redirect: %v", err) } - t.Cleanup(func() { - cleanup := &nftables.Conn{} - cleanup.DelTable(table) - if err := cleanup.Flush(); err != nil { - t.Errorf("removing nftables redirect: %v", err) - } - }) } func installOriginalDstIPv6Redirect(t *testing.T, table *nftables.Table, actorIP net.IP, targetPort, redirectPort int) { @@ -455,11 +469,4 @@ func installOriginalDstIPv6Redirect(t *testing.T, table *nftables.Table, actorIP } t.Fatalf("installing IPv6 nftables redirect: %v", err) } - t.Cleanup(func() { - cleanup := &nftables.Conn{} - cleanup.DelTable(table) - if err := cleanup.Flush(); err != nil { - t.Errorf("removing IPv6 nftables redirect: %v", err) - } - }) } From 462d8d145106dfbcffc37ce9a3be1a8f4f8024b8 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Mon, 24 Aug 2026 13:40:23 +0800 Subject: [PATCH 7/8] atunnel: test original destination formatting --- .../atunnel/original_dst_format_linux_test.go | 74 +++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 internal/atunnel/original_dst_format_linux_test.go diff --git a/internal/atunnel/original_dst_format_linux_test.go b/internal/atunnel/original_dst_format_linux_test.go new file mode 100644 index 0000000000..069f481df9 --- /dev/null +++ b/internal/atunnel/original_dst_format_linux_test.go @@ -0,0 +1,74 @@ +//go:build linux + +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package atunnel + +import ( + "encoding/binary" + "net" + "testing" +) + +// networkOrderPort produces the raw field value the kernel leaves in +// RawSockaddrInet4.Port and RawSockaddrInet6.Port: a uint16 whose in-memory +// bytes are the port in network order, which on a little-endian host is not +// the port's numeric value. +func networkOrderPort(port uint16) uint16 { + return binary.NativeEndian.Uint16(binary.BigEndian.AppendUint16(nil, port)) +} + +func TestFormatOriginalDestination(t *testing.T) { + tests := []struct { + name string + ip []byte + port uint16 + want string + wantErr bool + }{ + {name: "IPv4", ip: []byte{198, 18, 0, 1}, port: 443, want: "198.18.0.1:443"}, + { + name: "IPv6 is bracketed", + ip: net.ParseIP("fd00:198:18::1").To16(), + port: 443, + want: "[fd00:198:18::1]:443", + }, + { + name: "v4-mapped IPv6 renders as IPv4", + ip: net.ParseIP("::ffff:198.18.0.1").To16(), + port: 8080, + want: "198.18.0.1:8080", + }, + {name: "high port is not sign-extended", ip: []byte{198, 18, 0, 1}, port: 65535, want: "198.18.0.1:65535"}, + {name: "port zero is rejected", ip: []byte{198, 18, 0, 1}, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := formatOriginalDestination(test.ip, networkOrderPort(test.port)) + if test.wantErr { + if err == nil { + t.Fatalf("formatOriginalDestination() = %q, want an error", got) + } + return + } + if err != nil { + t.Fatalf("formatOriginalDestination() error = %v", err) + } + if got != test.want { + t.Errorf("formatOriginalDestination() = %q, want %q", got, test.want) + } + }) + } +} From b97ab7c9222d8bda61df11b50c2243f487c06672 Mon Sep 17 00:00:00 2001 From: lubingtan Date: Mon, 24 Aug 2026 13:44:19 +0800 Subject: [PATCH 8/8] atunnel: document original destination buffer sizes --- internal/atunnel/original_dst_linux.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/atunnel/original_dst_linux.go b/internal/atunnel/original_dst_linux.go index 5021269c75..0c2d44bef9 100644 --- a/internal/atunnel/original_dst_linux.go +++ b/internal/atunnel/original_dst_linux.go @@ -88,6 +88,9 @@ func originalIPv6Destination(fd uintptr) (string, error) { } func getOriginalDestination(fd uintptr, level, option int, addr unsafe.Pointer, addrSize uintptr) unix.Errno { + // getsockopt treats size as both input and output. Callers provide the exact + // size of RawSockaddrInet4 (16 bytes) or RawSockaddrInet6 (28 bytes), which + // the kernel validates before writing the original destination into addr. size := uint32(addrSize) _, _, errno := unix.Syscall6( unix.SYS_GETSOCKOPT,