Files
sing-box-rr-edition/sing-box-rr/protocol/group/urltest.go
T

753 lines
23 KiB
Go

package group
import (
"context"
"maps"
"net"
"regexp"
"sync"
"sync/atomic"
"time"
"github.com/sagernet/sing-box/adapter"
"github.com/sagernet/sing-box/adapter/outbound"
"github.com/sagernet/sing-box/common/interrupt"
"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"
"github.com/sagernet/sing/common/x/list"
"github.com/sagernet/sing/service"
"github.com/sagernet/sing/service/pause"
)
func RegisterURLTest(registry *outbound.Registry) {
outbound.Register[option.URLTestOutboundOptions](registry, C.TypeURLTest, NewURLTest)
}
var (
_ adapter.NetworkQualityURLTestGroup = (*URLTest)(nil)
_ adapter.InterfaceUpdateListener = (*URLTest)(nil)
)
type URLTest struct {
outbound.Adapter
ctx context.Context
outbound adapter.OutboundManager
connection adapter.ConnectionManager
logger log.ContextLogger
tags []string
link string
interval time.Duration
tolerance uint16
idleTimeout time.Duration
strategy string
networkQualityTTL time.Duration
networkQualityTarget string
networkQualityConfigURL string
networkQualityDownloadURL string
networkQualityUploadURL string
networkQualityProbeURL string
group *URLTestGroup
checkAccess sync.Mutex
interruptExternalConnections bool
provider adapter.ProviderManager
providers map[string]adapter.Provider
outboundsCache map[string][]adapter.Outbound
cancel context.CancelFunc
providerTags []string
exclude *regexp.Regexp
include *regexp.Regexp
useAllProviders bool
}
func NewURLTest(ctx context.Context, router adapter.Router, logger log.ContextLogger, tag string, options option.URLTestOutboundOptions) (adapter.Outbound, error) {
strategy := options.Strategy
if strategy == "" {
strategy = C.URLTestStrategyLatency
}
if _, err := parseURLTestSelectionStrategy(strategy); err != nil {
return nil, err
}
outbound := &URLTest{
Adapter: outbound.NewAdapter(C.TypeURLTest, tag, []string{N.NetworkTCP, N.NetworkUDP}, options.Outbounds),
ctx: ctx,
outbound: service.FromContext[adapter.OutboundManager](ctx),
connection: service.FromContext[adapter.ConnectionManager](ctx),
logger: logger,
tags: options.Outbounds,
link: options.URL,
interval: time.Duration(options.Interval),
tolerance: options.Tolerance,
idleTimeout: time.Duration(options.IdleTimeout),
strategy: strategy,
networkQualityTTL: time.Duration(options.NetworkQualityTTL),
networkQualityTarget: options.NetworkQualityTarget,
networkQualityConfigURL: options.NetworkQualityConfigURL,
networkQualityDownloadURL: options.NetworkQualityDownloadURL,
networkQualityUploadURL: options.NetworkQualityUploadURL,
networkQualityProbeURL: options.NetworkQualityProbeURL,
interruptExternalConnections: options.InterruptExistConnections,
provider: service.FromContext[adapter.ProviderManager](ctx),
providers: make(map[string]adapter.Provider),
outboundsCache: make(map[string][]adapter.Outbound),
providerTags: options.Providers,
exclude: (*regexp.Regexp)(options.Exclude),
include: (*regexp.Regexp)(options.Include),
useAllProviders: options.UseAllProviders,
}
return outbound, nil
}
func (s *URLTest) Start() error {
if s.useAllProviders {
var providerTags []string
for _, provider := range s.provider.Providers() {
providerTags = append(providerTags, provider.Tag())
s.providers[provider.Tag()] = provider
provider.RegisterCallback(s.onProviderUpdated)
}
s.providerTags = providerTags
} else {
for i, tag := range s.providerTags {
provider, loaded := s.provider.Get(tag)
if !loaded {
return E.New("outbound provider ", i, " not found: ", tag)
}
s.providers[tag] = provider
provider.RegisterCallback(s.onProviderUpdated)
}
}
if len(s.tags)+len(s.providerTags) == 0 {
return E.New("missing outbound and provider tags")
}
outbounds := make([]adapter.Outbound, 0, len(s.tags))
for i, tag := range s.tags {
detour, loaded := s.outbound.Outbound(tag)
if !loaded {
return E.New("outbound ", i, " not found: ", tag)
}
outbounds = append(outbounds, detour)
}
if len(s.tags) == 0 {
detour, _ := s.outbound.Outbound("Compatible")
s.tags = append(s.tags, detour.Tag())
outbounds = append(outbounds, detour)
}
group, err := newURLTestGroup(
s.ctx,
s.outbound,
s.logger,
outbounds,
s.link,
s.interval,
s.tolerance,
s.idleTimeout,
s.strategy,
s.networkQualityTTL,
networkQualityTargetOptions{
name: s.networkQualityTarget,
configURL: s.networkQualityConfigURL,
downloadURL: s.networkQualityDownloadURL,
uploadURL: s.networkQualityUploadURL,
probeURL: s.networkQualityProbeURL,
},
s.interruptExternalConnections,
)
if err != nil {
return err
}
s.group = group
return nil
}
func (s *URLTest) PostStart() error {
s.group.PostStart()
return nil
}
func (s *URLTest) Close() error {
return common.Close(
common.PtrOrNil(s.group),
)
}
func (s *URLTest) Now() string {
if s.group.selectedOutboundTCP != nil {
return s.group.selectedOutboundTCP.Tag()
} else if s.group.selectedOutboundUDP != nil {
return s.group.selectedOutboundUDP.Tag()
}
return ""
}
func (s *URLTest) All() []string {
return s.tags
}
func (s *URLTest) URLTest(ctx context.Context) (map[string]uint16, error) {
return s.group.URLTest(ctx)
}
func (s *URLTest) NetworkQuality(ctx context.Context, maxRuntime time.Duration, http3 bool) (map[string]*adapter.NetworkQualityHistory, error) {
return s.group.NetworkQuality(ctx, maxRuntime, http3)
}
func (s *URLTest) NetworkQualityHistory() map[string]*adapter.NetworkQualityHistory {
return s.group.NetworkQualityHistory()
}
func (s *URLTest) NetworkQualityProgress() (current int, total int) {
return s.group.NetworkQualityProgress()
}
func (s *URLTest) NetworkQualitySelectedHistory() *adapter.URLTestHistory {
return s.group.NetworkQualitySelectedHistory()
}
func (s *URLTest) SelectionStrategy() string {
return s.group.SelectionStrategy()
}
func (s *URLTest) SetSelectionStrategy(strategy string) error {
return s.group.SetSelectionStrategy(strategy)
}
func (s *URLTest) CheckOutbounds() {
s.group.CheckOutbounds(s.ctx, true)
}
func (s *URLTest) PerformUpdateCheck() {
if s.group.SelectionStrategy() == C.URLTestStrategyNetworkQuality {
return
}
s.group.performUpdateCheck()
}
func (s *URLTest) InterfaceUpdated(ctx context.Context) {
group := s.group
if group == nil {
return
}
if group.pause.IsDevicePaused() || group.pause.IsNetworkPaused() {
return
}
go func() {
s.checkAccess.Lock()
defer s.checkAccess.Unlock()
if ctx.Err() != nil {
return
}
group.CheckOutbounds(ctx, true)
}()
}
func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) {
s.group.Touch()
var outbound adapter.Outbound
switch N.NetworkName(network) {
case N.NetworkTCP:
outbound = s.group.selectedOutboundTCP
case N.NetworkUDP:
outbound = s.group.selectedOutboundUDP
default:
return nil, E.Extend(N.ErrUnknownNetwork, network)
}
if outbound == nil {
outbound, _ = s.group.Select(network)
}
if outbound == nil {
return nil, E.New("missing supported outbound")
}
conn, err := outbound.DialContext(ctx, network, destination)
if err == nil {
return s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil
}
s.logger.ErrorContext(ctx, err)
s.group.history.DeleteURLTestHistory(outbound.Tag())
return nil, err
}
func (s *URLTest) ListenPacket(ctx context.Context, destination M.Socksaddr) (net.PacketConn, error) {
s.group.Touch()
outbound := s.group.selectedOutboundUDP
if outbound == nil {
outbound, _ = s.group.Select(N.NetworkUDP)
}
if outbound == nil {
return nil, E.New("missing supported outbound")
}
conn, err := outbound.ListenPacket(ctx, destination)
if err == nil {
return s.group.interruptGroup.NewPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx)), nil
}
s.logger.ErrorContext(ctx, err)
s.group.history.DeleteURLTestHistory(outbound.Tag())
return nil, err
}
func (s *URLTest) NewConnection(ctx context.Context, conn net.Conn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
ctx = interrupt.ContextWithIsExternalConnection(ctx)
conn = s.group.interruptGroup.NewConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
s.connection.NewConnection(ctx, s, conn, metadata, onClose)
}
func (s *URLTest) NewPacketConnection(ctx context.Context, conn N.PacketConn, metadata adapter.InboundContext, onClose N.CloseHandlerFunc) {
ctx = interrupt.ContextWithIsExternalConnection(ctx)
conn = s.group.interruptGroup.NewSingPacketConn(conn, interrupt.IsExternalConnectionFromContext(ctx), interrupt.IsProviderConnectionFromContext(ctx))
s.connection.NewPacketConnection(ctx, s, conn, metadata, onClose)
}
func (s *URLTest) onProviderUpdated(tag string) error {
_, loaded := s.providers[tag]
if !loaded {
return E.New("outbound provider not found: ", tag)
}
var (
tags = s.Dependencies()
outbounds []adapter.Outbound
)
for _, tag := range tags {
detour, _ := s.outbound.Outbound(tag)
outbounds = append(outbounds, detour)
}
for _, providerTag := range s.providerTags {
if providerTag != tag && s.outboundsCache[providerTag] != nil {
for _, detour := range s.outboundsCache[providerTag] {
tags = append(tags, detour.Tag())
outbounds = append(outbounds, detour)
}
continue
}
provider := s.providers[providerTag]
var cache []adapter.Outbound
for _, detour := range provider.Outbounds() {
tag := detour.Tag()
if s.exclude != nil && s.exclude.MatchString(tag) {
continue
}
if s.include != nil && !s.include.MatchString(tag) {
continue
}
tags = append(tags, tag)
cache = append(cache, detour)
}
outbounds = append(outbounds, cache...)
s.outboundsCache[providerTag] = cache
}
if len(tags) == 0 {
detour, _ := s.outbound.Outbound("Compatible")
tags = append(tags, detour.Tag())
outbounds = append(outbounds, detour)
}
s.tags = tags
s.group.access.Lock()
s.group.outbounds = outbounds
if s.group.ticker != nil {
s.group.ticker.Reset(s.group.interval)
}
s.group.access.Unlock()
ctx, cancel := context.WithCancel(s.ctx)
if s.cancel != nil {
s.cancel()
}
s.cancel = cancel
s.URLTest(ctx)
return nil
}
type URLTestGroup struct {
ctx context.Context
outbound adapter.OutboundManager
pause pause.Manager
pauseCallback *list.Element[pause.Callback]
logger log.Logger
outbounds []adapter.Outbound
link string
interval time.Duration
tolerance uint16
idleTimeout time.Duration
history *urltest.HistoryStorage
checking atomic.Bool
networkQualityChecking atomic.Bool
selectionStrategy atomic.Uint32
networkQualityTTL time.Duration
automaticNetworkQualityCtx context.Context
automaticNetworkQualityCancel context.CancelFunc
automaticNetworkQualityUpdate chan struct{}
networkQualityTarget networkQualityTarget
networkQualityAccess sync.RWMutex
networkQualityResults map[string]*adapter.NetworkQualityHistory
networkQualitySelectionResults map[string]*adapter.NetworkQualityHistory
networkQualityCurrent int
networkQualityTotal int
networkQualitySelectedHistory *adapter.URLTestHistory
selectedOutboundTCP adapter.Outbound
selectedOutboundUDP adapter.Outbound
interruptGroup *interrupt.Group
interruptExternalConnections bool
access sync.Mutex
updateAccess sync.Mutex
ticker *time.Ticker
close chan struct{}
started bool
lastActive common.TypedValue[time.Time]
}
func NewURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, interruptExternalConnections bool) (*URLTestGroup, error) {
return newURLTestGroup(
ctx,
outboundManager,
logger,
outbounds,
link,
interval,
tolerance,
idleTimeout,
C.URLTestStrategyLatency,
C.DefaultNetworkQualityTTL,
networkQualityTargetOptions{},
interruptExternalConnections,
)
}
func newURLTestGroup(ctx context.Context, outboundManager adapter.OutboundManager, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, tolerance uint16, idleTimeout time.Duration, strategyName string, networkQualityTTL time.Duration, targetOptions networkQualityTargetOptions, interruptExternalConnections bool) (*URLTestGroup, error) {
if interval == 0 {
interval = C.DefaultURLTestInterval
}
if tolerance == 0 {
tolerance = 50
}
if idleTimeout == 0 {
idleTimeout = C.DefaultURLTestIdleTimeout
}
if networkQualityTTL == 0 {
networkQualityTTL = C.DefaultNetworkQualityTTL
}
if networkQualityTTL < C.MinNetworkQualityTTL {
return nil, E.New("networkquality_ttl must be at least ", C.MinNetworkQualityTTL)
}
if interval > idleTimeout {
return nil, E.New("interval must be less or equal than idle_timeout")
}
strategy, err := parseURLTestSelectionStrategy(strategyName)
if err != nil {
return nil, err
}
networkQualityTarget, err := newNetworkQualityTarget(targetOptions)
if err != nil {
return nil, err
}
history := service.PtrFromContext[urltest.HistoryStorage](ctx)
if history == nil {
return nil, E.New("missing URL test history storage")
}
automaticNetworkQualityCtx, automaticNetworkQualityCancel := context.WithCancel(ctx)
group := &URLTestGroup{
ctx: ctx,
outbound: outboundManager,
logger: logger,
outbounds: outbounds,
link: link,
interval: interval,
tolerance: tolerance,
idleTimeout: idleTimeout,
networkQualityTTL: networkQualityTTL,
automaticNetworkQualityCtx: automaticNetworkQualityCtx,
automaticNetworkQualityCancel: automaticNetworkQualityCancel,
automaticNetworkQualityUpdate: make(chan struct{}, 1),
networkQualityTarget: networkQualityTarget,
networkQualityResults: make(map[string]*adapter.NetworkQualityHistory),
networkQualitySelectionResults: make(map[string]*adapter.NetworkQualityHistory),
history: history,
close: make(chan struct{}),
pause: service.FromContext[pause.Manager](ctx),
interruptGroup: interrupt.NewGroup(),
interruptExternalConnections: interruptExternalConnections,
}
group.selectionStrategy.Store(uint32(strategy))
return group, nil
}
func (g *URLTestGroup) PostStart() {
g.access.Lock()
defer g.access.Unlock()
g.started = true
g.lastActive.Store(time.Now())
go func() {
g.CheckOutbounds(g.ctx, false)
g.loopAutomaticNetworkQuality(g.automaticNetworkQualityCtx, g.networkQualityTTL, g.runNetworkQuality)
}()
}
func (g *URLTestGroup) Touch() {
g.access.Lock()
defer g.access.Unlock()
if !g.started {
return
}
if g.ticker != nil {
g.lastActive.Store(time.Now())
return
}
ticker := time.NewTicker(g.interval)
g.ticker = ticker
g.pauseCallback = pause.RegisterTicker(g.pause, ticker, g.interval, nil)
go g.loopCheck(ticker, g.close)
}
func (g *URLTestGroup) Close() error {
g.access.Lock()
defer g.access.Unlock()
g.started = false
if g.automaticNetworkQualityCancel != nil {
g.automaticNetworkQualityCancel()
g.automaticNetworkQualityCancel = nil
}
if g.ticker == nil {
return nil
}
g.ticker.Stop()
g.ticker = nil
g.pause.UnregisterCallback(g.pauseCallback)
g.pauseCallback = nil
close(g.close)
return nil
}
func (g *URLTestGroup) Select(network string) (adapter.Outbound, bool) {
if urlTestSelectionStrategy(g.selectionStrategy.Load()) == urlTestSelectionStrategyNetworkQuality {
if selected := g.selectByNetworkQuality(network); selected != nil {
return selected, true
}
}
return g.selectByLatency(network)
}
func (g *URLTestGroup) selectByLatency(network string) (adapter.Outbound, bool) {
var minDelay uint16
var minOutbound adapter.Outbound
outbounds := g.outboundsSnapshot()
switch network {
case N.NetworkTCP:
if g.selectedOutboundTCP != nil {
if history := g.history.LoadURLTestHistory(RealTag(g.outbound, g.selectedOutboundTCP)); history != nil {
minOutbound = g.selectedOutboundTCP
minDelay = history.Delay
}
}
case N.NetworkUDP:
if g.selectedOutboundUDP != nil {
if history := g.history.LoadURLTestHistory(RealTag(g.outbound, g.selectedOutboundUDP)); history != nil {
minOutbound = g.selectedOutboundUDP
minDelay = history.Delay
}
}
}
for _, detour := range outbounds {
if !common.Contains(detour.Network(), network) {
continue
}
history := g.history.LoadURLTestHistory(RealTag(g.outbound, detour))
if history == nil {
continue
}
if minDelay == 0 || minDelay > history.Delay+g.tolerance {
minDelay = history.Delay
minOutbound = detour
}
}
if minOutbound == nil {
for _, detour := range outbounds {
if !common.Contains(detour.Network(), network) {
continue
}
return detour, false
}
return nil, false
}
return minOutbound, true
}
func (g *URLTestGroup) loopCheck(ticker *time.Ticker, closeChan <-chan struct{}) {
if time.Since(g.lastActive.Load()) > g.interval {
g.lastActive.Store(time.Now())
g.CheckOutbounds(g.ctx, false)
}
for {
select {
case <-closeChan:
return
case <-ticker.C:
}
if time.Since(g.lastActive.Load()) > g.idleTimeout {
g.access.Lock()
if g.ticker == ticker {
g.ticker.Stop()
g.ticker = nil
g.pause.UnregisterCallback(g.pauseCallback)
g.pauseCallback = nil
}
g.access.Unlock()
return
}
g.CheckOutbounds(g.ctx, false)
}
}
func (g *URLTestGroup) CheckOutbounds(ctx context.Context, force bool) {
_, _ = g.urlTest(ctx, force)
}
func (g *URLTestGroup) URLTest(ctx context.Context) (map[string]uint16, error) {
return g.urlTest(ctx, true)
}
func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) {
if g.checking.Swap(true) {
return make(map[string]uint16), nil
}
defer g.checking.Store(false)
result := URLTestOutbounds(ctx, g.outbound, g.history, g.logger, g.outboundsSnapshot(), g.link, g.interval, force)
select {
case <-ctx.Done():
default:
g.performUpdateCheck()
}
return result, nil
}
type urlTestResult struct {
delay uint16
err error
}
type urlTestBatch struct {
ctx context.Context
outbound adapter.OutboundManager
history *urltest.HistoryStorage
logger log.Logger
batch *batch.Batch[any]
checked map[string]bool
groups []adapter.OutboundGroup
access sync.Mutex
result map[string]uint16
}
func URLTestOutbounds(ctx context.Context, outboundManager adapter.OutboundManager, history *urltest.HistoryStorage, logger log.Logger, outbounds []adapter.Outbound, link string, interval time.Duration, force bool) map[string]uint16 {
b, _ := batch.New(ctx, batch.WithConcurrencyNum[any](10))
testBatch := &urlTestBatch{
ctx: ctx,
outbound: outboundManager,
history: history,
logger: logger,
batch: b,
checked: make(map[string]bool),
result: make(map[string]uint16),
}
testBatch.test(outbounds, link, interval, force)
b.Wait()
for _, outboundGroup := range testBatch.groups {
groupHistory := history.LoadURLTestHistory(RealTag(outboundManager, outboundGroup))
if groupHistory != nil {
testBatch.result[outboundGroup.Tag()] = groupHistory.Delay
}
}
return testBatch.result
}
func (b *urlTestBatch) test(outbounds []adapter.Outbound, link string, interval time.Duration, force bool) {
for _, detour := range outbounds {
tag := detour.Tag()
if b.checked[tag] {
continue
}
switch nested := detour.(type) {
case *URLTest:
b.checked[tag] = true
b.groups = append(b.groups, nested)
b.batch.Go(tag, func() (any, error) {
nestedResult, _ := nested.group.urlTest(b.ctx, force)
b.access.Lock()
maps.Copy(b.result, nestedResult)
b.access.Unlock()
return nil, nil
})
case adapter.OutboundGroup:
b.checked[tag] = true
b.groups = append(b.groups, nested)
b.test(common.FilterNotNil(common.Map(nested.All(), func(it string) adapter.Outbound {
member, _ := b.outbound.Outbound(it)
return member
})), link, interval, force)
default:
history := b.history.LoadURLTestHistory(tag)
if !force && history != nil && time.Since(history.Time) < interval {
continue
}
b.checked[tag] = true
b.batch.Go(tag, func() (any, error) {
testCtx, cancel := context.WithTimeout(b.ctx, C.TCPTimeout)
defer cancel()
testChan := make(chan urlTestResult, 1)
go func() {
delay, testErr := urltest.URLTest(testCtx, link, detour)
testChan <- urlTestResult{delay, testErr}
}()
var testResult urlTestResult
select {
case testResult = <-testChan:
case <-testCtx.Done():
testResult.err = testCtx.Err()
}
if testResult.err != nil {
b.logger.Debug("outbound ", tag, " unavailable: ", testResult.err)
b.history.DeleteURLTestHistory(tag)
} else {
b.logger.Debug("outbound ", tag, " available: ", testResult.delay, "ms")
b.history.StoreURLTestHistory(tag, &adapter.URLTestHistory{
Time: time.Now(),
Delay: testResult.delay,
})
b.access.Lock()
b.result[tag] = testResult.delay
b.access.Unlock()
}
return nil, nil
})
}
}
}
func (g *URLTestGroup) performUpdateCheck() {
g.updateAccess.Lock()
defer g.updateAccess.Unlock()
var updated bool
if outbound, exists := g.Select(N.NetworkTCP); outbound != nil && (g.selectedOutboundTCP == nil || (exists && outbound != g.selectedOutboundTCP)) {
if g.selectedOutboundTCP != nil {
updated = true
}
g.selectedOutboundTCP = outbound
}
if outbound, exists := g.Select(N.NetworkUDP); outbound != nil && (g.selectedOutboundUDP == nil || (exists && outbound != g.selectedOutboundUDP)) {
if g.selectedOutboundUDP != nil {
updated = true
}
g.selectedOutboundUDP = outbound
}
g.updateNetworkQualitySelectedHistory()
if updated {
g.interruptGroup.Interrupt(g.interruptExternalConnections)
}
}