1
0

api_users_e2e_test.go 15 KB

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