golden_fixtures_xray_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351
  1. package service
  2. import (
  3. "crypto/ecdsa"
  4. "crypto/elliptic"
  5. "crypto/rand"
  6. "crypto/x509"
  7. "crypto/x509/pkix"
  8. "encoding/json"
  9. "encoding/pem"
  10. "math/big"
  11. "os"
  12. "path/filepath"
  13. "sort"
  14. "strings"
  15. "testing"
  16. "time"
  17. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  18. "github.com/xtls/xray-core/infra/conf"
  19. )
  20. // The frontend's golden fixtures are the panel's model of an xray config: the
  21. // Zod snapshots pin what the forms parse and emit. Parsing proves the panel
  22. // agrees with itself, not that xray-core would accept the result, so every
  23. // fixture is also built here through the very config builders the panel hands
  24. // its config to — conf.InboundDetourConfig for the full-config and AddInbound
  25. // paths, conf.RouterConfig for ApplyRoutingConfig, conf.DNSConfig for the dns
  26. // section. A fixture xray-core refuses is a config the panel would let an
  27. // admin save and then fail to start the core with, taking every inbound down.
  28. //
  29. // mtproto is excluded: it is served by the bundled mtg-multi sidecar, not by
  30. // xray, so xray-core has no config id for it.
  31. func goldenFixtureDir(t *testing.T, category string) string {
  32. t.Helper()
  33. dir, err := filepath.Abs(filepath.Join("..", "..", "..", "frontend", "src", "test", "golden", "fixtures", category))
  34. if err != nil {
  35. t.Fatalf("resolve fixture dir: %v", err)
  36. }
  37. return dir
  38. }
  39. // goldenFixtures returns every fixture in a category as name -> decoded object.
  40. func goldenFixtures(t *testing.T, category string) map[string]map[string]any {
  41. t.Helper()
  42. dir := goldenFixtureDir(t, category)
  43. entries, err := os.ReadDir(dir)
  44. if err != nil {
  45. t.Fatalf("read %s: %v", dir, err)
  46. }
  47. out := make(map[string]map[string]any)
  48. names := make([]string, 0, len(entries))
  49. for _, entry := range entries {
  50. if filepath.Ext(entry.Name()) != ".json" {
  51. continue
  52. }
  53. names = append(names, entry.Name())
  54. }
  55. sort.Strings(names)
  56. if len(names) == 0 {
  57. t.Fatalf("no fixtures under %s", dir)
  58. }
  59. for _, name := range names {
  60. raw, err := os.ReadFile(filepath.Join(dir, name))
  61. if err != nil {
  62. t.Fatalf("read %s: %v", name, err)
  63. }
  64. var obj map[string]any
  65. if err := json.Unmarshal(raw, &obj); err != nil {
  66. t.Fatalf("unmarshal %s: %v", name, err)
  67. }
  68. out[strings.TrimSuffix(name, ".json")] = obj
  69. }
  70. return out
  71. }
  72. // writeTestCertificate writes a self-signed certificate and key, returning both
  73. // paths. The TLS fixtures point certificateFile/keyFile at deployment paths
  74. // that do not exist here, and xray-core reads them while building.
  75. func writeTestCertificate(t *testing.T) (certPath, keyPath string) {
  76. t.Helper()
  77. key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
  78. if err != nil {
  79. t.Fatalf("generate key: %v", err)
  80. }
  81. template := x509.Certificate{
  82. SerialNumber: big.NewInt(1),
  83. Subject: pkix.Name{CommonName: "golden-fixture.test"},
  84. NotBefore: time.Now().Add(-time.Hour),
  85. NotAfter: time.Now().Add(24 * time.Hour),
  86. DNSNames: []string{"golden-fixture.test"},
  87. }
  88. der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
  89. if err != nil {
  90. t.Fatalf("create certificate: %v", err)
  91. }
  92. keyDER, err := x509.MarshalECPrivateKey(key)
  93. if err != nil {
  94. t.Fatalf("marshal key: %v", err)
  95. }
  96. dir := t.TempDir()
  97. certPath = filepath.Join(dir, "fixture.crt")
  98. keyPath = filepath.Join(dir, "fixture.key")
  99. certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
  100. keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
  101. if err := os.WriteFile(certPath, certPEM, 0o600); err != nil {
  102. t.Fatalf("write certificate: %v", err)
  103. }
  104. if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
  105. t.Fatalf("write key: %v", err)
  106. }
  107. return certPath, keyPath
  108. }
  109. // repointCertificateFiles rewrites every certificateFile/keyFile reference to
  110. // the generated pair, so a fixture is judged on its shape rather than on paths
  111. // that only exist on a deployed server.
  112. func repointCertificateFiles(node any, certPath, keyPath string) {
  113. switch value := node.(type) {
  114. case map[string]any:
  115. for key, child := range value {
  116. switch key {
  117. case "certificateFile":
  118. if _, ok := child.(string); ok {
  119. value[key] = certPath
  120. continue
  121. }
  122. case "keyFile":
  123. if _, ok := child.(string); ok {
  124. value[key] = keyPath
  125. continue
  126. }
  127. }
  128. repointCertificateFiles(child, certPath, keyPath)
  129. }
  130. case []any:
  131. for _, child := range value {
  132. repointCertificateFiles(child, certPath, keyPath)
  133. }
  134. }
  135. }
  136. // assertXrayAccepts fails unless xray-core built the fixture. Fixtures naming
  137. // geoip:/geosite: need the dat files the panel ships next to the xray binary;
  138. // where they are absent the loader fails on the missing file rather than on the
  139. // fixture, so those are skipped instead of reported as broken.
  140. func assertXrayAccepts(t *testing.T, subject string, err error) {
  141. t.Helper()
  142. if err == nil {
  143. return
  144. }
  145. if isMissingGeoAssetErr(err) {
  146. t.Skipf("geo data files not available, cannot judge %s: %v", subject, err)
  147. }
  148. t.Fatalf("xray-core refuses %s: %v", subject, err)
  149. }
  150. func buildGoldenInbound(t *testing.T, inbound map[string]any) error {
  151. t.Helper()
  152. certPath, keyPath := writeTestCertificate(t)
  153. repointCertificateFiles(inbound, certPath, keyPath)
  154. raw, err := json.Marshal(inbound)
  155. if err != nil {
  156. t.Fatalf("marshal inbound: %v", err)
  157. }
  158. detour := new(conf.InboundDetourConfig)
  159. if err := json.Unmarshal(raw, detour); err != nil {
  160. return err
  161. }
  162. _, err = detour.Build()
  163. return err
  164. }
  165. // TestGoldenInboundFixturesBuildInXray wraps each protocol fixture in a minimal
  166. // inbound and builds it.
  167. func TestGoldenInboundFixturesBuildInXray(t *testing.T) {
  168. for name, fixture := range goldenFixtures(t, "inbound") {
  169. if protocol, _ := fixture["protocol"].(string); protocol == string(model.MTProto) {
  170. continue
  171. }
  172. t.Run(name, func(t *testing.T) {
  173. inbound := map[string]any{
  174. "tag": "golden-in",
  175. "listen": "127.0.0.1",
  176. "port": 8443,
  177. "protocol": fixture["protocol"],
  178. "settings": fixture["settings"],
  179. }
  180. assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
  181. })
  182. }
  183. }
  184. // TestGoldenInboundFullFixturesBuildInXray runs the complete panel Inbound
  185. // model through GenXrayInboundConfig, the conversion both the full-config and
  186. // the live AddInbound paths use, and builds the result.
  187. func TestGoldenInboundFullFixturesBuildInXray(t *testing.T) {
  188. for name, fixture := range goldenFixtures(t, "inbound-full") {
  189. protocol, _ := fixture["protocol"].(string)
  190. if protocol == string(model.MTProto) {
  191. continue
  192. }
  193. t.Run(name, func(t *testing.T) {
  194. section := func(key string) string {
  195. value, ok := fixture[key]
  196. if !ok || value == nil {
  197. return ""
  198. }
  199. raw, err := json.Marshal(value)
  200. if err != nil {
  201. t.Fatalf("marshal %s: %v", key, err)
  202. }
  203. return string(raw)
  204. }
  205. port := 0
  206. if p, ok := fixture["port"].(float64); ok {
  207. port = int(p)
  208. }
  209. listen, _ := fixture["listen"].(string)
  210. tag, _ := fixture["tag"].(string)
  211. ib := &model.Inbound{
  212. Protocol: model.Protocol(protocol),
  213. Port: port,
  214. Listen: listen,
  215. Tag: tag,
  216. Settings: section("settings"),
  217. StreamSettings: section("streamSettings"),
  218. Sniffing: section("sniffing"),
  219. }
  220. raw, err := json.Marshal(ib.GenXrayInboundConfig())
  221. if err != nil {
  222. t.Fatalf("marshal generated inbound: %v", err)
  223. }
  224. var generated map[string]any
  225. if err := json.Unmarshal(raw, &generated); err != nil {
  226. t.Fatalf("decode generated inbound: %v", err)
  227. }
  228. assertXrayAccepts(t, "the generated inbound", buildGoldenInbound(t, generated))
  229. })
  230. }
  231. }
  232. // TestGoldenStreamFixturesBuildInXray attaches the transport fragments — whole
  233. // stream sections, the security block, sockopt and finalmask — to an inbound.
  234. func TestGoldenStreamFixturesBuildInXray(t *testing.T) {
  235. for _, category := range []string{"stream", "security", "sockopt", "finalmask"} {
  236. for name, fixture := range goldenFixtures(t, category) {
  237. t.Run(category+"/"+name, func(t *testing.T) {
  238. stream := map[string]any{"network": "tcp"}
  239. switch category {
  240. case "stream":
  241. stream = fixture
  242. case "security":
  243. for key, value := range fixture {
  244. stream[key] = value
  245. }
  246. case "sockopt":
  247. stream["sockopt"] = fixture
  248. case "finalmask":
  249. stream["finalmask"] = fixture
  250. }
  251. inbound := map[string]any{
  252. "tag": "golden-in", "listen": "127.0.0.1", "port": 8443, "protocol": "vless",
  253. "settings": map[string]any{
  254. "clients": []any{map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "golden"}},
  255. "decryption": "none",
  256. },
  257. "streamSettings": stream,
  258. }
  259. assertXrayAccepts(t, "this fixture", buildGoldenInbound(t, inbound))
  260. })
  261. }
  262. }
  263. }
  264. func buildGoldenRouting(routing map[string]any) error {
  265. raw, err := json.Marshal(routing)
  266. if err != nil {
  267. return err
  268. }
  269. router := new(conf.RouterConfig)
  270. if err := json.Unmarshal(raw, router); err != nil {
  271. return err
  272. }
  273. _, err = router.Build()
  274. return err
  275. }
  276. // TestGoldenRoutingFixturesBuildInXray builds the rule and balancer fixtures
  277. // through the router config ApplyRoutingConfig hands to the running core.
  278. func TestGoldenRoutingFixturesBuildInXray(t *testing.T) {
  279. for name, rule := range goldenFixtures(t, "rule") {
  280. t.Run("rule/"+name, func(t *testing.T) {
  281. assertXrayAccepts(t, "this rule", buildGoldenRouting(map[string]any{
  282. "domainStrategy": "AsIs",
  283. "rules": []any{rule},
  284. "balancers": []any{map[string]any{"tag": "balancer-load", "selector": []any{"proxy-"}}},
  285. }))
  286. })
  287. }
  288. for name, balancer := range goldenFixtures(t, "balancer") {
  289. t.Run("balancer/"+name, func(t *testing.T) {
  290. assertXrayAccepts(t, "this balancer", buildGoldenRouting(map[string]any{
  291. "domainStrategy": "AsIs",
  292. "balancers": []any{balancer},
  293. "rules": []any{map[string]any{
  294. "type": "field", "port": "443", "balancerTag": balancer["tag"],
  295. }},
  296. }))
  297. })
  298. }
  299. }
  300. // TestGoldenDNSFixturesBuildInXray builds the dns section, and each dns-server
  301. // fixture inside one.
  302. func TestGoldenDNSFixturesBuildInXray(t *testing.T) {
  303. build := func(dns map[string]any) error {
  304. raw, err := json.Marshal(dns)
  305. if err != nil {
  306. return err
  307. }
  308. dnsConf := new(conf.DNSConfig)
  309. if err := json.Unmarshal(raw, dnsConf); err != nil {
  310. return err
  311. }
  312. _, err = dnsConf.Build()
  313. return err
  314. }
  315. for name, fixture := range goldenFixtures(t, "dns") {
  316. t.Run("dns/"+name, func(t *testing.T) {
  317. assertXrayAccepts(t, "this dns section", build(fixture))
  318. })
  319. }
  320. for name, server := range goldenFixtures(t, "dns-server") {
  321. t.Run("dns-server/"+name, func(t *testing.T) {
  322. assertXrayAccepts(t, "this dns server", build(map[string]any{"servers": []any{server}}))
  323. })
  324. }
  325. }
  326. func isMissingGeoAssetErr(err error) bool {
  327. msg := err.Error()
  328. return strings.Contains(msg, "geoip.dat") || strings.Contains(msg, "geosite.dat")
  329. }