host_sub_test.go 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  1. package sub
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net/url"
  6. "path/filepath"
  7. "strings"
  8. "testing"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. )
  13. func seedSubDB(t *testing.T) {
  14. t.Helper()
  15. dbDir := t.TempDir()
  16. t.Setenv("XUI_DB_FOLDER", dbDir)
  17. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  18. }
  19. // seedSubInbound creates a VLESS inbound with one client wired into the
  20. // normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
  21. func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
  22. t.Helper()
  23. db := database.GetDB()
  24. uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
  25. email := tag + "@e"
  26. settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
  27. ib := &model.Inbound{
  28. UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
  29. Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
  30. SubSortIndex: subSortIndex,
  31. }
  32. if err := db.Create(ib).Error; err != nil {
  33. t.Fatalf("seed inbound %s: %v", tag, err)
  34. }
  35. client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
  36. if err := db.Create(client).Error; err != nil {
  37. t.Fatalf("seed client %s: %v", email, err)
  38. }
  39. if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
  40. t.Fatalf("seed client_inbound %s: %v", email, err)
  41. }
  42. return ib
  43. }
  44. func seedHost(t *testing.T, h *model.Host) *model.Host {
  45. t.Helper()
  46. if err := database.GetDB().Create(h).Error; err != nil {
  47. t.Fatalf("seed host: %v", err)
  48. }
  49. return h
  50. }
  51. const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
  52. // #1 — an inbound with no hosts renders identically to the legacy path: a single
  53. // link from the inbound's own address. Mutation-checks the zero-hosts fallback.
  54. func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
  55. seedSubDB(t)
  56. seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
  57. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  58. if err != nil {
  59. t.Fatalf("GetSubs: %v", err)
  60. }
  61. if len(links) != 1 {
  62. t.Fatalf("links = %d, want 1", len(links))
  63. }
  64. if !strings.Contains(links[0], "203.0.113.5:4431") {
  65. t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
  66. }
  67. if strings.Contains(links[0], "\n") {
  68. t.Fatalf("zero-hosts must be a single link: %s", links[0])
  69. }
  70. }
  71. // #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
  72. // own address/port/sni and host-header/path override.
  73. func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
  74. seedSubDB(t)
  75. ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
  76. 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"})
  77. 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"})
  78. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  79. if err != nil {
  80. t.Fatalf("GetSubs: %v", err)
  81. }
  82. parts := strings.Split(strings.Join(links, "\n"), "\n")
  83. if len(parts) != 2 {
  84. t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
  85. }
  86. if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
  87. !strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
  88. t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
  89. }
  90. if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
  91. !strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
  92. t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
  93. }
  94. }
  95. // #3 — a disabled host is omitted; the inbound falls back to its legacy link.
  96. func TestSub_DisabledHostSkipped(t *testing.T) {
  97. seedSubDB(t)
  98. ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
  99. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
  100. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  101. if err != nil {
  102. t.Fatalf("GetSubs: %v", err)
  103. }
  104. joined := strings.Join(links, "\n")
  105. if strings.Contains(joined, "off.cdn.com") {
  106. t.Fatalf("disabled host must not render: %s", joined)
  107. }
  108. if !strings.Contains(joined, "203.0.113.5:4433") {
  109. t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
  110. }
  111. }
  112. // #4 — when both hosts and a legacy externalProxy are set, hosts win and the
  113. // externalProxy entry is ignored.
  114. func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
  115. seedSubDB(t)
  116. 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"}]}`
  117. ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
  118. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
  119. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  120. if err != nil {
  121. t.Fatalf("GetSubs: %v", err)
  122. }
  123. joined := strings.Join(links, "\n")
  124. if !strings.Contains(joined, "host.cdn.com:8443") {
  125. t.Fatalf("host should win: %s", joined)
  126. }
  127. if strings.Contains(joined, "legacy.cdn.com") {
  128. t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
  129. }
  130. }
  131. // #5 — hosts that share a remark but differ in address/port are NOT deduped:
  132. // distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
  133. func TestSub_NHosts_NoDedup(t *testing.T) {
  134. seedSubDB(t)
  135. ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
  136. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
  137. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
  138. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  139. if err != nil {
  140. t.Fatalf("GetSubs: %v", err)
  141. }
  142. joined := strings.Join(links, "\n")
  143. parts := strings.Split(joined, "\n")
  144. if len(parts) != 2 {
  145. t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
  146. }
  147. if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
  148. t.Fatalf("both distinct host addresses must appear: %s", joined)
  149. }
  150. }
  151. // #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
  152. // SubSortIndex, hosts within an inbound by sort_order.
  153. func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
  154. seedSubDB(t)
  155. // inbound "second" has a higher SubSortIndex so it must come after "first".
  156. ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
  157. ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
  158. seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
  159. seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
  160. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  161. if err != nil {
  162. t.Fatalf("GetSubs: %v", err)
  163. }
  164. joined := strings.Join(links, "\n")
  165. firstAt := strings.Index(joined, "first-host.com")
  166. secondAt := strings.Index(joined, "second-host.com")
  167. if firstAt < 0 || secondAt < 0 {
  168. t.Fatalf("both inbound hosts should render: %s", joined)
  169. }
  170. if firstAt > secondAt {
  171. t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
  172. }
  173. }
  174. // #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
  175. // address/sni win over the projected master stream.
  176. func TestSub_HostOverFallback(t *testing.T) {
  177. seedSubDB(t)
  178. db := database.GetDB()
  179. master := &model.Inbound{
  180. UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
  181. Protocol: model.VLESS, Remark: "master",
  182. Settings: `{"clients":[],"decryption":"none"}`,
  183. StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
  184. }
  185. if err := db.Create(master).Error; err != nil {
  186. t.Fatalf("seed master: %v", err)
  187. }
  188. // child listens internal-only so projection triggers.
  189. child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
  190. child.Listen = "127.0.0.1"
  191. if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
  192. t.Fatalf("set child listen: %v", err)
  193. }
  194. if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
  195. t.Fatalf("seed fallback: %v", err)
  196. }
  197. seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
  198. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  199. if err != nil {
  200. t.Fatalf("GetSubs: %v", err)
  201. }
  202. joined := strings.Join(links, "\n")
  203. if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
  204. t.Fatalf("host override must win over fallback master: %s", joined)
  205. }
  206. if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
  207. t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
  208. }
  209. }
  210. // #8 — a client only gets hosts for inbounds it is actually on (the
  211. // clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
  212. func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
  213. seedSubDB(t)
  214. seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream) // client on s1
  215. other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
  216. seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
  217. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  218. if err != nil {
  219. t.Fatalf("GetSubs: %v", err)
  220. }
  221. joined := strings.Join(links, "\n")
  222. if strings.Contains(joined, "other-host.com") {
  223. t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
  224. }
  225. }
  226. // allowInsecure renders as allowInsecure=1 in the raw link and
  227. // skip-cert-verify: true in the Clash proxy.
  228. func TestSub_HostAllowInsecure(t *testing.T) {
  229. seedSubDB(t)
  230. ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
  231. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
  232. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  233. if err != nil {
  234. t.Fatalf("GetSubs: %v", err)
  235. }
  236. if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
  237. t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
  238. }
  239. clash := NewSubClashService(false, "", NewSubService(""))
  240. yaml, _, err := clash.GetClash("s1", "req.example.com")
  241. if err != nil {
  242. t.Fatalf("GetClash: %v", err)
  243. }
  244. if !strings.Contains(yaml, "skip-cert-verify: true") {
  245. t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
  246. }
  247. }
  248. // A host's Host header and path reach the Clash and JSON renderers even when
  249. // the inbound's own ws settings leave them empty (#5944).
  250. func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
  251. seedSubDB(t)
  252. ib := seedSubInbound(t, "s1", "hh", 4457, 1,
  253. `{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
  254. seedHost(t, &model.Host{
  255. InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
  256. HostHeader: "cdn.example.com", Path: "/ws-path",
  257. })
  258. clash := NewSubClashService(false, "", NewSubService(""))
  259. yaml, _, err := clash.GetClash("s1", "req.example.com")
  260. if err != nil {
  261. t.Fatalf("GetClash: %v", err)
  262. }
  263. if !strings.Contains(yaml, "Host: cdn.example.com") {
  264. t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
  265. }
  266. if !strings.Contains(yaml, "path: /ws-path") {
  267. t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
  268. }
  269. js := NewSubJsonService("", "", "", "", NewSubService(""))
  270. out, _, err := js.GetJson("s1", "req.example.com", false)
  271. if err != nil {
  272. t.Fatalf("GetJson: %v", err)
  273. }
  274. if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
  275. t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
  276. }
  277. }
  278. // A host's Final Mask reaches the raw share link as the fm param, merged with
  279. // any inbound-level mask (#5831).
  280. func TestSub_HostFinalMask_RawLink(t *testing.T) {
  281. seedSubDB(t)
  282. ib := seedSubInbound(t, "s1", "fmh", 4455, 1,
  283. `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`)
  284. finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}`
  285. seedHost(t, &model.Host{
  286. InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls",
  287. FinalMask: finalMask,
  288. })
  289. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  290. if err != nil {
  291. t.Fatalf("GetSubs: %v", err)
  292. }
  293. if len(links) == 0 {
  294. t.Fatal("GetSubs returned no links")
  295. }
  296. link, err := url.Parse(strings.Split(links[0], "\n")[0])
  297. if err != nil {
  298. t.Fatalf("parse raw link: %v", err)
  299. }
  300. var finalmask map[string]any
  301. if err := json.Unmarshal([]byte(link.Query().Get("fm")), &finalmask); err != nil {
  302. t.Fatalf("unmarshal fm query param: %v", err)
  303. }
  304. tcp, _ := finalmask["tcp"].([]any)
  305. if len(tcp) != 2 {
  306. t.Fatalf("tcp mask count = %d, want existing + host mask: %#v", len(tcp), finalmask)
  307. }
  308. fragment, _ := tcp[1].(map[string]any)
  309. settings, _ := fragment["settings"].(map[string]any)
  310. if settings["length"] != "25-30" || settings["delay"] != "10-30" {
  311. t.Fatalf("legacy ranges = (%v, %v), want last per-segment values", settings["length"], settings["delay"])
  312. }
  313. if len(settings["lengths"].([]any)) != 5 || len(settings["delays"].([]any)) != 5 {
  314. t.Fatalf("per-segment ranges changed: %#v", settings)
  315. }
  316. }
  317. func TestSub_HostFinalMaskJSONAddsLegacyFragmentRanges(t *testing.T) {
  318. seedSubDB(t)
  319. baseStream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku","settings":{"password":"p"}}]}}`
  320. ib := seedSubInbound(t, "s1", "fmj", 4456, 1, baseStream)
  321. finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}`
  322. host := seedHost(t, &model.Host{
  323. InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm-json.cdn.com", Port: 8444, Security: "tls",
  324. FinalMask: finalMask,
  325. })
  326. globalFinalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["31-40"],"delays":[]}}]}`
  327. out, _, err := NewSubJsonService("", "", globalFinalMask, "", NewSubService("")).GetJson("s1", "req.example.com", false)
  328. if err != nil {
  329. t.Fatalf("GetJson: %v", err)
  330. }
  331. var config map[string]any
  332. if err := json.Unmarshal([]byte(out), &config); err != nil {
  333. t.Fatalf("unmarshal JSON subscription: %v", err)
  334. }
  335. outbounds, _ := config["outbounds"].([]any)
  336. if len(outbounds) == 0 {
  337. t.Fatalf("JSON subscription has no outbounds: %s", out)
  338. }
  339. outbound, _ := outbounds[0].(map[string]any)
  340. stream, _ := outbound["streamSettings"].(map[string]any)
  341. finalmask, _ := stream["finalmask"].(map[string]any)
  342. tcp, _ := finalmask["tcp"].([]any)
  343. if len(tcp) != 3 {
  344. t.Fatalf("tcp mask count = %d, want base + global + host masks: %#v", len(tcp), finalmask)
  345. }
  346. globalFragment, _ := tcp[1].(map[string]any)
  347. globalSettings, _ := globalFragment["settings"].(map[string]any)
  348. if globalSettings["length"] != "31-40" {
  349. t.Fatalf("global legacy length = %v, want 31-40", globalSettings["length"])
  350. }
  351. if _, exists := globalSettings["delay"]; exists {
  352. t.Fatalf("empty global delays must not emit a fallback: %#v", globalSettings)
  353. }
  354. fragment, _ := tcp[2].(map[string]any)
  355. settings, _ := fragment["settings"].(map[string]any)
  356. if settings["length"] != "25-30" || settings["delay"] != "10-30" {
  357. t.Fatalf("legacy ranges = (%v, %v), want the final per-segment ranges", settings["length"], settings["delay"])
  358. }
  359. if got := settings["lengths"].([]any); len(got) != 5 || got[4] != "25-30" {
  360. t.Fatalf("per-segment lengths changed: %#v", settings["lengths"])
  361. }
  362. if got := settings["delays"].([]any); len(got) != 5 || got[4] != "10-30" {
  363. t.Fatalf("per-segment delays changed: %#v", settings["delays"])
  364. }
  365. if got := settings["maxSplit"]; got != "10-15" {
  366. t.Fatalf("maxSplit = %v, want 10-15", got)
  367. }
  368. var storedHost model.Host
  369. if err := database.GetDB().First(&storedHost, host.Id).Error; err != nil {
  370. t.Fatalf("reload host: %v", err)
  371. }
  372. if storedHost.FinalMask != finalMask {
  373. t.Fatalf("stored host FinalMask changed: %s", storedHost.FinalMask)
  374. }
  375. var storedInbound model.Inbound
  376. if err := database.GetDB().First(&storedInbound, ib.Id).Error; err != nil {
  377. t.Fatalf("reload inbound: %v", err)
  378. }
  379. if storedInbound.StreamSettings != baseStream {
  380. t.Fatalf("stored inbound StreamSettings changed: %s", storedInbound.StreamSettings)
  381. }
  382. }
  383. // A host's sockoptParams is injected into the JSON output stream (sockopt is
  384. // stripped from the base stream, re-added per host).
  385. func TestSub_HostSockoptJSON(t *testing.T) {
  386. seedSubDB(t)
  387. ib := seedSubInbound(t, "s1", "so", 4460, 1,
  388. `{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
  389. seedHost(t, &model.Host{
  390. InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
  391. SockoptParams: `{"tcpFastOpen":true}`,
  392. })
  393. js := NewSubJsonService("", "", "", "", NewSubService(""))
  394. out, _, err := js.GetJson("s1", "req.example.com", false)
  395. if err != nil {
  396. t.Fatalf("GetJson: %v", err)
  397. }
  398. if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
  399. t.Fatalf("json should include the host sockopt:\n%s", out)
  400. }
  401. }
  402. // A host's muxParams override the JSON outbound's mux.
  403. func TestSub_HostMuxJSON(t *testing.T) {
  404. seedSubDB(t)
  405. ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
  406. seedHost(t, &model.Host{
  407. InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
  408. MuxParams: `{"enabled":true,"concurrency":8}`,
  409. })
  410. js := NewSubJsonService("", "", "", "", NewSubService(""))
  411. out, _, err := js.GetJson("s1", "req.example.com", false)
  412. if err != nil {
  413. t.Fatalf("GetJson: %v", err)
  414. }
  415. if !strings.Contains(out, "concurrency") {
  416. t.Fatalf("json should include the host mux override:\n%s", out)
  417. }
  418. }
  419. // A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
  420. // inbound (reality keys can't be host-supplied).
  421. func TestSub_HostRealitySniOverride(t *testing.T) {
  422. seedSubDB(t)
  423. realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
  424. ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
  425. seedHost(t, &model.Host{
  426. InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
  427. Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
  428. })
  429. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  430. if err != nil {
  431. t.Fatalf("GetSubs: %v", err)
  432. }
  433. joined := strings.Join(links, "\n")
  434. if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
  435. t.Fatalf("reality host base wrong: %s", joined)
  436. }
  437. if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
  438. t.Fatalf("reality host sni/fp override not applied: %s", joined)
  439. }
  440. if strings.Contains(joined, "sni=base.reality.com") {
  441. t.Fatalf("base reality sni must be overridden: %s", joined)
  442. }
  443. if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
  444. t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
  445. }
  446. }
  447. // #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
  448. // absent from GetClash but present in the raw GetSubs output.
  449. func TestSub_ExcludeFromSubTypes(t *testing.T) {
  450. seedSubDB(t)
  451. ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
  452. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
  453. sub := NewSubService("")
  454. links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
  455. if err != nil {
  456. t.Fatalf("GetSubs: %v", err)
  457. }
  458. if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
  459. t.Fatalf("host not excluded from raw should appear in GetSubs")
  460. }
  461. clash := NewSubClashService(false, "", NewSubService(""))
  462. yaml, _, err := clash.GetClash("s1", "req.example.com")
  463. if err != nil {
  464. t.Fatalf("GetClash: %v", err)
  465. }
  466. if strings.Contains(yaml, "clashless.cdn.com") {
  467. t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
  468. }
  469. }
  470. // A host that forces plain TLS over a Reality inbound must not leave the
  471. // Reality identity behind: pbk/sid/spx and the Reality dest sni describe a
  472. // handshake the endpoint no longer performs.
  473. func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) {
  474. seedSubDB(t)
  475. reality := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["master-dest.example.com"],"publicKey":"MASTERPBK","shortIds":["ab12"],"fingerprint":"chrome"}}`
  476. ib := seedSubInbound(t, "s1", "reality-in", 4461, 1, reality)
  477. seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "edge.example.com", Port: 443, Security: "tls"})
  478. links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
  479. if err != nil {
  480. t.Fatalf("GetSubs: %v", err)
  481. }
  482. joined := strings.Join(links, "\n")
  483. if !strings.Contains(joined, "security=tls") {
  484. t.Fatalf("host forces tls, link must say so: %s", joined)
  485. }
  486. for _, leaked := range []string{
  487. "pbk=",
  488. "sid=",
  489. "spx=",
  490. "support-x25519mlkem768=",
  491. "sni=master-dest.example.com",
  492. } {
  493. if strings.Contains(joined, leaked) {
  494. t.Fatalf("reality parameter %q survived a tls host override: %s", leaked, joined)
  495. }
  496. }
  497. }
  498. // A host's cipher suites override the inbound's own in the JSON subscription,
  499. // while a host that leaves the field blank inherits them.
  500. func TestSub_HostCipherSuitesJSON(t *testing.T) {
  501. seedSubDB(t)
  502. ib := seedSubInbound(t, "s1", "cs", 4462, 1,
  503. `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni","cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"}}`)
  504. seedHost(t, &model.Host{
  505. InboundId: ib.Id, SortOrder: 0, Remark: "CS", Address: "cs.cdn.com", Port: 8443, Security: "tls",
  506. CipherSuites: "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256",
  507. })
  508. seedHost(t, &model.Host{
  509. InboundId: ib.Id, SortOrder: 1, Remark: "INHERIT", Address: "inh.cdn.com", Port: 8443, Security: "tls",
  510. })
  511. out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
  512. if err != nil {
  513. t.Fatalf("GetJson: %v", err)
  514. }
  515. if !strings.Contains(out, `"cipherSuites": "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) &&
  516. !strings.Contains(out, `"cipherSuites":"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) {
  517. t.Fatalf("json tlsSettings should carry the host's cipher suites:\n%s", out)
  518. }
  519. if !strings.Contains(out, `"cipherSuites": "TLS_CHACHA20_POLY1305_SHA256"`) &&
  520. !strings.Contains(out, `"cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"`) {
  521. t.Fatalf("a host with no cipher suites should inherit the inbound's:\n%s", out)
  522. }
  523. }