Skip to content

Commit dfd4ed3

Browse files
committed
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.
1 parent ece1655 commit dfd4ed3

2 files changed

Lines changed: 72 additions & 5 deletions

File tree

internal/database/db.go

Lines changed: 26 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,9 +1278,14 @@ func resetIpLimitsWithoutFail2ban() error {
12781278
return nil
12791279
}
12801280

1281-
if fail2banCanEnforce() {
1281+
state, probeErr := fail2banEnforcementState()
1282+
if state == fail2banEnforcing {
12821283
return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
12831284
}
1285+
if state == fail2banUnknown {
1286+
log.Printf("ResetIpLimitNoFail2ban: fail2ban-client present but not runnable (%v); keeping configured IP limits, will retry next start", probeErr)
1287+
return nil
1288+
}
12841289

12851290
var inbounds []model.Inbound
12861291
if err := db.Find(&inbounds).Error; err != nil {
@@ -1340,14 +1345,30 @@ func resetIpLimitsWithoutFail2ban() error {
13401345
})
13411346
}
13421347

1343-
func fail2banCanEnforce() bool {
1348+
type fail2banState int
1349+
1350+
const (
1351+
fail2banEnforcing fail2banState = iota
1352+
fail2banAbsent
1353+
fail2banUnknown
1354+
)
1355+
1356+
// fail2banEnforcementState separates "fail2ban is not installed" from "the probe
1357+
// itself failed", so a transient failure never drives an irreversible cleanup.
1358+
func fail2banEnforcementState() (fail2banState, error) {
13441359
if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
1345-
return false
1360+
return fail2banAbsent, nil
13461361
}
13471362
if runtime.GOOS == "windows" {
1348-
return false
1363+
return fail2banAbsent, nil
1364+
}
1365+
if _, err := exec.LookPath("fail2ban-client"); err != nil {
1366+
return fail2banAbsent, nil
1367+
}
1368+
if err := exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run(); err != nil {
1369+
return fail2banUnknown, err
13491370
}
1350-
return exec.CommandContext(context.Background(), "fail2ban-client", "-h").Run() == nil
1371+
return fail2banEnforcing, nil
13511372
}
13521373

13531374
func clearLegacyProxySettings() error {
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package database
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
// stubFail2banClient puts a fail2ban-client on PATH whose exit code the test picks.
10+
func stubFail2banClient(t *testing.T, exitCode int) {
11+
t.Helper()
12+
dir := t.TempDir()
13+
script := filepath.Join(dir, "fail2ban-client")
14+
body := "#!/bin/sh\nexit " + string(rune('0'+exitCode)) + "\n"
15+
if err := os.WriteFile(script, []byte(body), 0o755); err != nil {
16+
t.Fatalf("write stub: %v", err)
17+
}
18+
t.Setenv("PATH", dir)
19+
}
20+
21+
func TestFail2banEnforcementStateSeparatesAbsentFromUnrunnable(t *testing.T) {
22+
t.Run("absent", func(t *testing.T) {
23+
t.Setenv("PATH", t.TempDir())
24+
if got, _ := fail2banEnforcementState(); got != fail2banAbsent {
25+
t.Fatalf("state = %v, want fail2banAbsent", got)
26+
}
27+
})
28+
29+
t.Run("present and runnable", func(t *testing.T) {
30+
stubFail2banClient(t, 0)
31+
if got, _ := fail2banEnforcementState(); got != fail2banEnforcing {
32+
t.Fatalf("state = %v, want fail2banEnforcing", got)
33+
}
34+
})
35+
36+
t.Run("present but failing", func(t *testing.T) {
37+
stubFail2banClient(t, 1)
38+
got, err := fail2banEnforcementState()
39+
if got != fail2banUnknown {
40+
t.Fatalf("state = %v, want fail2banUnknown", got)
41+
}
42+
if err == nil {
43+
t.Fatal("want the probe error, got nil")
44+
}
45+
})
46+
}

0 commit comments

Comments
 (0)