Files
FlClash/core/outbound_ip.go
T

134 lines
3.7 KiB
Go
Raw Normal View History

package core
import (
"context"
"net/netip"
"strings"
"time"
"github.com/metacubex/http"
)
const (
// Racing every source at once would take every probe slot.
outboundIpConcurrency = 3
outboundIpStagger = 800 * time.Millisecond
outboundIpMaxBody = 4096
// Kept in step with browserUa in lib/common/constant.dart.
browserUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
type OutboundIpParams struct {
ProxyName string `json:"proxy-name"`
Urls []string `json:"urls"`
Timeout int64 `json:"timeout"`
}
type OutboundIpResult struct {
Url string `json:"url"`
Body string `json:"body"`
Delay int64 `json:"delay"`
Chains []string `json:"chains"`
Error string `json:"error,omitempty"`
CoreEpoch uint64 `json:"core-epoch"`
PicksVersion uint64 `json:"picks-version"`
}
// handleOutboundIp takes the first usable answer and cancels the losers so they
// stop occupying probe slots. Url is the source that was asked, not where it
// redirected, and Body is raw: Dart owns the sources and their parsers.
func handleOutboundIp(params *OutboundIpParams) *OutboundIpResult {
epoch, picksVersion := routeStamp()
failure := func(kind string) *OutboundIpResult {
return &OutboundIpResult{Error: kind, CoreEpoch: epoch, PicksVersion: picksVersion}
}
if len(params.Urls) == 0 {
return failure(probeErrorFailed)
}
timeout := timeoutFromMillis(params.Timeout, defaultProbeTimeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
answers := make(chan *OutboundIpResult, len(params.Urls))
next := 0
inFlight := 0
start := func() {
if next >= len(params.Urls) || inFlight >= outboundIpConcurrency {
return
}
url := params.Urls[next]
next++
inFlight++
go func() {
answers <- probeOutboundIpSource(ctx, url, params.ProxyName, timeout)
}()
}
start()
stagger := time.NewTicker(outboundIpStagger)
defer stagger.Stop()
for inFlight > 0 {
select {
case answer := <-answers:
inFlight--
if answer != nil {
return answer
}
start()
case <-stagger.C:
start()
case <-ctx.Done():
return failure(probeErrorTimeout)
}
}
return failure(probeErrorFailed)
}
func probeOutboundIpSource(ctx context.Context, url string, proxyName string, timeout time.Duration) *OutboundIpResult {
result := runProbe(ctx, probeRequest{
method: http.MethodGet,
url: url,
proxyName: proxyName,
headers: map[string]string{"User-Agent": browserUserAgent},
timeout: timeout,
maxBody: outboundIpMaxBody,
})
if result == nil || result.Error != "" || result.StatusCode != http.StatusOK || !mentionsIp(result.Body) {
return nil
}
return &OutboundIpResult{
Url: url,
Body: result.Body,
Delay: result.Delay,
Chains: result.Chains,
CoreEpoch: result.CoreEpoch,
PicksVersion: result.PicksVersion,
}
}
// RFC 6598 space for carrier-grade NAT, which IsPrivate leaves out.
var sharedAddressSpace = netip.MustParsePrefix("100.64.0.0/10")
// A rate-limit or captcha page can still answer 200, and Dart cannot fall back
// once the race is decided, so a body without a public address must not win it.
func mentionsIp(body string) bool {
tokens := strings.FieldsFunc(body, func(r rune) bool {
return !(r == '.' || r == ':' || '0' <= r && r <= '9' || 'a' <= r && r <= 'z' || 'A' <= r && r <= 'Z')
})
for _, token := range tokens {
addr, err := netip.ParseAddr(token)
if err != nil {
continue
}
addr = addr.Unmap()
if addr.IsGlobalUnicast() && !addr.IsPrivate() && !sharedAddressSpace.Contains(addr) {
return true
}
}
return false
}