From 3349dcbc137ba550657344522dab9b5bb31982e8 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 7 May 2026 13:53:34 +0200 Subject: [PATCH 1/8] fix(fail2ban): fix banning regression and Docker zero-jail issue - DockerEntrypoint.sh: create jail.d/filter.d/action.d config files before starting fail2ban so Docker containers no longer start with 0 active jails (fixes #4134) - x-ui.sh create_iplimit_jails: lower maxretry from 2 to 1 so fail2ban bans on the first log entry; with maxretry=2 and the partitionLiveIps logic the second occurrence could arrive after the 32 s findtime window, silently preventing any ban (fixes #4163) - x-ui.sh: fix datepattern (%%Y -> %Y) so fail2ban parses the Go log timestamp correctly instead of looking for a literal %%Y string - x-ui.sh / DockerEntrypoint.sh: fix date command in actionban / actionunban echo (%%Y -> %Y) so the ban log records actual dates - check_client_ip_job.go: replace log.SetOutput / log.SetFlags on the global standard-library logger with a local log.New instance, eliminating the dangling closed-file-handle between calls and stopping unrelated stdlib log output from polluting 3xipl.log Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 1 + DockerEntrypoint.sh | 58 ++++++++++++++++++++++++++++++++-- web/job/check_client_ip_job.go | 24 +++++++------- x-ui.sh | 8 ++--- 4 files changed, 74 insertions(+), 17 deletions(-) diff --git a/.gitignore b/.gitignore index 8fa4eeb0..1761ece2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Ignore editor and IDE settings .idea/ .vscode/ +.claude/ .cache/ .sync* diff --git a/DockerEntrypoint.sh b/DockerEntrypoint.sh index 7511d2ea..4479a4c5 100644 --- a/DockerEntrypoint.sh +++ b/DockerEntrypoint.sh @@ -1,7 +1,61 @@ #!/bin/sh -# Start fail2ban -[ $XUI_ENABLE_FAIL2BAN == "true" ] && fail2ban-client -x start +# Start fail2ban with the 3x-ipl jail +if [ "$XUI_ENABLE_FAIL2BAN" = "true" ]; then + LOG_FOLDER="${XUI_LOG_FOLDER:-/var/log/x-ui}" + mkdir -p "$LOG_FOLDER" + touch "$LOG_FOLDER/3xipl.log" "$LOG_FOLDER/3xipl-banned.log" + + mkdir -p /etc/fail2ban/jail.d /etc/fail2ban/filter.d /etc/fail2ban/action.d + + cat > /etc/fail2ban/jail.d/3x-ipl.conf << EOF +[3x-ipl] +enabled=true +backend=auto +filter=3x-ipl +action=3x-ipl +logpath=$LOG_FOLDER/3xipl.log +maxretry=1 +findtime=32 +bantime=30m +EOF + + cat > /etc/fail2ban/filter.d/3x-ipl.conf << 'EOF' +[Definition] +datepattern = ^%Y/%m/%d %H:%M:%S +failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ +ignoreregex = +EOF + + cat > /etc/fail2ban/action.d/3x-ipl.conf << EOF +[INCLUDES] +before = iptables-allports.conf + +[Definition] +actionstart = -N f2b- + -A f2b- -j + -I -p -j f2b- + +actionstop = -D -p -j f2b- + + -X f2b- + +actioncheck = -n -L | grep -q 'f2b-[ \t]' + +actionban = -I f2b- 1 -s -j + echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = [IP] = banned for seconds." >> $LOG_FOLDER/3xipl-banned.log + +actionunban = -D f2b- -s -j + echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = [IP] = unbanned." >> $LOG_FOLDER/3xipl-banned.log + +[Init] +name = default +protocol = tcp +chain = INPUT +EOF + + fail2ban-client -x start +fi # Run x-ui exec /app/x-ui diff --git a/web/job/check_client_ip_job.go b/web/job/check_client_ip_job.go index 7f0ac2cf..e16cced2 100644 --- a/web/job/check_client_ip_job.go +++ b/web/job/check_client_ip_job.go @@ -403,16 +403,6 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun shouldCleanLog := false j.disAllowedIps = []string{} - // Open log file - logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - logger.Errorf("failed to open IP limit log file: %s", err) - return false - } - defer logIpFile.Close() - log.SetOutput(logIpFile) - log.SetFlags(log.LstdFlags) - // historical db-only ips are excluded from this count on purpose. var keptLive []IPWithTimestamp if len(liveIps) > limitIp { @@ -422,13 +412,25 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun keptLive = liveIps[:limitIp] bannedLive := liveIps[limitIp:] + // Open log file only when a ban entry needs to be written. + // Use a local logger to avoid mutating the global log.* state, + // which would redirect all standard-library logging to this file + // and leave a dangling closed-file handle after the defer fires. + logIpFile, err := os.OpenFile(xray.GetIPLimitLogPath(), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + logger.Errorf("failed to open IP limit log file: %s", err) + return false + } + defer logIpFile.Close() + ipLogger := log.New(logIpFile, "", log.LstdFlags) + // log format is load-bearing: x-ui.sh create_iplimit_jails builds // filter.d/3x-ipl.conf with // failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ // don't change the wording. for _, ipTime := range bannedLive { j.disAllowedIps = append(j.disAllowedIps, ipTime.IP) - log.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) + ipLogger.Printf("[LIMIT_IP] Email = %s || Disconnecting OLD IP = %s || Timestamp = %d", clientEmail, ipTime.IP, ipTime.Timestamp) } // force xray to drop existing connections from banned ips diff --git a/x-ui.sh b/x-ui.sh index 73e99195..31ecc683 100644 --- a/x-ui.sh +++ b/x-ui.sh @@ -2034,14 +2034,14 @@ backend=auto filter=3x-ipl action=3x-ipl logpath=${iplimit_log_path} -maxretry=2 +maxretry=1 findtime=32 bantime=${bantime}m EOF cat << EOF > /etc/fail2ban/filter.d/3x-ipl.conf [Definition] -datepattern = ^%%Y/%%m/%%d %%H:%%M:%%S +datepattern = ^%Y/%m/%d %H:%M:%S failregex = \[LIMIT_IP\]\s*Email\s*=\s*.+\s*\|\|\s*Disconnecting OLD IP\s*=\s*\s*\|\|\s*Timestamp\s*=\s*\d+ ignoreregex = EOF @@ -2062,10 +2062,10 @@ actionstop = -D -p -j f2b- actioncheck = -n -L | grep -q 'f2b-[ \t]' actionban = -I f2b- 1 -s -j - echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path} + echo "\$(date +"%Y/%m/%d %H:%M:%S") BAN [Email] = [IP] = banned for seconds." >> ${iplimit_banned_log_path} actionunban = -D f2b- -s -j - echo "\$(date +"%%Y/%%m/%%d %%H:%%M:%%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path} + echo "\$(date +"%Y/%m/%d %H:%M:%S") UNBAN [Email] = [IP] = unbanned." >> ${iplimit_banned_log_path} [Init] name = default From 79a7e7a5b5c1590c4997c08540cbce12e2bf272d Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 7 May 2026 14:44:33 +0200 Subject: [PATCH 2/8] fix(vless): scope testseed to xtls-rprx-vision flow testseed is only meaningful for the exact xtls-rprx-vision flow, but the panel was emitting it for any non-empty flow (including the UDP variant) and keeping it on the inbound after the flow was cleared via the client modal. Tighten the gate end-to-end: - VLESSSettings.toJson (inbound + outbound) now only emits testseed when the flow is exactly xtls-rprx-vision and the array is 4 positive ints; default state is empty so unmodified inbounds omit the field entirely. - canEnableVisionSeed drops the udp443 variant per spec. - Form adds a tooltip + theme-aware help text and an inline error when the user partially fills the four inputs; submit is blocked in that state. Reset clears to empty (= use server defaults). - UpdateInboundClient strips a now-orphaned testseed when the spliced client no longer leaves any XRV flow in the inbound. - MigrationRequirements cleans up legacy rows where testseed lingered after flow changes or was saved for non-XRV flows by older versions. Co-Authored-By: Claude Opus 4.7 --- web/assets/js/model/inbound.js | 41 +++++--- web/assets/js/model/outbound.js | 24 +++-- web/html/form/protocol/vless.html | 30 ++++-- web/html/modals/inbound_modal.html | 145 ++++++++++++++--------------- web/service/inbound.go | 35 +++++++ 5 files changed, 173 insertions(+), 102 deletions(-) diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 263091d9..074670a9 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1763,12 +1763,13 @@ class Inbound extends XrayCommonClass { return false; } - // Vision seed applies only when vision flow is selected + // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected. + // Excludes the UDP variant per spec. canEnableVisionSeed() { if (!this.canEnableTlsFlow()) return false; const clients = this.settings?.vlesses; if (!Array.isArray(clients)) return false; - return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION || c?.flow === TLS_FLOW_CONTROL.VISION_UDP443); + return clients.some(c => c?.flow === TLS_FLOW_CONTROL.VISION); } canEnableReality() { @@ -2543,7 +2544,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings { encryption = "none", fallbacks = [], selectedAuth = undefined, - testseed = [900, 500, 900, 256], + testseed = [], ) { super(protocol); this.vlesses = vlesses; @@ -2562,12 +2563,23 @@ Inbound.VLESSSettings = class extends Inbound.Settings { this.fallbacks.splice(index, 1); } + // Empty array means "use server defaults" (won't be sent). + // Anything else must be exactly 4 positive integers. + static isValidTestseed(arr) { + if (!Array.isArray(arr) || arr.length === 0) return true; + if (arr.length !== 4) return false; + return arr.every(v => Number.isInteger(v) && v > 0); + } + static fromJson(json = {}) { - // Ensure testseed is always initialized as an array - let testseed = [900, 500, 900, 256]; - if (json.testseed && Array.isArray(json.testseed) && json.testseed.length >= 4) { - testseed = json.testseed; - } + // Preserve a saved testseed only if it's a valid 4-positive-int array; otherwise leave empty + // so toJson omits it and the form falls back to placeholder defaults. + const saved = json.testseed; + const testseed = (Array.isArray(saved) + && saved.length === 4 + && saved.every(v => Number.isInteger(v) && v > 0)) + ? saved + : []; const obj = new Inbound.VLESSSettings( Protocols.VLESS, @@ -2576,7 +2588,7 @@ Inbound.VLESSSettings = class extends Inbound.Settings { json.encryption, Inbound.VLESSSettings.Fallback.fromJson(json.fallbacks || []), json.selectedAuth, - testseed + testseed, ); return obj; } @@ -2602,9 +2614,14 @@ Inbound.VLESSSettings = class extends Inbound.Settings { json.selectedAuth = this.selectedAuth; } - // Only include testseed if at least one client has a flow set - const hasFlow = this.vlesses && this.vlesses.some(vless => vless.flow && vless.flow !== ''); - if (hasFlow && this.testseed && this.testseed.length >= 4) { + // testseed is only meaningful for the exact xtls-rprx-vision flow, and only when + // the user supplied a complete 4-positive-int array. Otherwise omit and let the + // backend fall back to its safe defaults. + const hasVisionFlow = this.vlesses && this.vlesses.some(v => v.flow === TLS_FLOW_CONTROL.VISION); + if (hasVisionFlow + && Array.isArray(this.testseed) + && this.testseed.length === 4 + && this.testseed.every(v => Number.isInteger(v) && v > 0)) { json.testseed = this.testseed; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index f312ed96..bc4725c2 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -1139,11 +1139,11 @@ class Outbound extends CommonClass { return false; } - // Vision seed applies only when vision flow is selected + // Vision seed applies only when the XTLS Vision (TCP/TLS) flow is selected. + // Excludes the UDP variant per spec. canEnableVisionSeed() { if (!this.canEnableTlsFlow()) return false; - const flow = this.settings?.flow; - return flow === TLS_FLOW_CONTROL.VISION || flow === TLS_FLOW_CONTROL.VISION_UDP443; + return this.settings?.flow === TLS_FLOW_CONTROL.VISION; } canEnableReality() { @@ -1799,7 +1799,7 @@ Outbound.VmessSettings = class extends CommonClass { } }; Outbound.VLESSSettings = class extends CommonClass { - constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = [900, 500, 900, 256]) { + constructor(address, port, id, flow, encryption, reverseTag = '', reverseSniffing = new ReverseSniffing(), testpre = 0, testseed = []) { super(); this.address = address; this.port = port; @@ -1814,6 +1814,12 @@ Outbound.VLESSSettings = class extends CommonClass { static fromJson(json = {}) { if (ObjectUtil.isEmpty(json.address) || ObjectUtil.isEmpty(json.port)) return new Outbound.VLESSSettings(); + const saved = json.testseed; + const testseed = (Array.isArray(saved) + && saved.length === 4 + && saved.every(v => Number.isInteger(v) && v > 0)) + ? saved + : []; return new Outbound.VLESSSettings( json.address, json.port, @@ -1823,7 +1829,7 @@ Outbound.VLESSSettings = class extends CommonClass { json.reverse?.tag || '', ReverseSniffing.fromJson(json.reverse?.sniffing || {}), json.testpre || 0, - json.testseed && json.testseed.length >= 4 ? json.testseed : [900, 500, 900, 256] + testseed, ); } @@ -1843,12 +1849,14 @@ Outbound.VLESSSettings = class extends CommonClass { sniffing: JSON.stringify(reverseSniffing) === JSON.stringify(defaultReverseSniffing) ? {} : reverseSniffing, }; } - // Only include Vision settings when flow is set - if (this.flow && this.flow !== '') { + // Vision-specific knobs are only meaningful for the exact xtls-rprx-vision flow. + if (this.flow === TLS_FLOW_CONTROL.VISION) { if (this.testpre > 0) { result.testpre = this.testpre; } - if (this.testseed && this.testseed.length >= 4) { + if (Array.isArray(this.testseed) + && this.testseed.length === 4 + && this.testseed.every(v => Number.isInteger(v) && v > 0)) { result.testseed = this.testseed; } } diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html index f8ee1542..fd566e9d 100644 --- a/web/html/form/protocol/vless.html +++ b/web/html/form/protocol/vless.html @@ -81,30 +81,38 @@ - - - - + Date: Thu, 7 May 2026 20:27:34 +0200 Subject: [PATCH 6/8] refactor(fallbacks): share template, tighter UX, cleaner JSON Co-Authored-By: Claude Opus 4.7 --- web/assets/js/model/inbound.js | 72 +++++++++---------------- web/html/form/fallbacks.html | 85 ++++++++++++++++++++++++++++++ web/html/form/protocol/trojan.html | 30 +---------- web/html/form/protocol/vless.html | 30 +---------- 4 files changed, 113 insertions(+), 104 deletions(-) create mode 100644 web/html/form/fallbacks.html diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index 62deaf06..a3c1d017 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -145,6 +145,19 @@ class XrayCommonClass { return this; } + // Build a clean Xray fallback entry. Per docs, name/alpn/path empty = "any", + // and xver=0 means PROXY protocol off — omit them so the generated config + // stays minimal and readable. dest is required and always emitted. + static fallbackToJson(fb) { + const out = { dest: fb.dest }; + if (fb.name) out.name = fb.name; + if (fb.alpn) out.alpn = fb.alpn; + if (fb.path) out.path = fb.path; + const xver = Number(fb.xver); + if (Number.isInteger(xver) && xver > 0) out.xver = xver; + return out; + } + toString(format = true) { return format ? JSON.stringify(this.toJson(), null, 2) : JSON.stringify(this.toJson()); } @@ -2733,31 +2746,13 @@ Inbound.VLESSSettings.Fallback = class extends XrayCommonClass { } toJson() { - let xver = this.xver; - if (!Number.isInteger(xver)) { - xver = 0; - } - return { - name: this.name, - alpn: this.alpn, - path: this.path, - dest: this.dest, - xver: xver, - } + return XrayCommonClass.fallbackToJson(this); } static fromJson(json = []) { - const fallbacks = []; - for (let fallback of json) { - fallbacks.push(new Inbound.VLESSSettings.Fallback( - fallback.name, - fallback.alpn, - fallback.path, - fallback.dest, - fallback.xver, - )) - } - return fallbacks; + return (json || []).map(f => new Inbound.VLESSSettings.Fallback( + f.name, f.alpn, f.path, f.dest, f.xver, + )); } }; @@ -2786,10 +2781,13 @@ Inbound.TrojanSettings = class extends Inbound.Settings { } toJson() { - return { + const json = { clients: Inbound.TrojanSettings.toJsonArray(this.trojans), - fallbacks: Inbound.TrojanSettings.toJsonArray(this.fallbacks) }; + if (this.fallbacks && this.fallbacks.length > 0) { + json.fallbacks = Inbound.TrojanSettings.toJsonArray(this.fallbacks); + } + return json; } }; @@ -2828,31 +2826,13 @@ Inbound.TrojanSettings.Fallback = class extends XrayCommonClass { } toJson() { - let xver = this.xver; - if (!Number.isInteger(xver)) { - xver = 0; - } - return { - name: this.name, - alpn: this.alpn, - path: this.path, - dest: this.dest, - xver: xver, - } + return XrayCommonClass.fallbackToJson(this); } static fromJson(json = []) { - const fallbacks = []; - for (let fallback of json) { - fallbacks.push(new Inbound.TrojanSettings.Fallback( - fallback.name, - fallback.alpn, - fallback.path, - fallback.dest, - fallback.xver, - )) - } - return fallbacks; + return (json || []).map(f => new Inbound.TrojanSettings.Fallback( + f.name, f.alpn, f.path, f.dest, f.xver, + )); } }; diff --git a/web/html/form/fallbacks.html b/web/html/form/fallbacks.html new file mode 100644 index 00000000..703bfc8e --- /dev/null +++ b/web/html/form/fallbacks.html @@ -0,0 +1,85 @@ +{{define "form/fallbacks"}} +
+ + + Fallbacks ([[ inbound.settings.fallbacks.length ]]) + + + + + Add +
+ + + + Fallback [[ index + 1 ]] + + + + + + + + + + + + any + h2 + http/1.1 + + + + + + + + + + + + + + + + + Off + v1 + v2 + + + +{{end}} diff --git a/web/html/form/protocol/trojan.html b/web/html/form/protocol/trojan.html index 5d36808d..4c04e6e7 100644 --- a/web/html/form/protocol/trojan.html +++ b/web/html/form/protocol/trojan.html @@ -19,35 +19,7 @@ {{end}} \ No newline at end of file diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html index 21abc7b2..749e5b0c 100644 --- a/web/html/form/protocol/vless.html +++ b/web/html/form/protocol/vless.html @@ -42,35 +42,7 @@