git: 3ae9a65392aa - main - net/sing-box: Implement tun functionality

From: Yuri Victorovich <yuri_at_FreeBSD.org>
Date: Mon, 04 May 2026 18:24:53 UTC
The branch main has been updated by yuri:

URL: https://cgit.FreeBSD.org/ports/commit/?id=3ae9a65392aa67466258b115ae7580409038fa93

commit 3ae9a65392aa67466258b115ae7580409038fa93
Author:     Yuri Victorovich <yuri@FreeBSD.org>
AuthorDate: 2026-05-04 18:17:48 +0000
Commit:     Yuri Victorovich <yuri@FreeBSD.org>
CommitDate: 2026-05-04 18:24:49 +0000

    net/sing-box: Implement tun functionality
    
    sing-tun module is patched with FreeBSD support.
    These changes will be pushed into the upstream PR.
---
 net/sing-box/Makefile                              |   2 +-
 ...ithub.com_sagernet_sing-tun_monitor__freebsd.go | 224 +++++++++++
 ..._github.com_sagernet_sing-tun_monitor__other.go |  10 +
 ...github.com_sagernet_sing-tun_monitor__shared.go |  11 +
 ...or_github.com_sagernet_sing-tun_tun__freebsd.go | 435 +++++++++++++++++++++
 ...b.com_sagernet_sing-tun_tun__freebsd__gvisor.go | 116 ++++++
 ..._github.com_sagernet_sing-tun_tun__nondarwin.go |  12 +
 ...ndor_github.com_sagernet_sing-tun_tun__other.go |  10 +
 net/sing-box/files/sing-box-tun-test.json          |  24 ++
 9 files changed, 843 insertions(+), 1 deletion(-)

diff --git a/net/sing-box/Makefile b/net/sing-box/Makefile
index aa64650053b2..bd4ebed9d411 100644
--- a/net/sing-box/Makefile
+++ b/net/sing-box/Makefile
@@ -1,7 +1,7 @@
 PORTNAME=	sing-box
 DISTVERSIONPREFIX=	v
 DISTVERSION=	1.13.11
-PORTREVISION=	1
+PORTREVISION=	2
 CATEGORIES=	net
 
 MAINTAINER=	yuri@FreeBSD.org
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__freebsd.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__freebsd.go
new file mode 100644
index 000000000000..c00625b5338f
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__freebsd.go
@@ -0,0 +1,224 @@
+-- Add FreeBSD network change monitor for the sing-tun library.
+-- Both Darwin and FreeBSD use AF_ROUTE sockets to receive routing change
+-- events, so this is a straightforward adaptation of monitor_darwin.go.
+--- vendor/github.com/sagernet/sing-tun/monitor_freebsd.go.orig	2026-05-04 16:34:14 UTC
++++ vendor/github.com/sagernet/sing-tun/monitor_freebsd.go
+@@ -0,0 +1,218 @@
++//go:build freebsd
++
++package tun
++
++import (
++"net"
++"net/netip"
++"os"
++"sync"
++"time"
++
++"github.com/sagernet/sing/common/buf"
++"github.com/sagernet/sing/common/control"
++E "github.com/sagernet/sing/common/exceptions"
++"github.com/sagernet/sing/common/logger"
++"github.com/sagernet/sing/common/x/list"
++
++"golang.org/x/net/route"
++"golang.org/x/sys/unix"
++)
++
++type networkUpdateMonitor struct {
++access          sync.Mutex
++callbacks       list.List[NetworkUpdateCallback]
++routeSocketFile *os.File
++closeOnce       sync.Once
++done            chan struct{}
++logger          logger.Logger
++}
++
++func NewNetworkUpdateMonitor(logger logger.Logger) (NetworkUpdateMonitor, error) {
++return &networkUpdateMonitor{
++logger: logger,
++done:   make(chan struct{}),
++}, nil
++}
++
++func (m *networkUpdateMonitor) Start() error {
++go m.loopUpdate()
++return nil
++}
++
++func (m *networkUpdateMonitor) loopUpdate() {
++for {
++select {
++case <-m.done:
++return
++default:
++}
++err := m.loopUpdate0()
++if err != nil {
++m.logger.Error("listen network update: ", err)
++return
++}
++}
++}
++
++func (m *networkUpdateMonitor) loopUpdate0() error {
++routeSocket, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0)
++if err != nil {
++return err
++}
++err = unix.SetNonblock(routeSocket, true)
++if err != nil {
++unix.Close(routeSocket)
++return err
++}
++routeSocketFile := os.NewFile(uintptr(routeSocket), "route")
++defer routeSocketFile.Close()
++m.routeSocketFile = routeSocketFile
++m.loopUpdate1(routeSocketFile)
++return nil
++}
++
++func (m *networkUpdateMonitor) loopUpdate1(routeSocketFile *os.File) {
++buffer := buf.NewPacket()
++defer buffer.Release()
++done := make(chan struct{})
++go func() {
++select {
++case <-m.done:
++routeSocketFile.Close()
++case <-done:
++}
++}()
++n, err := routeSocketFile.Read(buffer.FreeBytes())
++close(done)
++if err != nil {
++return
++}
++buffer.Truncate(n)
++messages, err := route.ParseRIB(route.RIBTypeRoute, buffer.Bytes())
++if err != nil {
++return
++}
++for _, message := range messages {
++if _, isRouteMessage := message.(*route.RouteMessage); isRouteMessage {
++m.emit()
++return
++}
++}
++}
++
++func (m *networkUpdateMonitor) Close() error {
++m.closeOnce.Do(func() {
++close(m.done)
++})
++return nil
++}
++
++func (m *defaultInterfaceMonitor) checkUpdate() error {
++ribMessage, err := route.FetchRIB(unix.AF_UNSPEC, route.RIBTypeRoute, 0)
++if err != nil {
++return err
++}
++routeMessages, err := route.ParseRIB(route.RIBTypeRoute, ribMessage)
++if err != nil {
++return err
++}
++var defaultInterface *control.Interface
++for _, rawRouteMessage := range routeMessages {
++routeMessage, ok := rawRouteMessage.(*route.RouteMessage)
++if !ok {
++continue
++}
++if len(routeMessage.Addrs) <= unix.RTAX_NETMASK {
++continue
++}
++destination, isIPv4Destination := routeMessage.Addrs[unix.RTAX_DST].(*route.Inet4Addr)
++if !isIPv4Destination {
++continue
++}
++if destination.IP != netip.IPv4Unspecified().As4() {
++continue
++}
++mask, isIPv4Mask := routeMessage.Addrs[unix.RTAX_NETMASK].(*route.Inet4Addr)
++if !isIPv4Mask {
++continue
++}
++ones, _ := net.IPMask(mask.IP[:]).Size()
++if ones != 0 {
++continue
++}
++if routeMessage.Flags&unix.RTF_UP == 0 {
++continue
++}
++if routeMessage.Flags&unix.RTF_GATEWAY == 0 {
++continue
++}
++routeInterface, err := m.interfaceFinder.ByIndex(routeMessage.Index)
++if err != nil {
++return err
++}
++defaultInterface = routeInterface
++break
++}
++if defaultInterface == nil {
++return ErrNoRoute
++}
++newInterface, err := m.interfaceFinder.ByIndex(defaultInterface.Index)
++if err != nil {
++return E.Cause(err, "find updated interface: ", defaultInterface.Name)
++}
++oldInterface := m.defaultInterface.Swap(newInterface)
++if oldInterface != nil && oldInterface.Equals(*newInterface) {
++return nil
++}
++m.emit(newInterface, 0)
++return nil
++}
++
++// getDefaultInterfaceBySocket detects the default interface by attempting a
++// TCP connect and observing the locally assigned source address.
++func (m *defaultInterfaceMonitor) getDefaultInterfaceBySocket() (*control.Interface, error) {
++socketFd, err := unix.Socket(unix.AF_INET, unix.SOCK_STREAM, 0)
++if err != nil {
++return nil, E.Cause(err, "create file descriptor")
++}
++defer unix.Close(socketFd)
++go unix.Connect(socketFd, &unix.SockaddrInet4{
++Addr: [4]byte{10, 255, 255, 255},
++Port: 80,
++})
++result := make(chan netip.Addr, 1)
++done := make(chan struct{})
++defer close(done)
++go func() {
++for {
++sockname, sockErr := unix.Getsockname(socketFd)
++if sockErr != nil {
++break
++}
++sockaddr, isInet4Sockaddr := sockname.(*unix.SockaddrInet4)
++if !isInet4Sockaddr {
++break
++}
++addr := netip.AddrFrom4(sockaddr.Addr)
++if addr.IsUnspecified() {
++select {
++case <-done:
++return
++default:
++time.Sleep(10 * time.Millisecond)
++continue
++}
++}
++result <- addr
++break
++}
++}()
++var selectedAddr netip.Addr
++select {
++case selectedAddr = <-result:
++case <-time.After(time.Second):
++return nil, nil
++}
++return m.interfaceFinder.ByAddr(selectedAddr)
++}
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__other.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__other.go
new file mode 100644
index 000000000000..c1c61db50a08
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__other.go
@@ -0,0 +1,10 @@
+-- Exclude FreeBSD from the stub monitor that returns os.ErrInvalid.
+-- FreeBSD now has a real monitor implementation in monitor_freebsd.go.
+--- vendor/github.com/sagernet/sing-tun/monitor_other.go.orig	2026-05-04 16:34:14 UTC
++++ vendor/github.com/sagernet/sing-tun/monitor_other.go
+@@ -1,4 +1,4 @@
+-//go:build !(linux || windows || darwin)
++//go:build !(linux || windows || darwin || freebsd)
+ 
+ package tun
+ 
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__shared.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__shared.go
new file mode 100644
index 000000000000..930778e3037d
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_monitor__shared.go
@@ -0,0 +1,11 @@
+-- Include FreeBSD in the shared defaultInterfaceMonitor implementation.
+-- The shared code uses AF_ROUTE sockets and golang.org/x/net/route which
+-- work identically on FreeBSD and Darwin.
+--- vendor/github.com/sagernet/sing-tun/monitor_shared.go.orig	2026-05-04 16:34:14 UTC
++++ vendor/github.com/sagernet/sing-tun/monitor_shared.go
+@@ -1,4 +1,4 @@
+-//go:build linux || windows || darwin
++//go:build linux || windows || darwin || freebsd
+ 
+ package tun
+ 
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd.go
new file mode 100644
index 000000000000..ee4661cc5711
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd.go
@@ -0,0 +1,435 @@
+-- Add FreeBSD TUN interface support to the sing-tun library.
+-- FreeBSD uses /dev/tunN character devices.  TUNSIFHEAD is enabled so each
+-- packet carries a 4-byte AF-family prefix (PacketOffset=4), matching the
+-- Darwin layout.  The interface is auto-created via SIOCIFCREATE2 if it does
+-- not already exist.  IPv4/IPv6 addresses are configured via SIOCAIFADDR and
+-- SIOCAIFADDR_IN6 ioctls; routes are managed through AF_ROUTE sockets.
+--- vendor/github.com/sagernet/sing-tun/tun_freebsd.go.orig	2026-05-04 17:28:35 UTC
++++ vendor/github.com/sagernet/sing-tun/tun_freebsd.go
+@@ -0,0 +1,426 @@
++//go:build freebsd
++
++package tun
++
++import (
++"fmt"
++"net"
++"net/netip"
++"os"
++"syscall"
++"unsafe"
++
++"github.com/sagernet/sing/common"
++E "github.com/sagernet/sing/common/exceptions"
++
++"golang.org/x/net/route"
++"golang.org/x/sys/unix"
++)
++
++const PacketOffset = 4
++
++// FreeBSD-specific ioctl constants (computed from /usr/include/net/if_tun.h
++// and /usr/include/netinet6/in6_var.h).
++const (
++_TUNSIFHEAD      = 0x80047460 // _IOW('t', 96, int) — enable multi-AF header mode
++_SIOCAIFADDR_IN6 = 0x8088691b // _IOW('i', 27, struct in6_aliasreq)
++
++_IN6_IFF_NODAD         = 0x0020
++_IN6_IFF_SECURED       = 0x0400
++_ND6_INFINITE_LIFETIME = 0xFFFFFFFF
++)
++
++// ifreqFlags is used with SIOCGIFFLAGS / SIOCSIFFLAGS.
++type ifreqFlags struct {
++Name  [unix.IFNAMSIZ]byte
++Flags int16
++_     [14]byte
++}
++
++// ifreqMTU is used with SIOCSIFMTU.
++type ifreqMTU struct {
++Name [unix.IFNAMSIZ]byte
++MTU  int32
++_    [12]byte
++}
++
++// ifAliasReq is used with SIOCAIFADDR (IPv4).
++type ifAliasReq struct {
++Name    [unix.IFNAMSIZ]byte
++Addr    unix.RawSockaddrInet4
++Dstaddr unix.RawSockaddrInet4
++Mask    unix.RawSockaddrInet4
++}
++
++// addrLifetime6 mirrors struct in6_addrlifetime from netinet6/in6_var.h.
++type addrLifetime6 struct {
++Expire    int64  // time_t ia6t_expire
++Preferred int64  // time_t ia6t_preferred
++Vltime    uint32 // ia6t_vltime
++Pltime    uint32 // ia6t_pltime
++}
++
++// ifAliasReq6 mirrors struct in6_aliasreq from netinet6/in6_var.h.
++// FreeBSD adds ifra_vhid relative to the Darwin version.
++// sizeof = 136 bytes (verified against the C struct).
++type ifAliasReq6 struct {
++Name     [unix.IFNAMSIZ]byte
++Addr     unix.RawSockaddrInet6
++Dstaddr  unix.RawSockaddrInet6
++Mask     unix.RawSockaddrInet6
++Flags    int32
++Lifetime addrLifetime6
++Vhid     int32
++}
++
++// NativeTun implements Tun for FreeBSD using /dev/tunN character devices.
++// TUNSIFHEAD is enabled so each packet carries a 4-byte AF-family prefix
++// (PacketOffset = 4), matching the Darwin layout.
++type NativeTun struct {
++tunFd               int
++tunFile             *os.File
++options             Options
++routeSet            bool
++iovecsOutputDefault []unix.Iovec
++}
++
++func New(options Options) (Tun, error) {
++if options.Name == "" {
++return nil, E.New("tun name is required")
++}
++var tunIndex int
++if _, err := fmt.Sscanf(options.Name, "tun%d", &tunIndex); err != nil {
++return nil, E.New("bad tun name (expected tunN): ", options.Name)
++}
++
++// Create the interface.  If it already exists from a previous run (EEXIST),
++// destroy it first so we start with a clean slate — no stale addresses,
++// routes, or flags.
++if err := useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(fd int) error {
++var ifr ifreqMTU
++copy(ifr.Name[:], options.Name)
++_, _, errno := unix.Syscall(syscall.SYS_IOCTL, uintptr(fd),
++uintptr(unix.SIOCIFCREATE2), uintptr(unsafe.Pointer(&ifr)))
++if errno == unix.EEXIST {
++// Interface exists: destroy it so we can recreate it cleanly.
++if _, _, errno2 := unix.Syscall(syscall.SYS_IOCTL, uintptr(fd),
++uintptr(unix.SIOCIFDESTROY), uintptr(unsafe.Pointer(&ifr))); errno2 != 0 {
++return os.NewSyscallError("SIOCIFDESTROY", errno2)
++}
++if _, _, errno2 := unix.Syscall(syscall.SYS_IOCTL, uintptr(fd),
++uintptr(unix.SIOCIFCREATE2), uintptr(unsafe.Pointer(&ifr))); errno2 != 0 {
++return os.NewSyscallError("SIOCIFCREATE2", errno2)
++}
++} else if errno != 0 {
++return os.NewSyscallError("SIOCIFCREATE2", errno)
++}
++return nil
++}); err != nil {
++return nil, err
++}
++
++devPath := fmt.Sprintf("/dev/%s", options.Name)
++tunFd, err := unix.Open(devPath, unix.O_RDWR, 0)
++if err != nil {
++return nil, os.NewSyscallError("open "+devPath, err)
++}
++
++// Enable multi-AF header mode: each packet gains a 4-byte network-byte-order
++// AF_family prefix.  Without this, the kernel assumes AF_INET for every write
++// and IPv6 packets would be misdelivered.
++one := 1
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(tunFd),
++uintptr(_TUNSIFHEAD),
++uintptr(unsafe.Pointer(&one)),
++); errno != 0 {
++unix.Close(tunFd)
++return nil, os.NewSyscallError("TUNSIFHEAD", errno)
++}
++
++// Set interface MTU
++if err = useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(fd int) error {
++ifr := ifreqMTU{MTU: int32(options.MTU)}
++copy(ifr.Name[:], options.Name)
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(fd),
++uintptr(unix.SIOCSIFMTU),
++uintptr(unsafe.Pointer(&ifr)),
++); errno != 0 {
++return os.NewSyscallError("SIOCSIFMTU", errno)
++}
++return nil
++}); err != nil {
++unix.Close(tunFd)
++return nil, err
++}
++
++if !options.EXP_ExternalConfiguration {
++for _, address := range options.Inet4Address {
++if err = setInet4Address(options.Name, address); err != nil {
++unix.Close(tunFd)
++return nil, err
++}
++}
++for _, address := range options.Inet6Address {
++if err = setInet6Address(options.Name, address); err != nil {
++unix.Close(tunFd)
++return nil, err
++}
++}
++if err = bringUp(options.Name); err != nil {
++unix.Close(tunFd)
++return nil, err
++}
++}
++
++return &NativeTun{
++tunFd:   tunFd,
++tunFile: os.NewFile(uintptr(tunFd), options.Name),
++options: options,
++}, nil
++}
++
++func setInet4Address(name string, address netip.Prefix) error {
++ifReq := ifAliasReq{
++Addr: unix.RawSockaddrInet4{
++Len:    unix.SizeofSockaddrInet4,
++Family: unix.AF_INET,
++Addr:   address.Addr().As4(),
++},
++Dstaddr: unix.RawSockaddrInet4{
++Len:    unix.SizeofSockaddrInet4,
++Family: unix.AF_INET,
++Addr:   address.Addr().As4(),
++},
++Mask: unix.RawSockaddrInet4{
++Len:    unix.SizeofSockaddrInet4,
++Family: unix.AF_INET,
++Addr:   netip.MustParseAddr(net.IP(net.CIDRMask(address.Bits(), 32)).String()).As4(),
++},
++}
++copy(ifReq.Name[:], name)
++return useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(fd int) error {
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(fd),
++uintptr(unix.SIOCAIFADDR),
++uintptr(unsafe.Pointer(&ifReq)),
++); errno != 0 {
++return os.NewSyscallError("SIOCAIFADDR", errno)
++}
++return nil
++})
++}
++
++func setInet6Address(name string, address netip.Prefix) error {
++ifReq6 := ifAliasReq6{
++Addr: unix.RawSockaddrInet6{
++Len:    unix.SizeofSockaddrInet6,
++Family: unix.AF_INET6,
++Addr:   address.Addr().As16(),
++},
++Mask: unix.RawSockaddrInet6{
++Len:    unix.SizeofSockaddrInet6,
++Family: unix.AF_INET6,
++Addr:   netip.MustParseAddr(net.IP(net.CIDRMask(address.Bits(), 128)).String()).As16(),
++},
++Flags: _IN6_IFF_NODAD | _IN6_IFF_SECURED,
++Lifetime: addrLifetime6{
++Vltime: _ND6_INFINITE_LIFETIME,
++Pltime: _ND6_INFINITE_LIFETIME,
++},
++}
++if address.Bits() == 128 {
++ifReq6.Dstaddr = unix.RawSockaddrInet6{
++Len:    unix.SizeofSockaddrInet6,
++Family: unix.AF_INET6,
++Addr:   address.Addr().Next().As16(),
++}
++}
++copy(ifReq6.Name[:], name)
++return useSocket(unix.AF_INET6, unix.SOCK_DGRAM, 0, func(fd int) error {
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(fd),
++uintptr(_SIOCAIFADDR_IN6),
++uintptr(unsafe.Pointer(&ifReq6)),
++); errno != 0 {
++return os.NewSyscallError("SIOCAIFADDR_IN6", errno)
++}
++return nil
++})
++}
++
++func bringUp(name string) error {
++return useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(fd int) error {
++var ifr ifreqFlags
++copy(ifr.Name[:], name)
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(fd),
++uintptr(unix.SIOCGIFFLAGS),
++uintptr(unsafe.Pointer(&ifr)),
++); errno != 0 {
++return os.NewSyscallError("SIOCGIFFLAGS", errno)
++}
++ifr.Flags |= unix.IFF_UP
++if _, _, errno := unix.Syscall(
++syscall.SYS_IOCTL,
++uintptr(fd),
++uintptr(unix.SIOCSIFFLAGS),
++uintptr(unsafe.Pointer(&ifr)),
++); errno != 0 {
++return os.NewSyscallError("SIOCSIFFLAGS", errno)
++}
++return nil
++})
++}
++
++func (t *NativeTun) Name() (string, error) {
++return t.options.Name, nil
++}
++
++func (t *NativeTun) Start() error {
++if t.options.EXP_ExternalConfiguration {
++return nil
++}
++t.options.InterfaceMonitor.RegisterMyInterface(t.options.Name)
++return t.setRoutes()
++}
++
++func (t *NativeTun) Close() error {
++if t.options.EXP_ExternalConfiguration {
++return t.tunFile.Close()
++}
++err := E.Errors(t.unsetRoutes(), t.tunFile.Close())
++// Destroy the interface so it doesn't leave stale addresses/routes behind.
++destroyErr := useSocket(unix.AF_INET, unix.SOCK_DGRAM, 0, func(fd int) error {
++var ifr ifreqMTU
++copy(ifr.Name[:], t.options.Name)
++_, _, errno := unix.Syscall(syscall.SYS_IOCTL, uintptr(fd),
++uintptr(unix.SIOCIFDESTROY), uintptr(unsafe.Pointer(&ifr)))
++if errno != 0 && errno != unix.ENXIO {
++return os.NewSyscallError("SIOCIFDESTROY", errno)
++}
++return nil
++})
++return E.Errors(err, destroyErr)
++}
++
++func (t *NativeTun) Read(p []byte) (n int, err error) {
++return t.tunFile.Read(p)
++}
++
++func (t *NativeTun) Write(p []byte) (n int, err error) {
++return t.tunFile.Write(p)
++}
++
++func (t *NativeTun) UpdateRouteOptions(tunOptions Options) error {
++t.options = tunOptions
++if t.options.EXP_ExternalConfiguration {
++return nil
++}
++if err := t.unsetRoutes(); err != nil {
++return err
++}
++return t.setRoutes()
++}
++
++func (t *NativeTun) setRoutes() error {
++if t.options.FileDescriptor != 0 {
++return nil
++}
++routeRanges, err := t.options.BuildAutoRouteRanges(false)
++if err != nil {
++return err
++}
++if len(routeRanges) == 0 {
++return nil
++}
++gateway4, gateway6 := t.options.Inet4GatewayAddr(), t.options.Inet6GatewayAddr()
++for _, destination := range routeRanges {
++var gateway netip.Addr
++if destination.Addr().Is4() {
++gateway = gateway4
++} else {
++gateway = gateway6
++}
++err = execRoute(unix.RTM_ADD, destination, gateway)
++if err != nil {
++if err == unix.EEXIST {
++_ = execRoute(unix.RTM_DELETE, destination, gateway)
++err = execRoute(unix.RTM_ADD, destination, gateway)
++}
++if err != nil {
++return E.Cause(err, "add route: ", destination)
++}
++}
++}
++t.routeSet = true
++return nil
++}
++
++func (t *NativeTun) unsetRoutes() error {
++if !t.routeSet {
++return nil
++}
++routeRanges, err := t.options.BuildAutoRouteRanges(false)
++if err != nil {
++return err
++}
++gateway4, gateway6 := t.options.Inet4GatewayAddr(), t.options.Inet6GatewayAddr()
++for _, destination := range routeRanges {
++var gateway netip.Addr
++if destination.Addr().Is4() {
++gateway = gateway4
++} else {
++gateway = gateway6
++}
++_ = execRoute(unix.RTM_DELETE, destination, gateway)
++}
++return nil
++}
++
++func useSocket(domain, typ, proto int, block func(fd int) error) error {
++fd, err := unix.Socket(domain, typ, proto)
++if err != nil {
++return err
++}
++defer unix.Close(fd)
++return block(fd)
++}
++
++func execRoute(rtmType int, destination netip.Prefix, gateway netip.Addr) error {
++msg := route.RouteMessage{
++Type:    rtmType,
++Version: unix.RTM_VERSION,
++Flags:   unix.RTF_STATIC | unix.RTF_GATEWAY,
++Seq:     1,
++}
++if rtmType == unix.RTM_ADD {
++msg.Flags |= unix.RTF_UP
++}
++if gateway.Is4() {
++msg.Addrs = []route.Addr{
++syscall.RTAX_DST:     &route.Inet4Addr{IP: destination.Addr().As4()},
++syscall.RTAX_NETMASK: &route.Inet4Addr{IP: netip.MustParseAddr(net.IP(net.CIDRMask(destination.Bits(), 32)).String()).As4()},
++syscall.RTAX_GATEWAY: &route.Inet4Addr{IP: gateway.As4()},
++}
++} else {
++msg.Addrs = []route.Addr{
++syscall.RTAX_DST:     &route.Inet6Addr{IP: destination.Addr().As16()},
++syscall.RTAX_NETMASK: &route.Inet6Addr{IP: netip.MustParseAddr(net.IP(net.CIDRMask(destination.Bits(), 128)).String()).As16()},
++syscall.RTAX_GATEWAY: &route.Inet6Addr{IP: gateway.As16()},
++}
++}
++request, err := msg.Marshal()
++if err != nil {
++return err
++}
++return useSocket(unix.AF_ROUTE, unix.SOCK_RAW, 0, func(fd int) error {
++return common.Error(unix.Write(fd, request))
++})
++}
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd__gvisor.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd__gvisor.go
new file mode 100644
index 000000000000..4de5c81237e0
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__freebsd__gvisor.go
@@ -0,0 +1,116 @@
+-- Add gVisor (GVisorTun) support to FreeBSD's NativeTun.
+-- Implements WritePacket (writes gVisor PacketBuffer to the TUN fd with the
+-- 4-byte AF-family prefix required by TUNSIFHEAD) and NewEndpoint (returns a
+-- channel.Endpoint with a reader goroutine for the pure gVisor stack).
+-- Required by the "mixed" and "gvisor" network stacks when sing-box is built
+-- with the with_gvisor tag.
+--- vendor/github.com/sagernet/sing-tun/tun_freebsd_gvisor.go.orig	2026-05-04 17:24:50 UTC
++++ vendor/github.com/sagernet/sing-tun/tun_freebsd_gvisor.go
+@@ -0,0 +1,107 @@
++//go:build with_gvisor && freebsd
++
++package tun
++
++import (
++	"encoding/binary"
++	"syscall"
++	"unsafe"
++
++	"github.com/sagernet/gvisor/pkg/buffer"
++	"github.com/sagernet/gvisor/pkg/tcpip"
++	gHdr "github.com/sagernet/gvisor/pkg/tcpip/header"
++	"github.com/sagernet/gvisor/pkg/tcpip/link/channel"
++	"github.com/sagernet/gvisor/pkg/tcpip/stack"
++
++	"golang.org/x/sys/unix"
++)
++
++// packetHeader{4,6} are the 4-byte big-endian AF-family prefixes written before
++// each packet when TUNSIFHEAD mode is active.
++var (
++	packetHeader4    = [4]byte{0, 0, 0, unix.AF_INET}
++	packetHeader6    = [4]byte{0, 0, 0, unix.AF_INET6}
++	packetHeaderVec4 unix.Iovec
++	packetHeaderVec6 unix.Iovec
++)
++
++func init() {
++	packetHeaderVec4 = unix.Iovec{Base: &packetHeader4[0]}
++	packetHeaderVec4.SetLen(4)
++	packetHeaderVec6 = unix.Iovec{Base: &packetHeader6[0]}
++	packetHeaderVec6.SetLen(4)
++}
++
++var _ GVisorTun = (*NativeTun)(nil)
++
++// WritePacket writes a gVisor packet buffer to the TUN device, prepending the
++// 4-byte big-endian AF-family header required by TUNSIFHEAD mode.
++func (t *NativeTun) WritePacket(pkt *stack.PacketBuffer) (int, error) {
++	iovecs := t.iovecsOutputDefault
++	if pkt.NetworkProtocolNumber == gHdr.IPv4ProtocolNumber {
++		iovecs = append(iovecs, packetHeaderVec4)
++	} else {
++		iovecs = append(iovecs, packetHeaderVec6)
++	}
++	var dataLen int
++	for _, s := range pkt.AsSlices() {
++		if len(s) == 0 {
++			continue
++		}
++		dataLen += len(s)
++		iov := unix.Iovec{Base: &s[0]}
++		iov.SetLen(len(s))
++		iovecs = append(iovecs, iov)
++	}
++	if cap(iovecs) > cap(t.iovecsOutputDefault) {
++		t.iovecsOutputDefault = iovecs[:0]
++	}
++	_, _, errno := unix.Syscall(syscall.SYS_WRITEV,
++		uintptr(t.tunFd),
++		uintptr(unsafe.Pointer(&iovecs[0])),
++		uintptr(len(iovecs)))
++	if errno != 0 {
++		return 0, errno
++	}
++	return dataLen, nil
++}
++
++// NewEndpoint creates a gVisor link endpoint backed by the TUN device fd.
++// A goroutine is started to read packets from the TUN fd and inject them into
++// the channel endpoint.  This is used by the pure gVisor stack; the Mixed
++// stack drives packet flow itself via WritePacket / Tun.Read.
++func (t *NativeTun) NewEndpoint() (stack.LinkEndpoint, stack.NICOptions, error) {
++	ep := channel.New(1024, uint32(t.options.MTU), "")
++	go t.gvisorReadLoop(ep)
++	return ep, stack.NICOptions{}, nil
++}
++
++func (t *NativeTun) gvisorReadLoop(ep *channel.Endpoint) {
++	buf := make([]byte, t.options.MTU+PacketOffset)
++	for {
++		n, err := t.tunFile.Read(buf)
++		if err != nil {
++			return
++		}
++		if n < PacketOffset+gHdr.IPv4MinimumSize {
++			continue
++		}
++		af := binary.BigEndian.Uint32(buf[:PacketOffset])
++		rawPkt := make([]byte, n-PacketOffset)
++		copy(rawPkt, buf[PacketOffset:n])
++		var proto tcpip.NetworkProtocolNumber
++		switch uint32(af) {
++		case uint32(unix.AF_INET):
++			proto = gHdr.IPv4ProtocolNumber
++		case uint32(unix.AF_INET6):
++			proto = gHdr.IPv6ProtocolNumber
++		default:
++			continue
++		}
++		pkt := stack.NewPacketBuffer(stack.PacketBufferOptions{
++			Payload: buffer.MakeWithData(rawPkt),
++		})
++		ep.InjectInbound(proto, pkt)
++		pkt.DecRef()
++	}
++}
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__nondarwin.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__nondarwin.go
new file mode 100644
index 000000000000..e9508bea502b
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__nondarwin.go
@@ -0,0 +1,12 @@
+-- Exclude FreeBSD from the !darwin PacketOffset=0 fallback.
+-- FreeBSD uses TUNSIFHEAD multi-AF mode, which requires PacketOffset=4
+-- (same as Darwin).  FreeBSD gets its own PacketOffset constant in
+-- tun_freebsd.go.
+--- vendor/github.com/sagernet/sing-tun/tun_nondarwin.go.orig	2026-05-04 16:16:29 UTC
++++ vendor/github.com/sagernet/sing-tun/tun_nondarwin.go
+@@ -1,4 +1,4 @@
+-//go:build !darwin
++//go:build !darwin && !freebsd
+ 
+ package tun
+ 
diff --git a/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__other.go b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__other.go
new file mode 100644
index 000000000000..4254e059c543
--- /dev/null
+++ b/net/sing-box/files/patch-vendor_github.com_sagernet_sing-tun_tun__other.go
@@ -0,0 +1,10 @@
+-- Exclude FreeBSD from the stub that returns os.ErrInvalid.
+-- FreeBSD now has a real TUN implementation in tun_freebsd.go.
+--- vendor/github.com/sagernet/sing-tun/tun_other.go.orig	2026-05-04 16:34:14 UTC
++++ vendor/github.com/sagernet/sing-tun/tun_other.go
+@@ -1,4 +1,4 @@
+-//go:build !(linux || windows || darwin)
++//go:build !(linux || windows || darwin || freebsd)
+ 
+ package tun
+ 
diff --git a/net/sing-box/files/sing-box-tun-test.json b/net/sing-box/files/sing-box-tun-test.json
new file mode 100644
index 000000000000..4ec78cdfb1b4
--- /dev/null
+++ b/net/sing-box/files/sing-box-tun-test.json
@@ -0,0 +1,24 @@
+{
+  "log": {
+    "level": "info"
+  },
+  "inbounds": [
+    {
+      "type": "tun",
+      "tag": "tun-in",
+      "interface_name": "tun0",
+      "address": [
+        "172.19.0.1/30",
+        "fdfe:dcba:9876::1/126"
+      ],
+      "mtu": 1500,
+      "auto_route": false
+    }
+  ],
+  "outbounds": [
+    {
+      "type": "direct",
+      "tag": "direct"
+    }
+  ]
+}