Browse Source

Add pre-match support for auto redirect

世界 2 weeks ago
parent
commit
b15fecf992

+ 1 - 0
constant/rule.go

@@ -30,6 +30,7 @@ const (
 	RuleActionTypeRoute        = "route"
 	RuleActionTypeRouteOptions = "route-options"
 	RuleActionTypeDirect       = "direct"
+	RuleActionTypeBypass       = "bypass"
 	RuleActionTypeReject       = "reject"
 	RuleActionTypeHijackDNS    = "hijack-dns"
 	RuleActionTypeSniff        = "sniff"

+ 20 - 0
docs/configuration/inbound/tun.md

@@ -4,6 +4,8 @@ icon: material/new-box
 
 !!! quote "Changes in sing-box 1.13.0"
 
+    :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark)
+    :material-plus: [auto_redirect_nfqueue](#auto_redirect_nfqueue)
     :material-plus: [exclude_mptcp](#exclude_mptcp)
 
 !!! quote "Changes in sing-box 1.12.0"
@@ -67,6 +69,8 @@ icon: material/new-box
   "auto_redirect": true,
   "auto_redirect_input_mark": "0x2023",
   "auto_redirect_output_mark": "0x2024",
+  "auto_redirect_reset_mark": "0x2025",
+  "auto_redirect_nfqueue": 100,
   "exclude_mptcp": false,
   "loopback_address": [
     "10.7.0.1"
@@ -283,6 +287,22 @@ Connection output mark used by `auto_redirect`.
 
 `0x2024` is used by default.
 
+#### auto_redirect_reset_mark
+
+!!! question "Since sing-box 1.13.0"
+
+Connection reset mark used by `auto_redirect` pre-matching.
+
+`0x2025` is used by default.
+
+#### auto_redirect_nfqueue
+
+!!! question "Since sing-box 1.13.0"
+
+NFQueue number used by `auto_redirect` pre-matching.
+
+`100` is used by default.
+
 #### exclude_mptcp
 
 !!! question "Since sing-box 1.13.0"

+ 20 - 0
docs/configuration/inbound/tun.zh.md

@@ -4,6 +4,8 @@ icon: material/new-box
 
 !!! quote "sing-box 1.13.0 中的更改"
 
+    :material-plus: [auto_redirect_reset_mark](#auto_redirect_reset_mark)
+    :material-plus: [auto_redirect_nfqueue](#auto_redirect_nfqueue)
     :material-plus: [exclude_mptcp](#exclude_mptcp)
 
 !!! quote "sing-box 1.12.0 中的更改"
@@ -67,6 +69,8 @@ icon: material/new-box
   "auto_redirect": true,
   "auto_redirect_input_mark": "0x2023",
   "auto_redirect_output_mark": "0x2024",
+  "auto_redirect_reset_mark": "0x2025",
+  "auto_redirect_nfqueue": 100,
   "exclude_mptcp": false,
   "loopback_address": [
     "10.7.0.1"
@@ -282,6 +286,22 @@ tun 接口的 IPv6 前缀。
 
 默认使用 `0x2024`。
 
+#### auto_redirect_reset_mark
+
+!!! question "自 sing-box 1.13.0 起"
+
+`auto_redirect` 预匹配使用的连接重置标记。
+
+默认使用 `0x2025`。
+
+#### auto_redirect_nfqueue
+
+!!! question "自 sing-box 1.13.0 起"
+
+`auto_redirect` 预匹配使用的 NFQueue 编号。
+
+默认使用 `100`。
+
 #### exclude_mptcp
 
 !!! question "自 sing-box 1.13.0 起"

+ 35 - 0
docs/configuration/route/rule_action.md

@@ -4,6 +4,7 @@ icon: material/new-box
 
 !!! quote "Changes in sing-box 1.13.0"
 
+    :material-plus: [bypass](#bypass)
     :material-alert: [reject](#reject)
 
 !!! quote "Changes in sing-box 1.12.0"
@@ -44,6 +45,40 @@ Tag of target outbound.
 
 See `route-options` fields below.
 
+### bypass
+
+!!! question "Since sing-box 1.13.0"
+
+!!! quote ""
+
+    Only supported on Linux with `auto_redirect` enabled.
+
+```json
+{
+  "action": "bypass",
+  "outbound": "",
+
+  ... // route-options Fields
+}
+```
+
+`bypass` routes connection to the specified outbound.
+
+For tun connections in [pre-match](/configuration/shared/pre-match/),
+the connection will bypass sing-box and connect directly at the kernel level.
+
+For non-tun connections and already established connections, the behavior is the same as `route`.
+
+#### outbound
+
+==Required==
+
+Tag of target outbound.
+
+#### route-options Fields
+
+See `route-options` fields below.
+
 ### reject
 
 !!! quote "Changes in sing-box 1.13.0"

+ 34 - 0
docs/configuration/route/rule_action.zh.md

@@ -4,6 +4,7 @@ icon: material/new-box
 
 !!! quote "sing-box 1.13.0 中的更改"
 
+    :material-plus: [bypass](#bypass)
     :material-alert: [reject](#reject)
 
 !!! quote "sing-box 1.12.0 中的更改"
@@ -40,6 +41,39 @@ icon: material/new-box
 
 参阅下方的 `route-options` 字段。
 
+### bypass
+
+!!! question "自 sing-box 1.13.0 起"
+
+!!! quote ""
+
+    仅支持 Linux,且需要启用 `auto_redirect`。
+
+```json
+{
+  "action": "bypass",
+  "outbound": "",
+
+  ... // route-options 字段
+}
+```
+
+`bypass` 将连接路由到指定出站。
+
+对于[预匹配](/configuration/shared/pre-match/)中的 tun 连接,连接将在内核层面绕过 sing-box 直接连接。
+
+对于非 tun 连接和已建立的连接,行为与 `route` 相同。
+
+#### outbound
+
+==必填==
+
+目标出站的标签。
+
+#### route-options 字段
+
+参阅下方的 `route-options` 字段。
+
 ### reject
 
 !!! quote "sing-box 1.13.0 中的更改"

+ 39 - 0
docs/configuration/shared/pre-match.md

@@ -0,0 +1,39 @@
+---
+icon: material/new-box
+---
+
+# Pre-match
+
+!!! quote "Changes in sing-box 1.13.0"
+
+    :material-plus: [bypass](#bypass)
+
+Pre-match is rule matching that runs before the connection is established.
+
+### How it works
+
+When TUN receives a connection request, the connection has not yet been established,
+so no connection data can be read. In this phase, sing-box runs the routing rules in pre-match mode.
+
+Since connection data is unavailable, only actions that do not require connection data can be executed.
+When a rule matches an action that requires an established connection, pre-match stops at that rule.
+
+### Supported actions
+
+#### reject
+
+Reject with TCP RST / ICMP unreachable.
+
+#### route
+
+Route ICMP connections to the specified outbound for direct reply.
+
+#### bypass
+
+!!! question "Since sing-box 1.13.0"
+
+!!! quote ""
+
+    Only supported on Linux with `auto_redirect` enabled.
+
+Bypass sing-box and connect directly at kernel level.

+ 37 - 0
docs/configuration/shared/pre-match.zh.md

@@ -0,0 +1,37 @@
+---
+icon: material/new-box
+---
+
+# 预匹配
+
+!!! quote "sing-box 1.13.0 中的更改"
+
+    :material-plus: [bypass](#bypass)
+
+预匹配是在连接建立之前运行的规则匹配。
+
+### 工作原理
+
+当 TUN 收到连接请求时,连接尚未建立,因此无法读取连接数据。在此阶段,sing-box 在预匹配模式下运行路由规则。
+
+由于连接数据不可用,只有不需要连接数据的动作才能执行。当规则匹配到需要已建立连接的动作时,预匹配将在该规则处停止。
+
+### 支持的动作
+
+#### reject
+
+以 TCP RST / ICMP 不可达拒绝。
+
+#### route
+
+将 ICMP 连接路由到指定出站以直接回复。
+
+#### bypass
+
+!!! question "自 sing-box 1.13.0 起"
+
+!!! quote ""
+
+    仅支持 Linux,且需要启用 `auto_redirect`。
+
+在内核层面绕过 sing-box 直接连接。

+ 2 - 1
go.mod

@@ -38,7 +38,7 @@ require (
 	github.com/sagernet/sing-shadowsocks v0.2.8
 	github.com/sagernet/sing-shadowsocks2 v0.2.1
 	github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11
-	github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251201004738-e9e3fbf0c15e
+	github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251226064455-a850c4f8a1c8
 	github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1
 	github.com/sagernet/smux v1.5.34-mod.2
 	github.com/sagernet/tailscale v1.92.4-sing-box-1.13-mod.3.0.20251225080651-3b25379a5bf8
@@ -73,6 +73,7 @@ require (
 	github.com/dblohm7/wingoes v0.0.0-20240119213807-a09d6be7affa // indirect
 	github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 // indirect
 	github.com/ebitengine/purego v0.9.1 // indirect
+	github.com/florianl/go-nfqueue/v2 v2.0.2 // indirect
 	github.com/fsnotify/fsnotify v1.7.0 // indirect
 	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
 	github.com/gaissmai/bart v0.18.0 // indirect

+ 4 - 2
go.sum

@@ -39,6 +39,8 @@ github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1 h1:CaO/zOnF8VvUfEbhRatPcwKVWamvbY
 github.com/dgrijalva/jwt-go/v4 v4.0.0-preview1/go.mod h1:+hnT3ywWDTAFrW5aE+u2Sa/wT555ZqwoCS+pk3p6ry4=
 github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
 github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+github.com/florianl/go-nfqueue/v2 v2.0.2 h1:FL5lQTeetgpCvac1TRwSfgaXUn0YSO7WzGvWNIp3JPE=
+github.com/florianl/go-nfqueue/v2 v2.0.2/go.mod h1:VA09+iPOT43OMoCKNfXHyzujQUty2xmzyCRkBOlmabc=
 github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
 github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
 github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -216,8 +218,8 @@ github.com/sagernet/sing-shadowsocks2 v0.2.1 h1:dWV9OXCeFPuYGHb6IRqlSptVnSzOelnq
 github.com/sagernet/sing-shadowsocks2 v0.2.1/go.mod h1:RnXS0lExcDAovvDeniJ4IKa2IuChrdipolPYWBv9hWQ=
 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11 h1:tK+75l64tm9WvEFrYRE1t0YxoFdWQqw/h7Uhzj0vJ+w=
 github.com/sagernet/sing-shadowtls v0.2.1-0.20250503051639-fcd445d33c11/go.mod h1:sWqKnGlMipCHaGsw1sTTlimyUpgzP4WP3pjhCsYt9oA=
-github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251201004738-e9e3fbf0c15e h1:ZEv+9vy7vC1vbr3LfwZGx3JAOkl/w4+hnGamHw4W36M=
-github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251201004738-e9e3fbf0c15e/go.mod h1:eWETzl4AwaxGKiZTpDIDVJLTBz9cfIdoZwaZY1jlSjg=
+github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251226064455-a850c4f8a1c8 h1:aIgk6YzS/7fNm92CycFWzithdwIc+NAwXGHAJce1dyM=
+github.com/sagernet/sing-tun v0.8.0-beta.11.0.20251226064455-a850c4f8a1c8/go.mod h1:+HAK/y9GZljdT0KYKMYDR8MjjqnqDDQZYp5ZZQoRzS8=
 github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1 h1:aSwUNYUkVyVvdmBSufR8/nRFonwJeKSIROxHcm5br9o=
 github.com/sagernet/sing-vmess v0.2.8-0.20250909125414-3aed155119a1/go.mod h1:P11scgTxMxVVQ8dlM27yNm3Cro40mD0+gHbnqrNGDuY=
 github.com/sagernet/smux v1.5.34-mod.2 h1:gkmBjIjlJ2zQKpLigOkFur5kBKdV6bNRoFu2WkltRQ4=

+ 1 - 0
mkdocs.yml

@@ -122,6 +122,7 @@ nav:
           - Dial Fields: configuration/shared/dial.md
           - TLS: configuration/shared/tls.md
           - DNS01 Challenge Fields: configuration/shared/dns01_challenge.md
+          - Pre-match: configuration/shared/pre-match.md
           - Multiplex: configuration/shared/multiplex.md
           - V2Ray Transport: configuration/shared/v2ray-transport.md
           - UDP over TCP: configuration/shared/udp-over-tcp.md

+ 13 - 1
option/rule_action.go

@@ -18,6 +18,7 @@ type _RuleAction struct {
 	RouteOptions        RouteActionOptions        `json:"-"`
 	RouteOptionsOptions RouteOptionsActionOptions `json:"-"`
 	DirectOptions       DirectActionOptions       `json:"-"`
+	BypassOptions       RouteActionOptions        `json:"-"`
 	RejectOptions       RejectActionOptions       `json:"-"`
 	SniffOptions        RouteActionSniff          `json:"-"`
 	ResolveOptions      RouteActionResolve        `json:"-"`
@@ -38,6 +39,8 @@ func (r RuleAction) MarshalJSON() ([]byte, error) {
 		v = r.RouteOptionsOptions
 	case C.RuleActionTypeDirect:
 		v = r.DirectOptions
+	case C.RuleActionTypeBypass:
+		v = r.BypassOptions
 	case C.RuleActionTypeReject:
 		v = r.RejectOptions
 	case C.RuleActionTypeHijackDNS:
@@ -69,6 +72,8 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error {
 		v = &r.RouteOptionsOptions
 	case C.RuleActionTypeDirect:
 		v = &r.DirectOptions
+	case C.RuleActionTypeBypass:
+		v = &r.BypassOptions
 	case C.RuleActionTypeReject:
 		v = &r.RejectOptions
 	case C.RuleActionTypeHijackDNS:
@@ -84,7 +89,14 @@ func (r *RuleAction) UnmarshalJSON(data []byte) error {
 		// check unknown fields
 		return json.UnmarshalDisallowUnknownFields(data, &_RuleAction{})
 	}
-	return badjson.UnmarshallExcluded(data, (*_RuleAction)(r), v)
+	err = badjson.UnmarshallExcluded(data, (*_RuleAction)(r), v)
+	if err != nil {
+		return err
+	}
+	if r.Action == C.RuleActionTypeBypass && r.BypassOptions.Outbound == "" {
+		return E.New("missing outbound for bypass action")
+	}
+	return nil
 }
 
 type _DNSRuleAction struct {

+ 2 - 0
option/tun.go

@@ -20,6 +20,8 @@ type TunInboundOptions struct {
 	AutoRedirect           bool                             `json:"auto_redirect,omitempty"`
 	AutoRedirectInputMark  FwMark                           `json:"auto_redirect_input_mark,omitempty"`
 	AutoRedirectOutputMark FwMark                           `json:"auto_redirect_output_mark,omitempty"`
+	AutoRedirectResetMark  FwMark                           `json:"auto_redirect_reset_mark,omitempty"`
+	AutoRedirectNFQueue    uint16                           `json:"auto_redirect_nfqueue,omitempty"`
 	ExcludeMPTCP           bool                             `json:"exclude_mptcp,omitempty"`
 	LoopbackAddress        badoption.Listable[netip.Addr]   `json:"loopback_address,omitempty"`
 	StrictRoute            bool                             `json:"strict_route,omitempty"`

+ 9 - 2
protocol/tailscale/endpoint.go

@@ -481,8 +481,15 @@ func (t *Endpoint) PrepareConnection(network string, source M.Socksaddr, destina
 		Destination: destination,
 	}, routeContext, timeout)
 	if err != nil {
-		if !rule.IsRejected(err) {
-			t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+		switch {
+		case rule.IsBypassed(err):
+			err = nil
+		case rule.IsRejected(err):
+			t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
+		default:
+			if network == N.NetworkICMP {
+				t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+			}
 		}
 	}
 	return routeDestination, err

+ 54 - 2
protocol/tun/inbound.go

@@ -182,6 +182,14 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
 	if outputMark == 0 {
 		outputMark = tun.DefaultAutoRedirectOutputMark
 	}
+	resetMark := uint32(options.AutoRedirectResetMark)
+	if resetMark == 0 {
+		resetMark = tun.DefaultAutoRedirectResetMark
+	}
+	nfQueue := options.AutoRedirectNFQueue
+	if nfQueue == 0 {
+		nfQueue = tun.DefaultAutoRedirectNFQueue
+	}
 	networkManager := service.FromContext[adapter.NetworkManager](ctx)
 	multiPendingPackets := C.IsDarwin && ((options.Stack == "gvisor" && tunMTU < 32768) || (options.Stack != "gvisor" && options.MTU <= 9000))
 	inbound := &Inbound{
@@ -202,6 +210,8 @@ func NewInbound(ctx context.Context, router adapter.Router, logger log.ContextLo
 			IPRoute2RuleIndex:        ruleIndex,
 			AutoRedirectInputMark:    inputMark,
 			AutoRedirectOutputMark:   outputMark,
+			AutoRedirectResetMark:    resetMark,
+			AutoRedirectNFQueue:      nfQueue,
 			ExcludeMPTCP:             options.ExcludeMPTCP,
 			Inet4LoopbackAddress:     common.Filter(options.LoopbackAddress, netip.Addr.Is4),
 			Inet6LoopbackAddress:     common.Filter(options.LoopbackAddress, netip.Addr.Is6),
@@ -472,8 +482,15 @@ func (t *Inbound) PrepareConnection(network string, source M.Socksaddr, destinat
 		InboundOptions: t.inboundOptions,
 	}, routeContext, timeout)
 	if err != nil {
-		if !rule.IsRejected(err) {
-			t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+		switch {
+		case rule.IsBypassed(err):
+			err = nil
+		case rule.IsRejected(err):
+			t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
+		default:
+			if network == N.NetworkICMP {
+				t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+			}
 		}
 	}
 	return routeDestination, err
@@ -509,6 +526,37 @@ func (t *Inbound) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn,
 
 type autoRedirectHandler Inbound
 
+func (t *autoRedirectHandler) PrepareConnection(network string, source M.Socksaddr, destination M.Socksaddr, routeContext tun.DirectRouteContext, timeout time.Duration) (tun.DirectRouteDestination, error) {
+	var ipVersion uint8
+	if !destination.IsIPv6() {
+		ipVersion = 4
+	} else {
+		ipVersion = 6
+	}
+	routeDestination, err := t.router.PreMatch(adapter.InboundContext{
+		Inbound:        t.tag,
+		InboundType:    C.TypeTun,
+		IPVersion:      ipVersion,
+		Network:        network,
+		Source:         source,
+		Destination:    destination,
+		InboundOptions: t.inboundOptions,
+	}, routeContext, timeout)
+	if err != nil {
+		switch {
+		case rule.IsBypassed(err):
+			t.logger.Trace("bypass ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
+		case rule.IsRejected(err):
+			t.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
+		default:
+			if network == N.NetworkICMP {
+				t.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+			}
+		}
+	}
+	return routeDestination, err
+}
+
 func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
 	ctx = log.ContextWithNewID(ctx)
 	var metadata adapter.InboundContext
@@ -522,3 +570,7 @@ func (t *autoRedirectHandler) NewConnectionEx(ctx context.Context, conn net.Conn
 	t.logger.InfoContext(ctx, "inbound connection to ", metadata.Destination)
 	t.router.RouteConnectionEx(ctx, conn, metadata, onClose)
 }
+
+func (t *autoRedirectHandler) NewPacketConnectionEx(ctx context.Context, conn N.PacketConn, source M.Socksaddr, destination M.Socksaddr, onClose N.CloseHandlerFunc) {
+	panic("unexcepted")
+}

+ 9 - 2
protocol/wireguard/endpoint.go

@@ -142,8 +142,15 @@ func (w *Endpoint) PrepareConnection(network string, source M.Socksaddr, destina
 		Destination: destination,
 	}, routeContext, timeout)
 	if err != nil {
-		if !rule.IsRejected(err) {
-			w.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+		switch {
+		case rule.IsBypassed(err):
+			err = nil
+		case rule.IsRejected(err):
+			w.logger.Trace("reject ", network, " connection from ", source.AddrString(), " to ", destination.AddrString())
+		default:
+			if network == N.NetworkICMP {
+				w.logger.Warn(E.Cause(err, "link ", network, " connection from ", source.AddrString(), " to ", destination.AddrString()))
+			}
 		}
 	}
 	return routeDestination, err

+ 26 - 1
route/route.go

@@ -113,6 +113,17 @@ func (r *Router) routeConnection(ctx context.Context, conn net.Conn, metadata ad
 				buf.ReleaseMulti(buffers)
 				return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag())
 			}
+		case *R.RuleActionBypass:
+			var loaded bool
+			selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
+			if !loaded {
+				buf.ReleaseMulti(buffers)
+				return E.New("outbound not found: ", action.Outbound)
+			}
+			if !common.Contains(selectedOutbound.Network(), N.NetworkTCP) {
+				buf.ReleaseMulti(buffers)
+				return E.New("TCP is not supported by outbound: ", selectedOutbound.Tag())
+			}
 		case *R.RuleActionReject:
 			buf.ReleaseMulti(buffers)
 			if action.Method == C.RuleActionRejectMethodReply {
@@ -231,6 +242,17 @@ func (r *Router) routePacketConnection(ctx context.Context, conn N.PacketConn, m
 				N.ReleaseMultiPacketBuffer(packetBuffers)
 				return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag())
 			}
+		case *R.RuleActionBypass:
+			var loaded bool
+			selectedOutbound, loaded = r.outbound.Outbound(action.Outbound)
+			if !loaded {
+				N.ReleaseMultiPacketBuffer(packetBuffers)
+				return E.New("outbound not found: ", action.Outbound)
+			}
+			if !common.Contains(selectedOutbound.Network(), N.NetworkUDP) {
+				N.ReleaseMultiPacketBuffer(packetBuffers)
+				return E.New("UDP is not supported by outbound: ", selectedOutbound.Tag())
+			}
 		case *R.RuleActionReject:
 			N.ReleaseMultiPacketBuffer(packetBuffers)
 			if action.Method == C.RuleActionRejectMethodReply {
@@ -287,6 +309,8 @@ func (r *Router) PreMatch(metadata adapter.InboundContext, routeContext tun.Dire
 				}
 			}
 			return nil, action.Error(context.Background())
+		case *R.RuleActionBypass:
+			return nil, &R.BypassedError{Cause: tun.ErrBypass}
 		case *R.RuleActionRoute:
 			if routeContext == nil {
 				return nil, nil
@@ -567,7 +591,8 @@ match:
 		actionType := currentRule.Action().Type()
 		if actionType == C.RuleActionTypeRoute ||
 			actionType == C.RuleActionTypeReject ||
-			actionType == C.RuleActionTypeHijackDNS {
+			actionType == C.RuleActionTypeHijackDNS ||
+			actionType == C.RuleActionTypeBypass {
 			selectedRule = currentRule
 			selectedRuleIndex = currentRuleIndex
 			break match

+ 48 - 0
route/rule/rule_action.go

@@ -56,6 +56,21 @@ func NewRuleAction(ctx context.Context, logger logger.ContextLogger, action opti
 			TLSFragmentFallbackDelay:  time.Duration(action.RouteOptionsOptions.TLSFragmentFallbackDelay),
 			TLSRecordFragment:         action.RouteOptionsOptions.TLSRecordFragment,
 		}, nil
+	case C.RuleActionTypeBypass:
+		return &RuleActionBypass{
+			Outbound: action.BypassOptions.Outbound,
+			RuleActionRouteOptions: RuleActionRouteOptions{
+				OverrideAddress:           M.ParseSocksaddrHostPort(action.BypassOptions.OverrideAddress, 0),
+				OverridePort:              action.BypassOptions.OverridePort,
+				NetworkStrategy:           (*C.NetworkStrategy)(action.BypassOptions.NetworkStrategy),
+				FallbackDelay:             time.Duration(action.BypassOptions.FallbackDelay),
+				UDPDisableDomainUnmapping: action.BypassOptions.UDPDisableDomainUnmapping,
+				UDPConnect:                action.BypassOptions.UDPConnect,
+				TLSFragment:               action.BypassOptions.TLSFragment,
+				TLSFragmentFallbackDelay:  time.Duration(action.BypassOptions.TLSFragmentFallbackDelay),
+				TLSRecordFragment:         action.BypassOptions.TLSRecordFragment,
+			},
+		}, nil
 	case C.RuleActionTypeDirect:
 		directDialer, err := dialer.New(ctx, option.DialerOptions(action.DirectOptions), false)
 		if err != nil {
@@ -158,6 +173,22 @@ func (r *RuleActionRoute) String() string {
 	return F.ToString("route(", strings.Join(descriptions, ","), ")")
 }
 
+type RuleActionBypass struct {
+	Outbound string
+	RuleActionRouteOptions
+}
+
+func (r *RuleActionBypass) Type() string {
+	return C.RuleActionTypeBypass
+}
+
+func (r *RuleActionBypass) String() string {
+	var descriptions []string
+	descriptions = append(descriptions, r.Outbound)
+	descriptions = append(descriptions, r.Descriptions()...)
+	return F.ToString("bypass(", strings.Join(descriptions, ","), ")")
+}
+
 type RuleActionRouteOptions struct {
 	OverrideAddress           M.Socksaddr
 	OverridePort              uint16
@@ -301,6 +332,23 @@ func IsRejected(err error) bool {
 	return errors.As(err, &rejected)
 }
 
+type BypassedError struct {
+	Cause error
+}
+
+func (b *BypassedError) Error() string {
+	return "bypassed"
+}
+
+func (b *BypassedError) Unwrap() error {
+	return b.Cause
+}
+
+func IsBypassed(err error) bool {
+	var bypassed *BypassedError
+	return errors.As(err, &bypassed)
+}
+
 type RuleActionReject struct {
 	Method      string
 	NoDrop      bool