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.
This commit is contained in:
MHSanaei
2026-06-22 23:15:58 +02:00
parent 718b7e16e1
commit ce8b1bed77
23 changed files with 315 additions and 16 deletions
+39
View File
@@ -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": [
@@ -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
View File
@@ -1,6 +1,7 @@
export const keys = {
server: {
status: () => ['server', 'status'] as const,
fail2banStatus: () => ['server', 'fail2banStatus'] as const,
},
nodes: {
root: () => ['nodes'] as const,
+6
View File
@@ -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',
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -747,6 +747,9 @@
"addClients": "افزودن کلاینت‌ها",
"limitIp": "محدودیت IP",
"limitIpDesc": "حداکثر تعداد IP همزمان. ۰ = نامحدود",
"limitIpFail2banMissing": "Fail2ban نصب نشده است، بنابراین محدودیت IP اعمال نمی‌شود. برای فعال‌سازی این گزینه، Fail2ban را از منوی بش x-ui نصب کنید.",
"limitIpFail2banWindows": "Fail2ban روی ویندوز در دسترس نیست، بنابراین محدودیت IP قابل اعمال نیست.",
"limitIpDisabled": "قابلیت محدودیت IP روی این سرور غیرفعال است.",
"password": "رمز عبور",
"subId": "شناسه اشتراک",
"online": "آنلاین",
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": "上線",