| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750 |
- package sub
- import (
- "encoding/base64"
- "errors"
- "io"
- "net/http"
- "net/http/httptest"
- "path/filepath"
- "strings"
- "sync"
- "sync/atomic"
- "testing"
- "time"
- "github.com/gin-gonic/gin"
- yaml "github.com/goccy/go-yaml"
- "github.com/mhsanaei/3x-ui/v3/internal/database"
- )
- func mergeRemoteClashRulesYAML(base map[string]any, raw string) error {
- var remote map[string]any
- if err := yaml.Unmarshal([]byte(strings.TrimSpace(raw)), &remote); err != nil {
- return err
- }
- return mergeRemoteClashRules(base, remote)
- }
- type remoteRoutingRoundTripper func(*http.Request) (*http.Response, error)
- func (fn remoteRoutingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
- return fn(req)
- }
- func remoteRoutingTestClient(fn remoteRoutingRoundTripper) *http.Client {
- return &http.Client{Transport: fn}
- }
- func remoteRoutingResponse(status int, body string) *http.Response {
- return &http.Response{
- StatusCode: status,
- Header: make(http.Header),
- Body: io.NopCloser(strings.NewReader(body)),
- }
- }
- func waitRemoteRoutingIdle(t *testing.T, resolver *remoteRoutingResolver) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for {
- resolver.mu.Lock()
- inflight := len(resolver.inflight)
- resolver.mu.Unlock()
- if inflight == 0 {
- return
- }
- if time.Now().After(deadline) {
- t.Fatal("remote routing refresh did not finish")
- }
- time.Sleep(time.Millisecond)
- }
- }
- func waitRemoteRoutingLoadIdle(t *testing.T, resolver *remoteRoutingResolver) {
- t.Helper()
- deadline := time.Now().Add(2 * time.Second)
- for {
- resolver.mu.Lock()
- loading := resolver.loadInFlight
- resolver.mu.Unlock()
- if !loading {
- return
- }
- if time.Now().After(deadline) {
- t.Fatal("persisted routing cache load did not finish")
- }
- time.Sleep(time.Millisecond)
- }
- }
- func primeRemoteRouting(t *testing.T, resolver *remoteRoutingResolver, kind remoteRoutingKind, source string) string {
- t.Helper()
- if err := resolver.refreshSource(kind, source); err != nil {
- t.Fatalf("prime remote routing: %v", err)
- }
- value, remote, err := resolver.resolve(kind, source)
- if err != nil || !remote || value == "" {
- t.Fatalf("primed resolve got=%q remote=%v err=%v", value, remote, err)
- }
- return value
- }
- func TestNormalizeHappRoutingAcceptsJSONAndDeeplink(t *testing.T) {
- deeplink, err := normalizeHappRouting([]byte(`{"Name":"RoscomVPN","GlobalProxy":"true"}`))
- if err != nil {
- t.Fatalf("normalize JSON: %v", err)
- }
- const prefix = "happ://routing/onadd/"
- if !strings.HasPrefix(deeplink, prefix) {
- t.Fatalf("deeplink = %q", deeplink)
- }
- decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(deeplink, prefix))
- if err != nil || !strings.Contains(string(decoded), `"Name":"RoscomVPN"`) {
- t.Fatalf("decoded payload = %q, err=%v", decoded, err)
- }
- if got, err := normalizeHappRouting([]byte(deeplink + "\n")); err != nil || got != deeplink {
- t.Fatalf("ready deeplink got=%q err=%v", got, err)
- }
- if _, err := normalizeHappRouting([]byte("routing.help")); err == nil {
- t.Fatal("invalid Happ response was accepted")
- }
- }
- func TestRemoteRoutingResolverAcceptsHappRedirect(t *testing.T) {
- deeplink, err := normalizeHappRouting([]byte(`{"Name":"redirected"}`))
- if err != nil {
- t.Fatalf("normalize: %v", err)
- }
- var requests atomic.Int32
- client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
- requests.Add(1)
- response := remoteRoutingResponse(http.StatusFound, "")
- response.Header.Set("Location", deeplink)
- response.Request = req
- return response, nil
- })
- client.CheckRedirect = checkRemoteRoutingRedirect
- resolver := newRemoteRoutingResolver(client, false)
- const source = "https://routing.example/"
- if err := resolver.refreshSource(remoteRoutingHapp, source); err != nil {
- t.Fatalf("refresh redirect: %v", err)
- }
- got, remote, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || !remote || got != deeplink {
- t.Fatalf("redirect resolve got=%q remote=%v err=%v", got, remote, err)
- }
- if requests.Load() != 1 {
- t.Fatalf("network requests = %d, want 1", requests.Load())
- }
- }
- func TestRemoteRoutingResolverHandlesHappNotModified(t *testing.T) {
- var requests atomic.Int32
- client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
- if requests.Add(1) == 1 {
- response := remoteRoutingResponse(http.StatusOK, `{"Name":"etagged"}`)
- response.Header.Set("ETag", `"v1"`)
- return response, nil
- }
- if req.Header.Get("If-None-Match") != `"v1"` {
- t.Errorf("If-None-Match = %q", req.Header.Get("If-None-Match"))
- }
- return remoteRoutingResponse(http.StatusNotModified, ""), nil
- })
- resolver := newRemoteRoutingResolver(client, false)
- now := time.Unix(1_800_000_000, 0)
- resolver.now = func() time.Time { return now }
- const source = "https://example.com/default.json"
- first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
- now = now.Add(remoteRoutingCacheTTL + time.Second)
- second, _, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || second != first {
- t.Fatalf("stale resolve got=%q err=%v", second, err)
- }
- waitRemoteRoutingIdle(t, resolver)
- now = now.Add(time.Minute)
- third, _, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || third != first {
- t.Fatalf("refreshed cache got=%q err=%v", third, err)
- }
- if requests.Load() != 2 {
- t.Fatalf("requests = %d, want 2", requests.Load())
- }
- }
- func TestRemoteRoutingResolverDoesNotBlockAndCoalescesColdFetch(t *testing.T) {
- var requests atomic.Int32
- started := make(chan struct{})
- release := make(chan struct{})
- var startOnce sync.Once
- client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- requests.Add(1)
- startOnce.Do(func() { close(started) })
- <-release
- return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
- })
- resolver := newRemoteRoutingResolver(client, false)
- const source = "https://example.com/default.json"
- results := make(chan error, 8)
- for range 8 {
- go func() {
- _, remote, err := resolver.resolve(remoteRoutingHapp, source)
- if !remote {
- results <- errors.New("source was not classified as remote")
- return
- }
- results <- err
- }()
- }
- <-started
- for range 8 {
- select {
- case err := <-results:
- if !errors.Is(err, errRemoteRoutingUnavailable) {
- t.Fatalf("cold resolve err=%v", err)
- }
- case <-time.After(100 * time.Millisecond):
- t.Fatal("cold resolve blocked on the remote fetch")
- }
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("requests = %d, want 1", got)
- }
- close(release)
- waitRemoteRoutingIdle(t, resolver)
- if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || !strings.HasPrefix(got, "happ://routing/onadd/") {
- t.Fatalf("cached resolve got=%q err=%v", got, err)
- }
- if got := requests.Load(); got != 1 {
- t.Fatalf("cached request count = %d, want 1", got)
- }
- }
- func TestRemoteRoutingResolverServesStaleAfterFailedRefresh(t *testing.T) {
- var requests atomic.Int32
- refreshStarted := make(chan struct{})
- releaseRefresh := make(chan struct{})
- var startOnce sync.Once
- fail := atomic.Bool{}
- client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- requests.Add(1)
- if fail.Load() {
- startOnce.Do(func() { close(refreshStarted) })
- <-releaseRefresh
- return remoteRoutingResponse(http.StatusBadGateway, "bad gateway"), nil
- }
- return remoteRoutingResponse(http.StatusOK, `{"Name":"last-good"}`), nil
- })
- resolver := newRemoteRoutingResolver(client, false)
- now := time.Unix(1_800_000_000, 0)
- resolver.now = func() time.Time { return now }
- const source = "https://example.com/default.json"
- first := primeRemoteRouting(t, resolver, remoteRoutingHapp, source)
- fail.Store(true)
- now = now.Add(remoteRoutingCacheTTL + time.Second)
- startedAt := time.Now()
- stale, remote, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || !remote || stale != first {
- t.Fatalf("stale resolve got=%q remote=%v err=%v", stale, remote, err)
- }
- if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
- t.Fatalf("stale resolve blocked for %v", elapsed)
- }
- select {
- case <-refreshStarted:
- case <-time.After(time.Second):
- t.Fatal("refresh did not run")
- }
- close(releaseRefresh)
- waitRemoteRoutingIdle(t, resolver)
- if got, _, err := resolver.resolve(remoteRoutingHapp, source); err != nil || got != first {
- t.Fatalf("negative-cache resolve got=%q err=%v", got, err)
- }
- if got := requests.Load(); got != 2 {
- t.Fatalf("requests = %d, want 2", got)
- }
- }
- func TestRemoteRoutingResolverLoadsPersistedLastGood(t *testing.T) {
- initSubDB(t)
- deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted"}`))
- if err != nil {
- t.Fatalf("normalize: %v", err)
- }
- const source = "https://example.com/default.json"
- entry := remoteRoutingCacheEntry{
- Source: source, Content: deeplink, FetchedAt: time.Now().Add(-time.Hour).Unix(), ETag: `"v1"`,
- }
- newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, entry)
- resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
- }), true)
- resolver.ensurePersistedLoaded()
- got, remote, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || !remote || got != deeplink {
- t.Fatalf("persisted resolve got=%q remote=%v err=%v", got, remote, err)
- }
- waitRemoteRoutingIdle(t, resolver)
- }
- func TestRemoteRoutingResolverDoesNotBlockOnPersistedLoad(t *testing.T) {
- started := make(chan struct{})
- release := make(chan struct{})
- var startOnce sync.Once
- resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- startOnce.Do(func() { close(started) })
- <-release
- return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
- }), true)
- resolver.loadMu.Lock()
- loadLocked := true
- t.Cleanup(func() {
- if loadLocked {
- resolver.loadMu.Unlock()
- }
- })
- startedAt := time.Now()
- _, remote, err := resolver.resolve(remoteRoutingHapp, "https://example.com/default.json")
- if !remote || !errors.Is(err, errRemoteRoutingUnavailable) {
- t.Fatalf("resolve remote=%v err=%v", remote, err)
- }
- if elapsed := time.Since(startedAt); elapsed > 100*time.Millisecond {
- t.Fatalf("resolve blocked on persisted cache load for %v", elapsed)
- }
- resolver.loadMu.Unlock()
- loadLocked = false
- close(release)
- select {
- case <-started:
- case <-time.After(time.Second):
- t.Fatal("background refresh did not start")
- }
- waitRemoteRoutingIdle(t, resolver)
- waitRemoteRoutingLoadIdle(t, resolver)
- }
- func TestRemoteRoutingResolverRejectsOversizedPersistedHappValue(t *testing.T) {
- initSubDB(t)
- deeplink, err := normalizeHappRouting([]byte(`{"Name":"` + strings.Repeat("x", remoteRoutingHappMaxValue) + `"}`))
- if err != nil || len(deeplink) <= remoteRoutingHappMaxValue {
- t.Fatalf("oversized fixture length=%d err=%v", len(deeplink), err)
- }
- const source = "https://example.com/oversized.json"
- newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
- Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
- })
- resolver := newRemoteRoutingResolver(nil, true)
- resolver.ensurePersistedLoaded()
- resolver.mu.Lock()
- _, exists := resolver.entries[remoteRoutingKey{kind: remoteRoutingHapp, source: source}]
- resolver.mu.Unlock()
- if exists {
- t.Fatal("oversized persisted Happ routing value was loaded")
- }
- }
- func TestRemoteRoutingResolverDoesNotReplaceClashCacheWithInvalidSchema(t *testing.T) {
- var requests atomic.Int32
- client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- if requests.Add(1) == 1 {
- return remoteRoutingResponse(http.StatusOK, "rules:\n - MATCH,PROXY\n"), nil
- }
- return remoteRoutingResponse(http.StatusOK, "rules: not-a-list\n"), nil
- })
- resolver := newRemoteRoutingResolver(client, false)
- now := time.Unix(1_800_000_000, 0)
- resolver.now = func() time.Time { return now }
- const source = "https://example.com/routing.yaml"
- first := primeRemoteRouting(t, resolver, remoteRoutingClash, source)
- now = now.Add(remoteRoutingCacheTTL + time.Second)
- second, _, err := resolver.resolve(remoteRoutingClash, source)
- if err != nil || second != first {
- t.Fatalf("invalid refresh replaced last-good: got=%q err=%v", second, err)
- }
- waitRemoteRoutingIdle(t, resolver)
- second, _, err = resolver.resolve(remoteRoutingClash, source)
- if err != nil || second != first {
- t.Fatalf("invalid refresh replaced last-good after completion: got=%q err=%v", second, err)
- }
- if requests.Load() != 2 {
- t.Fatalf("requests = %d, want 2", requests.Load())
- }
- }
- func TestApplyCommonHeadersResolvesRemoteHappAndFailsClosed(t *testing.T) {
- gin.SetMode(gin.TestMode)
- oldResolver := routingSourceResolver
- t.Cleanup(func() { routingSourceResolver = oldResolver })
- routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(http.StatusOK, `{"Name":"RoscomVPN"}`), nil
- }), false)
- const source = "https://example.com/default.json"
- primeRemoteRouting(t, routingSourceResolver, remoteRoutingHapp, source)
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, source, false)
- if recorder.Header().Get("Routing-Enable") != "true" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
- t.Fatalf("headers = %#v", recorder.Header())
- }
- recorder = httptest.NewRecorder()
- ctx, _ = gin.CreateTestContext(recorder)
- (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, source, false)
- if recorder.Header().Get("Routing-Enable") != "" || !strings.HasPrefix(recorder.Header().Get("Routing"), "happ://routing/onadd/") {
- t.Fatalf("independent routing headers = %#v", recorder.Header())
- }
- routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(http.StatusOK, "routing.help"), nil
- }), false)
- recorder = httptest.NewRecorder()
- ctx, _ = gin.CreateTestContext(recorder)
- (&SUBController{}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", true, "https://example.com/bad", false)
- if recorder.Header().Get("Routing-Enable") != "true" || recorder.Header().Get("Routing") != "" {
- t.Fatalf("invalid remote source leaked routing headers: %#v", recorder.Header())
- }
- waitRemoteRoutingIdle(t, routingSourceResolver)
- }
- func TestResolveIncyRemoteSourceUsesAutorouting(t *testing.T) {
- got, remote, err := resolveIncyRoutingSource("https://example.com/DEFAULT.JSON")
- if err != nil || !remote || got != "incy://autorouting/onadd/https://example.com/DEFAULT.JSON" {
- t.Fatalf("got=%q remote=%v err=%v", got, remote, err)
- }
- inline := "incy://routing/onadd/abc"
- if got, remote, err := resolveIncyRoutingSource(inline); err != nil || remote || got != inline {
- t.Fatalf("inline got=%q remote=%v err=%v", got, remote, err)
- }
- }
- func TestMergeRemoteClashRulesPreservesGeneratedProxies(t *testing.T) {
- originalProxy := map[string]any{"name": "vpn-node", "type": "vless"}
- base := map[string]any{
- "proxies": []map[string]any{originalProxy},
- "proxy-groups": []map[string]any{{
- "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
- }},
- "rules": []string{"MATCH,PROXY"},
- }
- remote := `
- proxies:
- - name: attacker-controlled
- proxy-providers:
- prov:
- url: <SUBSCRIPTION PLACEHOLDER>
- external-controller: 0.0.0.0:9090
- allow-lan: true
- mixed-port: 7890
- dns:
- enable: true
- tun:
- enable: true
- proxy-groups:
- - name: VPN
- type: select
- include-all: true
- - name: PROXY
- type: select
- proxies: [VPN]
- rule-providers:
- roscom:
- type: http
- url: https://example.com/rules.mrs
- rules:
- - RULE-SET,roscom,PROXY
- - MATCH,PROXY
- `
- if err := mergeRemoteClashRulesYAML(base, remote); err != nil {
- t.Fatalf("merge: %v", err)
- }
- proxies, ok := base["proxies"].([]map[string]any)
- if !ok || len(proxies) != 1 || proxies[0]["name"] != "vpn-node" {
- t.Fatalf("generated proxies were replaced: %#v", base["proxies"])
- }
- if _, exists := base["proxy-providers"]; exists {
- t.Fatal("remote proxy-providers were imported")
- }
- if _, exists := base["external-controller"]; exists {
- t.Fatal("unsafe top-level key was imported")
- }
- for _, key := range []string{"allow-lan", "mixed-port", "dns", "tun"} {
- if _, exists := base[key]; exists {
- t.Fatalf("client-local key %q was imported", key)
- }
- }
- if _, exists := base["rule-providers"]; !exists {
- t.Fatal("rule-providers were not imported")
- }
- groups, ok := asAnySlice(base["proxy-groups"])
- if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "VPN" || clashProxyGroupName(groups[1]) != "PROXY" {
- t.Fatalf("proxy groups = %#v", base["proxy-groups"])
- }
- rules, ok := asAnySlice(base["rules"])
- if !ok || len(rules) != 2 || rules[1] != "MATCH,PROXY" {
- t.Fatalf("rules = %#v", base["rules"])
- }
- }
- func TestMergeRemoteClashRulesKeepsBaseProxyGroupWhenRemoteOmitsIt(t *testing.T) {
- base := map[string]any{
- "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
- "proxy-groups": []map[string]any{{
- "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
- }},
- "rules": []string{"MATCH,PROXY"},
- }
- if err := mergeRemoteClashRulesYAML(base, `proxy-groups:
- - name: Extra
- type: select
- proxies: [PROXY]
- rules:
- - MATCH,PROXY
- `); err != nil {
- t.Fatalf("merge: %v", err)
- }
- groups, ok := asAnySlice(base["proxy-groups"])
- if !ok || len(groups) != 2 || clashProxyGroupName(groups[0]) != "Extra" || clashProxyGroupName(groups[1]) != "PROXY" {
- t.Fatalf("proxy groups = %#v", base["proxy-groups"])
- }
- }
- func TestRemoteRoutingRejectsOversizedHappValues(t *testing.T) {
- largeJSON := `{"Name":"large","Rules":"` + strings.Repeat("a", remoteRoutingHappMaxValue) + `"}`
- largeDeeplink, err := normalizeHappRouting([]byte(largeJSON))
- if err != nil {
- t.Fatalf("prepare large deeplink: %v", err)
- }
- tests := []struct {
- name string
- response func(*http.Request) *http.Response
- wantErr string
- }{
- {
- name: "response body",
- response: func(*http.Request) *http.Response {
- return remoteRoutingResponse(http.StatusOK, strings.Repeat("x", remoteRoutingHappMaxBody+1))
- },
- wantErr: "response exceeds the size limit",
- },
- {
- name: "normalized header",
- response: func(*http.Request) *http.Response {
- return remoteRoutingResponse(http.StatusOK, largeJSON)
- },
- wantErr: "header exceeds the size limit",
- },
- {
- name: "redirect header",
- response: func(req *http.Request) *http.Response {
- response := remoteRoutingResponse(http.StatusFound, "")
- response.Header.Set("Location", largeDeeplink)
- response.Request = req
- return response
- },
- wantErr: "header exceeds the size limit",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- client := remoteRoutingTestClient(func(req *http.Request) (*http.Response, error) {
- return tt.response(req), nil
- })
- client.CheckRedirect = checkRemoteRoutingRedirect
- resolver := newRemoteRoutingResolver(client, false)
- err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
- t.Fatalf("err=%v, want %q", err, tt.wantErr)
- }
- })
- }
- }
- func TestRemoteRoutingRefreshTurnsPanicsIntoErrors(t *testing.T) {
- client := remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- panic("transport exploded")
- })
- resolver := newRemoteRoutingResolver(client, false)
- err := resolver.refreshSource(remoteRoutingHapp, "https://example.com/rules")
- if err == nil || !strings.Contains(err.Error(), "panicked") {
- t.Fatalf("err=%v, want the panic converted into an error", err)
- }
- // The inflight slot must be released so later refreshes are not wedged.
- waitRemoteRoutingIdle(t, resolver)
- }
- func TestRemoteRoutingHTTPClientRejectsLoopback(t *testing.T) {
- resolver := newRemoteRoutingResolver(newRemoteRoutingHTTPClient(), false)
- startedAt := time.Now()
- err := resolver.refreshSource(remoteRoutingHapp, "https://127.0.0.1:1/rules")
- if err == nil {
- t.Fatal("loopback remote source was accepted")
- }
- if elapsed := time.Since(startedAt); elapsed > 2*time.Second {
- t.Fatalf("loopback rejection took %v", elapsed)
- }
- }
- func TestRemoteRoutingPersistedLoadRetriesAfterDatabaseBecomesReady(t *testing.T) {
- dbPath := filepath.Join(t.TempDir(), "x-ui.db")
- if err := database.InitDB(dbPath); err != nil {
- t.Fatalf("init db: %v", err)
- }
- t.Cleanup(func() { _ = database.CloseDB() })
- deeplink, err := normalizeHappRouting([]byte(`{"Name":"persisted-after-ready"}`))
- if err != nil {
- t.Fatalf("normalize: %v", err)
- }
- const source = "https://example.com/default.json"
- newRemoteRoutingResolver(nil, false).persistEntry(remoteRoutingHapp, remoteRoutingCacheEntry{
- Source: source, Content: deeplink, FetchedAt: time.Now().Unix(),
- })
- if err := database.CloseDB(); err != nil {
- t.Fatalf("close db: %v", err)
- }
- resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(http.StatusServiceUnavailable, "offline"), nil
- }), true)
- if _, _, err := resolver.resolve(remoteRoutingHapp, source); !errors.Is(err, errRemoteRoutingUnavailable) {
- t.Fatalf("closed-db resolve err=%v", err)
- }
- waitRemoteRoutingIdle(t, resolver)
- waitRemoteRoutingLoadIdle(t, resolver)
- if err := database.InitDB(dbPath); err != nil {
- t.Fatalf("reopen db: %v", err)
- }
- resolver.triggerPersistedLoad()
- waitRemoteRoutingLoadIdle(t, resolver)
- got, remote, err := resolver.resolve(remoteRoutingHapp, source)
- if err != nil || !remote || got != deeplink {
- t.Fatalf("reloaded resolve got=%q remote=%v err=%v", got, remote, err)
- }
- }
- func TestRemoteClashRouteGraphValidation(t *testing.T) {
- tests := []struct {
- name string
- remote string
- wantErr string
- }{
- {
- name: "missing group name",
- remote: "proxy-groups:\n - type: select\n proxies: [vpn-node]\nrules:\n - MATCH,PROXY\n",
- wantErr: "named group maps",
- },
- {
- name: "duplicate group name",
- remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,A\n",
- wantErr: "duplicated",
- },
- {
- name: "unknown group reference",
- remote: "proxy-groups:\n - {name: A, type: select, proxies: [missing]}\nrules:\n - MATCH,A\n",
- wantErr: "unknown proxy or group",
- },
- {
- name: "remote proxy provider use",
- remote: "proxy-groups:\n - name: A\n type: select\n use: [manual-provider]\nrules:\n - MATCH,A\n",
- wantErr: "cannot use proxy-providers",
- },
- {
- name: "unknown rule provider",
- remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - RULE-SET,missing,A\n - MATCH,A\n",
- wantErr: "unknown rule-provider",
- },
- {
- name: "unknown rule target",
- remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrules:\n - MATCH,missing\n",
- wantErr: "unknown proxy or group",
- },
- {
- name: "unknown provider download proxy",
- remote: "proxy-groups:\n - {name: A, type: select, proxies: [vpn-node]}\nrule-providers:\n p: {type: http, url: https://example.com/p.mrs, proxy: missing}\nrules:\n - RULE-SET,p,A\n - MATCH,A\n",
- wantErr: "rule-provider \"p\" references unknown",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- base := map[string]any{
- "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
- "proxy-groups": []map[string]any{{
- "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
- }},
- "rules": []string{"MATCH,PROXY"},
- }
- err := mergeRemoteClashRulesYAML(base, tt.remote)
- if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
- t.Fatalf("err=%v, want %q", err, tt.wantErr)
- }
- })
- }
- }
- func TestRemoteClashRouteGraphAcceptsLogicalRulesAndCachedDocument(t *testing.T) {
- const remote = `
- proxy-groups:
- - name: Auto
- type: url-test
- include-all: true
- - name: Video
- type: select
- proxies: [Auto, DIRECT]
- rule-providers:
- video:
- type: http
- url: https://example.com/video.mrs
- proxy: Auto
- rules:
- - RULE-SET,video,Video
- - AND,((NETWORK,TCP),(DST-PORT,443)),Video
- - GEOIP,private,DIRECT,no-resolve
- - IP-CIDR,192.168.0.0/16,DIRECT,no-resolve,src
- - MATCH,Auto
- `
- var requests atomic.Int32
- resolver := newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- requests.Add(1)
- return remoteRoutingResponse(http.StatusOK, remote), nil
- }), false)
- const source = "https://example.com/routing.yaml"
- if err := resolver.refreshSource(remoteRoutingClash, source); err != nil {
- t.Fatalf("refresh: %v", err)
- }
- entry, remoteSource, err := resolver.resolveEntry(remoteRoutingClash, source)
- if err != nil || !remoteSource || entry.Clash == nil {
- t.Fatalf("entry remote=%v parsed=%v err=%v", remoteSource, entry.Clash != nil, err)
- }
- base := map[string]any{
- "proxies": []map[string]any{{"name": "vpn-node", "type": "vless"}},
- "proxy-groups": []map[string]any{{
- "name": "PROXY", "type": "select", "proxies": []string{"vpn-node", "DIRECT"},
- }},
- "rules": []string{"MATCH,PROXY"},
- }
- if err := mergeRemoteClashRules(base, entry.Clash); err != nil {
- t.Fatalf("merge cached document: %v", err)
- }
- if requests.Load() != 1 {
- t.Fatalf("requests=%d, want 1", requests.Load())
- }
- }
|