| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382 |
- package web
- import (
- "context"
- "net/http"
- "net/http/httptest"
- "net/url"
- "path/filepath"
- "reflect"
- "strconv"
- "strings"
- "sync"
- "testing"
- "time"
- "github.com/robfig/cron/v3"
- "github.com/mhsanaei/3x-ui/v3/internal/database"
- "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
- "github.com/mhsanaei/3x-ui/v3/internal/database/model"
- "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
- "github.com/mhsanaei/3x-ui/v3/internal/web/global"
- "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
- "github.com/mhsanaei/3x-ui/v3/internal/web/service"
- "github.com/mhsanaei/3x-ui/v3/internal/xray"
- )
- // nodeUnderContract serves the production router as a node and records every
- // request the node refused for auth or scope.
- type nodeUnderContract struct {
- srv *httptest.Server
- mu sync.Mutex
- refused []string
- }
- func startContractNode(t *testing.T) *nodeUnderContract {
- t.Helper()
- dbDir := t.TempDir()
- t.Setenv("XUI_DB_FOLDER", dbDir)
- dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
- prevMgr := runtime.GetManager()
- runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
- t.Cleanup(func() { runtime.SetManager(prevMgr) })
- previous := global.GetWebServer()
- s := NewServer()
- s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
- global.SetWebServer(s)
- t.Cleanup(func() {
- s.cancel()
- global.SetWebServer(previous)
- })
- engine, err := s.initRouter()
- if err != nil {
- t.Fatalf("initRouter: %v", err)
- }
- n := &nodeUnderContract{}
- n.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
- engine.ServeHTTP(rec, r)
- if rec.status == http.StatusUnauthorized || rec.status == http.StatusForbidden {
- n.mu.Lock()
- n.refused = append(n.refused, r.Method+" "+r.URL.Path+" -> "+strconv.Itoa(rec.status))
- n.mu.Unlock()
- }
- }))
- t.Cleanup(n.srv.Close)
- return n
- }
- type statusRecorder struct {
- http.ResponseWriter
- status int
- }
- func (r *statusRecorder) WriteHeader(code int) {
- r.status = code
- r.ResponseWriter.WriteHeader(code)
- }
- func (n *nodeUnderContract) takeRefused() []string {
- n.mu.Lock()
- defer n.mu.Unlock()
- out := n.refused
- n.refused = nil
- return out
- }
- func (n *nodeUnderContract) masterWithToken(t *testing.T, scope string) *runtime.Remote {
- t.Helper()
- token := "contract-" + scope
- if err := database.GetDB().Create(&model.ApiToken{
- Name: "master-" + scope, Token: crypto.HashTokenSHA256(token), Enabled: true, Scope: scope,
- }).Error; err != nil {
- t.Fatalf("seed %s token: %v", scope, err)
- }
- u, _ := url.Parse(n.srv.URL)
- port, _ := strconv.Atoi(u.Port())
- return runtime.NewRemote(&model.Node{
- Id: 1, Name: "contract-node", Scheme: "http", Address: u.Hostname(), Port: port,
- BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
- }, nil)
- }
- func nodeRow(t *testing.T, tag string) (*model.Inbound, bool) {
- t.Helper()
- var ib model.Inbound
- err := database.GetDB().Where("tag = ?", tag).First(&ib).Error
- return &ib, err == nil
- }
- func nodeTraffic(t *testing.T, email string) int64 {
- t.Helper()
- var ct xray.ClientTraffic
- if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
- t.Fatalf("client_traffics %s: %v", email, err)
- }
- return ct.Up + ct.Down
- }
- func seedNodeTraffic(t *testing.T, emails ...string) {
- t.Helper()
- for _, e := range emails {
- if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", e).
- Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
- t.Fatalf("seed traffic %s: %v", e, err)
- }
- }
- if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", contractTag).
- Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
- t.Fatalf("seed inbound traffic: %v", err)
- }
- }
- const contractTag = "in-51001-tcp"
- func masterInbound(remark string, enable bool, clients ...string) *model.Inbound {
- entries := make([]string, 0, len(clients))
- for i, email := range clients {
- entries = append(entries, `{"email":"`+email+`","enable":true,"subId":"s-`+email+
- `","id":"0b6d5c2e-7c1a-4f4e-9d3b-00000000000`+strconv.Itoa(i)+`"}`)
- }
- return &model.Inbound{
- Tag: contractTag, Remark: remark, Enable: enable, Port: 51001, Protocol: model.VLESS,
- Settings: `{"clients":[` + strings.Join(entries, ",") + `],"decryption":"none"}`,
- StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
- Sniffing: `{}`,
- }
- }
- func nodeEmails(t *testing.T) []string {
- t.Helper()
- ib, ok := nodeRow(t, contractTag)
- if !ok {
- t.Fatal("node has no contract inbound")
- }
- clients, err := (&service.InboundService{}).GetClients(ib)
- if err != nil {
- t.Fatalf("parse node clients: %v", err)
- }
- emails := make([]string, 0, len(clients))
- for _, c := range clients {
- emails = append(emails, c.Email)
- }
- return emails
- }
- // TestMasterNodeContract sends every node call the master makes through the production
- // router, once per enrollment scope; UpdatePanel is excluded from node-sync on purpose.
- func TestMasterNodeContract(t *testing.T) {
- for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
- t.Run(scope, func(t *testing.T) {
- node := startContractNode(t)
- master := node.masterWithToken(t, scope)
- ctx := context.Background()
- cells := []struct {
- name string
- covers []string
- run func() error
- check func(t *testing.T)
- }{
- {"AddInbound creates the inbound with its clients", []string{"AddInbound"}, func() error {
- return master.AddInbound(ctx, masterInbound("added", true, "c0", "c1"))
- }, func(t *testing.T) {
- if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1" {
- t.Fatalf("node clients = %v, want c0,c1", got)
- }
- }},
- {"UpdateInbound applies remark, clients and enable", []string{"UpdateInbound", "AddUser", "RemoveUser", "ReconcileInbound"}, func() error {
- ib := masterInbound("updated", false, "c0", "c1", "c2")
- if err := master.AddUser(ctx, ib, nil); err != nil {
- return err
- }
- if err := master.RemoveUser(ctx, ib, ""); err != nil {
- return err
- }
- if _, err := master.ReconcileInbound(ctx, ib, true); err != nil {
- return err
- }
- return master.UpdateInbound(ctx, ib, ib)
- }, func(t *testing.T) {
- ib, _ := nodeRow(t, contractTag)
- if ib.Remark != "updated" || ib.Enable {
- t.Fatalf("node remark=%q enable=%v, want updated/false", ib.Remark, ib.Enable)
- }
- if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1,c2" {
- t.Fatalf("node clients = %v, want c0,c1,c2", got)
- }
- }},
- {"SetInboundSubSortIndex reaches the node", []string{"SetInboundSubSortIndex"}, func() error {
- return master.SetInboundSubSortIndex(ctx, masterInbound("updated", false), 7)
- }, func(t *testing.T) {
- if ib, _ := nodeRow(t, contractTag); ib.SubSortIndex != 7 {
- t.Fatalf("node subSortIndex = %d, want 7", ib.SubSortIndex)
- }
- }},
- {"AddClient attaches one client", []string{"AddClient"}, func() error {
- return master.AddClient(ctx, masterInbound("updated", false), model.Client{
- Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true,
- })
- }, func(t *testing.T) {
- if got := nodeEmails(t); !strings.Contains(strings.Join(got, ","), "c3") {
- t.Fatalf("node clients = %v, want c3 among them", got)
- }
- }},
- {"UpdateUser changes the client's limits", []string{"UpdateUser"}, func() error {
- return master.UpdateUser(ctx, masterInbound("updated", false), "c3", model.Client{
- Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true, TotalGB: 5 << 30,
- })
- }, func(t *testing.T) {
- var ct xray.ClientTraffic
- database.GetDB().Where("email = ?", "c3").First(&ct)
- if ct.Total != 5<<30 {
- t.Fatalf("node c3 total = %d, want %d", ct.Total, int64(5<<30))
- }
- }},
- {"ResetClientTraffic zeroes one client", []string{"ResetClientTraffic"}, func() error {
- seedNodeTraffic(t, "c0")
- return master.ResetClientTraffic(ctx, nil, "c0")
- }, func(t *testing.T) {
- if u := nodeTraffic(t, "c0"); u != 0 {
- t.Fatalf("node c0 usage = %d, want 0", u)
- }
- }},
- {"ResetClientTraffics zeroes several clients", []string{"ResetClientTraffics"}, func() error {
- seedNodeTraffic(t, "c1", "c2")
- return master.ResetClientTraffics(ctx, []string{"c1", "c2"})
- }, func(t *testing.T) {
- if u := nodeTraffic(t, "c1") + nodeTraffic(t, "c2"); u != 0 {
- t.Fatalf("node c1+c2 usage = %d, want 0", u)
- }
- }},
- {"ResetInboundTraffic zeroes the inbound", []string{"ResetInboundTraffic"}, func() error {
- seedNodeTraffic(t)
- return master.ResetInboundTraffic(ctx, masterInbound("updated", false))
- }, func(t *testing.T) {
- if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
- t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
- }
- }},
- {"ResetAllTraffics zeroes every inbound's counters", []string{"ResetAllTraffics"}, func() error {
- seedNodeTraffic(t)
- return master.ResetAllTraffics(ctx)
- }, func(t *testing.T) {
- if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
- t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
- }
- }},
- {"FetchTrafficSnapshot reads every part of the snapshot", []string{"FetchTrafficSnapshot"}, func() error {
- _, err := master.FetchTrafficSnapshot(ctx)
- return err
- }, nil},
- {"PushGlobalClientTraffics is accepted", []string{"PushGlobalClientTraffics"}, func() error {
- return master.PushGlobalClientTraffics(ctx, "master-guid", []*xray.ClientTraffic{{Email: "c0", Up: 1, Down: 2}})
- }, nil},
- {"client IP sync is accepted both ways", []string{"FetchAllClientIps", "PushAllClientIps", "FetchClientIpsByGuid"}, func() error {
- ips, err := master.FetchAllClientIps(ctx)
- if err != nil {
- return err
- }
- if err := master.PushAllClientIps(ctx, ips); err != nil {
- return err
- }
- _, err = master.FetchClientIpsByGuid(ctx)
- return err
- }, nil},
- {"host groups, descendants and web cert files are readable", []string{"FetchHostGroups", "GetDescendants", "GetWebCertFiles", "ListInboundOptions", "ListRemoteTags"}, func() error {
- if _, err := master.FetchHostGroups(ctx); err != nil {
- return err
- }
- if _, err := master.GetDescendants(ctx); err != nil {
- return err
- }
- if _, err := master.GetWebCertFiles(ctx); err != nil {
- return err
- }
- if _, err := master.ListInboundOptions(ctx); err != nil {
- return err
- }
- _, err := master.ListRemoteTags(ctx)
- return err
- }, nil},
- {"RestartXray is accepted by the node", []string{"RestartXray"}, func() error {
- // No core binary here: only the node's own restart failure may come back.
- if err := master.RestartXray(ctx); err != nil && !strings.Contains(err.Error(), "rebooting the Xray") {
- return err
- }
- return nil
- }, nil},
- {"DeleteUser detaches the client from the inbound", []string{"DeleteUser"}, func() error {
- return master.DeleteUser(ctx, masterInbound("updated", false), "c3")
- }, func(t *testing.T) {
- if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c3") {
- t.Fatalf("node clients = %v, want c3 gone", got)
- }
- }},
- {"DeleteClient removes the client everywhere", []string{"DeleteClient"}, func() error {
- return master.DeleteClient(ctx, "c2")
- }, func(t *testing.T) {
- if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c2") {
- t.Fatalf("node clients = %v, want c2 gone", got)
- }
- }},
- {"DelInbound removes the inbound", []string{"DelInbound"}, func() error {
- return master.DelInbound(ctx, masterInbound("updated", false))
- }, func(t *testing.T) {
- if _, ok := nodeRow(t, contractTag); ok {
- t.Fatal("node still has the inbound")
- }
- }},
- }
- covered := map[string]bool{}
- for _, c := range cells {
- for _, m := range c.covers {
- covered[m] = true
- }
- }
- assertEveryRemoteCallCovered(t, covered)
- for _, c := range cells {
- t.Run(c.name, func(t *testing.T) {
- node.takeRefused()
- if err := c.run(); err != nil {
- t.Fatalf("master call failed: %v", err)
- }
- if refused := node.takeRefused(); len(refused) != 0 {
- t.Fatalf("node refused master requests: %v", refused)
- }
- if c.check != nil {
- c.check(t)
- }
- })
- }
- })
- }
- }
- // Remote methods that never reach the node, or that this table must not run.
- var remoteMethodsOutsideContract = map[string]string{
- "Name": "local label",
- "RecordAdoptedInbound": "local fingerprint bookkeeping",
- "AdoptInboundAlias": "local alias bookkeeping",
- "AdoptedInboundAliases": "local alias bookkeeping",
- "AdvancePushedInbound": "local fingerprint bookkeeping",
- "UpdatePanel": "replaces the node binary; node-sync is denied it on purpose (#6201)",
- }
- // A Remote method with no cell is how activeInbounds and bulkResetTraffic
- // drifted out of the node-sync allowlist unnoticed.
- func assertEveryRemoteCallCovered(t *testing.T, covered map[string]bool) {
- t.Helper()
- rt := reflect.TypeOf(&runtime.Remote{})
- for i := 0; i < rt.NumMethod(); i++ {
- name := rt.Method(i).Name
- if _, skip := remoteMethodsOutsideContract[name]; skip {
- continue
- }
- if !covered[name] {
- t.Errorf("runtime.Remote.%s has no cell in TestMasterNodeContract", name)
- }
- }
- }
|