From d8d4be325c67190045694eab63c546be1a89c312 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= <i@sekai.icu>
Date: Fri, 7 Jun 2024 15:55:21 +0800
Subject: [PATCH] Add auto-redirect & Improve auto-route

---
 adapter/router.go                          |  15 ++
 box.go                                     |  28 ++-
 cmd/sing-box/cmd_tools_fetch.go            |  32 ++-
 cmd/sing-box/cmd_tools_fetch_http3.go      |  36 +++
 cmd/sing-box/cmd_tools_fetch_http3_stub.go |  18 ++
 common/dialer/default.go                   |  18 +-
 docs/configuration/inbound/tun.md          | 144 +++++++++++-
 docs/configuration/inbound/tun.zh.md       | 147 ++++++++++++-
 docs/deprecated.md                         |   8 +
 docs/deprecated.zh.md                      |   8 +
 docs/migration.md                          |  72 +++++-
 docs/migration.zh.md                       |  72 +++++-
 go.mod                                     |  19 +-
 go.sum                                     |  40 ++--
 inbound/tun.go                             | 243 ++++++++++++++++++---
 option/tun.go                              |  65 ++++--
 route/router.go                            |  34 ++-
 route/rule_item_rule_set.go                |   1 +
 route/rule_set.go                          |  21 ++
 route/rule_set_local.go                    |  61 +++++-
 route/rule_set_remote.go                   |  74 ++++++-
 21 files changed, 1037 insertions(+), 119 deletions(-)
 create mode 100644 cmd/sing-box/cmd_tools_fetch_http3.go
 create mode 100644 cmd/sing-box/cmd_tools_fetch_http3_stub.go

diff --git a/adapter/router.go b/adapter/router.go
index c481f0c8..619c1110 100644
--- a/adapter/router.go
+++ b/adapter/router.go
@@ -10,15 +10,18 @@ import (
 	"github.com/sagernet/sing-tun"
 	"github.com/sagernet/sing/common/control"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/x/list"
 	"github.com/sagernet/sing/service"
 
 	mdns "github.com/miekg/dns"
+	"go4.org/netipx"
 )
 
 type Router interface {
 	Service
 	PreStarter
 	PostStarter
+	Cleanup() error
 
 	Outbounds() []Outbound
 	Outbound(tag string) (Outbound, bool)
@@ -46,6 +49,8 @@ type Router interface {
 	AutoDetectInterface() bool
 	AutoDetectInterfaceFunc() control.Func
 	DefaultMark() uint32
+	RegisterAutoRedirectOutputMark(mark uint32) error
+	AutoRedirectOutputMark() uint32
 	NetworkMonitor() tun.NetworkUpdateMonitor
 	InterfaceMonitor() tun.DefaultInterfaceMonitor
 	PackageManager() tun.PackageManager
@@ -92,12 +97,22 @@ type DNSRule interface {
 }
 
 type RuleSet interface {
+	Name() string
 	StartContext(ctx context.Context, startContext RuleSetStartContext) error
+	PostStart() error
 	Metadata() RuleSetMetadata
+	ExtractIPSet() []*netipx.IPSet
+	IncRef()
+	DecRef()
+	Cleanup()
+	RegisterCallback(callback RuleSetUpdateCallback) *list.Element[RuleSetUpdateCallback]
+	UnregisterCallback(element *list.Element[RuleSetUpdateCallback])
 	Close() error
 	HeadlessRule
 }
 
+type RuleSetUpdateCallback func(it RuleSet)
+
 type RuleSetMetadata struct {
 	ContainsProcessRule bool
 	ContainsWIFIRule    bool
diff --git a/box.go b/box.go
index 3c514cfe..716b1b09 100644
--- a/box.go
+++ b/box.go
@@ -303,7 +303,11 @@ func (s *Box) start() error {
 			return E.Cause(err, "initialize inbound/", in.Type(), "[", tag, "]")
 		}
 	}
-	return s.postStart()
+	err = s.postStart()
+	if err != nil {
+		return err
+	}
+	return s.router.Cleanup()
 }
 
 func (s *Box) postStart() error {
@@ -313,16 +317,28 @@ func (s *Box) postStart() error {
 			return E.Cause(err, "start ", serviceName)
 		}
 	}
-	for _, outbound := range s.outbounds {
-		if lateOutbound, isLateOutbound := outbound.(adapter.PostStarter); isLateOutbound {
+	// TODO: reorganize ALL start order
+	for _, out := range s.outbounds {
+		if lateOutbound, isLateOutbound := out.(adapter.PostStarter); isLateOutbound {
 			err := lateOutbound.PostStart()
 			if err != nil {
-				return E.Cause(err, "post-start outbound/", outbound.Tag())
+				return E.Cause(err, "post-start outbound/", out.Tag())
 			}
 		}
 	}
-
-	return s.router.PostStart()
+	err := s.router.PostStart()
+	if err != nil {
+		return err
+	}
+	for _, in := range s.inbounds {
+		if lateInbound, isLateInbound := in.(adapter.PostStarter); isLateInbound {
+			err = lateInbound.PostStart()
+			if err != nil {
+				return E.Cause(err, "post-start inbound/", in.Tag())
+			}
+		}
+	}
+	return nil
 }
 
 func (s *Box) Close() error {
diff --git a/cmd/sing-box/cmd_tools_fetch.go b/cmd/sing-box/cmd_tools_fetch.go
index 256c3f42..3f62424a 100644
--- a/cmd/sing-box/cmd_tools_fetch.go
+++ b/cmd/sing-box/cmd_tools_fetch.go
@@ -9,8 +9,10 @@ import (
 	"net/url"
 	"os"
 
+	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing/common/bufio"
+	E "github.com/sagernet/sing/common/exceptions"
 	M "github.com/sagernet/sing/common/metadata"
 
 	"github.com/spf13/cobra"
@@ -32,7 +34,10 @@ func init() {
 	commandTools.AddCommand(commandFetch)
 }
 
-var httpClient *http.Client
+var (
+	httpClient  *http.Client
+	http3Client *http.Client
+)
 
 func fetch(args []string) error {
 	instance, err := createPreStartedClient()
@@ -53,8 +58,16 @@ func fetch(args []string) error {
 		},
 	}
 	defer httpClient.CloseIdleConnections()
+	if C.WithQUIC {
+		err = initializeHTTP3Client(instance)
+		if err != nil {
+			return err
+		}
+		defer http3Client.CloseIdleConnections()
+	}
 	for _, urlString := range args {
-		parsedURL, err := url.Parse(urlString)
+		var parsedURL *url.URL
+		parsedURL, err = url.Parse(urlString)
 		if err != nil {
 			return err
 		}
@@ -63,16 +76,27 @@ func fetch(args []string) error {
 			parsedURL.Scheme = "http"
 			fallthrough
 		case "http", "https":
-			err = fetchHTTP(parsedURL)
+			err = fetchHTTP(httpClient, parsedURL)
 			if err != nil {
 				return err
 			}
+		case "http3":
+			if !C.WithQUIC {
+				return C.ErrQUICNotIncluded
+			}
+			parsedURL.Scheme = "https"
+			err = fetchHTTP(http3Client, parsedURL)
+			if err != nil {
+				return err
+			}
+		default:
+			return E.New("unsupported scheme: ", parsedURL.Scheme)
 		}
 	}
 	return nil
 }
 
-func fetchHTTP(parsedURL *url.URL) error {
+func fetchHTTP(httpClient *http.Client, parsedURL *url.URL) error {
 	request, err := http.NewRequest("GET", parsedURL.String(), nil)
 	if err != nil {
 		return err
diff --git a/cmd/sing-box/cmd_tools_fetch_http3.go b/cmd/sing-box/cmd_tools_fetch_http3.go
new file mode 100644
index 00000000..5dc3d915
--- /dev/null
+++ b/cmd/sing-box/cmd_tools_fetch_http3.go
@@ -0,0 +1,36 @@
+//go:build with_quic
+
+package main
+
+import (
+	"context"
+	"crypto/tls"
+	"net/http"
+
+	"github.com/sagernet/quic-go"
+	"github.com/sagernet/quic-go/http3"
+	box "github.com/sagernet/sing-box"
+	"github.com/sagernet/sing/common/bufio"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+func initializeHTTP3Client(instance *box.Box) error {
+	dialer, err := createDialer(instance, N.NetworkUDP, commandToolsFlagOutbound)
+	if err != nil {
+		return err
+	}
+	http3Client = &http.Client{
+		Transport: &http3.RoundTripper{
+			Dial: func(ctx context.Context, addr string, tlsCfg *tls.Config, cfg *quic.Config) (quic.EarlyConnection, error) {
+				destination := M.ParseSocksaddr(addr)
+				udpConn, dErr := dialer.DialContext(ctx, N.NetworkUDP, destination)
+				if dErr != nil {
+					return nil, dErr
+				}
+				return quic.DialEarly(ctx, bufio.NewUnbindPacketConn(udpConn), udpConn.RemoteAddr(), tlsCfg, cfg)
+			},
+		},
+	}
+	return nil
+}
diff --git a/cmd/sing-box/cmd_tools_fetch_http3_stub.go b/cmd/sing-box/cmd_tools_fetch_http3_stub.go
new file mode 100644
index 00000000..ae13f54c
--- /dev/null
+++ b/cmd/sing-box/cmd_tools_fetch_http3_stub.go
@@ -0,0 +1,18 @@
+//go:build !with_quic
+
+package main
+
+import (
+	"net/url"
+	"os"
+
+	box "github.com/sagernet/sing-box"
+)
+
+func initializeHTTP3Client(instance *box.Box) error {
+	return os.ErrInvalid
+}
+
+func fetchHTTP3(parsedURL *url.URL) error {
+	return os.ErrInvalid
+}
diff --git a/common/dialer/default.go b/common/dialer/default.go
index 4fbad07d..488e8000 100644
--- a/common/dialer/default.go
+++ b/common/dialer/default.go
@@ -50,12 +50,26 @@ func NewDefault(router adapter.Router, options option.DialerOptions) (*DefaultDi
 		dialer.Control = control.Append(dialer.Control, bindFunc)
 		listener.Control = control.Append(listener.Control, bindFunc)
 	}
-	if options.RoutingMark != 0 {
+	var autoRedirectOutputMark uint32
+	if router != nil {
+		autoRedirectOutputMark = router.AutoRedirectOutputMark()
+	}
+	if autoRedirectOutputMark > 0 {
+		dialer.Control = control.Append(dialer.Control, control.RoutingMark(autoRedirectOutputMark))
+		listener.Control = control.Append(listener.Control, control.RoutingMark(autoRedirectOutputMark))
+	}
+	if options.RoutingMark > 0 {
 		dialer.Control = control.Append(dialer.Control, control.RoutingMark(options.RoutingMark))
 		listener.Control = control.Append(listener.Control, control.RoutingMark(options.RoutingMark))
-	} else if router != nil && router.DefaultMark() != 0 {
+		if autoRedirectOutputMark > 0 {
+			return nil, E.New("`auto_redirect` with `route_[_exclude]_address_set is conflict with `routing_mark`")
+		}
+	} else if router != nil && router.DefaultMark() > 0 {
 		dialer.Control = control.Append(dialer.Control, control.RoutingMark(router.DefaultMark()))
 		listener.Control = control.Append(listener.Control, control.RoutingMark(router.DefaultMark()))
+		if autoRedirectOutputMark > 0 {
+			return nil, E.New("`auto_redirect` with `route_[_exclude]_address_set is conflict with `default_mark`")
+		}
 	}
 	if options.ReuseAddr {
 		listener.Control = control.Append(listener.Control, control.ReuseAddr())
diff --git a/docs/configuration/inbound/tun.md b/docs/configuration/inbound/tun.md
index 1d5d8d0f..1e2bf400 100644
--- a/docs/configuration/inbound/tun.md
+++ b/docs/configuration/inbound/tun.md
@@ -2,6 +2,21 @@
 icon: material/new-box
 ---
 
+!!! quote "Changes in sing-box 1.10.0"
+
+    :material-plus: [address](#address)  
+    :material-delete-clock: [inet4_address](#inet4_address)  
+    :material-delete-clock: [inet6_address](#inet6_address)  
+    :material-plus: [route_address](#route_address)  
+    :material-delete-clock: [inet4_route_address](#inet4_route_address)  
+    :material-delete-clock: [inet6_route_address](#inet6_route_address)  
+    :material-plus: [route_exclude_address](#route_address)  
+    :material-delete-clock: [inet4_route_exclude_address](#inet4_route_exclude_address)  
+    :material-delete-clock: [inet6_route_exclude_address](#inet6_route_exclude_address)  
+    :material-plus: [auto_redirect](#auto_redirect)  
+    :material-plus: [route_address_set](#route_address_set)  
+    :material-plus: [route_exclude_address_set](#route_address_set)
+
 !!! quote "Changes in sing-box 1.9.0"
 
     :material-plus: [platform.http_proxy.bypass_domain](#platformhttp_proxybypass_domain)  
@@ -23,26 +38,57 @@ icon: material/new-box
   "type": "tun",
   "tag": "tun-in",
   "interface_name": "tun0",
-  "inet4_address": "172.19.0.1/30",
-  "inet6_address": "fdfe:dcba:9876::1/126",
+  "address": [
+    "172.18.0.1/30",
+    "fdfe:dcba:9876::1/126"
+  ],
+  // deprecated
+  "inet4_address": [
+    "172.19.0.1/30"
+  ],
+  // deprecated
+  "inet6_address": [
+    "fdfe:dcba:9876::1/126"
+  ],
   "mtu": 9000,
   "gso": false,
   "auto_route": true,
   "strict_route": true,
+  "auto_redirect": false,
+  "route_address": [
+    "0.0.0.0/1",
+    "128.0.0.0/1",
+    "::/1",
+    "8000::/1"
+  ],
+  // deprecated
   "inet4_route_address": [
     "0.0.0.0/1",
     "128.0.0.0/1"
   ],
+  // deprecated
   "inet6_route_address": [
     "::/1",
     "8000::/1"
   ],
+  "route_exclude_address": [
+    "192.168.0.0/16",
+    "fc00::/7"
+  ],
+  // deprecated
   "inet4_route_exclude_address": [
     "192.168.0.0/16"
   ],
+  // deprecated
   "inet6_route_exclude_address": [
     "fc00::/7"
   ],
+  "route_address_set": [
+    "geoip-cloudflare"
+  ],
+  "route_exclude_address_set": [
+    "geoip-cn"
+  ],
   "endpoint_independent_nat": false,
   "udp_timeout": "5m",
   "stack": "system",
@@ -102,14 +148,26 @@ icon: material/new-box
 
 Virtual device name, automatically selected if empty.
 
+#### address
+
+!!! question "Since sing-box 1.10.0"
+
+IPv4 and IPv6 prefix for the tun interface.
+
 #### inet4_address
 
-==Required==
+!!! failure "Deprecated in sing-box 1.10.0"
+
+    `inet4_address` is merged to `address` and will be removed in sing-box 1.11.0.
 
 IPv4 prefix for the tun interface.
 
 #### inet6_address
 
+!!! failure "Deprecated in sing-box 1.10.0"
+
+    `inet6_address` is merged to `address` and will be removed in sing-box 1.11.0.
+
 IPv6 prefix for the tun interface.
 
 #### mtu
@@ -145,9 +203,10 @@ Enforce strict routing rules when `auto_route` is enabled:
 *In Linux*:
 
 * Let unsupported network unreachable
+* Make ICMP traffic route to tun instead of upstream interfaces
 * Route all connections to tun
 
-It prevents address leaks and makes DNS hijacking work on Android.
+It prevents IP address leaks and makes DNS hijacking work on Android.
 
 *In Windows*:
 
@@ -156,22 +215,95 @@ It prevents address leaks and makes DNS hijacking work on Android.
 
 It may prevent some applications (such as VirtualBox) from working properly in certain situations.
 
+#### auto_redirect
+
+!!! question "Since sing-box 1.10.0"
+
+!!! quote ""
+
+    Only supported on Linux with `auto_route` enabled.
+
+Automatically configure iptables/nftables to redirect connections.
+
+*In Android*:
+
+Only local connections are forwarded. To share your VPN connection over hotspot or repeater,
+use [VPNHotspot](https://github.com/Mygod/VPNHotspot).
+
+*In Linux*:
+
+`auto_route` with `auto_redirect` now works as expected on routers **without intervention**.
+
+#### route_address
+
+!!! question "Since sing-box 1.10.0"
+
+Use custom routes instead of default when `auto_route` is enabled.
+
 #### inet4_route_address
 
+!!! failure "Deprecated in sing-box 1.10.0"
+
+   `inet4_route_address` is deprecated and will be removed in sing-box 1.11.0, please use [route_address](#route_address) instead.
+
 Use custom routes instead of default when `auto_route` is enabled.
 
 #### inet6_route_address
 
+!!! failure "Deprecated in sing-box 1.10.0"
+
+   `inet6_route_address` is deprecated and will be removed in sing-box 1.11.0, please use [route_address](#route_address) instead.
+
 Use custom routes instead of default when `auto_route` is enabled.
 
+#### route_exclude_address
+
+!!! question "Since sing-box 1.10.0"
+
+Exclude custom routes when `auto_route` is enabled.
+
 #### inet4_route_exclude_address
 
+!!! failure "Deprecated in sing-box 1.10.0"
+
+   `inet4_route_exclude_address` is deprecated and will be removed in sing-box 1.11.0, please use [route_exclude_address](#route_exclude_address) instead.
+
 Exclude custom routes when `auto_route` is enabled.
 
 #### inet6_route_exclude_address
 
+!!! failure "Deprecated in sing-box 1.10.0"
+
+   `inet6_route_exclude_address` is deprecated and will be removed in sing-box 1.11.0, please use [route_exclude_address](#route_exclude_address) instead.
+
 Exclude custom routes when `auto_route` is enabled.
 
+#### route_address_set
+
+!!! question "Since sing-box 1.10.0"
+
+!!! quote ""
+
+    Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled.
+
+Add the destination IP CIDR rules in the specified rule-sets to the firewall.
+Unmatched traffic will bypass the sing-box routes.
+
+Conflict with `route.default_mark` and `[dialOptions].routing_mark`.
+
+#### route_exclude_address_set
+
+!!! question "Since sing-box 1.10.0"
+
+!!! quote ""
+
+    Only supported on Linux with nftables and requires `auto_route` and `auto_redirect` enabled.
+
+Add the destination IP CIDR rules in the specified rule-sets to the firewall.
+Matched traffic will bypass the sing-box routes.
+
+Conflict with `route.default_mark` and `[dialOptions].routing_mark`.
+
 #### endpoint_independent_nat
 
 !!! info ""
@@ -214,6 +346,10 @@ Conflict with `exclude_interface`.
 
 #### exclude_interface
 
+!!! warning ""
+
+    When `strict_route` enabled, return traffic to excluded interfaces will not be automatically excluded, so add them as well (example: `br-lan` and `pppoe-wan`).
+
 Exclude interfaces in route.
 
 Conflict with `include_interface`.
diff --git a/docs/configuration/inbound/tun.zh.md b/docs/configuration/inbound/tun.zh.md
index 73d31d64..5b1d35af 100644
--- a/docs/configuration/inbound/tun.zh.md
+++ b/docs/configuration/inbound/tun.zh.md
@@ -2,6 +2,21 @@
 icon: material/new-box
 ---
 
+!!! quote "Changes in sing-box 1.10.0"
+
+    :material-plus: [address](#address)  
+    :material-delete-clock: [inet4_address](#inet4_address)  
+    :material-delete-clock: [inet6_address](#inet6_address)  
+    :material-plus: [route_address](#route_address)  
+    :material-delete-clock: [inet4_route_address](#inet4_route_address)  
+    :material-delete-clock: [inet6_route_address](#inet6_route_address)  
+    :material-plus: [route_exclude_address](#route_address)  
+    :material-delete-clock: [inet4_route_exclude_address](#inet4_route_exclude_address)  
+    :material-delete-clock: [inet6_route_exclude_address](#inet6_route_exclude_address)  
+    :material-plus: [auto_redirect](#auto_redirect)  
+    :material-plus: [route_address_set](#route_address_set)  
+    :material-plus: [route_exclude_address_set](#route_address_set)
+
 !!! quote "sing-box 1.9.0 中的更改"
 
     :material-plus: [platform.http_proxy.bypass_domain](#platformhttp_proxybypass_domain)  
@@ -23,26 +38,57 @@ icon: material/new-box
   "type": "tun",
   "tag": "tun-in",
   "interface_name": "tun0",
-  "inet4_address": "172.19.0.1/30",
-  "inet6_address": "fdfe:dcba:9876::1/126",
+  "address": [
+    "172.18.0.1/30",
+    "fdfe:dcba:9876::1/126"
+  ],
+  // 已弃用
+  "inet4_address": [
+    "172.19.0.1/30"
+  ],
+  // 已弃用
+  "inet6_address": [
+    "fdfe:dcba:9876::1/126"
+  ],
   "mtu": 9000,
   "gso": false,
   "auto_route": true,
   "strict_route": true,
+  "auto_redirect": false,
+  "route_address": [
+    "0.0.0.0/1",
+    "128.0.0.0/1",
+    "::/1",
+    "8000::/1"
+  ],
+  // 已弃用
   "inet4_route_address": [
     "0.0.0.0/1",
     "128.0.0.0/1"
   ],
+  // 已弃用
   "inet6_route_address": [
     "::/1",
     "8000::/1"
   ],
+  "route_exclude_address": [
+    "192.168.0.0/16",
+    "fc00::/7"
+  ],
+  // 已弃用
   "inet4_route_exclude_address": [
     "192.168.0.0/16"
   ],
+  // 已弃用
   "inet6_route_exclude_address": [
     "fc00::/7"
   ],
+  "route_address_set": [
+    "geoip-cloudflare"
+  ],
+  "route_exclude_address_set": [
+    "geoip-cn"
+  ],
   "endpoint_independent_nat": false,
   "udp_timeout": "5m",
   "stack": "system",
@@ -102,14 +148,30 @@ icon: material/new-box
 
 虚拟设备名称,默认自动选择。
 
+#### address
+
+!!! question "自 sing-box 1.10.0 起"
+
+==必填==
+
+tun 接口的 IPv4 和 IPv6 前缀。
+
 #### inet4_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet4_address` 已合并到 `address` 且将在 sing-box 1.11.0 移除.
+
 ==必填==
 
 tun 接口的 IPv4 前缀。
 
 #### inet6_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet6_address` 已合并到 `address` 且将在 sing-box 1.11.0 移除.
+
 tun 接口的 IPv6 前缀。
 
 #### mtu
@@ -145,9 +207,10 @@ tun 接口的 IPv6 前缀。
 *在 Linux 中*:
 
 * 让不支持的网络无法到达
+* 使 ICMP 流量路由到 tun 而不是上游接口
 * 将所有连接路由到 tun
 
-它可以防止地址泄漏,并使 DNS 劫持在 Android 上工作。
+它可以防止 IP 地址泄漏,并使 DNS 劫持在 Android 上工作。
 
 *在 Windows 中*:
 
@@ -157,22 +220,94 @@ tun 接口的 IPv6 前缀。
 
 它可能会使某些应用程序(如 VirtualBox)在某些情况下无法正常工作。
 
+#### auto_redirect
+
+!!! question "自 sing-box 1.10.0 起"
+
+!!! quote ""
+
+    仅支持 Linux。
+
+自动配置 iptables 以重定向 TCP 连接。
+
+*在 Android 中*:
+
+仅转发本地 IPv4 连接。 要通过热点或中继共享您的 VPN 连接,请使用 [VPNHotspot](https://github.com/Mygod/VPNHotspot)。
+
+*在 Linux 中*:
+
+带有 `auto_redirect `的 `auto_route` 现在可以在路由器上按预期工作,**无需干预**。
+
+#### route_address
+
+!!! question "自 sing-box 1.10.0 起"
+
+设置到 Tun 的自定义路由。
+
 #### inet4_route_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet4_route_address` 已合并到 `route_address` 且将在 sing-box 1.11.0 移除.
+
 启用 `auto_route` 时使用自定义路由而不是默认路由。
 
 #### inet6_route_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet6_route_address` 已合并到 `route_address` 且将在 sing-box 1.11.0 移除.
+
 启用 `auto_route` 时使用自定义路由而不是默认路由。
 
+#### route_exclude_address
+
+!!! question "自 sing-box 1.10.0 起"
+
+设置到 Tun 的排除自定义路由。
+
 #### inet4_route_exclude_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet4_route_exclude_address` 已合并到 `route_exclude_address` 且将在 sing-box 1.11.0 移除.
+
 启用 `auto_route` 时排除自定义路由。
 
 #### inet6_route_exclude_address
 
+!!! failure "已在 sing-box 1.10.0 废弃"
+
+    `inet6_route_exclude_address` 已合并到 `route_exclude_address` 且将在 sing-box 1.11.0 移除.
+
 启用 `auto_route` 时排除自定义路由。
 
+#### route_address_set
+
+!!! question "自 sing-box 1.10.0 起"
+
+!!! quote ""
+
+    仅支持 Linux,且需要 nftables,`auto_route` 和 `auto_redirect` 已启用。 
+
+将指定规则集中的目标 IP CIDR 规则添加到防火墙。
+不匹配的流量将绕过 sing-box 路由。
+
+与 `route.default_mark` 和 `[dialOptions].routing_mark` 冲突。
+
+#### route_exclude_address_set
+
+!!! question "自 sing-box 1.10.0 起"
+
+!!! quote ""
+
+    仅支持 Linux,且需要 nftables,`auto_route` 和 `auto_redirect` 已启用。
+
+将指定规则集中的目标 IP CIDR 规则添加到防火墙。
+匹配的流量将绕过 sing-box 路由。
+
+与 `route.default_mark` 和 `[dialOptions].routing_mark` 冲突。
+
 #### endpoint_independent_nat
 
 启用独立于端点的 NAT。
@@ -211,6 +346,10 @@ TCP/IP 栈。
 
 #### exclude_interface
 
+!!! warning ""
+
+    当 `strict_route` 启用,到被排除接口的回程流量将不会被自动排除,因此也要添加它们(例:`br-lan` 与 `pppoe-wan`)。
+
 排除路由的接口。
 
 与 `include_interface` 冲突。
@@ -284,7 +423,7 @@ TCP/IP 栈。
 
 !!! note ""
 
-  在 Apple 平台,`bypass_domain` 项匹配主机名 **后缀**.
+    在 Apple 平台,`bypass_domain` 项匹配主机名 **后缀**.
 
 绕过代理的主机名列表。
 
diff --git a/docs/deprecated.md b/docs/deprecated.md
index 439bf7e8..249bc492 100644
--- a/docs/deprecated.md
+++ b/docs/deprecated.md
@@ -6,6 +6,14 @@ icon: material/delete-alert
 
 ## 1.10.0
 
+#### TUN address fields are merged
+
+`inet4_address` and `inet6_address` are merged into `address`,
+`inet4_route_address` and `inet6_route_address` are merged into `route_address`,
+`inet4_route_exclude_address` and `inet6_route_exclude_address` are merged into `route_exclude_address`.
+
+Old fields are deprecated and will be removed in sing-box 1.11.0.
+
 #### Drop support for go1.18 and go1.19
 
 Due to maintenance difficulties, sing-box 1.10.0 requires at least Go 1.20 to compile.
diff --git a/docs/deprecated.zh.md b/docs/deprecated.zh.md
index 76e7e768..6815e9fc 100644
--- a/docs/deprecated.zh.md
+++ b/docs/deprecated.zh.md
@@ -6,6 +6,14 @@ icon: material/delete-alert
 
 ## 1.10.0
 
+#### TUN 地址字段已合并
+
+`inet4_address` 和 `inet6_address` 已合并为 `address`,
+`inet4_route_address` 和 `inet6_route_address` 已合并为 `route_address`,
+`inet4_route_exclude_address` 和 `inet6_route_exclude_address` 已合并为 `route_exclude_address`。
+
+旧字段已废弃,且将在 sing-box 1.11.0 中移除。
+
 #### 移除对 go1.18 和 go1.19 的支持
 
 由于维护困难,sing-box 1.10.0 要求至少 Go 1.20 才能编译。
diff --git a/docs/migration.md b/docs/migration.md
index b282a90f..c696a836 100644
--- a/docs/migration.md
+++ b/docs/migration.md
@@ -2,12 +2,76 @@
 icon: material/arrange-bring-forward
 ---
 
+## 1.10.0
+
+### TUN address fields are merged
+
+`inet4_address` and `inet6_address` are merged into `address`,
+`inet4_route_address` and `inet6_route_address` are merged into `route_address`,
+`inet4_route_exclude_address` and `inet6_route_exclude_address` are merged into `route_exclude_address`.
+
+Old fields are deprecated and will be removed in sing-box 1.11.0.
+
+!!! info "References"
+
+    [TUN](/configuration/inbound/tun/)
+
+=== ":material-card-remove: Deprecated"
+
+    ```json
+    {
+      "inbounds": [
+        {
+          "type": "tun",
+          "inet4_address": "172.19.0.1/30",
+          "inet6_address": "fdfe:dcba:9876::1/126",
+          "inet4_route_address": [
+            "0.0.0.0/1",
+            "128.0.0.0/1"
+          ],
+          "inet6_route_address": [
+            "::/1",
+            "8000::/1"
+          ],
+          "inet4_route_exclude_address": [
+            "192.168.0.0/16"
+          ],
+          "inet6_route_exclude_address": [
+            "fc00::/7"
+          ]
+        }
+      ]
+    }
+    ```
+
+=== ":material-card-multiple: New"
+
+    ```json
+    {
+      "inbounds": [
+        {
+          "type": "tun",
+          "address": [
+            "172.19.0.1/30",
+            "fdfe:dcba:9876::1/126"
+          ],
+          "route_address": [
+            "0.0.0.0/1",
+            "128.0.0.0/1",
+            "::/1",
+            "8000::/1"
+          ],
+          "route_exclude_address": [
+            "192.168.0.0/16",
+            "fc00::/7"
+          ]
+        }
+      ]
+    }
+    ```
+
 ## 1.9.0
 
-!!! warning "Unstable"
-
-    This version is still under development, and the following migration guide may be changed in the future.
-
 ### `domain_suffix` behavior update
 
 For historical reasons, sing-box's `domain_suffix` rule matches literal prefixes instead of the same as other projects.
diff --git a/docs/migration.zh.md b/docs/migration.zh.md
index bd63bf17..9fe40cc9 100644
--- a/docs/migration.zh.md
+++ b/docs/migration.zh.md
@@ -2,12 +2,76 @@
 icon: material/arrange-bring-forward
 ---
 
+## 1.10.0
+
+### TUN 地址字段已合并
+
+`inet4_address` 和 `inet6_address` 已合并为 `address`,
+`inet4_route_address` 和 `inet6_route_address` 已合并为 `route_address`,
+`inet4_route_exclude_address` 和 `inet6_route_exclude_address` 已合并为 `route_exclude_address`。
+
+旧字段已废弃,且将在 sing-box 1.11.0 中移除。
+
+!!! info "参考"
+
+    [TUN](/zh/configuration/inbound/tun/)
+
+=== ":material-card-remove: 弃用的"
+
+    ```json
+    {
+      "inbounds": [
+        {
+          "type": "tun",
+          "inet4_address": "172.19.0.1/30",
+          "inet6_address": "fdfe:dcba:9876::1/126",
+          "inet4_route_address": [
+            "0.0.0.0/1",
+            "128.0.0.0/1"
+          ],
+          "inet6_route_address": [
+            "::/1",
+            "8000::/1"
+          ],
+          "inet4_route_exclude_address": [
+            "192.168.0.0/16"
+          ],
+          "inet6_route_exclude_address": [
+            "fc00::/7"
+          ]
+        }
+      ]
+    }
+    ```
+
+=== ":material-card-multiple: 新的"
+
+    ```json
+    {
+      "inbounds": [
+        {
+          "type": "tun",
+          "address": [
+            "172.19.0.1/30",
+            "fdfe:dcba:9876::1/126"
+          ],
+          "route_address": [
+            "0.0.0.0/1",
+            "128.0.0.0/1",
+            "::/1",
+            "8000::/1"
+          ],
+          "route_exclude_address": [
+            "192.168.0.0/16",
+            "fc00::/7"
+          ]
+        }
+      ]
+    }
+    ```
+
 ## 1.9.0
 
-!!! warning "不稳定的"
-
-    该版本仍在开发中,迁移指南可能将在未来更改。
-
 ### `domain_suffix` 行为更新
 
 由于历史原因,sing-box 的 `domain_suffix` 规则匹配字面前缀,而不与其他项目相同。
diff --git a/go.mod b/go.mod
index e10b77f4..704505e3 100644
--- a/go.mod
+++ b/go.mod
@@ -33,7 +33,7 @@ require (
 	github.com/sagernet/sing-shadowsocks v0.2.7
 	github.com/sagernet/sing-shadowsocks2 v0.2.0
 	github.com/sagernet/sing-shadowtls v0.1.4
-	github.com/sagernet/sing-tun v0.3.2
+	github.com/sagernet/sing-tun v0.4.0-beta.13.0.20240703164908-1f043289199d
 	github.com/sagernet/sing-vmess v0.1.12
 	github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7
 	github.com/sagernet/tfo-go v0.0.0-20231209031829-7b5343ac1dc6
@@ -44,8 +44,8 @@ require (
 	github.com/stretchr/testify v1.9.0
 	go.uber.org/zap v1.27.0
 	go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
-	golang.org/x/crypto v0.23.0
-	golang.org/x/net v0.25.0
+	golang.org/x/crypto v0.24.0
+	golang.org/x/net v0.26.0
 	golang.org/x/sys v0.21.0
 	golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
 	google.golang.org/grpc v1.63.2
@@ -65,6 +65,7 @@ require (
 	github.com/gobwas/httphead v0.1.0 // indirect
 	github.com/gobwas/pool v0.2.1 // indirect
 	github.com/google/btree v1.1.2 // indirect
+	github.com/google/go-cmp v0.6.0 // indirect
 	github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a // indirect
 	github.com/hashicorp/yamux v0.1.1 // indirect
 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -72,24 +73,28 @@ require (
 	github.com/klauspost/compress v1.17.4 // indirect
 	github.com/klauspost/cpuid/v2 v2.2.5 // indirect
 	github.com/libdns/libdns v0.2.2 // indirect
+	github.com/mdlayher/netlink v1.7.2 // indirect
+	github.com/mdlayher/socket v0.4.1 // indirect
 	github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e // indirect
 	github.com/onsi/ginkgo/v2 v2.9.7 // indirect
 	github.com/pierrec/lz4/v4 v4.1.14 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
 	github.com/quic-go/qpack v0.4.0 // indirect
 	github.com/quic-go/qtls-go1-20 v0.4.1 // indirect
-	github.com/sagernet/netlink v0.0.0-20240523065131-45e60152f9ba // indirect
+	github.com/sagernet/fswatch v0.1.1 // indirect
+	github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a // indirect
+	github.com/sagernet/nftables v0.3.0-beta.4 // indirect
 	github.com/spf13/pflag v1.0.5 // indirect
 	github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
-	github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 // indirect
+	github.com/vishvananda/netns v0.0.4 // indirect
 	github.com/zeebo/blake3 v0.2.3 // indirect
 	go.uber.org/multierr v1.11.0 // indirect
-	golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f // indirect
+	golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 // indirect
 	golang.org/x/mod v0.18.0 // indirect
 	golang.org/x/sync v0.7.0 // indirect
 	golang.org/x/text v0.16.0 // indirect
 	golang.org/x/time v0.5.0 // indirect
-	golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
+	golang.org/x/tools v0.22.0 // indirect
 	google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect
 	gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
 	gopkg.in/yaml.v3 v3.0.1 // indirect
diff --git a/go.sum b/go.sum
index 2186ab2b..206e29d6 100644
--- a/go.sum
+++ b/go.sum
@@ -40,6 +40,7 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
 github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU=
 github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a h1:fEBsGL/sjAuJrgah5XqmmYsTLzJp/TO9Lhy39gkverk=
 github.com/google/pprof v0.0.0-20231101202521-4ca4178f5c7a/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
 github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE=
@@ -69,6 +70,10 @@ github.com/libdns/libdns v0.2.2 h1:O6ws7bAfRPaBsgAYt8MDe2HcNBGC29hkZ9MX2eUSX3s=
 github.com/libdns/libdns v0.2.2/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
 github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
 github.com/logrusorgru/aurora v2.0.3+incompatible/go.mod h1:7rIyQOR62GCctdiQpZ/zOJlFyk6y+94wXzv6RNZgaR4=
+github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g=
+github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
+github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U=
+github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA=
 github.com/mholt/acmez v1.2.0 h1:1hhLxSgY5FvH5HCnGUuwbKY2VQVo8IU7rxXKSnZ7F30=
 github.com/mholt/acmez v1.2.0/go.mod h1:VT9YwH1xgNX1kmYY89gY8xPJC84BFAisjo8Egigt4kE=
 github.com/miekg/dns v1.1.59 h1:C9EXc/UToRwKLhK5wKU/I4QVsBUc8kE6MkHBkeypWZs=
@@ -95,12 +100,16 @@ github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a h1:+NkI2670SQpQWvkk
 github.com/sagernet/bbolt v0.0.0-20231014093535-ea5cb2fe9f0a/go.mod h1:63s7jpZqcDAIpj8oI/1v4Izok+npJOHACFCU6+huCkM=
 github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1 h1:YbmpqPQEMdlk9oFSKYWRqVuu9qzNiOayIonKmv1gCXY=
 github.com/sagernet/cloudflare-tls v0.0.0-20231208171750-a4483c1b7cd1/go.mod h1:J2yAxTFPDjrDPhuAi9aWFz2L3ox9it4qAluBBbN0H5k=
+github.com/sagernet/fswatch v0.1.1 h1:YqID+93B7VRfqIH3PArW/XpJv5H4OLEVWDfProGoRQs=
+github.com/sagernet/fswatch v0.1.1/go.mod h1:nz85laH0mkQqJfaOrqPpkwtU1znMFNVTpT/5oRsVz/o=
 github.com/sagernet/gomobile v0.1.3 h1:ohjIb1Ou2+1558PnZour3od69suSuvkdSVOlO1tC4B8=
 github.com/sagernet/gomobile v0.1.3/go.mod h1:Pqq2+ZVvs10U7xK+UwJgwYWUykewi8H6vlslAO73n9E=
 github.com/sagernet/gvisor v0.0.0-20240428053021-e691de28565f h1:NkhuupzH5ch7b/Y/6ZHJWrnNLoiNnSJaow6DPb8VW2I=
 github.com/sagernet/gvisor v0.0.0-20240428053021-e691de28565f/go.mod h1:KXmw+ouSJNOsuRpg4wgwwCQuunrGz4yoAqQjsLjc6N0=
-github.com/sagernet/netlink v0.0.0-20240523065131-45e60152f9ba h1:EY5AS7CCtfmARNv2zXUOrsEMPFDGYxaw65JzA2p51Vk=
-github.com/sagernet/netlink v0.0.0-20240523065131-45e60152f9ba/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a h1:ObwtHN2VpqE0ZNjr6sGeT00J8uU7JF4cNUdb44/Duis=
+github.com/sagernet/netlink v0.0.0-20240612041022-b9a21c07ac6a/go.mod h1:xLnfdiJbSp8rNqYEdIW/6eDO4mVoogml14Bh2hSiFpM=
+github.com/sagernet/nftables v0.3.0-beta.4 h1:kbULlAwAC3jvdGAC1P5Fa3GSxVwQJibNenDW2zaXr8I=
+github.com/sagernet/nftables v0.3.0-beta.4/go.mod h1:OQXAjvjNGGFxaTgVCSTRIhYB5/llyVDeapVoENYBDS8=
 github.com/sagernet/quic-go v0.45.1-beta.2 h1:zkEeCbhdFFkrxKcuIRBtXNKci/1t2J/39QSG/sPvlmc=
 github.com/sagernet/quic-go v0.45.1-beta.2/go.mod h1:+N3FqM9DAzOWfe64uxXuBejVJwX7DeW7BslzLO6N/xI=
 github.com/sagernet/reality v0.0.0-20230406110435-ee17307e7691 h1:5Th31OC6yj8byLGkEnIYp6grlXfo1QYUfiYFGjewIdc=
@@ -120,8 +129,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.0 h1:wpZNs6wKnR7mh1wV9OHwOyUr21VkS3wK
 github.com/sagernet/sing-shadowsocks2 v0.2.0/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
 github.com/sagernet/sing-shadowtls v0.1.4 h1:aTgBSJEgnumzFenPvc+kbD9/W0PywzWevnVpEx6Tw3k=
 github.com/sagernet/sing-shadowtls v0.1.4/go.mod h1:F8NBgsY5YN2beQavdgdm1DPlhaKQlaL6lpDdcBglGK4=
-github.com/sagernet/sing-tun v0.3.2 h1:z0bLUT/YXH9RrJS9DsIpB0Bb9afl2hVJOmHd0zA3HJY=
-github.com/sagernet/sing-tun v0.3.2/go.mod h1:DxLIyhjWU/HwGYoX0vNGg2c5QgTQIakphU1MuERR5tQ=
+github.com/sagernet/sing-tun v0.4.0-beta.13.0.20240703164908-1f043289199d h1:2nBM9W9fOCM45hjlu1Fh9qyzBCgKEkq+SOuRCbCCs7c=
+github.com/sagernet/sing-tun v0.4.0-beta.13.0.20240703164908-1f043289199d/go.mod h1:81JwnnYw8X9W9XvmZetSTTiPgIE3SbAbnc+EHKwPJ5U=
 github.com/sagernet/sing-vmess v0.1.12 h1:2gFD8JJb+eTFMoa8FIVMnknEi+vCSfaiTXTfEYAYAPg=
 github.com/sagernet/sing-vmess v0.1.12/go.mod h1:luTSsfyBGAc9VhtCqwjR+dt1QgqBhuYBCONB/POhF8I=
 github.com/sagernet/smux v0.0.0-20231208180855-7041f6ea79e7 h1:DImB4lELfQhplLTxeq2z31Fpv8CQqqrUwTbrIRumZqQ=
@@ -146,8 +155,8 @@ github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsT
 github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
 github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
-github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74 h1:gga7acRE695APm9hlsSMoOoE65U4/TcqNj90mc69Rlg=
-github.com/vishvananda/netns v0.0.0-20211101163701-50045581ed74/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0=
+github.com/vishvananda/netns v0.0.4 h1:Oeaw1EM2JMxD51g9uhtC0D7erkIjgmj8+JZc26m1YX8=
+github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
 github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
 github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
 github.com/zeebo/blake3 v0.2.3 h1:TFoLXsjeXqRNFxSbk35Dk4YtszE/MQQGK10BH4ptoTg=
@@ -163,20 +172,19 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs
 go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
 golang.org/x/crypto v0.0.0-20190404164418-38d8ce5564a5/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
 golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
-golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI=
-golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
-golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f h1:99ci1mjWVBWwJiEKYY6jWa4d2nTQVIEhZIptnrVb1XY=
-golang.org/x/exp v0.0.0-20240416160154-fe59bbe5cc7f/go.mod h1:/lliqkxwWAhPjf5oSOIJup2XcqJaw8RGS6k3TGEc7GI=
+golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
+golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY=
+golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI=
 golang.org/x/mod v0.18.0 h1:5+9lSbEzPSdWkH32vYPBwEpX8KwDbM52Ud9xBUvNlb0=
 golang.org/x/mod v0.18.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac=
-golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
+golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
+golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
 golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M=
 golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -187,7 +195,7 @@ golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
 golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.20.0 h1:VnkxpohqXaOBYJtBmEppKUG6mXpi+4O6purfc2+sMhw=
+golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
@@ -195,8 +203,8 @@ golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
 golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
 golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+golang.org/x/tools v0.22.0 h1:gqSGLZqv+AI9lIQzniJ0nZDRG5GBPsSi+DRNHWNz6yA=
+golang.org/x/tools v0.22.0/go.mod h1:aCwcsjqvq7Yqt6TNyX7QMU2enbQ/Gt0bo6krSeEri+c=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6 h1:CawjfCvYQH2OU3/TnxLx97WDSUDRABfT18pCOYwc2GE=
 golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6/go.mod h1:3rxYc4HtVcSG9gVaTs2GEBdehh+sYPOwKtyUWEOTb80=
 google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY=
diff --git a/inbound/tun.go b/inbound/tun.go
index 7bd700d3..cb6a02c3 100644
--- a/inbound/tun.go
+++ b/inbound/tun.go
@@ -3,6 +3,9 @@ package inbound
 import (
 	"context"
 	"net"
+	"net/netip"
+	"os"
+	"runtime"
 	"strconv"
 	"strings"
 	"time"
@@ -19,27 +22,91 @@ import (
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	"github.com/sagernet/sing/common/ranges"
+	"github.com/sagernet/sing/common/x/list"
+
+	"go4.org/netipx"
 )
 
 var _ adapter.Inbound = (*Tun)(nil)
 
 type Tun struct {
-	tag                    string
-	ctx                    context.Context
-	router                 adapter.Router
-	logger                 log.ContextLogger
-	inboundOptions         option.InboundOptions
-	tunOptions             tun.Options
-	endpointIndependentNat bool
-	udpTimeout             int64
-	stack                  string
-	tunIf                  tun.Tun
-	tunStack               tun.Stack
-	platformInterface      platform.Interface
-	platformOptions        option.TunPlatformOptions
+	tag                         string
+	ctx                         context.Context
+	router                      adapter.Router
+	logger                      log.ContextLogger
+	inboundOptions              option.InboundOptions
+	tunOptions                  tun.Options
+	endpointIndependentNat      bool
+	udpTimeout                  int64
+	stack                       string
+	tunIf                       tun.Tun
+	tunStack                    tun.Stack
+	platformInterface           platform.Interface
+	platformOptions             option.TunPlatformOptions
+	autoRedirect                tun.AutoRedirect
+	routeRuleSet                []adapter.RuleSet
+	routeRuleSetCallback        []*list.Element[adapter.RuleSetUpdateCallback]
+	routeExcludeRuleSet         []adapter.RuleSet
+	routeExcludeRuleSetCallback []*list.Element[adapter.RuleSetUpdateCallback]
+	routeAddressSet             []*netipx.IPSet
+	routeExcludeAddressSet      []*netipx.IPSet
 }
 
 func NewTun(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.TunInboundOptions, platformInterface platform.Interface) (*Tun, error) {
+	address := options.Address
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet4Address) > 0 {
+		address = append(address, options.Inet4Address...)
+	}
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet6Address) > 0 {
+		address = append(address, options.Inet6Address...)
+	}
+	inet4Address := common.Filter(address, func(it netip.Prefix) bool {
+		return it.Addr().Is4()
+	})
+	inet6Address := common.Filter(address, func(it netip.Prefix) bool {
+		return it.Addr().Is6()
+	})
+
+	routeAddress := options.RouteAddress
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet4RouteAddress) > 0 {
+		routeAddress = append(routeAddress, options.Inet4RouteAddress...)
+	}
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet6RouteAddress) > 0 {
+		routeAddress = append(routeAddress, options.Inet6RouteAddress...)
+	}
+	inet4RouteAddress := common.Filter(routeAddress, func(it netip.Prefix) bool {
+		return it.Addr().Is4()
+	})
+	inet6RouteAddress := common.Filter(routeAddress, func(it netip.Prefix) bool {
+		return it.Addr().Is6()
+	})
+
+	routeExcludeAddress := options.RouteExcludeAddress
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet4RouteExcludeAddress) > 0 {
+		routeExcludeAddress = append(routeExcludeAddress, options.Inet4RouteExcludeAddress...)
+	}
+	//nolint:staticcheck
+	//goland:noinspection GoDeprecation
+	if len(options.Inet6RouteExcludeAddress) > 0 {
+		routeExcludeAddress = append(routeExcludeAddress, options.Inet6RouteExcludeAddress...)
+	}
+	inet4RouteExcludeAddress := common.Filter(routeExcludeAddress, func(it netip.Prefix) bool {
+		return it.Addr().Is4()
+	})
+	inet6RouteExcludeAddress := common.Filter(routeExcludeAddress, func(it netip.Prefix) bool {
+		return it.Addr().Is6()
+	})
+
 	tunMTU := options.MTU
 	if tunMTU == 0 {
 		tunMTU = 9000
@@ -50,9 +117,9 @@ func NewTun(ctx context.Context, router adapter.Router, logger log.ContextLogger
 	} else {
 		udpTimeout = C.UDPTimeout
 	}
+	var err error
 	includeUID := uidToRange(options.IncludeUID)
 	if len(options.IncludeUIDRange) > 0 {
-		var err error
 		includeUID, err = parseRange(includeUID, options.IncludeUIDRange)
 		if err != nil {
 			return nil, E.Cause(err, "parse include_uid_range")
@@ -60,13 +127,30 @@ func NewTun(ctx context.Context, router adapter.Router, logger log.ContextLogger
 	}
 	excludeUID := uidToRange(options.ExcludeUID)
 	if len(options.ExcludeUIDRange) > 0 {
-		var err error
 		excludeUID, err = parseRange(excludeUID, options.ExcludeUIDRange)
 		if err != nil {
 			return nil, E.Cause(err, "parse exclude_uid_range")
 		}
 	}
-	return &Tun{
+
+	tableIndex := options.IPRoute2TableIndex
+	if tableIndex == 0 {
+		tableIndex = tun.DefaultIPRoute2TableIndex
+	}
+	ruleIndex := options.IPRoute2RuleIndex
+	if ruleIndex == 0 {
+		ruleIndex = tun.DefaultIPRoute2RuleIndex
+	}
+	inputMark := options.AutoRedirectInputMark
+	if inputMark == 0 {
+		inputMark = tun.DefaultAutoRedirectInputMark
+	}
+	outputMark := options.AutoRedirectOutputMark
+	if outputMark == 0 {
+		outputMark = tun.DefaultAutoRedirectOutputMark
+	}
+
+	inbound := &Tun{
 		tag:            tag,
 		ctx:            ctx,
 		router:         router,
@@ -76,30 +160,83 @@ func NewTun(ctx context.Context, router adapter.Router, logger log.ContextLogger
 			Name:                     options.InterfaceName,
 			MTU:                      tunMTU,
 			GSO:                      options.GSO,
-			Inet4Address:             options.Inet4Address,
-			Inet6Address:             options.Inet6Address,
+			Inet4Address:             inet4Address,
+			Inet6Address:             inet6Address,
 			AutoRoute:                options.AutoRoute,
+			IPRoute2TableIndex:       tableIndex,
+			IPRoute2RuleIndex:        ruleIndex,
+			AutoRedirectInputMark:    inputMark,
+			AutoRedirectOutputMark:   outputMark,
 			StrictRoute:              options.StrictRoute,
 			IncludeInterface:         options.IncludeInterface,
 			ExcludeInterface:         options.ExcludeInterface,
-			Inet4RouteAddress:        options.Inet4RouteAddress,
-			Inet6RouteAddress:        options.Inet6RouteAddress,
-			Inet4RouteExcludeAddress: options.Inet4RouteExcludeAddress,
-			Inet6RouteExcludeAddress: options.Inet6RouteExcludeAddress,
+			Inet4RouteAddress:        inet4RouteAddress,
+			Inet6RouteAddress:        inet6RouteAddress,
+			Inet4RouteExcludeAddress: inet4RouteExcludeAddress,
+			Inet6RouteExcludeAddress: inet6RouteExcludeAddress,
 			IncludeUID:               includeUID,
 			ExcludeUID:               excludeUID,
 			IncludeAndroidUser:       options.IncludeAndroidUser,
 			IncludePackage:           options.IncludePackage,
 			ExcludePackage:           options.ExcludePackage,
 			InterfaceMonitor:         router.InterfaceMonitor(),
-			TableIndex:               2022,
 		},
 		endpointIndependentNat: options.EndpointIndependentNat,
 		udpTimeout:             int64(udpTimeout.Seconds()),
 		stack:                  options.Stack,
 		platformInterface:      platformInterface,
 		platformOptions:        common.PtrValueOrDefault(options.Platform),
-	}, nil
+	}
+	if options.AutoRedirect {
+		if !options.AutoRoute {
+			return nil, E.New("`auto_route` is required by `auto_redirect`")
+		}
+		disableNFTables, dErr := strconv.ParseBool(os.Getenv("DISABLE_NFTABLES"))
+		inbound.autoRedirect, err = tun.NewAutoRedirect(tun.AutoRedirectOptions{
+			TunOptions:             &inbound.tunOptions,
+			Context:                ctx,
+			Handler:                inbound,
+			Logger:                 logger,
+			NetworkMonitor:         router.NetworkMonitor(),
+			InterfaceFinder:        router.InterfaceFinder(),
+			TableName:              "sing-box",
+			DisableNFTables:        dErr == nil && disableNFTables,
+			RouteAddressSet:        &inbound.routeAddressSet,
+			RouteExcludeAddressSet: &inbound.routeExcludeAddressSet,
+		})
+		if err != nil {
+			return nil, E.Cause(err, "initialize auto-redirect")
+		}
+		if runtime.GOOS != "android" {
+			var markMode bool
+			for _, routeAddressSet := range options.RouteAddressSet {
+				ruleSet, loaded := router.RuleSet(routeAddressSet)
+				if !loaded {
+					return nil, E.New("parse route_address_set: rule-set not found: ", routeAddressSet)
+				}
+				ruleSet.IncRef()
+				inbound.routeRuleSet = append(inbound.routeRuleSet, ruleSet)
+				markMode = true
+			}
+			for _, routeExcludeAddressSet := range options.RouteExcludeAddressSet {
+				ruleSet, loaded := router.RuleSet(routeExcludeAddressSet)
+				if !loaded {
+					return nil, E.New("parse route_exclude_address_set: rule-set not found: ", routeExcludeAddressSet)
+				}
+				ruleSet.IncRef()
+				inbound.routeExcludeRuleSet = append(inbound.routeExcludeRuleSet, ruleSet)
+				markMode = true
+			}
+			if markMode {
+				inbound.tunOptions.AutoRedirectMarkMode = true
+				err = router.RegisterAutoRedirectOutputMark(inbound.tunOptions.AutoRedirectOutputMark)
+				if err != nil {
+					return nil, err
+				}
+			}
+		}
+	}
+	return inbound, nil
 }
 
 func uidToRange(uidList option.Listable[uint32]) []ranges.Range[uint32] {
@@ -121,11 +258,11 @@ func parseRange(uidRanges []ranges.Range[uint32], rangeList []string) ([]ranges.
 		}
 		var start, end uint64
 		var err error
-		start, err = strconv.ParseUint(uidRange[:subIndex], 10, 32)
+		start, err = strconv.ParseUint(uidRange[:subIndex], 0, 32)
 		if err != nil {
 			return nil, E.Cause(err, "parse range start")
 		}
-		end, err = strconv.ParseUint(uidRange[subIndex+1:], 10, 32)
+		end, err = strconv.ParseUint(uidRange[subIndex+1:], 0, 32)
 		if err != nil {
 			return nil, E.Cause(err, "parse range end")
 		}
@@ -200,10 +337,58 @@ func (t *Tun) Start() error {
 	return nil
 }
 
+func (t *Tun) PostStart() error {
+	monitor := taskmonitor.New(t.logger, C.StartTimeout)
+	if t.autoRedirect != nil {
+		t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet)
+		for _, routeRuleSet := range t.routeRuleSet {
+			ipSets := routeRuleSet.ExtractIPSet()
+			if len(ipSets) == 0 {
+				t.logger.Warn("route_address_set: no destination IP CIDR rules found in rule-set: ", routeRuleSet.Name())
+			}
+			t.routeAddressSet = append(t.routeAddressSet, ipSets...)
+		}
+		t.routeExcludeAddressSet = common.FlatMap(t.routeExcludeRuleSet, adapter.RuleSet.ExtractIPSet)
+		for _, routeExcludeRuleSet := range t.routeExcludeRuleSet {
+			ipSets := routeExcludeRuleSet.ExtractIPSet()
+			if len(ipSets) == 0 {
+				t.logger.Warn("route_address_set: no destination IP CIDR rules found in rule-set: ", routeExcludeRuleSet.Name())
+			}
+			t.routeExcludeAddressSet = append(t.routeExcludeAddressSet, ipSets...)
+		}
+		monitor.Start("initialize auto-redirect")
+		err := t.autoRedirect.Start()
+		monitor.Finish()
+		if err != nil {
+			return E.Cause(err, "auto-redirect")
+		}
+		for _, routeRuleSet := range t.routeRuleSet {
+			t.routeRuleSetCallback = append(t.routeRuleSetCallback, routeRuleSet.RegisterCallback(t.updateRouteAddressSet))
+			routeRuleSet.DecRef()
+		}
+		for _, routeExcludeRuleSet := range t.routeExcludeRuleSet {
+			t.routeExcludeRuleSetCallback = append(t.routeExcludeRuleSetCallback, routeExcludeRuleSet.RegisterCallback(t.updateRouteAddressSet))
+			routeExcludeRuleSet.DecRef()
+		}
+		t.routeAddressSet = nil
+		t.routeExcludeAddressSet = nil
+	}
+	return nil
+}
+
+func (t *Tun) updateRouteAddressSet(it adapter.RuleSet) {
+	t.routeAddressSet = common.FlatMap(t.routeRuleSet, adapter.RuleSet.ExtractIPSet)
+	t.routeExcludeAddressSet = common.FlatMap(t.routeExcludeRuleSet, adapter.RuleSet.ExtractIPSet)
+	t.autoRedirect.UpdateRouteAddressSet()
+	t.routeAddressSet = nil
+	t.routeExcludeAddressSet = nil
+}
+
 func (t *Tun) Close() error {
 	return common.Close(
 		t.tunStack,
 		t.tunIf,
+		t.autoRedirect,
 	)
 }
 
@@ -215,7 +400,11 @@ func (t *Tun) NewConnection(ctx context.Context, conn net.Conn, upstreamMetadata
 	metadata.Source = upstreamMetadata.Source
 	metadata.Destination = upstreamMetadata.Destination
 	metadata.InboundOptions = t.inboundOptions
-	t.logger.InfoContext(ctx, "inbound connection from ", metadata.Source)
+	if upstreamMetadata.Protocol != "" {
+		t.logger.InfoContext(ctx, "inbound ", upstreamMetadata.Protocol, " connection from ", metadata.Source)
+	} else {
+		t.logger.InfoContext(ctx, "inbound connection from ", metadata.Source)
+	}
 	t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination)
 	err := t.router.RouteConnection(ctx, conn, metadata)
 	if err != nil {
diff --git a/option/tun.go b/option/tun.go
index ac66a806..cbc73e7d 100644
--- a/option/tun.go
+++ b/option/tun.go
@@ -3,29 +3,46 @@ package option
 import "net/netip"
 
 type TunInboundOptions struct {
-	InterfaceName            string                 `json:"interface_name,omitempty"`
-	MTU                      uint32                 `json:"mtu,omitempty"`
-	GSO                      bool                   `json:"gso,omitempty"`
-	Inet4Address             Listable[netip.Prefix] `json:"inet4_address,omitempty"`
-	Inet6Address             Listable[netip.Prefix] `json:"inet6_address,omitempty"`
-	AutoRoute                bool                   `json:"auto_route,omitempty"`
-	StrictRoute              bool                   `json:"strict_route,omitempty"`
-	Inet4RouteAddress        Listable[netip.Prefix] `json:"inet4_route_address,omitempty"`
-	Inet6RouteAddress        Listable[netip.Prefix] `json:"inet6_route_address,omitempty"`
-	Inet4RouteExcludeAddress Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty"`
-	Inet6RouteExcludeAddress Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty"`
-	IncludeInterface         Listable[string]       `json:"include_interface,omitempty"`
-	ExcludeInterface         Listable[string]       `json:"exclude_interface,omitempty"`
-	IncludeUID               Listable[uint32]       `json:"include_uid,omitempty"`
-	IncludeUIDRange          Listable[string]       `json:"include_uid_range,omitempty"`
-	ExcludeUID               Listable[uint32]       `json:"exclude_uid,omitempty"`
-	ExcludeUIDRange          Listable[string]       `json:"exclude_uid_range,omitempty"`
-	IncludeAndroidUser       Listable[int]          `json:"include_android_user,omitempty"`
-	IncludePackage           Listable[string]       `json:"include_package,omitempty"`
-	ExcludePackage           Listable[string]       `json:"exclude_package,omitempty"`
-	EndpointIndependentNat   bool                   `json:"endpoint_independent_nat,omitempty"`
-	UDPTimeout               UDPTimeoutCompat       `json:"udp_timeout,omitempty"`
-	Stack                    string                 `json:"stack,omitempty"`
-	Platform                 *TunPlatformOptions    `json:"platform,omitempty"`
+	InterfaceName          string                 `json:"interface_name,omitempty"`
+	MTU                    uint32                 `json:"mtu,omitempty"`
+	GSO                    bool                   `json:"gso,omitempty"`
+	Address                Listable[netip.Prefix] `json:"address,omitempty"`
+	AutoRoute              bool                   `json:"auto_route,omitempty"`
+	IPRoute2TableIndex     int                    `json:"iproute2_table_index,omitempty"`
+	IPRoute2RuleIndex      int                    `json:"iproute2_rule_index,omitempty"`
+	AutoRedirect           bool                   `json:"auto_redirect,omitempty"`
+	AutoRedirectInputMark  uint32                 `json:"auto_redirect_input_mark,omitempty"`
+	AutoRedirectOutputMark uint32                 `json:"auto_redirect_output_mark,omitempty"`
+	StrictRoute            bool                   `json:"strict_route,omitempty"`
+	RouteAddress           Listable[netip.Prefix] `json:"route_address,omitempty"`
+	RouteAddressSet        Listable[string]       `json:"route_address_set,omitempty"`
+	RouteExcludeAddress    Listable[netip.Prefix] `json:"route_exclude_address,omitempty"`
+	RouteExcludeAddressSet Listable[string]       `json:"route_exclude_address_set,omitempty"`
+	IncludeInterface       Listable[string]       `json:"include_interface,omitempty"`
+	ExcludeInterface       Listable[string]       `json:"exclude_interface,omitempty"`
+	IncludeUID             Listable[uint32]       `json:"include_uid,omitempty"`
+	IncludeUIDRange        Listable[string]       `json:"include_uid_range,omitempty"`
+	ExcludeUID             Listable[uint32]       `json:"exclude_uid,omitempty"`
+	ExcludeUIDRange        Listable[string]       `json:"exclude_uid_range,omitempty"`
+	IncludeAndroidUser     Listable[int]          `json:"include_android_user,omitempty"`
+	IncludePackage         Listable[string]       `json:"include_package,omitempty"`
+	ExcludePackage         Listable[string]       `json:"exclude_package,omitempty"`
+	EndpointIndependentNat bool                   `json:"endpoint_independent_nat,omitempty"`
+	UDPTimeout             UDPTimeoutCompat       `json:"udp_timeout,omitempty"`
+	Stack                  string                 `json:"stack,omitempty"`
+	Platform               *TunPlatformOptions    `json:"platform,omitempty"`
 	InboundOptions
+
+	// Deprecated: merged to Address
+	Inet4Address Listable[netip.Prefix] `json:"inet4_address,omitempty"`
+	// Deprecated: merged to Address
+	Inet6Address Listable[netip.Prefix] `json:"inet6_address,omitempty"`
+	// Deprecated: merged to RouteAddress
+	Inet4RouteAddress Listable[netip.Prefix] `json:"inet4_route_address,omitempty"`
+	// Deprecated: merged to RouteAddress
+	Inet6RouteAddress Listable[netip.Prefix] `json:"inet6_route_address,omitempty"`
+	// Deprecated: merged to RouteExcludeAddress
+	Inet4RouteExcludeAddress Listable[netip.Prefix] `json:"inet4_route_exclude_address,omitempty"`
+	// Deprecated: merged to RouteExcludeAddress
+	Inet6RouteExcludeAddress Listable[netip.Prefix] `json:"inet6_route_exclude_address,omitempty"`
 }
diff --git a/route/router.go b/route/router.go
index bf136d0a..d8008ea1 100644
--- a/route/router.go
+++ b/route/router.go
@@ -83,6 +83,7 @@ type Router struct {
 	autoDetectInterface                bool
 	defaultInterface                   string
 	defaultMark                        uint32
+	autoRedirectOutputMark             uint32
 	networkMonitor                     tun.NetworkUpdateMonitor
 	interfaceMonitor                   tun.DefaultInterfaceMonitor
 	packageManager                     tun.PackageManager
@@ -533,7 +534,10 @@ func (r *Router) Start() error {
 
 	if r.needPackageManager && r.platformInterface == nil {
 		monitor.Start("initialize package manager")
-		packageManager, err := tun.NewPackageManager(r)
+		packageManager, err := tun.NewPackageManager(tun.PackageManagerOptions{
+			Callback: r,
+			Logger:   r.logger,
+		})
 		monitor.Finish()
 		if err != nil {
 			return E.Cause(err, "create package manager")
@@ -724,10 +728,26 @@ func (r *Router) PostStart() error {
 			return E.Cause(err, "initialize rule[", i, "]")
 		}
 	}
+	for _, ruleSet := range r.ruleSets {
+		monitor.Start("post start rule_set[", ruleSet.Name(), "]")
+		err := ruleSet.PostStart()
+		monitor.Finish()
+		if err != nil {
+			return E.Cause(err, "post start rule_set[", ruleSet.Name(), "]")
+		}
+	}
 	r.started = true
 	return nil
 }
 
+func (r *Router) Cleanup() error {
+	for _, ruleSet := range r.ruleSetMap {
+		ruleSet.Cleanup()
+	}
+	runtime.GC()
+	return nil
+}
+
 func (r *Router) Outbound(tag string) (adapter.Outbound, bool) {
 	outbound, loaded := r.outboundByTag[tag]
 	return outbound, loaded
@@ -1132,6 +1152,18 @@ func (r *Router) AutoDetectInterfaceFunc() control.Func {
 	}
 }
 
+func (r *Router) RegisterAutoRedirectOutputMark(mark uint32) error {
+	if r.autoRedirectOutputMark > 0 {
+		return E.New("only one auto-redirect can be configured")
+	}
+	r.autoRedirectOutputMark = mark
+	return nil
+}
+
+func (r *Router) AutoRedirectOutputMark() uint32 {
+	return r.autoRedirectOutputMark
+}
+
 func (r *Router) DefaultInterface() string {
 	return r.defaultInterface
 }
diff --git a/route/rule_item_rule_set.go b/route/rule_item_rule_set.go
index 482a9c7b..4ecf8c18 100644
--- a/route/rule_item_rule_set.go
+++ b/route/rule_item_rule_set.go
@@ -32,6 +32,7 @@ func (r *RuleSetItem) Start() error {
 		if !loaded {
 			return E.New("rule-set not found: ", tag)
 		}
+		ruleSet.IncRef()
 		r.setList = append(r.setList, ruleSet)
 	}
 	return nil
diff --git a/route/rule_set.go b/route/rule_set.go
index f644fb40..ff28858e 100644
--- a/route/rule_set.go
+++ b/route/rule_set.go
@@ -9,10 +9,13 @@ import (
 	"github.com/sagernet/sing-box/adapter"
 	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"
 	"github.com/sagernet/sing/common/logger"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
+
+	"go4.org/netipx"
 )
 
 func NewRuleSet(ctx context.Context, router adapter.Router, logger logger.ContextLogger, options option.RuleSet) (adapter.RuleSet, error) {
@@ -26,6 +29,24 @@ func NewRuleSet(ctx context.Context, router adapter.Router, logger logger.Contex
 	}
 }
 
+func extractIPSetFromRule(rawRule adapter.HeadlessRule) []*netipx.IPSet {
+	switch rule := rawRule.(type) {
+	case *DefaultHeadlessRule:
+		return common.FlatMap(rule.destinationIPCIDRItems, func(rawItem RuleItem) []*netipx.IPSet {
+			switch item := rawItem.(type) {
+			case *IPCIDRItem:
+				return []*netipx.IPSet{item.ipSet}
+			default:
+				return nil
+			}
+		})
+	case *LogicalHeadlessRule:
+		return common.FlatMap(rule.rules, extractIPSetFromRule)
+	default:
+		panic("unexpected rule type")
+	}
+}
+
 var _ adapter.RuleSetStartContext = (*RuleSetStartContext)(nil)
 
 type RuleSetStartContext struct {
diff --git a/route/rule_set_local.go b/route/rule_set_local.go
index 39458267..53446183 100644
--- a/route/rule_set_local.go
+++ b/route/rule_set_local.go
@@ -9,16 +9,23 @@ import (
 	"github.com/sagernet/sing-box/common/srs"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/atomic"
 	E "github.com/sagernet/sing/common/exceptions"
 	F "github.com/sagernet/sing/common/format"
 	"github.com/sagernet/sing/common/json"
+	"github.com/sagernet/sing/common/x/list"
+
+	"go4.org/netipx"
 )
 
 var _ adapter.RuleSet = (*LocalRuleSet)(nil)
 
 type LocalRuleSet struct {
+	tag      string
 	rules    []adapter.HeadlessRule
 	metadata adapter.RuleSetMetadata
+	refs     atomic.Int32
 }
 
 func NewLocalRuleSet(router adapter.Router, options option.RuleSet) (*LocalRuleSet, error) {
@@ -58,16 +65,11 @@ func NewLocalRuleSet(router adapter.Router, options option.RuleSet) (*LocalRuleS
 	metadata.ContainsProcessRule = hasHeadlessRule(plainRuleSet.Rules, isProcessHeadlessRule)
 	metadata.ContainsWIFIRule = hasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule)
 	metadata.ContainsIPCIDRRule = hasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule)
-	return &LocalRuleSet{rules, metadata}, nil
+	return &LocalRuleSet{tag: options.Tag, rules: rules, metadata: metadata}, nil
 }
 
-func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool {
-	for _, rule := range s.rules {
-		if rule.Match(metadata) {
-			return true
-		}
-	}
-	return false
+func (s *LocalRuleSet) Name() string {
+	return s.tag
 }
 
 func (s *LocalRuleSet) String() string {
@@ -78,10 +80,51 @@ func (s *LocalRuleSet) StartContext(ctx context.Context, startContext adapter.Ru
 	return nil
 }
 
+func (s *LocalRuleSet) PostStart() error {
+	return nil
+}
+
 func (s *LocalRuleSet) Metadata() adapter.RuleSetMetadata {
 	return s.metadata
 }
 
-func (s *LocalRuleSet) Close() error {
+func (s *LocalRuleSet) ExtractIPSet() []*netipx.IPSet {
+	return common.FlatMap(s.rules, extractIPSetFromRule)
+}
+
+func (s *LocalRuleSet) IncRef() {
+	s.refs.Add(1)
+}
+
+func (s *LocalRuleSet) DecRef() {
+	if s.refs.Add(-1) < 0 {
+		panic("rule-set: negative refs")
+	}
+}
+
+func (s *LocalRuleSet) Cleanup() {
+	if s.refs.Load() == 0 {
+		s.rules = nil
+	}
+}
+
+func (s *LocalRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] {
 	return nil
 }
+
+func (s *LocalRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) {
+}
+
+func (s *LocalRuleSet) Close() error {
+	s.rules = nil
+	return nil
+}
+
+func (s *LocalRuleSet) Match(metadata *adapter.InboundContext) bool {
+	for _, rule := range s.rules {
+		if rule.Match(metadata) {
+			return true
+		}
+	}
+	return false
+}
diff --git a/route/rule_set_remote.go b/route/rule_set_remote.go
index 8389c2f4..bf0cfe20 100644
--- a/route/rule_set_remote.go
+++ b/route/rule_set_remote.go
@@ -8,20 +8,26 @@ import (
 	"net/http"
 	"runtime"
 	"strings"
+	"sync"
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/srs"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/atomic"
 	E "github.com/sagernet/sing/common/exceptions"
 	F "github.com/sagernet/sing/common/format"
 	"github.com/sagernet/sing/common/json"
 	"github.com/sagernet/sing/common/logger"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
+	"github.com/sagernet/sing/common/x/list"
 	"github.com/sagernet/sing/service"
 	"github.com/sagernet/sing/service/pause"
+
+	"go4.org/netipx"
 )
 
 var _ adapter.RuleSet = (*RemoteRuleSet)(nil)
@@ -40,6 +46,9 @@ type RemoteRuleSet struct {
 	lastEtag       string
 	updateTicker   *time.Ticker
 	pauseManager   pause.Manager
+	callbackAccess sync.Mutex
+	callbacks      list.List[adapter.RuleSetUpdateCallback]
+	refs           atomic.Int32
 }
 
 func NewRemoteRuleSet(ctx context.Context, router adapter.Router, logger logger.ContextLogger, options option.RuleSet) *RemoteRuleSet {
@@ -61,13 +70,8 @@ func NewRemoteRuleSet(ctx context.Context, router adapter.Router, logger logger.
 	}
 }
 
-func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool {
-	for _, rule := range s.rules {
-		if rule.Match(metadata) {
-			return true
-		}
-	}
-	return false
+func (s *RemoteRuleSet) Name() string {
+	return s.options.Tag
 }
 
 func (s *RemoteRuleSet) String() string {
@@ -108,6 +112,10 @@ func (s *RemoteRuleSet) StartContext(ctx context.Context, startContext adapter.R
 		}
 	}
 	s.updateTicker = time.NewTicker(s.updateInterval)
+	return nil
+}
+
+func (s *RemoteRuleSet) PostStart() error {
 	go s.loopUpdate()
 	return nil
 }
@@ -116,6 +124,38 @@ func (s *RemoteRuleSet) Metadata() adapter.RuleSetMetadata {
 	return s.metadata
 }
 
+func (s *RemoteRuleSet) ExtractIPSet() []*netipx.IPSet {
+	return common.FlatMap(s.rules, extractIPSetFromRule)
+}
+
+func (s *RemoteRuleSet) IncRef() {
+	s.refs.Add(1)
+}
+
+func (s *RemoteRuleSet) DecRef() {
+	if s.refs.Add(-1) < 0 {
+		panic("rule-set: negative refs")
+	}
+}
+
+func (s *RemoteRuleSet) Cleanup() {
+	if s.refs.Load() == 0 {
+		s.rules = nil
+	}
+}
+
+func (s *RemoteRuleSet) RegisterCallback(callback adapter.RuleSetUpdateCallback) *list.Element[adapter.RuleSetUpdateCallback] {
+	s.callbackAccess.Lock()
+	defer s.callbackAccess.Unlock()
+	return s.callbacks.PushBack(callback)
+}
+
+func (s *RemoteRuleSet) UnregisterCallback(element *list.Element[adapter.RuleSetUpdateCallback]) {
+	s.callbackAccess.Lock()
+	defer s.callbackAccess.Unlock()
+	s.callbacks.Remove(element)
+}
+
 func (s *RemoteRuleSet) loadBytes(content []byte) error {
 	var (
 		plainRuleSet option.PlainRuleSet
@@ -148,6 +188,12 @@ func (s *RemoteRuleSet) loadBytes(content []byte) error {
 	s.metadata.ContainsWIFIRule = hasHeadlessRule(plainRuleSet.Rules, isWIFIHeadlessRule)
 	s.metadata.ContainsIPCIDRRule = hasHeadlessRule(plainRuleSet.Rules, isIPCIDRHeadlessRule)
 	s.rules = rules
+	s.callbackAccess.Lock()
+	callbacks := s.callbacks.Array()
+	s.callbackAccess.Unlock()
+	for _, callback := range callbacks {
+		callback(s)
+	}
 	return nil
 }
 
@@ -156,6 +202,8 @@ func (s *RemoteRuleSet) loopUpdate() {
 		err := s.fetchOnce(s.ctx, nil)
 		if err != nil {
 			s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
+		} else if s.refs.Load() == 0 {
+			s.rules = nil
 		}
 	}
 	for {
@@ -168,6 +216,8 @@ func (s *RemoteRuleSet) loopUpdate() {
 			err := s.fetchOnce(s.ctx, nil)
 			if err != nil {
 				s.logger.Error("fetch rule-set ", s.options.Tag, ": ", err)
+			} else if s.refs.Load() == 0 {
+				s.rules = nil
 			}
 		}
 	}
@@ -253,7 +303,17 @@ func (s *RemoteRuleSet) fetchOnce(ctx context.Context, startContext adapter.Rule
 }
 
 func (s *RemoteRuleSet) Close() error {
+	s.rules = nil
 	s.updateTicker.Stop()
 	s.cancel()
 	return nil
 }
+
+func (s *RemoteRuleSet) Match(metadata *adapter.InboundContext) bool {
+	for _, rule := range s.rules {
+		if rule.Match(metadata) {
+			return true
+		}
+	}
+	return false
+}