mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-10 20:27:15 +00:00
2dd903ea8e
* feat(sub): parse generic Happ/INCY routing payloads for the JSON subscription Accepts the routing-rules format emitted for Happ and INCY (inline JSON, happ:// or incy:// deeplink, or a remote https:// URL resolved through the existing remote routing cache). The JSON subscription will bake these rules into its documents so header-ignoring clients still get routing. * feat(sub): bake Happ/INCY routing profiles into JSON subscription documents When subJsonRoutingRules is set, every emitted document (per-inbound and balancer alike) carries the profile's dns and routing rules baked in, so header-ignoring clients like Happ and INCY still get routing; the legacy simple-rules merge only applies when no profile is set. The balancer document builder keeps rewriting proxy-tag rules to the balancer. * feat(sub): add the subJsonRoutingRules setting Plumbed from the settings store through the subscription server into SubJsonService, so admins can set a routing profile once and every JSON subscription document carries it. * chore(api): regenerate OpenAPI artifacts for subJsonRoutingRules * feat(web): routing profile editor for the JSON subscription A textarea inside the JSON card accepts the routing profile (inline JSON, happ/incy deeplink, or https URL) with a remote-source badge; the badge helper moves to a shared module. Keys added to all 13 locales. * fix(sub): warm and lazily resolve the baked JSON routing source The routing profile was resolved once at service construction: a remote URL that was cold at that moment baked default routing forever, and the cron job never warmed it. The job now warms the subJsonRoutingRules URL, and the profile resolves per request with an in-memory memo (a failed resolve is not cached), so a warmed cache takes effect without a restart. * feat(sub): fall back to the JSON routing profile for the Routing header Happ and INCY download the geo files a routing profile references through the Routing response header. When the Happ header setting was blank the header stayed unset, and clients fetched no geo files even though a JSON routing profile was configured. A blank setting now falls back to the JSON profile: happ/incy deeplinks pass through, inline JSON and remote URLs are normalized to a happ:// deeplink; an unusable or oversized value leaves the header unset. Locale captions mention the fallback. * fix(sub): pass routingRules arg at call sites added by main Main gained four NewSubJsonService call sites after this branch forked; update them to the five-arg signature so internal/sub builds again. * fix(sub): address code review findings on the baked JSON routing The memoised baked template never invalidated, so an edited remote profile kept serving the superseded dns/routing subtrees until a panel restart; bakedTemplate now re-resolves the spec per request and rebuilds only when the payload actually changed (regression-tested). subJsonRoutingRules shared the happ persistence row with subRoutingRules, so only the last-written setting survived a restart; it now resolves under its own jsonhapp kind with the same validation and size caps. The setting also joins validateSettingsURLs, so remote values are canonicalised and bad URLs are rejected on save. Also: drop the unreachable half of the remote-source guard, cut the overlong comment blocks to the two-line convention, and deduplicate remoteSourceBadge in the General tab. Merges upstream/main (call sites for the widened NewSubJsonService signature). * style(sub): gofumpt the json_routing imports * fix(sub): accept happ add/ deeplinks and bound the routing warning The baked-JSON routing parser only recognised happ://routing/onadd/, but normalizeHappRouting treats happ://routing/add/ as an equally valid routing deeplink. An operator pasting the add/ form got the Routing header set, so the panel looked configured, while every JSON subscription document silently carried the default routing instead of their profile. resolveJsonRoutingSpec logged one warning per call and bakedTemplate calls it once per emitted document, so a single fetch of an unusable profile wrote one identical warning per document. On the public subscription server that floods the 10240-entry buffer the panel's log view reads, evicting real entries. Log only when the message changes, and reset on a successful resolve so a profile that recovers and fails again is still reported. Also resolve the template once in buildBalancerConfig: two resolves could straddle a profile refresh and pair one revision's dns with the other's routing.
448 lines
16 KiB
Go
448 lines
16 KiB
Go
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)
|
|
}
|
|
}
|