1
0

node_contract_test.go 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382
  1. package web
  2. import (
  3. "context"
  4. "net/http"
  5. "net/http/httptest"
  6. "net/url"
  7. "path/filepath"
  8. "reflect"
  9. "strconv"
  10. "strings"
  11. "sync"
  12. "testing"
  13. "time"
  14. "github.com/robfig/cron/v3"
  15. "github.com/mhsanaei/3x-ui/v3/internal/database"
  16. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  17. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  18. "github.com/mhsanaei/3x-ui/v3/internal/util/crypto"
  19. "github.com/mhsanaei/3x-ui/v3/internal/web/global"
  20. "github.com/mhsanaei/3x-ui/v3/internal/web/runtime"
  21. "github.com/mhsanaei/3x-ui/v3/internal/web/service"
  22. "github.com/mhsanaei/3x-ui/v3/internal/xray"
  23. )
  24. // nodeUnderContract serves the production router as a node and records every
  25. // request the node refused for auth or scope.
  26. type nodeUnderContract struct {
  27. srv *httptest.Server
  28. mu sync.Mutex
  29. refused []string
  30. }
  31. func startContractNode(t *testing.T) *nodeUnderContract {
  32. t.Helper()
  33. dbDir := t.TempDir()
  34. t.Setenv("XUI_DB_FOLDER", dbDir)
  35. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  36. prevMgr := runtime.GetManager()
  37. runtime.SetManager(runtime.NewManager(runtime.LocalDeps{APIPort: func() int { return 0 }, SetNeedRestart: func() {}}))
  38. t.Cleanup(func() { runtime.SetManager(prevMgr) })
  39. previous := global.GetWebServer()
  40. s := NewServer()
  41. s.cron = cron.New(cron.WithLocation(time.Local), cron.WithSeconds())
  42. global.SetWebServer(s)
  43. t.Cleanup(func() {
  44. s.cancel()
  45. global.SetWebServer(previous)
  46. })
  47. engine, err := s.initRouter()
  48. if err != nil {
  49. t.Fatalf("initRouter: %v", err)
  50. }
  51. n := &nodeUnderContract{}
  52. n.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
  53. rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
  54. engine.ServeHTTP(rec, r)
  55. if rec.status == http.StatusUnauthorized || rec.status == http.StatusForbidden {
  56. n.mu.Lock()
  57. n.refused = append(n.refused, r.Method+" "+r.URL.Path+" -> "+strconv.Itoa(rec.status))
  58. n.mu.Unlock()
  59. }
  60. }))
  61. t.Cleanup(n.srv.Close)
  62. return n
  63. }
  64. type statusRecorder struct {
  65. http.ResponseWriter
  66. status int
  67. }
  68. func (r *statusRecorder) WriteHeader(code int) {
  69. r.status = code
  70. r.ResponseWriter.WriteHeader(code)
  71. }
  72. func (n *nodeUnderContract) takeRefused() []string {
  73. n.mu.Lock()
  74. defer n.mu.Unlock()
  75. out := n.refused
  76. n.refused = nil
  77. return out
  78. }
  79. func (n *nodeUnderContract) masterWithToken(t *testing.T, scope string) *runtime.Remote {
  80. t.Helper()
  81. token := "contract-" + scope
  82. if err := database.GetDB().Create(&model.ApiToken{
  83. Name: "master-" + scope, Token: crypto.HashTokenSHA256(token), Enabled: true, Scope: scope,
  84. }).Error; err != nil {
  85. t.Fatalf("seed %s token: %v", scope, err)
  86. }
  87. u, _ := url.Parse(n.srv.URL)
  88. port, _ := strconv.Atoi(u.Port())
  89. return runtime.NewRemote(&model.Node{
  90. Id: 1, Name: "contract-node", Scheme: "http", Address: u.Hostname(), Port: port,
  91. BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true,
  92. }, nil)
  93. }
  94. func nodeRow(t *testing.T, tag string) (*model.Inbound, bool) {
  95. t.Helper()
  96. var ib model.Inbound
  97. err := database.GetDB().Where("tag = ?", tag).First(&ib).Error
  98. return &ib, err == nil
  99. }
  100. func nodeTraffic(t *testing.T, email string) int64 {
  101. t.Helper()
  102. var ct xray.ClientTraffic
  103. if err := database.GetDB().Where("email = ?", email).First(&ct).Error; err != nil {
  104. t.Fatalf("client_traffics %s: %v", email, err)
  105. }
  106. return ct.Up + ct.Down
  107. }
  108. func seedNodeTraffic(t *testing.T, emails ...string) {
  109. t.Helper()
  110. for _, e := range emails {
  111. if err := database.GetDB().Model(&xray.ClientTraffic{}).Where("email = ?", e).
  112. Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
  113. t.Fatalf("seed traffic %s: %v", e, err)
  114. }
  115. }
  116. if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", contractTag).
  117. Updates(map[string]any{"up": 100, "down": 200}).Error; err != nil {
  118. t.Fatalf("seed inbound traffic: %v", err)
  119. }
  120. }
  121. const contractTag = "in-51001-tcp"
  122. func masterInbound(remark string, enable bool, clients ...string) *model.Inbound {
  123. entries := make([]string, 0, len(clients))
  124. for i, email := range clients {
  125. entries = append(entries, `{"email":"`+email+`","enable":true,"subId":"s-`+email+
  126. `","id":"0b6d5c2e-7c1a-4f4e-9d3b-00000000000`+strconv.Itoa(i)+`"}`)
  127. }
  128. return &model.Inbound{
  129. Tag: contractTag, Remark: remark, Enable: enable, Port: 51001, Protocol: model.VLESS,
  130. Settings: `{"clients":[` + strings.Join(entries, ",") + `],"decryption":"none"}`,
  131. StreamSettings: `{"network":"tcp","security":"none","tcpSettings":{"header":{"type":"none"}}}`,
  132. Sniffing: `{}`,
  133. }
  134. }
  135. func nodeEmails(t *testing.T) []string {
  136. t.Helper()
  137. ib, ok := nodeRow(t, contractTag)
  138. if !ok {
  139. t.Fatal("node has no contract inbound")
  140. }
  141. clients, err := (&service.InboundService{}).GetClients(ib)
  142. if err != nil {
  143. t.Fatalf("parse node clients: %v", err)
  144. }
  145. emails := make([]string, 0, len(clients))
  146. for _, c := range clients {
  147. emails = append(emails, c.Email)
  148. }
  149. return emails
  150. }
  151. // TestMasterNodeContract sends every node call the master makes through the production
  152. // router, once per enrollment scope; UpdatePanel is excluded from node-sync on purpose.
  153. func TestMasterNodeContract(t *testing.T) {
  154. for _, scope := range []string{model.ApiScopeAdmin, model.ApiScopeNodeSync} {
  155. t.Run(scope, func(t *testing.T) {
  156. node := startContractNode(t)
  157. master := node.masterWithToken(t, scope)
  158. ctx := context.Background()
  159. cells := []struct {
  160. name string
  161. covers []string
  162. run func() error
  163. check func(t *testing.T)
  164. }{
  165. {"AddInbound creates the inbound with its clients", []string{"AddInbound"}, func() error {
  166. return master.AddInbound(ctx, masterInbound("added", true, "c0", "c1"))
  167. }, func(t *testing.T) {
  168. if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1" {
  169. t.Fatalf("node clients = %v, want c0,c1", got)
  170. }
  171. }},
  172. {"UpdateInbound applies remark, clients and enable", []string{"UpdateInbound", "AddUser", "RemoveUser", "ReconcileInbound"}, func() error {
  173. ib := masterInbound("updated", false, "c0", "c1", "c2")
  174. if err := master.AddUser(ctx, ib, nil); err != nil {
  175. return err
  176. }
  177. if err := master.RemoveUser(ctx, ib, ""); err != nil {
  178. return err
  179. }
  180. if _, err := master.ReconcileInbound(ctx, ib, true); err != nil {
  181. return err
  182. }
  183. return master.UpdateInbound(ctx, ib, ib)
  184. }, func(t *testing.T) {
  185. ib, _ := nodeRow(t, contractTag)
  186. if ib.Remark != "updated" || ib.Enable {
  187. t.Fatalf("node remark=%q enable=%v, want updated/false", ib.Remark, ib.Enable)
  188. }
  189. if got := nodeEmails(t); strings.Join(got, ",") != "c0,c1,c2" {
  190. t.Fatalf("node clients = %v, want c0,c1,c2", got)
  191. }
  192. }},
  193. {"SetInboundSubSortIndex reaches the node", []string{"SetInboundSubSortIndex"}, func() error {
  194. return master.SetInboundSubSortIndex(ctx, masterInbound("updated", false), 7)
  195. }, func(t *testing.T) {
  196. if ib, _ := nodeRow(t, contractTag); ib.SubSortIndex != 7 {
  197. t.Fatalf("node subSortIndex = %d, want 7", ib.SubSortIndex)
  198. }
  199. }},
  200. {"AddClient attaches one client", []string{"AddClient"}, func() error {
  201. return master.AddClient(ctx, masterInbound("updated", false), model.Client{
  202. Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true,
  203. })
  204. }, func(t *testing.T) {
  205. if got := nodeEmails(t); !strings.Contains(strings.Join(got, ","), "c3") {
  206. t.Fatalf("node clients = %v, want c3 among them", got)
  207. }
  208. }},
  209. {"UpdateUser changes the client's limits", []string{"UpdateUser"}, func() error {
  210. return master.UpdateUser(ctx, masterInbound("updated", false), "c3", model.Client{
  211. Email: "c3", ID: "0b6d5c2e-7c1a-4f4e-9d3b-000000000003", SubID: "s-c3", Enable: true, TotalGB: 5 << 30,
  212. })
  213. }, func(t *testing.T) {
  214. var ct xray.ClientTraffic
  215. database.GetDB().Where("email = ?", "c3").First(&ct)
  216. if ct.Total != 5<<30 {
  217. t.Fatalf("node c3 total = %d, want %d", ct.Total, int64(5<<30))
  218. }
  219. }},
  220. {"ResetClientTraffic zeroes one client", []string{"ResetClientTraffic"}, func() error {
  221. seedNodeTraffic(t, "c0")
  222. return master.ResetClientTraffic(ctx, nil, "c0")
  223. }, func(t *testing.T) {
  224. if u := nodeTraffic(t, "c0"); u != 0 {
  225. t.Fatalf("node c0 usage = %d, want 0", u)
  226. }
  227. }},
  228. {"ResetClientTraffics zeroes several clients", []string{"ResetClientTraffics"}, func() error {
  229. seedNodeTraffic(t, "c1", "c2")
  230. return master.ResetClientTraffics(ctx, []string{"c1", "c2"})
  231. }, func(t *testing.T) {
  232. if u := nodeTraffic(t, "c1") + nodeTraffic(t, "c2"); u != 0 {
  233. t.Fatalf("node c1+c2 usage = %d, want 0", u)
  234. }
  235. }},
  236. {"ResetInboundTraffic zeroes the inbound", []string{"ResetInboundTraffic"}, func() error {
  237. seedNodeTraffic(t)
  238. return master.ResetInboundTraffic(ctx, masterInbound("updated", false))
  239. }, func(t *testing.T) {
  240. if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
  241. t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
  242. }
  243. }},
  244. {"ResetAllTraffics zeroes every inbound's counters", []string{"ResetAllTraffics"}, func() error {
  245. seedNodeTraffic(t)
  246. return master.ResetAllTraffics(ctx)
  247. }, func(t *testing.T) {
  248. if ib, _ := nodeRow(t, contractTag); ib.Up+ib.Down != 0 {
  249. t.Fatalf("node inbound usage = %d, want 0", ib.Up+ib.Down)
  250. }
  251. }},
  252. {"FetchTrafficSnapshot reads every part of the snapshot", []string{"FetchTrafficSnapshot"}, func() error {
  253. _, err := master.FetchTrafficSnapshot(ctx)
  254. return err
  255. }, nil},
  256. {"PushGlobalClientTraffics is accepted", []string{"PushGlobalClientTraffics"}, func() error {
  257. return master.PushGlobalClientTraffics(ctx, "master-guid", []*xray.ClientTraffic{{Email: "c0", Up: 1, Down: 2}})
  258. }, nil},
  259. {"client IP sync is accepted both ways", []string{"FetchAllClientIps", "PushAllClientIps", "FetchClientIpsByGuid"}, func() error {
  260. ips, err := master.FetchAllClientIps(ctx)
  261. if err != nil {
  262. return err
  263. }
  264. if err := master.PushAllClientIps(ctx, ips); err != nil {
  265. return err
  266. }
  267. _, err = master.FetchClientIpsByGuid(ctx)
  268. return err
  269. }, nil},
  270. {"host groups, descendants and web cert files are readable", []string{"FetchHostGroups", "GetDescendants", "GetWebCertFiles", "ListInboundOptions", "ListRemoteTags"}, func() error {
  271. if _, err := master.FetchHostGroups(ctx); err != nil {
  272. return err
  273. }
  274. if _, err := master.GetDescendants(ctx); err != nil {
  275. return err
  276. }
  277. if _, err := master.GetWebCertFiles(ctx); err != nil {
  278. return err
  279. }
  280. if _, err := master.ListInboundOptions(ctx); err != nil {
  281. return err
  282. }
  283. _, err := master.ListRemoteTags(ctx)
  284. return err
  285. }, nil},
  286. {"RestartXray is accepted by the node", []string{"RestartXray"}, func() error {
  287. // No core binary here: only the node's own restart failure may come back.
  288. if err := master.RestartXray(ctx); err != nil && !strings.Contains(err.Error(), "rebooting the Xray") {
  289. return err
  290. }
  291. return nil
  292. }, nil},
  293. {"DeleteUser detaches the client from the inbound", []string{"DeleteUser"}, func() error {
  294. return master.DeleteUser(ctx, masterInbound("updated", false), "c3")
  295. }, func(t *testing.T) {
  296. if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c3") {
  297. t.Fatalf("node clients = %v, want c3 gone", got)
  298. }
  299. }},
  300. {"DeleteClient removes the client everywhere", []string{"DeleteClient"}, func() error {
  301. return master.DeleteClient(ctx, "c2")
  302. }, func(t *testing.T) {
  303. if got := nodeEmails(t); strings.Contains(strings.Join(got, ","), "c2") {
  304. t.Fatalf("node clients = %v, want c2 gone", got)
  305. }
  306. }},
  307. {"DelInbound removes the inbound", []string{"DelInbound"}, func() error {
  308. return master.DelInbound(ctx, masterInbound("updated", false))
  309. }, func(t *testing.T) {
  310. if _, ok := nodeRow(t, contractTag); ok {
  311. t.Fatal("node still has the inbound")
  312. }
  313. }},
  314. }
  315. covered := map[string]bool{}
  316. for _, c := range cells {
  317. for _, m := range c.covers {
  318. covered[m] = true
  319. }
  320. }
  321. assertEveryRemoteCallCovered(t, covered)
  322. for _, c := range cells {
  323. t.Run(c.name, func(t *testing.T) {
  324. node.takeRefused()
  325. if err := c.run(); err != nil {
  326. t.Fatalf("master call failed: %v", err)
  327. }
  328. if refused := node.takeRefused(); len(refused) != 0 {
  329. t.Fatalf("node refused master requests: %v", refused)
  330. }
  331. if c.check != nil {
  332. c.check(t)
  333. }
  334. })
  335. }
  336. })
  337. }
  338. }
  339. // Remote methods that never reach the node, or that this table must not run.
  340. var remoteMethodsOutsideContract = map[string]string{
  341. "Name": "local label",
  342. "RecordAdoptedInbound": "local fingerprint bookkeeping",
  343. "AdoptInboundAlias": "local alias bookkeeping",
  344. "AdoptedInboundAliases": "local alias bookkeeping",
  345. "AdvancePushedInbound": "local fingerprint bookkeeping",
  346. "UpdatePanel": "replaces the node binary; node-sync is denied it on purpose (#6201)",
  347. }
  348. // A Remote method with no cell is how activeInbounds and bulkResetTraffic
  349. // drifted out of the node-sync allowlist unnoticed.
  350. func assertEveryRemoteCallCovered(t *testing.T, covered map[string]bool) {
  351. t.Helper()
  352. rt := reflect.TypeOf(&runtime.Remote{})
  353. for i := 0; i < rt.NumMethod(); i++ {
  354. name := rt.Method(i).Name
  355. if _, skip := remoteMethodsOutsideContract[name]; skip {
  356. continue
  357. }
  358. if !covered[name] {
  359. t.Errorf("runtime.Remote.%s has no cell in TestMasterNodeContract", name)
  360. }
  361. }
  362. }