api_users_e2e_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447
  1. package xray
  2. import (
  3. "encoding/base64"
  4. "encoding/json"
  5. "fmt"
  6. "io"
  7. "net"
  8. "net/http"
  9. "net/url"
  10. "os"
  11. "os/exec"
  12. "path/filepath"
  13. "testing"
  14. "time"
  15. )
  16. // e2eCore is a real xray-core process plus a connected panel API client.
  17. type e2eCore struct {
  18. api *XrayAPI
  19. cmd *exec.Cmd
  20. port int
  21. }
  22. // alive reports whether the core is still serving its API port. A gRPC call
  23. // that hands the core an account type its inbound does not expect takes the
  24. // whole process down, so every user probe checks this.
  25. func (c *e2eCore) alive() bool {
  26. if c.cmd.ProcessState != nil {
  27. return false
  28. }
  29. conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", c.port), time.Second)
  30. if err != nil {
  31. return false
  32. }
  33. conn.Close()
  34. return true
  35. }
  36. // startE2ECore boots xray-core with the given inbounds plus the api inbound the
  37. // panel talks through, and returns a connected client. Skips unless
  38. // XRAY_E2E_BINARY points at an xray built from the version go.mod pins.
  39. func startE2ECore(t *testing.T, inbounds []any) *e2eCore {
  40. t.Helper()
  41. bin := os.Getenv("XRAY_E2E_BINARY")
  42. if bin == "" {
  43. t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
  44. }
  45. apiPort := freePort(t)
  46. all := []any{
  47. map[string]any{
  48. "listen": "127.0.0.1",
  49. "port": apiPort,
  50. "protocol": "tunnel",
  51. "settings": map[string]any{"rewriteAddress": "127.0.0.1"},
  52. "tag": "api",
  53. },
  54. }
  55. all = append(all, inbounds...)
  56. cfg := map[string]any{
  57. "log": map[string]any{"loglevel": "warning"},
  58. "api": map[string]any{
  59. "services": []string{"HandlerService", "StatsService", "RoutingService"},
  60. "tag": "api",
  61. },
  62. "inbounds": all,
  63. "outbounds": []any{
  64. map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
  65. map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
  66. },
  67. "routing": map[string]any{
  68. "domainStrategy": "AsIs",
  69. "rules": []any{
  70. map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
  71. },
  72. },
  73. "policy": map[string]any{
  74. "levels": map[string]any{
  75. "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true},
  76. },
  77. "system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true},
  78. },
  79. "stats": map[string]any{},
  80. }
  81. raw, err := json.MarshalIndent(cfg, "", " ")
  82. if err != nil {
  83. t.Fatal(err)
  84. }
  85. cfgPath := filepath.Join(t.TempDir(), "config.json")
  86. if err := os.WriteFile(cfgPath, raw, 0o644); err != nil {
  87. t.Fatal(err)
  88. }
  89. cmd := exec.Command(bin, "-c", cfgPath)
  90. cmd.Stdout = os.Stderr
  91. cmd.Stderr = os.Stderr
  92. if err := cmd.Start(); err != nil {
  93. t.Fatalf("failed to start xray: %v", err)
  94. }
  95. t.Cleanup(func() {
  96. _ = cmd.Process.Kill()
  97. _, _ = cmd.Process.Wait()
  98. })
  99. waitForPort(t, apiPort)
  100. api := &XrayAPI{}
  101. if err := api.Init(apiPort); err != nil {
  102. t.Fatalf("api init: %v", err)
  103. }
  104. t.Cleanup(api.Close)
  105. return &e2eCore{api: api, cmd: cmd, port: apiPort}
  106. }
  107. // ssKey builds a base64 shadowsocks-2022 key of the given byte length.
  108. func ssKey(n int, seed byte) string {
  109. raw := make([]byte, n)
  110. for i := range raw {
  111. raw[i] = seed + byte(i)
  112. }
  113. return base64.StdEncoding.EncodeToString(raw)
  114. }
  115. // panelUser mirrors the map shape the panel's client-apply paths hand to
  116. // AddUser: every field is always present, unused ones are empty.
  117. func panelUser(email string, fields map[string]any) map[string]any {
  118. user := map[string]any{
  119. "email": email,
  120. "id": "",
  121. "auth": "",
  122. "security": "",
  123. "flow": "",
  124. "password": "",
  125. "cipher": "",
  126. "publicKey": "",
  127. "allowedIPs": nil,
  128. "preSharedKey": "",
  129. "keepAlive": "",
  130. }
  131. for k, v := range fields {
  132. user[k] = v
  133. }
  134. return user
  135. }
  136. // TestXrayAPI_E2E_Users runs the panel's add/remove user surface against a live
  137. // core for every protocol it builds accounts for, pinning the error texts
  138. // IsUserExistsErr and IsMissingHandlerErr match on.
  139. func TestXrayAPI_E2E_Users(t *testing.T) {
  140. c := startE2ECore(t, []any{
  141. map[string]any{
  142. "listen": "127.0.0.1", "port": freePort(t), "protocol": "vmess", "tag": "vmess-in",
  143. "settings": map[string]any{"clients": []any{
  144. map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "seed-vmess"},
  145. }},
  146. },
  147. map[string]any{
  148. "listen": "127.0.0.1", "port": freePort(t), "protocol": "vless", "tag": "vless-in",
  149. "settings": map[string]any{"clients": []any{
  150. map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30812", "email": "seed-vless"},
  151. }, "decryption": "none"},
  152. },
  153. map[string]any{
  154. "listen": "127.0.0.1", "port": freePort(t), "protocol": "trojan", "tag": "trojan-in",
  155. "settings": map[string]any{"clients": []any{
  156. map[string]any{"password": "seed-pw", "email": "seed-trojan"},
  157. }},
  158. },
  159. map[string]any{
  160. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
  161. "settings": map[string]any{
  162. "method": "aes-256-gcm", "network": "tcp,udp",
  163. "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
  164. },
  165. },
  166. map[string]any{
  167. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
  168. "settings": map[string]any{
  169. "method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
  170. "clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
  171. },
  172. },
  173. })
  174. tests := []struct {
  175. name string
  176. protocol string
  177. tag string
  178. user map[string]any
  179. // rejectsDuplicateEmail is false for the legacy shadowsocks inbound,
  180. // whose validator does not dedupe emails at all.
  181. rejectsDuplicateEmail bool
  182. }{
  183. {
  184. name: "vmess", protocol: "vmess", tag: "vmess-in",
  185. user: panelUser("e2e-vmess", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30821", "security": "auto"}),
  186. rejectsDuplicateEmail: true,
  187. },
  188. {
  189. name: "vless", protocol: "vless", tag: "vless-in",
  190. user: panelUser("e2e-vless", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30822", "flow": "xtls-rprx-vision"}),
  191. rejectsDuplicateEmail: true,
  192. },
  193. {
  194. name: "trojan", protocol: "trojan", tag: "trojan-in",
  195. user: panelUser("e2e-trojan", map[string]any{"password": "e2e-pw"}),
  196. rejectsDuplicateEmail: true,
  197. },
  198. {
  199. name: "shadowsocks legacy", protocol: "shadowsocks", tag: "ss-in",
  200. user: panelUser("e2e-ss", map[string]any{"password": "e2e-pw", "cipher": "aes-256-gcm"}),
  201. rejectsDuplicateEmail: false,
  202. },
  203. {
  204. name: "shadowsocks 2022", protocol: "shadowsocks", tag: "ss2022-in",
  205. user: panelUser("e2e-ss2022", map[string]any{"password": ssKey(32, 40), "cipher": "2022-blake3-aes-256-gcm"}),
  206. rejectsDuplicateEmail: true,
  207. },
  208. }
  209. for _, tt := range tests {
  210. t.Run(tt.name, func(t *testing.T) {
  211. email := tt.user["email"].(string)
  212. if err := c.api.AddUser(tt.protocol, tt.tag, tt.user); err != nil {
  213. t.Fatalf("AddUser: %v", err)
  214. }
  215. if !c.alive() {
  216. t.Fatal("core died on AddUser: the account type does not match the running inbound")
  217. }
  218. if tt.rejectsDuplicateEmail {
  219. err := c.api.AddUser(tt.protocol, tt.tag, tt.user)
  220. if err == nil {
  221. t.Fatal("adding a user whose email is taken must fail")
  222. }
  223. if !IsUserExistsErr(err) {
  224. t.Fatalf("duplicate-email error not matched by IsUserExistsErr: %q", err)
  225. }
  226. }
  227. if err := c.api.RemoveUser(tt.tag, email); err != nil {
  228. t.Fatalf("RemoveUser: %v", err)
  229. }
  230. err := c.api.RemoveUser(tt.tag, email)
  231. if err == nil {
  232. t.Fatal("the user is still registered after RemoveUser")
  233. }
  234. if !IsMissingHandlerErr(err) {
  235. t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
  236. }
  237. })
  238. }
  239. t.Run("unknown inbound tag", func(t *testing.T) {
  240. err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"}))
  241. if err == nil {
  242. t.Fatal("AddUser on an unknown tag must fail")
  243. }
  244. if !IsMissingHandlerErr(err) {
  245. t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err)
  246. }
  247. })
  248. }
  249. // TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash
  250. // that took the whole core down: a shadowsocks user whose cipher the panel
  251. // could not resolve used to be sent as a shadowsocks-2022 account, which the
  252. // legacy inbound casts without checking. Every case here must leave the core
  253. // running.
  254. func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) {
  255. c := startE2ECore(t, []any{
  256. map[string]any{
  257. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
  258. "settings": map[string]any{
  259. "method": "aes-256-gcm", "network": "tcp,udp",
  260. "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
  261. },
  262. },
  263. map[string]any{
  264. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
  265. "settings": map[string]any{
  266. "method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
  267. "clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
  268. },
  269. },
  270. })
  271. // The auto-renew path hands over the client object straight out of the
  272. // inbound's settings, which carries the cipher under "method".
  273. t.Run("client object from settings", func(t *testing.T) {
  274. user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true}
  275. if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
  276. t.Fatalf("AddUser with the settings-shaped client: %v", err)
  277. }
  278. if !c.alive() {
  279. t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account")
  280. }
  281. if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil {
  282. t.Fatalf("RemoveUser: %v", err)
  283. }
  284. })
  285. t.Run("cipher missing entirely", func(t *testing.T) {
  286. err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"})
  287. if err == nil {
  288. t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed")
  289. }
  290. if !c.alive() {
  291. t.Fatal("core died on a shadowsocks user with no cipher")
  292. }
  293. })
  294. t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) {
  295. // The reverse mismatch is only reachable when the panel's view of the
  296. // inbound has drifted from the running one; the account type is still
  297. // the one the cipher names, so the panel never guesses here.
  298. user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"}
  299. if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil {
  300. t.Fatalf("AddUser: %v", err)
  301. }
  302. if !c.alive() {
  303. t.Fatal("core died adding a 2022 user to a 2022 inbound")
  304. }
  305. if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil {
  306. t.Fatalf("RemoveUser: %v", err)
  307. }
  308. })
  309. }
  310. // TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks
  311. // inbound accepting a second user under an email it already holds: one removal
  312. // then left the client connectable. Adding twice must leave exactly one
  313. // registration, so a single removal fully revokes the client.
  314. func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) {
  315. c := startE2ECore(t, []any{
  316. map[string]any{
  317. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
  318. "settings": map[string]any{
  319. "method": "aes-256-gcm", "network": "tcp,udp",
  320. "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
  321. },
  322. },
  323. })
  324. user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"}
  325. for i := range 2 {
  326. if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
  327. t.Fatalf("AddUser #%d: %v", i+1, err)
  328. }
  329. }
  330. if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil {
  331. t.Fatalf("RemoveUser: %v", err)
  332. }
  333. err := c.api.RemoveUser("ss-in", "dup-ss")
  334. if err == nil {
  335. t.Fatal("a second registration survived removal: a disabled client would keep connecting")
  336. }
  337. if !IsMissingHandlerErr(err) {
  338. t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
  339. }
  340. }
  341. // TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a
  342. // counter appearing after the baseline poll is reported in full: xray creates a
  343. // user's counter on first use, and dropping that first sample lost a new
  344. // client's traffic for a whole polling interval.
  345. func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) {
  346. const payload = 64 * 1024
  347. backendLn, err := net.Listen("tcp", "127.0.0.1:0")
  348. if err != nil {
  349. t.Fatal(err)
  350. }
  351. backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  352. _, _ = w.Write(make([]byte, payload))
  353. })}
  354. go func() { _ = backend.Serve(backendLn) }()
  355. t.Cleanup(func() { _ = backend.Close() })
  356. proxyPort := freePort(t)
  357. c := startE2ECore(t, []any{
  358. map[string]any{
  359. "listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in",
  360. "settings": map[string]any{
  361. "accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}},
  362. },
  363. },
  364. })
  365. // Baseline poll: the client's counter does not exist yet.
  366. if _, _, err := c.api.GetTraffic(); err != nil {
  367. t.Fatalf("GetTraffic (baseline): %v", err)
  368. }
  369. proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:[email protected]:%d", proxyPort))
  370. if err != nil {
  371. t.Fatal(err)
  372. }
  373. client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
  374. resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String()))
  375. if err != nil {
  376. t.Fatalf("proxied request: %v", err)
  377. }
  378. n, err := io.Copy(io.Discard, resp.Body)
  379. resp.Body.Close()
  380. if err != nil {
  381. t.Fatalf("reading the proxied response: %v", err)
  382. }
  383. if n != payload {
  384. t.Fatalf("proxied %d bytes, want %d", n, payload)
  385. }
  386. time.Sleep(500 * time.Millisecond)
  387. _, clients, err := c.api.GetTraffic()
  388. if err != nil {
  389. t.Fatalf("GetTraffic: %v", err)
  390. }
  391. var got *ClientTraffic
  392. for _, ct := range clients {
  393. if ct.Email == "e2e-fresh" {
  394. got = ct
  395. }
  396. }
  397. if got == nil {
  398. t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients)
  399. }
  400. if got.Down < payload {
  401. t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload)
  402. }
  403. }
  404. // TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an
  405. // out-of-range port into the uint32 the core is asked about.
  406. func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) {
  407. c := startE2ECore(t, nil)
  408. for _, port := range []int{-1, 65536, 70000} {
  409. if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil {
  410. t.Fatalf("TestRoute accepted the out-of-range port %d", port)
  411. }
  412. }
  413. if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil {
  414. t.Fatalf("TestRoute rejected the valid port 65535: %v", err)
  415. }
  416. }