소스 검색

feat(iplimit): gate IP limit on fail2ban and reset stale limits

Per-client IP limit only enforces where fail2ban is installed, so the panel now reports enforceability and disables the field otherwise:

- Add GET /panel/api/server/fail2banStatus (enabled/installed/usable/windows), cached 30s.
- ClientFormModal and ClientBulkAddModal disable the IP Limit input when not usable and show a hover tooltip; Windows gets a platform-specific message instead of the bash-menu hint.
- One-time migration ResetIpLimitNoFail2ban zeroes existing client limitIp (inbound settings JSON + clients table) on hosts without fail2ban, where the limit never applied.
- Drop the recurring '[LimitIP] Fail2Ban is not installed' warning.
- Add limitIpFail2banMissing/limitIpFail2banWindows/limitIpDisabled across all 13 locales.
MHSanaei 14 시간 전
부모
커밋
ce8b1bed77

+ 39 - 0
frontend/public/openapi.json

@@ -3316,6 +3316,45 @@
         }
       }
     },
+    "/panel/api/server/fail2banStatus": {
+      "get": {
+        "tags": [
+          "Server"
+        ],
+        "summary": "Reports whether per-client IP limits can be enforced on this host. The panel uses it to gate the \"IP Limit\" field, since enforcement depends on Fail2ban being installed.",
+        "operationId": "get_panel_api_server_fail2banStatus",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "enabled": true,
+                    "installed": true,
+                    "usable": true,
+                    "windows": false
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/server/cpuHistory/{bucket}": {
       "get": {
         "tags": [

+ 41 - 0
frontend/src/api/queries/useFail2banStatusQuery.ts

@@ -0,0 +1,41 @@
+import { useQuery } from '@tanstack/react-query';
+
+import { HttpUtil } from '@/utils';
+import { keys } from '@/api/queryKeys';
+
+export interface Fail2banStatus {
+  enabled: boolean;
+  installed: boolean;
+  usable: boolean;
+  windows: boolean;
+}
+
+const FAIL_OPEN_STATUS: Fail2banStatus = {
+  enabled: true,
+  installed: true,
+  usable: true,
+  windows: false,
+};
+
+async function fetchFail2banStatus(): Promise<Fail2banStatus> {
+  const msg = await HttpUtil.get<Fail2banStatus>('/panel/api/server/fail2banStatus', undefined, { silent: true });
+  if (!msg?.success || !msg.obj) throw new Error(msg?.msg || 'Failed to fetch fail2ban status');
+  return { ...FAIL_OPEN_STATUS, ...msg.obj };
+}
+
+export function getLimitIpNotice(status: Fail2banStatus, t: (key: string) => string): string {
+  if (status.usable) return '';
+  if (!status.enabled) return t('pages.clients.limitIpDisabled');
+  if (status.windows) return t('pages.clients.limitIpFail2banWindows');
+  return t('pages.clients.limitIpFail2banMissing');
+}
+
+export function useFail2banStatusQuery() {
+  const query = useQuery({
+    queryKey: keys.server.fail2banStatus(),
+    queryFn: fetchFail2banStatus,
+    staleTime: 60_000,
+  });
+
+  return query.data ?? FAIL_OPEN_STATUS;
+}

+ 1 - 0
frontend/src/api/queryKeys.ts

@@ -1,6 +1,7 @@
 export const keys = {
   server: {
     status: () => ['server', 'status'] as const,
+    fail2banStatus: () => ['server', 'fail2banStatus'] as const,
   },
   nodes: {
     root: () => ['nodes'] as const,

+ 6 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -252,6 +252,12 @@ export const sections: readonly Section[] = [
         summary: 'Real-time machine snapshot: CPU, memory, swap, disk, network IO, load averages, open connections, Xray state. Cached and refreshed every 2 seconds in the background.',
         response: '{\n  "success": true,\n  "obj": {\n    "cpu": 12.5,\n    "mem": { "current": 2147483648, "total": 8589934592 },\n    "swap": { "current": 0, "total": 4294967296 },\n    "disk": { "current": 53687091200, "total": 268435456000 },\n    "netIO": { "up": 1073741824, "down": 2147483648 },\n    "xray": { "state": "running", "version": "v25.10.31" },\n    "tcpCount": 42,\n    "load": { "load1": 0.5, "load5": 0.3, "load15": 0.2 }\n  }\n}',
       },
+      {
+        method: 'GET',
+        path: '/panel/api/server/fail2banStatus',
+        summary: 'Reports whether per-client IP limits can be enforced on this host. The panel uses it to gate the "IP Limit" field, since enforcement depends on Fail2ban being installed.',
+        response: '{\n  "success": true,\n  "obj": {\n    "enabled": true,\n    "installed": true,\n    "usable": true,\n    "windows": false\n  }\n}',
+      },
       {
         method: 'GET',
         path: '/panel/api/server/cpuHistory/:bucket',

+ 12 - 2
frontend/src/pages/clients/ClientBulkAddModal.tsx

@@ -1,6 +1,6 @@
 import { useEffect, useMemo, useState } from 'react';
 import { useTranslation } from 'react-i18next';
-import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, message } from 'antd';
+import { AutoComplete, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Tooltip, message } from 'antd';
 import { ReloadOutlined } from '@ant-design/icons';
 import dayjs from 'dayjs';
 import type { Dayjs } from 'dayjs';
@@ -10,6 +10,7 @@ import { formatInboundLabel } from '@/lib/inbounds/label';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { useClients, type InboundOption } from '@/hooks/useClients';
+import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientBulkAddFormSchema, type ClientBulkAddFormValues } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -62,6 +63,9 @@ export default function ClientBulkAddModal({
   const [form, setForm] = useState<FormState>(emptyForm);
   const [delayedStart, setDelayedStart] = useState(false);
   const [saving, setSaving] = useState(false);
+  const fail2ban = useFail2banStatusQuery();
+  const limitIpDisabled = !fail2ban.usable;
+  const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   useEffect(() => {
     if (!open) return;
@@ -311,7 +315,13 @@ export default function ClientBulkAddModal({
           )}
 
           <Form.Item label={t('pages.clients.limitIp')}>
-            <InputNumber value={form.limitIp} min={0} onChange={(v) => update('limitIp', Number(v) || 0)} />
+            <Tooltip title={limitIpNotice || undefined}>
+              <span style={{ display: 'inline-flex' }}>
+                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
+                  style={limitIpDisabled ? { pointerEvents: 'none' } : undefined}
+                  onChange={(v) => update('limitIp', Number(v) || 0)} />
+              </span>
+            </Tooltip>
           </Form.Item>
 
           <Form.Item label={t('pages.clients.totalGB')}>

+ 20 - 11
frontend/src/pages/clients/ClientFormModal.tsx

@@ -28,6 +28,7 @@ import { normalizeClientIps, type ClientIpInfo } from '@/lib/clients/ip-log';
 import { DateTimePicker, SelectAllClearButtons } from '@/components/form';
 import { TLS_FLOW_CONTROL } from '@/schemas/primitives';
 import type { ClientRecord, InboundOption, ExternalLink, ExternalLinkInput } from '@/hooks/useClients';
+import { useFail2banStatusQuery, getLimitIpNotice } from '@/api/queries/useFail2banStatusQuery';
 import { ClientFormSchema, ClientCreateFormSchema } from '@/schemas/client';
 
 const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
@@ -182,6 +183,9 @@ export default function ClientFormModal({
   const [ipsLoading, setIpsLoading] = useState(false);
   const [ipsClearing, setIpsClearing] = useState(false);
   const [ipsModalOpen, setIpsModalOpen] = useState(false);
+  const fail2ban = useFail2banStatusQuery();
+  const limitIpDisabled = !fail2ban.usable;
+  const limitIpNotice = getLimitIpNotice(fail2ban, t);
 
   function update<K extends keyof FormState>(key: K, value: FormState[K]) {
     setForm((prev) => ({ ...prev, [key]: value }));
@@ -550,17 +554,22 @@ export default function ClientFormModal({
                       </Col>
                       <Col xs={24} md={6}>
                         <Form.Item label={t('pages.clients.limitIp')} tooltip={t('pages.clients.limitIpDesc')}>
-                          <Space.Compact style={{ display: 'flex' }}>
-                            <InputNumber value={form.limitIp} min={0} style={{ flex: 1 }}
-                              onChange={(v) => update('limitIp', Number(v) || 0)} />
-                            {isEdit && (
-                              <Tooltip title={t('pages.clients.ipLog')}>
-                                <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
-                                  {clientIps.length > 0 ? clientIps.length : ''}
-                                </Button>
-                              </Tooltip>
-                            )}
-                          </Space.Compact>
+                          <Tooltip title={limitIpNotice || undefined}>
+                            <span style={{ display: 'flex', width: '100%' }}>
+                              <Space.Compact style={{ display: 'flex', flex: 1 }}>
+                                <InputNumber value={form.limitIp} min={0} disabled={limitIpDisabled}
+                                  style={{ flex: 1, ...(limitIpDisabled ? { pointerEvents: 'none' } : null) }}
+                                  onChange={(v) => update('limitIp', Number(v) || 0)} />
+                                {isEdit && (
+                                  <Tooltip title={t('pages.clients.ipLog')}>
+                                    <Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
+                                      {clientIps.length > 0 ? clientIps.length : ''}
+                                    </Button>
+                                  </Tooltip>
+                                )}
+                              </Space.Compact>
+                            </span>
+                          </Tooltip>
                         </Form.Item>
                       </Col>
                     </Row>

+ 97 - 0
internal/database/db.go

@@ -11,7 +11,9 @@ import (
 	"log"
 	"math"
 	"os"
+	"os/exec"
 	"path"
+	"runtime"
 	"slices"
 	"strconv"
 	"strings"
@@ -464,9 +466,104 @@ func runSeeders(isUsersEmpty bool) error {
 	if err := seedHostsFromExternalProxy(); err != nil {
 		return err
 	}
+
+	// Self-gated on the "ResetIpLimitNoFail2ban" row.
+	if err := resetIpLimitsWithoutFail2ban(); err != nil {
+		return err
+	}
 	return nil
 }
 
+// resetIpLimitsWithoutFail2ban zeroes every client's IP limit on hosts where
+// fail2ban can't enforce it (not installed, or the integration disabled). The
+// limit silently does nothing there yet kept logging a repeated warning, so a
+// stale value is just misleading — the panel also disables the field on these
+// hosts. One-time, self-gated on the seeder row.
+func resetIpLimitsWithoutFail2ban() error {
+	var history []string
+	if err := db.Model(&model.HistoryOfSeeders{}).Pluck("seeder_name", &history).Error; err != nil {
+		return err
+	}
+	if slices.Contains(history, "ResetIpLimitNoFail2ban") {
+		return nil
+	}
+
+	if fail2banCanEnforce() {
+		return db.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
+	}
+
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+
+	return db.Transaction(func(tx *gorm.DB) error {
+		for _, inbound := range inbounds {
+			if strings.TrimSpace(inbound.Settings) == "" {
+				continue
+			}
+			var settings map[string]any
+			if err := json.Unmarshal([]byte(inbound.Settings), &settings); err != nil {
+				log.Printf("ResetIpLimitNoFail2ban: skip inbound %d (invalid settings json): %v", inbound.Id, err)
+				continue
+			}
+			clients, ok := settings["clients"].([]any)
+			if !ok {
+				continue
+			}
+			mutated := false
+			for i, raw := range clients {
+				obj, ok := raw.(map[string]any)
+				if !ok {
+					continue
+				}
+				v, present := obj["limitIp"]
+				if !present {
+					continue
+				}
+				if n, isNum := v.(float64); isNum && n == 0 {
+					continue
+				}
+				obj["limitIp"] = 0
+				clients[i] = obj
+				mutated = true
+			}
+			if !mutated {
+				continue
+			}
+			settings["clients"] = clients
+			newSettings, err := json.MarshalIndent(settings, "", "  ")
+			if err != nil {
+				log.Printf("ResetIpLimitNoFail2ban: skip inbound %d (marshal failed): %v", inbound.Id, err)
+				continue
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbound.Id).
+				Update("settings", string(newSettings)).Error; err != nil {
+				return err
+			}
+		}
+		if err := tx.Model(&model.ClientRecord{}).Where("limit_ip <> ?", 0).
+			Update("limit_ip", 0).Error; err != nil {
+			return err
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "ResetIpLimitNoFail2ban"}).Error
+	})
+}
+
+// fail2banCanEnforce reports whether per-client IP limits can actually be
+// enforced on this host: the integration must be enabled (XUI_ENABLE_FAIL2BAN)
+// and fail2ban-client must be present. Mirrors the service-layer check, kept
+// local to avoid an import cycle.
+func fail2banCanEnforce() bool {
+	if v, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN"); ok && v != "true" {
+		return false
+	}
+	if runtime.GOOS == "windows" {
+		return false
+	}
+	return exec.Command("fail2ban-client", "-h").Run() == nil
+}
+
 // clearLegacyProxySettings drops the deprecated panelProxy/tgBotProxy rows so a
 // stale tgBotProxy no longer masks the panelOutbound egress fallback.
 func clearLegacyProxySettings() error {

+ 5 - 0
internal/web/controller/server.go

@@ -63,6 +63,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
 	g.GET("/getNewmlkem768", a.getNewmlkem768)
 	g.GET("/getNewVlessEnc", a.getNewVlessEnc)
 	g.GET("/clientIps", a.getClientIps)
+	g.GET("/fail2banStatus", a.getFail2banStatus)
 
 	g.POST("/stopXrayService", a.stopXrayService)
 	g.POST("/restartXrayService", a.restartXrayService)
@@ -103,6 +104,10 @@ func (a *ServerController) startTask() {
 // status returns the current server status information.
 func (a *ServerController) status(c *gin.Context) { jsonObj(c, a.serverService.LastStatus(), nil) }
 
+func (a *ServerController) getFail2banStatus(c *gin.Context) {
+	jsonObj(c, a.serverService.GetFail2banStatus(), nil)
+}
+
 func parseHistoryBucket(c *gin.Context) (int, bool) {
 	bucket, err := strconv.Atoi(c.Param("bucket"))
 	if err != nil || bucket <= 0 || !service.IsAllowedHistoryBucket(bucket) {

+ 4 - 3
internal/web/job/check_client_ip_job.go

@@ -88,11 +88,12 @@ func (j *CheckClientIpJob) Run() {
 	}
 }
 
-// resolveEnforce decides whether limits can actually be enforced this run,
-// warning when fail2ban is missing on a platform that needs it.
+// resolveEnforce decides whether limits can actually be enforced this run.
+// Without fail2ban on a platform that needs it the limit can't be applied, so
+// enforcement is skipped (the panel resets these limits to 0 on upgrade and
+// disables the field, so this is normally a no-op).
 func (j *CheckClientIpJob) resolveEnforce(hasLimit, f2bInstalled bool) bool {
 	if hasLimit && runtime.GOOS != "windows" && !f2bInstalled {
-		logger.Warning("[LimitIP] Fail2Ban is not installed, Please install Fail2Ban from the x-ui bash menu.")
 		return false
 	}
 	return hasLimit

+ 51 - 0
internal/web/service/server.go

@@ -142,6 +142,10 @@ type ServerService struct {
 
 	versionsCacheMu sync.Mutex
 	versionsCache   *cachedXrayVersions
+
+	fail2banMu        sync.Mutex
+	fail2banInstalled bool
+	fail2banCheckedAt time.Time
 }
 
 type cachedXrayVersions struct {
@@ -185,6 +189,53 @@ func (s *ServerService) LastStatus() *Status {
 	return s.lastStatus
 }
 
+// Fail2banStatus tells the frontend whether the per-client IP limit can
+// actually be enforced. Enforcement depends on fail2ban, so a limit set
+// without it would silently do nothing.
+type Fail2banStatus struct {
+	Enabled   bool `json:"enabled"`
+	Installed bool `json:"installed"`
+	Usable    bool `json:"usable"`
+	Windows   bool `json:"windows"`
+}
+
+const fail2banInstalledCacheTTL = 30 * time.Second
+
+func (s *ServerService) GetFail2banStatus() Fail2banStatus {
+	enabled := isFail2banEnabled()
+
+	installed := false
+	if enabled {
+		installed = s.isFail2banInstalled()
+	}
+
+	return Fail2banStatus{
+		Enabled:   enabled,
+		Installed: installed,
+		Usable:    enabled && installed,
+		Windows:   runtime.GOOS == "windows",
+	}
+}
+
+func isFail2banEnabled() bool {
+	value, ok := os.LookupEnv("XUI_ENABLE_FAIL2BAN")
+	return !ok || value == "true"
+}
+
+func (s *ServerService) isFail2banInstalled() bool {
+	s.fail2banMu.Lock()
+	defer s.fail2banMu.Unlock()
+
+	if !s.fail2banCheckedAt.IsZero() && time.Since(s.fail2banCheckedAt) < fail2banInstalledCacheTTL {
+		return s.fail2banInstalled
+	}
+
+	err := exec.Command("fail2ban-client", "-h").Run()
+	s.fail2banInstalled = err == nil
+	s.fail2banCheckedAt = time.Now()
+	return s.fail2banInstalled
+}
+
 // RefreshStatus collects a new system snapshot, stores it as LastStatus, and
 // appends it to the system-metrics time series. Returns the new snapshot (may
 // be nil if collection failed). Called by the background ticker; the caller is

+ 3 - 0
internal/web/translation/ar-EG.json

@@ -747,6 +747,9 @@
       "addClients": "إضافة عملاء",
       "limitIp": "حد عناوين IP",
       "limitIpDesc": "الحد الأقصى لعناوين IP المتزامنة. 0 = غير محدود.",
+      "limitIpFail2banMissing": "Fail2ban غير مثبّت، لذا لا يمكن تطبيق حد عناوين IP. ثبّت Fail2ban من قائمة x-ui النصية لتفعيل هذا الخيار.",
+      "limitIpFail2banWindows": "Fail2ban غير متوفّر على نظام Windows، لذا لا يمكن تطبيق حد عناوين IP.",
+      "limitIpDisabled": "ميزة حد عناوين IP معطّلة على هذا الخادم.",
       "password": "كلمة المرور",
       "subId": "معرّف الاشتراك",
       "online": "متصل",

+ 3 - 0
internal/web/translation/en-US.json

@@ -747,6 +747,9 @@
       "addClients": "Add Clients",
       "limitIp": "IP Limit",
       "limitIpDesc": "Maximum simultaneous IPs. 0 = unlimited.",
+      "limitIpFail2banMissing": "Fail2ban is not installed, so the IP limit cannot be enforced. Install Fail2ban from the x-ui bash menu to enable this option.",
+      "limitIpFail2banWindows": "Fail2ban is not available on Windows, so the IP limit cannot be enforced.",
+      "limitIpDisabled": "The IP limit feature is disabled on this server.",
       "password": "Password",
       "subId": "Subscription ID",
       "online": "Online",

+ 3 - 0
internal/web/translation/es-ES.json

@@ -747,6 +747,9 @@
       "addClients": "Añadir clientes",
       "limitIp": "Límite de IP",
       "limitIpDesc": "Máximo de IP simultáneas. 0 = ilimitado.",
+      "limitIpFail2banMissing": "Fail2ban no está instalado, por lo que no se puede aplicar el límite de IP. Instala Fail2ban desde el menú bash de x-ui para habilitar esta opción.",
+      "limitIpFail2banWindows": "Fail2ban no está disponible en Windows, por lo que no se puede aplicar el límite de IP.",
+      "limitIpDisabled": "La función de límite de IP está deshabilitada en este servidor.",
       "password": "Contraseña",
       "subId": "ID de suscripción",
       "online": "En línea",

+ 3 - 0
internal/web/translation/fa-IR.json

@@ -747,6 +747,9 @@
       "addClients": "افزودن کلاینت‌ها",
       "limitIp": "محدودیت IP",
       "limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
+      "limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
+      "limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
+      "limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
       "password": "رمز عبور",
       "subId": "شناسه اشتراک",
       "online": "آنلاین",

+ 3 - 0
internal/web/translation/id-ID.json

@@ -747,6 +747,9 @@
       "addClients": "Tambah klien",
       "limitIp": "Batas IP",
       "limitIpDesc": "Jumlah maksimum IP bersamaan. 0 = tidak terbatas.",
+      "limitIpFail2banMissing": "Fail2ban tidak terpasang, sehingga batas IP tidak dapat diterapkan. Pasang Fail2ban dari menu bash x-ui untuk mengaktifkan opsi ini.",
+      "limitIpFail2banWindows": "Fail2ban tidak tersedia di Windows, sehingga batas IP tidak dapat diterapkan.",
+      "limitIpDisabled": "Fitur batas IP dinonaktifkan di server ini.",
       "password": "Kata sandi",
       "subId": "ID Langganan",
       "online": "Online",

+ 3 - 0
internal/web/translation/ja-JP.json

@@ -747,6 +747,9 @@
       "addClients": "クライアントを追加",
       "limitIp": "IP 制限",
       "limitIpDesc": "同時接続 IP の最大数。0 = 無制限。",
+      "limitIpFail2banMissing": "Fail2ban がインストールされていないため、IP 制限を適用できません。このオプションを有効にするには、x-ui の bash メニューから Fail2ban をインストールしてください。",
+      "limitIpFail2banWindows": "Windows では Fail2ban を利用できないため、IP 制限を適用できません。",
+      "limitIpDisabled": "このサーバーでは IP 制限機能が無効になっています。",
       "password": "パスワード",
       "subId": "サブスクリプション ID",
       "online": "オンライン",

+ 3 - 0
internal/web/translation/pt-BR.json

@@ -747,6 +747,9 @@
       "addClients": "Adicionar clientes",
       "limitIp": "Limite de IP",
       "limitIpDesc": "Máximo de IPs simultâneos. 0 = ilimitado.",
+      "limitIpFail2banMissing": "O Fail2ban não está instalado, portanto o limite de IP não pode ser aplicado. Instale o Fail2ban pelo menu bash do x-ui para ativar esta opção.",
+      "limitIpFail2banWindows": "O Fail2ban não está disponível no Windows, portanto o limite de IP não pode ser aplicado.",
+      "limitIpDisabled": "O recurso de limite de IP está desativado neste servidor.",
       "password": "Senha",
       "subId": "ID da assinatura",
       "online": "Online",

+ 3 - 0
internal/web/translation/ru-RU.json

@@ -747,6 +747,9 @@
       "addClients": "Добавить клиентов",
       "limitIp": "Лимит IP",
       "limitIpDesc": "Максимум одновременных IP-адресов. 0 = без ограничений.",
+      "limitIpFail2banMissing": "Fail2ban не установлен, поэтому ограничение по IP не может быть применено. Установите Fail2ban из bash-меню x-ui, чтобы включить эту опцию.",
+      "limitIpFail2banWindows": "Fail2ban недоступен в Windows, поэтому ограничение по IP не может быть применено.",
+      "limitIpDisabled": "Функция ограничения по IP отключена на этом сервере.",
       "password": "Пароль",
       "subId": "ID подписки",
       "online": "В сети",

+ 3 - 0
internal/web/translation/tr-TR.json

@@ -747,6 +747,9 @@
       "addClients": "Kullanıcı Ekle",
       "limitIp": "IP Limiti",
       "limitIpDesc": "Eş zamanlı en fazla IP sayısı. 0 = sınırsız.",
+      "limitIpFail2banMissing": "Fail2ban yüklü değil, bu nedenle IP sınırı uygulanamaz. Bu seçeneği etkinleştirmek için x-ui bash menüsünden Fail2ban'ı yükleyin.",
+      "limitIpFail2banWindows": "Fail2ban Windows'ta kullanılamadığından IP sınırı uygulanamaz.",
+      "limitIpDisabled": "IP sınırı özelliği bu sunucuda devre dışı.",
       "password": "Şifre",
       "subId": "Abonelik ID'si",
       "online": "Çevrimiçi",

+ 3 - 0
internal/web/translation/uk-UA.json

@@ -747,6 +747,9 @@
       "addClients": "Додати клієнтів",
       "limitIp": "Ліміт IP",
       "limitIpDesc": "Максимум одночасних IP-адрес. 0 = без обмежень.",
+      "limitIpFail2banMissing": "Fail2ban не встановлено, тому обмеження за IP не може бути застосоване. Встановіть Fail2ban із bash-меню x-ui, щоб увімкнути цю опцію.",
+      "limitIpFail2banWindows": "Fail2ban недоступний у Windows, тому обмеження за IP не може бути застосоване.",
+      "limitIpDisabled": "Функцію обмеження за IP вимкнено на цьому сервері.",
       "password": "Пароль",
       "subId": "ID підписки",
       "online": "У мережі",

+ 3 - 0
internal/web/translation/vi-VN.json

@@ -747,6 +747,9 @@
       "addClients": "Thêm khách hàng",
       "limitIp": "Giới hạn IP",
       "limitIpDesc": "Số IP đồng thời tối đa. 0 = không giới hạn.",
+      "limitIpFail2banMissing": "Fail2ban chưa được cài đặt nên không thể áp dụng giới hạn IP. Hãy cài đặt Fail2ban từ menu bash x-ui để bật tùy chọn này.",
+      "limitIpFail2banWindows": "Fail2ban không khả dụng trên Windows nên không thể áp dụng giới hạn IP.",
+      "limitIpDisabled": "Tính năng giới hạn IP đã bị tắt trên máy chủ này.",
       "password": "Mật khẩu",
       "subId": "ID đăng ký",
       "online": "Trực tuyến",

+ 3 - 0
internal/web/translation/zh-CN.json

@@ -747,6 +747,9 @@
       "addClients": "添加客户端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同时连接 IP 数。0 = 不限制。",
+      "limitIpFail2banMissing": "未安装 Fail2ban,无法实施 IP 限制。请从 x-ui 命令行菜单安装 Fail2ban 以启用此选项。",
+      "limitIpFail2banWindows": "Windows 上不支持 Fail2ban,无法实施 IP 限制。",
+      "limitIpDisabled": "此服务器已禁用 IP 限制功能。",
       "password": "密码",
       "subId": "订阅 ID",
       "online": "在线",

+ 3 - 0
internal/web/translation/zh-TW.json

@@ -747,6 +747,9 @@
       "addClients": "新增客戶端",
       "limitIp": "IP 限制",
       "limitIpDesc": "最大同時連線 IP 數。0 = 不限制。",
+      "limitIpFail2banMissing": "未安裝 Fail2ban,無法實施 IP 限制。請從 x-ui 命令列選單安裝 Fail2ban 以啟用此選項。",
+      "limitIpFail2banWindows": "Windows 上不支援 Fail2ban,無法實施 IP 限制。",
+      "limitIpDisabled": "此伺服器已停用 IP 限制功能。",
       "password": "密碼",
       "subId": "訂閱 ID",
       "online": "上線",