api_users_e2e_test.go 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  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, IsMissingHandlerErr and IsMissingUserErr 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. if !IsMissingUserErr(err) {
  237. t.Fatalf("missing-user error not matched by IsMissingUserErr: %q", err)
  238. }
  239. })
  240. }
  241. t.Run("unknown inbound tag", func(t *testing.T) {
  242. err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"}))
  243. if err == nil {
  244. t.Fatal("AddUser on an unknown tag must fail")
  245. }
  246. if !IsMissingHandlerErr(err) {
  247. t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err)
  248. }
  249. err = c.api.RemoveUser("no-such-tag", "e2e-ghost")
  250. if err == nil {
  251. t.Fatal("RemoveUser on an unknown tag must fail")
  252. }
  253. if IsMissingUserErr(err) {
  254. t.Fatalf("a missing handler must not read as a missing user: %q", err)
  255. }
  256. })
  257. }
  258. // TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash
  259. // that took the whole core down: a shadowsocks user whose cipher the panel
  260. // could not resolve used to be sent as a shadowsocks-2022 account, which the
  261. // legacy inbound casts without checking. Every case here must leave the core
  262. // running.
  263. func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) {
  264. c := startE2ECore(t, []any{
  265. map[string]any{
  266. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
  267. "settings": map[string]any{
  268. "method": "aes-256-gcm", "network": "tcp,udp",
  269. "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
  270. },
  271. },
  272. map[string]any{
  273. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in",
  274. "settings": map[string]any{
  275. "method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp",
  276. "clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}},
  277. },
  278. },
  279. })
  280. // The auto-renew path hands over the client object straight out of the
  281. // inbound's settings, which carries the cipher under "method".
  282. t.Run("client object from settings", func(t *testing.T) {
  283. user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true}
  284. if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
  285. t.Fatalf("AddUser with the settings-shaped client: %v", err)
  286. }
  287. if !c.alive() {
  288. t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account")
  289. }
  290. if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil {
  291. t.Fatalf("RemoveUser: %v", err)
  292. }
  293. })
  294. t.Run("cipher missing entirely", func(t *testing.T) {
  295. err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"})
  296. if err == nil {
  297. t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed")
  298. }
  299. if !c.alive() {
  300. t.Fatal("core died on a shadowsocks user with no cipher")
  301. }
  302. })
  303. t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) {
  304. // The reverse mismatch is only reachable when the panel's view of the
  305. // inbound has drifted from the running one; the account type is still
  306. // the one the cipher names, so the panel never guesses here.
  307. user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"}
  308. if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil {
  309. t.Fatalf("AddUser: %v", err)
  310. }
  311. if !c.alive() {
  312. t.Fatal("core died adding a 2022 user to a 2022 inbound")
  313. }
  314. if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil {
  315. t.Fatalf("RemoveUser: %v", err)
  316. }
  317. })
  318. }
  319. // TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks
  320. // inbound accepting a second user under an email it already holds: one removal
  321. // then left the client connectable. Adding twice must leave exactly one
  322. // registration, so a single removal fully revokes the client.
  323. func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) {
  324. c := startE2ECore(t, []any{
  325. map[string]any{
  326. "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in",
  327. "settings": map[string]any{
  328. "method": "aes-256-gcm", "network": "tcp,udp",
  329. "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}},
  330. },
  331. },
  332. })
  333. user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"}
  334. for i := range 2 {
  335. if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil {
  336. t.Fatalf("AddUser #%d: %v", i+1, err)
  337. }
  338. }
  339. if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil {
  340. t.Fatalf("RemoveUser: %v", err)
  341. }
  342. err := c.api.RemoveUser("ss-in", "dup-ss")
  343. if err == nil {
  344. t.Fatal("a second registration survived removal: a disabled client would keep connecting")
  345. }
  346. if !IsMissingHandlerErr(err) {
  347. t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err)
  348. }
  349. }
  350. // TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a
  351. // counter appearing after the baseline poll is reported in full: xray creates a
  352. // user's counter on first use, and dropping that first sample lost a new
  353. // client's traffic for a whole polling interval.
  354. func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) {
  355. const payload = 64 * 1024
  356. backendLn, err := net.Listen("tcp", "127.0.0.1:0")
  357. if err != nil {
  358. t.Fatal(err)
  359. }
  360. backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
  361. _, _ = w.Write(make([]byte, payload))
  362. })}
  363. go func() { _ = backend.Serve(backendLn) }()
  364. t.Cleanup(func() { _ = backend.Close() })
  365. proxyPort := freePort(t)
  366. c := startE2ECore(t, []any{
  367. map[string]any{
  368. "listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in",
  369. "settings": map[string]any{
  370. "accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}},
  371. },
  372. },
  373. })
  374. // Baseline poll: the client's counter does not exist yet.
  375. if _, _, err := c.api.GetTraffic(); err != nil {
  376. t.Fatalf("GetTraffic (baseline): %v", err)
  377. }
  378. proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:[email protected]:%d", proxyPort))
  379. if err != nil {
  380. t.Fatal(err)
  381. }
  382. client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
  383. resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String()))
  384. if err != nil {
  385. t.Fatalf("proxied request: %v", err)
  386. }
  387. n, err := io.Copy(io.Discard, resp.Body)
  388. resp.Body.Close()
  389. if err != nil {
  390. t.Fatalf("reading the proxied response: %v", err)
  391. }
  392. if n != payload {
  393. t.Fatalf("proxied %d bytes, want %d", n, payload)
  394. }
  395. time.Sleep(500 * time.Millisecond)
  396. _, clients, err := c.api.GetTraffic()
  397. if err != nil {
  398. t.Fatalf("GetTraffic: %v", err)
  399. }
  400. var got *ClientTraffic
  401. for _, ct := range clients {
  402. if ct.Email == "e2e-fresh" {
  403. got = ct
  404. }
  405. }
  406. if got == nil {
  407. t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients)
  408. }
  409. if got.Down < payload {
  410. t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload)
  411. }
  412. }
  413. // TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an
  414. // out-of-range port into the uint32 the core is asked about.
  415. func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) {
  416. c := startE2ECore(t, nil)
  417. for _, port := range []int{-1, 65536, 70000} {
  418. if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil {
  419. t.Fatalf("TestRoute accepted the out-of-range port %d", port)
  420. }
  421. }
  422. if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil {
  423. t.Fatalf("TestRoute rejected the valid port 65535: %v", err)
  424. }
  425. }