1e9955dec6
The suspended tunnel rejected the traffic Doze still lets through, which kept FCM from reconnecting, and the re-probe on resume tested every node, select groups included. Changelog: Push notifications reconnect in Doze
1036 lines
32 KiB
Go
1036 lines
32 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"slices"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/metacubex/mihomo/adapter"
|
|
"github.com/metacubex/mihomo/adapter/outbound"
|
|
"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"
|
|
rp "github.com/metacubex/mihomo/rules/provider"
|
|
"github.com/metacubex/mihomo/tunnel"
|
|
D "github.com/miekg/dns"
|
|
)
|
|
|
|
func namedProxy(name string) constant.Proxy {
|
|
return adapter.NewProxy(outbound.NewDirectWithOption(outbound.DirectOption{Name: name}))
|
|
}
|
|
|
|
// selectorGroup builds a real Selector over a compatible provider, so the
|
|
// selection paths under test run against mihomo's own group rather than a stub
|
|
// that could agree with a wrong assumption.
|
|
func selectorGroup(t *testing.T, name string, members ...string) constant.Proxy {
|
|
t.Helper()
|
|
|
|
proxies := make([]constant.Proxy, 0, len(members))
|
|
for _, member := range members {
|
|
proxies = append(proxies, namedProxy(member))
|
|
}
|
|
|
|
// An empty url leaves the check interval at zero, so nothing schedules
|
|
// itself and no test reaches the network.
|
|
health := provider.NewHealthCheck(proxies, "", 0, 0, true, nil)
|
|
pd, err := provider.NewCompatibleProvider(name+"-provider", proxies, 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 groupNow(t *testing.T, proxy constant.Proxy) string {
|
|
t.Helper()
|
|
|
|
outboundProxy, ok := proxy.(*adapter.Proxy)
|
|
if !ok {
|
|
t.Fatalf("proxy %q is not an adapter.Proxy", proxy.Name())
|
|
}
|
|
group, ok := outboundProxy.ProxyAdapter.(outboundgroup.ProxyGroup)
|
|
if !ok {
|
|
t.Fatalf("proxy %q is not a group", proxy.Name())
|
|
}
|
|
return group.Now()
|
|
}
|
|
|
|
// patchSelectGroup restores selections before the providers behind the groups
|
|
// have loaded - executor.loadProvider never waits for them - so it has to write
|
|
// names through unvalidated and let Selector resolve them at dial time. A name
|
|
// that never comes back has to degrade to the group's first member rather than
|
|
// break the group.
|
|
func TestPatchSelectGroupRestoresASelectionWithoutValidatingIt(t *testing.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) })
|
|
|
|
patchSelectGroup(map[string]string{"group": "node-b"})
|
|
if got := groupNow(t, group); got != "node-b" {
|
|
t.Fatalf("group resolved to %q, want the selection that was restored", got)
|
|
}
|
|
|
|
patchSelectGroup(map[string]string{"group": "a name the provider dropped"})
|
|
if got := groupNow(t, group); got != "node-a" {
|
|
t.Errorf("group resolved to %q after a stale selection, want it to fall through to the first member", got)
|
|
}
|
|
}
|
|
|
|
func typeMap(types map[string]constant.AdapterType) func(string) (constant.AdapterType, bool) {
|
|
return func(name string) (constant.AdapterType, bool) {
|
|
adapterType, ok := types[name]
|
|
return adapterType, ok
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesKeepsOnlyGroups(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"GLOBAL", "Auto", "Direct node", "Fall", "Balance", "Chain"},
|
|
typeMap(map[string]constant.AdapterType{
|
|
"GLOBAL": constant.Selector,
|
|
"Auto": constant.URLTest,
|
|
"Direct node": constant.Direct,
|
|
"Fall": constant.Fallback,
|
|
"Balance": constant.LoadBalance,
|
|
"Chain": constant.Relay,
|
|
}),
|
|
)
|
|
|
|
want := []string{"GLOBAL", "Auto", "Fall", "Balance", "Chain"}
|
|
if strings.Join(names, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("proxyGroupNames = %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesPreservesListOrder(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"C", "A", "B"},
|
|
typeMap(map[string]constant.AdapterType{
|
|
"A": constant.Selector,
|
|
"B": constant.Selector,
|
|
"C": constant.Selector,
|
|
}),
|
|
)
|
|
|
|
if strings.Join(names, ",") != "C,A,B" {
|
|
t.Fatalf("proxyGroupNames = %v, want the config order C,A,B", names)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesSkipsUnknownNames(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"Known", "Missing"},
|
|
typeMap(map[string]constant.AdapterType{"Known": constant.Selector}),
|
|
)
|
|
|
|
if len(names) != 1 || names[0] != "Known" {
|
|
t.Fatalf("proxyGroupNames = %v, want only the registered name", names)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesPrependsUnlistedGlobal(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"Auto"},
|
|
typeMap(map[string]constant.AdapterType{
|
|
"Auto": constant.URLTest,
|
|
"GLOBAL": constant.Selector,
|
|
}),
|
|
)
|
|
|
|
want := []string{"GLOBAL", "Auto"}
|
|
if strings.Join(names, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("proxyGroupNames = %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesDoesNotDuplicateListedGlobal(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"Auto", "GLOBAL"},
|
|
typeMap(map[string]constant.AdapterType{
|
|
"Auto": constant.URLTest,
|
|
"GLOBAL": constant.Selector,
|
|
}),
|
|
)
|
|
|
|
want := []string{"Auto", "GLOBAL"}
|
|
if strings.Join(names, ",") != strings.Join(want, ",") {
|
|
t.Fatalf("proxyGroupNames = %v, want %v", names, want)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesOmitsMissingGlobal(t *testing.T) {
|
|
names := proxyGroupNames(
|
|
[]string{"Auto"},
|
|
typeMap(map[string]constant.AdapterType{"Auto": constant.URLTest}),
|
|
)
|
|
|
|
if len(names) != 1 || names[0] != "Auto" {
|
|
t.Fatalf("proxyGroupNames = %v, want no GLOBAL entry", names)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesTreatsGlobalLikeAnyOtherName(t *testing.T) {
|
|
types := map[string]constant.AdapterType{
|
|
"Auto": constant.URLTest,
|
|
"GLOBAL": constant.Direct,
|
|
}
|
|
|
|
unlisted := proxyGroupNames([]string{"Auto"}, typeMap(types))
|
|
listed := proxyGroupNames([]string{"GLOBAL", "Auto"}, typeMap(types))
|
|
|
|
if strings.Join(unlisted, ",") != "Auto" {
|
|
t.Fatalf("unlisted GLOBAL = %v, want it filtered out like a listed one", unlisted)
|
|
}
|
|
if strings.Join(listed, ",") != "Auto" {
|
|
t.Fatalf("listed GLOBAL = %v, want it filtered out", listed)
|
|
}
|
|
}
|
|
|
|
func TestProxyGroupNamesEmptyList(t *testing.T) {
|
|
names := proxyGroupNames(nil, typeMap(nil))
|
|
if len(names) != 0 {
|
|
t.Fatalf("proxyGroupNames = %v, want empty", names)
|
|
}
|
|
}
|
|
|
|
func TestIsProxyGroupType(t *testing.T) {
|
|
groups := []constant.AdapterType{
|
|
constant.Selector,
|
|
constant.URLTest,
|
|
constant.Fallback,
|
|
constant.Relay,
|
|
constant.LoadBalance,
|
|
}
|
|
for _, adapterType := range groups {
|
|
if !isProxyGroupType(adapterType) {
|
|
t.Errorf("isProxyGroupType(%v) = false, want true", adapterType)
|
|
}
|
|
}
|
|
|
|
singles := []constant.AdapterType{constant.Direct, constant.Reject}
|
|
for _, adapterType := range singles {
|
|
if isProxyGroupType(adapterType) {
|
|
t.Errorf("isProxyGroupType(%v) = true, want false", adapterType)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDelayValue(t *testing.T) {
|
|
tests := []struct {
|
|
delay uint16
|
|
want int32
|
|
}{
|
|
{delay: 0, want: -1},
|
|
{delay: 1, want: 1},
|
|
{delay: 250, want: 250},
|
|
{delay: 65535, want: 65535},
|
|
}
|
|
for _, test := range tests {
|
|
if got := delayValue(test.delay); got != test.want {
|
|
t.Errorf("delayValue(%d) = %d, want %d", test.delay, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestProviderPathsStayUnderTheRoot(t *testing.T) {
|
|
home := filepath.Join("var", "home")
|
|
root, target := providerPaths(home, 1234567890123)
|
|
|
|
wantRoot := filepath.Join(home, "profiles", "providers")
|
|
if root != wantRoot {
|
|
t.Fatalf("root = %q, want %q", root, wantRoot)
|
|
}
|
|
wantTarget := filepath.Join(wantRoot, "1234567890123")
|
|
if target != wantTarget {
|
|
t.Fatalf("target = %q, want %q", target, wantTarget)
|
|
}
|
|
}
|
|
|
|
// The ID is an int64 rendered through strconv, so no caller-supplied value can
|
|
// add a separator or climb out of the providers root.
|
|
func TestProviderPathsCannotEscape(t *testing.T) {
|
|
home := t.TempDir()
|
|
ids := []int64{1, -1, 0, 1 << 62, -(1 << 62)}
|
|
|
|
for _, id := range ids {
|
|
root, target := providerPaths(home, id)
|
|
cleaned := filepath.Clean(target)
|
|
if !strings.HasPrefix(cleaned, root+string(filepath.Separator)) {
|
|
t.Errorf("providerPaths(%d) escaped: %q is outside %q", id, cleaned, root)
|
|
}
|
|
if filepath.Dir(cleaned) != root {
|
|
t.Errorf("providerPaths(%d) = %q, want a direct child of %q", id, cleaned, root)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHandleValidateConfigAcceptsAValidFile(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(path, []byte("mixed-port: 7890\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
if got := handleValidateConfig(path); got != "" {
|
|
t.Fatalf("handleValidateConfig = %q, want no error", got)
|
|
}
|
|
}
|
|
|
|
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"))
|
|
|
|
if got == "" {
|
|
t.Fatal("handleValidateConfig accepted a path that does not exist")
|
|
}
|
|
if !strings.Contains(got, "absent.yaml") {
|
|
t.Errorf("handleValidateConfig = %q, want it to name the missing file", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleValidateConfigReportsMalformedYaml(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "config.yaml")
|
|
if err := os.WriteFile(path, []byte("proxies: [unterminated\n"), 0o600); err != nil {
|
|
t.Fatalf("write config: %v", err)
|
|
}
|
|
|
|
if got := handleValidateConfig(path); got == "" {
|
|
t.Fatal("handleValidateConfig accepted malformed yaml")
|
|
}
|
|
}
|
|
|
|
// The provider entry has to win, because that is the one handleGetProxies
|
|
// reports under the shared name and the one the host is asking about.
|
|
func TestLookupProxyPrefersTheProviderEntry(t *testing.T) {
|
|
base := namedProxy("shared")
|
|
fromProvider := newCachingProvider("subscription", "shared", "node-a")
|
|
|
|
tunnel.UpdateProxies(
|
|
map[string]constant.Proxy{"shared": base, "DIRECT": namedProxy("DIRECT")},
|
|
map[string]cp.ProxyProvider{"subscription": fromProvider},
|
|
)
|
|
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
|
|
|
if got := lookupProxy("shared"); got == base {
|
|
t.Error("lookupProxy(shared) returned the base entry, want the provider one")
|
|
}
|
|
if got := lookupProxy("node-a"); got == nil {
|
|
t.Error("lookupProxy(node-a) = nil, want the provider entry")
|
|
}
|
|
if got := lookupProxy("DIRECT"); got != base && got == nil {
|
|
t.Error("lookupProxy(DIRECT) = nil, want the base entry")
|
|
}
|
|
if got := lookupProxy("missing"); got != nil {
|
|
t.Errorf("lookupProxy(missing) = %v, want nil", got)
|
|
}
|
|
}
|
|
|
|
// A subscription refresh has to reach a delay test without a config apply,
|
|
// which is the whole reason lookupProxy may read a cache at all.
|
|
func TestLookupProxyFollowsAProviderUpdate(t *testing.T) {
|
|
provider := newCachingProvider("subscription", "old-node")
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
|
|
|
if lookupProxy("old-node") == nil {
|
|
t.Fatal("lookupProxy(old-node) = nil before the update")
|
|
}
|
|
|
|
provider.setProxies("new-node")
|
|
|
|
if lookupProxy("new-node") == nil {
|
|
t.Error("lookupProxy(new-node) = nil, a subscription refresh did not reach the lookup")
|
|
}
|
|
if got := lookupProxy("old-node"); got != nil {
|
|
t.Errorf("lookupProxy(old-node) = %v after the refresh dropped it, want nil", got)
|
|
}
|
|
}
|
|
|
|
func TestHandleShutdownTearsDownBackgroundWork(t *testing.T) {
|
|
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
|
|
isInit.Store(true)
|
|
|
|
cancelled := false
|
|
logMu.Lock()
|
|
logSubscriber = make(chan log.Event)
|
|
logCancel = func() { cancelled = true }
|
|
logMu.Unlock()
|
|
|
|
handleShutdown()
|
|
|
|
if currentConfig != nil {
|
|
t.Error("currentConfig survived shutdown, so updateConfig would still patch a dead config")
|
|
}
|
|
if isInit.Load() {
|
|
t.Error("isInit stayed true after shutdown")
|
|
}
|
|
|
|
logMu.Lock()
|
|
subscriber, cancel := logSubscriber, logCancel
|
|
logMu.Unlock()
|
|
if subscriber != nil || cancel != nil {
|
|
t.Error("shutdown left the log stream subscribed, so events keep being pumped to a host that stopped the core")
|
|
}
|
|
if !cancelled {
|
|
t.Error("shutdown never cancelled the log pump")
|
|
}
|
|
}
|
|
|
|
func TestHandleChangeProxyDoesNotWaitOutAConfigApply(t *testing.T) {
|
|
configMu.Lock()
|
|
defer configMu.Unlock()
|
|
|
|
answered := make(chan string, 1)
|
|
go func() {
|
|
answered <- handleChangeProxy(&ChangeProxyParams{GroupName: "absent", ProxyName: "node"}).Message
|
|
}()
|
|
|
|
select {
|
|
case message := <-answered:
|
|
if message != errGroupNotFound.Error() {
|
|
t.Errorf("message = %q, want %q", message, errGroupNotFound)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("handleChangeProxy queued behind configMu, so selecting a node waits for the whole apply")
|
|
}
|
|
}
|
|
|
|
// The exclusion it does need has to survive: patchSelectGroup and
|
|
// handleChangeProxy both write group selections with no lock inside mihomo.
|
|
func TestPatchSelectGroupSerialisesWithProxyChanges(t *testing.T) {
|
|
selectMu.Lock()
|
|
|
|
patched := make(chan struct{})
|
|
go func() {
|
|
patchSelectGroup(map[string]string{"group": "node"})
|
|
close(patched)
|
|
}()
|
|
|
|
select {
|
|
case <-patched:
|
|
selectMu.Unlock()
|
|
t.Fatal("patchSelectGroup wrote selections without taking selectMu")
|
|
case <-time.After(20 * time.Millisecond):
|
|
}
|
|
|
|
selectMu.Unlock()
|
|
select {
|
|
case <-patched:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("patchSelectGroup never ran after selectMu was released")
|
|
}
|
|
}
|
|
|
|
// The hoisted constant has to keep meaning what the call meant, so a change to
|
|
// mihomo's parser cannot silently narrow which HTTP statuses a delay test
|
|
// accepts.
|
|
func TestAnyDelayTestStatusMatchesTheEmptyRange(t *testing.T) {
|
|
built, err := utils.NewUnsignedRanges[uint16]("")
|
|
if err != nil {
|
|
t.Fatalf("NewUnsignedRanges(\"\") error: %v", err)
|
|
}
|
|
if len(built) != len(anyDelayTestStatus) {
|
|
t.Fatalf("anyDelayTestStatus = %v, want %v", anyDelayTestStatus, built)
|
|
}
|
|
for _, status := range []uint16{0, 200, 204, 302, 404, 503} {
|
|
if !anyDelayTestStatus.Check(status) {
|
|
t.Errorf("status %d was rejected, so a reachable proxy reports as unreachable", status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The host reads the proxy tables — a proxy list for the UI, a provider lookup
|
|
// for an update — while a config apply replaces them. Those reads used to be
|
|
// serialised against the apply by a lock the host held for both; the tunnel's
|
|
// own accessors take none, so under -race this is what proves the reads were
|
|
// put back under the lock the apply writes with.
|
|
func TestProxyTableReadsAreSerialisedAgainstAnApply(t *testing.T) {
|
|
withTunnelProviders(t, nil, nil)
|
|
|
|
const rounds = 200
|
|
var wg sync.WaitGroup
|
|
stop := make(chan struct{})
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for round := 0; round < rounds; round++ {
|
|
name := "subscription-" + strconv.Itoa(round)
|
|
tunnel.UpdateProxies(
|
|
map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")},
|
|
map[string]cp.ProxyProvider{name: newCachingProvider(name, "node")},
|
|
)
|
|
tunnel.UpdateRules(nil, nil, map[string]cp.RuleProvider{
|
|
name: &fakeRuleProvider{name: name, vehicle: cp.HTTP},
|
|
})
|
|
}
|
|
close(stop)
|
|
}()
|
|
|
|
for reader := 0; reader < 4; reader++ {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
default:
|
|
}
|
|
tunnel.AllProxies()
|
|
externalProviders()
|
|
lookupExternalProvider("subscription-0")
|
|
}
|
|
}()
|
|
}
|
|
|
|
wg.Wait()
|
|
}
|
|
|
|
// blockingProxyProvider holds its update open so a second request for the same
|
|
// provider can be observed while the first is still running.
|
|
type blockingProxyProvider struct {
|
|
fakeProxyProvider
|
|
started chan struct{}
|
|
release chan struct{}
|
|
calls atomic.Int32
|
|
}
|
|
|
|
type failingProxyProvider struct {
|
|
fakeProxyProvider
|
|
err error
|
|
}
|
|
|
|
func (p *failingProxyProvider) Update() error {
|
|
return p.err
|
|
}
|
|
|
|
func TestClassifyProviderRequestError(t *testing.T) {
|
|
tests := []struct {
|
|
err error
|
|
want providerRequestFailure
|
|
ok bool
|
|
}{
|
|
{
|
|
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 {
|
|
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{
|
|
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
|
|
err: errors.New("proxy 0: unsupported type"),
|
|
}
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil)
|
|
|
|
methodError := handleUpdateExternalProvider(name)
|
|
if methodError == nil {
|
|
t.Fatal("the provider error was reported as success")
|
|
}
|
|
if methodError.Code != "provider_update_error" {
|
|
t.Errorf("code = %q, want provider_update_error", methodError.Code)
|
|
}
|
|
if methodError.Message != provider.err.Error() {
|
|
t.Errorf("message = %q, want %q", methodError.Message, provider.err)
|
|
}
|
|
details, ok := methodError.Details.(map[string]any)
|
|
if !ok || details["providerName"] != name {
|
|
t.Errorf("details = %#v, want providerName %q", methodError.Details, name)
|
|
}
|
|
}
|
|
|
|
func (p *blockingProxyProvider) Update() error {
|
|
p.calls.Add(1)
|
|
p.started <- struct{}{}
|
|
<-p.release
|
|
return nil
|
|
}
|
|
|
|
func TestUpdateExternalProviderRunsOneAtATime(t *testing.T) {
|
|
const name = "subscription"
|
|
provider := &blockingProxyProvider{
|
|
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
|
|
started: make(chan struct{}, 2),
|
|
release: make(chan struct{}, 2),
|
|
}
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{name: provider}, nil)
|
|
// Released through the cleanup so a blocked update never wedges the run.
|
|
t.Cleanup(func() { close(provider.release) })
|
|
|
|
first := make(chan *MethodError, 1)
|
|
go func() { first <- handleUpdateExternalProvider(name) }()
|
|
|
|
select {
|
|
case <-provider.started:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("the first update never started")
|
|
}
|
|
|
|
second := make(chan *MethodError, 1)
|
|
go func() { second <- handleUpdateExternalProvider(name) }()
|
|
|
|
select {
|
|
case methodError := <-second:
|
|
if methodError == nil {
|
|
t.Fatal("the duplicate request reported success")
|
|
}
|
|
if methodError.Code != "provider_updating" {
|
|
t.Errorf("code = %q, want provider_updating", methodError.Code)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("the duplicate request reached the provider instead of being coalesced")
|
|
}
|
|
select {
|
|
case <-provider.started:
|
|
t.Fatal("a second update started while the first was still running; both rewrite the same vehicle file")
|
|
default:
|
|
}
|
|
|
|
provider.release <- struct{}{}
|
|
if methodError := <-first; methodError != nil {
|
|
t.Fatalf("the first update reported %q", methodError.Message)
|
|
}
|
|
|
|
if got := provider.calls.Load(); got != 1 {
|
|
t.Errorf("Update ran %d times, want 1", got)
|
|
}
|
|
if !claimUpdate(providerUpdateScope + name) {
|
|
t.Fatal("the in-flight claim was never released, so the provider can never be updated again")
|
|
}
|
|
releaseUpdate(providerUpdateScope + name)
|
|
}
|
|
|
|
// cachingProvider counts how often the tunnel walked its proxy list, which is
|
|
// exactly what the AllProxies cache exists to avoid.
|
|
type cachingProvider struct {
|
|
fakeProxyProvider
|
|
mu sync.Mutex
|
|
proxies []constant.Proxy
|
|
version uint32
|
|
reads int
|
|
}
|
|
|
|
func newCachingProvider(name string, proxyNames ...string) *cachingProvider {
|
|
provider := &cachingProvider{
|
|
fakeProxyProvider: fakeProxyProvider{name: name, vehicle: cp.HTTP},
|
|
}
|
|
provider.setProxies(proxyNames...)
|
|
return provider
|
|
}
|
|
|
|
func (p *cachingProvider) Proxies() []constant.Proxy {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.reads++
|
|
return p.proxies
|
|
}
|
|
|
|
func (p *cachingProvider) Version() uint32 {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.version
|
|
}
|
|
|
|
// setProxies mirrors mihomo's baseProvider.setProxies: a new list and a bumped
|
|
// version, which is the only signal a runtime provider update leaves behind.
|
|
func (p *cachingProvider) setProxies(proxyNames ...string) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.proxies = nil
|
|
for _, name := range proxyNames {
|
|
p.proxies = append(p.proxies, namedProxy(name))
|
|
}
|
|
p.version++
|
|
}
|
|
|
|
func (p *cachingProvider) readCount() int {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
return p.reads
|
|
}
|
|
|
|
func proxyNamesOf(proxies map[string]constant.Proxy) []string {
|
|
names := make([]string, 0, len(proxies))
|
|
for name := range proxies {
|
|
names = append(names, name)
|
|
}
|
|
slices.Sort(names)
|
|
return names
|
|
}
|
|
|
|
func TestAllProxiesServesRepeatedCallsFromCache(t *testing.T) {
|
|
provider := newCachingProvider("subscription", "node-a", "node-b")
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
|
|
|
first := tunnel.AllProxies()
|
|
reads := provider.readCount()
|
|
second := tunnel.AllProxies()
|
|
|
|
if provider.readCount() != reads {
|
|
t.Errorf("the provider list was walked again for an unchanged tunnel (%d -> %d reads)",
|
|
reads, provider.readCount())
|
|
}
|
|
if got, want := proxyNamesOf(second), proxyNamesOf(first); !slices.Equal(got, want) {
|
|
t.Errorf("cached answer = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
// executor.ApplyConfig installs the providers (line 100, updateProxies) before
|
|
// it loads them (line 115, loadProvider -> Initial -> the fetcher's onUpdate ->
|
|
// setProxies), so a provider is in the tunnel with an empty list for as long as
|
|
// its subscription takes to parse. Nothing tells the cache the list arrived
|
|
// except the version the load bumps, and a read landing inside that window must
|
|
// not be what the cache keeps answering with.
|
|
func TestAllProxiesPicksUpAProviderThatLoadsAfterTheApply(t *testing.T) {
|
|
loading := newCachingProvider("subscription")
|
|
base := map[string]constant.Proxy{"DIRECT": namedProxy("DIRECT")}
|
|
|
|
tunnel.UpdateProxies(base, map[string]cp.ProxyProvider{"subscription": loading})
|
|
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
|
|
|
during := proxyNamesOf(tunnel.AllProxies())
|
|
if !slices.Equal(during, []string{"DIRECT"}) {
|
|
t.Fatalf("mid-apply AllProxies = %v, want only the base proxies", during)
|
|
}
|
|
|
|
loading.setProxies("node-a", "node-b")
|
|
|
|
after := proxyNamesOf(tunnel.AllProxies())
|
|
want := []string{"DIRECT", "node-a", "node-b"}
|
|
if !slices.Equal(after, want) {
|
|
t.Errorf("AllProxies = %v, want %v; a provider that loaded after the apply did not reach the tunnel", after, want)
|
|
}
|
|
}
|
|
|
|
// forceGC is the host's "give the memory back" hook — Android calls it from
|
|
// onLowMemory and handleShutdown ends with it — and a cache the collection
|
|
// cannot reach defeats it.
|
|
func TestForceGCReleasesTheProxyCache(t *testing.T) {
|
|
provider := newCachingProvider("subscription", "node-a", "node-b")
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
|
|
|
tunnel.AllProxies()
|
|
warm := provider.readCount()
|
|
tunnel.AllProxies()
|
|
if provider.readCount() != warm {
|
|
t.Fatal("the cache was not warm, so this proves nothing about releasing it")
|
|
}
|
|
|
|
handleForceGC()
|
|
|
|
tunnel.AllProxies()
|
|
if provider.readCount() == warm {
|
|
t.Error("AllProxies still answered from cache after a forced GC, so the replaced proxies stay pinned")
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
tunnel.AllProxies()
|
|
provider.setProxies("node-b", "node-c")
|
|
|
|
got := proxyNamesOf(tunnel.AllProxies())
|
|
if want := []string{"node-b", "node-c"}; !slices.Equal(got, want) {
|
|
t.Errorf("proxies = %v, want %v; a subscription refresh did not reach the tunnel", got, want)
|
|
}
|
|
}
|
|
|
|
// A config apply replaces the maps outright, and the replacements start their
|
|
// own version counts — so identical versions across an apply say nothing about
|
|
// whether the proxies are the same. Only the invalidation on UpdateProxies
|
|
// catches this.
|
|
func TestAllProxiesFollowsAConfigApplyThatKeepsEveryVersion(t *testing.T) {
|
|
before := newCachingProvider("subscription", "old-node")
|
|
tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": before})
|
|
t.Cleanup(func() { tunnel.UpdateProxies(nil, nil) })
|
|
|
|
tunnel.AllProxies()
|
|
|
|
after := newCachingProvider("subscription", "new-node")
|
|
if after.Version() != before.Version() {
|
|
t.Fatalf("the two providers must share a version for this to test anything (%d vs %d)",
|
|
after.Version(), before.Version())
|
|
}
|
|
tunnel.UpdateProxies(nil, map[string]cp.ProxyProvider{"subscription": after})
|
|
|
|
got := proxyNamesOf(tunnel.AllProxies())
|
|
if want := []string{"new-node"}; !slices.Equal(got, want) {
|
|
t.Errorf("proxies = %v, want %v; the previous profile's nodes survived the apply", got, want)
|
|
}
|
|
}
|
|
|
|
func TestHandleGetProxiesSeesAProviderUpdate(t *testing.T) {
|
|
provider := newCachingProvider("subscription", "node-a")
|
|
withTunnelProviders(t, map[string]cp.ProxyProvider{"subscription": provider}, nil)
|
|
|
|
if _, exist := handleGetProxies().Proxies["node-a"]; !exist {
|
|
t.Fatal("the handler did not report the installed proxy")
|
|
}
|
|
|
|
provider.setProxies("node-b")
|
|
|
|
data := handleGetProxies()
|
|
if _, exist := data.Proxies["node-b"]; !exist {
|
|
t.Error("the handler kept serving the pre-refresh proxy list")
|
|
}
|
|
if _, exist := data.Proxies["node-a"]; exist {
|
|
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")
|
|
}
|
|
}
|
|
|
|
func TestDumpRuleSetReadsEitherMrsBehavior(t *testing.T) {
|
|
cases := []struct {
|
|
behavior cp.RuleBehavior
|
|
source string
|
|
want string
|
|
}{
|
|
{cp.Domain, "example.com\n+.example.org\n", "+.example.org\nexample.com\n"},
|
|
{cp.IPCIDR, "10.0.0.0/8\n192.168.1.0/24\n", "10.0.0.0/8\n192.168.1.0/24\n"},
|
|
}
|
|
for _, c := range cases {
|
|
var mrs strings.Builder
|
|
if err := rp.ConvertToMrs([]byte(c.source), c.behavior, cp.TextRule, &mrs); err != nil {
|
|
t.Fatalf("encode %s: %v", c.behavior, err)
|
|
}
|
|
path := filepath.Join(t.TempDir(), "rules.mrs")
|
|
if err := os.WriteFile(path, []byte(mrs.String()), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := handleDumpRuleSet(path)
|
|
if err != nil {
|
|
t.Fatalf("dump %s: %v", c.behavior, err)
|
|
}
|
|
if got != c.want {
|
|
t.Fatalf("dump %s = %q, want %q", c.behavior, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestDumpRuleSetRejectsText(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "rules.txt")
|
|
if err := os.WriteFile(path, []byte("example.com\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := handleDumpRuleSet(path); err == nil {
|
|
t.Fatal("dumping a text rule set succeeded")
|
|
}
|
|
}
|