api_e2e_test.go 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257
  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. "levels": map[string]any{
  55. "0": map[string]any{"statsUserOnline": true},
  56. },
  57. },
  58. "stats": map[string]any{},
  59. }
  60. cfgBytes, err := json.MarshalIndent(cfg, "", " ")
  61. if err != nil {
  62. t.Fatal(err)
  63. }
  64. cfgPath := filepath.Join(t.TempDir(), "config.json")
  65. if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
  66. t.Fatal(err)
  67. }
  68. cmd := exec.Command(bin, "-c", cfgPath)
  69. cmd.Stdout = os.Stderr
  70. cmd.Stderr = os.Stderr
  71. if err := cmd.Start(); err != nil {
  72. t.Fatalf("failed to start xray: %v", err)
  73. }
  74. defer func() {
  75. _ = cmd.Process.Kill()
  76. _, _ = cmd.Process.Wait()
  77. }()
  78. waitForPort(t, apiPort)
  79. api := XrayAPI{}
  80. if err := api.Init(apiPort); err != nil {
  81. t.Fatalf("api init: %v", err)
  82. }
  83. defer api.Close()
  84. // --- outbounds ---
  85. socksOutbound := []byte(`{"protocol":"socks","settings":{"servers":[{"address":"127.0.0.1","port":10808}]},"tag":"test-out"}`)
  86. if err := api.AddOutbound(socksOutbound); err != nil {
  87. t.Fatalf("AddOutbound: %v", err)
  88. }
  89. err = api.AddOutbound(socksOutbound)
  90. if err == nil {
  91. t.Fatal("duplicate AddOutbound must fail")
  92. }
  93. if !IsExistingTagErr(err) {
  94. t.Fatalf("duplicate AddOutbound error not matched by IsExistingTagErr: %q", err)
  95. }
  96. if err := api.DelOutbound("test-out"); err != nil {
  97. t.Fatalf("DelOutbound: %v", err)
  98. }
  99. // xray's outbound manager treats removal of an unknown tag as a no-op.
  100. if err := api.DelOutbound("test-out"); err != nil && !IsMissingHandlerErr(err) {
  101. t.Fatalf("removing a missing outbound: unexpected error %q", err)
  102. }
  103. // --- inbounds ---
  104. vlessPort := freePort(t)
  105. vlessInbound := fmt.Appendf(nil,
  106. `{"listen":"127.0.0.1","port":%d,"protocol":"vless","settings":{"clients":[{"id":"a17e367c-2074-4d3e-aaeb-fbef5dfde7e7","email":"e2e"}],"decryption":"none"},"tag":"test-in"}`,
  107. vlessPort)
  108. if err := api.AddInbound(vlessInbound); err != nil {
  109. t.Fatalf("AddInbound: %v", err)
  110. }
  111. err = api.AddInbound(vlessInbound)
  112. if err == nil {
  113. t.Fatal("duplicate AddInbound must fail")
  114. }
  115. if !IsExistingTagErr(err) {
  116. t.Fatalf("duplicate AddInbound error not matched by IsExistingTagErr: %q", err)
  117. }
  118. if err := api.DelInbound("test-in"); err != nil {
  119. t.Fatalf("DelInbound: %v", err)
  120. }
  121. err = api.DelInbound("test-in")
  122. if err == nil {
  123. t.Fatal("removing a missing inbound must fail")
  124. }
  125. if !IsMissingHandlerErr(err) {
  126. t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
  127. }
  128. // --- online-stats API ---
  129. // statsUserOnline is enabled in the policy above; with no client
  130. // connections the call must succeed and return an empty set. This proves
  131. // the GetUsersStats plumbing against a real core (an older binary would
  132. // return Unimplemented here — see IsUnimplementedErr).
  133. online, err := api.GetOnlineUsers()
  134. if err != nil {
  135. t.Fatalf("GetOnlineUsers: %v", err)
  136. }
  137. if len(online) != 0 {
  138. t.Fatalf("expected no online users on an idle core, got %+v", online)
  139. }
  140. // --- routing (rules + balancers replace) ---
  141. newRouting := []byte(`{
  142. "domainStrategy": "AsIs",
  143. "balancers": [{"tag":"b1","selector":["direct"]}],
  144. "rules": [
  145. {"type":"field","inboundTag":["api"],"outboundTag":"api"},
  146. {"type":"field","port":"6666","outboundTag":"blocked","ruleTag":"e2e-rule"},
  147. {"type":"field","port":"7777","balancerTag":"b1","ruleTag":"e2e-balancer-rule"}
  148. ]
  149. }`)
  150. if err := api.ApplyRoutingConfig(newRouting); err != nil {
  151. t.Fatalf("ApplyRoutingConfig: %v", err)
  152. }
  153. // The replaced rule set still contains the api rule — the gRPC channel
  154. // must keep working after the swap.
  155. if err := api.AddOutbound([]byte(`{"protocol":"blackhole","settings":{},"tag":"post-routing"}`)); err != nil {
  156. t.Fatalf("api unusable after routing replace (api rule lost?): %v", err)
  157. }
  158. if err := api.DelOutbound("post-routing"); err != nil {
  159. t.Fatalf("DelOutbound after routing replace: %v", err)
  160. }
  161. // --- route testing ---
  162. res, err := api.TestRoute(RouteTestRequest{IP: "1.2.3.4", Port: 6666, Network: "tcp"})
  163. if err != nil {
  164. t.Fatalf("TestRoute(port rule): %v", err)
  165. }
  166. if !res.Matched || res.OutboundTag != "blocked" {
  167. t.Fatalf("TestRoute(port rule) = %+v, want matched blocked", res)
  168. }
  169. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
  170. if err != nil {
  171. t.Fatalf("TestRoute(balancer rule): %v", err)
  172. }
  173. if !res.Matched || res.OutboundTag != "direct" {
  174. t.Fatalf("TestRoute(balancer rule) = %+v, want matched direct", res)
  175. }
  176. // Note: current xray-core never populates OutboundGroupTags in PickRoute,
  177. // so GroupTags stays empty even for balancer rules — don't assert on it.
  178. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 9999, Network: "tcp"})
  179. if err != nil {
  180. t.Fatalf("TestRoute(no match): %v", err)
  181. }
  182. if res.Matched {
  183. t.Fatalf("TestRoute(no match) = %+v, want unmatched (default outbound)", res)
  184. }
  185. // --- balancer info + override ---
  186. info, err := api.GetBalancerInfo("b1")
  187. if err != nil {
  188. t.Fatalf("GetBalancerInfo: %v", err)
  189. }
  190. if info.Override != "" {
  191. t.Fatalf("fresh balancer must have no override, got %q", info.Override)
  192. }
  193. if err := api.SetBalancerTarget("b1", "blocked"); err != nil {
  194. t.Fatalf("SetBalancerTarget: %v", err)
  195. }
  196. info, err = api.GetBalancerInfo("b1")
  197. if err != nil {
  198. t.Fatalf("GetBalancerInfo after override: %v", err)
  199. }
  200. if info.Override != "blocked" {
  201. t.Fatalf("override = %q, want blocked", info.Override)
  202. }
  203. res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
  204. if err != nil {
  205. t.Fatalf("TestRoute(overridden balancer): %v", err)
  206. }
  207. if res.OutboundTag != "blocked" {
  208. t.Fatalf("overridden balancer must route to blocked, got %+v", res)
  209. }
  210. if err := api.SetBalancerTarget("b1", ""); err != nil {
  211. t.Fatalf("SetBalancerTarget(clear): %v", err)
  212. }
  213. info, err = api.GetBalancerInfo("b1")
  214. if err != nil {
  215. t.Fatalf("GetBalancerInfo after clear: %v", err)
  216. }
  217. if info.Override != "" {
  218. t.Fatalf("override after clear = %q, want empty", info.Override)
  219. }
  220. }
  221. func freePort(t *testing.T) int {
  222. t.Helper()
  223. l, err := net.Listen("tcp", "127.0.0.1:0")
  224. if err != nil {
  225. t.Fatal(err)
  226. }
  227. defer l.Close()
  228. return l.Addr().(*net.TCPAddr).Port
  229. }
  230. func waitForPort(t *testing.T, port int) {
  231. t.Helper()
  232. deadline := time.Now().Add(15 * time.Second)
  233. addr := fmt.Sprintf("127.0.0.1:%d", port)
  234. for time.Now().Before(deadline) {
  235. conn, err := net.DialTimeout("tcp", addr, time.Second)
  236. if err == nil {
  237. conn.Close()
  238. return
  239. }
  240. time.Sleep(200 * time.Millisecond)
  241. }
  242. t.Fatalf("xray api port %d did not open in time", port)
  243. }