فهرست منبع

Add urltest outbound

世界 3 سال پیش
والد
کامیت
c4e46c35b5

+ 5 - 0
adapter/experimental.go

@@ -16,3 +16,8 @@ type TrafficController interface {
 	RoutedConnection(ctx context.Context, conn net.Conn, metadata InboundContext, matchedRule Rule) net.Conn
 	RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata InboundContext, matchedRule Rule) N.PacketConn
 }
+
+type OutboundGroup interface {
+	Now() string
+	All() []string
+}

+ 2 - 0
adapter/router.go

@@ -6,6 +6,7 @@ import (
 	"net/netip"
 
 	"github.com/sagernet/sing-box/common/geoip"
+	"github.com/sagernet/sing-box/common/urltest"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing/common/control"
 	N "github.com/sagernet/sing/common/network"
@@ -38,6 +39,7 @@ type Router interface {
 
 	Rules() []Rule
 	SetTrafficController(controller TrafficController)
+	URLTestHistoryStorage(create bool) *urltest.HistoryStorage
 }
 
 type Rule interface {

+ 107 - 0
common/urltest/urltest.go

@@ -0,0 +1,107 @@
+package urltest
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"net/url"
+	"sync"
+	"time"
+
+	M "github.com/sagernet/sing/common/metadata"
+	N "github.com/sagernet/sing/common/network"
+)
+
+type History struct {
+	Time  time.Time `json:"time"`
+	Delay uint16    `json:"delay"`
+}
+
+type HistoryStorage struct {
+	access       sync.RWMutex
+	delayHistory map[string]*History
+}
+
+func NewHistoryStorage() *HistoryStorage {
+	return &HistoryStorage{
+		delayHistory: make(map[string]*History),
+	}
+}
+
+func (s *HistoryStorage) LoadURLTestHistory(tag string) *History {
+	if s == nil {
+		return nil
+	}
+	s.access.RLock()
+	defer s.access.RUnlock()
+	return s.delayHistory[tag]
+}
+
+func (s *HistoryStorage) DeleteURLTestHistory(tag string) {
+	s.access.Lock()
+	defer s.access.Unlock()
+	delete(s.delayHistory, tag)
+}
+
+func (s *HistoryStorage) StoreURLTestHistory(tag string, history *History) {
+	s.access.Lock()
+	defer s.access.Unlock()
+	s.delayHistory[tag] = history
+}
+
+func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) {
+	linkURL, err := url.Parse(link)
+	if err != nil {
+		return
+	}
+	hostname := linkURL.Hostname()
+	port := linkURL.Port()
+	if port == "" {
+		switch linkURL.Scheme {
+		case "http":
+			port = "80"
+		case "https":
+			port = "443"
+		}
+	}
+
+	start := time.Now()
+	instance, err := detour.DialContext(ctx, "tcp", M.ParseSocksaddrHostPortStr(hostname, port))
+	if err != nil {
+		return
+	}
+	defer instance.Close()
+
+	req, err := http.NewRequest(http.MethodHead, link, nil)
+	if err != nil {
+		return
+	}
+	req = req.WithContext(ctx)
+
+	transport := &http.Transport{
+		Dial: func(string, string) (net.Conn, error) {
+			return instance, nil
+		},
+		// from http.DefaultTransport
+		MaxIdleConns:          100,
+		IdleConnTimeout:       90 * time.Second,
+		TLSHandshakeTimeout:   10 * time.Second,
+		ExpectContinueTimeout: 1 * time.Second,
+	}
+
+	client := http.Client{
+		Transport: transport,
+		CheckRedirect: func(req *http.Request, via []*http.Request) error {
+			return http.ErrUseLastResponse
+		},
+	}
+	defer client.CloseIdleConnections()
+
+	resp, err := client.Do(req)
+	if err != nil {
+		return
+	}
+	resp.Body.Close()
+	t = uint16(time.Since(start) / time.Millisecond)
+	return
+}

+ 1 - 0
constant/proxy.go

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

+ 4 - 2
constant/timeout.go

@@ -3,6 +3,8 @@ package constant
 import "time"
 
 const (
-	DefaultTCPTimeout  = 5 * time.Second
-	ReadPayloadTimeout = 300 * time.Millisecond
+	DefaultTCPTimeout      = 5 * time.Second
+	ReadPayloadTimeout     = 300 * time.Millisecond
+	URLTestTimeout         = DefaultTCPTimeout
+	DefaultURLTestInterval = 1 * time.Minute
 )

+ 29 - 83
experimental/clashapi/proxies.go

@@ -3,23 +3,21 @@ package clashapi
 import (
 	"context"
 	"fmt"
-	"net"
 	"net/http"
-	"net/url"
+	"sort"
 	"strconv"
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/badjson"
+	"github.com/sagernet/sing-box/common/urltest"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/outbound"
 	"github.com/sagernet/sing/common"
 	F "github.com/sagernet/sing/common/format"
-	M "github.com/sagernet/sing/common/metadata"
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/render"
-	"sort"
 )
 
 func proxyRouter(server *Server, router adapter.Router) http.Handler {
@@ -62,7 +60,7 @@ func findProxyByName(router adapter.Router) func(next http.Handler) http.Handler
 func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 	var info badjson.JSONObject
 	var clashType string
-	var isSelector bool
+	var isGroup bool
 	switch detour.Type() {
 	case C.TypeDirect:
 		clashType = "Direct"
@@ -78,28 +76,26 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 		clashType = "Vmess"
 	case C.TypeSelector:
 		clashType = "Selector"
-		isSelector = true
+		isGroup = true
+	case C.TypeURLTest:
+		clashType = "URLTest"
+		isGroup = true
 	default:
 		clashType = "Unknown"
 	}
 	info.Put("type", clashType)
 	info.Put("name", detour.Tag())
 	info.Put("udp", common.Contains(detour.Network(), C.NetworkUDP))
-
-	var delayHistory *DelayHistory
-	var loaded bool
-	if isSelector {
-		selector := detour.(*outbound.Selector)
-		info.Put("now", selector.Now())
-		info.Put("all", selector.All())
-		delayHistory, loaded = server.delayHistory[selector.Now()]
+	delayHistory := server.router.URLTestHistoryStorage(false).LoadURLTestHistory(outbound.RealTag(detour))
+	if delayHistory != nil {
+		info.Put("history", []*urltest.History{delayHistory})
 	} else {
-		delayHistory, loaded = server.delayHistory[detour.Tag()]
+		info.Put("history", []*urltest.History{})
 	}
-	if loaded {
-		info.Put("history", []*DelayHistory{delayHistory})
-	} else {
-		info.Put("history", []*DelayHistory{})
+	if isGroup {
+		selector := detour.(adapter.OutboundGroup)
+		info.Put("now", selector.Now())
+		info.Put("all", selector.All())
 	}
 	return &info
 }
@@ -135,7 +131,7 @@ func getProxies(server *Server, router adapter.Router) func(w http.ResponseWrite
 			"type":    "Fallback",
 			"name":    "GLOBAL",
 			"udp":     true,
-			"history": []*DelayHistory{},
+			"history": []*urltest.History{},
 			"all":     allProxies,
 			"now":     defaultTag,
 		})
@@ -218,7 +214,19 @@ func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request)
 		ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout))
 		defer cancel()
 
-		delay, err := URLTest(ctx, url, proxy)
+		delay, err := urltest.URLTest(ctx, url, proxy)
+		defer func() {
+			realTag := outbound.RealTag(proxy)
+			if err != nil {
+				server.router.URLTestHistoryStorage(true).DeleteURLTestHistory(realTag)
+			} else {
+				server.router.URLTestHistoryStorage(true).StoreURLTestHistory(realTag, &urltest.History{
+					Time:  time.Now(),
+					Delay: delay,
+				})
+			}
+		}()
+
 		if ctx.Err() != nil {
 			render.Status(r, http.StatusGatewayTimeout)
 			render.JSON(w, r, ErrRequestTimeout)
@@ -231,70 +239,8 @@ func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request)
 			return
 		}
 
-		server.delayHistory[proxy.Tag()] = &DelayHistory{
-			Time:  time.Now(),
-			Delay: delay,
-		}
-
 		render.JSON(w, r, render.M{
 			"delay": delay,
 		})
 	}
 }
-
-func URLTest(ctx context.Context, link string, detour adapter.Outbound) (t uint16, err error) {
-	linkURL, err := url.Parse(link)
-	if err != nil {
-		return
-	}
-	hostname := linkURL.Hostname()
-	port := linkURL.Port()
-	if port == "" {
-		switch linkURL.Scheme {
-		case "http":
-			port = "80"
-		case "https":
-			port = "443"
-		}
-	}
-
-	start := time.Now()
-	instance, err := detour.DialContext(ctx, "tcp", M.ParseSocksaddrHostPortStr(hostname, port))
-	if err != nil {
-		return
-	}
-	defer instance.Close()
-
-	req, err := http.NewRequest(http.MethodHead, link, nil)
-	if err != nil {
-		return
-	}
-	req = req.WithContext(ctx)
-
-	transport := &http.Transport{
-		Dial: func(string, string) (net.Conn, error) {
-			return instance, nil
-		},
-		// from http.DefaultTransport
-		MaxIdleConns:          100,
-		IdleConnTimeout:       90 * time.Second,
-		TLSHandshakeTimeout:   10 * time.Second,
-		ExpectContinueTimeout: 1 * time.Second,
-	}
-
-	client := http.Client{
-		Transport: transport,
-		CheckRedirect: func(req *http.Request, via []*http.Request) error {
-			return http.ErrUseLastResponse
-		},
-	}
-	defer client.CloseIdleConnections()
-
-	resp, err := client.Do(req)
-	if err != nil {
-		return
-	}
-	resp.Body.Close()
-	t = uint16(time.Since(start) / time.Millisecond)
-	return
-}

+ 5 - 10
experimental/clashapi/server.go

@@ -6,6 +6,7 @@ import (
 	"errors"
 	"net"
 	"net/http"
+	"os"
 	"strings"
 	"time"
 
@@ -23,34 +24,28 @@ import (
 	"github.com/go-chi/render"
 	"github.com/goccy/go-json"
 	"github.com/gorilla/websocket"
-	"os"
 )
 
 var _ adapter.ClashServer = (*Server)(nil)
 
 type Server struct {
+	router         adapter.Router
 	logger         log.Logger
 	httpServer     *http.Server
 	trafficManager *trafficontrol.Manager
-	delayHistory   map[string]*DelayHistory
-}
-
-type DelayHistory struct {
-	Time  time.Time `json:"time"`
-	Delay uint16    `json:"delay"`
 }
 
 func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) *Server {
 	trafficManager := trafficontrol.NewManager()
 	chiRouter := chi.NewRouter()
 	server := &Server{
+		router,
 		logFactory.NewLogger("clash-api"),
 		&http.Server{
 			Addr:    options.ExternalController,
 			Handler: chiRouter,
 		},
 		trafficManager,
-		make(map[string]*DelayHistory),
 	}
 	cors := cors.New(cors.Options{
 		AllowedOrigins: []string{"*"},
@@ -107,11 +102,11 @@ func (s *Server) Close() error {
 }
 
 func (s *Server) RoutedConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, matchedRule adapter.Rule) net.Conn {
-	return trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), matchedRule)
+	return trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule)
 }
 
 func (s *Server) RoutedPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, matchedRule adapter.Rule) N.PacketConn {
-	return trafficontrol.NewUDPTracker(conn, s.trafficManager, castMetadata(metadata), matchedRule)
+	return trafficontrol.NewUDPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule)
 }
 
 func castMetadata(metadata adapter.InboundContext) trafficontrol.Metadata {

+ 52 - 8
experimental/clashapi/trafficontrol/tracker.go

@@ -6,6 +6,8 @@ import (
 	"time"
 
 	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/buf"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
@@ -73,9 +75,29 @@ func (tt *tcpTracker) Close() error {
 	return tt.Conn.Close()
 }
 
-func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, rule adapter.Rule) *tcpTracker {
+func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, router adapter.Router, rule adapter.Rule) *tcpTracker {
 	uuid, _ := uuid.NewV4()
 
+	var chain []string
+	var next string
+	if rule == nil {
+		next = router.DefaultOutbound(C.NetworkTCP).Tag()
+	} else {
+		next = rule.Outbound()
+	}
+	for {
+		chain = append(chain, next)
+		detour, loaded := router.Outbound(next)
+		if !loaded {
+			break
+		}
+		group, isGroup := detour.(adapter.OutboundGroup)
+		if !isGroup {
+			break
+		}
+		next = group.Now()
+	}
+
 	t := &tcpTracker{
 		Conn:    conn,
 		manager: manager,
@@ -83,7 +105,7 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, rule adap
 			UUID:          uuid,
 			Start:         time.Now(),
 			Metadata:      metadata,
-			Chain:         []string{},
+			Chain:         common.Reverse(chain),
 			Rule:          "",
 			UploadTotal:   atomic.NewInt64(0),
 			DownloadTotal: atomic.NewInt64(0),
@@ -91,8 +113,9 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, rule adap
 	}
 
 	if rule != nil {
-		t.trackerInfo.Rule = rule.Outbound()
-		t.trackerInfo.RulePayload = rule.String()
+		t.trackerInfo.Rule = rule.String() + " => " + rule.Outbound()
+	} else {
+		t.trackerInfo.Rule = "final"
 	}
 
 	manager.Join(t)
@@ -135,9 +158,29 @@ func (ut *udpTracker) Close() error {
 	return ut.PacketConn.Close()
 }
 
-func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule adapter.Rule) *udpTracker {
+func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, router adapter.Router, rule adapter.Rule) *udpTracker {
 	uuid, _ := uuid.NewV4()
 
+	var chain []string
+	var next string
+	if rule == nil {
+		next = router.DefaultOutbound(C.NetworkUDP).Tag()
+	} else {
+		next = rule.Outbound()
+	}
+	for {
+		chain = append(chain, next)
+		detour, loaded := router.Outbound(next)
+		if !loaded {
+			break
+		}
+		group, isGroup := detour.(adapter.OutboundGroup)
+		if !isGroup {
+			break
+		}
+		next = group.Now()
+	}
+
 	ut := &udpTracker{
 		PacketConn: conn,
 		manager:    manager,
@@ -145,7 +188,7 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule
 			UUID:          uuid,
 			Start:         time.Now(),
 			Metadata:      metadata,
-			Chain:         []string{},
+			Chain:         common.Reverse(chain),
 			Rule:          "",
 			UploadTotal:   atomic.NewInt64(0),
 			DownloadTotal: atomic.NewInt64(0),
@@ -153,8 +196,9 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule
 	}
 
 	if rule != nil {
-		ut.trackerInfo.Rule = rule.Outbound()
-		ut.trackerInfo.RulePayload = rule.String()
+		ut.trackerInfo.Rule = rule.String() + " => " + rule.Outbound()
+	} else {
+		ut.trackerInfo.Rule = "final"
 	}
 
 	manager.Join(ut)

+ 1 - 1
go.mod

@@ -11,7 +11,7 @@ require (
 	github.com/gorilla/websocket v1.5.0
 	github.com/logrusorgru/aurora v2.0.3+incompatible
 	github.com/oschwald/maxminddb-golang v1.9.0
-	github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca
+	github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b
 	github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f
 	github.com/sagernet/sing-tun v0.0.0-20220720051454-d35c334b46c9

+ 2 - 2
go.sum

@@ -35,8 +35,8 @@ github.com/oschwald/maxminddb-golang v1.9.0/go.mod h1:TK+s/Z2oZq0rSl4PSeAEoP0bgm
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca h1:xz/41NRDcjMm3w5UeojeU79Tu0aRiy/apQN+JadrWZ8=
-github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM=
+github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b h1:V5gIp7HQOEEIaxV1TKhjhTu8RyAyXeYx8qeaHVrjFW4=
+github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f h1:PCrkSLS+fQtBimPi/2WzjJqeTy0zJtBDaMLykyTAiwQ=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f/go.mod h1:y2fpvoxukw3G7eApIZwkcpcG/NE4AB8pCQI0Qd8rMqk=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f h1:F6yiuKbBoXgWiuoP7R0YA14pDEl3emxA1mL7M16Q7gc=

+ 21 - 1
option/outbound.go

@@ -18,6 +18,7 @@ type _Outbound struct {
 	ShadowsocksOptions ShadowsocksOutboundOptions `json:"-"`
 	VMessOptions       VMessOutboundOptions       `json:"-"`
 	SelectorOptions    SelectorOutboundOptions    `json:"-"`
+	URLTestOptions     URLTestOutboundOptions     `json:"-"`
 }
 
 type Outbound _Outbound
@@ -30,7 +31,8 @@ func (h Outbound) Equals(other Outbound) bool {
 		h.HTTPOptions == other.HTTPOptions &&
 		h.ShadowsocksOptions == other.ShadowsocksOptions &&
 		h.VMessOptions == other.VMessOptions &&
-		common.Equals(h.SelectorOptions, other.SelectorOptions)
+		common.Equals(h.SelectorOptions, other.SelectorOptions) &&
+		common.Equals(h.URLTestOptions, other.URLTestOptions)
 }
 
 func (h Outbound) MarshalJSON() ([]byte, error) {
@@ -50,6 +52,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) {
 		v = h.VMessOptions
 	case C.TypeSelector:
 		v = h.SelectorOptions
+	case C.TypeURLTest:
+		v = h.URLTestOptions
 	default:
 		return nil, E.New("unknown outbound type: ", h.Type)
 	}
@@ -77,6 +81,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error {
 		v = &h.VMessOptions
 	case C.TypeSelector:
 		v = &h.SelectorOptions
+	case C.TypeURLTest:
+		v = &h.URLTestOptions
 	default:
 		return nil
 	}
@@ -171,3 +177,17 @@ func (o SelectorOutboundOptions) Equals(other SelectorOutboundOptions) bool {
 	return common.ComparableSliceEquals(o.Outbounds, other.Outbounds) &&
 		o.Default == other.Default
 }
+
+type URLTestOutboundOptions struct {
+	Outbounds []string `json:"outbounds"`
+	URL       string   `json:"url,omitempty"`
+	Interval  Duration `json:"interval,omitempty"`
+	Tolerance uint16   `json:"tolerance,omitempty"`
+}
+
+func (o URLTestOutboundOptions) Equals(other URLTestOutboundOptions) bool {
+	return common.ComparableSliceEquals(o.Outbounds, other.Outbounds) &&
+		o.URL == other.URL &&
+		o.Interval == other.Interval &&
+		o.Tolerance == other.Tolerance
+}

+ 2 - 0
outbound/builder.go

@@ -28,6 +28,8 @@ func New(router adapter.Router, logger log.ContextLogger, options option.Outboun
 		return NewVMess(router, logger, options.Tag, options.VMessOptions)
 	case C.TypeSelector:
 		return NewSelector(router, logger, options.Tag, options.SelectorOptions)
+	case C.TypeURLTest:
+		return NewURLTest(router, logger, options.Tag, options.URLTestOptions)
 	default:
 		return nil, E.New("unknown outbound type: ", options.Type)
 	}

+ 4 - 1
outbound/selector.go

@@ -13,7 +13,10 @@ import (
 	N "github.com/sagernet/sing/common/network"
 )
 
-var _ adapter.Outbound = (*Selector)(nil)
+var (
+	_ adapter.Outbound      = (*Selector)(nil)
+	_ adapter.OutboundGroup = (*Selector)(nil)
+)
 
 type Selector struct {
 	myOutboundAdapter

+ 225 - 0
outbound/urltest.go

@@ -0,0 +1,225 @@
+package outbound
+
+import (
+	"context"
+	"net"
+	"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
+	router    adapter.Router
+	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,
+			logger:   logger,
+			tag:      tag,
+		},
+		router:    router,
+		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{C.NetworkTCP, C.NetworkUDP}
+	}
+	return s.group.Select().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().Tag()
+}
+
+func (s *URLTest) All() []string {
+	return s.tags
+}
+
+func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
+	return s.group.Select().DialContext(ctx, network, destination)
+}
+
+func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
+	return s.group.Select().ListenPacket(ctx, destination)
+}
+
+func (s *URLTest) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext) error {
+	return s.group.Select().NewConnection(ctx, conn, metadata)
+}
+
+func (s *URLTest) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext) error {
+	return s.group.Select().NewPacketConnection(ctx, conn, metadata)
+}
+
+type URLTestGroup struct {
+	router    adapter.Router
+	logger    log.Logger
+	outbounds []adapter.Outbound
+	link      string
+	interval  time.Duration
+	tolerance uint16
+
+	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.DefaultURLTestInterval
+	}
+	if tolerance == 0 {
+		tolerance = 50
+	}
+	return &URLTestGroup{
+		router:    router,
+		logger:    logger,
+		outbounds: outbounds,
+		link:      link,
+		interval:  interval,
+		tolerance: tolerance,
+		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() adapter.Outbound {
+	var minDelay uint16
+	var minTime time.Time
+	var minOutbound adapter.Outbound
+	for _, detour := range g.outbounds {
+		history := g.router.URLTestHistoryStorage(false).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 {
+		minOutbound = g.outbounds[0]
+	}
+	return minOutbound
+}
+
+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 {
+		realTag := RealTag(detour)
+		if checked[realTag] {
+			continue
+		}
+		history := g.router.URLTestHistoryStorage(false).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.URLTestTimeout)
+			defer cancel()
+			t, err := urltest.URLTest(ctx, g.link, p)
+			if err != nil {
+				g.logger.Debug("outbound ", detour.Tag(), " unavailable: ", err)
+				g.router.URLTestHistoryStorage(true).DeleteURLTestHistory(realTag)
+			} else {
+				g.logger.Debug("outbound ", detour.Tag(), " available: ", t, "ms")
+				g.router.URLTestHistoryStorage(true).StoreURLTestHistory(realTag, &urltest.History{
+					Time:  time.Now(),
+					Delay: t,
+				})
+			}
+			return nil, nil
+		})
+	}
+	b.Wait()
+}
+
+func RealTag(detour adapter.Outbound) string {
+	if group, isGroup := detour.(adapter.OutboundGroup); isGroup {
+		return group.Now()
+	}
+	return detour.Tag()
+}

+ 10 - 1
route/router.go

@@ -18,6 +18,7 @@ import (
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-box/common/geosite"
 	"github.com/sagernet/sing-box/common/sniff"
+	"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"
@@ -71,7 +72,8 @@ type Router struct {
 	defaultInterface     string
 	interfaceMonitor     DefaultInterfaceMonitor
 
-	trafficController adapter.TrafficController
+	trafficController     adapter.TrafficController
+	urlTestHistoryStorage *urltest.HistoryStorage
 }
 
 func NewRouter(ctx context.Context, logger log.ContextLogger, dnsLogger log.ContextLogger, options option.RouteOptions, dnsOptions option.DNSOptions) (*Router, error) {
@@ -597,6 +599,13 @@ func (r *Router) SetTrafficController(controller adapter.TrafficController) {
 	r.trafficController = controller
 }
 
+func (r *Router) URLTestHistoryStorage(create bool) *urltest.HistoryStorage {
+	if r.urlTestHistoryStorage == nil && create {
+		r.urlTestHistoryStorage = urltest.NewHistoryStorage()
+	}
+	return r.urlTestHistoryStorage
+}
+
 func hasGeoRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool {
 	for _, rule := range rules {
 		switch rule.Type {

+ 1 - 1
test/go.mod

@@ -10,7 +10,7 @@ require (
 	github.com/docker/docker v20.10.17+incompatible
 	github.com/docker/go-connections v0.4.0
 	github.com/gofrs/uuid v4.2.0+incompatible
-	github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca
+	github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b
 	github.com/spyzhov/ajson v0.7.1
 	github.com/stretchr/testify v1.8.0
 	golang.org/x/net v0.0.0-20220708220712-1185a9018129

+ 2 - 2
test/go.sum

@@ -62,8 +62,8 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca h1:xz/41NRDcjMm3w5UeojeU79Tu0aRiy/apQN+JadrWZ8=
-github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM=
+github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b h1:V5gIp7HQOEEIaxV1TKhjhTu8RyAyXeYx8qeaHVrjFW4=
+github.com/sagernet/sing v0.0.0-20220722054850-4ce9815aca2b/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f h1:PCrkSLS+fQtBimPi/2WzjJqeTy0zJtBDaMLykyTAiwQ=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f/go.mod h1:y2fpvoxukw3G7eApIZwkcpcG/NE4AB8pCQI0Qd8rMqk=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f h1:F6yiuKbBoXgWiuoP7R0YA14pDEl3emxA1mL7M16Q7gc=