Fix(fdbased): support darwin utun fd devices (#549)

This commit is contained in:
LanceLi
2026-08-07 21:50:04 -04:00
committed by GitHub
parent 8dda19e8e4
commit d24a73449e
7 changed files with 296 additions and 4 deletions
+13 -1
View File
@@ -4,6 +4,7 @@ package fdbased
import (
"fmt"
"os"
"strconv"
"golang.org/x/sys/unix"
@@ -18,6 +19,7 @@ type FD struct {
stack.LinkEndpoint
fd int
file *os.File // non-nil on darwin/bsd (iobased path); nil on linux
mtu uint32
closed bool
}
@@ -44,7 +46,17 @@ func (f *FD) Name() string {
func (f *FD) Close() {
if !f.closed {
defer f.LinkEndpoint.Close()
_ = unix.Close(f.fd)
// On darwin/bsd the iobased endpoint wraps the fd in an *os.File
// and parks a goroutine in os.File.Read (runtime netpoller).
// Closing via os.File.Close() triggers runtime_pollUnblock and
// wakes that goroutine so dispatchLoop can exit and the link
// endpoint's Wait() can return. A bare unix.Close(fd) does NOT
// notify the netpoller, leaving the goroutine blocked forever.
if f.file != nil {
_ = f.file.Close()
} else {
_ = unix.Close(f.fd)
}
f.closed = true
}
}
+75
View File
@@ -0,0 +1,75 @@
//go:build unix && !linux
package fdbased
import (
"strconv"
"testing"
"time"
"golang.org/x/sys/unix"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
type testDispatcher struct {
got chan struct{}
}
func (d *testDispatcher) DeliverNetworkPacket(tcpip.NetworkProtocolNumber, *stack.PacketBuffer) {
select {
case d.got <- struct{}{}:
default:
}
}
func (d *testDispatcher) DeliverLinkPacket(tcpip.NetworkProtocolNumber, *stack.PacketBuffer) {}
func TestCloseUnblocksDispatchLoop(t *testing.T) {
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_DGRAM, 0)
if err != nil {
t.Fatalf("Socketpair() error = %v", err)
}
defer unix.Close(fds[1])
// os.NewFile only registers the fd with the runtime netpoller when the
// fd is non-blocking, which is the iobased path this fix targets.
if err := unix.SetNonblock(fds[0], true); err != nil {
t.Fatalf("SetNonblock() error = %v", err)
}
dev, err := Open(strconv.Itoa(fds[0]), 1500, 0)
if err != nil {
t.Fatalf("Open() error = %v", err)
}
// Attach a dispatcher and send a probe packet so we know the dispatch
// loop is running and then blocked on the next Read.
dispatcher := &testDispatcher{got: make(chan struct{}, 1)}
dev.Attach(dispatcher)
ipv4 := []byte{0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01}
if _, err := unix.Write(fds[1], ipv4); err != nil {
t.Fatalf("Write() error = %v", err)
}
select {
case <-dispatcher.got:
case <-time.After(5 * time.Second):
t.Fatal("probe packet not dispatched; dispatch loop not running")
}
// Close the device and verify Wait() returns instead of hanging.
done := make(chan struct{})
go func() {
dev.Wait()
close(done)
}()
dev.Close()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("Wait() did not return after Close(); dispatchLoop still blocked")
}
}
+6 -1
View File
@@ -12,11 +12,16 @@ import (
func open(fd int, mtu uint32, offset int) (device.Device, error) {
f := &FD{fd: fd, mtu: mtu}
ep, err := iobased.New(os.NewFile(uintptr(fd), f.Name()), mtu, offset)
file := os.NewFile(uintptr(fd), f.Name())
ep, err := iobased.New(file, mtu, offset)
if err != nil {
_ = file.Close()
return nil, fmt.Errorf("create endpoint: %w", err)
}
f.LinkEndpoint = ep
// Keep the *os.File reference so FD.Close() can close it via the
// runtime netpoller path and unblock the iobased dispatchLoop.
f.file = file
return f, nil
}
+12
View File
@@ -7,6 +7,7 @@ import (
"errors"
"io"
"sync"
"syscall"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
@@ -143,6 +144,17 @@ func (e *Endpoint) writePacket(pkt *stack.PacketBuffer) tcpip.Error {
defer buf.Release()
if e.offset != 0 {
v := buffer.NewViewWithData(make([]byte, e.offset))
// On macOS/iOS, TUN devices require a 4-byte header with the
// address family in byte 3 (AF_INET=2, AF_INET6=30).
// Without this, the kernel drops the outbound packet.
if e.offset >= 4 {
switch pkt.NetworkProtocolNumber {
case header.IPv4ProtocolNumber:
v.AsSlice()[3] = syscall.AF_INET
case header.IPv6ProtocolNumber:
v.AsSlice()[3] = syscall.AF_INET6
}
}
_ = buf.Prepend(v)
}
+103
View File
@@ -0,0 +1,103 @@
//go:build unix
package iobased
import (
"bytes"
"os"
"testing"
"time"
"golang.org/x/sys/unix"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
type readTestDispatcher struct {
proto tcpip.NetworkProtocolNumber
data []byte
got chan struct{}
}
func (d *readTestDispatcher) DeliverNetworkPacket(protocol tcpip.NetworkProtocolNumber, pkt *stack.PacketBuffer) {
d.proto = protocol
d.data = pkt.ToView().AsSlice()
select {
case d.got <- struct{}{}:
default:
}
}
func (d *readTestDispatcher) DeliverLinkPacket(tcpip.NetworkProtocolNumber, *stack.PacketBuffer) {}
func newSocketpairEndpoint(t *testing.T, offset int) (*Endpoint, int) {
t.Helper()
fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_DGRAM, 0)
if err != nil {
t.Fatalf("Socketpair() error = %v", err)
}
t.Cleanup(func() { _ = unix.Close(fds[1]) })
ep, err := New(os.NewFile(uintptr(fds[0]), "test"), 1500, offset)
if err != nil {
t.Fatalf("New() error = %v", err)
}
return ep, fds[1]
}
func TestReadStripsOffset(t *testing.T) {
ep, writer := newSocketpairEndpoint(t, 4)
dispatcher := &readTestDispatcher{got: make(chan struct{}, 1)}
ep.Attach(dispatcher)
// A darwin utun datagram is a 4-byte header (byte 3 = address family)
// followed by the IP packet. With the offset applied, the injected
// packet must be the IP packet itself with the header stripped.
ipv4 := []byte{0x45, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01, 0x12, 0x34, 0x56, 0x78}
datagram := append([]byte{0x00, 0x00, 0x00, 0x02}, ipv4...)
if _, err := unix.Write(writer, datagram); err != nil {
t.Fatalf("Write() error = %v", err)
}
select {
case <-dispatcher.got:
case <-time.After(5 * time.Second):
t.Fatal("packet not dispatched; 4-byte TUN header not stripped")
}
if dispatcher.proto != header.IPv4ProtocolNumber {
t.Errorf("protocol = %d, want IPv4", dispatcher.proto)
}
// The 4-byte utun header must be stripped: the injected packet has to
// start with the IP packet itself. (The read path may append up to
// `offset` stale buffer bytes after the packet.)
if !bytes.HasPrefix(dispatcher.data, ipv4) {
t.Errorf("injected packet = %x, want prefix %x (4-byte TUN header must be stripped)", dispatcher.data, ipv4)
}
}
func TestReadNoOffset(t *testing.T) {
ep, writer := newSocketpairEndpoint(t, 0)
dispatcher := &readTestDispatcher{got: make(chan struct{}, 1)}
ep.Attach(dispatcher)
ipv4 := []byte{0x45, 0x00, 0x00, 0x18, 0x00, 0x00, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01, 0x12, 0x34, 0x56, 0x78}
if _, err := unix.Write(writer, ipv4); err != nil {
t.Fatalf("Write() error = %v", err)
}
select {
case <-dispatcher.got:
case <-time.After(5 * time.Second):
t.Fatal("raw IPv4 packet not dispatched")
}
if dispatcher.proto != header.IPv4ProtocolNumber {
t.Errorf("protocol = %d, want IPv4", dispatcher.proto)
}
if !bytes.Equal(dispatcher.data, ipv4) {
t.Errorf("injected packet = %x, want %x", dispatcher.data, ipv4)
}
}
+85
View File
@@ -0,0 +1,85 @@
package iobased
import (
"bytes"
"syscall"
"testing"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip"
"gvisor.dev/gvisor/pkg/tcpip/header"
"gvisor.dev/gvisor/pkg/tcpip/stack"
)
func TestWritePacketDarwinAFHeader(t *testing.T) {
tests := []struct {
name string
proto tcpip.NetworkProtocolNumber
packet []byte
wantAF byte
}{
{
name: "ipv4",
proto: header.IPv4ProtocolNumber,
packet: []byte{0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01},
wantAF: byte(syscall.AF_INET),
},
{
name: "ipv6",
proto: header.IPv6ProtocolNumber,
packet: []byte{0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00},
wantAF: byte(syscall.AF_INET6),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var w bytes.Buffer
ep, err := New(&w, 1500, 4)
if err != nil {
t.Fatalf("New() error = %v", err)
}
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(tt.packet),
})
pkt.NetworkProtocolNumber = tt.proto
if err := ep.writePacket(pkt); err != nil {
t.Fatalf("writePacket() error = %v", err)
}
got := w.Bytes()
if len(got) != 4+len(tt.packet) {
t.Fatalf("written length = %d, want %d", len(got), 4+len(tt.packet))
}
if got[3] != tt.wantAF {
t.Errorf("header byte 3 = %d, want address family %d", got[3], tt.wantAF)
}
if !bytes.Equal(got[4:], tt.packet) {
t.Errorf("packet after header mismatch:\n got %x\nwant %x", got[4:], tt.packet)
}
})
}
}
func TestWritePacketNoHeaderWithoutOffset(t *testing.T) {
packet := []byte{0x45, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x40, 0x11, 0x00, 0x00, 0x7f, 0x00, 0x00, 0x01, 0x7f, 0x00, 0x00, 0x01}
var w bytes.Buffer
ep, err := New(&w, 1500, 0)
if err != nil {
t.Fatalf("New() error = %v", err)
}
pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
Payload: buffer.MakeWithData(packet),
})
pkt.NetworkProtocolNumber = header.IPv4ProtocolNumber
if err := ep.writePacket(pkt); err != nil {
t.Fatalf("writePacket() error = %v", err)
}
if got := w.Bytes(); !bytes.Equal(got, packet) {
t.Errorf("written bytes = %x, want %x", got, packet)
}
}
+2 -2
View File
@@ -65,9 +65,9 @@ func parseDevice(s string, mtu uint32) (device.Device, error) {
func parseFD(u *url.URL, mtu uint32) (device.Device, error) {
offset := 0
// fd offset in ios
// fd offset in darwin and ios
// https://stackoverflow.com/questions/69260852/ios-network-extension-packet-parsing/69487795#69487795
if runtime.GOOS == "ios" {
if runtime.GOOS == "darwin" || runtime.GOOS == "ios" {
offset = 4
}
return fdbased.Open(u.Host, mtu, offset)