mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
4d8e2b48ff
buildBalancerConfig restarted the free tag-suffix search at 2 for every member, so member k formatted and looked up k tags and n members of one protocol cost n²/2 lookups; a balancer with many same-protocol members held a subscription request for minutes. The search now resumes per base tag where the previous member stopped, producing the same tags in linear time.
556 lines
21 KiB
Go
556 lines
21 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
|
)
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// A node authors the endpoint list of its inbounds, so the member count is
|
|
// not the admin's to bound; probing every taken suffix per member went quadratic.
|
|
func TestSubJson_BalancerManySameProtocolMembers(t *testing.T) {
|
|
members := make([]map[string]any, 100_000)
|
|
for i := range members {
|
|
members[i] = map[string]any{"protocol": "vless", "tag": "proxy"}
|
|
}
|
|
// A protocol whose base collides with a suffixed tag must still be skipped.
|
|
members[0]["protocol"] = "vless-3"
|
|
members[4]["protocol"] = "trojan"
|
|
|
|
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
|
done := make(chan json_util.RawMessage, 1)
|
|
go func() {
|
|
done <- js.buildBalancerConfig(
|
|
&model.SubBalancer{Id: 1, Remark: "many", Strategy: "random", InboundIds: []int{7}},
|
|
[]subConfigEntry{{id: 7}},
|
|
[][]map[string]any{members},
|
|
)
|
|
}()
|
|
var raw json_util.RawMessage
|
|
select {
|
|
case raw = <-done:
|
|
case <-time.After(30 * time.Second):
|
|
t.Fatalf("retagging %d same-protocol members did not finish in 30s", len(members))
|
|
}
|
|
|
|
var doc map[string]any
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
|
t.Fatalf("balancer doc is not JSON: %v", err)
|
|
}
|
|
tags := docOutboundTags(doc)
|
|
want := []string{"bal-1-vless-3", "bal-1-vless", "bal-1-vless-2", "bal-1-vless-4", "bal-1-trojan", "bal-1-vless-5"}
|
|
if len(tags) < len(members) || !slices.Equal(tags[:len(want)], want) {
|
|
t.Fatalf("first member tags = %v, want %v", tags[:min(len(tags), len(want))], want)
|
|
}
|
|
if got, last := tags[len(members)-1], fmt.Sprintf("bal-1-vless-%d", len(members)-1); got != last {
|
|
t.Fatalf("last member tag = %q, want %q", got, last)
|
|
}
|
|
}
|