Files

1177 lines
36 KiB
Go
Raw Permalink Normal View History

package core
import (
"context"
"crypto/x509"
"encoding/base64"
"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/age"
"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"
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
timedOut bool
want int32
}{
{delay: 0, want: delayFailed},
{delay: 0, timedOut: true, want: delayTimedOut},
{delay: 1, want: 1},
{delay: 250, want: 250},
{delay: 250, timedOut: true, want: 250},
{delay: 65535, want: 65535},
}
for _, test := range tests {
if got := delayValue(test.delay, test.timedOut); got != test.want {
t.Errorf("delayValue(%d, %v) = %d, want %d", test.delay, test.timedOut, 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 err := handleValidateConfig(path); err != nil {
t.Fatalf("handleValidateConfig = %v, want no error", err)
}
}
func TestHandleDecryptConfigReadsAnAgeEncryptedFile(t *testing.T) {
secretKey, publicKey, err := age.GenX25519KeyPair()
if err != nil {
t.Fatalf("generate key pair: %v", err)
}
encrypted, err := age.EncryptBytes([]byte("mixed-port: 7890\n"), publicKey)
if err != nil {
t.Fatalf("encrypt config: %v", err)
}
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, encrypted, 0o600); err != nil {
t.Fatalf("write config: %v", err)
}
got, err := handleDecryptConfig(DecryptConfigParams{Path: path, AgeSecretKey: secretKey})
if err != nil {
t.Fatalf("handleDecryptConfig = %v, want no error", err)
}
if got != "mixed-port: 7890\n" {
t.Errorf("handleDecryptConfig = %q, want the plaintext config", got)
}
otherKey, _, err := age.GenX25519KeyPair()
if err != nil {
t.Fatalf("generate key pair: %v", err)
}
if _, err := handleDecryptConfig(DecryptConfigParams{Path: path, AgeSecretKey: otherKey}); err == nil {
t.Error("handleDecryptConfig decrypted with a key the file was not encrypted to")
}
}
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])
}
}
func TestHandleConvertProxiesReadsABase64ShareLinkList(t *testing.T) {
path := filepath.Join(t.TempDir(), "subscription")
links := "trojan://secret@example.com:443#first\nss://YWVzLTEyOC1nY206cGFzcw@example.com:8388#second\n"
if err := os.WriteFile(path, []byte(base64.StdEncoding.EncodeToString([]byte(links))), 0o600); err != nil {
t.Fatal(err)
}
got, err := handleConvertProxies(path)
if err != nil {
t.Fatalf("handleConvertProxies error: %v", err)
}
names := make([]string, 0, len(got))
for _, proxy := range got {
names = append(names, fmt.Sprint(proxy["name"]))
}
if !slices.Equal(names, []string{"first", "second"}) {
t.Errorf("names = %v, want [first second]", names)
}
}
func TestHandleConvertProxiesRejectsAConfig(t *testing.T) {
path := filepath.Join(t.TempDir(), "config.yaml")
if err := os.WriteFile(path, []byte("proxies:\n - name: a\n type: direct\n"), 0o600); err != nil {
t.Fatal(err)
}
if _, err := handleConvertProxies(path); err == nil {
t.Fatal("handleConvertProxies found share links in a config")
}
}
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) {
err := handleValidateConfig(filepath.Join(t.TempDir(), "absent.yaml"))
if err == nil {
t.Fatal("handleValidateConfig accepted a path that does not exist")
}
if !strings.Contains(err.Error(), "absent.yaml") {
t.Errorf("handleValidateConfig = %v, want it to name the missing file", err)
}
}
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 err := handleValidateConfig(path); err == nil {
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 currentLogPump() *logPump {
logMu.Lock()
defer logMu.Unlock()
return activeLog
}
func TestHandleShutdownTearsDownBackgroundWork(t *testing.T) {
withCurrentConfig(t, &config.Config{General: &config.General{}, Controller: &config.Controller{}})
isInit.Store(true)
handleStartLog()
pump := currentLogPump()
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")
}
if currentLogPump() != nil {
t.Error("shutdown left the log stream subscribed, so events keep being pumped to a host that stopped the core")
}
if !pump.stopped.Load() {
t.Error("shutdown never stopped 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. The tunnel's plain
// accessors take no lock, so under -race this proves the reads share 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 := asMethodError(handleUpdateExternalProvider(name))
if 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)
err := handleUpdateExternalProvider(name)
if err == nil {
t.Fatal("the provider error was reported as success")
}
methodError := asMethodError(err)
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 error, 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 error, 1)
go func() { second <- handleUpdateExternalProvider(name) }()
select {
case err := <-second:
if err == nil {
t.Fatal("the duplicate request reported success")
}
if code := asMethodError(err).Code; code != "provider_updating" {
t.Errorf("code = %q, want provider_updating", 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 err := <-first; err != nil {
t.Fatalf("the first update reported %q", err)
}
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 TestLoadMemoryReleaseWaitsForTheLoadsToGoQuiet(t *testing.T) {
released := make(chan struct{}, 4)
delay, release := loadReleaseDelay, releaseLoadMemory
loadReleaseDelay = 400 * time.Millisecond
releaseLoadMemory = func() { released <- struct{}{} }
t.Cleanup(func() {
loadReleaseMu.Lock()
if loadReleaseTimer != nil {
loadReleaseTimer.Stop()
}
loadReleaseMu.Unlock()
loadReleaseDelay, releaseLoadMemory = delay, release
})
scheduleLoadMemoryRelease()
time.Sleep(100 * time.Millisecond)
scheduleLoadMemoryRelease()
time.Sleep(300 * time.Millisecond)
select {
case <-released:
t.Fatal("released while providers were still loading")
default:
}
select {
case <-released:
case <-time.After(2 * time.Second):
t.Fatal("never released once the loads went quiet")
}
select {
case <-released:
t.Error("one quiet spell released twice")
case <-time.After(600 * time.Millisecond):
}
}
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 TestProxyViewHidesOnlyTheBuiltInGlobal(t *testing.T) {
hidden := func(providerName string) any {
t.Helper()
members := []constant.Proxy{namedProxy("node-a")}
pd, err := provider.NewCompatibleProvider(
providerName, members, provider.NewHealthCheck(members, "", 0, 0, true, nil),
)
if err != nil {
t.Fatalf("NewCompatibleProvider: %v", err)
}
selector, err := outboundgroup.NewSelector(
outboundgroup.GroupCommonOption{Name: globalProxyName},
outboundgroup.SelectorOption{},
namedProxy("COMPATIBLE"),
[]cp.ProxyProvider{pd},
)
if err != nil {
t.Fatalf("NewSelector: %v", err)
}
data, err := json.Marshal(proxyView(adapter.NewProxy(selector)))
if err != nil {
t.Fatalf("marshal: %v", err)
}
view := map[string]any{}
if err := json.Unmarshal(data, &view); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return view["hidden"]
}
if got := hidden(provider.ReservedName); got != true {
t.Errorf("the built-in GLOBAL reports hidden %v, want true", got)
}
if got := hidden(globalProxyName); got != false {
t.Errorf("a configured GLOBAL group reports hidden %v, want false", got)
}
}
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")
}
}