Forráskód Böngészése

fix(database): keep IP limits when the fail2ban probe is inconclusive (#6176)

* fix(database): keep IP limits when the fail2ban probe is inconclusive

ResetIpLimitNoFail2ban clears limitIp on every client — inbound settings JSON
and the clients table — whenever fail2banCanEnforce() returns false, then
records itself in the seeder history so it never re-evaluates. The probe was a
single `fail2ban-client -h` run, so it answered false both when fail2ban is
genuinely absent and when the command merely failed that once: a panel that
starts before fail2ban is up, or in a container where it is installed a moment
later, permanently loses every configured limit with no log line and no way
back.

Separate the two. A missing binary still means "absent" and the cleanup runs as
before; a binary that exists but will not run is reported as unknown, leaves the
configured values untouched, logs why, and does not record the seeder, so the
next start decides again.

* test(database): cover fail2ban reset safeguards

---------

Co-authored-by: n0ctal <[email protected]>
n0ctal 18 órája
szülő
commit
17fea2f656
2 módosított fájl, 135 hozzáadás és 5 törlés
  1. 26 5
      internal/database/db.go
  2. 109 0
      internal/database/fail2ban_state_test.go

+ 26 - 5
internal/database/db.go

@@ -1278,9 +1278,14 @@ func resetIpLimitsWithoutFail2ban() error {
 		return nil
 	}
 
-	if fail2banCanEnforce() {
+	state, probeErr := fail2banEnforcementState()
+	if state == fail2banEnforcing {
 		return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
 	}
+	if state == fail2banUnknown {
+		log.Printf("ResetIpLimitNoFail2ban: fail2ban-client present but not runnable (%v); keeping configured IP limits, will retry next start", probeErr)
+		return nil
+	}
 
 	var inbounds []model.Inbound
 	if err := db.Find(&inbounds).Error; err != nil {
@@ -1340,14 +1345,30 @@ func resetIpLimitsWithoutFail2ban() error {
 	})
 }
 
-func fail2banCanEnforce() bool {
+type fail2banState int
+
+const (
+	fail2banEnforcing fail2banState = iota
+	fail2banAbsent
+	fail2banUnknown
+)
+
+// fail2banEnforcementState separates "fail2ban is not installed" from "the probe
+// itself failed", so a transient failure never drives an irreversible cleanup.
+func fail2banEnforcementState() (fail2banState, error) {
 	if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
-		return false
+		return fail2banAbsent, nil
 	}
 	if runtime.GOOS == "windows" {
-		return false
+		return fail2banAbsent, nil
+	}
+	if _, err := exec.LookPath("fail2ban-client"); err != nil {
+		return fail2banAbsent, nil
+	}
+	if err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run(); err != nil {
+		return fail2banUnknown, err
 	}
-	return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
+	return fail2banEnforcing, nil
 }
 
 func clearLegacyProxySettings() error {

+ 109 - 0
internal/database/fail2ban_state_test.go

@@ -0,0 +1,109 @@
+package database
+
+import (
+	"encoding/json"
+	"os"
+	"path/filepath"
+	"runtime"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/config"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// stubFail2banClient puts a fail2ban-client on PATH whose exit code the test picks.
+func stubFail2banClient(t *testing.T, exitCode int) {
+	t.Helper()
+	dir := t.TempDir()
+	script := filepath.Join(dir, "fail2ban-client")
+	body := "#!/bin/sh\nexit " + string(rune('0'+exitCode)) + "\n"
+	if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
+		t.Fatalf("write stub: %v", err)
+	}
+	t.Setenv("PATH", dir)
+}
+
+func TestFail2banEnforcementStateSeparatesAbsentFromUnrunnable(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("fail2ban shell fixtures are Unix-only")
+	}
+	t.Run("absent", func(t *testing.T) {
+		t.Setenv("PATH", t.TempDir())
+		if got, _ := fail2banEnforcementState(); got != fail2banAbsent {
+			t.Fatalf("state = %v, want fail2banAbsent", got)
+		}
+	})
+
+	t.Run("present and runnable", func(t *testing.T) {
+		stubFail2banClient(t, 0)
+		if got, _ := fail2banEnforcementState(); got != fail2banEnforcing {
+			t.Fatalf("state = %v, want fail2banEnforcing", got)
+		}
+	})
+
+	t.Run("present but failing", func(t *testing.T) {
+		stubFail2banClient(t, 1)
+		got, err := fail2banEnforcementState()
+		if got != fail2banUnknown {
+			t.Fatalf("state = %v, want fail2banUnknown", got)
+		}
+		if err == nil {
+			t.Fatal("want the probe error, got nil")
+		}
+	})
+}
+
+func TestResetIpLimitsKeepsConfiguredLimitsWhenProbeFails(t *testing.T) {
+	if runtime.GOOS == "windows" {
+		t.Skip("fail2ban shell fixtures are Unix-only")
+	}
+	t.Setenv("XUI_DB_FOLDER", t.TempDir())
+	if err := InitDB(config.GetDBPath()); err != nil {
+		t.Fatalf("init db: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+	if err := db.Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Delete(&model.HistoryOfSeeders{}).Error; err != nil {
+		t.Fatalf("clear seeder history: %v", err)
+	}
+	settings, err := json.Marshal(map[string]any{"clients": []any{map[string]any{"email": "[email protected]", "limitIp": 6}}})
+	if err != nil {
+		t.Fatalf("marshal settings: %v", err)
+	}
+	inbound := model.Inbound{Remark: "kept", Settings: string(settings)}
+	if err := db.Create(&inbound).Error; err != nil {
+		t.Fatalf("create inbound: %v", err)
+	}
+	record := model.ClientRecord{Email: "[email protected]", LimitIP: 2}
+	if err := db.Create(&record).Error; err != nil {
+		t.Fatalf("create client record: %v", err)
+	}
+	stubFail2banClient(t, 1)
+	if err := resetIpLimitsWithoutFail2ban(); err != nil {
+		t.Fatalf("reset: %v", err)
+	}
+	var gotInbound model.Inbound
+	if err := db.First(&gotInbound, inbound.Id).Error; err != nil {
+		t.Fatalf("reload inbound: %v", err)
+	}
+	var got map[string]any
+	if err := json.Unmarshal([]byte(gotInbound.Settings), &got); err != nil {
+		t.Fatalf("decode settings: %v", err)
+	}
+	clients := got["clients"].([]any)
+	if limit := clients[0].(map[string]any)["limitIp"]; limit != float64(6) {
+		t.Fatalf("inbound limitIp = %v, want 6", limit)
+	}
+	if err := db.First(&record, record.Id).Error; err != nil {
+		t.Fatalf("reload client record: %v", err)
+	}
+	if record.LimitIP != 2 {
+		t.Fatalf("client record limitIp = %d, want 2", record.LimitIP)
+	}
+	var count int64
+	if err := db.Model(&model.HistoryOfSeeders{}).Where("seeder_name = ?", "ResetIpLimitNoFail2ban").Count(&count).Error; err != nil {
+		t.Fatalf("count seeder history: %v", err)
+	}
+	if count != 0 {
+		t.Fatalf("seeder history rows = %d, want 0", count)
+	}
+}