diff --git a/adapter/experimental.go b/adapter/experimental.go index bc7906cf..4abee333 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -4,6 +4,7 @@ import ( "context" "net" + "github.com/sagernet/sing-box/common/urltest" N "github.com/sagernet/sing/common/network" ) @@ -12,6 +13,7 @@ type ClashServer interface { Mode() string StoreSelected() bool CacheFile() ClashCacheFile + HistoryStorage() *urltest.HistoryStorage 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) } diff --git a/constant/proxy.go b/constant/proxy.go index be9df47a..f875d7e0 100644 --- a/constant/proxy.go +++ b/constant/proxy.go @@ -25,4 +25,5 @@ const ( const ( TypeSelector = "selector" + TypeURLTest = "urltest" ) diff --git a/constant/timeout.go b/constant/timeout.go index dd5db92a..db0379a4 100644 --- a/constant/timeout.go +++ b/constant/timeout.go @@ -3,10 +3,11 @@ package constant import "time" 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 ) diff --git a/docs/configuration/outbound/index.md b/docs/configuration/outbound/index.md index 78686888..189e4d1c 100644 --- a/docs/configuration/outbound/index.md +++ b/docs/configuration/outbound/index.md @@ -15,21 +15,24 @@ ### 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 diff --git a/docs/configuration/outbound/index.zh.md b/docs/configuration/outbound/index.zh.md index 3803f06b..f9053356 100644 --- a/docs/configuration/outbound/index.zh.md +++ b/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 diff --git a/docs/configuration/outbound/urltest.md b/docs/configuration/outbound/urltest.md new file mode 100644 index 00000000..83d174ba --- /dev/null +++ b/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. diff --git a/docs/configuration/outbound/urltest.zh.md b/docs/configuration/outbound/urltest.zh.md new file mode 100644 index 00000000..98a36ecc --- /dev/null +++ b/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`。 diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go index 85ca261c..bdc97436 100644 --- a/experimental/clashapi/proxies.go +++ b/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 { var info badjson.JSONObject var clashType string - var isGroup bool switch detour.Type() { case C.TypeDirect: clashType = "Direct" @@ -91,7 +90,8 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject { clashType = "SSH" case C.TypeSelector: clashType = "Selector" - isGroup = true + case C.TypeURLTest: + clashType = "URLTest" default: clashType = "Direct" } @@ -104,10 +104,9 @@ func proxyInfo(server *Server, detour adapter.Outbound) *badjson.JSONObject { } else { 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 } diff --git a/experimental/clashapi/server.go b/experimental/clashapi/server.go index 6e11b149..4f8fa88d 100644 --- a/experimental/clashapi/server.go +++ b/experimental/clashapi/server.go @@ -144,6 +144,10 @@ func (s *Server) CacheFile() adapter.ClashCacheFile { 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) { tracker := trafficontrol.NewTCPTracker(conn, s.trafficManager, castMetadata(metadata), s.router, matchedRule) return tracker, tracker diff --git a/go.mod b/go.mod index 60d36b70..83323626 100644 --- a/go.mod +++ b/go.mod @@ -26,7 +26,7 @@ require ( github.com/sagernet/sing v0.0.0-20220915031330-38f39bc0c690 github.com/sagernet/sing-dns v0.0.0-20220913115644-aebff1dfbba8 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/smux v0.0.0-20220831015742-e0f1988e3195 github.com/sagernet/websocket v0.0.0-20220913015213-615516348b4e diff --git a/go.sum b/go.sum index 09eb6c41..71e540bf 100644 --- a/go.sum +++ b/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-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-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/go.mod h1:u66Vv7NHXJWfeAmhh7JuJp/cwxmuQlM56QoZ7B7Mmd0= github.com/sagernet/smux v0.0.0-20220831015742-e0f1988e3195 h1:5VBIbVw9q7aKbrFdT83mjkyvQ+VaRsQ6yflTepfln38= diff --git a/mkdocs.yml b/mkdocs.yml index 7c6c0583..0c1c5fea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -90,6 +90,7 @@ nav: - SSH: configuration/outbound/ssh.md - DNS: configuration/outbound/dns.md - Selector: configuration/outbound/selector.md + - URLTest: configuration/outbound/urltest.md - FAQ: - faq/index.md - Known Issues: faq/known-issues.md diff --git a/option/clash.go b/option/clash.go index 13b5f7b3..df4f4978 100644 --- a/option/clash.go +++ b/option/clash.go @@ -14,3 +14,10 @@ type SelectorOutboundOptions struct { Outbounds []string `json:"outbounds"` 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"` +} diff --git a/option/outbound.go b/option/outbound.go index c8793ca2..a1926ebf 100644 --- a/option/outbound.go +++ b/option/outbound.go @@ -24,6 +24,7 @@ type _Outbound struct { ShadowsocksROptions ShadowsocksROutboundOptions `json:"-"` VLESSOptions VLESSOutboundOptions `json:"-"` SelectorOptions SelectorOutboundOptions `json:"-"` + URLTestOptions URLTestOutboundOptions `json:"-"` } type Outbound _Outbound @@ -61,6 +62,8 @@ func (h Outbound) MarshalJSON() ([]byte, error) { v = h.VLESSOptions case C.TypeSelector: v = h.SelectorOptions + case C.TypeURLTest: + v = h.URLTestOptions default: return nil, E.New("unknown outbound type: ", h.Type) } @@ -104,6 +107,8 @@ func (h *Outbound) UnmarshalJSON(bytes []byte) error { v = &h.VLESSOptions case C.TypeSelector: v = &h.SelectorOptions + case C.TypeURLTest: + v = &h.URLTestOptions default: return E.New("unknown outbound type: ", h.Type) } diff --git a/outbound/builder.go b/outbound/builder.go index 3e49c1a9..3fc68dfe 100644 --- a/outbound/builder.go +++ b/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) 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/urltest.go b/outbound/urltest.go new file mode 100644 index 00000000..ef399fc6 --- /dev/null +++ b/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() +}