fix(job): skip fail2ban IP limit when disabled

Honor XUI_ENABLE_FAIL2BAN before running fail2ban-dependent IP-limit work. This avoids spawning fail2ban-client on disabled Docker installs while preserving the default enabled behavior when the env var is unset.
This commit is contained in:
Mayurifag 2026-05-26 06:50:52 +04:00
parent 20edaee8ed
commit cb2d7db665
No known key found for this signature in database
GPG key ID: 871672CCF33EDE72
3 changed files with 134 additions and 5 deletions

View file

@ -66,8 +66,12 @@ func (j *CheckClientIpJob) Run() {
} }
shouldClearAccessLog := false shouldClearAccessLog := false
iplimitActive := j.hasLimitIp() fail2BanEnabled := isFail2BanEnabled()
f2bInstalled := j.checkFail2BanInstalled() iplimitActive := fail2BanEnabled && j.hasLimitIp()
f2bInstalled := false
if iplimitActive {
f2bInstalled = j.checkFail2BanInstalled()
}
isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive) isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive)
if isAccessLogAvailable { if isAccessLogAvailable {
@ -80,9 +84,7 @@ func (j *CheckClientIpJob) Run() {
if f2bInstalled { if f2bInstalled {
shouldClearAccessLog = j.processLogFile() shouldClearAccessLog = j.processLogFile()
} else { } else {
if !f2bInstalled { logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
}
} }
} }
} }
@ -281,12 +283,21 @@ func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool)
} }
func (j *CheckClientIpJob) checkFail2BanInstalled() bool { func (j *CheckClientIpJob) checkFail2BanInstalled() bool {
if !isFail2BanEnabled() {
return false
}
cmd := "fail2ban-client" cmd := "fail2ban-client"
args := []string{"-h"} args := []string{"-h"}
err := exec.Command(cmd, args...).Run() err := exec.Command(cmd, args...).Run()
return err == nil return err == nil
} }
func isFail2BanEnabled() bool {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
return !ok || value == "true"
}
func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool { func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool {
accessLogPath, err := xray.GetAccessLogPath() accessLogPath, err := xray.GetAccessLogPath()
if err != nil { if err != nil {

View file

@ -128,6 +128,49 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 {
return out return out
} }
func TestRun_DisabledFail2BanSkipsProbeAndBanLog(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
const email = "disabled-fail2ban"
seedInboundWithClient(t, "inbound-disabled-fail2ban", email, 1)
binDir := t.TempDir()
accessLog := filepath.Join(t.TempDir(), "access.log")
t.Setenv("XUI_BIN_FOLDER", binDir)
configData, err := json.Marshal(map[string]any{
"log": map[string]any{"access": accessLog},
})
if err != nil {
t.Fatalf("marshal xray config: %v", err)
}
if err := os.WriteFile(filepath.Join(binDir, "config.json"), configData, 0644); err != nil {
t.Fatalf("write xray config: %v", err)
}
if err := os.WriteFile(accessLog, []byte("2026/05/26 12:00:00 from tcp:203.0.113.10:443 accepted tcp:example.com:443 email: disabled-fail2ban\n"), 0644); err != nil {
t.Fatalf("write access log: %v", err)
}
j := NewCheckClientIpJob()
j.Run()
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
if info, err := os.Stat(readIpLimitLogPath()); err == nil && info.Size() > 0 {
body, _ := os.ReadFile(readIpLimitLogPath())
t.Fatalf("3xipl.log should be empty when fail2ban is disabled, got:\n%s", body)
}
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", email).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("disabled fail2ban should not persist IP-limit rows, got %d", count)
}
}
// #4091 repro: client has limit=3, db still holds 3 idle ips from a // #4091 repro: client has limit=3, db still holds 3 idle ips from a
// few minutes ago, only one live ip is actually connecting. pre-fix: // few minutes ago, only one live ip is actually connecting. pre-fix:
// live ip got banned every tick and never appeared in the panel. // live ip got banned every tick and never appeared in the panel.

View file

@ -1,7 +1,10 @@
package job package job
import ( import (
"os"
"path/filepath"
"reflect" "reflect"
"runtime"
"testing" "testing"
) )
@ -144,3 +147,75 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got) t.Fatalf("all merged entries should flow to historical\ngot: %v\nwant: [A B]", got)
} }
} }
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN=false")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestCheckFail2BanInstalled_EmptyEnvSkipsClientProbe(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "")
marker := fakeFail2BanClient(t)
if (&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be unavailable when XUI_ENABLE_FAIL2BAN is empty")
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("fail2ban-client should not have been executed, stat error: %v", err)
}
}
func TestIsFail2BanEnabled_DefaultsToEnabledWhenUnset(t *testing.T) {
value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
t.Cleanup(func() {
if ok {
os.Setenv("XUI_ENABLE_FAIL2BAN", value)
} else {
os.Unsetenv("XUI_ENABLE_FAIL2BAN")
}
})
if !isFail2BanEnabled() {
t.Fatal("fail2ban should default to enabled when XUI_ENABLE_FAIL2BAN is unset")
}
}
func TestCheckFail2BanInstalled_EnabledEnvProbesClient(t *testing.T) {
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
marker := fakeFail2BanClient(t)
if !(&CheckClientIpJob{}).checkFail2BanInstalled() {
t.Fatal("fail2ban should be available when the client probe succeeds")
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("fail2ban-client should have been executed: %v", err)
}
}
func fakeFail2BanClient(t *testing.T) string {
t.Helper()
dir := t.TempDir()
marker := filepath.Join(dir, "probe-called")
fakeClient := filepath.Join(dir, "fail2ban-client")
script := "#!/bin/sh\n: > \"$FAIL2BAN_PROBE_MARKER\"\nexit 0\n"
if runtime.GOOS == "windows" {
fakeClient += ".bat"
script = "@echo off\ntype nul > \"%FAIL2BAN_PROBE_MARKER%\"\nexit /b 0\n"
}
if err := os.WriteFile(fakeClient, []byte(script), 0o755); err != nil {
t.Fatalf("write fake fail2ban-client: %v", err)
}
t.Setenv("FAIL2BAN_PROBE_MARKER", marker)
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
return marker
}