Browse Source

Add back urltest outbound

世界 3 years ago
parent
commit
a5402ffb69

+ 2 - 0
adapter/experimental.go

@@ -4,6 +4,7 @@ import (
 	"context"
 	"context"
 	"net"
 	"net"
 
 
+	"github.com/sagernet/sing-box/common/urltest"
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
 )
 )
 
 
@@ -12,6 +13,7 @@ type ClashServer interface {
 	Mode() string
 	Mode() string
 	StoreSelected() bool
 	StoreSelected() bool
 	CacheFile() ClashCacheFile
 	CacheFile() ClashCacheFile
+	HistoryStorage() *urltest.HistoryStorage
 	RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule) (net.Conn, Tracker)
 	RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule) (net.Conn, Tracker)
 	RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule) (N.PacketConn, Tracker)
 	RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule) (N.PacketConn, Tracker)
 }
 }

+ 1 - 0
constant/proxy.go

@@ -25,4 +25,5 @@ const (
 
 
 const (
 const (
 	TypeSelector = "selector"
 	TypeSelector = "selector"
+	TypeURLTest  = "urltest"
 )
 )

+ 7 - 6
constant/timeout.go

@@ -3,10 +3,11 @@ package constant
 import "time"
 import "time"
 
 
 const (
 const (
-	TCPTimeout         = 5 * time.Second
-	ReadPayloadTimeout = 300 * time.Millisecond
-	DNSTimeout         = 10 * time.Second
-	QUICTimeout        = 30 * time.Second
-	STUNTimeout        = 15 * time.Second
-	UDPTimeout         = 5 * time.Minute
+	TCPTimeout             = 5 * time.Second
+	ReadPayloadTimeout     = 300 * time.Millisecond
+	DNSTimeout             = 10 * time.Second
+	QUICTimeout            = 30 * time.Second
+	STUNTimeout            = 15 * time.Second
+	UDPTimeout             = 5 * time.Minute
+	DefaultURLTestInterval = 1 * time.Minute
 )
 )

+ 18 - 15
docs/configuration/outbound/index.md

@@ -15,21 +15,24 @@
 
 
 ### Fields
 ### Fields
 
 
-| Type          | Format                       |
-|---------------|------------------------------|
-| `direct`      | [Direct](./direct)           |
-| `block`       | [Block](./block)             |
-| `socks`       | [SOCKS](./socks)             |
-| `http`        | [HTTP](./http)               |
-| `shadowsocks` | [Shadowsocks](./shadowsocks) |
-| `vmess`       | [VMess](./vmess)             |
-| `trojan`      | [Trojan](./trojan)           |
-| `wireguard`   | [Wireguard](./wireguard)     |
-| `hysteria`    | [Hysteria](./hysteria)       |
-| `tor`         | [Tor](./tor)                 |
-| `ssh`         | [SSH](./ssh)                 |
-| `dns`         | [DNS](./dns)                 |
-| `selector`    | [Selector](./selector)       |
+| Type           | Format                         |
+|----------------|--------------------------------|
+| `direct`       | [Direct](./direct)             |
+| `block`        | [Block](./block)               |
+| `socks`        | [SOCKS](./socks)               |
+| `http`         | [HTTP](./http)                 |
+| `shadowsocks`  | [Shadowsocks](./shadowsocks)   |
+| `vmess`        | [VMess](./vmess)               |
+| `trojan`       | [Trojan](./trojan)             |
+| `wireguard`    | [Wireguard](./wireguard)       |
+| `hysteria`     | [Hysteria](./hysteria)         |
+| `shadowsocksr` | [ShadowsocksR](./shadowsocksr) |
+| `vless`        | [VLESS](./vless)               |
+| `tor`          | [Tor](./tor)                   |
+| `ssh`          | [SSH](./ssh)                   |
+| `dns`          | [DNS](./dns)                   |
+| `selector`     | [Selector](./selector)         |
+| `urltest`      | [URLTest](./urltest)           |
 
 
 #### tag
 #### tag
 
 

+ 18 - 15
docs/configuration/outbound/index.zh.md

@@ -15,21 +15,24 @@
 
 
 ### 字段
 ### 字段
 
 
-| 类型            | 格式                           |
-|---------------|------------------------------|
-| `direct`      | [Direct](./direct)           |
-| `block`       | [Block](./block)             |
-| `socks`       | [SOCKS](./socks)             |
-| `http`        | [HTTP](./http)               |
-| `shadowsocks` | [Shadowsocks](./shadowsocks) |
-| `vmess`       | [VMess](./vmess)             |
-| `trojan`      | [Trojan](./trojan)           |
-| `wireguard`   | [Wireguard](./wireguard)     |
-| `hysteria`    | [Hysteria](./hysteria)       |
-| `tor`         | [Tor](./tor)                 |
-| `ssh`         | [SSH](./ssh)                 |
-| `dns`         | [DNS](./dns)                 |
-| `selector`    | [Selector](./selector)       |
+| 类型             | 格式                             |
+|----------------|--------------------------------|
+| `direct`       | [Direct](./direct)             |
+| `block`        | [Block](./block)               |
+| `socks`        | [SOCKS](./socks)               |
+| `http`         | [HTTP](./http)                 |
+| `shadowsocks`  | [Shadowsocks](./shadowsocks)   |
+| `vmess`        | [VMess](./vmess)               |
+| `trojan`       | [Trojan](./trojan)             |
+| `wireguard`    | [Wireguard](./wireguard)       |
+| `hysteria`     | [Hysteria](./hysteria)         |
+| `shadowsocksr` | [ShadowsocksR](./shadowsocksr) |
+| `vless`        | [VLESS](./vless)               |
+| `tor`          | [Tor](./tor)                   |
+| `ssh`          | [SSH](./ssh)                   |
+| `dns`          | [DNS](./dns)                   |
+| `selector`     | [Selector](./selector)         |
+| `urltest`      | [URLTest](./urltest)           |
 
 
 #### tag
 #### tag
 
 

+ 37 - 0
docs/configuration/outbound/urltest.md

@@ -0,0 +1,37 @@
+### Structure
+
+```json
+{
+  "type": "urltest",
+  "tag": "auto",
+  
+  "outbounds": [
+    "proxy-a",
+    "proxy-b",
+    "proxy-c"
+  ],
+  "url": "http://www.gstatic.com/generate_204",
+  "interval": "1m",
+  "tolerance": 50
+}
+```
+
+### Fields
+
+#### outbounds
+
+==Required==
+
+List of outbound tags to test.
+
+#### url
+
+The URL to test. `http://www.gstatic.com/generate_204` will be used if empty.
+
+#### interval
+
+The test interval. `1m` will be used if empty.
+
+#### tolerance
+
+The test tolerance in milliseconds. `50` will be used if empty.

+ 37 - 0
docs/configuration/outbound/urltest.zh.md

@@ -0,0 +1,37 @@
+### 结构
+
+```json
+{
+  "type": "urltest",
+  "tag": "auto",
+  
+  "outbounds": [
+    "proxy-a",
+    "proxy-b",
+    "proxy-c"
+  ],
+  "url": "http://www.gstatic.com/generate_204",
+  "interval": "1m",
+  "tolerance": 50
+}
+```
+
+### 字段
+
+#### outbounds
+
+==必填==
+
+用于测试的出站标签列表。
+
+#### url
+
+用于测试的链接。默认使用 `http://www.gstatic.com/generate_204`。
+
+#### interval
+
+测试间隔。 默认使用 `1m`。
+
+#### tolerance
+
+以毫秒为单位的测试容差。 默认使用 `50`。

+ 5 - 6
experimental/clashapi/proxies.go

@@ -61,7 +61,6 @@ func findProxyByName(router adapter.Router) func(next http.Handler) http.Handler
 func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 	var info badjson.JSONObject
 	var info badjson.JSONObject
 	var clashType string
 	var clashType string
-	var isGroup bool
 	switch detour.Type() {
 	switch detour.Type() {
 	case C.TypeDirect:
 	case C.TypeDirect:
 		clashType = "Direct"
 		clashType = "Direct"
@@ -91,7 +90,8 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 		clashType = "SSH"
 		clashType = "SSH"
 	case C.TypeSelector:
 	case C.TypeSelector:
 		clashType = "Selector"
 		clashType = "Selector"
-		isGroup = true
+	case C.TypeURLTest:
+		clashType = "URLTest"
 	default:
 	default:
 		clashType = "Direct"
 		clashType = "Direct"
 	}
 	}
@@ -104,10 +104,9 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 	} else {
 	} else {
 		info.Put("history", []*urltest.History{})
 		info.Put("history", []*urltest.History{})
 	}
 	}
-	if isGroup {
-		selector := detour.(adapter.OutboundGroup)
-		info.Put("now", selector.Now())
-		info.Put("all", selector.All())
+	if group, isGroup := detour.(adapter.OutboundGroup); isGroup {
+		info.Put("now", group.Now())
+		info.Put("all", group.All())
 	}
 	}
 	return &info
 	return &info
 }
 }

+ 4 - 0
experimental/clashapi/server.go

@@ -144,6 +144,10 @@ func (s *Server) CacheFile() adapter.ClashCacheFile {
 	return s.cacheFile
 	return s.cacheFile
 }
 }
 
 
+func (s *Server) HistoryStorage() *urltest.HistoryStorage {
+	return s.urlTestHistory
+}
+
 func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule) (net.Conn, adapter.Tracker) {
 func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule) (net.Conn, adapter.Tracker) {
 	tracker := trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule)
 	tracker := trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule)
 	return tracker, tracker
 	return tracker, tracker

+ 1 - 1
go.mod

@@ -26,7 +26,7 @@ require (
 	github.com/sagernet/sing v0.0.0-20220915031330-38f39bc0c690
 	github.com/sagernet/sing v0.0.0-20220915031330-38f39bc0c690
 	github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8
 	github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6
-	github.com/sagernet/sing-tun v0.0.0-20220915032336-60b1da576469
+	github.com/sagernet/sing-tun v0.0.0-20220915041614-0e80d729a3e1
 	github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12
 	github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12
 	github.com/sagernet/smux v0.0.0-20220831015742-e0f1988e3195
 	github.com/sagernet/smux v0.0.0-20220831015742-e0f1988e3195
 	github.com/sagernet/websocket v0.0.0-20220913015213-615516348b4e
 	github.com/sagernet/websocket v0.0.0-20220913015213-615516348b4e

+ 2 - 2
go.sum

@@ -151,8 +151,8 @@ github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8 h1:Iyfl+Rm5jcDvX
 github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8/go.mod h1:bPVnJ5gJ0WmUfN1bJP9Cis0ab8SSByx6JVzyLJjDMwA=
 github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8/go.mod h1:bPVnJ5gJ0WmUfN1bJP9Cis0ab8SSByx6JVzyLJjDMwA=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6 h1:JJfDeYYhWunvtxsU/mOVNTmFQmnzGx9dY034qG6G3g4=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6 h1:JJfDeYYhWunvtxsU/mOVNTmFQmnzGx9dY034qG6G3g4=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6/go.mod h1:EX3RbZvrwAkPI2nuGa78T2iQXmrkT+/VQtskjou42xM=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220819002358-7461bb09a8f6/go.mod h1:EX3RbZvrwAkPI2nuGa78T2iQXmrkT+/VQtskjou42xM=
-github.com/sagernet/sing-tun v0.0.0-20220915032336-60b1da576469 h1:tvGUJsOqxZ3ofAY9undQfQ+JCWvmIwLpIOC+XaBFO88=
-github.com/sagernet/sing-tun v0.0.0-20220915032336-60b1da576469/go.mod h1:5AhPUv9jWDQ3pv3Mj78SL/1TSjhoaj6WNASxRKLqXqM=
+github.com/sagernet/sing-tun v0.0.0-20220915041614-0e80d729a3e1 h1:QHpg9JSUeNVnit9UgwGug/P39naZgrct0fY5FiwnyuI=
+github.com/sagernet/sing-tun v0.0.0-20220915041614-0e80d729a3e1/go.mod h1:5AhPUv9jWDQ3pv3Mj78SL/1TSjhoaj6WNASxRKLqXqM=
 github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12 h1:4HYGbTDDemgBVTmaspXbkgjJlXc3hYVjNxSddJndq8Y=
 github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12 h1:4HYGbTDDemgBVTmaspXbkgjJlXc3hYVjNxSddJndq8Y=
 github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12/go.mod h1:u66Vv7NHXJWfeAmhh7JuJp/cwxmuQlM56QoZ7B7Mmd0=
 github.com/sagernet/sing-vmess v0.0.0-20220913015714-c4ab86d40e12/go.mod h1:u66Vv7NHXJWfeAmhh7JuJp/cwxmuQlM56QoZ7B7Mmd0=
 github.com/sagernet/smux v0.0.0-20220831015742-e0f1988e3195 h1:5VBIbVw9q7aKbrFdT83mjkyvQ+VaRsQ6yflTepfln38=
 github.com/sagernet/smux v0.0.0-20220831015742-e0f1988e3195 h1:5VBIbVw9q7aKbrFdT83mjkyvQ+VaRsQ6yflTepfln38=

+ 1 - 0
mkdocs.yml

@@ -90,6 +90,7 @@ nav:
           - SSH: configuration/outbound/ssh.md
           - SSH: configuration/outbound/ssh.md
           - DNS: configuration/outbound/dns.md
           - DNS: configuration/outbound/dns.md
           - Selector: configuration/outbound/selector.md
           - Selector: configuration/outbound/selector.md
+          - URLTest: configuration/outbound/urltest.md
   - FAQ:
   - FAQ:
       - faq/index.md
       - faq/index.md
       - Known Issues: faq/known-issues.md
       - Known Issues: faq/known-issues.md

+ 7 - 0
option/clash.go

@@ -14,3 +14,10 @@ type SelectorOutboundOptions struct {
 	Outbounds []string `json:"outbounds"`
 	Outbounds []string `json:"outbounds"`
 	Default   string   `json:"default,omitempty"`
 	Default   string   `json:"default,omitempty"`
 }
 }
+
+type URLTestOutboundOptions struct {
+	Outbounds []string `json:"outbounds"`
+	URL       string   `json:"url,omitempty"`
+	Interval  Duration `json:"interval,omitempty"`
+	Tolerance uint16   `json:"tolerance,omitempty"`
+}

+ 5 - 0
option/outbound.go

@@ -24,6 +24,7 @@ type _Outbound struct {
 	ShadowsocksROptions ShadowsocksROutboundOptions `json:"-"`
 	ShadowsocksROptions ShadowsocksROutboundOptions `json:"-"`
 	VLESSOptions        VLESSOutboundOptions        `json:"-"`
 	VLESSOptions        VLESSOutboundOptions        `json:"-"`
 	SelectorOptions     SelectorOutboundOptions     `json:"-"`
 	SelectorOptions     SelectorOutboundOptions     `json:"-"`
+	URLTestOptions      URLTestOutboundOptions      `json:"-"`
 }
 }
 
 
 type Outbound _Outbound
 type Outbound _Outbound
@@ -61,6 +62,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) {
 		v = h.VLESSOptions
 		v = h.VLESSOptions
 	case C.TypeSelector:
 	case C.TypeSelector:
 		v = h.SelectorOptions
 		v = h.SelectorOptions
+	case C.TypeURLTest:
+		v = h.URLTestOptions
 	default:
 	default:
 		return nil, E.New("unknown outbound type: ", h.Type)
 		return nil, E.New("unknown outbound type: ", h.Type)
 	}
 	}
@@ -104,6 +107,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error {
 		v = &h.VLESSOptions
 		v = &h.VLESSOptions
 	case C.TypeSelector:
 	case C.TypeSelector:
 		v = &h.SelectorOptions
 		v = &h.SelectorOptions
+	case C.TypeURLTest:
+		v = &h.URLTestOptions
 	default:
 	default:
 		return E.New("unknown outbound type: ", h.Type)
 		return E.New("unknown outbound type: ", h.Type)
 	}
 	}

+ 2 - 0
outbound/builder.go

@@ -47,6 +47,8 @@ func New(ctx context.Context, router adapter.Router, logger log.ContextLogger, o
 		return NewVLESS(ctx, router, logger, options.Tag, options.VLESSOptions)
 		return NewVLESS(ctx, router, logger, options.Tag, options.VLESSOptions)
 	case C.TypeSelector:
 	case C.TypeSelector:
 		return NewSelector(router, logger, options.Tag, options.SelectorOptions)
 		return NewSelector(router, logger, options.Tag, options.SelectorOptions)
+	case C.TypeURLTest:
+		return NewURLTest(router, logger, options.Tag, options.URLTestOptions)
 	default:
 	default:
 		return nil, E.New("unknown outbound type: ", options.Type)
 		return nil, E.New("unknown outbound type: ", options.Type)
 	}
 	}

+ 287 - 0
outbound/urltest.go

@@ -0,0 +1,287 @@
+package outbound
+
+import (
+	"context"
+	"net"
+	"sort"
+	"time"
+
+	"github.com/sagernet/sing-box/adapter"
+	"github.com/sagernet/sing-box/common/urltest"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing-box/log"
+	"github.com/sagernet/sing-box/option"
+	"github.com/sagernet/sing/common"
+	"github.com/sagernet/sing/common/batch"
+	E "github.com/sagernet/sing/common/exceptions"
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+var (
+	_ adapter.Outbound      = (*URLTest)(nil)
+	_ adapter.OutboundGroup = (*URLTest)(nil)
+)
+
+type URLTest struct {
+	myOutboundAdapter
+	tags      []string
+	link      string
+	interval  time.Duration
+	tolerance uint16
+	group     *URLTestGroup
+}
+
+func NewURLTest(router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (*URLTest, error) {
+	outbound := &URLTest{
+		myOutboundAdapter: myOutboundAdapter{
+			protocol: C.TypeURLTest,
+			router:   router,
+			logger:   logger,
+			tag:      tag,
+		},
+		tags:      options.Outbounds,
+		link:      options.URL,
+		interval:  time.Duration(options.Interval),
+		tolerance: options.Tolerance,
+	}
+	if len(outbound.tags) == 0 {
+		return nil, E.New("missing tags")
+	}
+	return outbound, nil
+}
+
+func (s *URLTest) Network() []string {
+	if s.group == nil {
+		return []string{N.NetworkTCP, N.NetworkUDP}
+	}
+	return s.group.Select(N.NetworkTCP).Network()
+}
+
+func (s *URLTest) Start() error {
+	outbounds := make([]adapter.Outbound, 0, len(s.tags))
+	for i, tag := range s.tags {
+		detour, loaded := s.router.Outbound(tag)
+		if !loaded {
+			return E.New("outbound ", i, " not found: ", tag)
+		}
+		outbounds = append(outbounds, detour)
+	}
+	s.group = NewURLTestGroup(s.router, s.logger, outbounds, s.link, s.interval, s.tolerance)
+	return s.group.Start()
+}
+
+func (s URLTest) Close() error {
+	return common.Close(
+		common.PtrOrNil(s.group),
+	)
+}
+
+func (s *URLTest) Now() string {
+	return s.group.Select(N.NetworkTCP).Tag()
+}
+
+func (s *URLTest) All() []string {
+	return s.tags
+}
+
+func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
+	outbound := s.group.Select(network)
+	conn, err := outbound.DialContext(ctx, network, destination)
+	if err == nil {
+		return conn, nil
+	}
+	s.logger.ErrorContext(ctx, err)
+	go s.group.checkOutbounds()
+	outbounds := s.group.Fallback(outbound)
+	for _, fallback := range outbounds {
+		conn, err = fallback.DialContext(ctx, network, destination)
+		if err == nil {
+			return conn, nil
+		}
+	}
+	return nil, err
+}
+
+func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+	outbound := s.group.Select(N.NetworkUDP)
+	conn, err := outbound.ListenPacket(ctx, destination)
+	if err == nil {
+		return conn, nil
+	}
+	s.logger.ErrorContext(ctx, err)
+	go s.group.checkOutbounds()
+	outbounds := s.group.Fallback(outbound)
+	for _, fallback := range outbounds {
+		conn, err = fallback.ListenPacket(ctx, destination)
+		if err == nil {
+			return conn, nil
+		}
+	}
+	return nil, err
+}
+
+func (s *URLTest) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
+	return NewConnection(ctx, s, conn, metadata)
+}
+
+func (s *URLTest) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
+	return NewPacketConnection(ctx, s, conn, metadata)
+}
+
+type URLTestGroup struct {
+	router    adapter.Router
+	logger    log.Logger
+	outbounds []adapter.Outbound
+	link      string
+	interval  time.Duration
+	tolerance uint16
+	history   *urltest.HistoryStorage
+
+	ticker *time.Ticker
+	close  chan struct{}
+}
+
+func NewURLTestGroup(router adapter.Router, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16) *URLTestGroup {
+	if link == "" {
+		//goland:noinspection HttpUrlsUsage
+		link = "http://www.gstatic.com/generate_204"
+	}
+	if interval == 0 {
+		interval = C.TCPTimeout
+	}
+	if tolerance == 0 {
+		tolerance = 50
+	}
+	var history *urltest.HistoryStorage
+	if clashServer := router.ClashServer(); clashServer != nil {
+		history = clashServer.HistoryStorage()
+	} else {
+		history = urltest.NewHistoryStorage()
+	}
+	return &URLTestGroup{
+		router:    router,
+		logger:    logger,
+		outbounds: outbounds,
+		link:      link,
+		interval:  interval,
+		tolerance: tolerance,
+		history:   history,
+		close:     make(chan struct{}),
+	}
+}
+
+func (g *URLTestGroup) Start() error {
+	g.ticker = time.NewTicker(g.interval)
+	go g.loopCheck()
+	return nil
+}
+
+func (g *URLTestGroup) Close() error {
+	g.ticker.Stop()
+	close(g.close)
+	return nil
+}
+
+func (g *URLTestGroup) Select(network string) adapter.Outbound {
+	var minDelay uint16
+	var minTime time.Time
+	var minOutbound adapter.Outbound
+	for _, detour := range g.outbounds {
+		if !common.Contains(detour.Network(), network) {
+			continue
+		}
+		history := g.history.LoadURLTestHistory(RealTag(detour))
+		if history == nil {
+			continue
+		}
+		if minDelay == 0 || minDelay > history.Delay+g.tolerance || minDelay > history.Delay-g.tolerance && minTime.Before(history.Time) {
+			minDelay = history.Delay
+			minTime = history.Time
+			minOutbound = detour
+		}
+	}
+	if minOutbound == nil {
+		for _, detour := range g.outbounds {
+			if !common.Contains(detour.Network(), network) {
+				continue
+			}
+			minOutbound = detour
+			break
+		}
+	}
+	return minOutbound
+}
+
+func (g *URLTestGroup) Fallback(used adapter.Outbound) []adapter.Outbound {
+	outbounds := make([]adapter.Outbound, 0, len(g.outbounds)-1)
+	for _, detour := range g.outbounds {
+		if detour != used {
+			outbounds = append(outbounds, detour)
+		}
+	}
+	sort.Slice(outbounds, func(i, j int) bool {
+		oi := outbounds[i]
+		oj := outbounds[j]
+		hi := g.history.LoadURLTestHistory(RealTag(oi))
+		if hi == nil {
+			return false
+		}
+		hj := g.history.LoadURLTestHistory(RealTag(oj))
+		if hj == nil {
+			return false
+		}
+		return hi.Delay < hj.Delay
+	})
+	return outbounds
+}
+
+func (g *URLTestGroup) loopCheck() {
+	go g.checkOutbounds()
+	for {
+		select {
+		case <-g.close:
+			return
+		case <-g.ticker.C:
+			g.checkOutbounds()
+		}
+	}
+}
+
+func (g *URLTestGroup) checkOutbounds() {
+	b, _ := batch.New(context.Background(), batch.WithConcurrencyNum[any](10))
+	checked := make(map[string]bool)
+	for _, detour := range g.outbounds {
+		tag := detour.Tag()
+		realTag := RealTag(detour)
+		if checked[realTag] {
+			continue
+		}
+		history := g.history.LoadURLTestHistory(realTag)
+		if history != nil && time.Now().Sub(history.Time) < g.interval {
+			continue
+		}
+		checked[realTag] = true
+		p, loaded := g.router.Outbound(realTag)
+		if !loaded {
+			continue
+		}
+		b.Go(realTag, func() (any, error) {
+			ctx, cancel := context.WithTimeout(context.Background(), C.TCPTimeout)
+			defer cancel()
+			t, err := urltest.URLTest(ctx, g.link, p)
+			if err != nil {
+				g.logger.Debug("outbound ", tag, " unavailable: ", err)
+				g.history.DeleteURLTestHistory(realTag)
+			} else {
+				g.logger.Debug("outbound ", tag, " available: ", t, "ms")
+				g.history.StoreURLTestHistory(realTag, &urltest.History{
+					Time:  time.Now(),
+					Delay: t,
+				})
+			}
+			return nil, nil
+		})
+	}
+	b.Wait()
+}