diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 6c9939b49..3ba6cdd38 100644 --- a/frontend/public/openapi.json +++ b/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": [ diff --git a/frontend/src/api/queries/useFail2banStatusQuery.ts b/frontend/src/api/queries/useFail2banStatusQuery.ts new file mode 100644 index 000000000..7cb8267bb --- /dev/null +++ b/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 { + const msg = await HttpUtil.get('/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; +} diff --git a/frontend/src/api/queryKeys.ts b/frontend/src/api/queryKeys.ts index 2496dd9b2..4166c28c1 100644 --- a/frontend/src/api/queryKeys.ts +++ b/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, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 1a40ff024..fb7168f43 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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', diff --git a/frontend/src/pages/clients/ClientBulkAddModal.tsx b/frontend/src/pages/clients/ClientBulkAddModal.tsx index ba9ce28f4..d1f6d92e3 100644 --- a/frontend/src/pages/clients/ClientBulkAddModal.tsx +++ b/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(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({ )} - update('limitIp', Number(v) || 0)} /> + + + update('limitIp', Number(v) || 0)} /> + + diff --git a/frontend/src/pages/clients/ClientFormModal.tsx b/frontend/src/pages/clients/ClientFormModal.tsx index 8ebf80d5e..4c94871f6 100644 --- a/frontend/src/pages/clients/ClientFormModal.tsx +++ b/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(key: K, value: FormState[K]) { setForm((prev) => ({ ...prev, [key]: value })); @@ -550,17 +554,22 @@ export default function ClientFormModal({ - - update('limitIp', Number(v) || 0)} /> - {isEdit && ( - - - - )} - + + + + update('limitIp', Number(v) || 0)} /> + {isEdit && ( + + + + )} + + + diff --git a/internal/database/db.go b/internal/database/db.go index 238f78e2e..55589d784 100644 --- a/internal/database/db.go +++ b/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 { diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 192c8fb09..a6ef125f8 100644 --- a/internal/web/controller/server.go +++ b/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) { diff --git a/internal/web/job/check_client_ip_job.go b/internal/web/job/check_client_ip_job.go index 854dc783e..0de12d534 100644 --- a/internal/web/job/check_client_ip_job.go +++ b/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 diff --git a/internal/web/service/server.go b/internal/web/service/server.go index ed87c7291..550e99cf4 100644 --- a/internal/web/service/server.go +++ b/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 diff --git a/internal/web/translation/ar-EG.json b/internal/web/translation/ar-EG.json index 90c77371d..222e82ce3 100644 --- a/internal/web/translation/ar-EG.json +++ b/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": "متصل", diff --git a/internal/web/translation/en-US.json b/internal/web/translation/en-US.json index fbd30a641..c77163b13 100644 --- a/internal/web/translation/en-US.json +++ b/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", diff --git a/internal/web/translation/es-ES.json b/internal/web/translation/es-ES.json index 2164769ca..1be1cf02e 100644 --- a/internal/web/translation/es-ES.json +++ b/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", diff --git a/internal/web/translation/fa-IR.json b/internal/web/translation/fa-IR.json index 3243844dc..a3be15ee2 100644 --- a/internal/web/translation/fa-IR.json +++ b/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": "آنلاین", diff --git a/internal/web/translation/id-ID.json b/internal/web/translation/id-ID.json index b85587b37..ad30198e6 100644 --- a/internal/web/translation/id-ID.json +++ b/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", diff --git a/internal/web/translation/ja-JP.json b/internal/web/translation/ja-JP.json index fe88a20a9..8cb793251 100644 --- a/internal/web/translation/ja-JP.json +++ b/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": "オンライン", diff --git a/internal/web/translation/pt-BR.json b/internal/web/translation/pt-BR.json index 3154f57eb..cc012a584 100644 --- a/internal/web/translation/pt-BR.json +++ b/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", diff --git a/internal/web/translation/ru-RU.json b/internal/web/translation/ru-RU.json index ce0cbc9be..621d946aa 100644 --- a/internal/web/translation/ru-RU.json +++ b/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": "В сети", diff --git a/internal/web/translation/tr-TR.json b/internal/web/translation/tr-TR.json index 3a6413432..4cf8f0010 100644 --- a/internal/web/translation/tr-TR.json +++ b/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", diff --git a/internal/web/translation/uk-UA.json b/internal/web/translation/uk-UA.json index c5e8999e1..4925989f4 100644 --- a/internal/web/translation/uk-UA.json +++ b/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": "У мережі", diff --git a/internal/web/translation/vi-VN.json b/internal/web/translation/vi-VN.json index b070b586a..fb0458fb6 100644 --- a/internal/web/translation/vi-VN.json +++ b/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", diff --git a/internal/web/translation/zh-CN.json b/internal/web/translation/zh-CN.json index 22119c098..e3ce22157 100644 --- a/internal/web/translation/zh-CN.json +++ b/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": "在线", diff --git a/internal/web/translation/zh-TW.json b/internal/web/translation/zh-TW.json index e38aae9d5..13d371c88 100644 --- a/internal/web/translation/zh-TW.json +++ b/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": "上線",