Files
3x-ui/internal/sub/sub_balancer_test.go
T
DIMFLIX 2dd903ea8e feat(sub): bake Happ/INCY routing profiles into the JSON subscription (#6402)
* 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.
2026-09-10 17:05:54 +02:00

511 lines
19 KiB
Go

package sub
import (
"encoding/json"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/database"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
)
func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
t.Helper()
if err := database.GetDB().Create(b).Error; err != nil {
t.Fatalf("seed balancer: %v", err)
}
return b
}
func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
t.Helper()
var docs []map[string]any
if err := json.Unmarshal([]byte(out), &docs); err != nil {
t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
}
return docs
}
func docOutboundTags(doc map[string]any) []string {
outbounds, _ := doc["outbounds"].([]any)
tags := make([]string, 0, len(outbounds))
for _, ob := range outbounds {
if m, ok := ob.(map[string]any); ok {
tags = append(tags, m["tag"].(string))
}
}
return tags
}
func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
for _, doc := range docs {
if doc["remarks"] == remarks {
return doc
}
}
return nil
}
// The balancer document retags members under a per-balancer prefix, points
// proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
func TestSubJson_BalancerDocument(t *testing.T) {
seedSubDB(t)
tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
})
rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
js := NewSubJsonService("", rules, "", "", 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) != 3 {
t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
}
balancerDoc := findDocByRemarks(docs, "auto")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
if len(balancers) != 1 {
t.Fatalf("balancers = %d, want 1", len(balancers))
}
balancer, _ := balancers[0].(map[string]any)
if balancer["tag"] != "balancer" {
t.Fatalf("balancer tag = %v", balancer["tag"])
}
if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("selector = %v", selector)
}
strategy, _ := balancer["strategy"].(map[string]any)
if strategy["type"] != "leastLoad" {
t.Fatalf("strategy = %v", strategy)
}
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
ruleJSON, _ := json.Marshal(routing["rules"])
if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
}
if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
}
proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
if proxyRules != 2 { // custom rule + default catch-all
t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
}
observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
t.Fatalf("subjectSelector = %v", selector)
}
ping, _ := observatory["pingConfig"].(map[string]any)
if ping["destination"] != subBalancerProbeURL {
t.Fatalf("pingConfig destination = %v", ping["destination"])
}
// The routing rewrite must not leak into the manual documents: s.configJson
// is shared, so a missing clone would corrupt every other doc.
for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
manual := findDocByRemarks(docs, remarks)
if manual == nil {
t.Fatalf("manual doc %q missing:\n%s", remarks, out)
}
if tags := docOutboundTags(manual); tags[0] != "proxy" {
t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
}
manualRouting, _ := manual["routing"].(map[string]any)
manualRules, _ := json.Marshal(manualRouting["rules"])
if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
}
if _, has := manualRouting["balancers"]; has {
t.Fatalf("manual doc %q must not carry balancers", remarks)
}
}
}
func stringify(values []any) []string {
out := make([]string, 0, len(values))
for _, v := range values {
out = append(out, v.(string))
}
return out
}
// The balancer interleaves with inbounds by the same 1-based number and, on a
// tie, follows the inbound group with that number.
func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
seedSubDB(t)
later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
var remarks []string
for _, doc := range docs {
remarks = append(remarks, doc["remarks"].(string))
}
if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
t.Fatalf("doc order = %v, want [first bal later]", remarks)
}
balancerDoc := findDocByRemarks(docs, "bal")
routing, _ := balancerDoc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
if strategy["type"] != "roundRobin" {
t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
}
}
// A disabled balancer is not emitted; an enabled one whose selected inbounds
// have no configs for this subscriber is skipped rather than emitted empty.
func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
seedSubDB(t)
inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", 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)
}
if docs[0]["remarks"] != "only-only@e" {
t.Fatalf("remaining doc = %v", docs[0]["remarks"])
}
}
// Two members sharing a transport get deduplicated tags (…-2 suffix), matching
// the reference makeTag convention.
func TestSubJson_BalancerTagDedup(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "dedup")
if balancerDoc == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
t.Fatalf("balancer outbound tags = %v", tags)
}
}
// random/roundRobin have no fallback so they emit no observatory; leastPing
// carries one, with the panel-wide ping config overriding the defaults.
func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
seedSubDB(t)
rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
})
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
rnd := findDocByRemarks(docs, "rnd")
if _, has := rnd["burstObservatory"]; has {
t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
}
pinger := findDocByRemarks(docs, "pinger")
obs, _ := pinger["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
}
ping, _ := obs["pingConfig"].(map[string]any)
if ping["destination"] != "https://probe.example/204" {
t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
}
if ping["httpMethod"] != "GET" {
t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
}
if ping["sampling"] != float64(5) {
t.Fatalf("sampling = %v, want 5", ping["sampling"])
}
if ping["interval"] != "1m" {
t.Fatalf("interval = %v, want default 1m", ping["interval"])
}
}
// A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
// filters enable=true, so B never reaches entries. Guards the access scoping.
func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable inbound B: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
docs := parseSubJsonDocs(t, out)
balancerDoc := findDocByRemarks(docs, "bal")
if balancerDoc == nil {
t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
}
tags := docOutboundTags(balancerDoc)
joined := strings.Join(tags, ",")
if !strings.Contains(joined, "bal-1-vless") {
t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
}
// B's address must not surface anywhere in the balancer doc — not as an
// outbound tag, not as a connection target a client could dial.
balJSON, _ := json.Marshal(balancerDoc)
if strings.Contains(string(balJSON), "203.0.113.5:4752") {
t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
}
}
// A balancer whose only selected inbound is disabled for this subscriber is
// skipped entirely — never emitted as an empty balancer with zero members.
func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
seedSubDB(t)
only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
t.Fatalf("disable only inbound: %v", err)
}
seedSubBalancer(t, &model.SubBalancer{
Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
if strings.TrimSpace(out) == "" {
return
}
docs := parseSubJsonDocs(t, out)
if findDocByRemarks(docs, "empty") != nil {
t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
}
}
// Connectivity defaults to empty (skip the direct pre-check); an explicit empty
// value stays empty instead of restoring the old generate_204 default.
func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
seedSubDB(t)
inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":""}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "" {
t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
}
js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
out, _, err = js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
if ping["connectivity"] != "http://probe.example/204" {
t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
}
}
// leastPing/leastLoad always emit a burst observatory (Xray won't start them
// without one); a stored {"enabled":false} is ignored as it is mandatory.
func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
js.SetObservatoryConfig(`{"enabled":false}`)
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
if pinger == nil {
t.Fatalf("balancer doc missing:\n%s", out)
}
if _, has := pinger["burstObservatory"]; !has {
t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
}
routing, _ := pinger["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
balancer, _ := balancers[0].(map[string]any)
if balancer["fallbackTag"] != "bal-1-vless" {
t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
}
}
func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
t.Helper()
doc := findDocByRemarks(docs, remarks)
if doc == nil {
t.Fatalf("balancer doc %q missing", remarks)
}
obs, _ := doc["burstObservatory"].(map[string]any)
if obs == nil {
t.Fatalf("balancer %q has no burstObservatory", remarks)
}
ping, _ := obs["pingConfig"].(map[string]any)
return ping
}
func balancerStrategy(t *testing.T, docs []map[string]any, remarks string) map[string]any {
t.Helper()
doc := findDocByRemarks(docs, remarks)
if doc == nil {
t.Fatalf("balancer doc %q missing", remarks)
}
routing, _ := doc["routing"].(map[string]any)
balancers, _ := routing["balancers"].([]any)
strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
return strategy
}
// leastLoad with configured weights must emit strategy.settings.costs keyed by
// the retagged member tags; members without a weight count as 1.0.
func TestSubJson_BalancerLeastLoadCosts(t *testing.T) {
seedSubDB(t)
fast := seedSubInbound(t, "s1", "fast", 4791, 1, wsTLSStream)
slow := seedSubInbound(t, "s1", "slow", 4792, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "weighted", Strategy: "leastLoad", InboundIds: []int{fast.Id, slow.Id},
MemberWeights: map[int]float64{fast.Id: 0.2}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "weighted")
settings, _ := strategy["settings"].(map[string]any)
costs, _ := settings["costs"].([]any)
if len(costs) != 2 {
t.Fatalf("costs = %v, want 2 entries:\n%s", costs, out)
}
first, _ := costs[0].(map[string]any)
second, _ := costs[1].(map[string]any)
// Anchored regexp is required: xray's plain cost match is substring-based,
// so a bare "bal-1-vless" would also hit the deduplicated "bal-1-vless-2".
if first["regexp"] != true || first["match"] != "^bal-1-vless$" || first["value"] != 0.2 {
t.Fatalf("costs[0] = %v, want regexp ^bal-1-vless$ value=0.2", first)
}
if second["regexp"] != true || second["match"] != "^bal-1-vless-2$" || second["value"] != 1.0 {
t.Fatalf("costs[1] = %v, want regexp ^bal-1-vless-2$ value=1 (default)", second)
}
}
// leastLoad without any configured weight emits no settings at all.
func TestSubJson_BalancerLeastLoadWithoutWeightsOmitsCosts(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "a", 4801, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "b", 4802, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "plain", Strategy: "leastLoad", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "plain")
if _, has := strategy["settings"]; has {
t.Fatalf("leastLoad without weights must not emit strategy.settings: %v", strategy["settings"])
}
}
// Emission-side guard independent of validate(): a non-leastLoad row written
// directly to the DB must still emit no costs — xray would ignore them.
func TestSubJson_BalancerCostsSkippedForNonLeastLoadStrategy(t *testing.T) {
seedSubDB(t)
a := seedSubInbound(t, "s1", "a", 4811, 1, wsTLSStream)
b := seedSubInbound(t, "s1", "b", 4812, 2, wsTLSStream)
seedSubBalancer(t, &model.SubBalancer{
Remark: "misconfig", Strategy: "random", InboundIds: []int{a.Id, b.Id},
MemberWeights: map[int]float64{a.Id: 0.5}, SortOrder: 1, Enabled: true,
})
js := NewSubJsonService("", "", "", "", NewSubService(""))
out, _, err := js.GetJson("s1", "req.example.com", true)
if err != nil {
t.Fatalf("GetJson: %v", err)
}
strategy := balancerStrategy(t, parseSubJsonDocs(t, out), "misconfig")
if _, has := strategy["settings"]; has {
t.Fatalf("random balancer must never emit costs despite stored weights: %v", strategy)
}
}