1
0

api_e2e_test.go 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. package xray
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "net"
  6. "os"
  7. "os/exec"
  8. "path/filepath"
  9. "testing"
  10. "time"
  11. )
  12. // TestXrayAPI_E2E exercises the gRPC hot-apply surface (outbounds, inbounds,
  13. // routing) against a real xray-core process. It validates the exact error
  14. // texts IsMissingHandlerErr/IsExistingTagErr rely on, and that replacing the
  15. // routing config keeps the api rule working.
  16. //
  17. // Skipped unless XRAY_E2E_BINARY points at an xray executable built from the
  18. // same xray-core version as go.mod, e.g.:
  19. //
  20. // go install github.com/xtls/xray-core/main@<version from go.mod>
  21. // XRAY_E2E_BINARY=$GOBIN/main go test ./internal/xray -run TestXrayAPI_E2E -v
  22. func TestXrayAPI_E2E(t *testing.T) {
  23. bin := os.Getenv("XRAY_E2E_BINARY")
  24. if bin == "" {
  25. t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
  26. }
  27. apiPort := freePort(t)
  28. cfg := map[string]any{
  29. "log": map[string]any{"loglevel": "warning"},
  30. "api": map[string]any{
  31. "services": []string{"HandlerService", "StatsService", "RoutingService"},
  32. "tag": "api",
  33. },
  34. "inbounds": []any{
  35. map[string]any{
  36. "listen": "127.0.0.1",
  37. "port": apiPort,
  38. "protocol": "tunnel",
  39. "settings": map[string]any{"rewriteAddress": "127.0.0.1"},
  40. "tag": "api",
  41. },
  42. },
  43. "outbounds": []any{
  44. map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
  45. map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
  46. },
  47. "routing": map[string]any{
  48. "domainStrategy": "AsIs",
  49. "rules": []any{
  50. map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
  51. },
  52. },
  53. "policy": map[string]any{},
  54. "stats": map[string]any{},
  55. }
  56. cfgBytes, err := json.MarshalIndent(cfg, "", " ")
  57. if err != nil {
  58. t.Fatal(err)
  59. }
  60. cfgPath := filepath.Join(t.TempDir(), "config.json")
  61. if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
  62. t.Fatal(err)
  63. }
  64. cmd := exec.Command(bin, "-c", cfgPath)
  65. cmd.Stdout = os.Stderr
  66. cmd.Stderr = os.Stderr
  67. if err := cmd.Start(); err != nil {
  68. t.Fatalf("failed to start xray: %v", err)
  69. }
  70. defer func() {
  71. _ = cmd.Process.Kill()
  72. _, _ = cmd.Process.Wait()
  73. }()
  74. waitForPort(t, apiPort)
  75. api := XrayAPI{}
  76. if err := api.Init(apiPort); err != nil {
  77. t.Fatalf("api init: %v", err)
  78. }
  79. defer api.Close()
  80. // --- outbounds ---
  81. socksOutbound := []byte(`{"protocol":"socks","settings":{"servers":[{"address":"127.0.0.1","port":10808}]},"tag":"test-out"}`)
  82. if err := api.AddOutbound(socksOutbound); err != nil {
  83. t.Fatalf("AddOutbound: %v", err)
  84. }
  85. err = api.AddOutbound(socksOutbound)
  86. if err == nil {
  87. t.Fatal("duplicate AddOutbound must fail")
  88. }
  89. if !IsExistingTagErr(err) {
  90. t.Fatalf("duplicate AddOutbound error not matched by IsExistingTagErr: %q", err)
  91. }
  92. if err := api.DelOutbound("test-out"); err != nil {
  93. t.Fatalf("DelOutbound: %v", err)
  94. }
  95. // xray's outbound manager treats removal of an unknown tag as a no-op.
  96. if err := api.DelOutbound("test-out"); err != nil && !IsMissingHandlerErr(err) {
  97. t.Fatalf("removing a missing outbound: unexpected error %q", err)
  98. }
  99. // --- inbounds ---
  100. vlessPort := freePort(t)
  101. vlessInbound := fmt.Appendf(nil,
  102. `{"listen":"127.0.0.1","port":%d,"protocol":"vless","settings":{"clients":[{"id":"a17e367c-2074-4d3e-aaeb-fbef5dfde7e7","email":"e2e"}],"decryption":"none"},"tag":"test-in"}`,
  103. vlessPort)
  104. if err := api.AddInbound(vlessInbound); err != nil {
  105. t.Fatalf("AddInbound: %v", err)
  106. }
  107. err = api.AddInbound(vlessInbound)
  108. if err == nil {
  109. t.Fatal("duplicate AddInbound must fail")
  110. }
  111. if !IsExistingTagErr(err) {
  112. t.Fatalf("duplicate AddInbound error not matched by IsExistingTagErr: %q", err)
  113. }
  114. if err := api.DelInbound("test-in"); err != nil {
  115. t.Fatalf("DelInbound: %v", err)
  116. }
  117. err = api.DelInbound("test-in")
  118. if err == nil {
  119. t.Fatal("removing a missing inbound must fail")
  120. }
  121. if !IsMissingHandlerErr(err) {
  122. t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
  123. }
  124. // --- routing (rules + balancers replace) ---
  125. newRouting := []byte(`{
  126. "domainStrategy": "AsIs",
  127. "balancers": [{"tag":"b1","selector":["direct"]}],
  128. "rules": [
  129. {"type":"field","inboundTag":["api"],"outboundTag":"api"},
  130. {"type":"field","port":"6666","outboundTag":"blocked","ruleTag":"e2e-rule"},
  131. {"type":"field","port":"7777","balancerTag":"b1","ruleTag":"e2e-balancer-rule"}
  132. ]
  133. }`)
  134. if err := api.ApplyRoutingConfig(newRouting); err != nil {
  135. t.Fatalf("ApplyRoutingConfig: %v", err)
  136. }
  137. // The replaced rule set still contains the api rule — the gRPC channel
  138. // must keep working after the swap.
  139. if err := api.AddOutbound([]byte(`{"protocol":"blackhole","settings":{},"tag":"post-routing"}`)); err != nil {
  140. t.Fatalf("api unusable after routing replace (api rule lost?): %v", err)
  141. }
  142. if err := api.DelOutbound("post-routing"); err != nil {
  143. t.Fatalf("DelOutbound after routing replace: %v", err)
  144. }
  145. // --- route testing ---
  146. res, err := api.TestRoute(RouteTestRequest{IP: "1.2.3.4", Port: 6666, Network: "tcp"})
  147. if err != nil {
  148. t.Fatalf("TestRoute(port rule): %v", err)
  149. }
  150. if !res.Matched || res.OutboundTag != "blocked" {
  151. t.Fatalf("TestRoute(port rule) = %+v, want matched blocked", res)
  152. }
  153. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
  154. if err != nil {
  155. t.Fatalf("TestRoute(balancer rule): %v", err)
  156. }
  157. if !res.Matched || res.OutboundTag != "direct" {
  158. t.Fatalf("TestRoute(balancer rule) = %+v, want matched direct", res)
  159. }
  160. // Note: current xray-core never populates OutboundGroupTags in PickRoute,
  161. // so GroupTags stays empty even for balancer rules — don't assert on it.
  162. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 9999, Network: "tcp"})
  163. if err != nil {
  164. t.Fatalf("TestRoute(no match): %v", err)
  165. }
  166. if res.Matched {
  167. t.Fatalf("TestRoute(no match) = %+v, want unmatched (default outbound)", res)
  168. }
  169. // --- balancer info + override ---
  170. info, err := api.GetBalancerInfo("b1")
  171. if err != nil {
  172. t.Fatalf("GetBalancerInfo: %v", err)
  173. }
  174. if info.Override != "" {
  175. t.Fatalf("fresh balancer must have no override, got %q", info.Override)
  176. }
  177. if err := api.SetBalancerTarget("b1", "blocked"); err != nil {
  178. t.Fatalf("SetBalancerTarget: %v", err)
  179. }
  180. info, err = api.GetBalancerInfo("b1")
  181. if err != nil {
  182. t.Fatalf("GetBalancerInfo after override: %v", err)
  183. }
  184. if info.Override != "blocked" {
  185. t.Fatalf("override = %q, want blocked", info.Override)
  186. }
  187. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
  188. if err != nil {
  189. t.Fatalf("TestRoute(overridden balancer): %v", err)
  190. }
  191. if res.OutboundTag != "blocked" {
  192. t.Fatalf("overridden balancer must route to blocked, got %+v", res)
  193. }
  194. if err := api.SetBalancerTarget("b1", ""); err != nil {
  195. t.Fatalf("SetBalancerTarget(clear): %v", err)
  196. }
  197. info, err = api.GetBalancerInfo("b1")
  198. if err != nil {
  199. t.Fatalf("GetBalancerInfo after clear: %v", err)
  200. }
  201. if info.Override != "" {
  202. t.Fatalf("override after clear = %q, want empty", info.Override)
  203. }
  204. }
  205. func freePort(t *testing.T) int {
  206. t.Helper()
  207. l, err := net.Listen("tcp", "127.0.0.1:0")
  208. if err != nil {
  209. t.Fatal(err)
  210. }
  211. defer l.Close()
  212. return l.Addr().(*net.TCPAddr).Port
  213. }
  214. func waitForPort(t *testing.T, port int) {
  215. t.Helper()
  216. deadline := time.Now().Add(15 * time.Second)
  217. addr := fmt.Sprintf("127.0.0.1:%d", port)
  218. for time.Now().Before(deadline) {
  219. conn, err := net.DialTimeout("tcp", addr, time.Second)
  220. if err == nil {
  221. conn.Close()
  222. return
  223. }
  224. time.Sleep(200 * time.Millisecond)
  225. }
  226. t.Fatalf("xray api port %d did not open in time", port)
  227. }