Pārlūkot izejas kodu

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 <[email protected]>
Mayurifag 1 dienu atpakaļ
vecāks
revīzija
8fa248c621

+ 16 - 5
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 {

+ 43 - 0
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.

+ 75 - 0
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
+}