1
0

harness_test.go 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365
  1. // Package nodee2e drives a real master panel and a real node panel, each its own
  2. // process, through the node-sync paths. Gated by XUI_NODE_E2E_BINARY.
  3. package nodee2e
  4. import (
  5. "bytes"
  6. "encoding/json"
  7. "fmt"
  8. "io"
  9. "net"
  10. "net/http"
  11. "os"
  12. "os/exec"
  13. "path/filepath"
  14. "regexp"
  15. "strconv"
  16. "strings"
  17. "sync"
  18. "testing"
  19. "time"
  20. "github.com/mhsanaei/3x-ui/v3/internal/database"
  21. )
  22. const settleTimeout = 30 * time.Second
  23. func panelBinary(t *testing.T) string {
  24. t.Helper()
  25. bin := os.Getenv("XUI_NODE_E2E_BINARY")
  26. if bin == "" {
  27. t.Skip("XUI_NODE_E2E_BINARY not set; run `make node-e2e`")
  28. }
  29. abs, err := filepath.Abs(bin)
  30. if err != nil {
  31. t.Fatalf("resolve %s: %v", bin, err)
  32. }
  33. return abs
  34. }
  35. type panel struct {
  36. t *testing.T
  37. name string
  38. bin string
  39. dir string
  40. port int
  41. token string
  42. cmd *exec.Cmd
  43. logOut *os.File
  44. }
  45. func freePort(t *testing.T) int {
  46. t.Helper()
  47. l, err := net.Listen("tcp", "127.0.0.1:0")
  48. if err != nil {
  49. t.Fatalf("free port: %v", err)
  50. }
  51. defer l.Close()
  52. return l.Addr().(*net.TCPAddr).Port
  53. }
  54. func (p *panel) env() []string {
  55. return append(os.Environ(),
  56. "XUI_DB_FOLDER="+filepath.Join(p.dir, "db"),
  57. "XUI_LOG_FOLDER="+filepath.Join(p.dir, "log"),
  58. "XUI_BIN_FOLDER="+filepath.Join(p.dir, "bin"),
  59. "XUI_ENABLE_FAIL2BAN=false",
  60. "MSYS_NO_PATHCONV=1",
  61. )
  62. }
  63. func (p *panel) cli(args ...string) string {
  64. p.t.Helper()
  65. cmd := exec.Command(p.bin, args...)
  66. cmd.Env = p.env()
  67. out, err := cmd.CombinedOutput()
  68. if err != nil {
  69. p.t.Fatalf("%s %v: %v\n%s", p.name, args, err, out)
  70. }
  71. return string(out)
  72. }
  73. var apiTokenLine = regexp.MustCompile(`(?m)^apiToken:\s*(\S+)`)
  74. func (p *panel) mintToken(name, scope string) string {
  75. p.t.Helper()
  76. out := p.cli("setting", "-getApiToken", "-tokenName", name, "-tokenScope", scope)
  77. m := apiTokenLine.FindStringSubmatch(out)
  78. if m == nil {
  79. p.t.Fatalf("%s: no apiToken in output:\n%s", p.name, out)
  80. }
  81. return m[1]
  82. }
  83. // newPanel prepares a panel's database: credentials, a private port, its own
  84. // sub-server port (two panels on one host would race for 2096) and an admin token.
  85. func newPanel(t *testing.T, bin, name string) *panel {
  86. t.Helper()
  87. p := preparePanel(t, bin, name)
  88. p.token = p.mintToken("e2e-driver", "admin")
  89. return p
  90. }
  91. // sharedDBMu guards the process-global database handle the harness borrows
  92. // while the scopes run in parallel.
  93. var sharedDBMu sync.Mutex
  94. func preparePanel(t *testing.T, bin, name string) *panel {
  95. t.Helper()
  96. p := &panel{t: t, name: name, bin: bin, dir: t.TempDir(), port: freePort(t)}
  97. for _, d := range []string{"db", "log", "bin"} {
  98. if err := os.MkdirAll(filepath.Join(p.dir, d), 0o755); err != nil {
  99. t.Fatal(err)
  100. }
  101. }
  102. p.cli("setting", "-username", "e2e", "-password", "e2e-pass", "-port", strconv.Itoa(p.port), "-webBasePath", "/")
  103. sharedDBMu.Lock()
  104. defer sharedDBMu.Unlock()
  105. if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
  106. t.Fatalf("%s: open db: %v", name, err)
  107. }
  108. db := database.GetDB()
  109. db.Exec("DELETE FROM settings WHERE key = ?", "subPort")
  110. if err := db.Exec("INSERT INTO settings(key, value) VALUES (?, ?)", "subPort", strconv.Itoa(freePort(t))).Error; err != nil {
  111. t.Fatalf("%s: set subPort: %v", name, err)
  112. }
  113. if err := database.CloseDB(); err != nil {
  114. t.Fatalf("%s: close db: %v", name, err)
  115. }
  116. t.Cleanup(p.stop)
  117. return p
  118. }
  119. func (p *panel) start() {
  120. p.t.Helper()
  121. logOut, err := os.OpenFile(filepath.Join(p.dir, "stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
  122. if err != nil {
  123. p.t.Fatal(err)
  124. }
  125. p.logOut = logOut
  126. p.cmd = exec.Command(p.bin, "run")
  127. p.cmd.Env = p.env()
  128. p.cmd.Stdout = logOut
  129. p.cmd.Stderr = logOut
  130. if err := p.cmd.Start(); err != nil {
  131. p.t.Fatalf("%s: start: %v", p.name, err)
  132. }
  133. eventually(p.t, settleTimeout, p.name+" answers /server/status", func() (bool, string) {
  134. env, err := p.try(http.MethodGet, "/panel/api/server/status", nil)
  135. if err != nil {
  136. return false, err.Error()
  137. }
  138. return env.Success, env.Msg
  139. })
  140. }
  141. func (p *panel) stop() {
  142. if p.cmd == nil || p.cmd.Process == nil {
  143. return
  144. }
  145. _ = p.cmd.Process.Kill()
  146. _, _ = p.cmd.Process.Wait()
  147. p.cmd = nil
  148. if p.logOut != nil {
  149. _ = p.logOut.Close()
  150. p.logOut = nil
  151. }
  152. if p.t.Failed() {
  153. if b, err := os.ReadFile(filepath.Join(p.dir, "stdout.log")); err == nil {
  154. tail := string(b)
  155. if len(tail) > 6000 {
  156. tail = tail[len(tail)-6000:]
  157. }
  158. p.t.Logf("---- %s stdout tail ----\n%s", p.name, tail)
  159. }
  160. }
  161. }
  162. // deleteInboundRow simulates a node that lost an inbound (restore, reinstall)
  163. // while stopped; it must not run against a live panel.
  164. func (p *panel) deleteInboundRow(id int) {
  165. p.t.Helper()
  166. if p.cmd != nil {
  167. p.t.Fatalf("%s: deleteInboundRow on a running panel", p.name)
  168. }
  169. sharedDBMu.Lock()
  170. defer sharedDBMu.Unlock()
  171. if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
  172. p.t.Fatalf("%s: open db: %v", p.name, err)
  173. }
  174. defer func() { _ = database.CloseDB() }()
  175. db := database.GetDB()
  176. for _, q := range []string{"DELETE FROM client_inbounds WHERE inbound_id = ?", "DELETE FROM client_traffics WHERE inbound_id = ?", "DELETE FROM inbounds WHERE id = ?"} {
  177. if err := db.Exec(q, id).Error; err != nil {
  178. p.t.Fatalf("%s: %s: %v", p.name, q, err)
  179. }
  180. }
  181. }
  182. func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
  183. type envelope struct {
  184. Success bool `json:"success"`
  185. Msg string `json:"msg"`
  186. Obj json.RawMessage `json:"obj"`
  187. }
  188. func (p *panel) try(method, path string, body any) (*envelope, error) {
  189. var rd io.Reader
  190. if body != nil {
  191. b, err := json.Marshal(body)
  192. if err != nil {
  193. return nil, err
  194. }
  195. rd = bytes.NewReader(b)
  196. }
  197. req, err := http.NewRequest(method, p.url()+path, rd)
  198. if err != nil {
  199. return nil, err
  200. }
  201. req.Header.Set("Authorization", "Bearer "+p.token)
  202. if body != nil {
  203. req.Header.Set("Content-Type", "application/json")
  204. }
  205. resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
  206. if err != nil {
  207. return nil, err
  208. }
  209. defer resp.Body.Close()
  210. raw, err := io.ReadAll(resp.Body)
  211. if err != nil {
  212. return nil, err
  213. }
  214. if resp.StatusCode != http.StatusOK {
  215. return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
  216. }
  217. var env envelope
  218. if err := json.Unmarshal(raw, &env); err != nil {
  219. return nil, fmt.Errorf("decode %s: %w (%s)", path, err, raw)
  220. }
  221. return &env, nil
  222. }
  223. // call fails the test on transport errors or success:false.
  224. func (p *panel) call(method, path string, body any) json.RawMessage {
  225. p.t.Helper()
  226. env, err := p.try(method, path, body)
  227. if err != nil {
  228. p.t.Fatalf("%s %s %s: %v", p.name, method, path, err)
  229. }
  230. if !env.Success {
  231. p.t.Fatalf("%s %s %s: success=false msg=%q", p.name, method, path, env.Msg)
  232. }
  233. return env.Obj
  234. }
  235. func eventually(t *testing.T, timeout time.Duration, what string, check func() (bool, string)) {
  236. t.Helper()
  237. deadline := time.Now().Add(timeout)
  238. last := ""
  239. for {
  240. ok, detail := check()
  241. if ok {
  242. return
  243. }
  244. last = detail
  245. if time.Now().After(deadline) {
  246. t.Fatalf("timed out after %s waiting for %s; last: %s", timeout, what, last)
  247. }
  248. time.Sleep(500 * time.Millisecond)
  249. }
  250. }
  251. // inboundView is the subset of an inbound row the scenarios assert on.
  252. type inboundView struct {
  253. Id int `json:"id"`
  254. Remark string `json:"remark"`
  255. Enable bool `json:"enable"`
  256. Port int `json:"port"`
  257. Tag string `json:"tag"`
  258. NodeID *int `json:"nodeId"`
  259. Settings json.RawMessage `json:"settings"`
  260. }
  261. type clientEntry map[string]any
  262. func (c clientEntry) email() string { s, _ := c["email"].(string); return s }
  263. func (ib inboundView) clients() []clientEntry {
  264. raw := ib.Settings
  265. var asString string
  266. if json.Unmarshal(raw, &asString) == nil {
  267. raw = json.RawMessage(asString)
  268. }
  269. var s struct {
  270. Clients []clientEntry `json:"clients"`
  271. }
  272. _ = json.Unmarshal(raw, &s)
  273. return s.Clients
  274. }
  275. func (ib inboundView) emails() []string {
  276. out := []string{}
  277. for _, c := range ib.clients() {
  278. out = append(out, c.email())
  279. }
  280. return out
  281. }
  282. func (ib inboundView) client(email string) clientEntry {
  283. for _, c := range ib.clients() {
  284. if strings.EqualFold(c.email(), email) {
  285. return c
  286. }
  287. }
  288. return nil
  289. }
  290. func (p *panel) inbounds() []inboundView {
  291. p.t.Helper()
  292. var list []inboundView
  293. if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/inbounds/list", nil), &list); err != nil {
  294. p.t.Fatalf("%s: decode inbound list: %v", p.name, err)
  295. }
  296. return list
  297. }
  298. func (p *panel) inboundOnPort(port int) (inboundView, bool) {
  299. p.t.Helper()
  300. for _, ib := range p.inbounds() {
  301. if ib.Port == port {
  302. return ib, true
  303. }
  304. }
  305. return inboundView{}, false
  306. }
  307. const tcpStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`
  308. func vlessInbound(remark string, port int, nodeID *int, clients ...map[string]any) map[string]any {
  309. if clients == nil {
  310. clients = []map[string]any{}
  311. }
  312. settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
  313. body := map[string]any{
  314. "remark": remark, "enable": true, "port": port, "protocol": "vless",
  315. "settings": string(settings), "streamSettings": tcpStream, "sniffing": `{}`,
  316. }
  317. if nodeID != nil {
  318. body["nodeId"] = *nodeID
  319. }
  320. return body
  321. }
  322. func vlessClient(email string) map[string]any {
  323. return map[string]any{"email": email, "enable": true, "id": newUUID(email)}
  324. }
  325. // newUUID derives a stable, valid UUID from a label so failures are reproducible.
  326. func newUUID(label string) string {
  327. var b [16]byte
  328. copy(b[:], []byte(label+"________________"))
  329. b[6] = (b[6] & 0x0f) | 0x40
  330. b[8] = (b[8] & 0x3f) | 0x80
  331. return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
  332. }