| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350 |
- // Package nodee2e drives a real master panel and a real node panel, each its own
- // process, through the node-sync paths. Gated by XUI_NODE_E2E_BINARY.
- package nodee2e
- import (
- "bytes"
- "encoding/json"
- "fmt"
- "io"
- "net"
- "net/http"
- "os"
- "os/exec"
- "path/filepath"
- "regexp"
- "strconv"
- "strings"
- "testing"
- "time"
- "github.com/mhsanaei/3x-ui/v3/internal/database"
- )
- const settleTimeout = 30 * time.Second
- func panelBinary(t *testing.T) string {
- t.Helper()
- bin := os.Getenv("XUI_NODE_E2E_BINARY")
- if bin == "" {
- t.Skip("XUI_NODE_E2E_BINARY not set; run `make node-e2e`")
- }
- abs, err := filepath.Abs(bin)
- if err != nil {
- t.Fatalf("resolve %s: %v", bin, err)
- }
- return abs
- }
- type panel struct {
- t *testing.T
- name string
- bin string
- dir string
- port int
- token string
- cmd *exec.Cmd
- logOut *os.File
- }
- func freePort(t *testing.T) int {
- t.Helper()
- l, err := net.Listen("tcp", "127.0.0.1:0")
- if err != nil {
- t.Fatalf("free port: %v", err)
- }
- defer l.Close()
- return l.Addr().(*net.TCPAddr).Port
- }
- func (p *panel) env() []string {
- return append(os.Environ(),
- "XUI_DB_FOLDER="+filepath.Join(p.dir, "db"),
- "XUI_LOG_FOLDER="+filepath.Join(p.dir, "log"),
- "XUI_BIN_FOLDER="+filepath.Join(p.dir, "bin"),
- "XUI_ENABLE_FAIL2BAN=false",
- "MSYS_NO_PATHCONV=1",
- )
- }
- func (p *panel) cli(args ...string) string {
- p.t.Helper()
- cmd := exec.Command(p.bin, args...)
- cmd.Env = p.env()
- out, err := cmd.CombinedOutput()
- if err != nil {
- p.t.Fatalf("%s %v: %v\n%s", p.name, args, err, out)
- }
- return string(out)
- }
- var apiTokenLine = regexp.MustCompile(`(?m)^apiToken:\s*(\S+)`)
- func (p *panel) mintToken(name, scope string) string {
- p.t.Helper()
- out := p.cli("setting", "-getApiToken", "-tokenName", name, "-tokenScope", scope)
- m := apiTokenLine.FindStringSubmatch(out)
- if m == nil {
- p.t.Fatalf("%s: no apiToken in output:\n%s", p.name, out)
- }
- return m[1]
- }
- // newPanel prepares a panel's database: credentials, a private port, its own
- // sub-server port (two panels on one host would race for 2096) and an admin token.
- func newPanel(t *testing.T, bin, name string) *panel {
- t.Helper()
- p := &panel{t: t, name: name, bin: bin, dir: t.TempDir(), port: freePort(t)}
- for _, d := range []string{"db", "log", "bin"} {
- if err := os.MkdirAll(filepath.Join(p.dir, d), 0o755); err != nil {
- t.Fatal(err)
- }
- }
- p.cli("setting", "-username", "e2e", "-password", "e2e-pass", "-port", strconv.Itoa(p.port), "-webBasePath", "/")
- if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
- t.Fatalf("%s: open db: %v", name, err)
- }
- db := database.GetDB()
- db.Exec("DELETE FROM settings WHERE key = ?", "subPort")
- if err := db.Exec("INSERT INTO settings(key, value) VALUES (?, ?)", "subPort", strconv.Itoa(freePort(t))).Error; err != nil {
- t.Fatalf("%s: set subPort: %v", name, err)
- }
- if err := database.CloseDB(); err != nil {
- t.Fatalf("%s: close db: %v", name, err)
- }
- p.token = p.mintToken("e2e-driver", "admin")
- t.Cleanup(p.stop)
- return p
- }
- func (p *panel) start() {
- p.t.Helper()
- logOut, err := os.OpenFile(filepath.Join(p.dir, "stdout.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
- if err != nil {
- p.t.Fatal(err)
- }
- p.logOut = logOut
- p.cmd = exec.Command(p.bin, "run")
- p.cmd.Env = p.env()
- p.cmd.Stdout = logOut
- p.cmd.Stderr = logOut
- if err := p.cmd.Start(); err != nil {
- p.t.Fatalf("%s: start: %v", p.name, err)
- }
- eventually(p.t, settleTimeout, p.name+" answers /server/status", func() (bool, string) {
- env, err := p.try(http.MethodGet, "/panel/api/server/status", nil)
- if err != nil {
- return false, err.Error()
- }
- return env.Success, env.Msg
- })
- }
- func (p *panel) stop() {
- if p.cmd == nil || p.cmd.Process == nil {
- return
- }
- _ = p.cmd.Process.Kill()
- _, _ = p.cmd.Process.Wait()
- p.cmd = nil
- if p.logOut != nil {
- _ = p.logOut.Close()
- p.logOut = nil
- }
- if p.t.Failed() {
- if b, err := os.ReadFile(filepath.Join(p.dir, "stdout.log")); err == nil {
- tail := string(b)
- if len(tail) > 6000 {
- tail = tail[len(tail)-6000:]
- }
- p.t.Logf("---- %s stdout tail ----\n%s", p.name, tail)
- }
- }
- }
- // deleteInboundRow simulates a node that lost an inbound (restore, reinstall)
- // while stopped; it must not run against a live panel.
- func (p *panel) deleteInboundRow(id int) {
- p.t.Helper()
- if p.cmd != nil {
- p.t.Fatalf("%s: deleteInboundRow on a running panel", p.name)
- }
- if err := database.InitDB(filepath.Join(p.dir, "db", "x-ui.db")); err != nil {
- p.t.Fatalf("%s: open db: %v", p.name, err)
- }
- defer func() { _ = database.CloseDB() }()
- db := database.GetDB()
- for _, q := range []string{"DELETE FROM client_inbounds WHERE inbound_id = ?", "DELETE FROM client_traffics WHERE inbound_id = ?", "DELETE FROM inbounds WHERE id = ?"} {
- if err := db.Exec(q, id).Error; err != nil {
- p.t.Fatalf("%s: %s: %v", p.name, q, err)
- }
- }
- }
- func (p *panel) url() string { return "http://127.0.0.1:" + strconv.Itoa(p.port) }
- type envelope struct {
- Success bool `json:"success"`
- Msg string `json:"msg"`
- Obj json.RawMessage `json:"obj"`
- }
- func (p *panel) try(method, path string, body any) (*envelope, error) {
- var rd io.Reader
- if body != nil {
- b, err := json.Marshal(body)
- if err != nil {
- return nil, err
- }
- rd = bytes.NewReader(b)
- }
- req, err := http.NewRequest(method, p.url()+path, rd)
- if err != nil {
- return nil, err
- }
- req.Header.Set("Authorization", "Bearer "+p.token)
- if body != nil {
- req.Header.Set("Content-Type", "application/json")
- }
- resp, err := (&http.Client{Timeout: 20 * time.Second}).Do(req)
- if err != nil {
- return nil, err
- }
- defer resp.Body.Close()
- raw, err := io.ReadAll(resp.Body)
- if err != nil {
- return nil, err
- }
- if resp.StatusCode != http.StatusOK {
- return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, raw)
- }
- var env envelope
- if err := json.Unmarshal(raw, &env); err != nil {
- return nil, fmt.Errorf("decode %s: %w (%s)", path, err, raw)
- }
- return &env, nil
- }
- // call fails the test on transport errors or success:false.
- func (p *panel) call(method, path string, body any) json.RawMessage {
- p.t.Helper()
- env, err := p.try(method, path, body)
- if err != nil {
- p.t.Fatalf("%s %s %s: %v", p.name, method, path, err)
- }
- if !env.Success {
- p.t.Fatalf("%s %s %s: success=false msg=%q", p.name, method, path, env.Msg)
- }
- return env.Obj
- }
- func eventually(t *testing.T, timeout time.Duration, what string, check func() (bool, string)) {
- t.Helper()
- deadline := time.Now().Add(timeout)
- last := ""
- for {
- ok, detail := check()
- if ok {
- return
- }
- last = detail
- if time.Now().After(deadline) {
- t.Fatalf("timed out after %s waiting for %s; last: %s", timeout, what, last)
- }
- time.Sleep(500 * time.Millisecond)
- }
- }
- // inboundView is the subset of an inbound row the scenarios assert on.
- type inboundView struct {
- Id int `json:"id"`
- Remark string `json:"remark"`
- Enable bool `json:"enable"`
- Port int `json:"port"`
- Tag string `json:"tag"`
- NodeID *int `json:"nodeId"`
- Settings json.RawMessage `json:"settings"`
- }
- type clientEntry map[string]any
- func (c clientEntry) email() string { s, _ := c["email"].(string); return s }
- func (ib inboundView) clients() []clientEntry {
- raw := ib.Settings
- var asString string
- if json.Unmarshal(raw, &asString) == nil {
- raw = json.RawMessage(asString)
- }
- var s struct {
- Clients []clientEntry `json:"clients"`
- }
- _ = json.Unmarshal(raw, &s)
- return s.Clients
- }
- func (ib inboundView) emails() []string {
- out := []string{}
- for _, c := range ib.clients() {
- out = append(out, c.email())
- }
- return out
- }
- func (ib inboundView) client(email string) clientEntry {
- for _, c := range ib.clients() {
- if strings.EqualFold(c.email(), email) {
- return c
- }
- }
- return nil
- }
- func (p *panel) inbounds() []inboundView {
- p.t.Helper()
- var list []inboundView
- if err := json.Unmarshal(p.call(http.MethodGet, "/panel/api/inbounds/list", nil), &list); err != nil {
- p.t.Fatalf("%s: decode inbound list: %v", p.name, err)
- }
- return list
- }
- func (p *panel) inboundOnPort(port int) (inboundView, bool) {
- p.t.Helper()
- for _, ib := range p.inbounds() {
- if ib.Port == port {
- return ib, true
- }
- }
- return inboundView{}, false
- }
- const tcpStream = `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`
- func vlessInbound(remark string, port int, nodeID *int, clients ...map[string]any) map[string]any {
- if clients == nil {
- clients = []map[string]any{}
- }
- settings, _ := json.Marshal(map[string]any{"clients": clients, "decryption": "none"})
- body := map[string]any{
- "remark": remark, "enable": true, "port": port, "protocol": "vless",
- "settings": string(settings), "streamSettings": tcpStream, "sniffing": `{}`,
- }
- if nodeID != nil {
- body["nodeId"] = *nodeID
- }
- return body
- }
- func vlessClient(email string) map[string]any {
- return map[string]any{"email": email, "enable": true, "id": newUUID(email)}
- }
- // newUUID derives a stable, valid UUID from a label so failures are reproducible.
- func newUUID(label string) string {
- var b [16]byte
- copy(b[:], []byte(label+"________________"))
- b[6] = (b[6] & 0x0f) | 0x40
- b[8] = (b[8] & 0x3f) | 0x80
- return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16])
- }
|