From 8fa248c621e3915a833d1d0a52233a166f54e958 Mon Sep 17 00:00:00 2001 From: Mayurifag Date: Tue, 2 Jun 2026 03:36:24 +0400 Subject: [PATCH] fix(job): skip fail2ban IP limit when disabled (#4581) 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. Co-authored-by: Mayurifag --- web/job/check_client_ip_job.go | 21 ++++-- .../check_client_ip_job_integration_test.go | 43 +++++++++++ web/job/check_client_ip_job_test.go | 75 +++++++++++++++++++ 3 files changed, 134 insertions(+), 5 deletions(-) diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 47a06a21..b7d0efa4 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -66,8 +66,12 @@ func (j *CheckClientIpJob) Run() { } shouldClearAccessLog := false - iplimitActive := j.hasLimitIp() - f2bInstalled := j.checkFail2BanInstalled() + fail2BanEnabled := isFail2BanEnabled() + iplimitActive := fail2BanEnabled && j.hasLimitIp() + f2bInstalled := false + if iplimitActive { + f2bInstalled = j.checkFail2BanInstalled() + } isAccessLogAvailable := j.checkAccessLogAvailable(iplimitActive) if isAccessLogAvailable { @@ -80,9 +84,7 @@ func (j *CheckClientIpJob) Run() { if f2bInstalled { shouldClearAccessLog = j.processLogFile() } 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.") } } } @@ -279,12 +281,21 @@ func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) } func (j *CheckClientIpJob) checkFail2BanInstalled() bool { + if !isFail2BanEnabled() { + return false + } + cmd := "fail2ban-client" args := []string{"-h"} err := exec.Command(cmd, args...).Run() return err == nil } +func isFail2BanEnabled() bool { + value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN") + return !ok || value == "true" +} + func (j *CheckClientIpJob) checkAccessLogAvailable(iplimitActive bool) bool { accessLogPath, err := xray.GetAccessLogPath() if err != nil { diff --git a/web/job/check_client_ip_job_integration_test.go b/web/job/check_client_ip_job_integration_test.go index 87ffa98f..de7e857c 100644 --- a/web/job/check_client_ip_job_integration_test.go +++ b/web/job/check_client_ip_job_integration_test.go @@ -128,6 +128,49 @@ func ipSet(entries []IPWithTimestamp) map[string]int64 { 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 // few minutes ago, only one live ip is actually connecting. pre-fix: // live ip got banned every tick and never appeared in the panel. diff --git a/web/job/check_client_ip_job_test.go b/web/job/check_client_ip_job_test.go index fa8fc7ee..b6967d03 100644 --- a/web/job/check_client_ip_job_test.go +++ b/web/job/check_client_ip_job_test.go @@ -1,7 +1,10 @@ package job import ( + "os" + "path/filepath" "reflect" + "runtime" "testing" ) @@ -145,3 +148,75 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) { 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 +}