check_client_ip_job_integration_test.go 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. package job
  2. import (
  3. "encoding/json"
  4. "log"
  5. "os"
  6. "path/filepath"
  7. "sync"
  8. "testing"
  9. "time"
  10. "github.com/mhsanaei/3x-ui/v2/database"
  11. "github.com/mhsanaei/3x-ui/v2/database/model"
  12. xuilogger "github.com/mhsanaei/3x-ui/v2/logger"
  13. "github.com/op/go-logging"
  14. )
  15. // 3x-ui logger must be initialised once before any code path that can
  16. // log a warning. otherwise log.Warningf panics on a nil logger.
  17. var loggerInitOnce sync.Once
  18. // setupIntegrationDB wires a temp sqlite db and log folder so
  19. // updateInboundClientIps can run end to end. closes the db before
  20. // TempDir cleanup so windows doesn't complain about the file being in
  21. // use.
  22. func setupIntegrationDB(t *testing.T) {
  23. t.Helper()
  24. loggerInitOnce.Do(func() {
  25. xuilogger.InitLogger(logging.ERROR)
  26. })
  27. dbDir := t.TempDir()
  28. logDir := t.TempDir()
  29. t.Setenv("XUI_DB_FOLDER", dbDir)
  30. t.Setenv("XUI_LOG_FOLDER", logDir)
  31. // updateInboundClientIps calls log.SetOutput on the package global,
  32. // which would leak to other tests in the same binary.
  33. origLogWriter := log.Writer()
  34. origLogFlags := log.Flags()
  35. t.Cleanup(func() {
  36. log.SetOutput(origLogWriter)
  37. log.SetFlags(origLogFlags)
  38. })
  39. if err := database.InitDB(filepath.Join(dbDir, "3x-ui.db")); err != nil {
  40. t.Fatalf("database.InitDB failed: %v", err)
  41. }
  42. // LIFO cleanup order: this runs before t.TempDir's own cleanup.
  43. t.Cleanup(func() {
  44. if err := database.CloseDB(); err != nil {
  45. t.Logf("database.CloseDB warning: %v", err)
  46. }
  47. })
  48. }
  49. // seed an inbound whose settings json has a single client with the
  50. // given email and ip limit.
  51. func seedInboundWithClient(t *testing.T, tag, email string, limitIp int) {
  52. t.Helper()
  53. settings := map[string]any{
  54. "clients": []map[string]any{
  55. {
  56. "email": email,
  57. "limitIp": limitIp,
  58. "enable": true,
  59. },
  60. },
  61. }
  62. settingsJSON, err := json.Marshal(settings)
  63. if err != nil {
  64. t.Fatalf("marshal settings: %v", err)
  65. }
  66. inbound := &model.Inbound{
  67. Tag: tag,
  68. Enable: true,
  69. Protocol: model.VLESS,
  70. Port: 4321,
  71. Settings: string(settingsJSON),
  72. }
  73. if err := database.GetDB().Create(inbound).Error; err != nil {
  74. t.Fatalf("seed inbound: %v", err)
  75. }
  76. }
  77. // seed an InboundClientIps row with the given blob.
  78. func seedClientIps(t *testing.T, email string, ips []IPWithTimestamp) *model.InboundClientIps {
  79. t.Helper()
  80. blob, err := json.Marshal(ips)
  81. if err != nil {
  82. t.Fatalf("marshal ips: %v", err)
  83. }
  84. row := &model.InboundClientIps{
  85. ClientEmail: email,
  86. Ips: string(blob),
  87. }
  88. if err := database.GetDB().Create(row).Error; err != nil {
  89. t.Fatalf("seed InboundClientIps: %v", err)
  90. }
  91. return row
  92. }
  93. // read the persisted blob and parse it back.
  94. func readClientIps(t *testing.T, email string) []IPWithTimestamp {
  95. t.Helper()
  96. row := &model.InboundClientIps{}
  97. if err := database.GetDB().Where("client_email = ?", email).First(row).Error; err != nil {
  98. t.Fatalf("read InboundClientIps for %s: %v", email, err)
  99. }
  100. if row.Ips == "" {
  101. return nil
  102. }
  103. var out []IPWithTimestamp
  104. if err := json.Unmarshal([]byte(row.Ips), &out); err != nil {
  105. t.Fatalf("unmarshal Ips blob %q: %v", row.Ips, err)
  106. }
  107. return out
  108. }
  109. // make a lookup map so asserts don't depend on slice order.
  110. func ipSet(entries []IPWithTimestamp) map[string]int64 {
  111. out := make(map[string]int64, len(entries))
  112. for _, e := range entries {
  113. out[e.IP] = e.Timestamp
  114. }
  115. return out
  116. }
  117. // #4091 repro: client has limit=3, db still holds 3 idle ips from a
  118. // few minutes ago, only one live ip is actually connecting. pre-fix:
  119. // live ip got banned every tick and never appeared in the panel.
  120. // post-fix: no ban, live ip persisted, historical ips still visible.
  121. func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testing.T) {
  122. setupIntegrationDB(t)
  123. const email = "pr4091-repro"
  124. seedInboundWithClient(t, "inbound-pr4091", email, 3)
  125. now := time.Now().Unix()
  126. // idle but still within the 30min staleness window.
  127. row := seedClientIps(t, email, []IPWithTimestamp{
  128. {IP: "10.0.0.1", Timestamp: now - 20*60},
  129. {IP: "10.0.0.2", Timestamp: now - 15*60},
  130. {IP: "10.0.0.3", Timestamp: now - 10*60},
  131. })
  132. j := NewCheckClientIpJob()
  133. // the one that's actually connecting (user's 128.71.x.x).
  134. live := []IPWithTimestamp{
  135. {IP: "128.71.1.1", Timestamp: now},
  136. }
  137. shouldCleanLog := j.updateInboundClientIps(row, email, live)
  138. if shouldCleanLog {
  139. t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
  140. }
  141. if len(j.disAllowedIps) != 0 {
  142. t.Fatalf("disAllowedIps must be empty, got %v", j.disAllowedIps)
  143. }
  144. persisted := ipSet(readClientIps(t, email))
  145. for _, want := range []string{"128.71.1.1", "10.0.0.1", "10.0.0.2", "10.0.0.3"} {
  146. if _, ok := persisted[want]; !ok {
  147. t.Errorf("expected %s to be persisted in inbound_client_ips.ips; got %v", want, persisted)
  148. }
  149. }
  150. if got := persisted["128.71.1.1"]; got != now {
  151. t.Errorf("live ip timestamp should match the scan timestamp %d, got %d", now, got)
  152. }
  153. // 3xipl.log must not contain a ban line.
  154. if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
  155. body, _ := os.ReadFile(readIpLimitLogPath())
  156. t.Fatalf("3xipl.log should be empty when no ips are banned, got:\n%s", body)
  157. }
  158. }
  159. // opposite invariant: when several ips are actually live and exceed
  160. // the limit, the newcomer still gets banned.
  161. func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
  162. setupIntegrationDB(t)
  163. const email = "pr4091-abuse"
  164. seedInboundWithClient(t, "inbound-pr4091-abuse", email, 1)
  165. now := time.Now().Unix()
  166. row := seedClientIps(t, email, []IPWithTimestamp{
  167. {IP: "10.1.0.1", Timestamp: now - 60}, // original connection
  168. })
  169. j := NewCheckClientIpJob()
  170. // both live, limit=1. use distinct timestamps so sort-by-timestamp
  171. // is deterministic: 10.1.0.1 is the original (older), 192.0.2.9
  172. // joined later and must get banned.
  173. live := []IPWithTimestamp{
  174. {IP: "10.1.0.1", Timestamp: now - 5},
  175. {IP: "192.0.2.9", Timestamp: now},
  176. }
  177. shouldCleanLog := j.updateInboundClientIps(row, email, live)
  178. if !shouldCleanLog {
  179. t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
  180. }
  181. if len(j.disAllowedIps) != 1 || j.disAllowedIps[0] != "192.0.2.9" {
  182. t.Fatalf("expected 192.0.2.9 to be banned; disAllowedIps = %v", j.disAllowedIps)
  183. }
  184. persisted := ipSet(readClientIps(t, email))
  185. if _, ok := persisted["10.1.0.1"]; !ok {
  186. t.Errorf("original IP 10.1.0.1 must still be persisted; got %v", persisted)
  187. }
  188. if _, ok := persisted["192.0.2.9"]; ok {
  189. t.Errorf("banned IP 192.0.2.9 must NOT be persisted; got %v", persisted)
  190. }
  191. // 3xipl.log must contain the ban line in the exact fail2ban format.
  192. body, err := os.ReadFile(readIpLimitLogPath())
  193. if err != nil {
  194. t.Fatalf("read 3xipl.log: %v", err)
  195. }
  196. wantSubstr := "[LIMIT_IP] Email = pr4091-abuse || Disconnecting OLD IP = 192.0.2.9"
  197. if !contains(string(body), wantSubstr) {
  198. t.Fatalf("3xipl.log missing expected ban line %q\nfull log:\n%s", wantSubstr, body)
  199. }
  200. }
  201. // readIpLimitLogPath reads the 3xipl.log path the same way the job
  202. // does via xray.GetIPLimitLogPath but without importing xray here
  203. // just for the path helper (which would pull a lot more deps into the
  204. // test binary). The env-derived log folder is deterministic.
  205. func readIpLimitLogPath() string {
  206. folder := os.Getenv("XUI_LOG_FOLDER")
  207. if folder == "" {
  208. folder = filepath.Join(".", "log")
  209. }
  210. return filepath.Join(folder, "3xipl.log")
  211. }
  212. func contains(haystack, needle string) bool {
  213. for i := 0; i+len(needle) <= len(haystack); i++ {
  214. if haystack[i:i+len(needle)] == needle {
  215. return true
  216. }
  217. }
  218. return false
  219. }