feat(core): check services and the outbound IP through the Core

Changelog: Proxied service checks and outbound IP risk
This commit is contained in:
chen08209
2026-09-25 03:56:58 +08:00
parent 1e97716636
commit 8068e686a3
110 changed files with 14675 additions and 344 deletions
+27 -6
View File
@@ -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
+27
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
}
+7 -5
View File
@@ -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
View File
@@ -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) {
+133
View File
@@ -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
}
+147
View File
@@ -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
View File
@@ -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
}
}
+328
View File
@@ -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)
}
}
+68
View File
@@ -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
View File
@@ -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()
}
}
}
}
+307
View File
@@ -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
View File
@@ -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))
}
}
+297
View File
@@ -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 ""
}
+416
View File
@@ -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)
}
}
+440
View File
@@ -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
}
+141
View File
@@ -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)
}
}