Files
FlClash/core/probe.go
T
chen08209 9c0f8bbcd2 feat(core): check services and the outbound IP through the Core
Changelog: Service checks run through the proxy, and the outbound IP shows its quality and risk
2026-10-01 08:35:18 +08:00

326 lines
7.4 KiB
Go

package main
import (
"bytes"
"context"
"errors"
"fmt"
"github.com/metacubex/http"
"io"
"net"
"sync"
"time"
N "github.com/metacubex/mihomo/common/net"
"github.com/metacubex/mihomo/component/ca"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel"
"github.com/metacubex/mihomo/tunnel/statistic"
)
const (
probeConcurrency = 8
defaultProbeTimeout = 10 * time.Second
probeErrorTimeout = "timeout"
probeErrorFailed = "failed"
probeTunnelPoll = 50 * time.Millisecond
probeScanChunk = 32 * 1024
probeScanOverlap = 256
)
var (
probeSlots = make(chan struct{}, probeConcurrency)
pendingProbeRoutes sync.Map
)
type probeRoute struct {
chains []string
rule string
rulePayload string
}
// probeDialer opens one connection per dial, either pinned to a proxy or
// routed through the tunnel like any inbound connection, and keeps the route
// of the most recent one, which after redirects is the one that produced the
// final response.
type probeDialer struct {
proxy constant.Proxy
mu sync.Mutex
last *routedConn
}
func (d *probeDialer) dial(ctx context.Context, _ string, address string) (net.Conn, error) {
metadata := &constant.Metadata{
NetWork: constant.TCP,
Type: constant.INNER,
DNSMode: constant.DNSNormal,
Process: constant.MihomoName,
}
if err := metadata.SetRemoteAddress(address); err != nil {
return nil, err
}
if d.proxy != nil {
conn, err := d.proxy.DialContext(ctx, metadata)
if err != nil {
return nil, err
}
d.remember(&routedConn{route: &probeRoute{chains: conn.Chains()}})
return conn, nil
}
if err := awaitTunnel(ctx); err != nil {
return nil, err
}
local, remote := N.Pipe()
routed := &routedConn{Conn: local, metadata: metadata}
pendingProbeRoutes.Store(metadata, routed)
go tunnel.Tunnel.HandleTCPConn(remote, metadata)
d.remember(routed)
return routed, nil
}
// Applying a config suspends the tunnel before the route epoch moves, so a
// connection refused in that window would come back as a failure the host
// still takes for the current route.
func awaitTunnel(ctx context.Context) error {
for tunnel.Status() == tunnel.Suspend {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(probeTunnelPoll):
}
}
return nil
}
func (d *probeDialer) remember(conn *routedConn) {
d.mu.Lock()
defer d.mu.Unlock()
d.last = conn
}
func (d *probeDialer) route() *probeRoute {
d.mu.Lock()
last := d.last
d.mu.Unlock()
if last == nil {
return nil
}
return last.resolve()
}
// The tunnel registers the tracker, which settles the route, before it relays
// the first byte back, so the request never has to wait for the route.
type routedConn struct {
net.Conn
metadata *constant.Metadata
mu sync.Mutex
route *probeRoute
}
func (c *routedConn) Close() error {
pendingProbeRoutes.CompareAndDelete(c.metadata, c)
return c.Conn.Close()
}
func (c *routedConn) resolve() *probeRoute {
c.mu.Lock()
defer c.mu.Unlock()
return c.route
}
func (c *routedConn) settle(route *probeRoute) {
c.mu.Lock()
defer c.mu.Unlock()
c.route = route
}
func notifyProbeRoute(tracker statistic.Tracker) {
info := tracker.Info()
pending, ok := pendingProbeRoutes.LoadAndDelete(info.Metadata)
if !ok {
return
}
pending.(*routedConn).settle(&probeRoute{
chains: append([]string(nil), info.Chain...),
rule: info.Rule,
rulePayload: info.RulePayload,
})
}
func probeTimeout(millis int64) time.Duration {
if millis <= 0 {
return defaultProbeTimeout
}
return time.Duration(millis) * time.Millisecond
}
func probeErrorKind(err error) string {
var netErr net.Error
if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) {
return probeErrorTimeout
}
return probeErrorFailed
}
type probeRequest struct {
method string
url string
proxyName string
headers map[string]string
body []byte
timeout time.Duration
maxBody int64
// A followed redirect loses the Location that Netflix names its region in.
noRedirect bool
// until sees the latest chunk and probeScanOverlap bytes before it.
until func(tail string) bool
}
func handleProbe(params *ProbeParams) *ProbeResult {
return runProbe(context.Background(), probeRequest{
method: http.MethodGet,
url: params.Url,
proxyName: params.ProxyName,
headers: params.Headers,
timeout: probeTimeout(params.Timeout),
maxBody: params.MaxBody,
})
}
// runProbe returns nil when the request never got a slot before its deadline.
func runProbe(parent context.Context, req probeRequest) *ProbeResult {
result := &ProbeResult{Url: req.url, Chains: []string{}}
result.CoreEpoch, result.PicksVersion = routeStamp()
fail := func(err error) *ProbeResult {
result.Error = probeErrorKind(err)
result.Message = err.Error()
return result
}
dialer := &probeDialer{}
if req.proxyName != "" {
dialer.proxy = lookupProxy(req.proxyName)
if dialer.proxy == nil {
return fail(fmt.Errorf("proxy %q is not part of the applied config", req.proxyName))
}
}
tlsConfig, err := ca.GetTLSConfig(ca.Option{})
if err != nil {
return fail(err)
}
timeout := req.timeout
if timeout <= 0 {
timeout = defaultProbeTimeout
}
queueCtx, cancelQueue := context.WithTimeout(parent, timeout)
granted := acquireProbeSlot(queueCtx)
cancelQueue()
if !granted {
return nil
}
defer releaseProbeSlot()
ctx, cancel := context.WithTimeout(parent, timeout)
defer cancel()
var body io.Reader
if len(req.body) > 0 {
body = bytes.NewReader(req.body)
}
method := req.method
if method == "" {
method = http.MethodGet
}
request, err := http.NewRequestWithContext(ctx, method, req.url, body)
if err != nil {
return fail(err)
}
for name, value := range req.headers {
request.Header.Set(name, value)
}
transport := &http.Transport{
DialContext: dialer.dial,
TLSClientConfig: tlsConfig,
TLSHandshakeTimeout: timeout,
MaxIdleConns: 1,
IdleConnTimeout: timeout,
}
defer transport.CloseIdleConnections()
client := &http.Client{Transport: transport}
if req.noRedirect {
client.CheckRedirect = func(*http.Request, []*http.Request) error {
return http.ErrUseLastResponse
}
}
start := time.Now()
response, err := client.Do(request)
if err != nil {
return fail(err)
}
defer func() {
_ = response.Body.Close()
}()
result.Delay = time.Since(start).Milliseconds()
result.StatusCode = response.StatusCode
result.header = response.Header
result.Url = response.Request.URL.String()
if route := dialer.route(); route != nil {
result.Chains = route.chains
result.Rule = route.rule
result.RulePayload = route.rulePayload
}
if req.maxBody > 0 {
result.Body = readProbeBody(response.Body, req.maxBody, req.until)
}
return result
}
func readProbeBody(body io.Reader, maxBody int64, until func(string) bool) string {
limited := io.LimitReader(body, maxBody)
if until == nil {
payload, _ := io.ReadAll(limited)
return string(payload)
}
var buffer bytes.Buffer
chunk := make([]byte, probeScanChunk)
for {
n, err := limited.Read(chunk)
if n > 0 {
start := max(0, buffer.Len()-probeScanOverlap)
buffer.Write(chunk[:n])
if until(string(buffer.Bytes()[start:])) {
break
}
}
if err != nil {
break
}
}
return buffer.String()
}
func acquireProbeSlot(ctx context.Context) bool {
if ctx.Err() != nil {
return false
}
select {
case probeSlots <- struct{}{}:
return true
case <-ctx.Done():
return false
}
}
func releaseProbeSlot() {
<-probeSlots
}