Add urltest outbound

This commit is contained in:
世界 2022-07-22 13:51:08 +08:00
parent 139127f1e4
commit c4e46c35b5
No known key found for this signature in database
GPG key ID: CD109927C34A63C4
17 changed files with 473 additions and 112 deletions

View file

@ -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
}

View file

@ -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
common/urltest/urltest.go Normal file
View file

@ -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
}

View file

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

View file

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

View file

@ -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))
delayHistory := server.router.URLTestHistoryStorage(false).LoadURLTestHistory(outbound.RealTag(detour))
var delayHistory *DelayHistory if delayHistory != nil {
var loaded bool info.Put("history", []*urltest.History{delayHistory})
if isSelector { } else {
selector := detour.(*outbound.Selector) info.Put("history", []*urltest.History{})
}
if isGroup {
selector := detour.(adapter.OutboundGroup)
info.Put("now", selector.Now()) info.Put("now", selector.Now())
info.Put("all", selector.All()) 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 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
}

View file

@ -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 {

View file

@ -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.Rule = rule.String() + " => " + rule.Outbound()
t.trackerInfo.RulePayload = rule.String() } 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.Rule = rule.String() + " => " + rule.Outbound()
ut.trackerInfo.RulePayload = rule.String() } else {
ut.trackerInfo.Rule = "final"
} }
manager.Join(ut) manager.Join(ut)

2
go.mod
View file

@ -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

4
go.sum
View file

@ -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-20220722054850-4ce9815aca2b h1:V5gIp7HQOEEIaxV1TKhjhTu8RyAyXeYx8qeaHVrjFW4=
github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM= 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=

View file

@ -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
}

View file

@ -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)
} }

View file

@ -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
outbound/urltest.go Normal file
View file

@ -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()
}

View file

@ -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 {

View file

@ -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

View file

@ -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-20220722054850-4ce9815aca2b h1:V5gIp7HQOEEIaxV1TKhjhTu8RyAyXeYx8qeaHVrjFW4=
github.com/sagernet/sing v0.0.0-20220720140412-fd4ec74d5eca/go.mod h1:GbtQfZSpmtD3cXeD1qX2LCMwY8dH+bnnInDTqd92IsM= 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=