feat(core): check services and the outbound IP through the Core
Changelog: Proxied service checks and outbound IP risk
This commit is contained in:
+1
-1
Submodule core/Clash.Meta updated: 70f0570405...8597778c7d
+27
-6
@@ -55,11 +55,12 @@ var (
|
||||
// configMu -> selectMu.
|
||||
selectMu sync.Mutex
|
||||
|
||||
isInit atomic.Bool
|
||||
isRunning atomic.Bool
|
||||
isSuspended atomic.Bool
|
||||
sdkVersion atomic.Int32
|
||||
testURL atomic.Pointer[string]
|
||||
isInit atomic.Bool
|
||||
isRunning atomic.Bool
|
||||
isSuspended atomic.Bool
|
||||
healthChecksStale atomic.Bool
|
||||
sdkVersion atomic.Int32
|
||||
testURL atomic.Pointer[string]
|
||||
|
||||
delayTestSlots = make(chan struct{}, delayTestConcurrency)
|
||||
|
||||
@@ -290,7 +291,10 @@ func updateConfig(params *UpdateParams) error {
|
||||
general.UnifiedDelay = *params.UnifiedDelay
|
||||
adapter.UnifiedDelay.Store(general.UnifiedDelay)
|
||||
}
|
||||
// IPv6 belongs with mode: turning it on moves a direct dial to another exit address.
|
||||
routeChanged := false
|
||||
if params.Mode != nil {
|
||||
routeChanged = routeChanged || general.Mode != *params.Mode
|
||||
general.Mode = *params.Mode
|
||||
tunnel.SetMode(general.Mode)
|
||||
}
|
||||
@@ -299,6 +303,7 @@ func updateConfig(params *UpdateParams) error {
|
||||
log.SetLevel(general.LogLevel)
|
||||
}
|
||||
if params.IPv6 != nil {
|
||||
routeChanged = routeChanged || general.IPv6 != *params.IPv6
|
||||
general.IPv6 = *params.IPv6
|
||||
resolver.DisableIPv6 = !general.IPv6
|
||||
}
|
||||
@@ -316,6 +321,9 @@ func updateConfig(params *UpdateParams) error {
|
||||
|
||||
updateListeners(currentConfig)
|
||||
syncGeoUpdater(params.GeoAutoUpdate, params.GeoUpdateInterval)
|
||||
if routeChanged {
|
||||
bumpRouteEpoch()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -365,12 +373,24 @@ func reconcileGeoUpdater() {
|
||||
stopGeoUpdater()
|
||||
}
|
||||
|
||||
// mihomo writes the NTP time with settimeofday, which Android's app seccomp
|
||||
// policy answers by killing the process rather than with EPERM.
|
||||
var systemTimeWritable = runtime.GOOS != "android"
|
||||
|
||||
func loadConfig(path string) (*config.Config, error) {
|
||||
buf, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return executor.ParseWithBytes(buf)
|
||||
cfg, err := executor.ParseWithBytes(buf)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if cfg.NTP.WriteToSystem && !systemTimeWritable {
|
||||
log.Warnln("ntp write-to-system ignored: Android does not let apps set the system time")
|
||||
cfg.NTP.WriteToSystem = false
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func applyConfig(params *SetupParams) error {
|
||||
@@ -400,6 +420,7 @@ func applyConfig(params *SetupParams) error {
|
||||
currentConfig = cfg
|
||||
hub.ApplyConfig(cfg)
|
||||
patchSelectGroup(params.SelectedMap)
|
||||
bumpRouteEpoch()
|
||||
updateListeners(cfg)
|
||||
reconcileGeoUpdater()
|
||||
return err
|
||||
|
||||
@@ -598,6 +598,33 @@ func TestLoadConfigReportsMalformedYaml(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigKeepsTheSystemTimeWriteOnlyWhereItIsAllowed(t *testing.T) {
|
||||
previous := systemTimeWritable
|
||||
t.Cleanup(func() { systemTimeWritable = previous })
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yaml")
|
||||
profile := "ntp:\n enable: true\n write-to-system: true\n"
|
||||
if err := os.WriteFile(path, []byte(profile), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
|
||||
for _, writable := range []bool{true, false} {
|
||||
systemTimeWritable = writable
|
||||
|
||||
cfg, err := loadConfig(path)
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("loadConfig = %v", err)
|
||||
}
|
||||
if cfg.NTP.WriteToSystem != writable {
|
||||
t.Errorf("writable %t: WriteToSystem = %t", writable, cfg.NTP.WriteToSystem)
|
||||
}
|
||||
if !cfg.NTP.Enable {
|
||||
t.Errorf("writable %t: NTP was disabled, want only the system write dropped", writable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withSetupConfig(t *testing.T, apply func(*SetupParams) error) {
|
||||
t.Helper()
|
||||
previous := setupConfig
|
||||
|
||||
+65
-3
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/metacubex/http"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
P "github.com/metacubex/mihomo/component/process"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
@@ -55,17 +57,63 @@ type ChangeProxyParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
}
|
||||
|
||||
type ChangeProxyResult struct {
|
||||
Message string `json:"message"`
|
||||
Changed bool `json:"changed"`
|
||||
}
|
||||
|
||||
type RouteState struct {
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
Picks map[string]string `json:"picks"`
|
||||
}
|
||||
|
||||
type TestDelayParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
TestUrl string `json:"test-url"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
type ProbeParams struct {
|
||||
Url string `json:"url"`
|
||||
ProxyName string `json:"proxy-name"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
MaxBody int64 `json:"max-body"`
|
||||
}
|
||||
|
||||
type ProbeResult struct {
|
||||
StatusCode int `json:"status-code"`
|
||||
Delay int64 `json:"delay"`
|
||||
Body string `json:"body"`
|
||||
Url string `json:"url"`
|
||||
Chains []string `json:"chains"`
|
||||
Rule string `json:"rule"`
|
||||
RulePayload string `json:"rule-payload"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
|
||||
// Unexported, so it never reaches Dart: only the in-core checks read
|
||||
// response headers, and ProbeResult is the shape the Dart side decodes.
|
||||
header http.Header
|
||||
}
|
||||
|
||||
type Traffic struct {
|
||||
Up int64 `json:"up"`
|
||||
Down int64 `json:"down"`
|
||||
}
|
||||
|
||||
type MemoryStats struct {
|
||||
Rss uint64 `json:"rss"`
|
||||
HeapInuse uint64 `json:"heapInuse"`
|
||||
HeapIdle uint64 `json:"heapIdle"`
|
||||
StackInuse uint64 `json:"stackInuse"`
|
||||
RuntimeOther uint64 `json:"runtimeOther"`
|
||||
}
|
||||
|
||||
type ExternalProvider struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
@@ -77,8 +125,13 @@ type ExternalProvider struct {
|
||||
}
|
||||
|
||||
type ProxiesData struct {
|
||||
Proxies map[string]constant.Proxy `json:"proxies"`
|
||||
All []string `json:"all"`
|
||||
Proxies map[string]any `json:"proxies"`
|
||||
All []string `json:"all"`
|
||||
}
|
||||
|
||||
type nodeView struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
const (
|
||||
@@ -88,6 +141,7 @@ const (
|
||||
forceGcMethod CoreMethod = "forceGc"
|
||||
shutdownMethod CoreMethod = "shutdown"
|
||||
validateConfigMethod CoreMethod = "validateConfig"
|
||||
validateProxiesMethod CoreMethod = "validateProxies"
|
||||
updateConfigMethod CoreMethod = "updateConfig"
|
||||
getProxiesMethod CoreMethod = "getProxies"
|
||||
changeProxyMethod CoreMethod = "changeProxy"
|
||||
@@ -95,13 +149,17 @@ const (
|
||||
getTotalTrafficMethod CoreMethod = "getTotalTraffic"
|
||||
resetTrafficMethod CoreMethod = "resetTraffic"
|
||||
asyncTestDelayMethod CoreMethod = "asyncTestDelay"
|
||||
probeMethod CoreMethod = "probe"
|
||||
outboundIpMethod CoreMethod = "outboundIp"
|
||||
serviceCheckMethod CoreMethod = "serviceCheck"
|
||||
getConnectionsMethod CoreMethod = "getConnections"
|
||||
getConnectionCountMethod CoreMethod = "getConnectionCount"
|
||||
closeConnectionsMethod CoreMethod = "closeConnections"
|
||||
resetConnectionsMethod CoreMethod = "resetConnections"
|
||||
closeConnectionMethod CoreMethod = "closeConnection"
|
||||
getExternalProvidersMethod CoreMethod = "getExternalProviders"
|
||||
getExternalProviderMethod CoreMethod = "getExternalProvider"
|
||||
getMemoryMethod CoreMethod = "getMemory"
|
||||
getMemoryStatsMethod CoreMethod = "getMemoryStats"
|
||||
updateGeoDataMethod CoreMethod = "updateGeoData"
|
||||
updateExternalProviderMethod CoreMethod = "updateExternalProvider"
|
||||
sideLoadExternalProviderMethod CoreMethod = "sideLoadExternalProvider"
|
||||
@@ -114,6 +172,7 @@ const (
|
||||
setupConfigMethod CoreMethod = "setupConfig"
|
||||
getConfigMethod CoreMethod = "getConfig"
|
||||
clearEffectMethod CoreMethod = "clearEffect"
|
||||
watchRouteMethod CoreMethod = "watchRoute"
|
||||
)
|
||||
|
||||
type CoreMethod string
|
||||
@@ -135,8 +194,11 @@ const (
|
||||
LogMessage MessageType = "log"
|
||||
DelayMessage MessageType = "delay"
|
||||
RequestMessage MessageType = "request"
|
||||
DnsMessage MessageType = "dns"
|
||||
LoadedMessage MessageType = "loaded"
|
||||
GeoUpdateMessage MessageType = "geoUpdate"
|
||||
// Named after the Dart enum value, which is how CoreEvent.fromJson decodes it.
|
||||
RouteChangedMessage MessageType = "routeChanged"
|
||||
)
|
||||
|
||||
type GeoUpdateStatus struct {
|
||||
|
||||
+20
-18
@@ -6,7 +6,9 @@ replace github.com/metacubex/mihomo => ./Clash.Meta
|
||||
|
||||
require (
|
||||
github.com/Microsoft/go-winio v0.6.2
|
||||
github.com/metacubex/http v0.1.8
|
||||
github.com/metacubex/mihomo v0.0.0-00010101000000-000000000000
|
||||
github.com/miekg/dns v1.1.63
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -22,7 +24,8 @@ require (
|
||||
github.com/coreos/go-iptables v0.8.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.12.0 // indirect
|
||||
github.com/dunglas/httpsfv v1.0.2 // indirect
|
||||
github.com/enfein/mieru/v3 v3.35.0 // indirect
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 // indirect
|
||||
github.com/enfein/mieru/v3 v3.38.0 // indirect
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 // indirect
|
||||
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 // indirect
|
||||
github.com/ericlagergren/siv v0.0.0-20220507050439-0b757b3aa5f1 // indirect
|
||||
@@ -50,7 +53,7 @@ require (
|
||||
github.com/mdlayher/netlink v1.7.2 // indirect
|
||||
github.com/mdlayher/socket v0.5.1 // indirect
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 // indirect
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 // indirect
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 // indirect
|
||||
github.com/metacubex/ascon v0.1.0 // indirect
|
||||
github.com/metacubex/bart v0.29.0 // indirect
|
||||
github.com/metacubex/bbolt v0.0.0-20260706163408-d4ec34ad7c48 // indirect
|
||||
@@ -58,20 +61,19 @@ require (
|
||||
github.com/metacubex/chacha v0.1.5 // indirect
|
||||
github.com/metacubex/chi v0.1.1 // indirect
|
||||
github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 // indirect
|
||||
github.com/metacubex/cpu v0.1.1 // indirect
|
||||
github.com/metacubex/cpu v0.1.2 // indirect
|
||||
github.com/metacubex/edwards25519 v1.2.0 // indirect
|
||||
github.com/metacubex/fswatch v0.1.1 // indirect
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 // indirect
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 // indirect
|
||||
github.com/metacubex/gvisor v0.0.0-20260922041103-e2cbcd6e7400 // indirect
|
||||
github.com/metacubex/hkdf v0.1.0 // indirect
|
||||
github.com/metacubex/hpke v0.1.0 // indirect
|
||||
github.com/metacubex/http v0.1.7 // indirect
|
||||
github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d // indirect
|
||||
github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 // indirect
|
||||
github.com/metacubex/jsonv2 v0.0.0-20260721082349-16b4998c8f89 // indirect
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 // indirect
|
||||
github.com/metacubex/mhurl v0.1.0 // indirect
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 // indirect
|
||||
github.com/metacubex/mipstack v0.0.0-20260930071539-961d4b1c1983 // indirect
|
||||
github.com/metacubex/mlkem v0.1.0 // indirect
|
||||
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb // indirect
|
||||
github.com/metacubex/qpack v0.6.0 // indirect
|
||||
@@ -79,25 +81,25 @@ require (
|
||||
github.com/metacubex/randv2 v0.2.0 // indirect
|
||||
github.com/metacubex/restls-client-go v0.1.9 // indirect
|
||||
github.com/metacubex/sevenzip v1.6.4 // indirect
|
||||
github.com/metacubex/sing v0.5.7 // indirect
|
||||
github.com/metacubex/sing-mux v0.3.10 // indirect
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 // indirect
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 // indirect
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7 // indirect
|
||||
github.com/metacubex/sing-tun v0.4.22 // indirect
|
||||
github.com/metacubex/sing v0.5.8 // indirect
|
||||
github.com/metacubex/sing-mux v0.3.12 // indirect
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a // indirect
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13 // indirect
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8 // indirect
|
||||
github.com/metacubex/sing-tun v0.4.27 // indirect
|
||||
github.com/metacubex/sing-vmess v0.2.5 // indirect
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 // indirect
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e // indirect
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 // indirect
|
||||
github.com/metacubex/ssh v0.1.0 // indirect
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb // indirect
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 // indirect
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 // indirect
|
||||
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c // indirect
|
||||
github.com/metacubex/tls v0.1.8 // indirect
|
||||
github.com/metacubex/utls v1.8.7 // indirect
|
||||
github.com/metacubex/utls v1.8.8 // indirect
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d // indirect
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f // indirect
|
||||
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 // indirect
|
||||
github.com/metacubex/zerotier-go v0.0.0-20260813124750-13fa6f45da5f // indirect
|
||||
github.com/miekg/dns v1.1.63 // indirect
|
||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||
github.com/mroth/weightedrand/v2 v2.1.0 // indirect
|
||||
github.com/oasisprotocol/deoxysii v0.0.0-20220228165953-2091330c22b7 // indirect
|
||||
@@ -127,8 +129,9 @@ require (
|
||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||
gitlab.com/go-extension/aes-ccm v0.0.0-20230221065045-e58665ef23c7 // indirect
|
||||
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 // indirect
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb // indirect
|
||||
golang.org/x/crypto v0.33.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240904232852-e7e105dedf7e // indirect
|
||||
golang.org/x/mod v0.20.0 // indirect
|
||||
@@ -140,5 +143,4 @@ require (
|
||||
golang.org/x/time v0.10.0 // indirect
|
||||
golang.org/x/tools v0.24.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
+40
-39
@@ -26,14 +26,15 @@ github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3C
|
||||
github.com/coreos/go-iptables v0.8.0 h1:MPc2P89IhuVpLI7ETL/2tx3XZ61VeICZjYqDEgNsPRc=
|
||||
github.com/coreos/go-iptables v0.8.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8=
|
||||
github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||
github.com/dunglas/httpsfv v1.0.2 h1:iERDp/YAfnojSDJ7PW3dj1AReJz4MrwbECSSE59JWL0=
|
||||
github.com/dunglas/httpsfv v1.0.2/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/enfein/mieru/v3 v3.35.0 h1:AKQT6l9R6xcYaUocpfjqE/vWEvYMxpl+071bhfDXkMY=
|
||||
github.com/enfein/mieru/v3 v3.35.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2 h1:2/9iRUcMR3QPrT2XFf/i7QV5YoSHPFjkgBM5tP8xbcc=
|
||||
github.com/easytier/easytier/easytier-go v0.0.0-20260910071355-3d0c9c3ca5e2/go.mod h1:fCEi5+K2yt+sPDONSWO1uNpOifrwPgFGvm3J0YrFRvk=
|
||||
github.com/enfein/mieru/v3 v3.38.0 h1:+DuixHFoCGEYEopoEg8fHmDJ5Ak30PuXuf1GIh2Xpu0=
|
||||
github.com/enfein/mieru/v3 v3.38.0/go.mod h1:zJBUCsi5rxyvHM8fjFf+GLaEl4OEjjBXr1s5F6Qd3hM=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358 h1:kXYqH/sL8dS/FdoFjr12ePjnLPorPo2FsnrHNuXSDyo=
|
||||
github.com/ericlagergren/aegis v0.0.0-20250325060835-cd0defd64358/go.mod h1:hkIFzoiIPZYxdFOOLyDho59b7SrDfo+w3h+yWdlg45I=
|
||||
github.com/ericlagergren/polyval v0.0.0-20220411101811-e25bc10ba391 h1:8j2RH289RJplhA6WfdaPqzg1MjH2K8wX5e0uhAxrw2g=
|
||||
@@ -102,8 +103,8 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos
|
||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78 h1:LqWr0vb9zDNuQS+jJd4fnRYk/SEI7KJ7TDe/L4WFK48=
|
||||
github.com/metacubex/age v0.0.0-20260603010618-28d156b4ea78/go.mod h1:BTBG/iVY7rg3qq5WdVCg0GFk58CSvCDSbjy8I7kEx/c=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832 h1:mAIVFc8Z9jziImzNl5bXUBVU2bTEmP2sXfosn/6YxPs=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260816073447-736a78668832/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7 h1:OJa/EzqUNScl8X4UCUfG5DDqRdEds89k2XwzBZrnqcE=
|
||||
github.com/metacubex/amneziawg-go v0.0.0-20260908071407-0c1c6f40ecd7/go.mod h1:MsM/5czONyXMJ3PRr5DbQ4O/BxzAnJWOIcJdLzW6qHY=
|
||||
github.com/metacubex/ascon v0.1.0 h1:6ZWxmXYszT1XXtwkf6nxfFhc/OTtQ9R3Vyj1jN32lGM=
|
||||
github.com/metacubex/ascon v0.1.0/go.mod h1:eV5oim4cVPPdEL8/EYaTZ0iIKARH9pnhAK/fcT5Kacc=
|
||||
github.com/metacubex/bart v0.29.0 h1:yVc/lLdDqn+7FShI0qUghStHl0/d3mn4EH/I5R5niKk=
|
||||
@@ -118,22 +119,22 @@ github.com/metacubex/chi v0.1.1 h1:GajmCIXSIPAZNxqTTYpkCrkY9suBz9wjIdgPcoQ5K1A=
|
||||
github.com/metacubex/chi v0.1.1/go.mod h1:/CvDXe8jZD/ecU8Y7fmS5XKLRR44UDmC6/IBkRIb6Z4=
|
||||
github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771 h1:nLGBvwQ2vmsD+gU/XWBQH3xaYCM3vQCJIqPfw3mc/L0=
|
||||
github.com/metacubex/connect-ip-go v0.0.0-20260727083417-67ccdb0cf771/go.mod h1:9FjDcopUc+tgUva3dauAVzrYPHd0rant+zkvyECzkMg=
|
||||
github.com/metacubex/cpu v0.1.1 h1:rRV5HGmeuGzjiKI3hYbL0dCd0qGwM7VUtk4ICXD06mI=
|
||||
github.com/metacubex/cpu v0.1.1/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU=
|
||||
github.com/metacubex/cpu v0.1.2 h1:xJHPWLkGilSWm6vEvzz8Loe+UusODRkVnuXz96TWAZo=
|
||||
github.com/metacubex/cpu v0.1.2/go.mod h1:09VEt4dSRLR+bOA8l4w4NDuzGZ8n5dkMv7e8axgEeTU=
|
||||
github.com/metacubex/edwards25519 v1.2.0 h1:pIQZLBsjQgg3Nl/c86YYFEUAbL5qQRnPq4LrgIw0KK4=
|
||||
github.com/metacubex/edwards25519 v1.2.0/go.mod h1:NCQF3J/Ki7382FJuokwsywEIIEI/gro/3smyXgQJsx0=
|
||||
github.com/metacubex/fswatch v0.1.1 h1:jqU7C/v+g0qc2RUFgmAOPoVvfl2BXXUXEumn6oQuxhU=
|
||||
github.com/metacubex/fswatch v0.1.1/go.mod h1:czrTT7Zlbz7vWft8RQu9Qqh+JoX+Nnb+UabuyN1YsgI=
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759 h1:cjd4biTvOzK9ubNCCkQ+ldc4YSH/rILn53l/xGBFHHI=
|
||||
github.com/metacubex/gopacket v1.1.20-0.20230608035415-7e2f98a3e759/go.mod h1:UHOv2xu+RIgLwpXca7TLrXleEd4oR3sPatW6IF8wU88=
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22 h1:C4d4/BA8WuQmJnSpjy0UN4vJvRMjU3133rxIYQgbPPs=
|
||||
github.com/metacubex/gvisor v0.0.0-20260810011720-3cc44cf9ac22/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
|
||||
github.com/metacubex/gvisor v0.0.0-20260922041103-e2cbcd6e7400 h1:r9YO+xZUWi+xQROzdYkKL2twoliSJh34vOHMVAqsaL8=
|
||||
github.com/metacubex/gvisor v0.0.0-20260922041103-e2cbcd6e7400/go.mod h1:mBJW3UXUusd8ZYO5M6mig0uScIey9iIubgdIiQAk2ug=
|
||||
github.com/metacubex/hkdf v0.1.0 h1:fPA6VzXK8cU1foc/TOmGCDmSa7pZbxlnqhl3RNsthaA=
|
||||
github.com/metacubex/hkdf v0.1.0/go.mod h1:3seEfds3smgTAXqUGn+tgEJH3uXdsUjOiduG/2EtvZ4=
|
||||
github.com/metacubex/hpke v0.1.0 h1:gu2jUNhraehWi0P/z5HX2md3d7L1FhPQE6/Q0E9r9xQ=
|
||||
github.com/metacubex/hpke v0.1.0/go.mod h1:vfDm6gfgrwlXUxKDkWbcE44hXtmc1uxLDm2BcR11b3U=
|
||||
github.com/metacubex/http v0.1.7 h1:dEaQmijUaR/2QKqgjBUmPZCRuh1zBfDqB6ZRNLwvbHg=
|
||||
github.com/metacubex/http v0.1.7/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
|
||||
github.com/metacubex/http v0.1.8 h1:d91onDan2ZXYE8W/uAy5oFmXHH2xi1L7LbGPV5EmCM0=
|
||||
github.com/metacubex/http v0.1.8/go.mod h1:Nxx0zZAo2AhRfanyL+fmmK6ACMtVsfpwIl1aFAik2Eg=
|
||||
github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d h1:OF3TUGKdHRrRMp7nb0Pa72QYwKPol9NqSy2yTcP2Sog=
|
||||
github.com/metacubex/jls-quic-go v0.0.0-20260727080412-732f2fc9a34d/go.mod h1:dH8StPlpZ7atSUuTnfTnRXvJK22nOtQVimNwQ9Gmk58=
|
||||
github.com/metacubex/jls-tls v0.0.0-20260723084315-67adc0e2f796 h1:1iI4np/Dm1ztCfTW2LoN166O6n728HXMc11aIazYdps=
|
||||
@@ -144,8 +145,8 @@ github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604 h1:hJwCVlE3ojViC3
|
||||
github.com/metacubex/kcp-go v0.0.0-20260105040817-550693377604/go.mod h1:lpmN3m269b3V5jFCWtffqBLS4U3QQoIid9ugtO+OhVc=
|
||||
github.com/metacubex/mhurl v0.1.0 h1:ZdW4Zxe3j3uJ89gNytOazHu6kbHn5owutN/VfXOI8GE=
|
||||
github.com/metacubex/mhurl v0.1.0/go.mod h1:2qpQImCbXoUs6GwJrjuEXKelPyoimsIXr07eNKZdS00=
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13 h1:aOU4avd9u7uFivubzn+SjFmEDWAkGF+gvZ0ecEdeg64=
|
||||
github.com/metacubex/mipstack v0.0.0-20260816065001-b7038299fe13/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
|
||||
github.com/metacubex/mipstack v0.0.0-20260930071539-961d4b1c1983 h1:BGb9PDJrrZSsDnJyeFWFY9v6uTr6/LxokQgwuHVAsDI=
|
||||
github.com/metacubex/mipstack v0.0.0-20260930071539-961d4b1c1983/go.mod h1:+bbwALZI0pbi2auSG5A3ptdpV2DZ2eLObziXo+P7oj0=
|
||||
github.com/metacubex/mlkem v0.1.0 h1:wFClitonSFcmipzzQvax75beLQU+D7JuC+VK1RzSL8I=
|
||||
github.com/metacubex/mlkem v0.1.0/go.mod h1:amhaXZVeYNShuy9BILcR7P0gbeo/QLZsnqCdL8U2PDQ=
|
||||
github.com/metacubex/nftables v0.0.0-20260426003805-208c2c1ba2cb h1:wk6mHYPURSUvWcUv72gNP79oiylFsscBSDPJ6ieV6Iw=
|
||||
@@ -160,36 +161,38 @@ github.com/metacubex/restls-client-go v0.1.9 h1:QmLKwVFuAjB6rL9lQNKj8CuwHqGMJjV3
|
||||
github.com/metacubex/restls-client-go v0.1.9/go.mod h1:BN/U52vPw7j8VTSh2vleD/MnmVKCov84mS5VcjVHH4g=
|
||||
github.com/metacubex/sevenzip v1.6.4 h1:OIL+DeOeSAbKNsjqxcYUMiarRmX6Kaxakb0GT7E9Oik=
|
||||
github.com/metacubex/sevenzip v1.6.4/go.mod h1:FP3X9bzFKj9wPxifGN9B3w2fIEicMjzKYIGIhnu+1pw=
|
||||
github.com/metacubex/sing v0.5.7 h1:8OC+fhKFSv/l9ehEhJRaZZAOuthfZo68SteBVLe8QqM=
|
||||
github.com/metacubex/sing v0.5.7/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
|
||||
github.com/metacubex/sing-mux v0.3.10 h1:r5CuZ/KuwFsEcRRwpLvzLncW4fDzNfmSEcBEWcy/+94=
|
||||
github.com/metacubex/sing-mux v0.3.10/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51 h1:FpQj1XfYBIm4LAml+LDjvnfjDs20cSDsFmgjThOqmks=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260726014900-38b0e9295f51/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12 h1:Wqzo8bYXrK5aWqxu/TjlTnYZzAKtKsaFQBdr6IHFaBE=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.12/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7 h1:hSuuc0YpsfiqYqt1o+fP4m34BQz4e6wVj3PPBVhor3A=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.7/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||
github.com/metacubex/sing-tun v0.4.22 h1:6ARRJ2BIFD1u4r/DTMNcxaNuGyimfXEeUyD4iFJRaZs=
|
||||
github.com/metacubex/sing-tun v0.4.22/go.mod h1:9T0SN+FHvzRrn7hgl2kmXy5v8xzzLKW1hITButIPAco=
|
||||
github.com/metacubex/sing v0.5.8 h1:OwJDSPP+pza6Ywt7NMR4kEYq+UNrsIywSk/eAMlqxz8=
|
||||
github.com/metacubex/sing v0.5.8/go.mod h1:ypf0mjwlZm0sKdQSY+yQvmsbWa0hNPtkeqyRMGgoN+w=
|
||||
github.com/metacubex/sing-mux v0.3.12 h1:80jrJsGgRW7ZHpJCRpwJhG3yzT5muEHIWjaOsDJZ2wE=
|
||||
github.com/metacubex/sing-mux v0.3.12/go.mod h1:8bT7ZKT3clRrJjYc/x5CRYibC1TX/bK73a3r3+2E+Fc=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a h1:qZzQwtWDrPU1LXoMow0/iLHY3mb7uauS/mho8hYcenE=
|
||||
github.com/metacubex/sing-quic v0.0.0-20260904234848-1c242664697a/go.mod h1:6ayFGfzzBE85csgQkM3gf4neFq6s0losHlPRSxY+nuk=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13 h1:PKmInHy9+4nY3FLhmKcWdASUBo9WZ/KrSPWjNpGaUTQ=
|
||||
github.com/metacubex/sing-shadowsocks v0.2.13/go.mod h1:2e5EIaw0rxKrm1YTRmiMnDulwbGxH9hAFlrwQLQMQkU=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8 h1:6MoODu7BAcnwIC0w9muq+2LZVz01wWD07L5IRhOty9Q=
|
||||
github.com/metacubex/sing-shadowsocks2 v0.2.8/go.mod h1:vOEbfKC60txi0ca+yUlqEwOGc3Obl6cnSgx9Gf45KjE=
|
||||
github.com/metacubex/sing-tun v0.4.27 h1:dWCpFDlWelQrhH3y5KkMrJXEXYeJ0V2LROP8Cxv96MI=
|
||||
github.com/metacubex/sing-tun v0.4.27/go.mod h1:5nlmrOy+FG0yhiLm+O/pzmirZVrpLuDQZuA+qI9x5kg=
|
||||
github.com/metacubex/sing-vmess v0.2.5 h1:m9Zt5I27lB9fmLMZfism9sH2LcnAfShZfwSkf6/KJoE=
|
||||
github.com/metacubex/sing-vmess v0.2.5/go.mod h1:AwtlzUgf8COe9tRYAKqWZ+leDH7p5U98a0ZUpYehl8Q=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0 h1:4g/ObGTfHaY2zXcFjKOUO9aAFk+yFjxsZfUPQPG6J8c=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260810013230-110eac03c3f0/go.mod h1:H27NH3IgI4qk1Wj8kS9pneNMJYEuxFskcQfkfVW9vnw=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e h1:KGKZt//rrELT/dEwgYcHCqNYEGr3a7uVutlZrzrZZWY=
|
||||
github.com/metacubex/sing-wireguard v0.0.0-20260826105301-c3ae17d19f9e/go.mod h1:OiYj4hitKrN1YIrgGHXp6zKnc9asCspF+SrYlLN2hYQ=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141 h1:DK2l6m2Fc85H2BhiAPgbJygiWhesPlfGmF+9Vw6ARdk=
|
||||
github.com/metacubex/smux v0.0.0-20260105030934-d0c8756d3141/go.mod h1:/yI4OiGOSn0SURhZdJF3CbtPg3nwK700bG8TZLMBvAg=
|
||||
github.com/metacubex/ssh v0.1.0 h1:iGfr99qk/eMHzUnQ/0bTxXT8+8SWqLSHBWDHoAhngzw=
|
||||
github.com/metacubex/ssh v0.1.0/go.mod h1:NUtl0d+/f2cG9ECEpMM8iCVOpmggQlC13oLeDUONDlU=
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb h1:EE4+ehlygfki5qJvkIYs6zJXhv7WFAX+4cDQi/b6cY8=
|
||||
github.com/metacubex/tailscale v0.0.0-20260807072706-a4fb5feabcbb/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181 h1:hWsSSzuCHe+WvPV1zhoJ8gXTXbp/5nhpPKxtpqgIXu0=
|
||||
github.com/metacubex/tailscale v0.0.0-20260821153257-ff0ecd818181/go.mod h1:fMMYScJPYt8Ss3D4VhontA2uV2N9NPZbbXsPP6LgQeo=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2 h1:nKyLVJEU4jsLVx+rdqbDUbEKTX/Sdu161AeVoZC9tlY=
|
||||
github.com/metacubex/tailscale-wireguard-go v0.0.0-20260725073821-e61ab99cede2/go.mod h1:W00EeoPdd2jbV3+ZUa0m2b7FdKx5B1LFsXvEpy7cToQ=
|
||||
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c h1:+2C1UshSLzaogLY/IDz1XhOLxjhs3rkhVHB4eCN9Zto=
|
||||
github.com/metacubex/tfo-go v0.0.0-20260623020846-376a77860b8c/go.mod h1:l9oLnLoEXyGZ5RVLsh7QCC5XsouTUyKk4F2nLm2DHLw=
|
||||
github.com/metacubex/tls v0.1.8 h1:BHF2payjtxC7wdR3tnq8vl3FAgEQB6/pPjP2tJNTsP4=
|
||||
github.com/metacubex/tls v0.1.8/go.mod h1:0XeVdL0cBw+8i5Hqy3lVeP9IyD/LFTq02ExvHM6rzEM=
|
||||
github.com/metacubex/utls v1.8.7 h1:Cp+yWkNTFkSihETgGWq34hlVFds5HpYWVOR1xovUVTs=
|
||||
github.com/metacubex/utls v1.8.7/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||
github.com/metacubex/utls v1.8.8 h1:7fiMYahL+tT1I/vG1MWidv88r/3A5NnPrurjzApZGyQ=
|
||||
github.com/metacubex/utls v1.8.8/go.mod h1:kncGGVhFaoGn5M3pFe3SXhZCzsbCJayNOH4UEqTKTko=
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d h1:UMFI+kdp0jA8s9tC7oul0pWeTW6s8lnm0dD5PT+RY14=
|
||||
github.com/metacubex/wazero v0.0.0-20260628025728-9ae6bdcf2a7d/go.mod h1:p48xp436h1oGfoXuEnVONeDhTW+E2UpY94GAVxA62oI=
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f h1:FGBPRb1zUabhPhDrlKEjQ9lgIwQ6cHL4x8M9lrERhbk=
|
||||
github.com/metacubex/wireguard-go v0.0.0-20250820062549-a6cecdd7f57f/go.mod h1:oPGcV994OGJedmmxrcK9+ni7jUEMGhR+uVQAdaduIP4=
|
||||
github.com/metacubex/yamux v0.0.0-20250918083631-dd5f17c0be49 h1:lhlqpYHopuTLx9xQt22kSA9HtnyTDmk5XjjQVCGHe2E=
|
||||
@@ -217,7 +220,6 @@ github.com/pierrec/lz4/v4 v4.1.27/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcR
|
||||
github.com/pires/go-proxyproto v0.8.0 h1:5unRmEAPbHXHuLjDg01CxJWf91cw3lKHc/0xzKpXEe0=
|
||||
github.com/pires/go-proxyproto v0.8.0/go.mod h1:iknsfgnH8EkjrMeMyvfKByp9TiBZCKZM0jx2xmKqnVY=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e h1:dCWirM5F3wMY+cmRda/B1BiPsFtmzXqV9b0hLWtVBMs=
|
||||
github.com/rasky/go-lzo v0.0.0-20200203143853-96a758eda86e/go.mod h1:9leZcVcItj6m9/CfHY5Em/iBrCz7js8LcRQGTKEEv2M=
|
||||
@@ -250,8 +252,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d h1:JcGKBZAL7ePLwOhUdN8qGQZlP5GueEiIZwY7R62pejE=
|
||||
github.com/tailscale/certstore v0.1.1-0.20260409135935-3638fb84b77d/go.mod h1:XrBNfAFN+pwoWuksbFS9Ccxnopa15zJGgXRFN90l3K4=
|
||||
github.com/tailscale/go-winio v0.0.0-20231025203758-c4f33415bf55 h1:Gzfnfk2TWrk8Jj4P4c1a3CtQyMaTVCznlkLZI++hok4=
|
||||
@@ -287,10 +289,12 @@ gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec h1:FpfFs4EhNehiV
|
||||
gitlab.com/yawning/bsaes.git v0.0.0-20190805113838-0a714cd429ec/go.mod h1:BZ1RAoRPbCxum9Grlv5aeksu2H8BiKehBYooU2LFiOQ=
|
||||
go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU=
|
||||
go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745 h1:Tl++JLUCe4sxGu8cTpDzRLd3tN7US4hOxG5YpKCzkek=
|
||||
go4.org/mem v0.0.0-20240501181205-ae6ca9944745/go.mod h1:reUoABIJ9ikfM5sgtSF3Wushcza7+WeD01VB9Lirh3g=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb h1:XBM4hvfwGAttkkiTIFfeigdfcL1xIfdKXqFdgiHGtDs=
|
||||
go4.org/netipx v0.0.0-20260823151212-3075585bcbeb/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus=
|
||||
@@ -333,10 +337,7 @@ golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8T
|
||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k=
|
||||
software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
|
||||
|
||||
+183
-27
@@ -3,7 +3,12 @@ package main
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"maps"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
@@ -12,12 +17,13 @@ import (
|
||||
"runtime/debug"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/common/observable"
|
||||
"github.com/metacubex/mihomo/common/utils"
|
||||
"github.com/metacubex/mihomo/component/resolver"
|
||||
@@ -25,6 +31,7 @@ import (
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/constant/features"
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
"github.com/metacubex/mihomo/hub/executor"
|
||||
"github.com/metacubex/mihomo/listener"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
@@ -54,6 +61,7 @@ func handleStartListener() bool {
|
||||
isRunning.Store(true)
|
||||
updateListeners(currentConfig)
|
||||
resolver.ResetConnection()
|
||||
refreshRoute()
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -81,6 +89,7 @@ func handleForceGC() {
|
||||
|
||||
func handleShutdown() bool {
|
||||
handleStopLog()
|
||||
stopRouteWatch()
|
||||
|
||||
configMu.Lock()
|
||||
isRunning.Store(false)
|
||||
@@ -106,6 +115,33 @@ func handleValidateConfig(path string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Tailscale and EasyTier register a DNS client under the proxy name when built
|
||||
// and drop that name's entry on Close, so a probe under the real name would
|
||||
// take the resolver from a running proxy of the same name.
|
||||
const validationNameSuffix = "\x00validate"
|
||||
|
||||
// The per-proxy parser the config load runs; checks across proxies, such as
|
||||
// duplicate names, are left to the load itself.
|
||||
func handleValidateProxies(mappings []map[string]any) []string {
|
||||
results := make([]string, len(mappings))
|
||||
for i, mapping := range mappings {
|
||||
name, _ := mapping["name"].(string)
|
||||
probe := mapping
|
||||
switch mapping["type"] {
|
||||
case "tailscale", "easytier":
|
||||
probe = maps.Clone(mapping)
|
||||
probe["name"] = name + validationNameSuffix
|
||||
}
|
||||
proxy, err := adapter.ParseProxy(probe)
|
||||
if err != nil {
|
||||
results[i] = strings.ReplaceAll(err.Error(), validationNameSuffix, "")
|
||||
continue
|
||||
}
|
||||
_ = proxy.Close()
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
const globalProxyName = "GLOBAL"
|
||||
|
||||
func isProxyGroupType(adapterType constant.AdapterType) bool {
|
||||
@@ -155,12 +191,35 @@ func handleGetProxies() ProxiesData {
|
||||
return p.Type(), true
|
||||
})
|
||||
|
||||
views := make(map[string]any, len(proxies))
|
||||
for name, proxy := range proxies {
|
||||
views[name] = proxyView(proxy)
|
||||
}
|
||||
return ProxiesData{
|
||||
All: allNames,
|
||||
Proxies: proxies,
|
||||
Proxies: views,
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy.MarshalJSON encodes each node twice, with history the host never reads.
|
||||
func proxyView(proxy constant.Proxy) any {
|
||||
node := nodeView{Name: proxy.Name(), Type: proxy.Type().String()}
|
||||
if !isProxyGroupType(proxy.Type()) {
|
||||
return node
|
||||
}
|
||||
data, err := proxy.Adapter().MarshalJSON()
|
||||
view := map[string]any{}
|
||||
if err == nil {
|
||||
err = json.Unmarshal(data, &view)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnln("[APP] encode group %s: %v", node.Name, err)
|
||||
return node
|
||||
}
|
||||
view["name"] = node.Name
|
||||
return view
|
||||
}
|
||||
|
||||
var (
|
||||
errGroupNotFound = errors.New("Not found group")
|
||||
errGroupInvalidType = errors.New("Group has invalid proxy type")
|
||||
@@ -171,7 +230,7 @@ func lookupProxy(name string) constant.Proxy {
|
||||
return tunnel.AllProxies()[name]
|
||||
}
|
||||
|
||||
func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
|
||||
func selectableGroup(groupName string) (pickableGroup, error) {
|
||||
group := lookupProxy(groupName)
|
||||
if group == nil {
|
||||
return nil, errGroupNotFound
|
||||
@@ -180,29 +239,30 @@ func selectableGroup(groupName string) (outboundgroup.SelectAble, error) {
|
||||
if !ok {
|
||||
return nil, errGroupInvalidType
|
||||
}
|
||||
selector, ok := adapterProxy.ProxyAdapter.(outboundgroup.SelectAble)
|
||||
selector, ok := adapterProxy.ProxyAdapter.(pickableGroup)
|
||||
if !ok {
|
||||
return nil, errGroupNotSelect
|
||||
}
|
||||
return selector, nil
|
||||
}
|
||||
|
||||
func handleChangeProxy(params *ChangeProxyParams) string {
|
||||
func handleChangeProxy(params *ChangeProxyParams) *ChangeProxyResult {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
|
||||
selector, err := selectableGroup(params.GroupName)
|
||||
if err != nil {
|
||||
return err.Error()
|
||||
return &ChangeProxyResult{Message: err.Error()}
|
||||
}
|
||||
before := selector.Now()
|
||||
if params.ProxyName == "" {
|
||||
selector.ForceSet(params.ProxyName)
|
||||
return ""
|
||||
} else if err := selector.Set(params.ProxyName); err != nil {
|
||||
return &ChangeProxyResult{Message: err.Error()}
|
||||
}
|
||||
if err := selector.Set(params.ProxyName); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
changed := selector.Now() != before
|
||||
refreshRouteLocked(false)
|
||||
return &ChangeProxyResult{Changed: changed}
|
||||
}
|
||||
|
||||
func handleGetTraffic(onlyStatisticsProxy bool) Traffic {
|
||||
@@ -307,6 +367,15 @@ func handleGetConnections() *statistic.Snapshot {
|
||||
return statistic.DefaultManager.Snapshot()
|
||||
}
|
||||
|
||||
func handleGetConnectionCount() int {
|
||||
count := 0
|
||||
statistic.DefaultManager.Range(func(statistic.Tracker) bool {
|
||||
count++
|
||||
return true
|
||||
})
|
||||
return count
|
||||
}
|
||||
|
||||
func handleCloseConnections() bool {
|
||||
statistic.DefaultManager.Range(func(c statistic.Tracker) bool {
|
||||
_ = c.Close()
|
||||
@@ -440,20 +509,65 @@ func handleUpdateGeoData(geoType string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func providerRequestErrorCode(err error) string {
|
||||
type providerRequestFailure struct {
|
||||
code string
|
||||
reason string
|
||||
statusCode int
|
||||
}
|
||||
|
||||
// The reason values are matched by lib/common/network_error.dart.
|
||||
func classifyProviderRequestError(err error) (providerRequestFailure, bool) {
|
||||
// mihomo reports a non-2xx fetch as errors.New(resp.Status).
|
||||
message := err.Error()
|
||||
if len(message) >= 4 && message[3] == ' ' {
|
||||
status, parseErr := strconv.Atoi(message[:3])
|
||||
if parseErr == nil && status >= 100 && status <= 599 {
|
||||
return "request_bad_response"
|
||||
return providerRequestFailure{
|
||||
code: "request_bad_response",
|
||||
statusCode: status,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
var urlError *url.Error
|
||||
var networkError net.Error
|
||||
if errors.Is(err, context.DeadlineExceeded) ||
|
||||
if reason := providerRequestFailureReason(err); reason != "" ||
|
||||
errors.As(err, &urlError) ||
|
||||
errors.As(err, &networkError) {
|
||||
return "request_error"
|
||||
return providerRequestFailure{code: "request_error", reason: reason}, true
|
||||
}
|
||||
return providerRequestFailure{}, false
|
||||
}
|
||||
|
||||
func providerRequestFailureReason(err error) string {
|
||||
var unknownAuthority x509.UnknownAuthorityError
|
||||
var hostname x509.HostnameError
|
||||
var invalidCertificate x509.CertificateInvalidError
|
||||
var certificateVerification *tls.CertificateVerificationError
|
||||
var recordHeader tls.RecordHeaderError
|
||||
var alert tls.AlertError
|
||||
var dnsError *net.DNSError
|
||||
var networkError net.Error
|
||||
var opError *net.OpError
|
||||
switch {
|
||||
case errors.As(err, &unknownAuthority),
|
||||
errors.As(err, &hostname),
|
||||
errors.As(err, &invalidCertificate),
|
||||
errors.As(err, &certificateVerification),
|
||||
errors.As(err, &recordHeader),
|
||||
errors.As(err, &alert):
|
||||
return "tls"
|
||||
case errors.As(err, &dnsError), errors.Is(err, resolver.ErrIPNotFound):
|
||||
return "dns"
|
||||
case errors.Is(err, context.DeadlineExceeded),
|
||||
errors.Is(err, os.ErrDeadlineExceeded),
|
||||
errors.As(err, &networkError) && networkError.Timeout():
|
||||
return "timeout"
|
||||
case errors.As(err, &opError),
|
||||
errors.Is(err, syscall.ECONNREFUSED),
|
||||
errors.Is(err, syscall.ECONNRESET),
|
||||
errors.Is(err, io.EOF),
|
||||
errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return "connection"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -466,6 +580,25 @@ func providerMethodError(code, providerName string, err error) *MethodError {
|
||||
}
|
||||
}
|
||||
|
||||
func providerRequestMethodError(
|
||||
failure providerRequestFailure,
|
||||
providerName string,
|
||||
err error,
|
||||
) *MethodError {
|
||||
details := map[string]any{"providerName": providerName}
|
||||
if failure.reason != "" {
|
||||
details["reason"] = failure.reason
|
||||
}
|
||||
if failure.statusCode != 0 {
|
||||
details["statusCode"] = failure.statusCode
|
||||
}
|
||||
return &MethodError{
|
||||
Code: failure.code,
|
||||
Message: err.Error(),
|
||||
Details: details,
|
||||
}
|
||||
}
|
||||
|
||||
func handleUpdateExternalProvider(providerName string) *MethodError {
|
||||
p, exist := lookupExternalProvider(providerName)
|
||||
if !exist {
|
||||
@@ -485,12 +618,12 @@ func handleUpdateExternalProvider(providerName string) *MethodError {
|
||||
}
|
||||
defer releaseUpdate(key)
|
||||
if err := p.Update(); err != nil {
|
||||
code := providerRequestErrorCode(err)
|
||||
if code == "" {
|
||||
code = "provider_update_error"
|
||||
if failure, ok := classifyProviderRequestError(err); ok {
|
||||
return providerRequestMethodError(failure, providerName, err)
|
||||
}
|
||||
return providerMethodError(code, providerName, err)
|
||||
return providerMethodError("provider_update_error", providerName, err)
|
||||
}
|
||||
refreshRoute()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -515,6 +648,7 @@ func handleSideLoadExternalProvider(providerName string, data []byte) *MethodErr
|
||||
if err := sideUpdateExternalProvider(p, data); err != nil {
|
||||
return providerMethodError("provider_update_error", providerName, err)
|
||||
}
|
||||
refreshRoute()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -532,9 +666,10 @@ func defaultRefreshHealthChecks() {
|
||||
|
||||
var refreshHealthChecks = defaultRefreshHealthChecks
|
||||
|
||||
func handleSuspend(suspended bool) bool {
|
||||
wasSuspended := isSuspended.Swap(suspended)
|
||||
func handleSuspend(suspended, interactive bool) bool {
|
||||
isSuspended.Store(suspended)
|
||||
if suspended {
|
||||
healthChecksStale.Store(true)
|
||||
tunnel.OnSuspend()
|
||||
return true
|
||||
}
|
||||
@@ -544,10 +679,11 @@ func handleSuspend(suspended bool) bool {
|
||||
// network at all, so coming back means every proxy is marked dead and every
|
||||
// delay reads Timeout. A lazy provider then skips its next tick because
|
||||
// nothing touched it in the meantime, and the whole list stays wrong until
|
||||
// the user tests by hand. Re-check now instead - but not while the
|
||||
// listeners are stopped, since the service also resumes the core on its way
|
||||
// down.
|
||||
if wasSuspended && isRunning.Load() {
|
||||
// the user tests by hand. Re-check once the screen is on: a maintenance
|
||||
// window resumes the core too, and probing every node there spends the
|
||||
// window's radio time on results nobody sees. Not while the listeners are
|
||||
// stopped either, since the service also resumes the core on its way down.
|
||||
if interactive && healthChecksStale.Swap(false) && isRunning.Load() {
|
||||
refreshHealthChecks()
|
||||
}
|
||||
return true
|
||||
@@ -620,8 +756,18 @@ func handleStopLog() {
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetMemory() uint64 {
|
||||
return statistic.DefaultManager.Memory()
|
||||
// HeapIdle still counts spans the runtime has already handed back to the OS,
|
||||
// so the retained-but-unused figure subtracts HeapReleased.
|
||||
func handleGetMemoryStats() MemoryStats {
|
||||
var stats runtime.MemStats
|
||||
runtime.ReadMemStats(&stats)
|
||||
return MemoryStats{
|
||||
Rss: statistic.DefaultManager.Memory(),
|
||||
HeapInuse: stats.HeapInuse,
|
||||
HeapIdle: stats.HeapIdle - stats.HeapReleased,
|
||||
StackInuse: stats.StackInuse,
|
||||
RuntimeOther: stats.MSpanInuse + stats.MCacheInuse + stats.BuckHashSys + stats.GCSys + stats.OtherSys,
|
||||
}
|
||||
}
|
||||
|
||||
func handleGetConfig(path string) (*config.RawConfig, error) {
|
||||
@@ -695,11 +841,18 @@ func init() {
|
||||
})
|
||||
}
|
||||
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
|
||||
notifyProbeRoute(c)
|
||||
sendMessage(Message{
|
||||
Type: RequestMessage,
|
||||
Data: c,
|
||||
})
|
||||
}
|
||||
dns.DefaultQueryNotify = func(record dns.QueryRecord) {
|
||||
sendMessage(Message{
|
||||
Type: DnsMessage,
|
||||
Data: newDnsQuery(record),
|
||||
})
|
||||
}
|
||||
executor.DefaultProviderLoadedHook = func(providerName string) {
|
||||
scheduleReclaimOwnership()
|
||||
sendMessage(Message{
|
||||
@@ -713,6 +866,9 @@ func init() {
|
||||
} else {
|
||||
releaseGeoUpdateFromHook(geoType)
|
||||
scheduleReclaimOwnership()
|
||||
if !skipped && updateErr == nil {
|
||||
bumpRouteEpoch()
|
||||
}
|
||||
}
|
||||
status := GeoUpdateStatus{
|
||||
Type: geoType,
|
||||
|
||||
+243
-14
@@ -2,8 +2,12 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
@@ -11,6 +15,7 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -19,11 +24,14 @@ import (
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
"github.com/metacubex/mihomo/common/utils"
|
||||
"github.com/metacubex/mihomo/component/resolver"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
cp "github.com/metacubex/mihomo/constant/provider"
|
||||
"github.com/metacubex/mihomo/dns"
|
||||
"github.com/metacubex/mihomo/log"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
D "github.com/miekg/dns"
|
||||
)
|
||||
|
||||
func namedProxy(name string) constant.Proxy {
|
||||
@@ -110,18 +118,20 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
|
||||
refreshHealthChecks = previous
|
||||
isRunning.Store(previousRunning)
|
||||
isSuspended.Store(false)
|
||||
healthChecksStale.Store(false)
|
||||
tunnel.OnRunning()
|
||||
})
|
||||
|
||||
isSuspended.Store(false)
|
||||
healthChecksStale.Store(false)
|
||||
isRunning.Store(true)
|
||||
|
||||
handleSuspend(false)
|
||||
handleSuspend(false, true)
|
||||
if got := refreshes.Load(); got != 0 {
|
||||
t.Errorf("refreshes = %d, want none: the device was never suspended", got)
|
||||
}
|
||||
|
||||
handleSuspend(true)
|
||||
handleSuspend(true, false)
|
||||
if !isSuspended.Load() {
|
||||
t.Error("handleSuspend(true) did not record the suspension")
|
||||
}
|
||||
@@ -129,7 +139,7 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
|
||||
t.Errorf("refreshes = %d, want none while the device is still suspended", got)
|
||||
}
|
||||
|
||||
handleSuspend(false)
|
||||
handleSuspend(false, true)
|
||||
if isSuspended.Load() {
|
||||
t.Error("handleSuspend(false) did not clear the suspension")
|
||||
}
|
||||
@@ -137,7 +147,7 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
|
||||
t.Errorf("refreshes = %d, want exactly one on resume", got)
|
||||
}
|
||||
|
||||
handleSuspend(false)
|
||||
handleSuspend(false, true)
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want a redundant resume to change nothing", got)
|
||||
}
|
||||
@@ -145,13 +155,48 @@ func TestHandleSuspendRefreshesHealthChecksOnResume(t *testing.T) {
|
||||
// The service resumes the core on its way down, and probing every node
|
||||
// through a teardown only produces failures nobody asked for.
|
||||
isRunning.Store(false)
|
||||
handleSuspend(true)
|
||||
handleSuspend(false)
|
||||
handleSuspend(true, false)
|
||||
handleSuspend(false, true)
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want no probe while the listeners are stopped", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSuspendHoldsTheRefreshUntilTheScreenIsOn(t *testing.T) {
|
||||
var refreshes atomic.Int32
|
||||
|
||||
previous := refreshHealthChecks
|
||||
previousRunning := isRunning.Load()
|
||||
refreshHealthChecks = func() { refreshes.Add(1) }
|
||||
t.Cleanup(func() {
|
||||
refreshHealthChecks = previous
|
||||
isRunning.Store(previousRunning)
|
||||
isSuspended.Store(false)
|
||||
healthChecksStale.Store(false)
|
||||
tunnel.OnRunning()
|
||||
})
|
||||
|
||||
isSuspended.Store(false)
|
||||
healthChecksStale.Store(false)
|
||||
isRunning.Store(true)
|
||||
|
||||
handleSuspend(true, false)
|
||||
handleSuspend(false, false)
|
||||
if isSuspended.Load() {
|
||||
t.Error("a maintenance window has to resume the core, or nothing syncs in it")
|
||||
}
|
||||
handleSuspend(true, false)
|
||||
handleSuspend(false, false)
|
||||
if got := refreshes.Load(); got != 0 {
|
||||
t.Errorf("refreshes = %d, want none in maintenance windows with the screen off", got)
|
||||
}
|
||||
|
||||
handleSuspend(false, true)
|
||||
if got := refreshes.Load(); got != 1 {
|
||||
t.Errorf("refreshes = %d, want the held refresh once the screen turns on", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldPublishDelayDropsFailuresMeasuredInDoze(t *testing.T) {
|
||||
t.Cleanup(func() { isSuspended.Store(false) })
|
||||
|
||||
@@ -371,6 +416,66 @@ func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleValidateProxiesReportsEachProxyInPlace(t *testing.T) {
|
||||
got := handleValidateProxies([]map[string]any{
|
||||
{"name": "ok", "type": "socks5", "server": "127.0.0.1", "port": 1080},
|
||||
{"name": "untyped", "server": "127.0.0.1", "port": 1080},
|
||||
{"name": "unknown", "type": "nope"},
|
||||
})
|
||||
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("handleValidateProxies returned %d results, want 3", len(got))
|
||||
}
|
||||
if got[0] != "" {
|
||||
t.Errorf("valid proxy reported %q", got[0])
|
||||
}
|
||||
if !strings.Contains(got[1], "missing type") {
|
||||
t.Errorf("untyped proxy reported %q, want missing type", got[1])
|
||||
}
|
||||
if !strings.Contains(got[2], "unsupport") {
|
||||
t.Errorf("unknown type reported %q, want unsupported", got[2])
|
||||
}
|
||||
}
|
||||
|
||||
type answeringDNSClient struct{}
|
||||
|
||||
func (answeringDNSClient) Address() string { return "answering" }
|
||||
func (answeringDNSClient) ResetConnection() {}
|
||||
func (answeringDNSClient) ExchangeContext(_ context.Context, m *D.Msg) (*D.Msg, error) {
|
||||
return new(D.Msg).SetReply(m), nil
|
||||
}
|
||||
|
||||
func TestHandleValidateProxiesLeavesARunningProxysResolverInPlace(t *testing.T) {
|
||||
t.Cleanup(dns.RegisterEasyTierDnsClient("mesh", answeringDNSClient{}))
|
||||
easyTier := func(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"type": "easytier",
|
||||
"network-name": "net",
|
||||
"peers": []any{"tcp://127.0.0.1:11010"},
|
||||
}
|
||||
}
|
||||
|
||||
got := handleValidateProxies([]map[string]any{
|
||||
easyTier("mesh"),
|
||||
easyTier("../../../../../../../../../../outside"),
|
||||
})
|
||||
|
||||
if got[0] != "" {
|
||||
t.Fatalf("easytier proxy reported %q", got[0])
|
||||
}
|
||||
if !strings.Contains(got[1], "/outside") || strings.Contains(got[1], "\x00") {
|
||||
t.Errorf("unsafe state dir reported %q, want the proxy's own name", got[1])
|
||||
}
|
||||
resolvers := dns.NewResolver(dns.Config{
|
||||
Main: []dns.NameServer{{Net: "easytier", Addr: "mesh"}},
|
||||
})
|
||||
query := new(D.Msg).SetQuestion("host.mesh.", D.TypeA)
|
||||
if _, err := resolvers.ExchangeContext(context.Background(), query); err != nil {
|
||||
t.Fatalf("the running proxy lost its resolver: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleValidateConfigReportsAMissingFile(t *testing.T) {
|
||||
got := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml"))
|
||||
|
||||
@@ -475,7 +580,7 @@ func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) {
|
||||
|
||||
answered := make(chan string, 1)
|
||||
go func() {
|
||||
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"})
|
||||
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}).Message
|
||||
}()
|
||||
|
||||
select {
|
||||
@@ -598,22 +703,75 @@ func (p *failingProxyProvider) Update() error {
|
||||
return p.err
|
||||
}
|
||||
|
||||
func TestProviderRequestErrorCode(t *testing.T) {
|
||||
func TestClassifyProviderRequestError(t *testing.T) {
|
||||
tests := []struct {
|
||||
err error
|
||||
want string
|
||||
want providerRequestFailure
|
||||
ok bool
|
||||
}{
|
||||
{err: errors.New("503 Service Unavailable"), want: "request_bad_response"},
|
||||
{err: fmt.Errorf("fetch: %w", context.DeadlineExceeded), want: "request_error"},
|
||||
{err: errors.New("proxy 0: unsupported type"), want: ""},
|
||||
{
|
||||
err: errors.New("503 Service Unavailable"),
|
||||
want: providerRequestFailure{code: "request_bad_response", statusCode: 503},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: fmt.Errorf("fetch: %w", context.DeadlineExceeded),
|
||||
want: providerRequestFailure{code: "request_error", reason: "timeout"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.DNSError{Err: "no such host", Name: "sub.example"}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "dns"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: fmt.Errorf("dial: %w", resolver.ErrIPNotFound),
|
||||
want: providerRequestFailure{code: "request_error", reason: "dns"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: x509.UnknownAuthorityError{}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "tls"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: &net.OpError{Op: "dial", Net: "tcp", Err: syscall.ECONNREFUSED}},
|
||||
want: providerRequestFailure{code: "request_error", reason: "connection"},
|
||||
ok: true,
|
||||
},
|
||||
{
|
||||
err: &url.Error{Op: "Get", URL: "https://sub.example", Err: errors.New("unexpected")},
|
||||
want: providerRequestFailure{code: "request_error"},
|
||||
ok: true,
|
||||
},
|
||||
{err: errors.New("proxy 0: unsupported type")},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if got := providerRequestErrorCode(test.err); got != test.want {
|
||||
t.Errorf("providerRequestErrorCode(%q) = %q, want %q", test.err, got, test.want)
|
||||
got, ok := classifyProviderRequestError(test.err)
|
||||
if got != test.want || ok != test.ok {
|
||||
t.Errorf("classifyProviderRequestError(%q) = %+v, %v, want %+v, %v", test.err, got, ok, test.want, test.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExternalProviderReportsRequestFailureDetails(t *testing.T) {
|
||||
const name = "subscription"
|
||||
provider := &failingProxyProvider{
|
||||
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
|
||||
err: errors.New("403 Forbidden"),
|
||||
}
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil)
|
||||
|
||||
methodError := handleUpdateExternalProvider(name)
|
||||
if methodError == nil || methodError.Code != "request_bad_response" {
|
||||
t.Fatalf("methodError = %+v, want request_bad_response", methodError)
|
||||
}
|
||||
details, ok := methodError.Details.(map[string]any)
|
||||
if !ok || details["providerName"] != name || details["statusCode"] != 403 {
|
||||
t.Errorf("details = %#v, want providerName %q and statusCode 403", methodError.Details, name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateExternalProviderCategorizesFailure(t *testing.T) {
|
||||
const name = "subscription"
|
||||
provider := &failingProxyProvider{
|
||||
@@ -823,6 +981,27 @@ func TestForceGCReleasesTheProxyCache(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleGetMemoryStatsReportsALiveRuntime(t *testing.T) {
|
||||
stats := handleGetMemoryStats()
|
||||
|
||||
if stats.HeapInuse == 0 {
|
||||
t.Error("a running Go program always has heap in use; a zero means the runtime read was dropped")
|
||||
}
|
||||
encoded, err := json.Marshal(stats)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]uint64
|
||||
if err := json.Unmarshal(encoded, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"rss", "heapInuse", "heapIdle", "stackInuse", "runtimeOther"} {
|
||||
if _, ok := fields[key]; !ok {
|
||||
t.Errorf("memory stats lost the %q field the Dart model reads", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllProxiesFollowsAProviderUpdate(t *testing.T) {
|
||||
provider := newCachingProvider("subscription", "node-a")
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
||||
@@ -878,3 +1057,53 @@ func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) {
|
||||
t.Error("a proxy the refresh removed is still reported")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyViewKeepsWhatTheHostReads(t *testing.T) {
|
||||
encode := func(proxy constant.Proxy) map[string]any {
|
||||
t.Helper()
|
||||
data, err := json.Marshal(proxyView(proxy))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal %s: %v", proxy.Name(), err)
|
||||
}
|
||||
view := map[string]any{}
|
||||
if err := json.Unmarshal(data, &view); err != nil {
|
||||
t.Fatalf("unmarshal %s: %v", proxy.Name(), err)
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
node := encode(namedProxy("node-a"))
|
||||
if want := map[string]any{"name": "node-a", "type": "Direct"}; fmt.Sprint(node) != fmt.Sprint(want) {
|
||||
t.Errorf("node view = %v, want %v", node, want)
|
||||
}
|
||||
|
||||
members := []constant.Proxy{namedProxy("node-a"), namedProxy("node-b")}
|
||||
pd, err := provider.NewCompatibleProvider(
|
||||
"group-provider", members, provider.NewHealthCheck(members, "", 0, 0, true, nil),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCompatibleProvider: %v", err)
|
||||
}
|
||||
selector, err := outboundgroup.NewSelector(
|
||||
outboundgroup.GroupCommonOption{Name: "group"},
|
||||
outboundgroup.SelectorOption{},
|
||||
namedProxy("COMPATIBLE"),
|
||||
[]cp.ProxyProvider{pd},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSelector: %v", err)
|
||||
}
|
||||
group := encode(adapter.NewProxy(selector))
|
||||
if group["name"] != "group" || group["type"] != "Selector" || group["now"] != "node-a" {
|
||||
t.Errorf("group view = %v, want its name, type and selection", group)
|
||||
}
|
||||
if fmt.Sprint(group["all"]) != "[node-a node-b]" {
|
||||
t.Errorf("group members = %v, want [node-a node-b]", group["all"])
|
||||
}
|
||||
if _, exist := group["hidden"]; !exist {
|
||||
t.Error("the host filters groups on hidden, so it has to be present")
|
||||
}
|
||||
if _, exist := group["history"]; exist {
|
||||
t.Error("the view still carries delay history")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-4
@@ -285,7 +285,7 @@ func invokeMethod(callback unsafe.Pointer, paramsChar *C.char) {
|
||||
newMethodResponse("", callback).failure("invalid_method_call", err.Error(), nil)
|
||||
return
|
||||
}
|
||||
go handleMethodCall(call, newMethodResponse(call.ID, callback))
|
||||
dispatchMethodCall(call, newMethodResponse(call.ID, callback))
|
||||
}
|
||||
|
||||
//export startTUN
|
||||
@@ -329,14 +329,26 @@ func quickSetup(callback unsafe.Pointer, initParamsChar *C.char, setupParamsChar
|
||||
}()
|
||||
}
|
||||
|
||||
// The listener goes away with the Flutter engine, which never gets to release
|
||||
// the route watch it held, while the service and this core run on.
|
||||
//
|
||||
//export setEventListener
|
||||
func setEventListener(listener unsafe.Pointer) {
|
||||
eventListenerLock.Lock()
|
||||
defer eventListenerLock.Unlock()
|
||||
if eventListener != nil {
|
||||
releaseObject(eventListener)
|
||||
}
|
||||
eventListener = listener
|
||||
eventListenerLock.Unlock()
|
||||
if listener == nil {
|
||||
stopRouteWatch()
|
||||
}
|
||||
}
|
||||
|
||||
func hasEventListener() bool {
|
||||
eventListenerLock.RLock()
|
||||
defer eventListenerLock.RUnlock()
|
||||
return eventListener != nil
|
||||
}
|
||||
|
||||
//export getTotalTraffic
|
||||
@@ -376,8 +388,8 @@ func stopTun() {
|
||||
}
|
||||
|
||||
//export suspend
|
||||
func suspend(suspended bool) {
|
||||
handleSuspend(suspended)
|
||||
func suspend(suspended, interactive bool) {
|
||||
handleSuspend(suspended, interactive)
|
||||
}
|
||||
|
||||
//export forceGC
|
||||
|
||||
+13
-1
@@ -29,25 +29,37 @@ const (
|
||||
stateMessageClass messageClass = iota
|
||||
priorityMessageClass
|
||||
bulkMessageClass
|
||||
// Shares the bulk queue but never evicts from it: a lookup burst would
|
||||
// otherwise push out the request events the connection list is built from.
|
||||
yieldingBulkMessageClass
|
||||
)
|
||||
|
||||
func classOfMessage(message Message) messageClass {
|
||||
switch message.Type {
|
||||
case LoadedMessage, GeoUpdateMessage:
|
||||
case LoadedMessage, GeoUpdateMessage, RouteChangedMessage:
|
||||
return stateMessageClass
|
||||
case LogMessage, RequestMessage:
|
||||
return bulkMessageClass
|
||||
case DnsMessage:
|
||||
return yieldingBulkMessageClass
|
||||
default:
|
||||
return priorityMessageClass
|
||||
}
|
||||
}
|
||||
|
||||
// Request and DNS events fire per connection and per query for as long as the
|
||||
// Android service runs, engine or not; unheard, they are not worth encoding.
|
||||
func sendMessage(message Message) {
|
||||
if !hasEventListener() {
|
||||
return
|
||||
}
|
||||
switch classOfMessage(message) {
|
||||
case stateMessageClass:
|
||||
enqueueState(stateMessageQueue, message)
|
||||
case bulkMessageClass:
|
||||
enqueueLatest(bulkMessageQueue, message)
|
||||
case yieldingBulkMessageClass:
|
||||
enqueueState(bulkMessageQueue, message)
|
||||
default:
|
||||
enqueueLatest(priorityMessageQueue, message)
|
||||
}
|
||||
|
||||
@@ -55,11 +55,13 @@ func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *b
|
||||
|
||||
func TestClassOfMessageRoutesEachTier(t *testing.T) {
|
||||
for messageType, want := range map[MessageType]messageClass{
|
||||
LoadedMessage: stateMessageClass,
|
||||
GeoUpdateMessage: stateMessageClass,
|
||||
DelayMessage: priorityMessageClass,
|
||||
LogMessage: bulkMessageClass,
|
||||
RequestMessage: bulkMessageClass,
|
||||
LoadedMessage: stateMessageClass,
|
||||
GeoUpdateMessage: stateMessageClass,
|
||||
RouteChangedMessage: stateMessageClass,
|
||||
DelayMessage: priorityMessageClass,
|
||||
LogMessage: bulkMessageClass,
|
||||
RequestMessage: bulkMessageClass,
|
||||
DnsMessage: yieldingBulkMessageClass,
|
||||
} {
|
||||
if got := classOfMessage(Message{Type: messageType}); got != want {
|
||||
t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want)
|
||||
|
||||
+43
-2
@@ -12,6 +12,17 @@ type MethodCall struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Method CoreMethod `json:"method"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
seq uint64
|
||||
}
|
||||
|
||||
var methodCallSeq atomic.Uint64
|
||||
|
||||
// Each call runs on its own goroutine, so a handler that must honour the
|
||||
// host's latest intent compares seq, stamped here in arrival order; every
|
||||
// transport has to deliver calls in the order the host sent them.
|
||||
func dispatchMethodCall(call *MethodCall, response MethodResponse) {
|
||||
call.seq = methodCallSeq.Add(1)
|
||||
go handleMethodCall(call, response)
|
||||
}
|
||||
|
||||
func (call MethodCall) decodeArguments(target any) error {
|
||||
@@ -172,6 +183,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
validateConfigMethod: withArguments(func(path *string, response MethodResponse) {
|
||||
response.success(handleValidateConfig(*path))
|
||||
}),
|
||||
validateProxiesMethod: withArguments(func(mappings *[]map[string]any, response MethodResponse) {
|
||||
response.success(handleValidateProxies(*mappings))
|
||||
}),
|
||||
updateConfigMethod: withArguments(func(params *UpdateParams, response MethodResponse) {
|
||||
response.success(handleUpdateConfig(params))
|
||||
}),
|
||||
@@ -209,9 +223,27 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
response.success(handleTestDelay(params))
|
||||
})
|
||||
}),
|
||||
probeMethod: withArguments(func(params *ProbeParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleProbe(params))
|
||||
})
|
||||
}),
|
||||
outboundIpMethod: withArguments(func(params *OutboundIpParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleOutboundIp(params))
|
||||
})
|
||||
}),
|
||||
serviceCheckMethod: withArguments(func(params *ServiceCheckParams, response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleServiceCheck(params))
|
||||
})
|
||||
}),
|
||||
getConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetConnections())
|
||||
}),
|
||||
getConnectionCountMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleGetConnectionCount())
|
||||
}),
|
||||
closeConnectionsMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleCloseConnections())
|
||||
}),
|
||||
@@ -262,9 +294,9 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
stopListenerMethod: withoutArguments(func(response MethodResponse) {
|
||||
response.success(handleStopListener())
|
||||
}),
|
||||
getMemoryMethod: withoutArguments(func(response MethodResponse) {
|
||||
getMemoryStatsMethod: withoutArguments(func(response MethodResponse) {
|
||||
safeGo(response, func() {
|
||||
response.success(handleGetMemory())
|
||||
response.success(handleGetMemoryStats())
|
||||
})
|
||||
}),
|
||||
clearEffectMethod: withArguments(func(profileId *int64, response MethodResponse) {
|
||||
@@ -272,6 +304,15 @@ var methodHandlers = map[CoreMethod]methodHandler{
|
||||
response.success(handleClearEffect(*profileId))
|
||||
})
|
||||
}),
|
||||
watchRouteMethod: func(call *MethodCall, response MethodResponse) {
|
||||
var watch bool
|
||||
if !decodeMethodArguments(call, response, &watch) {
|
||||
return
|
||||
}
|
||||
safeGo(response, func() {
|
||||
response.success(handleWatchRoute(watch, call.seq))
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
func registerMethod(method CoreMethod, handler methodHandler) {
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
package main
|
||||
|
||||
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 := probeTimeout(params.Timeout)
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHandleOutboundIpTakesTheFirstUsableAnswer(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
slow := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(400 * time.Millisecond)
|
||||
_, _ = w.Write([]byte("slow"))
|
||||
})
|
||||
empty := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
quick := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7"))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{slow.URL, empty.URL, quick.URL},
|
||||
Timeout: 4000,
|
||||
})
|
||||
|
||||
if result.Error != "" {
|
||||
t.Fatalf("error = %q, want none", result.Error)
|
||||
}
|
||||
if result.Body != "ip=203.0.113.7" {
|
||||
t.Errorf("body = %q, want the quick source's", result.Body)
|
||||
}
|
||||
if result.Url != quick.URL {
|
||||
t.Errorf("url = %q, want %q", result.Url, quick.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpSkipsAnAnswerWithoutAnAddress(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
limited := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"error":"rate limited"}`))
|
||||
})
|
||||
later := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
_, _ = w.Write([]byte(`{"ip":"2001:db8::7","country":"JP"}`))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{limited.URL, later.URL},
|
||||
Timeout: 4000,
|
||||
})
|
||||
|
||||
if result.Url != later.URL {
|
||||
t.Errorf("url = %q, want the source that named an address", result.Url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsIpNeedsAWholeAddress(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
"fl=12f\nip=203.0.113.7\nts=1700000000.1": true,
|
||||
`{"ip":"2001:db8::7","country":"JP"}`: true,
|
||||
`{"ip":"::ffff:203.0.113.7"}`: true,
|
||||
`a::after{content:""}`: false,
|
||||
`div::before,li::marker{color:#fff}`: false,
|
||||
`::-webkit-scrollbar{width:0}`: false,
|
||||
`<a href="http://127.0.0.1/login">`: false,
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := mentionsIp(body); got != want {
|
||||
t.Errorf("mentionsIp(%q) = %v, want %v", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMentionsIpSkipsLocalAddresses(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
"ip=192.168.1.1",
|
||||
`{"ip":"10.20.30.40"}`,
|
||||
"ip=172.16.0.9",
|
||||
"ip=100.64.12.34",
|
||||
`{"ip":"fd00::1"}`,
|
||||
`{"ip":"::ffff:192.168.1.1"}`,
|
||||
} {
|
||||
if mentionsIp(body) {
|
||||
t.Errorf("mentionsIp(%q) = true, want false", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpCancelsTheLosers(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
var cancelled atomic.Bool
|
||||
losing := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
cancelled.Store(true)
|
||||
case <-time.After(3 * time.Second):
|
||||
}
|
||||
})
|
||||
winning := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.9"))
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{losing.URL, winning.URL},
|
||||
Timeout: 5000,
|
||||
})
|
||||
|
||||
if result.Body != "ip=203.0.113.9" {
|
||||
t.Fatalf("body = %q, want the winning source's", result.Body)
|
||||
}
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for !cancelled.Load() && time.Now().Before(deadline) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !cancelled.Load() {
|
||||
t.Error("the losing request kept running, want it cancelled once the race was won")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpReportsWhenEverySourceFails(t *testing.T) {
|
||||
withProbeProxies(t, "node-a")
|
||||
broken := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
|
||||
result := handleOutboundIp(&OutboundIpParams{
|
||||
ProxyName: "node-a",
|
||||
Urls: []string{broken.URL, broken.URL},
|
||||
Timeout: 3000,
|
||||
})
|
||||
|
||||
if result.Error != probeErrorFailed {
|
||||
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleOutboundIpRejectsAnEmptySourceList(t *testing.T) {
|
||||
result := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
|
||||
if result.Error != probeErrorFailed {
|
||||
t.Errorf("error = %q, want %q", result.Error, probeErrorFailed)
|
||||
}
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
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 := acquireSlot(queueCtx, probeSlots)
|
||||
cancelQueue()
|
||||
if !granted {
|
||||
return nil
|
||||
}
|
||||
defer func() { <-probeSlots }()
|
||||
|
||||
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 acquireSlot(ctx context.Context, slots chan struct{}) bool {
|
||||
if ctx.Err() != nil {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
return true
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
func probeServer(t *testing.T, handler http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
server := httptest.NewServer(handler)
|
||||
t.Cleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func withProbeProxies(t *testing.T, names ...string) {
|
||||
t.Helper()
|
||||
proxies := make(map[string]constant.Proxy, len(names))
|
||||
for _, name := range names {
|
||||
proxies[name] = namedProxy(name)
|
||||
}
|
||||
tunnel.UpdateProxies(proxies, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
}
|
||||
|
||||
func TestHandleProbePinsTheNamedProxy(t *testing.T) {
|
||||
var userAgent string
|
||||
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
userAgent = r.UserAgent()
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7\nloc=US\n"))
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{
|
||||
Url: server.URL + "/trace",
|
||||
ProxyName: "node-a",
|
||||
Headers: map[string]string{"User-Agent": "probe-test"},
|
||||
Timeout: 2000,
|
||||
MaxBody: 1024,
|
||||
})
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleProbe = nil, want a result")
|
||||
}
|
||||
if result.Error != "" {
|
||||
t.Fatalf("error = %q (%s), want none", result.Error, result.Message)
|
||||
}
|
||||
if result.StatusCode != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", result.StatusCode)
|
||||
}
|
||||
if result.Body != "ip=203.0.113.7\nloc=US\n" {
|
||||
t.Errorf("body = %q", result.Body)
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"node-a"}) {
|
||||
t.Errorf("chains = %v, want [node-a]", result.Chains)
|
||||
}
|
||||
if result.Url != server.URL+"/trace" {
|
||||
t.Errorf("url = %q", result.Url)
|
||||
}
|
||||
if userAgent != "probe-test" {
|
||||
t.Errorf("user agent = %q, want the header the caller passed", userAgent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeFollowsRedirectsAndCapsTheBody(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
http.Redirect(w, r, "/final", http.StatusFound)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(strings.Repeat("x", 100)))
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000, MaxBody: 8})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d, want 200 after the redirect", result.StatusCode)
|
||||
}
|
||||
if result.Url != server.URL+"/final" {
|
||||
t.Errorf("url = %q, want the redirect target", result.Url)
|
||||
}
|
||||
if len(result.Body) != 8 {
|
||||
t.Errorf("body length = %d, want the cap", len(result.Body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProbeBodyStopsOnceTheMarkerArrives(t *testing.T) {
|
||||
marker := "\"currentTerritory\":\"JP\""
|
||||
page := strings.Repeat("x", probeScanChunk-10) + marker + strings.Repeat("y", 4*probeScanChunk)
|
||||
var seen int
|
||||
|
||||
body := readProbeBody(strings.NewReader(page), int64(len(page)), func(tail string) bool {
|
||||
seen++
|
||||
return strings.Contains(tail, marker)
|
||||
})
|
||||
|
||||
if !strings.Contains(body, marker) {
|
||||
t.Fatalf("body lost the marker that straddled two chunks")
|
||||
}
|
||||
if len(body) >= len(page) || seen != 2 {
|
||||
t.Errorf("read %d of %d bytes in %d chunks, want it to stop at the marker", len(body), len(page), seen)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadProbeBodyKeepsTheCapWithoutAMarker(t *testing.T) {
|
||||
page := strings.Repeat("x", 3*probeScanChunk)
|
||||
|
||||
body := readProbeBody(strings.NewReader(page), probeScanChunk+5, func(string) bool { return false })
|
||||
|
||||
if len(body) != probeScanChunk+5 {
|
||||
t.Errorf("body length = %d, want the cap", len(body))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeSkipsTheBodyWhenNotAsked(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, ProxyName: "node-a", Timeout: 2000})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusNoContent || result.Body != "" {
|
||||
t.Errorf("status = %d body = %q, want 204 with no body", result.StatusCode, result.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeReportsATimeout(t *testing.T) {
|
||||
addr := blackHoleServer(t)
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
start := time.Now()
|
||||
result := handleProbe(&ProbeParams{Url: "http://" + addr.String(), ProxyName: "node-a", Timeout: 200})
|
||||
|
||||
if result == nil {
|
||||
t.Fatal("handleProbe = nil, want a result")
|
||||
}
|
||||
if result.Error != probeErrorTimeout {
|
||||
t.Errorf("error = %q (%s), want timeout", result.Error, result.Message)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > 2*time.Second {
|
||||
t.Errorf("took %s, want the caller's budget", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeReportsARefusedConnection(t *testing.T) {
|
||||
server := probeServer(t, func(http.ResponseWriter, *http.Request) {})
|
||||
url := server.URL
|
||||
server.Close()
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: url, ProxyName: "node-a", Timeout: 2000})
|
||||
|
||||
if result == nil || result.Error != probeErrorFailed {
|
||||
t.Fatalf("result = %+v, want a failed probe", result)
|
||||
}
|
||||
if result.StatusCode != 0 || result.Message == "" {
|
||||
t.Errorf("status = %d message = %q, want no status and a reason", result.StatusCode, result.Message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeRejectsAnUnknownProxyWithoutWaitingForASlot(t *testing.T) {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
probeSlots <- struct{}{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
<-probeSlots
|
||||
}
|
||||
})
|
||||
|
||||
done := make(chan *ProbeResult, 1)
|
||||
go func() {
|
||||
done <- handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "missing", Timeout: 1})
|
||||
}()
|
||||
|
||||
select {
|
||||
case result := <-done:
|
||||
if result == nil || result.Error != probeErrorFailed {
|
||||
t.Errorf("result = %+v, want a failed probe", result)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("an unknown proxy queued behind a saturated probe semaphore")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeGivesUpQueueingOnTheDeadline(t *testing.T) {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
probeSlots <- struct{}{}
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for i := 0; i < probeConcurrency; i++ {
|
||||
<-probeSlots
|
||||
}
|
||||
})
|
||||
withProbeProxies(t, "node-a")
|
||||
|
||||
if result := handleProbe(&ProbeParams{Url: "http://example.invalid", ProxyName: "node-a", Timeout: 50}); result != nil {
|
||||
t.Errorf("result = %+v, want nil when no slot was granted", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeRoutesThroughTheTunnel(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("routed"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnRunning()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if result.StatusCode != http.StatusOK || result.Body != "routed" {
|
||||
t.Errorf("status = %d body = %q", result.StatusCode, result.Body)
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"DIRECT"}) {
|
||||
t.Errorf("chains = %v, want the outbound the tunnel picked", result.Chains)
|
||||
}
|
||||
}
|
||||
|
||||
// earlyDataProxy stands in for vmess, vless, Shadowsocks and the others whose
|
||||
// handshake carries the client's first bytes; the tunnel waits 200ms for them.
|
||||
type earlyDataProxy struct {
|
||||
constant.Proxy
|
||||
handshake chan []byte
|
||||
}
|
||||
|
||||
func (p *earlyDataProxy) DialContext(ctx context.Context, metadata *constant.Metadata) (constant.Conn, error) {
|
||||
conn, err := p.Proxy.DialContext(ctx, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &earlyDataConn{Conn: conn, handshake: p.handshake}, nil
|
||||
}
|
||||
|
||||
type earlyDataConn struct {
|
||||
constant.Conn
|
||||
handshake chan []byte
|
||||
sent atomic.Bool
|
||||
}
|
||||
|
||||
func (c *earlyDataConn) NeedHandshake() bool {
|
||||
return !c.sent.Load()
|
||||
}
|
||||
|
||||
func (c *earlyDataConn) Write(b []byte) (int, error) {
|
||||
if !c.sent.Swap(true) {
|
||||
c.handshake <- slices.Clone(b)
|
||||
}
|
||||
return c.Conn.Write(b)
|
||||
}
|
||||
|
||||
func TestHandleProbeSendsTheRequestWithTheHandshake(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("routed"))
|
||||
})
|
||||
handshake := make(chan []byte, 1)
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{
|
||||
"DIRECT": &earlyDataProxy{Proxy: namedProxy("DIRECT"), handshake: handshake},
|
||||
}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
tunnel.OnRunning()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want a success", result)
|
||||
}
|
||||
if first := <-handshake; len(first) == 0 {
|
||||
t.Error("the handshake went out empty: the request waited on the route while the tunnel waited on the request")
|
||||
}
|
||||
if !slices.Equal(result.Chains, []string{"DIRECT"}) {
|
||||
t.Errorf("chains = %v, want the outbound the tunnel picked", result.Chains)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeWaitsOutASuspendedTunnel(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("resumed"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnSuspend()
|
||||
t.Cleanup(tunnel.OnSuspend)
|
||||
time.AfterFunc(150*time.Millisecond, tunnel.OnRunning)
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 2000, MaxBody: 64})
|
||||
|
||||
if result == nil || result.Error != "" {
|
||||
t.Fatalf("result = %+v, want the answer once the tunnel runs again", result)
|
||||
}
|
||||
if result.Body != "resumed" {
|
||||
t.Errorf("body = %q", result.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleProbeTimesOutWhileTheTunnelStaysSuspended(t *testing.T) {
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("unreachable"))
|
||||
})
|
||||
withProbeProxies(t, "DIRECT")
|
||||
tunnel.OnSuspend()
|
||||
|
||||
result := handleProbe(&ProbeParams{Url: server.URL, Timeout: 200})
|
||||
|
||||
if result == nil || result.Error != probeErrorTimeout {
|
||||
t.Fatalf("result = %+v, want a timeout", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// Five bytes per entry, alpha-3 then alpha-2, sorted by alpha-3.
|
||||
const iso3166Packed = "ABWAWAFGAFAGOAOAIAAIALAAXALBALANDADAREAEARGARARMAMASMASATAAQ" +
|
||||
"ATFTFATGAGAUSAUAUTATAZEAZBDIBIBELBEBENBJBESBQBFABFBGDBDBGRBG" +
|
||||
"BHRBHBHSBSBIHBABLMBLBLRBYBLZBZBMUBMBOLBOBRABRBRBBBBRNBNBTNBT" +
|
||||
"BVTBVBWABWCAFCFCANCACCKCCCHECHCHLCLCHNCNCIVCICMRCMCODCDCOGCG" +
|
||||
"COKCKCOLCOCOMKMCPVCVCRICRCUBCUCUWCWCXRCXCYMKYCYPCYCZECZDEUDE" +
|
||||
"DJIDJDMADMDNKDKDOMDODZADZECUECEGYEGERIERESHEHESPESESTEEETHET" +
|
||||
"FINFIFJIFJFLKFKFRAFRFROFOFSMFMGABGAGBRGBGEOGEGGYGGGHAGHGIBGI" +
|
||||
"GINGNGLPGPGMBGMGNBGWGNQGQGRCGRGRDGDGRLGLGTMGTGUFGFGUMGUGUYGY" +
|
||||
"HKGHKHMDHMHNDHNHRVHRHTIHTHUNHUIDNIDIMNIMINDINIOTIOIRLIEIRNIR" +
|
||||
"IRQIQISLISISRILITAITJAMJMJEYJEJORJOJPNJPKAZKZKENKEKGZKGKHMKH" +
|
||||
"KIRKIKNAKNKORKRKWTKWLAOLALBNLBLBRLRLBYLYLCALCLIELILKALKLSOLS" +
|
||||
"LTULTLUXLULVALVMACMOMAFMFMARMAMCOMCMDAMDMDGMGMDVMVMEXMXMHLMH" +
|
||||
"MKDMKMLIMLMLTMTMMRMMMNEMEMNGMNMNPMPMOZMZMRTMRMSRMSMTQMQMUSMU" +
|
||||
"MWIMWMYSMYMYTYTNAMNANCLNCNERNENFKNFNGANGNICNINIUNUNLDNLNORNO" +
|
||||
"NPLNPNRUNRNZLNZOMNOMPAKPKPANPAPCNPNPERPEPHLPHPLWPWPNGPGPOLPL" +
|
||||
"PRIPRPRKKPPRTPTPRYPYPSEPSPYFPFQATQAREUREROURORUSRURWARWSAUSA" +
|
||||
"SDNSDSENSNSGPSGSGSGSSHNSHSJMSJSLBSBSLESLSLVSVSMRSMSOMSOSPMPM" +
|
||||
"SRBRSSSDSSSTPSTSURSRSVKSKSVNSISWESESWZSZSXMSXSYCSCSYRSYTCATC" +
|
||||
"TCDTDTGOTGTHATHTJKTJTKLTKTKMTMTLSTLTONTOTTOTTTUNTNTURTRTUVTV" +
|
||||
"TWNTWTZATZUGAUGUKRUAUMIUMURYUYUSAUSUZBUZVATVAVCTVCVENVEVGBVG" +
|
||||
"VIRVIVNMVNVUTVUWLFWFWSMWSYEMYEZAFZAZMBZMZWEZW"
|
||||
|
||||
// normalizeRegion folds a country code to the alpha-2 form Dart renders a flag
|
||||
// from, or "" when it is not one. Only Gemini reports alpha-3.
|
||||
func normalizeRegion(code string) string {
|
||||
code = strings.ToUpper(strings.TrimSpace(code))
|
||||
switch len(code) {
|
||||
case 2:
|
||||
if isAsciiUpper(code) {
|
||||
return code
|
||||
}
|
||||
return ""
|
||||
case 3:
|
||||
if !isAsciiUpper(code) {
|
||||
return ""
|
||||
}
|
||||
low, high := 0, len(iso3166Packed)/5
|
||||
for low < high {
|
||||
mid := (low + high) / 2
|
||||
entry := iso3166Packed[mid*5 : mid*5+3]
|
||||
switch {
|
||||
case entry < code:
|
||||
low = mid + 1
|
||||
case entry > code:
|
||||
high = mid
|
||||
default:
|
||||
return iso3166Packed[mid*5+3 : mid*5+5]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func isAsciiUpper(value string) bool {
|
||||
for _, char := range value {
|
||||
if char < 'A' || char > 'Z' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
// mihomo has no hook for a health check moving a URLTest or Fallback pick, so
|
||||
// the picks are re-read on this timer while the host watches.
|
||||
const routePollInterval = time.Second
|
||||
|
||||
type pickableGroup interface {
|
||||
outboundgroup.ProxyGroup
|
||||
outboundgroup.SelectAble
|
||||
}
|
||||
|
||||
type routeTracker struct {
|
||||
mu sync.Mutex
|
||||
epoch uint64
|
||||
picksVersion uint64
|
||||
picks map[string]string
|
||||
providerVersions map[string]uint32
|
||||
ruleSets map[string]any
|
||||
watched bool
|
||||
watchSeq uint64
|
||||
stopPoll context.CancelFunc
|
||||
}
|
||||
|
||||
var (
|
||||
currentRoute routeTracker
|
||||
|
||||
publishRoute = func(state RouteState) {
|
||||
sendMessage(Message{Type: RouteChangedMessage, Data: state})
|
||||
}
|
||||
)
|
||||
|
||||
func routeStateLocked() RouteState {
|
||||
picks := currentRoute.picks
|
||||
if picks == nil {
|
||||
picks = map[string]string{}
|
||||
}
|
||||
return RouteState{
|
||||
CoreEpoch: currentRoute.epoch,
|
||||
PicksVersion: currentRoute.picksVersion,
|
||||
Picks: maps.Clone(picks),
|
||||
}
|
||||
}
|
||||
|
||||
func routeState() RouteState {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
return routeStateLocked()
|
||||
}
|
||||
|
||||
func routeStamp() (epoch, picksVersion uint64) {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
return currentRoute.epoch, currentRoute.picksVersion
|
||||
}
|
||||
|
||||
func readPicks() (map[string]string, map[string]uint32) {
|
||||
picks := map[string]string{}
|
||||
for name, proxy := range tunnel.AllProxies() {
|
||||
outbound, ok := proxy.(*adapter.Proxy)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
group, ok := outbound.ProxyAdapter.(pickableGroup)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
picks[name] = group.Now()
|
||||
}
|
||||
providers := tunnel.ProvidersSnapshot()
|
||||
versions := make(map[string]uint32, len(providers))
|
||||
for name, p := range providers {
|
||||
versions[name] = p.Version()
|
||||
}
|
||||
return picks, versions
|
||||
}
|
||||
|
||||
// mihomo swaps in a new strategy each time a rule set, manually or on its
|
||||
// interval, loads new content.
|
||||
func readRuleSets() map[string]any {
|
||||
providers := tunnel.RuleProvidersSnapshot()
|
||||
ruleSets := make(map[string]any, len(providers))
|
||||
for name, p := range providers {
|
||||
ruleSets[name] = p.Strategy()
|
||||
}
|
||||
return ruleSets
|
||||
}
|
||||
|
||||
// A provider that swapped its list is a structural change: the names may
|
||||
// survive while the servers behind them do not. The caller holds selectMu.
|
||||
func refreshRouteLocked(structural bool) {
|
||||
picks, versions := readPicks()
|
||||
ruleSets := readRuleSets()
|
||||
|
||||
currentRoute.mu.Lock()
|
||||
if currentRoute.providerVersions != nil && !maps.Equal(versions, currentRoute.providerVersions) {
|
||||
structural = true
|
||||
}
|
||||
if currentRoute.ruleSets != nil && !maps.Equal(ruleSets, currentRoute.ruleSets) {
|
||||
structural = true
|
||||
}
|
||||
currentRoute.providerVersions = versions
|
||||
currentRoute.ruleSets = ruleSets
|
||||
if structural {
|
||||
currentRoute.epoch++
|
||||
}
|
||||
changed := structural
|
||||
if !maps.Equal(picks, currentRoute.picks) {
|
||||
currentRoute.picks = picks
|
||||
currentRoute.picksVersion++
|
||||
changed = true
|
||||
}
|
||||
state := routeStateLocked()
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
|
||||
if changed && watched {
|
||||
publishRoute(state)
|
||||
}
|
||||
}
|
||||
|
||||
func refreshRoute() {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
refreshRouteLocked(false)
|
||||
}
|
||||
|
||||
func bumpRouteEpoch() {
|
||||
selectMu.Lock()
|
||||
defer selectMu.Unlock()
|
||||
refreshRouteLocked(true)
|
||||
}
|
||||
|
||||
func handleWatchRoute(watch bool, seq uint64) RouteState {
|
||||
refreshRoute()
|
||||
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
if seq < currentRoute.watchSeq {
|
||||
return routeStateLocked()
|
||||
}
|
||||
currentRoute.watchSeq = seq
|
||||
currentRoute.watched = watch
|
||||
if watch && currentRoute.stopPoll == nil {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
currentRoute.stopPoll = cancel
|
||||
go pollRoute(ctx)
|
||||
}
|
||||
if !watch {
|
||||
stopRoutePollLocked()
|
||||
}
|
||||
return routeStateLocked()
|
||||
}
|
||||
|
||||
// The stop takes its own place in the call order: a watch dispatched before
|
||||
// it, the newest one included, may still be waiting to run.
|
||||
func stopRouteWatch() {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.watchSeq = max(currentRoute.watchSeq, methodCallSeq.Add(1))
|
||||
currentRoute.watched = false
|
||||
stopRoutePollLocked()
|
||||
}
|
||||
|
||||
func stopRoutePollLocked() {
|
||||
if currentRoute.stopPoll != nil {
|
||||
currentRoute.stopPoll()
|
||||
currentRoute.stopPoll = nil
|
||||
}
|
||||
}
|
||||
|
||||
// With the listeners down the host pins every probe to DIRECT, so the picks
|
||||
// do not matter until they come back.
|
||||
func pollRoute(ctx context.Context) {
|
||||
ticker := time.NewTicker(routePollInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if isRunning.Load() {
|
||||
refreshRoute()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/metacubex/mihomo/adapter"
|
||||
"github.com/metacubex/mihomo/adapter/outboundgroup"
|
||||
"github.com/metacubex/mihomo/adapter/provider"
|
||||
"github.com/metacubex/mihomo/component/resource"
|
||||
"github.com/metacubex/mihomo/config"
|
||||
"github.com/metacubex/mihomo/constant"
|
||||
cp "github.com/metacubex/mihomo/constant/provider"
|
||||
"github.com/metacubex/mihomo/rules"
|
||||
rp "github.com/metacubex/mihomo/rules/provider"
|
||||
"github.com/metacubex/mihomo/tunnel"
|
||||
)
|
||||
|
||||
type routeRecorder struct {
|
||||
mu sync.Mutex
|
||||
states []RouteState
|
||||
}
|
||||
|
||||
func (r *routeRecorder) record(state RouteState) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.states = append(r.states, state)
|
||||
}
|
||||
|
||||
func (r *routeRecorder) published() []RouteState {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return append([]RouteState(nil), r.states...)
|
||||
}
|
||||
|
||||
func clearRoute() {
|
||||
stopRouteWatch()
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.epoch = 0
|
||||
currentRoute.picksVersion = 0
|
||||
currentRoute.picks = nil
|
||||
currentRoute.providerVersions = nil
|
||||
currentRoute.ruleSets = nil
|
||||
currentRoute.watchSeq = 0
|
||||
}
|
||||
|
||||
func recordRoutes(t *testing.T) *routeRecorder {
|
||||
t.Helper()
|
||||
clearRoute()
|
||||
recorder := &routeRecorder{}
|
||||
previous := publishRoute
|
||||
publishRoute = recorder.record
|
||||
t.Cleanup(func() {
|
||||
clearRoute()
|
||||
publishRoute = previous
|
||||
})
|
||||
return recorder
|
||||
}
|
||||
|
||||
func stampRoute(epoch, picksVersion uint64) {
|
||||
currentRoute.mu.Lock()
|
||||
defer currentRoute.mu.Unlock()
|
||||
currentRoute.epoch = epoch
|
||||
currentRoute.picksVersion = picksVersion
|
||||
}
|
||||
|
||||
func selectorOver(t *testing.T, name string, members ...constant.Proxy) constant.Proxy {
|
||||
t.Helper()
|
||||
health := provider.NewHealthCheck(members, "", 0, 0, true, nil)
|
||||
pd, err := provider.NewCompatibleProvider(name+"-provider", members, health)
|
||||
if err != nil {
|
||||
t.Fatalf("NewCompatibleProvider: %v", err)
|
||||
}
|
||||
group, err := outboundgroup.NewSelector(
|
||||
outboundgroup.GroupCommonOption{Name: name},
|
||||
outboundgroup.SelectorOption{},
|
||||
nil,
|
||||
[]cp.ProxyProvider{pd},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NewSelector: %v", err)
|
||||
}
|
||||
return adapter.NewProxy(group)
|
||||
}
|
||||
|
||||
func TestHandleChangeProxyReportsWhetherThePickMoved(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
group := selectorGroup(t, "group", "node-a", "node-b")
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{"group": group}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
|
||||
state := handleWatchRoute(true, 0)
|
||||
if state.Picks["group"] != "node-a" {
|
||||
t.Fatalf("picks = %v, want the group's first member before any selection", state.Picks)
|
||||
}
|
||||
|
||||
result := handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
|
||||
if result.Message != "" || !result.Changed {
|
||||
t.Fatalf("result = %+v, want a silent change", result)
|
||||
}
|
||||
published := recorder.published()
|
||||
if len(published) != 1 {
|
||||
t.Fatalf("published %d route states, want 1", len(published))
|
||||
}
|
||||
if published[0].Picks["group"] != "node-b" || published[0].PicksVersion != state.PicksVersion+1 {
|
||||
t.Errorf("published = %+v, want the new pick under the next version", published[0])
|
||||
}
|
||||
|
||||
result = handleChangeProxy(&ChangeProxyParams{GroupName: "group", ProxyName: "node-b"})
|
||||
if result.Changed {
|
||||
t.Error("re-selecting the current node reported a change")
|
||||
}
|
||||
if len(recorder.published()) != 1 {
|
||||
t.Error("re-selecting the current node published a route state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedGroupSwitchPublishesEveryPick(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
nested := selectorGroup(t, "Nested", "HK-01", "HK-02")
|
||||
outer := selectorOver(t, "Proxy", nested, namedProxy("JP-01"))
|
||||
tunnel.UpdateProxies(map[string]constant.Proxy{"Proxy": outer, "Nested": nested}, nil)
|
||||
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
||||
handleWatchRoute(true, 0)
|
||||
|
||||
handleChangeProxy(&ChangeProxyParams{GroupName: "Nested", ProxyName: "HK-02"})
|
||||
|
||||
published := recorder.published()
|
||||
want := map[string]string{"Proxy": "Nested", "Nested": "HK-02"}
|
||||
if len(published) != 1 || !maps.Equal(published[0].Picks, want) {
|
||||
t.Fatalf("published = %+v, want one state with picks %v", published, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRouteEpochFollowsStructuralChanges(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
handleWatchRoute(true, 0)
|
||||
before := routeState().CoreEpoch
|
||||
|
||||
bumpRouteEpoch()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Fatalf("epoch = %d after an apply, want %d", got, before+1)
|
||||
}
|
||||
if len(recorder.published()) != 1 {
|
||||
t.Error("an apply did not publish the new epoch")
|
||||
}
|
||||
|
||||
general := &config.General{}
|
||||
general.Mode = tunnel.Rule
|
||||
withCurrentConfig(t, &config.Config{General: general, Controller: &config.Controller{}})
|
||||
global := tunnel.Global
|
||||
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
|
||||
t.Fatalf("updateConfig: %v", err)
|
||||
}
|
||||
if got := routeState().CoreEpoch; got != before+2 {
|
||||
t.Errorf("epoch = %d after a mode switch, want %d", got, before+2)
|
||||
}
|
||||
if err := updateConfig(&UpdateParams{Mode: &global}); err != nil {
|
||||
t.Fatalf("updateConfig: %v", err)
|
||||
}
|
||||
if got := routeState().CoreEpoch; got != before+2 {
|
||||
t.Errorf("epoch = %d after an unchanged mode, want it left at %d", got, before+2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProviderRefreshAdvancesTheEpoch(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
subscription := newCachingProvider("subscription", "node-a")
|
||||
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": subscription}, nil)
|
||||
before := handleWatchRoute(true, 0).CoreEpoch
|
||||
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before {
|
||||
t.Fatalf("epoch = %d after a refresh without a provider change, want %d", got, before)
|
||||
}
|
||||
|
||||
subscription.setProxies("node-b")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Errorf("epoch = %d after the provider swapped its list, want %d", got, before+1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleSetContentAdvancesTheEpoch(t *testing.T) {
|
||||
recorder := recordRoutes(t)
|
||||
rp.SetTunnel(tunnel.Tunnel)
|
||||
ruleSet := rp.NewRuleSetProvider(
|
||||
"ruleset", cp.Domain, cp.YamlRule, 0,
|
||||
resource.NewFileVehicle(filepath.Join(t.TempDir(), "ruleset.yaml")),
|
||||
nil, nil, rules.ParseRule,
|
||||
).(*rp.RuleSetProvider)
|
||||
load := func(payload string) {
|
||||
t.Helper()
|
||||
if _, _, err := ruleSet.SideUpdate([]byte(payload)); err != nil {
|
||||
t.Fatalf("SideUpdate: %v", err)
|
||||
}
|
||||
}
|
||||
load("payload:\n - example.com\n")
|
||||
withTunnelProviders(t, nil, map[string]cp.RuleProvider{"ruleset": ruleSet})
|
||||
before := handleWatchRoute(true, 0).CoreEpoch
|
||||
|
||||
load("payload:\n - example.com\n")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before {
|
||||
t.Fatalf("epoch = %d after the rule set reloaded the same content, want %d", got, before)
|
||||
}
|
||||
|
||||
load("payload:\n - example.org\n")
|
||||
refreshRoute()
|
||||
if got := routeState().CoreEpoch; got != before+1 {
|
||||
t.Errorf("epoch = %d after the rule set loaded new content, want %d", got, before+1)
|
||||
}
|
||||
if published := recorder.published(); len(published) != 1 || published[0].CoreEpoch != before+1 {
|
||||
t.Errorf("published = %+v, want the new epoch once", published)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeAnswersCarryTheRouteStamp(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
stampRoute(7, 42)
|
||||
withProbeProxies(t, "node-a")
|
||||
server := probeServer(t, func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("ip=203.0.113.7"))
|
||||
})
|
||||
|
||||
answer := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Urls: []string{server.URL}, Timeout: 4000})
|
||||
if answer.CoreEpoch != 7 || answer.PicksVersion != 42 {
|
||||
t.Errorf("outbound ip stamp = %d/%d, want 7/42", answer.CoreEpoch, answer.PicksVersion)
|
||||
}
|
||||
|
||||
failed := handleOutboundIp(&OutboundIpParams{ProxyName: "node-a", Timeout: 1000})
|
||||
if failed.Error == "" || failed.CoreEpoch != 7 || failed.PicksVersion != 42 {
|
||||
t.Errorf("failed outbound ip = %+v, want an error stamped 7/42", failed)
|
||||
}
|
||||
|
||||
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
if items[0].CoreEpoch != 7 || items[0].PicksVersion != 42 {
|
||||
t.Errorf("service check stamp = %d/%d, want 7/42", items[0].CoreEpoch, items[0].PicksVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchRoutePollsOnlyWhileWatched(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
|
||||
handleWatchRoute(true, 0)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
if !polling {
|
||||
t.Fatal("watching the route did not start the poll")
|
||||
}
|
||||
|
||||
handleWatchRoute(false, 0)
|
||||
currentRoute.mu.Lock()
|
||||
polling = currentRoute.stopPoll != nil
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
if polling || watched {
|
||||
t.Error("releasing the route left the poll running")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWatchRouteIgnoresAnOlderIntent(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
|
||||
handleWatchRoute(false, 2)
|
||||
handleWatchRoute(true, 1)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
watched := currentRoute.watched
|
||||
currentRoute.mu.Unlock()
|
||||
if polling || watched {
|
||||
t.Error("a watch overtaken by a later release restarted the poll")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopRouteWatchOutranksEarlierWatches(t *testing.T) {
|
||||
recordRoutes(t)
|
||||
previous := methodCallSeq.Load()
|
||||
t.Cleanup(func() { methodCallSeq.Store(previous) })
|
||||
methodCallSeq.Store(previous + 5)
|
||||
|
||||
stopRouteWatch()
|
||||
for _, seq := range []uint64{previous + 4, previous + 5} {
|
||||
handleWatchRoute(true, seq)
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
if polling {
|
||||
t.Fatalf("watch %d, dispatched before the stop, restarted the poll", seq-previous)
|
||||
}
|
||||
}
|
||||
|
||||
handleWatchRoute(true, methodCallSeq.Add(1))
|
||||
currentRoute.mu.Lock()
|
||||
polling := currentRoute.stopPoll != nil
|
||||
currentRoute.mu.Unlock()
|
||||
handleWatchRoute(false, methodCallSeq.Add(1))
|
||||
if !polling {
|
||||
t.Error("a watch issued after the stop was ignored")
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -55,6 +55,11 @@ func deliverEvent(data []byte) {
|
||||
send(data)
|
||||
}
|
||||
|
||||
// The desktop core exits with its host connection, so it is always heard.
|
||||
func hasEventListener() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func writeFrame(w io.Writer, data []byte) (int, error) {
|
||||
if len(data) > maxIPCFrameSize {
|
||||
return 0, fmt.Errorf("IPC frame exceeds %d bytes", maxIPCFrameSize)
|
||||
@@ -212,6 +217,6 @@ func serve(dialed ipcConn) {
|
||||
continue
|
||||
}
|
||||
|
||||
go handleMethodCall(call, newMethodResponse(call.ID, nil))
|
||||
dispatchMethodCall(call, newMethodResponse(call.ID, nil))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
)
|
||||
|
||||
const (
|
||||
// A sweep runs a few checks at a time so it cannot take every probe slot.
|
||||
// It therefore runs in waves, and a rule like Disney+ spends several
|
||||
// requests in sequence, so the run gets a multiple of the request budget.
|
||||
serviceCheckConcurrency = 4
|
||||
serviceSweepBudgetFactor = 6
|
||||
|
||||
// Region markers sit deep inside these pages; endpoints that answer with a
|
||||
// few lines get the small cap.
|
||||
serviceHtmlMaxBody = 512 * 1024
|
||||
serviceSmallMaxBody = 16 * 1024
|
||||
|
||||
// Gemini's region sits past 800 KiB, Prime Video's at times past 4 MiB.
|
||||
serviceScanMaxBody = 8 * 1024 * 1024
|
||||
)
|
||||
|
||||
// Statuses a check reports. Dart maps each to a localized label, so these
|
||||
// strings are part of the contract and cannot be reworded on one side alone.
|
||||
const (
|
||||
serviceAvailable = "available"
|
||||
serviceUnavailable = "unavailable"
|
||||
serviceRestricted = "restricted"
|
||||
serviceDisallowedIsp = "disallowed-isp"
|
||||
serviceBlocked = "blocked"
|
||||
serviceUnsupportedRegion = "unsupported-region"
|
||||
serviceOriginalsOnly = "originals-only"
|
||||
serviceComingSoon = "coming-soon"
|
||||
serviceTimeout = "timeout"
|
||||
serviceFailed = "failed"
|
||||
)
|
||||
|
||||
// An empty Names runs every check.
|
||||
type ServiceCheckParams struct {
|
||||
ProxyName string `json:"proxy-name"`
|
||||
Names []string `json:"names"`
|
||||
Timeout int64 `json:"timeout"`
|
||||
}
|
||||
|
||||
// runServiceCheck stamps Name; a blank Status means a rule has yet to judge it.
|
||||
type ServiceCheckItem struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Region string `json:"region,omitempty"`
|
||||
Delay int64 `json:"delay,omitempty"`
|
||||
Chains []string `json:"chains,omitempty"`
|
||||
CheckedAt int64 `json:"checked-at"`
|
||||
CoreEpoch uint64 `json:"core-epoch"`
|
||||
PicksVersion uint64 `json:"picks-version"`
|
||||
}
|
||||
|
||||
var (
|
||||
serviceCheckSlots = make(chan struct{}, serviceCheckConcurrency)
|
||||
|
||||
serviceClaimsMu sync.Mutex
|
||||
serviceClaims = map[string]*context.CancelFunc{}
|
||||
)
|
||||
|
||||
type serviceEnv struct {
|
||||
ctx context.Context
|
||||
proxyName string
|
||||
timeout time.Duration
|
||||
}
|
||||
|
||||
func (e serviceEnv) get(url string, maxBody int64) *ProbeResult {
|
||||
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: maxBody})
|
||||
}
|
||||
|
||||
func (e serviceEnv) scan(url string, until func(tail string) bool) *ProbeResult {
|
||||
return e.send(probeRequest{method: http.MethodGet, url: url, maxBody: serviceScanMaxBody, until: until})
|
||||
}
|
||||
|
||||
func (e serviceEnv) send(req probeRequest) *ProbeResult {
|
||||
req.proxyName = e.proxyName
|
||||
req.timeout = e.timeout
|
||||
headers := map[string]string{"User-Agent": browserUserAgent}
|
||||
for name, value := range req.headers {
|
||||
headers[name] = value
|
||||
}
|
||||
req.headers = headers
|
||||
return runProbe(e.ctx, req)
|
||||
}
|
||||
|
||||
type serviceChecker struct {
|
||||
name string
|
||||
check func(serviceEnv) ServiceCheckItem
|
||||
}
|
||||
|
||||
var serviceCheckers = []serviceChecker{
|
||||
{name: "google", check: checkReachable("https://www.google.com/generate_204")},
|
||||
{name: "github", check: checkReachable("https://github.com/")},
|
||||
{name: "youtube", check: checkYouTubePremium},
|
||||
{name: "chatgpt", check: checkChatGpt},
|
||||
{name: "claude", check: checkClaude},
|
||||
{name: "gemini", check: checkGemini},
|
||||
{name: "netflix", check: checkNetflix},
|
||||
{name: "disney-plus", check: checkDisneyPlus},
|
||||
{name: "prime-video", check: checkPrimeVideo},
|
||||
{name: "spotify", check: checkSpotify},
|
||||
{name: "tiktok", check: checkTikTok},
|
||||
{name: "bilibili", check: checkBilibili},
|
||||
}
|
||||
|
||||
func handleServiceCheck(params *ServiceCheckParams) []ServiceCheckItem {
|
||||
wanted := selectedCheckers(params.Names)
|
||||
items := make([]ServiceCheckItem, len(wanted))
|
||||
if len(wanted) == 0 {
|
||||
return items
|
||||
}
|
||||
|
||||
timeout := probeTimeout(params.Timeout)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout*serviceSweepBudgetFactor)
|
||||
defer cancel()
|
||||
|
||||
// Claimed before queueing, so a check waiting for a slot is cancelled the
|
||||
// moment its service is asked again.
|
||||
contexts, release := claimServices(ctx, wanted)
|
||||
defer release()
|
||||
var running sync.WaitGroup
|
||||
for index, checker := range wanted {
|
||||
granted := acquireSlot(contexts[index], serviceCheckSlots)
|
||||
running.Add(1)
|
||||
go func(index int, checker serviceChecker) {
|
||||
defer running.Done()
|
||||
if granted {
|
||||
defer func() { <-serviceCheckSlots }()
|
||||
}
|
||||
items[index] = runServiceCheck(contexts[index], checker, params.ProxyName, timeout)
|
||||
}(index, checker)
|
||||
}
|
||||
running.Wait()
|
||||
return items
|
||||
}
|
||||
|
||||
// A misbehaving rule must not take the sweep down with it.
|
||||
func runServiceCheck(ctx context.Context, checker serviceChecker, proxyName string, timeout time.Duration) (item ServiceCheckItem) {
|
||||
epoch, picksVersion := routeStamp()
|
||||
item = ServiceCheckItem{Status: serviceFailed}
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
item = ServiceCheckItem{Status: serviceFailed}
|
||||
}
|
||||
item.Name = checker.name
|
||||
item.CheckedAt = time.Now().UnixMilli()
|
||||
item.CoreEpoch = epoch
|
||||
item.PicksVersion = picksVersion
|
||||
}()
|
||||
if ctx.Err() != nil {
|
||||
return item
|
||||
}
|
||||
env := serviceEnv{ctx: ctx, proxyName: proxyName, timeout: timeout}
|
||||
item = checker.check(env)
|
||||
if item.Status == serviceTimeout && hasBudget(ctx, timeout) {
|
||||
item = checker.check(env)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// The host asks for a service again only once the older answer can no longer
|
||||
// be used, so a newer check cancels the older one and frees its probe slots.
|
||||
func claimServices(parent context.Context, checkers []serviceChecker) ([]context.Context, func()) {
|
||||
contexts := make([]context.Context, len(checkers))
|
||||
claimed := make(map[string]context.Context, len(checkers))
|
||||
var releases []func()
|
||||
for index, checker := range checkers {
|
||||
ctx, ok := claimed[checker.name]
|
||||
if !ok {
|
||||
var release func()
|
||||
ctx, release = claimService(parent, checker.name)
|
||||
claimed[checker.name] = ctx
|
||||
releases = append(releases, release)
|
||||
}
|
||||
contexts[index] = ctx
|
||||
}
|
||||
return contexts, func() {
|
||||
for _, release := range releases {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func claimService(parent context.Context, name string) (context.Context, func()) {
|
||||
ctx, cancel := context.WithCancel(parent)
|
||||
claim := &cancel
|
||||
serviceClaimsMu.Lock()
|
||||
if previous := serviceClaims[name]; previous != nil {
|
||||
(*previous)()
|
||||
}
|
||||
serviceClaims[name] = claim
|
||||
serviceClaimsMu.Unlock()
|
||||
return ctx, func() {
|
||||
cancel()
|
||||
serviceClaimsMu.Lock()
|
||||
if serviceClaims[name] == claim {
|
||||
delete(serviceClaims, name)
|
||||
}
|
||||
serviceClaimsMu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func hasBudget(ctx context.Context, timeout time.Duration) bool {
|
||||
deadline, ok := ctx.Deadline()
|
||||
return !ok || time.Until(deadline) >= timeout
|
||||
}
|
||||
|
||||
func selectedCheckers(names []string) []serviceChecker {
|
||||
if len(names) == 0 {
|
||||
return serviceCheckers
|
||||
}
|
||||
wanted := make([]serviceChecker, 0, len(names))
|
||||
for _, name := range names {
|
||||
for _, checker := range serviceCheckers {
|
||||
if checker.name == name {
|
||||
wanted = append(wanted, checker)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return wanted
|
||||
}
|
||||
|
||||
// checkReachable is the rule for a target with no unlock semantics of its own.
|
||||
func checkReachable(url string) func(serviceEnv) ServiceCheckItem {
|
||||
return func(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get(url, 0)
|
||||
item := itemFrom(result)
|
||||
if item.Status != "" {
|
||||
return item
|
||||
}
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
return item
|
||||
}
|
||||
}
|
||||
|
||||
func itemFrom(result *ProbeResult) ServiceCheckItem {
|
||||
if result == nil {
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
}
|
||||
item := ServiceCheckItem{Delay: result.Delay, Chains: result.Chains}
|
||||
switch result.Error {
|
||||
case "":
|
||||
case probeErrorTimeout:
|
||||
item.Status = serviceTimeout
|
||||
item.Delay = 0
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
item.Delay = 0
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func statusFromCode(code int) string {
|
||||
switch {
|
||||
case code >= 200 && code < 300:
|
||||
return serviceAvailable
|
||||
case code == http.StatusUnauthorized,
|
||||
code == http.StatusForbidden,
|
||||
code == http.StatusTooManyRequests,
|
||||
code == http.StatusUnavailableForLegalReasons:
|
||||
return serviceRestricted
|
||||
default:
|
||||
return serviceUnavailable
|
||||
}
|
||||
}
|
||||
|
||||
func answered(item ServiceCheckItem, result *ProbeResult) bool {
|
||||
return item.Status == "" && result != nil
|
||||
}
|
||||
|
||||
func bodyContains(result *ProbeResult, needles ...string) bool {
|
||||
body := strings.ToLower(result.Body)
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(body, needle) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func traceValue(body string, key string) string {
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if rest, found := strings.CutPrefix(line, key+"="); found {
|
||||
return strings.TrimSpace(rest)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,416 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func withServiceCheckers(t *testing.T, checkers ...serviceChecker) {
|
||||
t.Helper()
|
||||
previous := serviceCheckers
|
||||
serviceCheckers = checkers
|
||||
t.Cleanup(func() { serviceCheckers = previous })
|
||||
}
|
||||
|
||||
func stubChecker(name string, item ServiceCheckItem) serviceChecker {
|
||||
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem { return item }}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckRunsEveryCheckerByDefault(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable, Region: "US"}),
|
||||
stubChecker("beta", ServiceCheckItem{Status: serviceUnsupportedRegion}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Name != "alpha" || items[0].Status != serviceAvailable || items[0].Region != "US" {
|
||||
t.Errorf("items[0] = %+v", items[0])
|
||||
}
|
||||
if items[1].Name != "beta" || items[1].Status != serviceUnsupportedRegion {
|
||||
t.Errorf("items[1] = %+v", items[1])
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.CheckedAt == 0 {
|
||||
t.Errorf("%s has no check time", item.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckStampsTheRegisteredName(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Name: "stale", Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Name != "alpha" {
|
||||
t.Errorf("name = %q, want the registry's rather than the rule's", items[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckKeepsTheRequestedOrder(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
|
||||
stubChecker("beta", ServiceCheckItem{Status: serviceAvailable}),
|
||||
stubChecker("gamma", ServiceCheckItem{Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"gamma", "alpha"}, Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Name != "gamma" || items[1].Name != "alpha" {
|
||||
t.Errorf("names = %q, %q, want gamma, alpha", items[0].Name, items[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckIgnoresAnUnknownName(t *testing.T) {
|
||||
withServiceCheckers(t, stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}))
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Names: []string{"nope"}, Timeout: 1000})
|
||||
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("items = %d, want none", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckSurvivesAPanickingRule(t *testing.T) {
|
||||
withServiceCheckers(t,
|
||||
serviceChecker{name: "boom", check: func(serviceEnv) ServiceCheckItem { panic("rule blew up") }},
|
||||
stubChecker("alpha", ServiceCheckItem{Status: serviceAvailable}),
|
||||
)
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
if items[0].Status != serviceFailed {
|
||||
t.Errorf("panicking rule reported %q, want %q", items[0].Status, serviceFailed)
|
||||
}
|
||||
if items[1].Status != serviceAvailable {
|
||||
t.Errorf("the sibling rule reported %q, want it unaffected", items[1].Status)
|
||||
}
|
||||
}
|
||||
|
||||
type concurrencyTracker struct {
|
||||
mu sync.Mutex
|
||||
running, peak int
|
||||
total atomic.Int32
|
||||
}
|
||||
|
||||
func (c *concurrencyTracker) checker(name string) serviceChecker {
|
||||
return serviceChecker{name: name, check: func(serviceEnv) ServiceCheckItem {
|
||||
c.mu.Lock()
|
||||
c.running++
|
||||
c.peak = max(c.peak, c.running)
|
||||
c.mu.Unlock()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
c.mu.Lock()
|
||||
c.running--
|
||||
c.mu.Unlock()
|
||||
c.total.Add(1)
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}}
|
||||
}
|
||||
|
||||
func (c *concurrencyTracker) verify(t *testing.T, want int32) {
|
||||
t.Helper()
|
||||
if got := c.total.Load(); got != want {
|
||||
t.Errorf("ran %d checks, want %d", got, want)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.peak > serviceCheckConcurrency {
|
||||
t.Errorf("peak concurrency = %d, want at most %d", c.peak, serviceCheckConcurrency)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckBoundsConcurrency(t *testing.T) {
|
||||
var tracker concurrencyTracker
|
||||
checkers := make([]serviceChecker, 0, 12)
|
||||
for index := 0; index < 12; index++ {
|
||||
checkers = append(checkers, tracker.checker("x"))
|
||||
}
|
||||
withServiceCheckers(t, checkers...)
|
||||
|
||||
handleServiceCheck(&ServiceCheckParams{Timeout: 5000})
|
||||
|
||||
tracker.verify(t, 12)
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckBoundsConcurrencyAcrossRequests(t *testing.T) {
|
||||
var tracker concurrencyTracker
|
||||
checkers := make([]serviceChecker, 0, 12)
|
||||
for index := 0; index < 12; index++ {
|
||||
checkers = append(checkers, tracker.checker(fmt.Sprintf("service-%d", index)))
|
||||
}
|
||||
withServiceCheckers(t, checkers...)
|
||||
|
||||
var requests sync.WaitGroup
|
||||
for _, checker := range checkers {
|
||||
requests.Add(1)
|
||||
go func(name string) {
|
||||
defer requests.Done()
|
||||
handleServiceCheck(&ServiceCheckParams{Names: []string{name}, Timeout: 5000})
|
||||
}(checker.name)
|
||||
}
|
||||
requests.Wait()
|
||||
|
||||
tracker.verify(t, 12)
|
||||
}
|
||||
|
||||
func waitForClaim(t *testing.T, name string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
serviceClaimsMu.Lock()
|
||||
claimed := serviceClaims[name] != nil
|
||||
serviceClaimsMu.Unlock()
|
||||
if claimed {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("%s was never claimed", name)
|
||||
}
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckDropsAQueuedCheckAskedAgain(t *testing.T) {
|
||||
for i := 0; i < serviceCheckConcurrency; i++ {
|
||||
serviceCheckSlots <- struct{}{}
|
||||
}
|
||||
var freeOnce sync.Once
|
||||
free := func() {
|
||||
freeOnce.Do(func() {
|
||||
for i := 0; i < serviceCheckConcurrency; i++ {
|
||||
<-serviceCheckSlots
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Cleanup(free)
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t, serviceChecker{name: "alpha", check: func(serviceEnv) ServiceCheckItem {
|
||||
calls.Add(1)
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}})
|
||||
ask := func() chan []ServiceCheckItem {
|
||||
answer := make(chan []ServiceCheckItem, 1)
|
||||
go func() {
|
||||
answer <- handleServiceCheck(&ServiceCheckParams{Names: []string{"alpha"}, Timeout: 5000})
|
||||
}()
|
||||
return answer
|
||||
}
|
||||
|
||||
older := ask()
|
||||
waitForClaim(t, "alpha")
|
||||
newer := ask()
|
||||
|
||||
select {
|
||||
case items := <-older:
|
||||
if items[0].Status != serviceFailed {
|
||||
t.Errorf("older alpha = %q, want it cancelled", items[0].Status)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("a check queued for a slot kept waiting after its service was asked again")
|
||||
}
|
||||
free()
|
||||
select {
|
||||
case items := <-newer:
|
||||
if items[0].Status != serviceAvailable {
|
||||
t.Errorf("newer alpha = %q, want %q", items[0].Status, serviceAvailable)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the newer check never got a slot")
|
||||
}
|
||||
if got := calls.Load(); got != 1 {
|
||||
t.Errorf("ran %d checks, want only the newer one", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckHandsTheCancelledCheckSlotToItsReplacement(t *testing.T) {
|
||||
started := make(chan string, serviceCheckConcurrency)
|
||||
checkers := make([]serviceChecker, 0, serviceCheckConcurrency)
|
||||
for index := 0; index < serviceCheckConcurrency; index++ {
|
||||
name := fmt.Sprintf("service-%d", index)
|
||||
calls := &atomic.Int32{}
|
||||
checkers = append(checkers, serviceChecker{name: name, check: func(env serviceEnv) ServiceCheckItem {
|
||||
if calls.Add(1) > 1 {
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}
|
||||
started <- name
|
||||
<-env.ctx.Done()
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
}})
|
||||
}
|
||||
withServiceCheckers(t, checkers...)
|
||||
var older sync.WaitGroup
|
||||
for _, checker := range checkers {
|
||||
older.Add(1)
|
||||
go func(name string) {
|
||||
defer older.Done()
|
||||
handleServiceCheck(&ServiceCheckParams{Names: []string{name}, Timeout: 5000})
|
||||
}(checker.name)
|
||||
}
|
||||
for range checkers {
|
||||
<-started
|
||||
}
|
||||
|
||||
done := make(chan []ServiceCheckItem, 1)
|
||||
go func() {
|
||||
done <- handleServiceCheck(&ServiceCheckParams{Names: []string{checkers[0].name}, Timeout: 5000})
|
||||
}()
|
||||
|
||||
select {
|
||||
case items := <-done:
|
||||
if items[0].Status != serviceAvailable {
|
||||
t.Errorf("replacement = %q, want %q", items[0].Status, serviceAvailable)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the replacement waited behind the checks every slot was held by")
|
||||
}
|
||||
for _, checker := range checkers[1:] {
|
||||
handleServiceCheck(&ServiceCheckParams{Names: []string{checker.name}, Timeout: 5000})
|
||||
}
|
||||
older.Wait()
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckRetriesATimeoutOnce(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t, serviceChecker{name: "flaky", check: func(serviceEnv) ServiceCheckItem {
|
||||
if calls.Add(1) == 1 {
|
||||
return ServiceCheckItem{Status: serviceTimeout}
|
||||
}
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}})
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Status != serviceAvailable {
|
||||
t.Errorf("status = %q, want the retry's answer", items[0].Status)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("ran %d times, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckReportsARepeatedTimeout(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t, serviceChecker{name: "down", check: func(serviceEnv) ServiceCheckItem {
|
||||
calls.Add(1)
|
||||
return ServiceCheckItem{Status: serviceTimeout}
|
||||
}})
|
||||
|
||||
items := handleServiceCheck(&ServiceCheckParams{Timeout: 1000})
|
||||
|
||||
if items[0].Status != serviceTimeout {
|
||||
t.Errorf("status = %q, want %q", items[0].Status, serviceTimeout)
|
||||
}
|
||||
if got := calls.Load(); got != 2 {
|
||||
t.Errorf("ran %d times, want a single retry", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleServiceCheckCancelsTheOlderCheckOfTheSameService(t *testing.T) {
|
||||
started := make(chan struct{}, 2)
|
||||
var calls atomic.Int32
|
||||
withServiceCheckers(t,
|
||||
serviceChecker{name: "alpha", check: func(env serviceEnv) ServiceCheckItem {
|
||||
if calls.Add(1) > 1 {
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}
|
||||
started <- struct{}{}
|
||||
<-env.ctx.Done()
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
}},
|
||||
serviceChecker{name: "beta", check: func(env serviceEnv) ServiceCheckItem {
|
||||
started <- struct{}{}
|
||||
select {
|
||||
case <-env.ctx.Done():
|
||||
return ServiceCheckItem{Status: serviceFailed}
|
||||
case <-time.After(200 * time.Millisecond):
|
||||
return ServiceCheckItem{Status: serviceAvailable}
|
||||
}
|
||||
}},
|
||||
)
|
||||
|
||||
older := make(chan []ServiceCheckItem)
|
||||
go func() { older <- handleServiceCheck(&ServiceCheckParams{Timeout: 5000}) }()
|
||||
<-started
|
||||
<-started
|
||||
|
||||
newer := handleServiceCheck(&ServiceCheckParams{Names: []string{"alpha"}, Timeout: 5000})
|
||||
|
||||
if newer[0].Status != serviceAvailable {
|
||||
t.Errorf("newer alpha = %q, want %q", newer[0].Status, serviceAvailable)
|
||||
}
|
||||
select {
|
||||
case items := <-older:
|
||||
if items[0].Status != serviceFailed {
|
||||
t.Errorf("older alpha = %q, want it cancelled", items[0].Status)
|
||||
}
|
||||
if items[1].Status != serviceAvailable {
|
||||
t.Errorf("older beta = %q, want it left running", items[1].Status)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("the older sweep kept running after its service was asked again")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusFromCode(t *testing.T) {
|
||||
cases := map[int]string{
|
||||
http.StatusOK: serviceAvailable,
|
||||
http.StatusNoContent: serviceAvailable,
|
||||
http.StatusUnauthorized: serviceRestricted,
|
||||
http.StatusForbidden: serviceRestricted,
|
||||
http.StatusTooManyRequests: serviceRestricted,
|
||||
http.StatusUnavailableForLegalReasons: serviceRestricted,
|
||||
http.StatusNotFound: serviceUnavailable,
|
||||
http.StatusInternalServerError: serviceUnavailable,
|
||||
}
|
||||
for code, want := range cases {
|
||||
if got := statusFromCode(code); got != want {
|
||||
t.Errorf("statusFromCode(%d) = %q, want %q", code, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemFromMapsTransportErrors(t *testing.T) {
|
||||
timedOut := itemFrom(&ProbeResult{Error: probeErrorTimeout, Delay: 900})
|
||||
if timedOut.Status != serviceTimeout {
|
||||
t.Errorf("status = %q, want %q", timedOut.Status, serviceTimeout)
|
||||
}
|
||||
if timedOut.Delay != 0 {
|
||||
t.Errorf("delay = %d, want it dropped for a timeout", timedOut.Delay)
|
||||
}
|
||||
if missing := itemFrom(nil); missing.Status != serviceFailed {
|
||||
t.Errorf("status = %q, want %q when the probe got no slot", missing.Status, serviceFailed)
|
||||
}
|
||||
answered := itemFrom(&ProbeResult{StatusCode: http.StatusOK, Delay: 120})
|
||||
if answered.Status != "" {
|
||||
t.Errorf("status = %q, want it left for the rule to judge", answered.Status)
|
||||
}
|
||||
if answered.Delay != 120 {
|
||||
t.Errorf("delay = %d, want 120", answered.Delay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTraceValue(t *testing.T) {
|
||||
body := "fl=abc\nip=203.0.113.7\nloc=JP\nwarp=off\n"
|
||||
if got := traceValue(body, "loc"); got != "JP" {
|
||||
t.Errorf("loc = %q, want JP", got)
|
||||
}
|
||||
if got := traceValue(body, "colo"); got != "" {
|
||||
t.Errorf("colo = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/metacubex/http"
|
||||
)
|
||||
|
||||
// disneyAuthHeader is the public browser client token Disney+ hands to any web
|
||||
// visitor; the device and token calls below are rejected without it.
|
||||
const disneyAuthHeader = "Bearer ZGlzbmV5JmJyb3dzZXImMS4wLjA.Cu56AgSfBTDag5NiRA81oLHkDZfu5L3CKadnefEAY84"
|
||||
|
||||
var (
|
||||
youTubeRegionPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?i)id=["']country-code["'][^>]*>\s*([A-Za-z]{2,3})\s*<`),
|
||||
regexp.MustCompile(`"GL"\s*:\s*"([A-Za-z]{2})"`),
|
||||
regexp.MustCompile(`"countryCode"\s*:\s*"([A-Za-z]{2})"`),
|
||||
regexp.MustCompile(`"country_code"\s*:\s*"([A-Za-z]{2})"`),
|
||||
}
|
||||
tikTokRegionPattern = regexp.MustCompile(`"webapp\.app-context"\s*:\s*\{[^{}]*?"region"\s*:\s*"([^"]*)"`)
|
||||
primeRegionPattern = regexp.MustCompile(`"currentTerritory":"([^"]+)"`)
|
||||
disneyAssertionPattern = regexp.MustCompile(`"assertion"\s*:\s*"([^"]+)"`)
|
||||
disneyRefreshPattern = regexp.MustCompile(`"refresh_token"\s*:\s*"([^"]+)"`)
|
||||
disneyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
|
||||
disneySupportedPattern = regexp.MustCompile(`"inSupportedLocation"\s*:\s*(false|true)`)
|
||||
spotifyCountryPattern = regexp.MustCompile(`"countryCode"\s*:\s*"([^"]+)"`)
|
||||
geminiRegionMarker = `,2,1,200,"`
|
||||
claudeBlockedRegions = []string{"AF", "BY", "CN", "CU", "HK", "IR", "KP", "MO", "RU", "SY"}
|
||||
geminiBlockedRegions = []string{"CHN", "RUS", "BLR", "CUB", "IRN", "PRK", "SYR", "HKG", "MAC"}
|
||||
)
|
||||
|
||||
func firstSubmatch(pattern *regexp.Regexp, body string) string {
|
||||
match := pattern.FindStringSubmatch(body)
|
||||
if len(match) < 2 {
|
||||
return ""
|
||||
}
|
||||
return match[1]
|
||||
}
|
||||
|
||||
func checkClaude(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get("https://claude.ai/cdn-cgi/trace", serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
region := normalizeRegion(traceValue(result.Body, "loc"))
|
||||
if region == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = region
|
||||
item.Status = serviceAvailable
|
||||
if slices.Contains(claudeBlockedRegions, region) {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkGemini(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://gemini.google.com", func(tail string) bool {
|
||||
index := strings.Index(tail, geminiRegionMarker)
|
||||
return index >= 0 && index+len(geminiRegionMarker)+3 <= len(tail)
|
||||
})
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
index := strings.Index(result.Body, geminiRegionMarker)
|
||||
if index < 0 || index+len(geminiRegionMarker)+3 > len(result.Body) {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
start := index + len(geminiRegionMarker)
|
||||
code := result.Body[start : start+3]
|
||||
if !isAsciiUpper(code) {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(code)
|
||||
item.Status = serviceAvailable
|
||||
if slices.Contains(geminiBlockedRegions, code) {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// checkChatGpt folds Verge's separate iOS and Web results into one entry: the
|
||||
// card shows a single row per service, and a split would put two identical
|
||||
// OpenAI icons next to each other.
|
||||
func checkChatGpt(env serviceEnv) ServiceCheckItem {
|
||||
ios := env.get("https://ios.chat.openai.com/", serviceSmallMaxBody)
|
||||
item := itemFrom(ios)
|
||||
if answered(item, ios) {
|
||||
item.Status = chatGptIosStatus(ios)
|
||||
}
|
||||
|
||||
trace := env.get("https://chat.openai.com/cdn-cgi/trace", serviceSmallMaxBody)
|
||||
if traceItem := itemFrom(trace); answered(traceItem, trace) {
|
||||
item.Region = normalizeRegion(traceValue(trace.Body, "loc"))
|
||||
}
|
||||
|
||||
web := env.get("https://api.openai.com/compliance/cookie_requirements", serviceSmallMaxBody)
|
||||
if webItem := itemFrom(web); answered(webItem, web) &&
|
||||
bodyContains(web, "unsupported_country") {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func chatGptIosStatus(result *ProbeResult) string {
|
||||
switch {
|
||||
case bodyContains(result, "you may be connected to a disallowed isp"):
|
||||
return serviceDisallowedIsp
|
||||
case bodyContains(result, "sorry, you have been blocked"):
|
||||
return serviceBlocked
|
||||
case bodyContains(result, "request is not allowed. please try again later."):
|
||||
// Being turned away as an API client is what a reachable region looks
|
||||
// like here; anything else means the request never got that far.
|
||||
return serviceAvailable
|
||||
default:
|
||||
return serviceFailed
|
||||
}
|
||||
}
|
||||
|
||||
func checkYouTubePremium(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get("https://www.youtube.com/premium?hl=en", serviceHtmlMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
for _, pattern := range youTubeRegionPatterns {
|
||||
if code := firstSubmatch(pattern, result.Body); code != "" {
|
||||
item.Region = normalizeRegion(code)
|
||||
break
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case bodyContains(result,
|
||||
"youtube premium is not available in your country",
|
||||
"premium is not available in your country",
|
||||
"premium is not available in your region"):
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case result.StatusCode >= 200 && result.StatusCode < 300 &&
|
||||
bodyContains(result, "youtube premium", "ad-free", `"browseid":"spunlimited"`):
|
||||
item.Status = serviceAvailable
|
||||
default:
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkSpotify(env serviceEnv) ServiceCheckItem {
|
||||
result := env.get(
|
||||
"https://www.spotify.com/api/content/v1/country-selector?platform=web&format=json",
|
||||
serviceSmallMaxBody,
|
||||
)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(spotifyRegion(result))
|
||||
switch {
|
||||
case result.StatusCode == http.StatusForbidden ||
|
||||
result.StatusCode == http.StatusUnavailableForLegalReasons:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case result.StatusCode < 200 || result.StatusCode >= 300:
|
||||
item.Status = statusFromCode(result.StatusCode)
|
||||
case bodyContains(result, "not available in your country"):
|
||||
item.Status = serviceUnsupportedRegion
|
||||
default:
|
||||
item.Status = serviceAvailable
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// spotifyRegion prefers the country Spotify redirected the request into, and
|
||||
// falls back to the one named in the payload.
|
||||
func spotifyRegion(result *ProbeResult) string {
|
||||
if final, err := url.Parse(result.Url); err == nil {
|
||||
segments := strings.Split(strings.Trim(final.Path, "/"), "/")
|
||||
if first := segments[0]; first != "" && first != "api" {
|
||||
return strings.Split(first, "-")[0]
|
||||
}
|
||||
}
|
||||
return firstSubmatch(spotifyCountryPattern, result.Body)
|
||||
}
|
||||
|
||||
// TikTok is not behind Cloudflare: /cdn-cgi/trace redirects to a 404 page.
|
||||
func checkTikTok(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://www.tiktok.com/", tikTokRegionPattern.MatchString)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
item.Status = tikTokStatus(result)
|
||||
item.Region = tikTokRegion(result.Body)
|
||||
return item
|
||||
}
|
||||
|
||||
func tikTokStatus(result *ProbeResult) string {
|
||||
switch {
|
||||
case result.StatusCode == http.StatusForbidden ||
|
||||
result.StatusCode == http.StatusUnavailableForLegalReasons:
|
||||
return serviceUnsupportedRegion
|
||||
case result.StatusCode < 200 || result.StatusCode >= 300:
|
||||
return serviceFailed
|
||||
case bodyContains(result, "access denied", "not available in your region", "tiktok is not available"):
|
||||
return serviceUnsupportedRegion
|
||||
default:
|
||||
return serviceAvailable
|
||||
}
|
||||
}
|
||||
|
||||
// The page names its serving cluster "region" too ("EU-TTP2"); the visitor's
|
||||
// country is the one in the web app's context.
|
||||
func tikTokRegion(body string) string {
|
||||
return normalizeRegion(firstSubmatch(tikTokRegionPattern, body))
|
||||
}
|
||||
|
||||
func checkBilibili(env serviceEnv) ServiceCheckItem {
|
||||
const url = "https://api.bilibili.com/pgc/player/web/playurl?avid=18281381&cid=29892777&qn=0&type=&otype=json&ep_id=183799&fourk=1&fnver=0&fnval=16&module=bangumi"
|
||||
result := env.get(url, serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
var payload struct {
|
||||
Code int `json:"code"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
switch payload.Code {
|
||||
case 0:
|
||||
item.Status = serviceAvailable
|
||||
case -10403:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func checkPrimeVideo(env serviceEnv) ServiceCheckItem {
|
||||
result := env.scan("https://www.primevideo.com", func(tail string) bool {
|
||||
return strings.Contains(tail, "isServiceRestricted") || primeRegionPattern.MatchString(tail)
|
||||
})
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
if strings.Contains(result.Body, "isServiceRestricted") {
|
||||
item.Status = serviceUnsupportedRegion
|
||||
return item
|
||||
}
|
||||
region := firstSubmatch(primeRegionPattern, result.Body)
|
||||
if region == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(region)
|
||||
item.Status = serviceAvailable
|
||||
return item
|
||||
}
|
||||
|
||||
func checkNetflix(env serviceEnv) ServiceCheckItem {
|
||||
if item := netflixFromCdn(env); item.Status == serviceAvailable || item.Status == serviceBlocked {
|
||||
return item
|
||||
}
|
||||
|
||||
first := env.get("https://www.netflix.com/title/81280792", 0)
|
||||
firstItem := itemFrom(first)
|
||||
if !answered(firstItem, first) {
|
||||
return firstItem
|
||||
}
|
||||
second := env.get("https://www.netflix.com/title/70143836", 0)
|
||||
secondItem := itemFrom(second)
|
||||
if !answered(secondItem, second) {
|
||||
return secondItem
|
||||
}
|
||||
|
||||
item := firstItem
|
||||
switch {
|
||||
case first.StatusCode == http.StatusNotFound && second.StatusCode == http.StatusNotFound:
|
||||
item.Status = serviceOriginalsOnly
|
||||
case first.StatusCode == http.StatusForbidden || second.StatusCode == http.StatusForbidden:
|
||||
item.Status = serviceUnsupportedRegion
|
||||
case netflixServed(first.StatusCode) || netflixServed(second.StatusCode):
|
||||
item.Status = serviceAvailable
|
||||
item.Region = netflixRegion(env)
|
||||
default:
|
||||
item.Status = statusFromCode(first.StatusCode)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
func netflixServed(code int) bool {
|
||||
return code == http.StatusOK || code == http.StatusMovedPermanently
|
||||
}
|
||||
|
||||
// netflixRegion reads the country out of the redirect Netflix would send a
|
||||
// visitor to, so the redirect must not be followed.
|
||||
func netflixRegion(env serviceEnv) string {
|
||||
result := env.send(probeRequest{
|
||||
method: http.MethodGet,
|
||||
url: "https://www.netflix.com/title/80018499",
|
||||
noRedirect: true,
|
||||
})
|
||||
if result == nil || result.Error != "" || result.header == nil {
|
||||
return ""
|
||||
}
|
||||
segments := strings.Split(result.header.Get("Location"), "/")
|
||||
if len(segments) < 4 {
|
||||
return ""
|
||||
}
|
||||
return normalizeRegion(strings.Split(segments[3], "-")[0])
|
||||
}
|
||||
|
||||
func netflixFromCdn(env serviceEnv) ServiceCheckItem {
|
||||
const url = "https://api.fast.com/netflix/speedtest/v2?https=true&token=YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm&urlCount=5"
|
||||
result := env.get(url, serviceSmallMaxBody)
|
||||
item := itemFrom(result)
|
||||
if !answered(item, result) {
|
||||
return item
|
||||
}
|
||||
if result.StatusCode == http.StatusForbidden {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
var payload struct {
|
||||
Targets []struct {
|
||||
Location struct {
|
||||
Country string `json:"country"`
|
||||
} `json:"location"`
|
||||
} `json:"targets"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(result.Body), &payload); err != nil || len(payload.Targets) == 0 {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
item.Region = normalizeRegion(payload.Targets[0].Location.Country)
|
||||
item.Status = serviceAvailable
|
||||
return item
|
||||
}
|
||||
|
||||
// checkDisneyPlus walks the browser hand-shake: register a device, exchange the
|
||||
// assertion for a refresh token, then ask the session which region it landed
|
||||
// in. Any step can answer on its own that the address is not welcome.
|
||||
func checkDisneyPlus(env serviceEnv) ServiceCheckItem {
|
||||
device := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/devices",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/json; charset=UTF-8",
|
||||
},
|
||||
body: []byte(`{"deviceFamily":"browser","applicationRuntime":"chrome","deviceProfile":"windows","attributes":{}}`),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
item := itemFrom(device)
|
||||
if !answered(item, device) {
|
||||
return item
|
||||
}
|
||||
if device.StatusCode == http.StatusForbidden {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
assertion := firstSubmatch(disneyAssertionPattern, device.Body)
|
||||
if assertion == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
|
||||
form := strings.NewReplacer("{assertion}", url.QueryEscape(assertion)).Replace(
|
||||
"grant_type=urn:ietf:params:oauth:grant-type:token-exchange" +
|
||||
"&latitude=0&longitude=0&platform=browser" +
|
||||
"&subject_token={assertion}" +
|
||||
"&subject_token_type=urn:bamtech:params:oauth:token-type:device",
|
||||
)
|
||||
token := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/token",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: []byte(form),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
if tokenItem := itemFrom(token); !answered(tokenItem, token) {
|
||||
return tokenItem
|
||||
}
|
||||
if strings.Contains(token.Body, "forbidden-location") || strings.Contains(token.Body, "403 ERROR") {
|
||||
item.Status = serviceBlocked
|
||||
return item
|
||||
}
|
||||
refresh := firstSubmatch(disneyRefreshPattern, token.Body)
|
||||
if refresh == "" {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
|
||||
query := `{"query":"mutation refreshToken($input: RefreshTokenInput!) { refreshToken(refreshToken: $input) { activeSession { sessionId } } }","variables":{"input":{"refreshToken":"` +
|
||||
refresh + `"}}}`
|
||||
session := env.send(probeRequest{
|
||||
method: http.MethodPost,
|
||||
url: "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql",
|
||||
headers: map[string]string{
|
||||
"authorization": disneyAuthHeader,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: []byte(query),
|
||||
maxBody: serviceSmallMaxBody,
|
||||
})
|
||||
sessionItem := itemFrom(session)
|
||||
if !answered(sessionItem, session) || session.Body == "" || session.StatusCode >= 400 {
|
||||
item.Status = serviceFailed
|
||||
return item
|
||||
}
|
||||
region := normalizeRegion(firstSubmatch(disneyCountryPattern, session.Body))
|
||||
if region == "" {
|
||||
item.Status = serviceUnavailable
|
||||
return item
|
||||
}
|
||||
item.Region = region
|
||||
switch firstSubmatch(disneySupportedPattern, session.Body) {
|
||||
case "true":
|
||||
item.Status = serviceAvailable
|
||||
case "false":
|
||||
item.Status = serviceComingSoon
|
||||
default:
|
||||
item.Status = serviceFailed
|
||||
}
|
||||
return item
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeRegion(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"US": "US",
|
||||
"jp": "JP",
|
||||
"CHN": "CN",
|
||||
"usa": "US",
|
||||
"HKG": "HK",
|
||||
"ZZZ": "",
|
||||
"C": "",
|
||||
"CNAAA": "",
|
||||
"": "",
|
||||
"1A": "",
|
||||
}
|
||||
for input, want := range cases {
|
||||
if got := normalizeRegion(input); got != want {
|
||||
t.Errorf("normalizeRegion(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRegionCoversTheBlockLists(t *testing.T) {
|
||||
for _, code := range geminiBlockedRegions {
|
||||
if normalizeRegion(code) == "" {
|
||||
t.Errorf("gemini block list entry %q does not fold to an alpha-2 code", code)
|
||||
}
|
||||
}
|
||||
for _, code := range claudeBlockedRegions {
|
||||
if normalizeRegion(code) != code {
|
||||
t.Errorf("claude block list entry %q is not already alpha-2", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The iOS endpoint turns API clients away even where ChatGPT works, so that
|
||||
// refusal is the success signal and anything else is not.
|
||||
func TestChatGptIosStatus(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Request is not allowed. Please try again later.": serviceAvailable,
|
||||
"You may be connected to a disallowed ISP": serviceDisallowedIsp,
|
||||
"Sorry, you have been blocked": serviceBlocked,
|
||||
"<html>something else entirely</html>": serviceFailed,
|
||||
"": serviceFailed,
|
||||
}
|
||||
for body, want := range cases {
|
||||
if got := chatGptIosStatus(&ProbeResult{Body: body}); got != want {
|
||||
t.Errorf("chatGptIosStatus(%q) = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokStatus(t *testing.T) {
|
||||
cases := []struct {
|
||||
code int
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{http.StatusOK, `{"region":"JP"}`, serviceAvailable},
|
||||
{http.StatusForbidden, "", serviceUnsupportedRegion},
|
||||
{http.StatusUnavailableForLegalReasons, "", serviceUnsupportedRegion},
|
||||
{http.StatusOK, "Access Denied", serviceUnsupportedRegion},
|
||||
{http.StatusOK, "TikTok is not available in your region", serviceUnsupportedRegion},
|
||||
{http.StatusInternalServerError, "", serviceFailed},
|
||||
}
|
||||
for _, testCase := range cases {
|
||||
got := tikTokStatus(&ProbeResult{StatusCode: testCase.code, Body: testCase.body})
|
||||
if got != testCase.want {
|
||||
t.Errorf("tikTokStatus(%d, %q) = %q, want %q", testCase.code, testCase.body, got, testCase.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTikTokRegionReadsTheAppContext(t *testing.T) {
|
||||
const cluster = `{"idc":"no1a","vdc":"no1a","region":"EU-TTP2","vregion":"EU-TTP2"}`
|
||||
page := cluster + `{"__DEFAULT_SCOPE__":{"webapp.app-context":{"language":"en","region":"GB","appId":1233}}}`
|
||||
if got := tikTokRegion(page); got != "GB" {
|
||||
t.Errorf("region = %q, want the visitor's country rather than the serving cluster", got)
|
||||
}
|
||||
if got := tikTokRegion(cluster); got != "" {
|
||||
t.Errorf("region = %q, want empty without the app context", got)
|
||||
}
|
||||
if got := tikTokRegion(`{"webapp.app-context":{"region":"zh-Hant-TW"}}`); got != "" {
|
||||
t.Errorf("region = %q, want a locale left unread rather than its language", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpotifyRegionPrefersTheRedirectedPath(t *testing.T) {
|
||||
redirected := &ProbeResult{Url: "https://www.spotify.com/de-de/", Body: `{"countryCode":"US"}`}
|
||||
if got := spotifyRegion(redirected); got != "de" {
|
||||
t.Errorf("region = %q, want the path's country", got)
|
||||
}
|
||||
unredirected := &ProbeResult{
|
||||
Url: "https://www.spotify.com/api/content/v1/country-selector",
|
||||
Body: `{"countryCode":"SE"}`,
|
||||
}
|
||||
if got := spotifyRegion(unredirected); got != "SE" {
|
||||
t.Errorf("region = %q, want the payload's country", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestYouTubeRegionPatterns(t *testing.T) {
|
||||
bodies := map[string]string{
|
||||
`<span id="country-code"> GB </span>`: "GB",
|
||||
`{"GL":"JP","other":1}`: "JP",
|
||||
`{"countryCode":"KR"}`: "KR",
|
||||
`{"country_code":"BR"}`: "BR",
|
||||
}
|
||||
for body, want := range bodies {
|
||||
var got string
|
||||
for _, pattern := range youTubeRegionPatterns {
|
||||
if code := firstSubmatch(pattern, body); code != "" {
|
||||
got = normalizeRegion(code)
|
||||
break
|
||||
}
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("region from %q = %q, want %q", body, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisneyAndPrimePatterns(t *testing.T) {
|
||||
if got := firstSubmatch(disneyAssertionPattern, `{"assertion":"abc.def"}`); got != "abc.def" {
|
||||
t.Errorf("assertion = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(disneyRefreshPattern, `{"refresh_token":"tok123"}`); got != "tok123" {
|
||||
t.Errorf("refresh token = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(disneySupportedPattern, `{"inSupportedLocation":false}`); got != "false" {
|
||||
t.Errorf("supported = %q", got)
|
||||
}
|
||||
if got := firstSubmatch(primeRegionPattern, `{"currentTerritory":"NL"}`); got != "NL" {
|
||||
t.Errorf("territory = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user