Add netns and unshare support

This commit is contained in:
世界
2026-07-11 00:59:03 +08:00
parent cf587cc246
commit 792f2007ac
36 changed files with 894 additions and 52 deletions
+5
View File
@@ -0,0 +1,5 @@
package adapter
type NetworkNamespaceManager interface {
ResolvePath(nameOrPath string) string
}
+10 -2
View File
@@ -17,6 +17,7 @@ import (
"github.com/sagernet/sing-box/common/certificate"
"github.com/sagernet/sing-box/common/dialer"
"github.com/sagernet/sing-box/common/httpclient"
"github.com/sagernet/sing-box/common/netns"
"github.com/sagernet/sing-box/common/taskmonitor"
"github.com/sagernet/sing-box/common/tls"
"github.com/sagernet/sing-box/common/trafficcontrol"
@@ -61,8 +62,9 @@ type Box struct {
type Options struct {
option.Options
Context context.Context
PlatformLogWriter log.PlatformWriter
Context context.Context
PlatformLogWriter log.PlatformWriter
NetworkNamespaceHolderArgs []string
}
func Context(
@@ -194,6 +196,12 @@ func New(options Options) (*Box, error) {
service.MustRegister[adapter.CertificateStore](ctx, certificateStore)
internalServices = append(internalServices, certificateStore)
}
netnsManager, err := netns.NewManager(logFactory.NewLogger("netns"), options.NetworkNamespaces, options.NetworkNamespaceHolderArgs)
if err != nil {
return nil, err
}
service.MustRegister[adapter.NetworkNamespaceManager](ctx, netnsManager)
internalServices = append(internalServices, netnsManager)
dnsOptions := common.PtrValueOrDefault(options.DNS)
endpointManager := endpoint.NewManager(logFactory.NewLogger("endpoint"), endpointRegistry)
inboundManager := inbound.NewManager(logFactory.NewLogger("inbound"), inboundRegistry, endpointManager)
+20
View File
@@ -0,0 +1,20 @@
package main
import (
"github.com/sagernet/sing-box/common/netns"
"github.com/spf13/cobra"
)
var commandNetnsHolder = &cobra.Command{
Use: "netns-holder",
Args: cobra.NoArgs,
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
netns.Hold()
},
}
func init() {
mainCommand.AddCommand(commandNetnsHolder)
}
+31 -11
View File
@@ -104,10 +104,17 @@ func readConfigAndMerge() (option.Options, error) {
if err != nil {
return option.Options{}, err
}
return mergeOptionsList(optionsList)
}
func mergeOptionsList(optionsList []*OptionsEntry) (option.Options, error) {
if len(optionsList) == 1 {
return optionsList[0].options, nil
}
var mergedMessage json.RawMessage
var (
mergedMessage json.RawMessage
err error
)
for _, options := range optionsList {
mergedMessage, err = badjson.MergeJSON(globalCtx, options.options.RawMessage, mergedMessage, false)
if err != nil {
@@ -122,11 +129,7 @@ func readConfigAndMerge() (option.Options, error) {
return mergedOptions, nil
}
func create() (*box.Box, context.CancelFunc, error) {
options, err := readConfigAndMerge()
if err != nil {
return nil, nil, err
}
func create(options option.Options) (*box.Box, context.CancelFunc, error) {
if disableColor {
if options.Log == nil {
options.Log = &option.LogOptions{}
@@ -135,8 +138,9 @@ func create() (*box.Box, context.CancelFunc, error) {
}
ctx, cancel := context.WithCancel(globalCtx)
instance, err := box.New(box.Options{
Context: ctx,
Options: options,
Context: ctx,
Options: options,
NetworkNamespaceHolderArgs: []string{"/proc/self/exe", commandNetnsHolder.Use},
})
if err != nil {
cancel()
@@ -167,13 +171,25 @@ func create() (*box.Box, context.CancelFunc, error) {
}
func run() error {
optionsList, err := readConfig()
if err != nil {
return err
}
options, err := mergeOptionsList(optionsList)
if err != nil {
return err
}
err = runInUserNamespaceIfNeeded(options, optionsList)
if err != nil {
return err
}
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
defer signal.Stop(osSignals)
for {
instance, cancel, err := create()
if err != nil {
return err
instance, cancel, createErr := create(options)
if createErr != nil {
return createErr
}
runtimeDebug.FreeOSMemory()
for {
@@ -198,6 +214,10 @@ func run() error {
}
break
}
options, err = readConfigAndMerge()
if err != nil {
return err
}
}
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"bytes"
"os"
"os/exec"
"os/signal"
"syscall"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
"github.com/sagernet/sing/common"
E "github.com/sagernet/sing/common/exceptions"
"golang.org/x/sys/unix"
)
func runInUserNamespaceIfNeeded(options option.Options, optionsList []*OptionsEntry) error {
if !common.Any(options.NetworkNamespaces, func(namespace option.NetworkNamespace) bool {
return namespace.Type == C.NetNsTypeUnshare
}) {
return nil
}
var header unix.CapUserHeader
header.Version = unix.LINUX_CAPABILITY_VERSION_3
var data [2]unix.CapUserData
err := unix.Capget(&header, &data[0])
if err != nil {
return E.Cause(err, "get capabilities")
}
if data[0].Effective&(1<<unix.CAP_SYS_ADMIN) != 0 {
return nil
}
command := exec.Command("/proc/self/exe", os.Args[1:]...)
command.Args = os.Args
command.Stdin = os.Stdin
for _, entry := range optionsList {
if entry.path == "stdin" {
command.Stdin = bytes.NewReader(entry.content)
}
}
command.Stdout = os.Stdout
command.Stderr = os.Stderr
command.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWUSER,
UidMappings: []syscall.SysProcIDMap{
{ContainerID: os.Geteuid(), HostID: os.Geteuid(), Size: 1},
},
GidMappings: []syscall.SysProcIDMap{
{ContainerID: os.Getegid(), HostID: os.Getegid(), Size: 1},
},
GidMappingsEnableSetgroups: false,
AmbientCaps: []uintptr{unix.CAP_SYS_ADMIN, unix.CAP_NET_ADMIN, unix.CAP_NET_RAW},
Setpgid: true,
Pdeathsig: syscall.SIGKILL,
}
err = command.Start()
if err != nil {
return E.Cause(err, "create user namespace for unshare network namespace (is unprivileged user namespace creation allowed by the kernel?)")
}
signalChannel := make(chan os.Signal, 4)
signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
go func() {
for receivedSignal := range signalChannel {
command.Process.Signal(receivedSignal)
}
}()
err = command.Wait()
exitError, isExitError := E.Cast[*exec.ExitError](err)
if isExitError {
os.Exit(exitError.ExitCode())
}
if err != nil {
return E.Cause(err, "wait user namespace child")
}
os.Exit(0)
return nil
}
+9
View File
@@ -0,0 +1,9 @@
//go:build !linux
package main
import "github.com/sagernet/sing-box/option"
func runInUserNamespaceIfNeeded(options option.Options, optionsList []*OptionsEntry) error {
return nil
}
+2 -2
View File
@@ -250,7 +250,7 @@ func (d *DefaultDialer) DialContext(ctx context.Context, network string, address
return nil, E.New("domain not resolved")
}
if d.networkStrategy == nil {
return d.trackConn(listener.ListenNetworkNamespace[net.Conn](d.netns, func() (net.Conn, error) {
return d.trackConn(listener.ListenNetworkNamespace[net.Conn](ctx, d.netns, func() (net.Conn, error) {
switch N.NetworkName(network) {
case N.NetworkUDP:
if !address.IsIPv6() {
@@ -320,7 +320,7 @@ func (d *DefaultDialer) DialParallelInterface(ctx context.Context, network strin
func (d *DefaultDialer) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
if d.networkStrategy == nil {
return d.trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](d.netns, func() (net.PacketConn, error) {
return d.trackPacketConn(listener.ListenNetworkNamespace[net.PacketConn](ctx, d.netns, func() (net.PacketConn, error) {
listenConfig := d.udpListener
if d.autoDetectBindFunc != nil && destination.Addr.IsValid() {
listenConfig.Control = control.Append(listenConfig.Control, func(network, address string, conn syscall.RawConn) error {
+39 -23
View File
@@ -16,6 +16,7 @@ import (
"github.com/sagernet/sing/common/logger"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/service"
"github.com/vishvananda/netns"
)
@@ -143,30 +144,45 @@ func (l *Listener) ListenOptions() option.ListenOptions {
return l.listenOptions
}
func ListenNetworkNamespace[T any](nameOrPath string, block func() (T, error)) (T, error) {
if nameOrPath != "" {
func ListenNetworkNamespace[T any](ctx context.Context, nameOrPath string, block func() (T, error)) (T, error) {
if nameOrPath == "" {
return block()
}
manager := service.FromContext[adapter.NetworkNamespaceManager](ctx)
if manager != nil {
nameOrPath = manager.ResolvePath(nameOrPath)
}
type blockResult struct {
value T
err error
}
resultChannel := make(chan blockResult, 1)
go func() {
runtime.LockOSThread()
defer runtime.UnlockOSThread()
currentNs, err := netns.Get()
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "get current netns")
}
defer currentNs.Close()
defer netns.Set(currentNs)
var targetNs netns.NsHandle
if strings.HasPrefix(nameOrPath, "/") {
targetNs, err = netns.GetFromPath(nameOrPath)
} else {
targetNs, err = netns.GetFromName(nameOrPath)
}
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "get netns ", nameOrPath)
}
defer targetNs.Close()
err = netns.Set(targetNs)
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "set netns to ", nameOrPath)
}
value, err := listenNetworkNamespaceThread(nameOrPath, block)
resultChannel <- blockResult{value, err}
}()
result := <-resultChannel
return result.value, result.err
}
func listenNetworkNamespaceThread[T any](nameOrPath string, block func() (T, error)) (T, error) {
var (
targetNs netns.NsHandle
err error
)
if strings.HasPrefix(nameOrPath, "/") {
targetNs, err = netns.GetFromPath(nameOrPath)
} else {
targetNs, err = netns.GetFromName(nameOrPath)
}
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "get netns ", nameOrPath)
}
defer targetNs.Close()
err = netns.Set(targetNs)
if err != nil {
return common.DefaultValue[T](), E.Cause(err, "set netns to ", nameOrPath)
}
return block()
}
+1 -1
View File
@@ -65,7 +65,7 @@ func (l *Listener) ListenTCP() (net.Listener, error) {
})
})
}
tcpListener, err := ListenNetworkNamespace[net.Listener](l.listenOptions.NetNs, func() (net.Listener, error) {
tcpListener, err := ListenNetworkNamespace[net.Listener](l.ctx, l.listenOptions.NetNs, func() (net.Listener, error) {
if l.listenOptions.TCPFastOpen {
var tfoConfig tfo.ListenConfig
tfoConfig.ListenConfig = listenConfig
+3 -3
View File
@@ -49,7 +49,7 @@ func (l *Listener) ListenUDP() (net.PacketConn, error) {
})
})
}
udpConn, err := ListenNetworkNamespace[net.PacketConn](l.listenOptions.NetNs, func() (net.PacketConn, error) {
udpConn, err := ListenNetworkNamespace[net.PacketConn](l.ctx, l.listenOptions.NetNs, func() (net.PacketConn, error) {
return listenConfig.ListenPacket(l.ctx, M.NetworkFromNetAddr(N.NetworkUDP, bindAddr.Addr), bindAddr.String())
})
if err != nil {
@@ -62,7 +62,7 @@ func (l *Listener) ListenUDP() (net.PacketConn, error) {
}
func (l *Listener) DialContext(dialer net.Dialer, ctx context.Context, network string, address string) (net.Conn, error) {
return ListenNetworkNamespace[net.Conn](l.listenOptions.NetNs, func() (net.Conn, error) {
return ListenNetworkNamespace[net.Conn](l.ctx, l.listenOptions.NetNs, func() (net.Conn, error) {
if l.listenOptions.BindInterface != "" {
dialer.Control = control.Append(dialer.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1))
}
@@ -77,7 +77,7 @@ func (l *Listener) DialContext(dialer net.Dialer, ctx context.Context, network s
}
func (l *Listener) ListenPacket(listenConfig net.ListenConfig, ctx context.Context, network string, address string) (net.PacketConn, error) {
return ListenNetworkNamespace[net.PacketConn](l.listenOptions.NetNs, func() (net.PacketConn, error) {
return ListenNetworkNamespace[net.PacketConn](l.ctx, l.listenOptions.NetNs, func() (net.PacketConn, error) {
if l.listenOptions.BindInterface != "" {
listenConfig.Control = control.Append(listenConfig.Control, control.BindToInterface(service.FromContext[adapter.NetworkManager](l.ctx).InterfaceFinder(), l.listenOptions.BindInterface, -1))
}
+141
View File
@@ -0,0 +1,141 @@
package netns
import (
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"syscall"
"github.com/sagernet/netlink"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
vnetns "github.com/vishvananda/netns"
)
type holder struct {
command *exec.Cmd
pipeWriter *os.File
pidFile string
}
func (m *Manager) start() error {
for _, namespace := range m.namespaces {
switch namespace.Type {
case C.NetNsTypeDefault:
path := namespace.DefaultOptions.Path
if !strings.HasPrefix(path, "/") {
path = "/run/netns/" + path
}
_, err := os.Stat(path)
if err != nil {
return E.Cause(err, "network namespace[", namespace.Tag, "]")
}
case C.NetNsTypeUnshare:
err := m.startNamespace(namespace)
if err != nil {
return E.Cause(err, "network namespace[", namespace.Tag, "]")
}
}
}
return nil
}
func (m *Manager) startNamespace(namespace option.NetworkNamespace) error {
if len(m.holderArgs) == 0 {
return E.New("unshare network namespace is only supported in `sing-box run`")
}
created, err := m.startHolder(namespace.UnshareOptions.PidFile)
if err != nil {
return err
}
m.holders = append(m.holders, created)
pid := created.command.Process.Pid
if created.pidFile != "" {
err = os.WriteFile(created.pidFile, []byte(strconv.Itoa(pid)+"\n"), 0o644)
if err != nil {
return E.Cause(err, "write pid file")
}
}
m.paths[namespace.Tag] = netnsPath(pid)
m.logger.Info("created network namespace[", namespace.Tag, "], holder pid: ", pid)
if os.Geteuid() == 0 {
m.logger.Info("enter network namespace[", namespace.Tag, "] with: nsenter -n -t ", pid)
} else {
m.logger.Info("enter network namespace[", namespace.Tag, "] with: nsenter -U --preserve-credentials -n -t ", pid)
}
return nil
}
func (m *Manager) close() error {
for _, created := range m.holders {
created.pipeWriter.Close()
if created.pidFile != "" {
os.Remove(created.pidFile)
}
}
m.holders = nil
return nil
}
func netnsPath(pid int) string {
return "/proc/" + strconv.Itoa(pid) + "/ns/net"
}
func (m *Manager) startHolder(pidFile string) (*holder, error) {
pipeReader, pipeWriter, err := os.Pipe()
if err != nil {
return nil, err
}
command := exec.Command(m.holderArgs[0], m.holderArgs[1:]...)
command.Stdin = pipeReader
command.SysProcAttr = &syscall.SysProcAttr{
Cloneflags: syscall.CLONE_NEWNET,
}
err = command.Start()
pipeReader.Close()
if err != nil {
pipeWriter.Close()
return nil, E.Cause(err, "start holder process")
}
go command.Wait()
err = setupNamespace(command.Process.Pid)
if err != nil {
pipeWriter.Close()
return nil, err
}
return &holder{command: command, pipeWriter: pipeWriter, pidFile: pidFile}, nil
}
func setupNamespace(pid int) error {
resultChannel := make(chan error, 1)
go func() {
runtime.LockOSThread()
resultChannel <- setupNamespaceThread(pid)
}()
return <-resultChannel
}
func setupNamespaceThread(pid int) error {
targetNs, err := vnetns.GetFromPath(netnsPath(pid))
if err != nil {
return E.Cause(err, "open created netns")
}
defer targetNs.Close()
err = vnetns.Set(targetNs)
if err != nil {
return E.Cause(err, "enter created netns")
}
loopbackLink, err := netlink.LinkByName("lo")
if err != nil {
return E.Cause(err, "find lo")
}
err = netlink.LinkSetUp(loopbackLink)
if err != nil {
return E.Cause(err, "set lo up")
}
return nil
}
+20
View File
@@ -0,0 +1,20 @@
//go:build !linux
package netns
import (
E "github.com/sagernet/sing/common/exceptions"
)
type holder struct{}
func (m *Manager) start() error {
if len(m.namespaces) > 0 {
return E.New("network namespaces are only supported on Linux")
}
return nil
}
func (m *Manager) close() error {
return nil
}
+85
View File
@@ -0,0 +1,85 @@
//go:build linux
package netns
import (
"bufio"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
F "github.com/sagernet/sing/common/format"
"github.com/sagernet/sing/common/logger"
)
func TestUnshareNamespace(t *testing.T) {
if os.Getenv("NETNS_TEST_HOLDER") == "1" {
Hold()
}
pipeReader, pipeWriter, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer pipeReader.Close()
defer pipeWriter.Close()
os.Setenv("NETNS_TEST_HOLDER", "1")
defer os.Unsetenv("NETNS_TEST_HOLDER")
manager, err := NewManager(logger.NOP(), []option.NetworkNamespace{{
Type: C.NetNsTypeUnshare,
Tag: "test",
UnshareOptions: option.UnshareNetworkNamespaceOptions{
PidFile: "/proc/self/fd/" + F.ToString(pipeWriter.Fd()),
},
}}, []string{"/proc/self/exe", "-test.run=^TestUnshareNamespace$"})
if err != nil {
t.Fatal(err)
}
err = manager.Start(adapter.StartStateInitialize)
if err != nil {
t.Fatal(err)
}
defer manager.Close()
pipeReader.SetReadDeadline(time.Now().Add(10 * time.Second))
pidLine, err := bufio.NewReader(pipeReader).ReadString('\n')
if err != nil {
t.Fatal("read pid from pipe: ", err)
}
pid, err := strconv.Atoi(strings.TrimSuffix(pidLine, "\n"))
if err != nil {
t.Fatal("parse pid: ", err)
}
resolvedPath := manager.ResolvePath("test")
if resolvedPath != netnsPath(pid) {
t.Fatal("resolved path ", resolvedPath, " does not match pid ", pid)
}
currentNs, err := os.Readlink("/proc/thread-self/ns/net")
if err != nil {
t.Fatal(err)
}
holderNs, err := os.Readlink(resolvedPath)
if err != nil {
t.Fatal("holder netns not accessible: ", err)
}
if currentNs == holderNs {
t.Fatal("holder is in the current netns")
}
err = manager.Close()
if err != nil {
t.Fatal(err)
}
for deadline := time.Now().Add(10 * time.Second); time.Now().Before(deadline); time.Sleep(10 * time.Millisecond) {
_, err = os.Stat("/proc/" + strconv.Itoa(pid))
if err != nil {
return
}
}
t.Fatal("holder process did not exit after close")
}
+82
View File
@@ -0,0 +1,82 @@
package netns
import (
"os"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
"github.com/sagernet/sing-box/option"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/logger"
)
var _ adapter.NetworkNamespaceManager = (*Manager)(nil)
type Manager struct {
logger logger.ContextLogger
namespaces []option.NetworkNamespace
holderArgs []string
paths map[string]string
holders []*holder
}
func NewManager(logger logger.ContextLogger, namespaces []option.NetworkNamespace, holderArgs []string) (*Manager, error) {
paths := make(map[string]string)
for _, namespace := range namespaces {
if namespace.Tag == "" {
return nil, E.New("network namespace: missing tag")
}
_, duplicated := paths[namespace.Tag]
if duplicated {
return nil, E.New("network namespace: duplicated tag: ", namespace.Tag)
}
switch namespace.Type {
case C.NetNsTypeDefault:
if namespace.DefaultOptions.Path == "" {
return nil, E.New("network namespace[", namespace.Tag, "]: missing path")
}
paths[namespace.Tag] = namespace.DefaultOptions.Path
case C.NetNsTypeUnshare:
paths[namespace.Tag] = ""
}
}
return &Manager{
logger: logger,
namespaces: namespaces,
holderArgs: holderArgs,
paths: paths,
}, nil
}
func (m *Manager) Name() string {
return "netns"
}
func (m *Manager) Start(stage adapter.StartStage) error {
if stage != adapter.StartStateInitialize {
return nil
}
return m.start()
}
func (m *Manager) Close() error {
return m.close()
}
func (m *Manager) ResolvePath(nameOrPath string) string {
path, loaded := m.paths[nameOrPath]
if loaded && path != "" {
return path
}
return nameOrPath
}
func Hold() {
buffer := make([]byte, 1)
for {
_, err := os.Stdin.Read(buffer)
if err != nil {
os.Exit(0)
}
}
}
+6
View File
@@ -0,0 +1,6 @@
package constant
const (
NetNsTypeDefault = "default"
NetNsTypeUnshare = "unshare"
)
+18 -1
View File
@@ -7,7 +7,8 @@ icon: material/new-box
:material-plus: [include_mac_address](#include_mac_address)
:material-plus: [exclude_mac_address](#exclude_mac_address)
:material-plus: [dns_mode](#dns_mode)
:material-plus: [dns_address](#dns_address)
:material-plus: [dns_address](#dns_address)
:material-plus: [netns](#netns)
!!! quote "Changes in sing-box 1.13.3"
@@ -197,6 +198,22 @@ icon: material/new-box
Virtual device name, automatically selected if empty.
#### netns
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux.
Create the tun interface in the specified network namespace, name, path, or the tag of a
[network namespace](/configuration/network-namespace/).
When set, `auto_route` and `auto_redirect` operate inside the namespace, and no root privilege is
required if the namespace is owned by the current user.
Conflict with `platform`.
#### address
!!! question "Since sing-box 1.10.0"
+16 -1
View File
@@ -7,7 +7,8 @@ icon: material/new-box
:material-plus: [include_mac_address](#include_mac_address)
:material-plus: [exclude_mac_address](#exclude_mac_address)
:material-plus: [dns_mode](#dns_mode)
:material-plus: [dns_address](#dns_address)
:material-plus: [dns_address](#dns_address)
:material-plus: [netns](#netns)
!!! quote "sing-box 1.13.3 中的更改"
@@ -199,6 +200,20 @@ icon: material/new-box
虚拟设备名称,默认自动选择。
#### netns
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux。
在指定的网络命名空间中创建 tun 接口,可以是名称、路径,或[网络命名空间](/zh/configuration/network-namespace/)的标签。
设置后,`auto_route``auto_redirect` 在该命名空间内生效;若命名空间归当前用户所有,则无需 root 权限。
`platform` 冲突。
#### address
!!! question "自 sing-box 1.10.0 起"
+2
View File
@@ -11,6 +11,7 @@ sing-box uses JSON for configuration files.
"certificate": {},
"certificate_providers": [],
"http_clients": [],
"network_namespaces": [],
"endpoints": [],
"inbounds": [],
"outbounds": [],
@@ -30,6 +31,7 @@ sing-box uses JSON for configuration files.
| `certificate` | [Certificate](./certificate/) |
| `certificate_providers` | [Certificate Provider](./shared/certificate-provider/) |
| `http_clients` | [HTTP Client](./shared/http-client/) |
| `network_namespaces` | [Network Namespace](./network-namespace/) |
| `endpoints` | [Endpoint](./endpoint/) |
| `inbounds` | [Inbound](./inbound/) |
| `outbounds` | [Outbound](./outbound/) |
+2
View File
@@ -11,6 +11,7 @@ sing-box 使用 JSON 作为配置文件格式。
"certificate": {},
"certificate_providers": [],
"http_clients": [],
"network_namespaces": [],
"endpoints": [],
"inbounds": [],
"outbounds": [],
@@ -30,6 +31,7 @@ sing-box 使用 JSON 作为配置文件格式。
| `certificate` | [证书](./certificate/) |
| `certificate_providers` | [证书提供者](./shared/certificate-provider/) |
| `http_clients` | [HTTP 客户端](./shared/http-client/) |
| `network_namespaces` | [网络命名空间](./network-namespace/) |
| `endpoints` | [端点](./endpoint/) |
| `inbounds` | [入站](./inbound/) |
| `outbounds` | [出站](./outbound/) |
@@ -0,0 +1,31 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.14.0"
# Default
Attach to an existing network namespace.
### Structure
```json
{
"network_namespaces": [
{
"type": "default", // optional
"tag": "",
"path": ""
}
]
}
```
### Fields
#### path
==Required==
Name or path of the network namespace, for example `sing` or `/run/netns/sing`.
@@ -0,0 +1,31 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.14.0 起"
# Default
附加到已存在的网络命名空间。
### 结构
```json
{
"network_namespaces": [
{
"type": "default", // 可选
"tag": "",
"path": ""
}
]
}
```
### 字段
#### path
==必填==
网络命名空间的名称或路径,例如 `sing``/run/netns/sing`
@@ -0,0 +1,43 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.14.0"
!!! quote ""
Only supported on Linux.
# Network Namespace
Network namespaces let inbounds and outbounds run inside a separate Linux network namespace,
referenced by tag from the [tun](/configuration/inbound/tun/#netns),
[Listen Fields](/configuration/shared/listen/#netns) and [Dial Fields](/configuration/shared/dial/#netns).
### Structure
```json
{
"network_namespaces": [
{
"type": "",
"tag": ""
}
]
}
```
#### type
The type of the network namespace, `default` is used by default.
| Type | Format |
|-----------|------------------------|
| `default` | [Default](./default/) |
| `unshare` | [Unshare](./unshare/) |
#### tag
==Required==
The tag of the network namespace.
@@ -0,0 +1,43 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.14.0 起"
!!! quote ""
仅支持 Linux。
# 网络命名空间
网络命名空间使入站和出站可以运行在独立的 Linux 网络命名空间中,
通过标签从 [tun](/zh/configuration/inbound/tun/#netns)、
[监听字段](/zh/configuration/shared/listen/#netns) 和 [拨号字段](/zh/configuration/shared/dial/#netns) 引用。
### 结构
```json
{
"network_namespaces": [
{
"type": "",
"tag": ""
}
]
}
```
#### type
网络命名空间的类型,默认使用 `default`
| 类型 | 格式 |
|-----------|------------------------|
| `default` | [Default](./default/) |
| `unshare` | [Unshare](./unshare/) |
#### tag
==必填==
网络命名空间的标签。
@@ -0,0 +1,36 @@
---
icon: material/new-box
---
!!! question "Since sing-box 1.14.0"
# Unshare
Create a new network namespace, without root privilege.
!!! info ""
Rootless operation requires the kernel to allow unprivileged user namespace creation.
### Structure
```json
{
"network_namespaces": [
{
"type": "unshare",
"tag": "",
"pid_file": ""
}
]
}
```
### Fields
#### pid_file
If set, the PID of the process holding the namespace open is written to this path.
The namespace can be entered with `nsenter -t <pid> -n` when sing-box is run as root,
or `nsenter -t <pid> -U --preserve-credentials -n` otherwise.
@@ -0,0 +1,36 @@
---
icon: material/new-box
---
!!! question "自 sing-box 1.14.0 起"
# Unshare
创建一个新的网络命名空间,无需 root 权限。
!!! info ""
无 root 运行需要内核允许非特权用户创建 user namespace。
### 结构
```json
{
"network_namespaces": [
{
"type": "unshare",
"tag": "",
"pid_file": ""
}
]
}
```
### 字段
#### pid_file
如果设置,持有该命名空间的进程 PID 将写入此路径。
当 sing-box 以 root 运行时,可通过 `nsenter -t <pid> -n` 进入该命名空间,
否则使用 `nsenter -t <pid> -U --preserve-credentials -n`
+5 -1
View File
@@ -4,7 +4,8 @@ icon: material/new-box
!!! quote "Changes in sing-box 1.14.0"
:material-alert: [domain_resolver](#domain_resolver)
:material-alert: [domain_resolver](#domain_resolver)
:material-alert: [netns](#netns)
!!! quote "Changes in sing-box 1.13.0"
@@ -118,6 +119,9 @@ Reuse listener address.
Set network namespace, name or path.
Since sing-box 1.14.0, the tag of a [network namespace](/configuration/network-namespace/) can also be used.
Referencing an `unshare` network namespace should be avoided, since its only route out is the tun interface managed by sing-box itself.
#### connect_timeout
Connect timeout, in golang's Duration format.
+5 -1
View File
@@ -4,7 +4,8 @@ icon: material/new-box
!!! quote "sing-box 1.14.0 中的更改"
:material-alert: [domain_resolver](#domain_resolver)
:material-alert: [domain_resolver](#domain_resolver)
:material-alert: [netns](#netns)
!!! quote "sing-box 1.13.0 中的更改"
@@ -118,6 +119,9 @@ icon: material/new-box
设置网络命名空间,名称或路径。
自 sing-box 1.14.0 起,也可以使用[网络命名空间](/zh/configuration/network-namespace/)的标签。
应避免引用 `unshare` 类型的网络命名空间,因为其唯一出口是由 sing-box 自身管理的 tun 接口。
#### connect_timeout
连接超时,采用 golang 的 Duration 格式。
+6
View File
@@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "Changes in sing-box 1.14.0"
:material-alert: [netns](#netns)
!!! quote "Changes in sing-box 1.13.0"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
@@ -97,6 +101,8 @@ Reuse listener address.
Set network namespace, name or path.
Since sing-box 1.14.0, the tag of a [network namespace](/configuration/network-namespace/) can also be used.
#### tcp_fast_open
Enable TCP Fast Open.
+6
View File
@@ -2,6 +2,10 @@
icon: material/new-box
---
!!! quote "sing-box 1.14.0 中的更改"
:material-alert: [netns](#netns)
!!! quote "sing-box 1.13.0 中的更改"
:material-plus: [disable_tcp_keep_alive](#disable_tcp_keep_alive)
@@ -97,6 +101,8 @@ icon: material/new-box
设置网络命名空间,名称或路径。
自 sing-box 1.14.0 起,也可以使用[网络命名空间](/zh/configuration/network-namespace/)的标签。
#### tcp_fast_open
启用 TCP Fast Open。
+1 -1
View File
@@ -51,7 +51,7 @@ require (
github.com/sagernet/sing-shadowsocks2 v0.2.1
github.com/sagernet/sing-shadowtls v0.2.1
github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb
github.com/sagernet/sing-tun v0.8.12-0.20260710042924-375e9ae639c5
github.com/sagernet/sing-tun v0.8.12-0.20260710165757-8c8594272daa
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
github.com/sagernet/smux v1.5.50-sing-box-mod.1
+2 -2
View File
@@ -286,8 +286,8 @@ github.com/sagernet/sing-shadowtls v0.2.1 h1:ZiHZdnEnP+YS73NMsxiZmIFCwNd0M4k7PkG
github.com/sagernet/sing-shadowtls v0.2.1/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb h1:VvU2/PZqP5tbKTDq0BxkhRO8ZnKI4UJzziakgBiP2Qg=
github.com/sagernet/sing-snell v0.0.0-20260710094516-a4e97ee24beb/go.mod h1:PcwzX/Xvqky0EP3kGt8OCjYb3R1pydenPHNQZcPZmXY=
github.com/sagernet/sing-tun v0.8.12-0.20260710042924-375e9ae639c5 h1:kL9E3UR9BRTH3ESiI519idZ2FZtObbFs8vlLwfJCt/g=
github.com/sagernet/sing-tun v0.8.12-0.20260710042924-375e9ae639c5/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
github.com/sagernet/sing-tun v0.8.12-0.20260710165757-8c8594272daa h1:NnzWGTMB9OcGctzeJRSSjWCOBx/x7kQPWaNsqNe5q5k=
github.com/sagernet/sing-tun v0.8.12-0.20260710165757-8c8594272daa/go.mod h1:QvarqUtHfj1ULaRR+6kZOS/OoCE+pYGq67A5tyIy+dQ=
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb h1:KEMbfexD4DvrQGYWwx6r+AwH9Veh8z6cnBZmtCS2G+0=
github.com/sagernet/sing-usbip v0.0.0-20260616101517-efb91521eddb/go.mod h1:D4CnJX3MNAAANhbQUxfIRgBdnvlTEaV7h6ojedcs+pw=
github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
+5
View File
@@ -139,6 +139,10 @@ nav:
- TCP Brutal: configuration/shared/tcp-brutal.md
- Wi-Fi State: configuration/shared/wifi-state.md
- Neighbor Resolution: configuration/shared/neighbor.md
- Network Namespace:
- configuration/network-namespace/index.md
- Default: configuration/network-namespace/default.md
- Unshare: configuration/network-namespace/unshare.md
- Endpoint:
- configuration/endpoint/index.md
- WireGuard: configuration/endpoint/wireguard.md
@@ -296,6 +300,7 @@ plugins:
V2Ray Transport: V2Ray 传输层
Wi-Fi State: Wi-Fi 状态
Network Namespace: 网络命名空间
Endpoint: 端点
Inbound: 入站
Outbound: 出站
+57
View File
@@ -0,0 +1,57 @@
package option
import (
C "github.com/sagernet/sing-box/constant"
E "github.com/sagernet/sing/common/exceptions"
"github.com/sagernet/sing/common/json"
"github.com/sagernet/sing/common/json/badjson"
)
type _NetworkNamespace struct {
Type string `json:"type,omitempty"`
Tag string `json:"tag"`
DefaultOptions DefaultNetworkNamespaceOptions `json:"-"`
UnshareOptions UnshareNetworkNamespaceOptions `json:"-"`
}
type NetworkNamespace _NetworkNamespace
func (o NetworkNamespace) MarshalJSON() ([]byte, error) {
var v any
switch o.Type {
case C.NetNsTypeDefault:
o.Type = ""
v = o.DefaultOptions
case C.NetNsTypeUnshare:
v = o.UnshareOptions
default:
return nil, E.New("unknown network namespace type: ", o.Type)
}
return badjson.MarshallObjects((_NetworkNamespace)(o), v)
}
func (o *NetworkNamespace) UnmarshalJSON(content []byte) error {
err := json.Unmarshal(content, (*_NetworkNamespace)(o))
if err != nil {
return err
}
var v any
switch o.Type {
case "", C.NetNsTypeDefault:
o.Type = C.NetNsTypeDefault
v = &o.DefaultOptions
case C.NetNsTypeUnshare:
v = &o.UnshareOptions
default:
return E.New("unknown network namespace type: ", o.Type)
}
return badjson.UnmarshallExcluded(content, (*_NetworkNamespace)(o), v)
}
type DefaultNetworkNamespaceOptions struct {
Path string `json:"path"`
}
type UnshareNetworkNamespaceOptions struct {
PidFile string `json:"pid_file,omitempty"`
}
+1
View File
@@ -19,6 +19,7 @@ type _Options struct {
Certificate *CertificateOptions `json:"certificate,omitempty"`
CertificateProviders []CertificateProvider `json:"certificate_providers,omitempty"`
HTTPClients []HTTPClient `json:"http_clients,omitempty"`
NetworkNamespaces []NetworkNamespace `json:"network_namespaces,omitempty"`
Endpoints []Endpoint `json:"endpoints,omitempty"`
Inbounds []Inbound `json:"inbounds,omitempty"`
Outbounds []Outbound `json:"outbounds,omitempty"`
+1
View File
@@ -12,6 +12,7 @@ import (
type TunInboundOptions struct {
InterfaceName string `json:"interface_name,omitempty"`
NetNs string `json:"netns,omitempty"`
MTU uint32 `json:"mtu,omitempty"`
Address badoption.Listable[netip.Prefix] `json:"address,omitempty"`
DNSMode string `json:"dns_mode,omitempty"`
+15 -3
View File
@@ -98,6 +98,9 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
})
platformInterface := service.FromContext[adapter.PlatformInterface](ctx)
if options.NetNs != "" && !C.IsLinux {
return nil, E.New("`netns` is only supported on Linux")
}
tunMTU := options.MTU
if tunMTU == 0 {
if platformInterface != nil && platformInterface.UnderNetworkExtension() {
@@ -190,6 +193,7 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
logger: logger,
tunOptions: tun.Options{
Name: options.InterfaceName,
NetNs: options.NetNs,
MTU: tunMTU,
GSO: enableGSO,
Inet4Address: inet4Address,
@@ -266,9 +270,11 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
}
if !C.IsAndroid {
inbound.tunOptions.AutoRedirectMarkMode = true
err = networkManager.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark)
if err != nil {
return nil, err
if options.NetNs == "" {
err = networkManager.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark)
if err != nil {
return nil, err
}
}
}
}
@@ -355,6 +361,12 @@ func (t *Inbound) Start(stage adapter.StartStage) error {
if t.tunOptions.Name == "" {
t.tunOptions.Name = tun.CalculateInterfaceName("")
}
if t.tunOptions.NetNs != "" {
manager := service.FromContext[adapter.NetworkNamespaceManager](t.ctx)
if manager != nil {
t.tunOptions.NetNs = manager.ResolvePath(t.tunOptions.NetNs)
}
}
if t.platformInterface == nil {
t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet)
for _, routeRuleSet := range t.routeRuleSet {