diff --git a/adapter/netns.go b/adapter/netns.go new file mode 100644 index 000000000..76a45d23c --- /dev/null +++ b/adapter/netns.go @@ -0,0 +1,5 @@ +package adapter + +type NetworkNamespaceManager interface { + ResolvePath(nameOrPath string) string +} diff --git a/box.go b/box.go index 1c129b03d..fcd753d9c 100644 --- a/box.go +++ b/box.go @@ -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) diff --git a/cmd/sing-box/cmd_netns_holder.go b/cmd/sing-box/cmd_netns_holder.go new file mode 100644 index 000000000..5851567cc --- /dev/null +++ b/cmd/sing-box/cmd_netns_holder.go @@ -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) +} diff --git a/cmd/sing-box/cmd_run.go b/cmd/sing-box/cmd_run.go index f31db9dc8..2037a9797 100644 --- a/cmd/sing-box/cmd_run.go +++ b/cmd/sing-box/cmd_run.go @@ -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 + } } } diff --git a/cmd/sing-box/cmd_run_userns_linux.go b/cmd/sing-box/cmd_run_userns_linux.go new file mode 100644 index 000000000..aacd39cdc --- /dev/null +++ b/cmd/sing-box/cmd_run_userns_linux.go @@ -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< 0 { + return E.New("network namespaces are only supported on Linux") + } + return nil +} + +func (m *Manager) close() error { + return nil +} diff --git a/common/netns/manager_test.go b/common/netns/manager_test.go new file mode 100644 index 000000000..59b7ae111 --- /dev/null +++ b/common/netns/manager_test.go @@ -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") +} diff --git a/common/netns/netns.go b/common/netns/netns.go new file mode 100644 index 000000000..50d39696a --- /dev/null +++ b/common/netns/netns.go @@ -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) + } + } +} diff --git a/constant/netns.go b/constant/netns.go new file mode 100644 index 000000000..d4c722423 --- /dev/null +++ b/constant/netns.go @@ -0,0 +1,6 @@ +package constant + +const ( + NetNsTypeDefault = "default" + NetNsTypeUnshare = "unshare" +) diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md index 9af118b68..145caece3 100644 --- a/docs/configuration/inbound/tun.md +++ b/docs/configuration/inbound/tun.md @@ -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" diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md index 7471771ee..4c88b11f0 100644 --- a/docs/configuration/inbound/tun.zh.md +++ b/docs/configuration/inbound/tun.zh.md @@ -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 起" diff --git a/docs/configuration/index.md b/docs/configuration/index.md index 311161c1c..9bc9ad1bf 100644 --- a/docs/configuration/index.md +++ b/docs/configuration/index.md @@ -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/) | diff --git a/docs/configuration/index.zh.md b/docs/configuration/index.zh.md index fbb44c79e..f1e76ca9a 100644 --- a/docs/configuration/index.zh.md +++ b/docs/configuration/index.zh.md @@ -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/) | diff --git a/docs/configuration/network-namespace/default.md b/docs/configuration/network-namespace/default.md new file mode 100644 index 000000000..f76b4fc5b --- /dev/null +++ b/docs/configuration/network-namespace/default.md @@ -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`. diff --git a/docs/configuration/network-namespace/default.zh.md b/docs/configuration/network-namespace/default.zh.md new file mode 100644 index 000000000..19483e42e --- /dev/null +++ b/docs/configuration/network-namespace/default.zh.md @@ -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`。 diff --git a/docs/configuration/network-namespace/index.md b/docs/configuration/network-namespace/index.md new file mode 100644 index 000000000..6ee9146bb --- /dev/null +++ b/docs/configuration/network-namespace/index.md @@ -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. diff --git a/docs/configuration/network-namespace/index.zh.md b/docs/configuration/network-namespace/index.zh.md new file mode 100644 index 000000000..4408f5885 --- /dev/null +++ b/docs/configuration/network-namespace/index.zh.md @@ -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 + +==必填== + +网络命名空间的标签。 diff --git a/docs/configuration/network-namespace/unshare.md b/docs/configuration/network-namespace/unshare.md new file mode 100644 index 000000000..963e158d7 --- /dev/null +++ b/docs/configuration/network-namespace/unshare.md @@ -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 -n` when sing-box is run as root, +or `nsenter -t -U --preserve-credentials -n` otherwise. diff --git a/docs/configuration/network-namespace/unshare.zh.md b/docs/configuration/network-namespace/unshare.zh.md new file mode 100644 index 000000000..972a21ed4 --- /dev/null +++ b/docs/configuration/network-namespace/unshare.zh.md @@ -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 -n` 进入该命名空间, +否则使用 `nsenter -t -U --preserve-credentials -n`。 diff --git a/docs/configuration/shared/dial.md b/docs/configuration/shared/dial.md index 2ef2fbdb6..83f867cbc 100644 --- a/docs/configuration/shared/dial.md +++ b/docs/configuration/shared/dial.md @@ -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. diff --git a/docs/configuration/shared/dial.zh.md b/docs/configuration/shared/dial.zh.md index 24388fecf..3067c9d45 100644 --- a/docs/configuration/shared/dial.zh.md +++ b/docs/configuration/shared/dial.zh.md @@ -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 格式。 diff --git a/docs/configuration/shared/listen.md b/docs/configuration/shared/listen.md index 55325564a..a332fb160 100644 --- a/docs/configuration/shared/listen.md +++ b/docs/configuration/shared/listen.md @@ -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. diff --git a/docs/configuration/shared/listen.zh.md b/docs/configuration/shared/listen.zh.md index 0afcbc46b..009825b65 100644 --- a/docs/configuration/shared/listen.zh.md +++ b/docs/configuration/shared/listen.zh.md @@ -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。 diff --git a/go.mod b/go.mod index 4ff296df0..9394ec6bd 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 82a9fd7c4..749115c6f 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/mkdocs.yml b/mkdocs.yml index 525413174..da838d572 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: 出站 diff --git a/option/netns.go b/option/netns.go new file mode 100644 index 000000000..9516b660b --- /dev/null +++ b/option/netns.go @@ -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"` +} diff --git a/option/options.go b/option/options.go index 4e87852ac..e28ccef85 100644 --- a/option/options.go +++ b/option/options.go @@ -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"` diff --git a/option/tun.go b/option/tun.go index 379aa7c33..34e95b734 100644 --- a/option/tun.go +++ b/option/tun.go @@ -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"` diff --git a/protocol/tun/inbound.go b/protocol/tun/inbound.go index 541512098..814a711ba 100644 --- a/protocol/tun/inbound.go +++ b/protocol/tun/inbound.go @@ -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 {