sub_balancer_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. package sub
  2. import (
  3. "encoding/json"
  4. "strings"
  5. "testing"
  6. "github.com/mhsanaei/3x-ui/v3/internal/database"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  8. )
  9. func seedSubBalancer(t *testing.T, b *model.SubBalancer) *model.SubBalancer {
  10. t.Helper()
  11. if err := database.GetDB().Create(b).Error; err != nil {
  12. t.Fatalf("seed balancer: %v", err)
  13. }
  14. return b
  15. }
  16. func parseSubJsonDocs(t *testing.T, out string) []map[string]any {
  17. t.Helper()
  18. var docs []map[string]any
  19. if err := json.Unmarshal([]byte(out), &docs); err != nil {
  20. t.Fatalf("subscription is not a JSON array: %v\n%s", err, out)
  21. }
  22. return docs
  23. }
  24. func docOutboundTags(doc map[string]any) []string {
  25. outbounds, _ := doc["outbounds"].([]any)
  26. tags := make([]string, 0, len(outbounds))
  27. for _, ob := range outbounds {
  28. if m, ok := ob.(map[string]any); ok {
  29. tags = append(tags, m["tag"].(string))
  30. }
  31. }
  32. return tags
  33. }
  34. func findDocByRemarks(docs []map[string]any, remarks string) map[string]any {
  35. for _, doc := range docs {
  36. if doc["remarks"] == remarks {
  37. return doc
  38. }
  39. }
  40. return nil
  41. }
  42. // The balancer document retags members under a per-balancer prefix, points
  43. // proxy rules at the balancer, and probes it — manual docs keep plain "proxy".
  44. func TestSubJson_BalancerDocument(t *testing.T) {
  45. seedSubDB(t)
  46. tcp := seedSubInbound(t, "s1", "tcpin", 4701, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
  47. ws := seedSubInbound(t, "s1", "wsin", 4702, 2, wsTLSStream)
  48. seedSubBalancer(t, &model.SubBalancer{
  49. Remark: "auto", Strategy: "leastLoad", InboundIds: []int{tcp.Id, ws.Id}, SortOrder: 1, Enabled: true,
  50. })
  51. rules := `[{"type":"field","domain":["geosite:example"],"outboundTag":"proxy"}]`
  52. js := NewSubJsonService("", rules, "", NewSubService(""))
  53. out, _, err := js.GetJson("s1", "req.example.com", true)
  54. if err != nil {
  55. t.Fatalf("GetJson: %v", err)
  56. }
  57. docs := parseSubJsonDocs(t, out)
  58. if len(docs) != 3 {
  59. t.Fatalf("docs = %d, want 3 (2 inbounds + 1 balancer):\n%s", len(docs), out)
  60. }
  61. balancerDoc := findDocByRemarks(docs, "auto")
  62. if balancerDoc == nil {
  63. t.Fatalf("balancer doc missing:\n%s", out)
  64. }
  65. if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
  66. t.Fatalf("balancer outbound tags = %v", tags)
  67. }
  68. routing, _ := balancerDoc["routing"].(map[string]any)
  69. balancers, _ := routing["balancers"].([]any)
  70. if len(balancers) != 1 {
  71. t.Fatalf("balancers = %d, want 1", len(balancers))
  72. }
  73. balancer, _ := balancers[0].(map[string]any)
  74. if balancer["tag"] != "balancer" {
  75. t.Fatalf("balancer tag = %v", balancer["tag"])
  76. }
  77. if selector, _ := balancer["selector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
  78. t.Fatalf("selector = %v", selector)
  79. }
  80. strategy, _ := balancer["strategy"].(map[string]any)
  81. if strategy["type"] != "leastLoad" {
  82. t.Fatalf("strategy = %v", strategy)
  83. }
  84. if balancer["fallbackTag"] != "bal-1-vless" {
  85. t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
  86. }
  87. ruleJSON, _ := json.Marshal(routing["rules"])
  88. if strings.Contains(string(ruleJSON), `"outboundTag":"proxy"`) {
  89. t.Fatalf("balancer rules must not point at the plain proxy tag: %s", ruleJSON)
  90. }
  91. if !strings.Contains(string(ruleJSON), `"balancerTag":"balancer"`) {
  92. t.Fatalf("balancer catch-all rule missing balancerTag: %s", ruleJSON)
  93. }
  94. proxyRules := strings.Count(string(ruleJSON), `"balancerTag"`)
  95. if proxyRules != 2 { // custom rule + default catch-all
  96. t.Fatalf("balancerTag rules = %d, want 2: %s", proxyRules, ruleJSON)
  97. }
  98. observatory, _ := balancerDoc["burstObservatory"].(map[string]any)
  99. if selector, _ := observatory["subjectSelector"].([]any); strings.Join(stringify(selector), ",") != "bal-1-" {
  100. t.Fatalf("subjectSelector = %v", selector)
  101. }
  102. ping, _ := observatory["pingConfig"].(map[string]any)
  103. if ping["destination"] != subBalancerProbeURL {
  104. t.Fatalf("pingConfig destination = %v", ping["destination"])
  105. }
  106. // The routing rewrite must not leak into the manual documents: s.configJson
  107. // is shared, so a missing clone would corrupt every other doc.
  108. for _, remarks := range []string{"tcpin-tcpin@e", "wsin-wsin@e"} {
  109. manual := findDocByRemarks(docs, remarks)
  110. if manual == nil {
  111. t.Fatalf("manual doc %q missing:\n%s", remarks, out)
  112. }
  113. if tags := docOutboundTags(manual); tags[0] != "proxy" {
  114. t.Fatalf("manual doc %q first tag = %q, want proxy", remarks, tags[0])
  115. }
  116. manualRouting, _ := manual["routing"].(map[string]any)
  117. manualRules, _ := json.Marshal(manualRouting["rules"])
  118. if !strings.Contains(string(manualRules), `"outboundTag":"proxy"`) {
  119. t.Fatalf("manual doc %q lost its proxy rule: %s", remarks, manualRules)
  120. }
  121. if _, has := manualRouting["balancers"]; has {
  122. t.Fatalf("manual doc %q must not carry balancers", remarks)
  123. }
  124. }
  125. }
  126. func stringify(values []any) []string {
  127. out := make([]string, 0, len(values))
  128. for _, v := range values {
  129. out = append(out, v.(string))
  130. }
  131. return out
  132. }
  133. // The balancer interleaves with inbounds by the same 1-based number and, on a
  134. // tie, follows the inbound group with that number.
  135. func TestSubJson_BalancerOrderInterleavesWithInbounds(t *testing.T) {
  136. seedSubDB(t)
  137. later := seedSubInbound(t, "s1", "later", 4711, 2, wsTLSStream)
  138. first := seedSubInbound(t, "s1", "first", 4712, 1, wsTLSStream)
  139. seedSubBalancer(t, &model.SubBalancer{
  140. Remark: "bal", Strategy: "roundRobin", InboundIds: []int{later.Id, first.Id}, SortOrder: 1, Enabled: true,
  141. })
  142. js := NewSubJsonService("", "", "", NewSubService(""))
  143. out, _, err := js.GetJson("s1", "req.example.com", true)
  144. if err != nil {
  145. t.Fatalf("GetJson: %v", err)
  146. }
  147. docs := parseSubJsonDocs(t, out)
  148. var remarks []string
  149. for _, doc := range docs {
  150. remarks = append(remarks, doc["remarks"].(string))
  151. }
  152. if strings.Join(remarks, ",") != "first-first@e,bal,later-later@e" {
  153. t.Fatalf("doc order = %v, want [first bal later]", remarks)
  154. }
  155. balancerDoc := findDocByRemarks(docs, "bal")
  156. routing, _ := balancerDoc["routing"].(map[string]any)
  157. balancers, _ := routing["balancers"].([]any)
  158. strategy, _ := balancers[0].(map[string]any)["strategy"].(map[string]any)
  159. if strategy["type"] != "roundRobin" {
  160. t.Fatalf("strategy = %v, want roundRobin", strategy["type"])
  161. }
  162. }
  163. // A disabled balancer is not emitted; an enabled one whose selected inbounds
  164. // have no configs for this subscriber is skipped rather than emitted empty.
  165. func TestSubJson_BalancerDisabledAndEmptySkipped(t *testing.T) {
  166. seedSubDB(t)
  167. inbound := seedSubInbound(t, "s1", "only", 4721, 1, wsTLSStream)
  168. seedSubBalancer(t, &model.SubBalancer{
  169. Remark: "off", Strategy: "random", InboundIds: []int{inbound.Id}, SortOrder: 1, Enabled: false,
  170. })
  171. seedSubBalancer(t, &model.SubBalancer{
  172. Remark: "nomembers", Strategy: "random", InboundIds: []int{inbound.Id + 100}, SortOrder: 1, Enabled: true,
  173. })
  174. js := NewSubJsonService("", "", "", NewSubService(""))
  175. out, _, err := js.GetJson("s1", "req.example.com", true)
  176. if err != nil {
  177. t.Fatalf("GetJson: %v", err)
  178. }
  179. docs := parseSubJsonDocs(t, out)
  180. if len(docs) != 1 {
  181. t.Fatalf("docs = %d, want 1:\n%s", len(docs), out)
  182. }
  183. if docs[0]["remarks"] != "only-only@e" {
  184. t.Fatalf("remaining doc = %v", docs[0]["remarks"])
  185. }
  186. }
  187. // Two members sharing a transport get deduplicated tags (…-2 suffix), matching
  188. // the reference makeTag convention.
  189. func TestSubJson_BalancerTagDedup(t *testing.T) {
  190. seedSubDB(t)
  191. a := seedSubInbound(t, "s1", "wsa", 4731, 1, wsTLSStream)
  192. b := seedSubInbound(t, "s1", "wsb", 4732, 2, wsTLSStream)
  193. seedSubBalancer(t, &model.SubBalancer{
  194. Remark: "dedup", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
  195. })
  196. js := NewSubJsonService("", "", "", NewSubService(""))
  197. out, _, err := js.GetJson("s1", "req.example.com", true)
  198. if err != nil {
  199. t.Fatalf("GetJson: %v", err)
  200. }
  201. docs := parseSubJsonDocs(t, out)
  202. balancerDoc := findDocByRemarks(docs, "dedup")
  203. if balancerDoc == nil {
  204. t.Fatalf("balancer doc missing:\n%s", out)
  205. }
  206. if tags := docOutboundTags(balancerDoc); strings.Join(tags, ",") != "bal-1-vless,bal-1-vless-2,direct,block" {
  207. t.Fatalf("balancer outbound tags = %v", tags)
  208. }
  209. }
  210. // random/roundRobin have no fallback so they emit no observatory; leastPing
  211. // carries one, with the panel-wide ping config overriding the defaults.
  212. func TestSubJson_BalancerObservatoryConditional(t *testing.T) {
  213. seedSubDB(t)
  214. rr := seedSubInbound(t, "s1", "rr", 4741, 1, wsTLSStream)
  215. lp := seedSubInbound(t, "s1", "lp", 4742, 2, wsTLSStream)
  216. seedSubBalancer(t, &model.SubBalancer{
  217. Remark: "rnd", Strategy: "random", InboundIds: []int{rr.Id}, SortOrder: 1, Enabled: true,
  218. })
  219. seedSubBalancer(t, &model.SubBalancer{
  220. Remark: "pinger", Strategy: "leastPing", InboundIds: []int{lp.Id}, SortOrder: 2, Enabled: true,
  221. })
  222. js := NewSubJsonService("", "", "", NewSubService(""))
  223. js.SetObservatoryConfig(`{"destination":"https://probe.example/204","httpMethod":"GET","sampling":5}`)
  224. out, _, err := js.GetJson("s1", "req.example.com", true)
  225. if err != nil {
  226. t.Fatalf("GetJson: %v", err)
  227. }
  228. docs := parseSubJsonDocs(t, out)
  229. rnd := findDocByRemarks(docs, "rnd")
  230. if _, has := rnd["burstObservatory"]; has {
  231. t.Fatalf("random balancer must not emit burstObservatory: %v", rnd["burstObservatory"])
  232. }
  233. pinger := findDocByRemarks(docs, "pinger")
  234. obs, _ := pinger["burstObservatory"].(map[string]any)
  235. if obs == nil {
  236. t.Fatalf("leastPing balancer must emit burstObservatory:\n%s", out)
  237. }
  238. ping, _ := obs["pingConfig"].(map[string]any)
  239. if ping["destination"] != "https://probe.example/204" {
  240. t.Fatalf("destination = %v, want custom probe URL", ping["destination"])
  241. }
  242. if ping["httpMethod"] != "GET" {
  243. t.Fatalf("httpMethod = %v, want GET", ping["httpMethod"])
  244. }
  245. if ping["sampling"] != float64(5) {
  246. t.Fatalf("sampling = %v, want 5", ping["sampling"])
  247. }
  248. if ping["interval"] != "1m" {
  249. t.Fatalf("interval = %v, want default 1m", ping["interval"])
  250. }
  251. }
  252. // A balancer selecting [A, B] with B disabled must carry only A: getInboundsBySubId
  253. // filters enable=true, so B never reaches entries. Guards the access scoping.
  254. func TestSubJson_BalancerExcludesDisabledInbound(t *testing.T) {
  255. seedSubDB(t)
  256. a := seedSubInbound(t, "s1", "keep", 4751, 1, wsTLSStream)
  257. b := seedSubInbound(t, "s1", "drop", 4752, 2, wsTLSStream)
  258. if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", b.Id).Update("enable", false).Error; err != nil {
  259. t.Fatalf("disable inbound B: %v", err)
  260. }
  261. seedSubBalancer(t, &model.SubBalancer{
  262. Remark: "bal", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
  263. })
  264. js := NewSubJsonService("", "", "", NewSubService(""))
  265. out, _, err := js.GetJson("s1", "req.example.com", true)
  266. if err != nil {
  267. t.Fatalf("GetJson: %v", err)
  268. }
  269. docs := parseSubJsonDocs(t, out)
  270. balancerDoc := findDocByRemarks(docs, "bal")
  271. if balancerDoc == nil {
  272. t.Fatalf("balancer doc missing (A is still enabled, balancer must emit):\n%s", out)
  273. }
  274. tags := docOutboundTags(balancerDoc)
  275. joined := strings.Join(tags, ",")
  276. if !strings.Contains(joined, "bal-1-vless") {
  277. t.Fatalf("enabled inbound A must be a balancer member: %v", tags)
  278. }
  279. // B's address must not surface anywhere in the balancer doc — not as an
  280. // outbound tag, not as a connection target a client could dial.
  281. balJSON, _ := json.Marshal(balancerDoc)
  282. if strings.Contains(string(balJSON), "203.0.113.5:4752") {
  283. t.Fatalf("disabled inbound B leaked into balancer doc: %s", balJSON)
  284. }
  285. }
  286. // A balancer whose only selected inbound is disabled for this subscriber is
  287. // skipped entirely — never emitted as an empty balancer with zero members.
  288. func TestSubJson_BalancerSkippedWhenAllMembersDisabled(t *testing.T) {
  289. seedSubDB(t)
  290. only := seedSubInbound(t, "s1", "onlydisabled", 4761, 1, wsTLSStream)
  291. if err := database.GetDB().Model(&model.Inbound{}).Where("id = ?", only.Id).Update("enable", false).Error; err != nil {
  292. t.Fatalf("disable only inbound: %v", err)
  293. }
  294. seedSubBalancer(t, &model.SubBalancer{
  295. Remark: "empty", Strategy: "random", InboundIds: []int{only.Id}, SortOrder: 1, Enabled: true,
  296. })
  297. js := NewSubJsonService("", "", "", NewSubService(""))
  298. out, _, err := js.GetJson("s1", "req.example.com", true)
  299. if err != nil {
  300. t.Fatalf("GetJson: %v", err)
  301. }
  302. if strings.TrimSpace(out) == "" {
  303. return
  304. }
  305. docs := parseSubJsonDocs(t, out)
  306. if findDocByRemarks(docs, "empty") != nil {
  307. t.Fatalf("balancer with no accessible members must not be emitted:\n%s", out)
  308. }
  309. }
  310. // Connectivity defaults to empty (skip the direct pre-check); an explicit empty
  311. // value stays empty instead of restoring the old generate_204 default.
  312. func TestSubJson_BalancerObservatoryConnectivityDefaultEmpty(t *testing.T) {
  313. seedSubDB(t)
  314. inb := seedSubInbound(t, "s1", "lp", 4781, 1, wsTLSStream)
  315. seedSubBalancer(t, &model.SubBalancer{
  316. Remark: "pinger", Strategy: "leastPing", InboundIds: []int{inb.Id}, SortOrder: 1, Enabled: true,
  317. })
  318. js := NewSubJsonService("", "", "", NewSubService(""))
  319. out, _, err := js.GetJson("s1", "req.example.com", true)
  320. if err != nil {
  321. t.Fatalf("GetJson: %v", err)
  322. }
  323. ping := observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
  324. if ping["connectivity"] != "" {
  325. t.Fatalf("default connectivity = %v, want empty (skip)", ping["connectivity"])
  326. }
  327. js.SetObservatoryConfig(`{"connectivity":""}`)
  328. out, _, err = js.GetJson("s1", "req.example.com", true)
  329. if err != nil {
  330. t.Fatalf("GetJson: %v", err)
  331. }
  332. ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
  333. if ping["connectivity"] != "" {
  334. t.Fatalf("explicit empty connectivity = %v, want empty", ping["connectivity"])
  335. }
  336. js.SetObservatoryConfig(`{"connectivity":"http://probe.example/204"}`)
  337. out, _, err = js.GetJson("s1", "req.example.com", true)
  338. if err != nil {
  339. t.Fatalf("GetJson: %v", err)
  340. }
  341. ping = observatoryPingConfig(t, parseSubJsonDocs(t, out), "pinger")
  342. if ping["connectivity"] != "http://probe.example/204" {
  343. t.Fatalf("custom connectivity = %v, want http://probe.example/204", ping["connectivity"])
  344. }
  345. }
  346. // leastPing/leastLoad always emit a burst observatory (Xray won't start them
  347. // without one); a stored {"enabled":false} is ignored as it is mandatory.
  348. func TestSubJson_BalancerObservatoryAlwaysEmittedForProbingStrategies(t *testing.T) {
  349. seedSubDB(t)
  350. a := seedSubInbound(t, "s1", "a", 4771, 1, wsTLSStream)
  351. b := seedSubInbound(t, "s1", "b", 4772, 2, wsTLSStream)
  352. seedSubBalancer(t, &model.SubBalancer{
  353. Remark: "pinger", Strategy: "leastPing", InboundIds: []int{a.Id, b.Id}, SortOrder: 1, Enabled: true,
  354. })
  355. js := NewSubJsonService("", "", "", NewSubService(""))
  356. js.SetObservatoryConfig(`{"enabled":false}`)
  357. out, _, err := js.GetJson("s1", "req.example.com", true)
  358. if err != nil {
  359. t.Fatalf("GetJson: %v", err)
  360. }
  361. pinger := findDocByRemarks(parseSubJsonDocs(t, out), "pinger")
  362. if pinger == nil {
  363. t.Fatalf("balancer doc missing:\n%s", out)
  364. }
  365. if _, has := pinger["burstObservatory"]; !has {
  366. t.Fatalf("leastPing must always emit burstObservatory (Xray requires it):\n%s", out)
  367. }
  368. routing, _ := pinger["routing"].(map[string]any)
  369. balancers, _ := routing["balancers"].([]any)
  370. balancer, _ := balancers[0].(map[string]any)
  371. if balancer["fallbackTag"] != "bal-1-vless" {
  372. t.Fatalf("fallbackTag = %v, want bal-1-vless (first member)", balancer["fallbackTag"])
  373. }
  374. }
  375. func observatoryPingConfig(t *testing.T, docs []map[string]any, remarks string) map[string]any {
  376. t.Helper()
  377. doc := findDocByRemarks(docs, remarks)
  378. if doc == nil {
  379. t.Fatalf("balancer doc %q missing", remarks)
  380. }
  381. obs, _ := doc["burstObservatory"].(map[string]any)
  382. if obs == nil {
  383. t.Fatalf("balancer %q has no burstObservatory", remarks)
  384. }
  385. ping, _ := obs["pingConfig"].(map[string]any)
  386. return ping
  387. }