host_sub_test.go 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387
  1. package sub
  2. import (
  3. "fmt"
  4. "net/url"
  5. "path/filepath"
  6. "strings"
  7. "testing"
  8. "github.com/mhsanaei/3x-ui/v3/internal/database"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  10. )
  11. func seedSubDB(t *testing.T) {
  12. t.Helper()
  13. dbDir := t.TempDir()
  14. t.Setenv("XUI_DB_FOLDER", dbDir)
  15. if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
  16. t.Fatalf("InitDB: %v", err)
  17. }
  18. t.Cleanup(func() { _ = database.CloseDB() })
  19. }
  20. // seedSubInbound creates a VLESS inbound with one client wired into the
  21. // normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
  22. func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
  23. t.Helper()
  24. db := database.GetDB()
  25. uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
  26. email := tag + "@e"
  27. settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
  28. ib := &model.Inbound{
  29. UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
  30. Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
  31. SubSortIndex: subSortIndex,
  32. }
  33. if err := db.Create(ib).Error; err != nil {
  34. t.Fatalf("seed inbound %s: %v", tag, err)
  35. }
  36. client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
  37. if err := db.Create(client).Error; err != nil {
  38. t.Fatalf("seed client %s: %v", email, err)
  39. }
  40. if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
  41. t.Fatalf("seed client_inbound %s: %v", email, err)
  42. }
  43. return ib
  44. }
  45. func seedHost(t *testing.T, h *model.Host) *model.Host {
  46. t.Helper()
  47. if err := database.GetDB().Create(h).Error; err != nil {
  48. t.Fatalf("seed host: %v", err)
  49. }
  50. return h
  51. }
  52. const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
  53. // #1 — an inbound with no hosts renders identically to the legacy path: a single
  54. // link from the inbound's own address. Mutation-checks the zero-hosts fallback.
  55. func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
  56. seedSubDB(t)
  57. seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
  58. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  59. if err != nil {
  60. t.Fatalf("GetSubs: %v", err)
  61. }
  62. if len(links) != 1 {
  63. t.Fatalf("links = %d, want 1", len(links))
  64. }
  65. if !strings.Contains(links[0], "203.0.113.5:4431") {
  66. t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
  67. }
  68. if strings.Contains(links[0], "\n") {
  69. t.Fatalf("zero-hosts must be a single link: %s", links[0])
  70. }
  71. }
  72. // #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
  73. // own address/port/sni and host-header/path override.
  74. func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
  75. seedSubDB(t)
  76. ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
  77. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", Sni: "b.sni", HostHeader: "b.host", Path: "/b"})
  78. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls", Sni: "a.sni", HostHeader: "a.host", Path: "/a"})
  79. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  80. if err != nil {
  81. t.Fatalf("GetSubs: %v", err)
  82. }
  83. parts := strings.Split(strings.Join(links, "\n"), "\n")
  84. if len(parts) != 2 {
  85. t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
  86. }
  87. if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
  88. !strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
  89. t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
  90. }
  91. if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
  92. !strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
  93. t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
  94. }
  95. }
  96. // #3 — a disabled host is omitted; the inbound falls back to its legacy link.
  97. func TestSub_DisabledHostSkipped(t *testing.T) {
  98. seedSubDB(t)
  99. ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
  100. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
  101. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  102. if err != nil {
  103. t.Fatalf("GetSubs: %v", err)
  104. }
  105. joined := strings.Join(links, "\n")
  106. if strings.Contains(joined, "off.cdn.com") {
  107. t.Fatalf("disabled host must not render: %s", joined)
  108. }
  109. if !strings.Contains(joined, "203.0.113.5:4433") {
  110. t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
  111. }
  112. }
  113. // #4 — when both hosts and a legacy externalProxy are set, hosts win and the
  114. // externalProxy entry is ignored.
  115. func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
  116. seedSubDB(t)
  117. stream := `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.cdn.com","port":7443,"remark":"L"}]}`
  118. ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
  119. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
  120. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  121. if err != nil {
  122. t.Fatalf("GetSubs: %v", err)
  123. }
  124. joined := strings.Join(links, "\n")
  125. if !strings.Contains(joined, "host.cdn.com:8443") {
  126. t.Fatalf("host should win: %s", joined)
  127. }
  128. if strings.Contains(joined, "legacy.cdn.com") {
  129. t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
  130. }
  131. }
  132. // #5 — hosts that share a remark but differ in address/port are NOT deduped:
  133. // distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
  134. func TestSub_NHosts_NoDedup(t *testing.T) {
  135. seedSubDB(t)
  136. ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
  137. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
  138. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
  139. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  140. if err != nil {
  141. t.Fatalf("GetSubs: %v", err)
  142. }
  143. joined := strings.Join(links, "\n")
  144. parts := strings.Split(joined, "\n")
  145. if len(parts) != 2 {
  146. t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
  147. }
  148. if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
  149. t.Fatalf("both distinct host addresses must appear: %s", joined)
  150. }
  151. }
  152. // #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
  153. // SubSortIndex, hosts within an inbound by sort_order.
  154. func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
  155. seedSubDB(t)
  156. // inbound "second" has a higher SubSortIndex so it must come after "first".
  157. ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
  158. ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
  159. seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
  160. seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
  161. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  162. if err != nil {
  163. t.Fatalf("GetSubs: %v", err)
  164. }
  165. joined := strings.Join(links, "\n")
  166. firstAt := strings.Index(joined, "first-host.com")
  167. secondAt := strings.Index(joined, "second-host.com")
  168. if firstAt < 0 || secondAt < 0 {
  169. t.Fatalf("both inbound hosts should render: %s", joined)
  170. }
  171. if firstAt > secondAt {
  172. t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
  173. }
  174. }
  175. // #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
  176. // address/sni win over the projected master stream.
  177. func TestSub_HostOverFallback(t *testing.T) {
  178. seedSubDB(t)
  179. db := database.GetDB()
  180. master := &model.Inbound{
  181. UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
  182. Protocol: model.VLESS, Remark: "master",
  183. Settings: `{"clients":[],"decryption":"none"}`,
  184. StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
  185. }
  186. if err := db.Create(master).Error; err != nil {
  187. t.Fatalf("seed master: %v", err)
  188. }
  189. // child listens internal-only so projection triggers.
  190. child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
  191. child.Listen = "127.0.0.1"
  192. if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
  193. t.Fatalf("set child listen: %v", err)
  194. }
  195. if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
  196. t.Fatalf("seed fallback: %v", err)
  197. }
  198. seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
  199. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  200. if err != nil {
  201. t.Fatalf("GetSubs: %v", err)
  202. }
  203. joined := strings.Join(links, "\n")
  204. if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
  205. t.Fatalf("host override must win over fallback master: %s", joined)
  206. }
  207. if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
  208. t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
  209. }
  210. }
  211. // #8 — a client only gets hosts for inbounds it is actually on (the
  212. // clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
  213. func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
  214. seedSubDB(t)
  215. seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream) // client on s1
  216. other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
  217. seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
  218. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  219. if err != nil {
  220. t.Fatalf("GetSubs: %v", err)
  221. }
  222. joined := strings.Join(links, "\n")
  223. if strings.Contains(joined, "other-host.com") {
  224. t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
  225. }
  226. }
  227. // allowInsecure renders as allowInsecure=1 in the raw link and
  228. // skip-cert-verify: true in the Clash proxy.
  229. func TestSub_HostAllowInsecure(t *testing.T) {
  230. seedSubDB(t)
  231. ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
  232. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
  233. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  234. if err != nil {
  235. t.Fatalf("GetSubs: %v", err)
  236. }
  237. if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
  238. t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
  239. }
  240. clash := NewSubClashService(false, "", NewSubService(""))
  241. yaml, _, err := clash.GetClash("s1", "req.example.com")
  242. if err != nil {
  243. t.Fatalf("GetClash: %v", err)
  244. }
  245. if !strings.Contains(yaml, "skip-cert-verify: true") {
  246. t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
  247. }
  248. }
  249. // A host's Final Mask reaches the raw share link as the fm param, merged with
  250. // any inbound-level mask (#5831).
  251. func TestSub_HostFinalMask_RawLink(t *testing.T) {
  252. seedSubDB(t)
  253. ib := seedSubInbound(t, "s1", "fmh", 4455, 1,
  254. `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`)
  255. seedHost(t, &model.Host{
  256. InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls",
  257. FinalMask: `{"tcp":[{"type":"fragment"}]}`,
  258. })
  259. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  260. if err != nil {
  261. t.Fatalf("GetSubs: %v", err)
  262. }
  263. joined := strings.Join(links, "\n")
  264. wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
  265. if !strings.Contains(joined, wantFm) {
  266. t.Fatalf("raw link should merge the host Final Mask into fm.\n got: %s\nwant substring: %s", joined, wantFm)
  267. }
  268. }
  269. // A host's sockoptParams is injected into the JSON output stream (sockopt is
  270. // stripped from the base stream, re-added per host).
  271. func TestSub_HostSockoptJSON(t *testing.T) {
  272. seedSubDB(t)
  273. ib := seedSubInbound(t, "s1", "so", 4460, 1,
  274. `{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
  275. seedHost(t, &model.Host{
  276. InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
  277. SockoptParams: `{"tcpFastOpen":true}`,
  278. })
  279. js := NewSubJsonService("", "", "", NewSubService(""))
  280. out, _, err := js.GetJson("s1", "req.example.com")
  281. if err != nil {
  282. t.Fatalf("GetJson: %v", err)
  283. }
  284. if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
  285. t.Fatalf("json should include the host sockopt:\n%s", out)
  286. }
  287. }
  288. // A host's muxParams override the JSON outbound's mux.
  289. func TestSub_HostMuxJSON(t *testing.T) {
  290. seedSubDB(t)
  291. ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
  292. seedHost(t, &model.Host{
  293. InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
  294. MuxParams: `{"enabled":true,"concurrency":8}`,
  295. })
  296. js := NewSubJsonService("", "", "", NewSubService(""))
  297. out, _, err := js.GetJson("s1", "req.example.com")
  298. if err != nil {
  299. t.Fatalf("GetJson: %v", err)
  300. }
  301. if !strings.Contains(out, "concurrency") {
  302. t.Fatalf("json should include the host mux override:\n%s", out)
  303. }
  304. }
  305. // A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
  306. // inbound (reality keys can't be host-supplied).
  307. func TestSub_HostRealitySniOverride(t *testing.T) {
  308. seedSubDB(t)
  309. realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
  310. ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
  311. seedHost(t, &model.Host{
  312. InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
  313. Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
  314. })
  315. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  316. if err != nil {
  317. t.Fatalf("GetSubs: %v", err)
  318. }
  319. joined := strings.Join(links, "\n")
  320. if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
  321. t.Fatalf("reality host base wrong: %s", joined)
  322. }
  323. if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
  324. t.Fatalf("reality host sni/fp override not applied: %s", joined)
  325. }
  326. if strings.Contains(joined, "sni=base.reality.com") {
  327. t.Fatalf("base reality sni must be overridden: %s", joined)
  328. }
  329. if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
  330. t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
  331. }
  332. }
  333. // #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
  334. // absent from GetClash but present in the raw GetSubs output.
  335. func TestSub_ExcludeFromSubTypes(t *testing.T) {
  336. seedSubDB(t)
  337. ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
  338. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
  339. sub := NewSubService("")
  340. links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
  341. if err != nil {
  342. t.Fatalf("GetSubs: %v", err)
  343. }
  344. if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
  345. t.Fatalf("host not excluded from raw should appear in GetSubs")
  346. }
  347. clash := NewSubClashService(false, "", NewSubService(""))
  348. yaml, _, err := clash.GetClash("s1", "req.example.com")
  349. if err != nil {
  350. t.Fatalf("GetClash: %v", err)
  351. }
  352. if strings.Contains(yaml, "clashless.cdn.com") {
  353. t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
  354. }
  355. }