Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions internal/atunnel/original_dst_format_linux_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//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"
"strings"
"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,
// SplitHostPort in the atunnel client needs the brackets.
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",
},
{
// A zero port means the lookup answered without a real destination,
// which would otherwise become a dial to port 0.
name: "port zero is rejected",
ip: []byte{198, 18, 0, 1},
port: 0,
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)
}
})
}
}

func TestTCPOriginalDestinationRejectsNonTCPConn(t *testing.T) {
client, server := net.Pipe()
t.Cleanup(func() { _ = client.Close() })
t.Cleanup(func() { _ = server.Close() })

got, err := TCPOriginalDestination(client)
if err == nil {
t.Fatalf("TCPOriginalDestination() = %q, want an error on a non-TCP connection", got)
}
if !strings.Contains(err.Error(), "requires a TCP connection") {
t.Errorf("TCPOriginalDestination() error = %v, want it to name the unsupported connection type", err)
}
}
82 changes: 62 additions & 20 deletions internal/atunnel/original_dst_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,14 @@ 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 — the same number as
// unix.SO_ORIGINAL_DST by coincidence, not by definition, since the two are
// options of different levels.
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 {
Expand All @@ -39,34 +43,72 @@ func TCPOriginalDestination(conn net.Conn) (string, error) {
if err != nil {
return "", fmt.Errorf("atunnel: acquiring TCP syscall connection: %w", err)
}
// Each family keeps its original destination under its own socket option
// level, and querying the other one answers EOPNOTSUPP rather than anything
// about the flow. A v4-mapped local address still means an IPv4 flow, so To4
// is the test, not the socket domain.
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 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
if isIPv6 {
destination, sockoptErr = originalIPv6Destination(fd)
return
}
destination, sockoptErr = originalIPv4Destination(fd)
}); err != nil {
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
}

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
}
Loading
Loading