check_client_ip_ban_commit_test.go 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. package job
  2. import (
  3. "errors"
  4. "os"
  5. "testing"
  6. "time"
  7. "github.com/mhsanaei/3x-ui/v3/internal/database"
  8. "gorm.io/gorm"
  9. )
  10. // installClientIpCommitFailure fails the transaction at COMMIT, not at a
  11. // statement, so every write inside it succeeds before the rollback.
  12. func installClientIpCommitFailure(t *testing.T) {
  13. t.Helper()
  14. db := database.GetDB()
  15. switch db.Name() {
  16. case "sqlite":
  17. sqlDB, err := db.DB()
  18. if err != nil {
  19. t.Fatalf("get sql DB: %v", err)
  20. }
  21. // foreign_keys is per connection, so the injection only holds while the
  22. // pool cannot hand the scan a fresh one with the pragma back at OFF.
  23. sqlDB.SetMaxOpenConns(1)
  24. for _, statement := range []string{
  25. "DROP TRIGGER IF EXISTS commitfail_on_ips",
  26. "DROP TABLE IF EXISTS commitfail_child",
  27. "DROP TABLE IF EXISTS commitfail_parent",
  28. "PRAGMA foreign_keys = ON",
  29. "CREATE TABLE commitfail_parent (id INTEGER PRIMARY KEY)",
  30. "CREATE TABLE commitfail_child (parent_id INTEGER, FOREIGN KEY(parent_id) REFERENCES commitfail_parent(id) DEFERRABLE INITIALLY DEFERRED)",
  31. "CREATE TRIGGER commitfail_on_ips AFTER UPDATE OF ips ON inbound_client_ips BEGIN INSERT INTO commitfail_child(parent_id) VALUES (999); END",
  32. } {
  33. if err := db.Exec(statement).Error; err != nil {
  34. t.Fatalf("install SQLite commit-failure injection %q: %v", statement, err)
  35. }
  36. }
  37. t.Cleanup(func() {
  38. _ = db.Exec("DROP TRIGGER IF EXISTS commitfail_on_ips").Error
  39. _ = db.Exec("DROP TABLE IF EXISTS commitfail_child").Error
  40. _ = db.Exec("DROP TABLE IF EXISTS commitfail_parent").Error
  41. _ = db.Exec("PRAGMA foreign_keys = OFF").Error
  42. })
  43. case "postgres":
  44. for _, statement := range []string{
  45. "DROP TABLE IF EXISTS commitfail_child",
  46. "DROP TABLE IF EXISTS commitfail_parent",
  47. "CREATE TABLE commitfail_parent (id bigint PRIMARY KEY)",
  48. "CREATE TABLE commitfail_child (id bigint PRIMARY KEY, parent_id bigint REFERENCES commitfail_parent(id) DEFERRABLE INITIALLY DEFERRED)",
  49. } {
  50. if err := db.Exec(statement).Error; err != nil {
  51. t.Fatalf("install PostgreSQL commit-failure injection %q: %v", statement, err)
  52. }
  53. }
  54. const callbackName = "test:client_ip_commit_failure"
  55. if err := db.Callback().Update().After("gorm:update").Register(callbackName, func(tx *gorm.DB) {
  56. stmt := tx.Statement
  57. if stmt == nil || stmt.Schema == nil || stmt.Schema.Table != "inbound_client_ips" {
  58. return
  59. }
  60. result := tx.Session(&gorm.Session{NewDB: true}).Exec("INSERT INTO commitfail_child (id, parent_id) VALUES (1, 999)")
  61. if result.Error != nil {
  62. _ = tx.AddError(result.Error)
  63. }
  64. }); err != nil {
  65. t.Fatalf("register PostgreSQL commit-failure callback: %v", err)
  66. }
  67. t.Cleanup(func() {
  68. _ = db.Callback().Update().Remove(callbackName)
  69. _ = db.Exec("DROP TABLE IF EXISTS commitfail_child").Error
  70. _ = db.Exec("DROP TABLE IF EXISTS commitfail_parent").Error
  71. })
  72. default:
  73. t.Fatalf("unsupported test database dialect %q", db.Name())
  74. }
  75. }
  76. // A fail2ban line is not a row a rollback can take back, so nothing may be
  77. // appended, and bannedSeen not advanced, until the scan has committed.
  78. func TestProcessObserved_CommitFailureDoesNotPublishBan(t *testing.T) {
  79. setupIntegrationDB(t)
  80. const email = "rollback-must-not-ban@x"
  81. seedLinkedInboundWithClient(t, "rollback-must-not-ban", email, 1)
  82. now := time.Now().Unix()
  83. seedClientIps(t, email, []IPWithTimestamp{{IP: "198.51.100.10", Timestamp: now - 2}})
  84. installClientIpCommitFailure(t)
  85. j := NewCheckClientIpJob()
  86. cleaned := j.processObserved(map[string]map[string]int64{
  87. email: {
  88. "198.51.100.10": now - 1,
  89. "198.51.100.11": now,
  90. },
  91. }, true, true)
  92. if cleaned {
  93. t.Errorf("processObserved reported a published ban after the commit failed")
  94. }
  95. if got := ipSet(readClientIps(t, email)); len(got) != 1 || got["198.51.100.10"] != now-2 {
  96. t.Errorf("rolled-back IP row = %v, want only the original client address", got)
  97. }
  98. if _, err := os.Stat(readIpLimitLogPath()); !os.IsNotExist(err) {
  99. body, _ := os.ReadFile(readIpLimitLogPath())
  100. t.Errorf("the rollback still touched the fail2ban trigger file (stat=%v):\n%s", err, body)
  101. }
  102. if _, seen := j.bannedSeen[email+"|198.51.100.10"]; seen {
  103. t.Errorf("the rollback advanced bannedSeen and would suppress the retry")
  104. }
  105. }
  106. // The committed row has already dropped the address, so a bannedSeen entry
  107. // recorded ahead of a failed write would suppress the ban for good.
  108. func TestProcessObserved_PublishFailureLeavesBanRetryable(t *testing.T) {
  109. setupIntegrationDB(t)
  110. const email = "publish-failure@x"
  111. seedLinkedInboundWithClient(t, "publish-failure", email, 1)
  112. now := time.Now().Unix()
  113. // A directory where the log file belongs makes every open fail.
  114. if err := os.MkdirAll(readIpLimitLogPath(), 0o755); err != nil {
  115. t.Fatalf("block the log path: %v", err)
  116. }
  117. j := NewCheckClientIpJob()
  118. observed := map[string]map[string]int64{
  119. email: {"198.51.100.20": now - 1, "198.51.100.21": now},
  120. }
  121. if cleaned := j.processObserved(observed, true, true); cleaned {
  122. t.Errorf("processObserved reported a publication that could not happen")
  123. }
  124. for key := range j.bannedSeen {
  125. t.Errorf("bannedSeen recorded %q although nothing was written", key)
  126. }
  127. }
  128. // A client back under its limit produces no candidates, so pruning cannot live
  129. // in the selection step: a surviving entry suppresses its next real ban.
  130. func TestProcessObserved_ForgetsBannedSeenWhenClientReturnsUnderLimit(t *testing.T) {
  131. setupIntegrationDB(t)
  132. const email = "prune-banned-seen@x"
  133. seedLinkedInboundWithClient(t, "prune-banned-seen", email, 1)
  134. now := time.Now().Unix()
  135. seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.1", Timestamp: now - 500}})
  136. j := NewCheckClientIpJob()
  137. j.processObserved(map[string]map[string]int64{
  138. email: {"203.0.113.1": now - 400, "203.0.113.2": now - 300},
  139. }, true, true)
  140. if got := banLineCount(t, email); got != 1 {
  141. t.Fatalf("ban lines after the first scan = %d, want 1", got)
  142. }
  143. // Back under the limit: no candidates, so the stale entry must be dropped here.
  144. j.processObserved(map[string]map[string]int64{
  145. email: {"203.0.113.1": now - 400},
  146. }, true, true)
  147. if len(j.bannedSeen) != 0 {
  148. t.Fatalf("bannedSeen = %v, want empty once the client is under its limit", j.bannedSeen)
  149. }
  150. j.processObserved(map[string]map[string]int64{
  151. email: {"203.0.113.1": now - 400, "203.0.113.3": now},
  152. }, true, true)
  153. if got := banLineCount(t, email); got != 2 {
  154. t.Fatalf("ban lines after the client goes over again = %d, want 2", got)
  155. }
  156. }
  157. type failingWriter struct{ err error }
  158. func (f failingWriter) Write([]byte) (int, error) { return 0, f.err }
  159. // A dropped write error would let publishBans record an address the jail never
  160. // sees, so it has to reach the caller.
  161. func TestWriteBanLinesSurfacesWriteFailure(t *testing.T) {
  162. want := errors.New("no space left on device")
  163. err := writeBanLines(failingWriter{err: want}, "write-failure@x", []IPWithTimestamp{
  164. {IP: "203.0.113.9", Timestamp: time.Now().Unix()},
  165. })
  166. if !errors.Is(err, want) {
  167. t.Fatalf("writeBanLines error = %v, want the writer's own error", err)
  168. }
  169. }
  170. // A committed over-limit scan writes its line and hands the client on.
  171. func TestProcessObserved_PublishesBanForCommittedScan(t *testing.T) {
  172. setupIntegrationDB(t)
  173. const email = "published-ban@x"
  174. seedLinkedInboundWithClient(t, "published-ban", email, 1)
  175. now := time.Now().Unix()
  176. seedClientIps(t, email, []IPWithTimestamp{{IP: "203.0.113.50", Timestamp: now - 500}})
  177. j := NewCheckClientIpJob()
  178. if cleaned := j.processObserved(map[string]map[string]int64{
  179. email: {"203.0.113.50": now - 400, "203.0.113.51": now},
  180. }, true, true); !cleaned {
  181. t.Fatalf("a published ban must report the access log as worth cleaning")
  182. }
  183. if got := banLineCount(t, email); got != 1 {
  184. t.Fatalf("ban lines = %d, want 1", got)
  185. }
  186. if _, seen := j.bannedSeen[email+"|203.0.113.50"]; !seen {
  187. t.Fatalf("a published address must be recorded so the next scan does not repeat it")
  188. }
  189. }