check_client_ip_job_test.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. package job
  2. import (
  3. "os"
  4. "path/filepath"
  5. "reflect"
  6. "runtime"
  7. "testing"
  8. "time"
  9. "github.com/mhsanaei/3x-ui/v3/internal/database"
  10. "github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
  11. "github.com/mhsanaei/3x-ui/v3/internal/database/model"
  12. )
  13. func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
  14. // #4077: after a ban expires, a single IP that reconnects used to get
  15. // banned again immediately because a long-disconnected IP stayed in the
  16. // DB with an ancient timestamp and kept "protecting" itself against
  17. // eviction. Guard against that regression here.
  18. old := []IPWithTimestamp{
  19. {IP: "1.1.1.1", Timestamp: 100}, // stale — client disconnected long ago
  20. {IP: "2.2.2.2", Timestamp: 1900}, // fresh — still connecting
  21. }
  22. new := []IPWithTimestamp{
  23. {IP: "2.2.2.2", Timestamp: 2000}, // same IP, newer log line
  24. }
  25. got := mergeClientIps(old, new, 1000, false)
  26. want := map[string]int64{"2.2.2.2": 2000}
  27. if !reflect.DeepEqual(got, want) {
  28. t.Fatalf("stale 1.1.1.1 should have been dropped\ngot: %v\nwant: %v", got, want)
  29. }
  30. }
  31. func TestMergeClientIps_KeepsFreshOldEntriesUnchanged(t *testing.T) {
  32. // Backwards-compat: entries that aren't stale are still carried forward,
  33. // so enforcement survives access-log rotation.
  34. old := []IPWithTimestamp{
  35. {IP: "1.1.1.1", Timestamp: 1500},
  36. }
  37. got := mergeClientIps(old, nil, 1000, false)
  38. want := map[string]int64{"1.1.1.1": 1500}
  39. if !reflect.DeepEqual(got, want) {
  40. t.Fatalf("fresh old IP should have been retained\ngot: %v\nwant: %v", got, want)
  41. }
  42. }
  43. func TestMergeClientIps_PrefersLaterTimestampForSameIp(t *testing.T) {
  44. old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1500}}
  45. new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 1700}}
  46. got := mergeClientIps(old, new, 1000, false)
  47. if got["1.1.1.1"] != 1700 {
  48. t.Fatalf("expected latest timestamp 1700, got %d", got["1.1.1.1"])
  49. }
  50. }
  51. func TestMergeClientIps_DropsStaleNewEntries(t *testing.T) {
  52. // A log line with a clock-skewed old timestamp must not resurrect a
  53. // stale IP past the cutoff.
  54. new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}}
  55. got := mergeClientIps(nil, new, 1000, false)
  56. if len(got) != 0 {
  57. t.Fatalf("stale new IP should have been dropped, got %v", got)
  58. }
  59. }
  60. func TestMergeClientIps_NoStaleCutoffStillWorks(t *testing.T) {
  61. // Defensive: a zero cutoff (e.g. during very first run on a fresh
  62. // install) must not over-evict.
  63. old := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 100}}
  64. new := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 200}}
  65. got := mergeClientIps(old, new, 0, false)
  66. want := map[string]int64{"1.1.1.1": 100, "2.2.2.2": 200}
  67. if !reflect.DeepEqual(got, want) {
  68. t.Fatalf("zero cutoff should keep everything\ngot: %v\nwant: %v", got, want)
  69. }
  70. }
  71. func TestMergeClientIps_LiveObservationsBypassStaleCutoff(t *testing.T) {
  72. // online-API mode: lastSeen is set when the connection was dispatched, so
  73. // a connection held open for hours has an "old" timestamp while being live
  74. // by definition. It must survive the stale cutoff.
  75. new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 500}} // opened long ago, still connected
  76. got := mergeClientIps(nil, new, 1000, true)
  77. want := map[string]int64{"1.1.1.1": 500}
  78. if !reflect.DeepEqual(got, want) {
  79. t.Fatalf("live observation must bypass the stale cutoff\ngot: %v\nwant: %v", got, want)
  80. }
  81. }
  82. func TestMergeClientIps_LiveModeStillEvictsStaleOldEntries(t *testing.T) {
  83. // the bypass applies only to this scan's observations — persisted entries
  84. // from past scans still age out as before.
  85. old := []IPWithTimestamp{{IP: "2.2.2.2", Timestamp: 100}}
  86. new := []IPWithTimestamp{{IP: "1.1.1.1", Timestamp: 2000}}
  87. got := mergeClientIps(old, new, 1000, true)
  88. want := map[string]int64{"1.1.1.1": 2000}
  89. if !reflect.DeepEqual(got, want) {
  90. t.Fatalf("stale db entry must still be evicted in live mode\ngot: %v\nwant: %v", got, want)
  91. }
  92. }
  93. func TestSelectIpsToBan(t *testing.T) {
  94. live := []IPWithTimestamp{ // sorted oldest-first, as partitionLiveIps returns
  95. {IP: "A", Timestamp: 100},
  96. {IP: "B", Timestamp: 200},
  97. {IP: "C", Timestamp: 300},
  98. }
  99. // over the limit: oldest connections are banned, newest keep the slots
  100. kept, banned := selectIpsToBan(live, 1)
  101. if got := collectIps(kept); !reflect.DeepEqual(got, []string{"C"}) {
  102. t.Fatalf("newest ip must keep the slot, got %v", got)
  103. }
  104. if got := collectIps(banned); !reflect.DeepEqual(got, []string{"A", "B"}) {
  105. t.Fatalf("older ips must be banned oldest-first, got %v", got)
  106. }
  107. // at the limit: nothing banned
  108. kept, banned = selectIpsToBan(live, 3)
  109. if len(banned) != 0 || len(kept) != 3 {
  110. t.Fatalf("at-limit set must not ban, kept=%v banned=%v", kept, banned)
  111. }
  112. // under the limit: nothing banned
  113. kept, banned = selectIpsToBan(live[:1], 3)
  114. if len(banned) != 0 || len(kept) != 1 {
  115. t.Fatalf("under-limit set must not ban, kept=%v banned=%v", kept, banned)
  116. }
  117. // defensive: non-positive limit never reaches enforcement, but must not panic
  118. if _, banned := selectIpsToBan(live, 0); banned != nil {
  119. t.Fatalf("zero limit must not ban, got %v", banned)
  120. }
  121. }
  122. func collectIps(entries []IPWithTimestamp) []string {
  123. out := make([]string, 0, len(entries))
  124. for _, e := range entries {
  125. out = append(out, e.IP)
  126. }
  127. return out
  128. }
  129. func TestPartitionLiveIps_SingleLiveNotStarvedByStillFreshHistoricals(t *testing.T) {
  130. // #4091: db holds A, B, C from minutes ago (still in the 30min
  131. // window) but they're not connecting anymore. only D is. old code
  132. // merged all four, sorted ascending, kept [A,B,C] and banned D
  133. // every tick. pin the new rule: only live ips count toward the limit.
  134. ipMap := map[string]int64{
  135. "A": 1000,
  136. "B": 1100,
  137. "C": 1200,
  138. "D": 2000,
  139. }
  140. observed := map[string]bool{"D": true}
  141. live, historical := partitionLiveIps(ipMap, observed)
  142. if got := collectIps(live); !reflect.DeepEqual(got, []string{"D"}) {
  143. t.Fatalf("live set should only contain the ip observed this scan\ngot: %v\nwant: [D]", got)
  144. }
  145. if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B", "C"}) {
  146. t.Fatalf("historical set should contain db-only ips in ascending order\ngot: %v\nwant: [A B C]", got)
  147. }
  148. }
  149. func TestPartitionLiveIps_ConcurrentLiveIpsSortedAscending(t *testing.T) {
  150. // when several ips are really live, partition returns them all in the
  151. // live set sorted ascending by timestamp. updateInboundClientIps then
  152. // keeps the newest and bans the oldest (last-IP-wins, #4699).
  153. ipMap := map[string]int64{
  154. "A": 5000,
  155. "B": 5500,
  156. }
  157. observed := map[string]bool{"A": true, "B": true}
  158. live, historical := partitionLiveIps(ipMap, observed)
  159. if got := collectIps(live); !reflect.DeepEqual(got, []string{"A", "B"}) {
  160. t.Fatalf("both live ips should be in the live set, ascending\ngot: %v\nwant: [A B]", got)
  161. }
  162. if len(historical) != 0 {
  163. t.Fatalf("no historical ips expected, got %v", historical)
  164. }
  165. }
  166. func TestGetInboundByEmailFallbackIgnoresProtocolScalarFields(t *testing.T) {
  167. dbDir := t.TempDir()
  168. t.Setenv("XUI_DB_FOLDER", dbDir)
  169. dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
  170. inbound := &model.Inbound{
  171. UserId: 1,
  172. Tag: "vless-limit-fallback",
  173. Enable: true,
  174. Port: 43002,
  175. Protocol: model.VLESS,
  176. Settings: `{
  177. "clients": [{"email": "[email protected]", "id": "11111111-1111-1111-1111-111111111111", "limitIp": 2}],
  178. "decryption": "none",
  179. "encryption": "none",
  180. "fallbacks": []
  181. }`,
  182. }
  183. if err := database.GetDB().Create(inbound).Error; err != nil {
  184. t.Fatalf("create inbound: %v", err)
  185. }
  186. got, err := (&CheckClientIpJob{}).getInboundByEmail("[email protected]")
  187. if err != nil {
  188. t.Fatalf("getInboundByEmail: %v", err)
  189. }
  190. if got.Id != inbound.Id {
  191. t.Fatalf("inbound id = %d, want %d", got.Id, inbound.Id)
  192. }
  193. }
  194. func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
  195. // quiet tick: nothing observed => nothing live. everything merged
  196. // is historical. keeps the panel from wiping recent-but-idle ips.
  197. ipMap := map[string]int64{
  198. "A": 1000,
  199. "B": 1100,
  200. }
  201. observed := map[string]bool{}
  202. live, historical := partitionLiveIps(ipMap, observed)
  203. if len(live) != 0 {
  204. t.Fatalf("no live ips expected, got %v", live)
  205. }
  206. if got := collectIps(historical); !reflect.DeepEqual(got, []string{"A", "B"}) {
  207. t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
  208. }
  209. }
  210. func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
  211. // Synced IPs from other nodes within 2 minutes should be counted as live
  212. // even if they weren't observed in the local scan.
  213. now := time.Now().Unix()
  214. ipMap := map[string]int64{
  215. "A": now - 30, // synced 30s ago -> live
  216. "B": now - 150, // synced 2m30s ago -> historical
  217. }
  218. observed := map[string]bool{}
  219. live, historical := partitionLiveIps(ipMap, observed)
  220. if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
  221. t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
  222. }
  223. if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
  224. t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
  225. }
  226. }
  227. func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
  228. t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
  229. marker := fakeFail2BanClient(t)
  230. if (&CheckClientIpJob{}).checkFail2BanInstalled() {
  231. t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
  232. }
  233. if _, err := os.Stat(marker); !os.IsNotExist(err) {
  234. t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
  235. }
  236. }
  237. func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
  238. t.Setenv("XUI_ENABLE_FAIL2BAN", "")
  239. marker := fakeFail2BanClient(t)
  240. if (&CheckClientIpJob{}).checkFail2BanInstalled() {
  241. t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
  242. }
  243. if _, err := os.Stat(marker); !os.IsNotExist(err) {
  244. t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
  245. }
  246. }
  247. func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
  248. value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
  249. os.Unsetenv("XUI_ENABLE_FAIL2BAN")
  250. t.Cleanup(func() {
  251. if ok {
  252. os.Setenv("XUI_ENABLE_FAIL2BAN", value)
  253. } else {
  254. os.Unsetenv("XUI_ENABLE_FAIL2BAN")
  255. }
  256. })
  257. if !isFail2BanEnabled() {
  258. t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
  259. }
  260. }
  261. func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
  262. t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
  263. marker := fakeFail2BanClient(t)
  264. if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
  265. t.Fatal("fail2ban should be available when the client probe succeeds")
  266. }
  267. if _, err := os.Stat(marker); err != nil {
  268. t.Fatalf("fail2ban-client should have been executed: %v", err)
  269. }
  270. }
  271. func fakeFail2BanClient(t *testing.T) string {
  272. t.Helper()
  273. dir := t.TempDir()
  274. marker := filepath.Join(dir, "probe-called")
  275. fakeClient := filepath.Join(dir, "fail2ban-client")
  276. script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
  277. if runtime.GOOS == "windows" {
  278. fakeClient += ".bat"
  279. script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
  280. }
  281. if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
  282. t.Fatalf("write fake fail2ban-client: %v", err)
  283. }
  284. t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
  285. t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
  286. return marker
  287. }