sing-box/common/urltest/urltest.go
世界 22650b3020
Fix group status updates broken by API service
The URL test history update hook and the Clash mode update hook were
single-slot: the API service's attached service overwrote the hook set
by the daemon, so clients stopped receiving group updates. Replace both
with multicast hook lists.

Also share a single URL test history storage via context: Clash API
looked it up under a key nobody registered and fell back to its own
empty storage, so dashboards showed no delay once an API service was
configured. Selector changes now notify through the shared storage,
covering selections made from any API surface.
2026-07-09 11:20:05 +08:00

136 lines
3.0 KiB
Go

package urltest
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/url"
"sync"
"time"
"github.com/sagernet/sing-box/adapter"
C "github.com/sagernet/sing-box/constant"
M "github.com/sagernet/sing/common/metadata"
N "github.com/sagernet/sing/common/network"
"github.com/sagernet/sing/common/ntp"
"github.com/sagernet/sing/common/observable"
)
type HistoryStorage struct {
access sync.RWMutex
delayHistory map[string]*adapter.URLTestHistory
updateHooks []*observable.Subscriber[struct{}]
}
func NewHistoryStorage() *HistoryStorage {
return &HistoryStorage{
delayHistory: make(map[string]*adapter.URLTestHistory),
}
}
func (s *HistoryStorage) AddUpdateHook(hook *observable.Subscriber[struct{}]) {
s.access.Lock()
defer s.access.Unlock()
s.updateHooks = append(s.updateHooks, hook)
}
func (s *HistoryStorage) NotifyUpdated() {
s.access.RLock()
defer s.access.RUnlock()
s.notifyUpdated()
}
func (s *HistoryStorage) LoadURLTestHistory(tag string) *adapter.URLTestHistory {
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()
delete(s.delayHistory, tag)
s.notifyUpdated()
s.access.Unlock()
}
func (s *HistoryStorage) StoreURLTestHistory(tag string, history *adapter.URLTestHistory) {
s.access.Lock()
s.delayHistory[tag] = history
s.notifyUpdated()
s.access.Unlock()
}
func (s *HistoryStorage) notifyUpdated() {
for _, updateHook := range s.updateHooks {
updateHook.Emit(struct{}{})
}
}
func (s *HistoryStorage) Close() error {
s.access.Lock()
defer s.access.Unlock()
s.updateHooks = nil
return nil
}
func URLTest(ctx context.Context, link string, detour N.Dialer) (t uint16, err error) {
if link == "" {
link = "https://www.gstatic.com/generate_204"
}
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()
if N.NeedHandshakeForWrite(instance) {
start = time.Now()
}
req, err := http.NewRequest(http.MethodHead, link, nil)
if err != nil {
return
}
client := http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
return instance, nil
},
TLSClientConfig: &tls.Config{
Time: ntp.TimeFuncFromContext(ctx),
RootCAs: adapter.RootPoolFromContext(ctx),
},
},
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
Timeout: C.TCPTimeout,
}
defer client.CloseIdleConnections()
resp, err := client.Do(req.WithContext(ctx))
if err != nil {
return
}
resp.Body.Close()
t = uint16(time.Since(start) / time.Millisecond)
return
}