diff --git a/adapter/experimental.go b/adapter/experimental.go index 67191141..cd46fb1f 100644 --- a/adapter/experimental.go +++ b/adapter/experimental.go @@ -143,6 +143,7 @@ type OutboundGroup interface { type URLTestGroup interface { OutboundGroup URLTest(ctx context.Context) (map[string]uint16, error) + PerformUpdateCheck() } func OutboundTag(detour Outbound) string { diff --git a/experimental/clashapi/proxies.go b/experimental/clashapi/proxies.go index ef88ff37..afd7ba22 100644 --- a/experimental/clashapi/proxies.go +++ b/experimental/clashapi/proxies.go @@ -184,6 +184,30 @@ func updateProxy(w http.ResponseWriter, r *http.Request) { render.NoContent(w, r) } +func groupContains(outboundManager adapter.OutboundManager, outboundGroup adapter.OutboundGroup, tag string, visited map[string]bool) bool { + for _, memberTag := range outboundGroup.All() { + if memberTag == tag { + return true + } + member, loaded := outboundManager.Outbound(memberTag) + if !loaded { + continue + } + if group.RealTag(member) == tag { + return true + } + memberGroup, isGroup := member.(adapter.OutboundGroup) + if !isGroup || visited[memberTag] { + continue + } + visited[memberTag] = true + if groupContains(outboundManager, memberGroup, tag, visited) { + return true + } + } + return false +} + func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query() @@ -213,6 +237,16 @@ func getProxyDelay(server *Server) func(w http.ResponseWriter, r *http.Request) Delay: delay, }) } + for _, detour := range server.outbound.Outbounds() { + urlTestGroup, isURLTestGroup := detour.(adapter.URLTestGroup) + if !isURLTestGroup { + continue + } + if !groupContains(server.outbound, urlTestGroup, realTag, map[string]bool{detour.Tag(): true}) { + continue + } + urlTestGroup.PerformUpdateCheck() + } }() if ctx.Err() != nil { diff --git a/go.mod b/go.mod index 6f8e242e..10362e4d 100644 --- a/go.mod +++ b/go.mod @@ -34,7 +34,7 @@ require ( github.com/sagernet/gomobile v0.1.12 github.com/sagernet/gvisor v0.0.0-20250811.0-sing-box-mod.1 github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 - github.com/sagernet/sing v0.8.13 + github.com/sagernet/sing v0.8.14 github.com/sagernet/sing-mux v0.3.5 github.com/sagernet/sing-quic v0.6.4-0.20260803041914-d83826c306d7 github.com/sagernet/sing-shadowsocks v0.2.8 diff --git a/go.sum b/go.sum index 6ab540be..53ebbc08 100644 --- a/go.sum +++ b/go.sum @@ -236,8 +236,8 @@ github.com/sagernet/nftables v0.3.0-mod.2 h1:ck2KMU02OxL1eDFgGaWYglMDpoOZ7OHzxje github.com/sagernet/nftables v0.3.0-mod.2/go.mod h1:8kslHG4VvYNihcco+i6uxIX7qbT8A56T0y5q7U44ZaQ= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4 h1:6qvrUW79S+CrPwWz6cMePXohgjHoKxLo3c+MDhNwc3o= github.com/sagernet/quic-go v0.59.0-sing-box-mod.4/go.mod h1:OqILvS182CyOol5zNNo6bguvOGgXzV459+chpRaUC+4= -github.com/sagernet/sing v0.8.13 h1:yVoXnx9nPxfjlwD4Tp+Wd9zuW2tfiSVrcRDBZNbKRCw= -github.com/sagernet/sing v0.8.13/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= +github.com/sagernet/sing v0.8.14 h1:S6Netv4F61uNAuD/sUbHnGuNUEPwtL08Ouk0//CVYgM= +github.com/sagernet/sing v0.8.14/go.mod h1:olXxWQNqRW/l2Q6JI3b2Qmz8iQnIFlOeeH8bx6JhgUA= github.com/sagernet/sing-mux v0.3.5 h1:RHnhVEc+SFqkrK4xMygYjDwwLhzp2Bj3lztSukONfhI= github.com/sagernet/sing-mux v0.3.5/go.mod h1:QvlKMyNBNrQoyX4x+gq028uPbLM2XeRpWtDsWBJbFSk= github.com/sagernet/sing-quic v0.6.4-0.20260803041914-d83826c306d7 h1:D46kmyvKMNVFvL3KXdq3T4vy8r29xCMmSqNuQHJBybE= diff --git a/protocol/group/urltest.go b/protocol/group/urltest.go index 730040f7..cf82c342 100644 --- a/protocol/group/urltest.go +++ b/protocol/group/urltest.go @@ -115,6 +115,10 @@ func (s *URLTest) CheckOutbounds() { s.group.CheckOutbounds(true) } +func (s *URLTest) PerformUpdateCheck() { + s.group.performUpdateCheck() +} + func (s *URLTest) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { s.group.Touch() var outbound adapter.Outbound @@ -202,6 +206,7 @@ type URLTestGroup struct { interruptGroup *interrupt.Group interruptExternalConnections bool access sync.Mutex + updateAccess sync.Mutex ticker *time.Ticker close chan struct{} started bool @@ -362,6 +367,11 @@ func (g *URLTestGroup) URLTest(ctx context.Context) (map[string]uint16, error) { return g.urlTest(ctx, false) } +type urlTestResult struct { + delay uint16 + err error +} + func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint16, error) { result := make(map[string]uint16) if g.checking.Swap(true) { @@ -389,18 +399,28 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint b.Go(realTag, func() (any, error) { testCtx, cancel := context.WithTimeout(g.ctx, C.TCPTimeout) defer cancel() - t, err := urltest.URLTest(testCtx, g.link, p) - if err != nil { - g.logger.Debug("outbound ", tag, " unavailable: ", err) + testChan := make(chan urlTestResult, 1) + go func() { + delay, testErr := urltest.URLTest(testCtx, g.link, p) + testChan <- urlTestResult{delay, testErr} + }() + var testResult urlTestResult + select { + case testResult = <-testChan: + case <-testCtx.Done(): + testResult.err = testCtx.Err() + } + if testResult.err != nil { + g.logger.Debug("outbound ", tag, " unavailable: ", testResult.err) g.history.DeleteURLTestHistory(realTag) } else { - g.logger.Debug("outbound ", tag, " available: ", t, "ms") + g.logger.Debug("outbound ", tag, " available: ", testResult.delay, "ms") g.history.StoreURLTestHistory(realTag, &adapter.URLTestHistory{ Time: time.Now(), - Delay: t, + Delay: testResult.delay, }) resultAccess.Lock() - result[tag] = t + result[tag] = testResult.delay resultAccess.Unlock() } return nil, nil @@ -412,6 +432,8 @@ func (g *URLTestGroup) urlTest(ctx context.Context, force bool) (map[string]uint } 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 { diff --git a/protocol/ssh/outbound.go b/protocol/ssh/outbound.go index b76c79ad..7d7bd7dd 100644 --- a/protocol/ssh/outbound.go +++ b/protocol/ssh/outbound.go @@ -35,7 +35,6 @@ var _ adapter.InterfaceUpdateListener = (*Outbound)(nil) type Outbound struct { outbound.Adapter - ctx context.Context logger logger.ContextLogger dialer N.Dialer serverAddr M.Socksaddr @@ -56,7 +55,6 @@ func NewOutbound(ctx context.Context, router adapter.Router, logger log.ContextL } outbound := &Outbound{ Adapter: outbound.NewAdapterWithDialerOptions(C.TypeSSH, tag, []string{N.NetworkTCP}, options.DialerOptions), - ctx: ctx, logger: logger, dialer: outboundDialer, serverAddr: options.ServerOptions.Build(), @@ -121,7 +119,7 @@ func randomVersion() string { return version } -func (s *Outbound) connect() (*ssh.Client, error) { +func (s *Outbound) connect(ctx context.Context) (client *ssh.Client, err error) { if s.client != nil { return s.client, nil } @@ -133,10 +131,24 @@ func (s *Outbound) connect() (*ssh.Client, error) { return s.client, nil } - conn, err := s.dialer.DialContext(s.ctx, N.NetworkTCP, s.serverAddr) + conn, err := s.dialer.DialContext(ctx, N.NetworkTCP, s.serverAddr) if err != nil { return nil, err } + if ctx.Done() != nil { + handshakeConn := conn + stopContext := context.AfterFunc(ctx, func() { + _ = handshakeConn.Close() + }) + defer func() { + if !stopContext() { + s.client = nil + s.clientConn = nil + client = nil + err = ctx.Err() + } + }() + } config := &ssh.ClientConfig{ User: s.user, Auth: s.authMethod, @@ -161,7 +173,7 @@ func (s *Outbound) connect() (*ssh.Client, error) { return nil, E.Cause(err, "connect to ssh server") } - client := ssh.NewClient(clientConn, chans, reqs) + client = ssh.NewClient(clientConn, chans, reqs) s.clientConn = conn s.client = client @@ -187,7 +199,7 @@ func (s *Outbound) Close() error { } func (s *Outbound) DialContext(ctx context.Context, network string, destination M.Socksaddr) (net.Conn, error) { - client, err := s.connect() + client, err := s.connect(ctx) if err != nil { return nil, err }