世界 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
 	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
 	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"
 	"net/netip"
 
 
 	"github.com/sagernet/sing-box/common/geoip"
 	"github.com/sagernet/sing-box/common/geoip"
+	"github.com/sagernet/sing-box/common/urltest"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing-dns"
 	"github.com/sagernet/sing/common/control"
 	"github.com/sagernet/sing/common/control"
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
@@ -38,6 +39,7 @@ type Router interface {
 
 
 	Rules() []Rule
 	Rules() []Rule
 	SetTrafficController(controller TrafficController)
 	SetTrafficController(controller TrafficController)
+	URLTestHistoryStorage(create bool) *urltest.HistoryStorage
 }
 }
 
 
 type Rule interface {
 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 (
 const (
 	TypeSelector = "selector"
 	TypeSelector = "selector"
+	TypeURLTest  = "urltest"
 )
 )

+ 4 - 2
constant/timeout.go

@@ -3,6 +3,8 @@ package constant
 import "time"
 import "time"
 
 
 const (
 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 (
 import (
 	"context"
 	"context"
 	"fmt"
 	"fmt"
-	"net"
 	"net/http"
 	"net/http"
-	"net/url"
+	"sort"
 	"strconv"
 	"strconv"
 	"time"
 	"time"
 
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/common/badjson"
 	"github.com/sagernet/sing-box/common/badjson"
+	"github.com/sagernet/sing-box/common/urltest"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/outbound"
 	"github.com/sagernet/sing-box/outbound"
 	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common"
 	F "github.com/sagernet/sing/common/format"
 	F "github.com/sagernet/sing/common/format"
-	M "github.com/sagernet/sing/common/metadata"
 
 
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/chi/v5"
 	"github.com/go-chi/render"
 	"github.com/go-chi/render"
-	"sort"
 )
 )
 
 
 func proxyRouter(server *Server, router adapter.Router) http.Handler {
 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 {
 func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 	var info badjson.JSONObject
 	var info badjson.JSONObject
 	var clashType string
 	var clashType string
-	var isSelector bool
+	var isGroup bool
 	switch detour.Type() {
 	switch detour.Type() {
 	case C.TypeDirect:
 	case C.TypeDirect:
 		clashType = "Direct"
 		clashType = "Direct"
@@ -78,28 +76,26 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject {
 		clashType = "Vmess"
 		clashType = "Vmess"
 	case C.TypeSelector:
 	case C.TypeSelector:
 		clashType = "Selector"
 		clashType = "Selector"
-		isSelector = true
+		isGroup = true
+	case C.TypeURLTest:
+		clashType = "URLTest"
+		isGroup = true
 	default:
 	default:
 		clashType = "Unknown"
 		clashType = "Unknown"
 	}
 	}
 	info.Put("type", clashType)
 	info.Put("type", clashType)
 	info.Put("name", detour.Tag())
 	info.Put("name", detour.Tag())
 	info.Put("udp", common.Contains(detour.Network(), C.NetworkUDP))
 	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 {
 	} 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
 	return &info
 }
 }
@@ -135,7 +131,7 @@ func getProxies(server *Server, router adapter.Router) func(w http.ResponseWrite
 			"type":    "Fallback",
 			"type":    "Fallback",
 			"name":    "GLOBAL",
 			"name":    "GLOBAL",
 			"udp":     true,
 			"udp":     true,
-			"history": []*DelayHistory{},
+			"history": []*urltest.History{},
 			"all":     allProxies,
 			"all":     allProxies,
 			"now":     defaultTag,
 			"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))
 		ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond*time.Duration(timeout))
 		defer cancel()
 		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 {
 		if ctx.Err() != nil {
 			render.Status(r, http.StatusGatewayTimeout)
 			render.Status(r, http.StatusGatewayTimeout)
 			render.JSON(w, r, ErrRequestTimeout)
 			render.JSON(w, r, ErrRequestTimeout)
@@ -231,70 +239,8 @@ func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request)
 			return
 			return
 		}
 		}
 
 
-		server.delayHistory[proxy.Tag()] = &DelayHistory{
-			Time:  time.Now(),
-			Delay: delay,
-		}
-
 		render.JSON(w, r, render.M{
 		render.JSON(w, r, render.M{
 			"delay": delay,
 			"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"
 	"errors"
 	"net"
 	"net"
 	"net/http"
 	"net/http"
+	"os"
 	"strings"
 	"strings"
 	"time"
 	"time"
 
 
@@ -23,34 +24,28 @@ import (
 	"github.com/go-chi/render"
 	"github.com/go-chi/render"
 	"github.com/goccy/go-json"
 	"github.com/goccy/go-json"
 	"github.com/gorilla/websocket"
 	"github.com/gorilla/websocket"
-	"os"
 )
 )
 
 
 var _ adapter.ClashServer = (*Server)(nil)
 var _ adapter.ClashServer = (*Server)(nil)
 
 
 type Server struct {
 type Server struct {
+	router         adapter.Router
 	logger         log.Logger
 	logger         log.Logger
 	httpServer     *http.Server
 	httpServer     *http.Server
 	trafficManager *trafficontrol.Manager
 	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 {
 func NewServer(router adapter.Router, logFactory log.ObservableFactory, options option.ClashAPIOptions) *Server {
 	trafficManager := trafficontrol.NewManager()
 	trafficManager := trafficontrol.NewManager()
 	chiRouter := chi.NewRouter()
 	chiRouter := chi.NewRouter()
 	server := &Server{
 	server := &Server{
+		router,
 		logFactory.NewLogger("clash-api"),
 		logFactory.NewLogger("clash-api"),
 		&http.Server{
 		&http.Server{
 			Addr:    options.ExternalController,
 			Addr:    options.ExternalController,
 			Handler: chiRouter,
 			Handler: chiRouter,
 		},
 		},
 		trafficManager,
 		trafficManager,
-		make(map[string]*DelayHistory),
 	}
 	}
 	cors := cors.New(cors.Options{
 	cors := cors.New(cors.Options{
 		AllowedOrigins: []string{"*"},
 		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 {
 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 {
 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 {
 func castMetadata(metadata adapter.InboundContext) trafficontrol.Metadata {

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

@@ -6,6 +6,8 @@ import (
 	"time"
 	"time"
 
 
 	"github.com/sagernet/sing-box/adapter"
 	"github.com/sagernet/sing-box/adapter"
+	C "github.com/sagernet/sing-box/constant"
+	"github.com/sagernet/sing/common"
 	"github.com/sagernet/sing/common/buf"
 	"github.com/sagernet/sing/common/buf"
 	M "github.com/sagernet/sing/common/metadata"
 	M "github.com/sagernet/sing/common/metadata"
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
@@ -73,9 +75,29 @@ func (tt *tcpTracker) Close() error {
 	return tt.Conn.Close()
 	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()
 	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{
 	t := &tcpTracker{
 		Conn:    conn,
 		Conn:    conn,
 		manager: manager,
 		manager: manager,
@@ -83,7 +105,7 @@ func NewTCPTracker(conn net.Conn, manager *Manager, metadata Metadata, rule adap
 			UUID:          uuid,
 			UUID:          uuid,
 			Start:         time.Now(),
 			Start:         time.Now(),
 			Metadata:      metadata,
 			Metadata:      metadata,
-			Chain:         []string{},
+			Chain:         common.Reverse(chain),
 			Rule:          "",
 			Rule:          "",
 			UploadTotal:   atomic.NewInt64(0),
 			UploadTotal:   atomic.NewInt64(0),
 			DownloadTotal: 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 {
 	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)
 	manager.Join(t)
@@ -135,9 +158,29 @@ func (ut *udpTracker) Close() error {
 	return ut.PacketConn.Close()
 	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()
 	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{
 	ut := &udpTracker{
 		PacketConn: conn,
 		PacketConn: conn,
 		manager:    manager,
 		manager:    manager,
@@ -145,7 +188,7 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule
 			UUID:          uuid,
 			UUID:          uuid,
 			Start:         time.Now(),
 			Start:         time.Now(),
 			Metadata:      metadata,
 			Metadata:      metadata,
-			Chain:         []string{},
+			Chain:         common.Reverse(chain),
 			Rule:          "",
 			Rule:          "",
 			UploadTotal:   atomic.NewInt64(0),
 			UploadTotal:   atomic.NewInt64(0),
 			DownloadTotal: atomic.NewInt64(0),
 			DownloadTotal: atomic.NewInt64(0),
@@ -153,8 +196,9 @@ func NewUDPTracker(conn N.PacketConn, manager *Manager, metadata Metadata, rule
 	}
 	}
 
 
 	if rule != nil {
 	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)
 	manager.Join(ut)

+ 1 - 1
go.mod

@@ -11,7 +11,7 @@ require (
 	github.com/gorilla/websocket v1.5.0
 	github.com/gorilla/websocket v1.5.0
 	github.com/logrusorgru/aurora v2.0.3+incompatible
 	github.com/logrusorgru/aurora v2.0.3+incompatible
 	github.com/oschwald/maxminddb-golang v1.9.0
 	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-dns v0.0.0-20220720045209-c44590ebeb0f
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f
 	github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f
 	github.com/sagernet/sing-tun v0.0.0-20220720051454-d35c334b46c9
 	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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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/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 h1:PCrkSLS+fQtBimPi/2WzjJqeTy0zJtBDaMLykyTAiwQ=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f/go.mod h1:y2fpvoxukw3G7eApIZwkcpcG/NE4AB8pCQI0Qd8rMqk=
 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=
 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:"-"`
 	ShadowsocksOptions ShadowsocksOutboundOptions `json:"-"`
 	VMessOptions       VMessOutboundOptions       `json:"-"`
 	VMessOptions       VMessOutboundOptions       `json:"-"`
 	SelectorOptions    SelectorOutboundOptions    `json:"-"`
 	SelectorOptions    SelectorOutboundOptions    `json:"-"`
+	URLTestOptions     URLTestOutboundOptions     `json:"-"`
 }
 }
 
 
 type Outbound _Outbound
 type Outbound _Outbound
@@ -30,7 +31,8 @@ func (h Outbound) Equals(other Outbound) bool {
 		h.HTTPOptions == other.HTTPOptions &&
 		h.HTTPOptions == other.HTTPOptions &&
 		h.ShadowsocksOptions == other.ShadowsocksOptions &&
 		h.ShadowsocksOptions == other.ShadowsocksOptions &&
 		h.VMessOptions == other.VMessOptions &&
 		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) {
 func (h Outbound) MarshalJSON() ([]byte, error) {
@@ -50,6 +52,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) {
 		v = h.VMessOptions
 		v = h.VMessOptions
 	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)
 	}
 	}
@@ -77,6 +81,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error {
 		v = &h.VMessOptions
 		v = &h.VMessOptions
 	case C.TypeSelector:
 	case C.TypeSelector:
 		v = &h.SelectorOptions
 		v = &h.SelectorOptions
+	case C.TypeURLTest:
+		v = &h.URLTestOptions
 	default:
 	default:
 		return nil
 		return nil
 	}
 	}
@@ -171,3 +177,17 @@ func (o SelectorOutboundOptions) Equals(other SelectorOutboundOptions) bool {
 	return common.ComparableSliceEquals(o.Outbounds, other.Outbounds) &&
 	return common.ComparableSliceEquals(o.Outbounds, other.Outbounds) &&
 		o.Default == other.Default
 		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)
 		return NewVMess(router, logger, options.Tag, options.VMessOptions)
 	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)
 	}
 	}

+ 4 - 1
outbound/selector.go

@@ -13,7 +13,10 @@ import (
 	N "github.com/sagernet/sing/common/network"
 	N "github.com/sagernet/sing/common/network"
 )
 )
 
 
-var _ adapter.Outbound = (*Selector)(nil)
+var (
+	_ adapter.Outbound      = (*Selector)(nil)
+	_ adapter.OutboundGroup = (*Selector)(nil)
+)
 
 
 type Selector struct {
 type Selector struct {
 	myOutboundAdapter
 	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/geoip"
 	"github.com/sagernet/sing-box/common/geosite"
 	"github.com/sagernet/sing-box/common/geosite"
 	"github.com/sagernet/sing-box/common/sniff"
 	"github.com/sagernet/sing-box/common/sniff"
+	"github.com/sagernet/sing-box/common/urltest"
 	C "github.com/sagernet/sing-box/constant"
 	C "github.com/sagernet/sing-box/constant"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/log"
 	"github.com/sagernet/sing-box/option"
 	"github.com/sagernet/sing-box/option"
@@ -71,7 +72,8 @@ type Router struct {
 	defaultInterface     string
 	defaultInterface     string
 	interfaceMonitor     DefaultInterfaceMonitor
 	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) {
 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
 	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 {
 func hasGeoRule(rules []option.Rule, cond func(rule option.DefaultRule) bool) bool {
 	for _, rule := range rules {
 	for _, rule := range rules {
 		switch rule.Type {
 		switch rule.Type {

+ 1 - 1
test/go.mod

@@ -10,7 +10,7 @@ require (
 	github.com/docker/docker v20.10.17+incompatible
 	github.com/docker/docker v20.10.17+incompatible
 	github.com/docker/go-connections v0.4.0
 	github.com/docker/go-connections v0.4.0
 	github.com/gofrs/uuid v4.2.0+incompatible
 	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/spyzhov/ajson v0.7.1
 	github.com/stretchr/testify v1.8.0
 	github.com/stretchr/testify v1.8.0
 	golang.org/x/net v0.0.0-20220708220712-1185a9018129
 	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/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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 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 h1:PCrkSLS+fQtBimPi/2WzjJqeTy0zJtBDaMLykyTAiwQ=
 github.com/sagernet/sing-dns v0.0.0-20220720045209-c44590ebeb0f/go.mod h1:y2fpvoxukw3G7eApIZwkcpcG/NE4AB8pCQI0Qd8rMqk=
 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=
 github.com/sagernet/sing-shadowsocks v0.0.0-20220717063942-45a2ad9cd41f h1:F6yiuKbBoXgWiuoP7R0YA14pDEl3emxA1mL7M16Q7gc=