From c4e46c35b59381d0aaf969a15f085de38f0b0796 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Fri, 22 Jul 2022 13:51:08 +0800 Subject: [PATCH] Add urltest outbound --- adapter/experimental.go | 5 + adapter/router.go | 2 + common/urltest/urltest.go | 107 +++++++++ constant/proxy.go | 1 + constant/timeout.go | 6 +- experimental/clashapi/proxies.go | 112 +++------ experimental/clashapi/server.go | 15 +- .../clashapi/trafficontrol/tracker.go | 60 ++++- go.mod | 2 +- go.sum | 4 +- option/outbound.go | 22 +- outbound/builder.go | 2 + outbound/selector.go | 5 +- outbound/urltest.go | 225 ++++++++++++++++++ route/router.go | 11 +- test/go.mod | 2 +- test/go.sum | 4 +- 17 files changed, 473 insertions(+), 112 deletions(-) create mode 100644 common/urltest/urltest.go create mode 100644 outbound/urltest.go diff --git a/adapter/experimental.go b/adapter/experimental.go index 0f6104c2..1215a234 100644 --- a/adapter/experimental.go +++ b/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 +} diff --git a/adapter/router.go b/adapter/router.go index 354ed6d3..c42d4e0f 100644 --- a/adapter/router.go +++ b/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 { diff --git a/common/urltest/urltest.go b/common/urltest/urltest.go new file mode 100644 index 00000000..c60fa569 --- /dev/null +++ b/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 +} diff --git a/constant/proxy.go b/constant/proxy.go index 4f25fd93..f037e76b 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -16,4 +16,5 @@ const ( const ( TypeSelector = "selector" + TypeURLTest = "urltest" ) diff --git a/constant/timeout.go b/constant/timeout.go index 0ae26011..26037dfa 100644 --- a/constant/timeout.go +++ b/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 ) diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go index 4af9b77b..6d8be6bb 100644 --- a/experimental/clashapi/proxies.go +++ b/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) + delayHistory := server.router.URLTestHistoryStorage(false).LoadURLTestHistory(outbound.RealTag(detour)) + if delayHistory != nil { + info.Put("history", []*urltest.History{delayHistory}) + } else { + info.Put("history", []*urltest.History{}) + } + if isGroup { + selector := detour.(adapter.OutboundGroup) info.Put("now", selector.Now()) info.Put("all", selector.All()) - delayHistory, loaded = server.delayHistory[selector.Now()] - } else { - delayHistory, loaded = server.delayHistory[detour.Tag()] - } - if loaded { - info.Put("history", []*DelayHistory{delayHistory}) - } else { - info.Put("history", []*DelayHistory{}) } 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 -} diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 0a127aab..fc926588 100644 --- a/experimental/clashapi/server.go +++ b/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 { diff --git a/experimental/clashapi/trafficontrol/tracker.go b/experimental/clashapi/trafficontrol/tracker.go index f88401d0..7ef547c3 100644 --- a/experimental/clashapi/trafficontrol/tracker.go +++ b/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) diff --git a/go.mod b/go.mod index 38ac7c8b..80a6ecb0 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 3002ccee..cd263252 100644 --- a/go.sum +++ b/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= diff --git a/option/outbound.go b/option/outbound.go index ce012642..f56d5962 100644 --- a/option/outbound.go +++ b/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 +} diff --git a/outbound/builder.go b/outbound/builder.go index 98de1c70..faa00980 100644 --- a/outbound/builder.go +++ b/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) } diff --git a/outbound/selector.go b/outbound/selector.go index 8d74320e..ad7b9ddb 100644 --- a/outbound/selector.go +++ b/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 diff --git a/outbound/urltest.go b/outbound/urltest.go new file mode 100644 index 00000000..1a7d69e2 --- /dev/null +++ b/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() +} diff --git a/route/router.go b/route/router.go index f8275036..1f402ed9 100644 --- a/route/router.go +++ b/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 { diff --git a/test/go.mod b/test/go.mod index fdfcad08..d360f8f8 100644 --- a/test/go.mod +++ b/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 diff --git a/test/go.sum b/test/go.sum index f06de290..becee13d 100644 --- a/test/go.sum +++ b/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=