git: 3ae9a65392aa - main - net/sing-box: Implement tun functionality
- Go to: [ bottom of page ] [ top of archives ] [ this month ]
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"
+ }
+ ]
+}