| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447 |
- package sub
- import (
- "encoding/base64"
- "encoding/json"
- "net/http"
- "net/http/httptest"
- "strings"
- "testing"
- "time"
- "github.com/gin-gonic/gin"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/logger"
- )
- const bakedRoutingPayload = `{
- "DomainStrategy": "IPIfNonMatch",
- "RemoteDNSDomain": "https://8.8.8.8/dns-query",
- "RemoteDNSIP": "8.8.8.8",
- "DomesticDNSDomain": "https://77.88.8.8/dns-query",
- "DomesticDNSIP": "77.88.8.8",
- "DnsHosts": {"lknpd.nalog.ru": "213.24.64.181"},
- "RouteOrder": "block-proxy-direct",
- "DirectSites": ["geosite:category-ru"],
- "DirectIp": ["geoip:private"],
- "ProxySites": ["geosite:youtube"],
- "BlockSites": ["geosite:category-ads"]
- }`
- func ruleSignatures(t *testing.T, doc map[string]any) []string {
- t.Helper()
- routing, _ := doc["routing"].(map[string]any)
- rules, _ := routing["rules"].([]any)
- signatures := make([]string, 0, len(rules))
- for _, rule := range rules {
- m, _ := rule.(map[string]any)
- target, _ := m["outboundTag"].(string)
- if target == "" {
- target = "balancer:" + m["balancerTag"].(string)
- }
- kind := "ip"
- if _, has := m["domain"]; has {
- kind = "domain"
- }
- if _, has := m["network"]; has {
- kind = "network"
- }
- signatures = append(signatures, kind+"->"+target)
- }
- return signatures
- }
- func assertBakedRouting(t *testing.T, doc map[string]any, wantRules []string, proxyTag string) {
- t.Helper()
- dns, _ := doc["dns"].(map[string]any)
- if dns == nil {
- t.Fatalf("doc has no dns:\n%v", doc)
- }
- if dns["tag"] != "dns_out" || dns["queryStrategy"] != "UseIP" {
- t.Fatalf("dns header = %v", dns)
- }
- servers, _ := dns["servers"].([]any)
- if len(servers) != 2 {
- t.Fatalf("dns servers = %d, want 2 (domestic + remote): %v", len(servers), servers)
- }
- first, _ := servers[0].(map[string]any)
- if first["address"] != "https://77.88.8.8/dns-query" {
- t.Fatalf("domestic dns = %v", first)
- }
- if domains, _ := first["domains"].([]any); strings.Join(stringify(domains), ",") != "geosite:category-ru" {
- t.Fatalf("domestic dns domains = %v", first["domains"])
- }
- second, _ := servers[1].(map[string]any)
- if second["address"] != "https://8.8.8.8/dns-query" {
- t.Fatalf("remote dns = %v", second)
- }
- hosts, _ := dns["hosts"].(map[string]any)
- if hosts["lknpd.nalog.ru"] != "213.24.64.181" {
- t.Fatalf("dns hosts = %v", dns["hosts"])
- }
- routing, _ := doc["routing"].(map[string]any)
- if routing["domainStrategy"] != "IPIfNonMatch" {
- t.Fatalf("domainStrategy = %v", routing["domainStrategy"])
- }
- want := make([]string, 0, len(wantRules))
- for _, rule := range wantRules {
- want = append(want, strings.Replace(rule, "PROXY", proxyTag, 1))
- }
- got := ruleSignatures(t, doc)
- if strings.Join(got, ",") != strings.Join(want, ",") {
- t.Fatalf("rules = %v\nwant %v", got, want)
- }
- }
- func TestSubJson_BakedRoutingInEveryDocument(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4801, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- if len(docs) != 1 {
- t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
- }
- want := []string{"domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY"}
- assertBakedRouting(t, docs[0], want, "proxy")
- }
- func TestSubJson_BakedRoutingReplacesLegacyRules(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4802, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
- js := NewSubJsonService("", legacy, "", bakedRoutingPayload, NewSubService(""))
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- routing, _ := docs[0]["routing"].(map[string]any)
- ruleJSON, _ := json.Marshal(routing["rules"])
- if strings.Contains(string(ruleJSON), "geosite:example") {
- t.Fatalf("legacy subJsonRules must not leak into baked docs: %s", ruleJSON)
- }
- }
- func TestSubJson_BakedRoutingWithBalancer(t *testing.T) {
- seedSubDB(t)
- tcp := seedSubInbound(t, "s1", "tcpin", 4803, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- seedSubBalancer(t, &model.SubBalancer{
- Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id}, SortOrder: 1, Enabled: true,
- })
- js := NewSubJsonService("", "", "", bakedRoutingPayload, NewSubService(""))
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- if len(docs) != 2 {
- t.Fatalf("docs = %d, want 2 (inbound + balancer):\n%s", len(docs), out)
- }
- // Manual doc keeps the plain proxy tag.
- assertBakedRouting(t, findDocByRemarks(docs, "tcpin-tcpin@e"), []string{
- "domain->block", "domain->PROXY", "domain->direct", "ip->direct", "network->PROXY",
- }, "proxy")
- // Balancer doc routes proxy groups into the balancer.
- balancerDoc := findDocByRemarks(docs, "auto")
- want := []string{"domain->block", "domain->balancer:balancer", "domain->direct", "ip->direct", "network->balancer:balancer"}
- assertBakedRouting(t, balancerDoc, want, "balancer:balancer")
- }
- func TestSubJson_BakedRoutingInvalidFallsBackToDefault(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4804, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson must survive a bad routing payload: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- routing, _ := docs[0]["routing"].(map[string]any)
- rules, _ := json.Marshal(routing["rules"])
- if !strings.Contains(string(rules), `"outboundTag":"proxy"`) {
- t.Fatalf("default routing missing: %s", rules)
- }
- }
- func TestSubJson_LegacyRulesStillWorkWithoutBakedRouting(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4805, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- legacy := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
- js := NewSubJsonService("", legacy, "", "", NewSubService(""))
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- routing, _ := docs[0]["routing"].(map[string]any)
- ruleJSON, _ := json.Marshal(routing["rules"])
- if !strings.Contains(string(ruleJSON), "geosite:example") {
- t.Fatalf("legacy rules missing: %s", ruleJSON)
- }
- }
- func TestSubJson_BakedRoutingRemoteWarmsAfterColdStart(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4806, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- oldResolver := routingSourceResolver
- t.Cleanup(func() { routingSourceResolver = oldResolver })
- const source = "https://example.com/DEFAULT.JSON"
- routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
- }), false)
- js := NewSubJsonService("", "", "", source, NewSubService(""))
- // Cold: no request has primed the resolver cache yet.
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- routing, _ := docs[0]["routing"].(map[string]any)
- if routing["domainStrategy"] != "AsIs" {
- t.Fatalf("cold doc must keep default routing: %v", routing["domainStrategy"])
- }
- // The cron job warms the cache; the next request must bake the profile.
- primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
- out, _, err = js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs = parseSubJsonDocs(t, out)
- routing, _ = docs[0]["routing"].(map[string]any)
- if routing["domainStrategy"] != "IPIfNonMatch" {
- t.Fatalf("warm doc must carry the profile: %v", routing["domainStrategy"])
- }
- dns, _ := docs[0]["dns"].(map[string]any)
- servers, _ := dns["servers"].([]any)
- if len(servers) != 2 {
- t.Fatalf("warm doc dns servers = %v", servers)
- }
- }
- func TestApplyCommonHeadersFallsBackToJsonRoutingProfile(t *testing.T) {
- gin.SetMode(gin.TestMode)
- var object map[string]any
- if err := json.Unmarshal([]byte(bakedRoutingPayload), &object); err != nil {
- t.Fatalf("payload: %v", err)
- }
- happDeeplink := "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, object)))
- incyDeeplink := "incy://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(mustMarshal(t, map[string]any{"Name": "RoscomVPN"})))
- cases := []struct {
- name string
- jsonRules string
- happRules string
- want string
- }{
- {name: "inline json becomes a happ deeplink", jsonRules: bakedRoutingPayload, want: happDeeplink},
- {name: "happ deeplink passes through", jsonRules: happDeeplink, want: happDeeplink},
- {name: "incy deeplink passes through", jsonRules: incyDeeplink, want: incyDeeplink},
- {name: "blank profile keeps the header unset", jsonRules: "", want: ""},
- {name: "unusable profile keeps the header unset", jsonRules: "happ://routing/onadd/%%%", want: ""},
- {name: "explicit happ rules take precedence", jsonRules: bakedRoutingPayload, happRules: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`)), want: "happ://routing/onadd/" + base64.StdEncoding.EncodeToString([]byte(`{"A":1}`))},
- }
- for _, tc := range cases {
- t.Run(tc.name, func(t *testing.T) {
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- (&SUBController{subJsonRoutingRules: tc.jsonRules}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, tc.happRules, false)
- if got := recorder.Header().Get("Routing"); got != tc.want {
- t.Fatalf("Routing = %q, want %q", got, tc.want)
- }
- })
- }
- }
- func TestApplyCommonHeadersJsonRoutingRemoteFailsClosed(t *testing.T) {
- gin.SetMode(gin.TestMode)
- oldResolver := routingSourceResolver
- t.Cleanup(func() { routingSourceResolver = oldResolver })
- const source = "https://example.com/DEFAULT.JSON"
- routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(200, mustMarshal(t, fullRoutingPayload())), nil
- }), false)
- recorder := httptest.NewRecorder()
- ctx, _ := gin.CreateTestContext(recorder)
- (&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
- if got := recorder.Header().Get("Routing"); got != "" {
- t.Fatalf("cold cache must keep the header unset, got %q", got)
- }
- primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
- recorder = httptest.NewRecorder()
- ctx, _ = gin.CreateTestContext(recorder)
- (&SUBController{subJsonRoutingRules: source}).ApplyCommonHeaders(ctx, "", "12", "", "", "", "", false, "", false)
- got := recorder.Header().Get("Routing")
- if !strings.HasPrefix(got, "happ://routing/onadd/") {
- t.Fatalf("warm cache Routing = %q", got)
- }
- decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
- if err != nil {
- t.Fatalf("deeplink payload: %v", err)
- }
- var payload map[string]any
- if err := json.Unmarshal(decoded, &payload); err != nil {
- t.Fatalf("deeplink JSON: %v", err)
- }
- if payload["Name"] != "RoscomVPN" {
- t.Fatalf("deeplink payload = %v", payload)
- }
- waitRemoteRoutingIdle(t, routingSourceResolver)
- }
- func TestSubJson_BakedRoutingRemoteUpdateReachesDocuments(t *testing.T) {
- seedSubDB(t)
- seedSubInbound(t, "s1", "tcpin", 4807, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
- oldResolver := routingSourceResolver
- t.Cleanup(func() { routingSourceResolver = oldResolver })
- const source = "https://example.com/DEFAULT.JSON"
- current := mustMarshal(t, fullRoutingPayload())
- routingSourceResolver = newRemoteRoutingResolver(remoteRoutingTestClient(func(*http.Request) (*http.Response, error) {
- return remoteRoutingResponse(200, current), nil
- }), false)
- js := NewSubJsonService("", "", "", source, NewSubService(""))
- // A cold resolver fails closed (default routing); prime the cache first.
- primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- routing, _ := docs[0]["routing"].(map[string]any)
- if routing["domainStrategy"] != "IPIfNonMatch" {
- t.Fatalf("first doc must carry the profile: %v", routing["domainStrategy"])
- }
- // The operator edits the published profile; after the cache TTL expires,
- // the next request must re-bake the template with the new payload.
- updated := fullRoutingPayload()
- updated["DomainStrategy"] = "AsIs"
- current = mustMarshal(t, updated)
- waitRemoteRoutingIdle(t, routingSourceResolver)
- staleKey := remoteRoutingKey{kind: remoteRoutingJson, source: source}
- routingSourceResolver.mu.Lock()
- entry := routingSourceResolver.entries[staleKey]
- entry.FetchedAt = time.Now().Add(-remoteRoutingCacheTTL - time.Minute).Unix()
- routingSourceResolver.entries[staleKey] = entry
- delete(routingSourceResolver.lastAttempt, staleKey)
- routingSourceResolver.mu.Unlock()
- primeRemoteRouting(t, routingSourceResolver, remoteRoutingJson, source)
- out, _, err = js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs = parseSubJsonDocs(t, out)
- routing, _ = docs[0]["routing"].(map[string]any)
- if routing["domainStrategy"] != "AsIs" {
- t.Fatalf("updated profile must reach the documents without a restart: %v", routing["domainStrategy"])
- }
- waitRemoteRoutingIdle(t, routingSourceResolver)
- }
- func TestRemoteRoutingJsonHasItsOwnPersistedRow(t *testing.T) {
- seedSubDB(t)
- const source = "https://example.com/DEFAULT.JSON"
- // Two happ-payload settings pointing at different sources must not
- // overwrite each other's persisted cache rows.
- happSource := "https://example.com/HAPP.json"
- for _, tc := range []struct {
- kind remoteRoutingKind
- source string
- payload string
- }{
- {kind: remoteRoutingHapp, source: happSource, payload: `{"Name":"happ-profile"}`},
- {kind: remoteRoutingJson, source: source, payload: `{"Name":"json-profile"}`},
- } {
- deeplink, err := normalizeHappRouting([]byte(tc.payload))
- if err != nil {
- t.Fatalf("normalize: %v", err)
- }
- newRemoteRoutingResolver(nil, false).persistEntry(tc.kind, remoteRoutingCacheEntry{
- Source: tc.source, Content: deeplink, FetchedAt: time.Now().Unix(),
- })
- }
- for _, tc := range []struct {
- kind remoteRoutingKind
- source string
- want string
- }{
- {kind: remoteRoutingHapp, source: happSource, want: "happ-profile"},
- {kind: remoteRoutingJson, source: source, want: "json-profile"},
- } {
- resolver := newRemoteRoutingResolver(nil, true)
- resolver.now = func() time.Time { return time.Unix(1_800_000_000, 0) }
- resolver.ensurePersistedLoaded()
- got, remote, err := resolver.resolve(tc.kind, tc.source)
- if err != nil || !remote {
- t.Fatalf("resolve kind=%s: remote=%v err=%v", tc.kind, remote, err)
- }
- decoded, err := decodeRoutingBase64(strings.TrimPrefix(got, "happ://routing/onadd/"))
- if err != nil {
- t.Fatalf("decode kind=%s: %v", tc.kind, err)
- }
- var payload map[string]any
- if json.Unmarshal(decoded, &payload) != nil || payload["Name"] != tc.want {
- t.Fatalf("kind=%s payload = %s", tc.kind, decoded)
- }
- }
- }
- const maxSubLogScan = 10240
- func routingWarningCount(t *testing.T) int {
- t.Helper()
- n := 0
- for _, line := range logger.GetLogs(maxSubLogScan, "warning") {
- if strings.Contains(line, "subJsonRoutingRules") {
- n++
- }
- }
- return n
- }
- // A public subscription fetch must not write one warning per emitted document:
- // the 10k in-memory buffer the panel's log view reads is evicted by the flood.
- func TestSubJson_BadRoutingProfileWarnsOncePerRequest(t *testing.T) {
- seedSubDB(t)
- for i, name := range []string{"w1", "w2", "w3", "w4", "w5", "w6"} {
- seedSubInbound(t, "s1", name, 4870+i, 1, `{"network":"tcp","security":"none"}`)
- }
- js := NewSubJsonService("", "", "", "not json at all", NewSubService(""))
- before := routingWarningCount(t)
- out, _, err := js.GetJson("s1", "req.example.com", true)
- if err != nil {
- t.Fatalf("GetJson: %v", err)
- }
- docs := parseSubJsonDocs(t, out)
- if len(docs) < 6 {
- t.Fatalf("docs = %d, want >= 6:\n%s", len(docs), out)
- }
- if got := routingWarningCount(t) - before; got > 1 {
- t.Fatalf("one request emitting %d documents logged %d warnings, want at most 1", len(docs), got)
- }
- }
|