11 커밋 3b64a62137 ... f2bc4938b7

작성자 SHA1 메시지 날짜
  MHSanaei f2bc4938b7 Reality: remove tesla.com because of blocking 2 일 전
  MHSanaei 7f703f927e fix(scripts): harden server-IP detection with multi-provider + manual fallback 2 일 전
  MHSanaei f2c79b57fa Bump Go to 1.26.3 2 일 전
  MHSanaei c394938f01 refactor(websocket): split controller into service + thin controller 2 일 전
  MHSanaei b84b58ef21 fix(websocket): guard stale events and disconnect race in JS client 2 일 전
  Farhad H. P. Shirvan 10ebc6cbdc Implement CSRF protection and security hardening across the application (#4179) 2 일 전
  Harry NG a1b2382877 chore: fix shadowrocketUrl client (#4183) 2 일 전
  MHSanaei 59c55dfc92 fix(panel-update): poll for restart, fix dark-mode version label 2 일 전
  MHSanaei 28a3dddb60 refactor(fallbacks): share template, tighter UX, cleaner JSON 2 일 전
  MHSanaei 39bf31bd56 fix(tun): use single mtu number per Xray spec 2 일 전
  MHSanaei 42b2ebc00b refactor(xhttp): split fields by direction, expand outbound coverage 2 일 전
50개의 변경된 파일1400개의 추가작업 그리고 442개의 파일을 삭제
  1. 3 3
      go.mod
  2. 4 4
      go.sum
  3. 14 2
      install.sh
  4. 95 48
      sub/subService.go
  5. 14 8
      update.sh
  6. 0 0
      web/assets/css/custom.min.css
  7. 6 0
      web/assets/js/axios-init.js
  8. 168 142
      web/assets/js/model/inbound.js
  9. 105 1
      web/assets/js/model/outbound.js
  10. 0 1
      web/assets/js/model/reality_targets.js
  11. 3 2
      web/assets/js/subscription.js
  12. 16 1
      web/assets/js/websocket.js
  13. 2 0
      web/controller/api.go
  14. 47 17
      web/controller/index.go
  15. 99 0
      web/controller/login_limiter.go
  16. 74 0
      web/controller/login_limiter_test.go
  17. 7 0
      web/controller/util.go
  18. 11 84
      web/controller/websocket.go
  19. 3 0
      web/controller/xui.go
  20. 2 1
      web/html/common/page.html
  21. 85 0
      web/html/form/fallbacks.html
  22. 129 15
      web/html/form/outbound.html
  23. 1 29
      web/html/form/protocol/trojan.html
  24. 2 5
      web/html/form/protocol/tun.html
  25. 1 29
      web/html/form/protocol/vless.html
  26. 5 14
      web/html/form/stream/stream_xhttp.html
  27. 22 2
      web/html/index.html
  28. 11 0
      web/html/modals/xray_outbound_modal.html
  29. 47 0
      web/middleware/security.go
  30. 121 0
      web/middleware/security_test.go
  31. 19 7
      web/service/tgbot.go
  32. 13 0
      web/service/tgbot_test.go
  33. 115 0
      web/service/websocket.go
  34. 55 0
      web/session/csrf.go
  35. 1 0
      web/session/session.go
  36. 1 1
      web/translation/translate.ar_EG.toml
  37. 1 1
      web/translation/translate.en_US.toml
  38. 1 1
      web/translation/translate.es_ES.toml
  39. 1 1
      web/translation/translate.fa_IR.toml
  40. 1 1
      web/translation/translate.id_ID.toml
  41. 1 1
      web/translation/translate.ja_JP.toml
  42. 1 1
      web/translation/translate.pt_BR.toml
  43. 1 1
      web/translation/translate.ru_RU.toml
  44. 1 1
      web/translation/translate.tr_TR.toml
  45. 1 1
      web/translation/translate.uk_UA.toml
  46. 1 1
      web/translation/translate.vi_VN.toml
  47. 1 1
      web/translation/translate.zh_CN.toml
  48. 1 1
      web/translation/translate.zh_TW.toml
  49. 16 2
      web/web.go
  50. 71 12
      x-ui.sh

+ 3 - 3
go.mod

@@ -1,6 +1,6 @@
 module github.com/mhsanaei/3x-ui/v2
 
-go 1.26.2
+go 1.26.3
 
 require (
 	github.com/gin-contrib/gzip v1.2.6
@@ -19,7 +19,7 @@ require (
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.4
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/valyala/fasthttp v1.70.0
+	github.com/valyala/fasthttp v1.71.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.0
 	go.uber.org/atomic v1.11.0
@@ -95,7 +95,7 @@ require (
 	golang.org/x/tools v0.44.0 // indirect
 	golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
 	golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
-	google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 // indirect
+	google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 // indirect
 	google.golang.org/protobuf v1.36.11 // indirect
 	gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
 	lukechampine.com/blake3 v1.4.1 // indirect

+ 4 - 4
go.sum

@@ -185,8 +185,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
 github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.70.0 h1:LAhMGcWk13QZWm85+eg8ZBNbrq5mnkWFGbHMUJHIdXA=
-github.com/valyala/fasthttp v1.70.0/go.mod h1:oDZEHHkJ/Buyklg6uURmYs19442zFSnCIfX3j1FY3pE=
+github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
+github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
 github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
 github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
@@ -256,8 +256,8 @@ golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+Z
 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
 gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
 gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4 h1:tEkOQcXgF6dH1G+MVKZrfpYvozGrzb91k6ha7jireSM=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20260427160629-7cedc36a6bc4/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348 h1:pfIbyB44sWzHiCpRqIen67ZQnVXSfIxWrqUMk1qwODE=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260504160031-60b97b32f348/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
 google.golang.org/grpc v1.81.0 h1:W3G9N3KQf3BU+YuCtGKJk0CmxQNbAISICD/9AORxLIw=
 google.golang.org/grpc v1.81.0/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
 google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=

+ 14 - 2
install.sh

@@ -678,13 +678,25 @@ config_after_install() {
     for ip_address in "${URL_lists[@]}"; do
         local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
         local http_code=$(echo "$response" | tail -n1)
-        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
-        if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
+        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
+        if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
             server_ip="${ip_result}"
             break
         fi
     done
 
+    if [[ -z "$server_ip" ]]; then
+        echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
+        while [[ -z "$server_ip" ]]; do
+            read -rp "Please enter your server's public IPv4 address: " server_ip
+            server_ip="${server_ip// /}"
+            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                server_ip=""
+            fi
+        done
+    fi
+
     if [[ ${#existing_webBasePath} -lt 4 ]]; then
         if [[ "$existing_hasDefaultCredential" == "true" ]]; then
             local config_webBasePath=$(gen_random_string 18)

+ 95 - 48
sub/subService.go

@@ -582,28 +582,18 @@ func applyShareNetworkParams(stream map[string]any, streamNetwork string, params
 		applyPathAndHostParams(httpupgrade, params)
 	case "xhttp":
 		xhttp, _ := stream["xhttpSettings"].(map[string]any)
-		applyPathAndHostParams(xhttp, params)
-		params["mode"], _ = xhttp["mode"].(string)
-		applyXhttpPaddingParams(xhttp, params)
+		applyXhttpExtraParams(xhttp, params)
 	}
 }
 
-func applyXhttpPaddingObj(xhttp map[string]any, obj map[string]any) {
-	// VMess base64 JSON supports arbitrary keys; copy the padding
-	// settings through so clients can match the server's xhttp
-	// xPaddingBytes range and, when the admin opted into obfs
-	// mode, the custom key / header / placement / method.
+// applyXhttpExtraObj copies the bidirectional xhttp settings into the
+// VMess base64 JSON link object. VMess supports arbitrary keys, so we
+// flatten the SplitHTTPConfig "extra" fields directly onto obj.
+func applyXhttpExtraObj(xhttp map[string]any, obj map[string]any) {
 	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
 		obj["x_padding_bytes"] = xpb
 	}
-	if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
-		obj["xPaddingObfsMode"] = true
-		for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
-			if v, ok := xhttp[field].(string); ok && len(v) > 0 {
-				obj[field] = v
-			}
-		}
-	}
+	maps.Copy(obj, buildXhttpExtra(xhttp))
 }
 
 func applyVmessNetworkParams(stream map[string]any, network string, obj map[string]any) {
@@ -639,8 +629,10 @@ func applyVmessNetworkParams(stream map[string]any, network string, obj map[stri
 	case "xhttp":
 		xhttp, _ := stream["xhttpSettings"].(map[string]any)
 		applyPathAndHostObj(xhttp, obj)
-		obj["mode"], _ = xhttp["mode"].(string)
-		applyXhttpPaddingObj(xhttp, obj)
+		if mode, ok := xhttp["mode"].(string); ok {
+			obj["mode"] = mode
+		}
+		applyXhttpExtraObj(xhttp, obj)
 	}
 }
 
@@ -928,45 +920,33 @@ func searchKey(data any, key string) (any, bool) {
 	return nil, false
 }
 
-// applyXhttpPaddingParams copies the xPadding* fields from an xhttpSettings
-// map into the URL query params of a vless:// / trojan:// / ss:// link.
+// buildXhttpExtra walks an xhttpSettings map and returns the JSON blob
+// that goes into the URL's `extra` param (or, for VMess, the link
+// object). Carries ONLY the bidirectional fields from xray-core's
+// SplitHTTPConfig — i.e. the ones the server enforces and the client
+// must match. Strictly one-sided fields are excluded:
 //
-// Before this helper existed, only path / host / mode were propagated,
-// so a server configured with a non-default xPaddingBytes (e.g. 80-600)
-// or with xPaddingObfsMode=true + custom xPaddingKey / xPaddingHeader
-// would silently diverge from the client: the client kept defaults,
-// hit the server, and was rejected by its padding validation
-// ("invalid padding" in the inbound log) — the client-visible symptom
-// was "xhttp doesn't connect" on OpenWRT / sing-box.
-//
-// Two encodings are written so every popular client can read at least one:
-//
-//   - x_padding_bytes=<range>  — flat param, understood by sing-box and its
-//     derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
-//   - extra=<url-encoded-json> — full xhttp settings blob, which is how
-//     xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
-//     obfs-mode key / header / placement / method.
+//   - server-only (noSSEHeader, scMaxBufferedPosts, scStreamUpServerSecs,
+//     serverMaxHeaderBytes) — client wouldn't read them, so emitting
+//     them just bloats the URL.
+//   - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
+//     noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) — the
+//     inbound config doesn't have them; the client configures them
+//     locally.
 //
-// Anything that doesn't map to a non-empty value is skipped, so simple
-// inbounds (no custom padding) produce exactly the same URL as before.
-func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
+// Truthy-only guards keep default inbounds emitting the same compact URL
+// they did before this helper grew.
+func buildXhttpExtra(xhttp map[string]any) map[string]any {
 	if xhttp == nil {
-		return
-	}
-
-	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
-		params["x_padding_bytes"] = xpb
+		return nil
 	}
-
 	extra := map[string]any{}
+
 	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
 		extra["xPaddingBytes"] = xpb
 	}
 	if obfs, ok := xhttp["xPaddingObfsMode"].(bool); ok && obfs {
 		extra["xPaddingObfsMode"] = true
-		// The obfs-mode-only fields: only populate the ones the admin
-		// actually set, so xray-core falls back to its own defaults for
-		// the rest instead of seeing spurious empty strings.
 		for _, field := range []string{"xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"} {
 			if v, ok := xhttp[field].(string); ok && len(v) > 0 {
 				extra[field] = v
@@ -974,7 +954,74 @@ func applyXhttpPaddingParams(xhttp map[string]any, params map[string]string) {
 		}
 	}
 
-	if len(extra) > 0 {
+	stringFields := []string{
+		"sessionPlacement", "sessionKey",
+		"seqPlacement", "seqKey",
+		"uplinkDataPlacement", "uplinkDataKey",
+		"scMaxEachPostBytes",
+	}
+	for _, field := range stringFields {
+		if v, ok := xhttp[field].(string); ok && len(v) > 0 {
+			extra[field] = v
+		}
+	}
+
+	// Headers — emitted as the {name: value} map upstream's struct
+	// expects. The server runtime ignores this field, but the client
+	// (consuming the share link) honors it. Drop any "host" entry —
+	// host already wins as a top-level URL param.
+	if rawHeaders, ok := xhttp["headers"].(map[string]any); ok && len(rawHeaders) > 0 {
+		out := map[string]any{}
+		for k, v := range rawHeaders {
+			if strings.EqualFold(k, "host") {
+				continue
+			}
+			out[k] = v
+		}
+		if len(out) > 0 {
+			extra["headers"] = out
+		}
+	}
+
+	if len(extra) == 0 {
+		return nil
+	}
+	return extra
+}
+
+// applyXhttpExtraParams emits the full xhttp config into the URL query
+// params of a vless:// / trojan:// / ss:// link. Sets path/host/mode at
+// top level (xray's Build() always lets these win over `extra`) and packs
+// everything else into a JSON `extra` param. Also writes the flat
+// `x_padding_bytes` param sing-box-family clients understand.
+//
+// Without this, the admin's custom xPaddingBytes / sessionKey / etc. never
+// reach the client and handshakes are silently rejected with
+// `invalid padding (...) length: 0` — the client-visible symptom is
+// "xhttp doesn't connect" on OpenWRT / sing-box.
+//
+// Two encodings are written so every popular client can read at least one:
+//
+//   - x_padding_bytes=<range>  — flat param, understood by sing-box and its
+//     derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
+//   - extra=<url-encoded-json> — full xhttp settings blob, which is how
+//     xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
+//     bidirectional fields beyond path/host/mode.
+func applyXhttpExtraParams(xhttp map[string]any, params map[string]string) {
+	if xhttp == nil {
+		return
+	}
+	applyPathAndHostParams(xhttp, params)
+	if mode, ok := xhttp["mode"].(string); ok {
+		params["mode"] = mode
+	}
+
+	if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 {
+		params["x_padding_bytes"] = xpb
+	}
+
+	extra := buildXhttpExtra(xhttp)
+	if extra != nil {
 		if b, err := json.Marshal(extra); err == nil {
 			params["extra"] = string(b)
 		}

+ 14 - 8
update.sh

@@ -711,13 +711,25 @@ config_after_update() {
     for ip_address in "${URL_lists[@]}"; do
         local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
         local http_code=$(echo "$response" | tail -n1)
-        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
-        if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
+        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
+        if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
             server_ip="${ip_result}"
             break
         fi
     done
 
+    if [[ -z "$server_ip" ]]; then
+        echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
+        while [[ -z "$server_ip" ]]; do
+            read -rp "Please enter your server's public IPv4 address: " server_ip
+            server_ip="${server_ip// /}"
+            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                server_ip=""
+            fi
+        done
+    fi
+
     # Handle missing/short webBasePath
     if [[ ${#existing_webBasePath} -lt 4 ]]; then
         echo -e "${yellow}WebBasePath is missing or too short. Generating a new one...${plain}"
@@ -737,12 +749,6 @@ config_after_update() {
         echo -e "${yellow}Let's Encrypt now supports both domains and IP addresses!${plain}"
         echo ""
 
-        if [[ -z "${server_ip}" ]]; then
-            echo -e "${red}Failed to detect server IP${plain}"
-            echo -e "${yellow}Please configure SSL manually using: x-ui${plain}"
-            return
-        fi
-
         # Prompt and setup SSL (domain or IP)
         prompt_and_setup_ssl "${existing_port}" "${existing_webBasePath}" "${server_ip}"
 

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 0 - 0
web/assets/css/custom.min.css


+ 6 - 0
web/assets/js/axios-init.js

@@ -3,6 +3,12 @@ axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
 
 axios.interceptors.request.use(
     (config) => {
+        config.headers = config.headers || {};
+        const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
+        const method = (config.method || 'get').toUpperCase();
+        if (csrfToken && !['GET', 'HEAD', 'OPTIONS', 'TRACE'].includes(method)) {
+            config.headers['X-CSRF-Token'] = csrfToken;
+        }
         if (config.data instanceof FormData) {
             config.headers['Content-Type'] = 'multipart/form-data';
         } else {

+ 168 - 142
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());
     }
@@ -472,54 +485,67 @@ class HTTPUpgradeStreamSettings extends XrayCommonClass {
     }
 }
 
+// Mirrors the inbound (server-side) view of Xray-core's SplitHTTPConfig
+// (infra/conf/transport_internet.go). Only fields the server actually
+// reads at runtime, plus the bidirectional fields the server enforces,
+// live here. Client-only fields (uplinkHTTPMethod, uplinkChunkSize,
+// noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) belong on
+// the outbound class instead.
+//
+// `headers` is technically client-only at runtime (xray's listener
+// doesn't read it) but we keep it here so the admin can set request
+// headers that get embedded into the share link's `extra` blob — the
+// client picks them up from there.
 class xHTTPStreamSettings extends XrayCommonClass {
     constructor(
+        // Bidirectional — must match between client and server
         path = '/',
         host = '',
-        headers = [],
-        scMaxBufferedPosts = 30,
-        scMaxEachPostBytes = "1000000",
-        scStreamUpServerSecs = "20-80",
-        noSSEHeader = false,
-        xPaddingBytes = "100-1000",
         mode = MODE_OPTION.AUTO,
+        xPaddingBytes = "100-1000",
         xPaddingObfsMode = false,
         xPaddingKey = '',
         xPaddingHeader = '',
         xPaddingPlacement = '',
         xPaddingMethod = '',
-        uplinkHTTPMethod = '',
         sessionPlacement = '',
         sessionKey = '',
         seqPlacement = '',
         seqKey = '',
         uplinkDataPlacement = '',
         uplinkDataKey = '',
-        uplinkChunkSize = 0,
+        scMaxEachPostBytes = "1000000",
+        // Server-side only
+        noSSEHeader = false,
+        scMaxBufferedPosts = 30,
+        scStreamUpServerSecs = "20-80",
+        serverMaxHeaderBytes = 0,
+        // URL-share only — embedded in the link's `extra` blob so clients
+        // pick them up; xray's listener ignores them at runtime.
+        headers = [],
     ) {
         super();
         this.path = path;
         this.host = host;
-        this.headers = headers;
-        this.scMaxBufferedPosts = scMaxBufferedPosts;
-        this.scMaxEachPostBytes = scMaxEachPostBytes;
-        this.scStreamUpServerSecs = scStreamUpServerSecs;
-        this.noSSEHeader = noSSEHeader;
-        this.xPaddingBytes = xPaddingBytes;
         this.mode = mode;
+        this.xPaddingBytes = xPaddingBytes;
         this.xPaddingObfsMode = xPaddingObfsMode;
         this.xPaddingKey = xPaddingKey;
         this.xPaddingHeader = xPaddingHeader;
         this.xPaddingPlacement = xPaddingPlacement;
         this.xPaddingMethod = xPaddingMethod;
-        this.uplinkHTTPMethod = uplinkHTTPMethod;
         this.sessionPlacement = sessionPlacement;
         this.sessionKey = sessionKey;
         this.seqPlacement = seqPlacement;
         this.seqKey = seqKey;
         this.uplinkDataPlacement = uplinkDataPlacement;
         this.uplinkDataKey = uplinkDataKey;
-        this.uplinkChunkSize = uplinkChunkSize;
+        this.scMaxEachPostBytes = scMaxEachPostBytes;
+        this.noSSEHeader = noSSEHeader;
+        this.scMaxBufferedPosts = scMaxBufferedPosts;
+        this.scStreamUpServerSecs = scStreamUpServerSecs;
+        this.serverMaxHeaderBytes = serverMaxHeaderBytes;
+        this.headers = headers;
     }
 
     addHeader(name, value) {
@@ -534,26 +560,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
         return new xHTTPStreamSettings(
             json.path,
             json.host,
-            XrayCommonClass.toHeaders(json.headers),
-            json.scMaxBufferedPosts,
-            json.scMaxEachPostBytes,
-            json.scStreamUpServerSecs,
-            json.noSSEHeader,
-            json.xPaddingBytes,
             json.mode,
+            json.xPaddingBytes,
             json.xPaddingObfsMode,
             json.xPaddingKey,
             json.xPaddingHeader,
             json.xPaddingPlacement,
             json.xPaddingMethod,
-            json.uplinkHTTPMethod,
             json.sessionPlacement,
             json.sessionKey,
             json.seqPlacement,
             json.seqKey,
             json.uplinkDataPlacement,
             json.uplinkDataKey,
-            json.uplinkChunkSize,
+            json.scMaxEachPostBytes,
+            json.noSSEHeader,
+            json.scMaxBufferedPosts,
+            json.scStreamUpServerSecs,
+            json.serverMaxHeaderBytes,
+            XrayCommonClass.toHeaders(json.headers),
         );
     }
 
@@ -561,26 +586,25 @@ class xHTTPStreamSettings extends XrayCommonClass {
         return {
             path: this.path,
             host: this.host,
-            headers: XrayCommonClass.toV2Headers(this.headers, false),
-            scMaxBufferedPosts: this.scMaxBufferedPosts,
-            scMaxEachPostBytes: this.scMaxEachPostBytes,
-            scStreamUpServerSecs: this.scStreamUpServerSecs,
-            noSSEHeader: this.noSSEHeader,
-            xPaddingBytes: this.xPaddingBytes,
             mode: this.mode,
+            xPaddingBytes: this.xPaddingBytes,
             xPaddingObfsMode: this.xPaddingObfsMode,
             xPaddingKey: this.xPaddingKey,
             xPaddingHeader: this.xPaddingHeader,
             xPaddingPlacement: this.xPaddingPlacement,
             xPaddingMethod: this.xPaddingMethod,
-            uplinkHTTPMethod: this.uplinkHTTPMethod,
             sessionPlacement: this.sessionPlacement,
             sessionKey: this.sessionKey,
             seqPlacement: this.seqPlacement,
             seqKey: this.seqKey,
             uplinkDataPlacement: this.uplinkDataPlacement,
             uplinkDataKey: this.uplinkDataKey,
-            uplinkChunkSize: this.uplinkChunkSize,
+            scMaxEachPostBytes: this.scMaxEachPostBytes,
+            noSSEHeader: this.noSSEHeader,
+            scMaxBufferedPosts: this.scMaxBufferedPosts,
+            scStreamUpServerSecs: this.scStreamUpServerSecs,
+            serverMaxHeaderBytes: this.serverMaxHeaderBytes,
+            headers: XrayCommonClass.toV2Headers(this.headers, false),
         };
     }
 }
@@ -1523,26 +1547,39 @@ class Inbound extends XrayCommonClass {
         return this.clientStats;
     }
 
-    // Copy the xPadding* settings into the query-string of a vless/trojan/ss
-    // link. Without this, the admin's custom xPaddingBytes range and (in
-    // obfs mode) the custom xPaddingKey / xPaddingHeader / placement /
-    // method never reach the client — the client keeps xray / sing-box's
-    // internal defaults and the server rejects every handshake with
-    // `invalid padding (...) length: 0`.
+    // Looks for a "host"-named entry in xhttp.headers and returns its value,
+    // or '' if not found. Used as a fallback when xhttp.host is empty so the
+    // share URL still carries a usable Host hint.
+    static xhttpHostFallback(xhttp) {
+        if (!xhttp || !Array.isArray(xhttp.headers)) return '';
+        for (const h of xhttp.headers) {
+            if (h && typeof h.name === 'string' && h.name.toLowerCase() === 'host') {
+                return h.value || '';
+            }
+        }
+        return '';
+    }
+
+    // Build the JSON blob that goes into the URL's `extra` param (or, for
+    // VMess, into the base64-encoded link object). Carries ONLY the
+    // bidirectional fields from xray-core's SplitHTTPConfig — i.e. the
+    // ones the server enforces and the client must match. Strictly
+    // one-sided fields are excluded:
     //
-    // Two encodings are emitted so each client family can pick at least
-    // one up:
-    //   - x_padding_bytes=<range>       flat, for sing-box-family clients
-    //   - extra=<url-encoded-json>       full blob, for xray-core clients
+    //   - server-only (noSSEHeader, scMaxBufferedPosts,
+    //     scStreamUpServerSecs, serverMaxHeaderBytes) — client wouldn't
+    //     read them, so emitting them just bloats the URL.
+    //   - client-only (headers, uplinkHTTPMethod, uplinkChunkSize,
+    //     noGRPCHeader, scMinPostsIntervalMs, xmux, downloadSettings) —
+    //     not on the inbound class at all; the client configures them
+    //     locally.
     //
-    // Fields are only included when they actually have a value, so a
-    // default inbound yields the same URL it did before this helper.
-    static applyXhttpPaddingToParams(xhttp, params) {
-        if (!xhttp) return;
-        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
-            params.set("x_padding_bytes", xhttp.xPaddingBytes);
-        }
+    // Truthy-only guards keep default inbounds emitting the same compact
+    // URL they did before this helper grew.
+    static buildXhttpExtra(xhttp) {
+        if (!xhttp) return null;
         const extra = {};
+
         if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
             extra.xPaddingBytes = xhttp.xPaddingBytes;
         }
@@ -1554,26 +1591,73 @@ class Inbound extends XrayCommonClass {
                 }
             });
         }
-        if (Object.keys(extra).length > 0) {
-            params.set("extra", JSON.stringify(extra));
+
+        const stringFields = [
+            "sessionPlacement", "sessionKey",
+            "seqPlacement", "seqKey",
+            "uplinkDataPlacement", "uplinkDataKey",
+            "scMaxEachPostBytes",
+        ];
+        for (const k of stringFields) {
+            const v = xhttp[k];
+            if (typeof v === 'string' && v.length > 0) extra[k] = v;
+        }
+
+        // Headers — emitted as the {name: value} map upstream's struct
+        // expects. The server runtime ignores this field, but the client
+        // (consuming the share link) honors it.
+        if (Array.isArray(xhttp.headers) && xhttp.headers.length > 0) {
+            const headersMap = {};
+            for (const h of xhttp.headers) {
+                if (h && h.name && h.name.toLowerCase() !== 'host') {
+                    headersMap[h.name] = h.value || '';
+                }
+            }
+            if (Object.keys(headersMap).length > 0) extra.headers = headersMap;
+        }
+
+        return Object.keys(extra).length > 0 ? extra : null;
+    }
+
+    // Inject the inbound-side xhttp config into URL query params for
+    // vless/trojan/ss links. Sets path/host/mode at top level (xray's
+    // Build() always lets these win over `extra`) and packs the
+    // bidirectional fields into a JSON `extra` param. Also writes the
+    // flat `x_padding_bytes` param sing-box-family clients understand.
+    //
+    // Without this, the admin's custom xPaddingBytes / sessionKey / etc.
+    // never reach the client and handshakes are silently rejected with
+    // `invalid padding (...) length: 0`.
+    static applyXhttpExtraToParams(xhttp, params) {
+        if (!xhttp) return;
+        params.set("path", xhttp.path);
+        const host = xhttp.host?.length > 0 ? xhttp.host : Inbound.xhttpHostFallback(xhttp);
+        params.set("host", host);
+        params.set("mode", xhttp.mode);
+
+        // Flat fallback for sing-box-family clients that don't read `extra`.
+        if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
+            params.set("x_padding_bytes", xhttp.xPaddingBytes);
         }
+
+        const extra = Inbound.buildXhttpExtra(xhttp);
+        if (extra) params.set("extra", JSON.stringify(extra));
     }
 
     // VMess variant: VMess links are a base64-encoded JSON object, so we
-    // copy the padding fields directly into the JSON instead of building
-    // a query string.
-    static applyXhttpPaddingToObj(xhttp, obj) {
+    // copy the same bidirectional fields directly into the JSON instead
+    // of building a query string. (The base VMess link generator already
+    // sets net/type/path/host, so we only contribute the SplitHTTPConfig
+    // extra side here.)
+    static applyXhttpExtraToObj(xhttp, obj) {
         if (!xhttp || !obj) return;
         if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) {
             obj.x_padding_bytes = xhttp.xPaddingBytes;
         }
-        if (xhttp.xPaddingObfsMode === true) {
-            obj.xPaddingObfsMode = true;
-            ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => {
-                if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) {
-                    obj[k] = xhttp[k];
-                }
-            });
+        const extra = Inbound.buildXhttpExtra(xhttp);
+        if (!extra) return;
+        for (const [k, v] of Object.entries(extra)) {
+            obj[k] = v;
         }
     }
 
@@ -1839,7 +1923,7 @@ class Inbound extends XrayCommonClass {
             obj.path = xhttp.path;
             obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
             obj.type = xhttp.mode;
-            Inbound.applyXhttpPaddingToObj(xhttp, obj);
+            Inbound.applyXhttpExtraToObj(xhttp, obj);
         }
 
         Inbound.applyFinalMaskToObj(this.stream.finalmask, obj);
@@ -1904,11 +1988,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 
@@ -2009,11 +2089,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 
@@ -2090,11 +2166,7 @@ class Inbound extends XrayCommonClass {
                 params.set("host", httpupgrade.host?.length > 0 ? httpupgrade.host : this.getHeader(httpupgrade, 'host'));
                 break;
             case "xhttp":
-                const xhttp = this.stream.xhttp;
-                params.set("path", xhttp.path);
-                params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
-                params.set("mode", xhttp.mode);
-                Inbound.applyXhttpPaddingToParams(xhttp, params);
+                Inbound.applyXhttpExtraToParams(this.stream.xhttp, params);
                 break;
         }
 
@@ -2674,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,
+        ));
     }
 };
 
@@ -2727,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;
     }
 };
 
@@ -2769,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,
+        ));
     }
 };
 
@@ -3138,7 +3177,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
     constructor(
         protocol,
         name = 'xray0',
-        mtu = [1500, 1280],
+        mtu = 1500,
         gateway = [],
         dns = [],
         userLevel = 0,
@@ -3147,7 +3186,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
     ) {
         super(protocol);
         this.name = name;
-        this.mtu = this._normalizeMtu(mtu);
+        this.mtu = Number(mtu) || 1500;
         this.gateway = Array.isArray(gateway) ? gateway : [];
         this.dns = Array.isArray(dns) ? dns : [];
         this.userLevel = userLevel;
@@ -3155,26 +3194,13 @@ Inbound.TunSettings = class extends Inbound.Settings {
         this.autoOutboundsInterface = autoOutboundsInterface;
     }
 
-    _normalizeMtu(mtu) {
-        if (!Array.isArray(mtu)) {
-            const single = Number(mtu) || 1500;
-            return [single, single];
-        }
-        if (mtu.length === 0) {
-            return [1500, 1280];
-        }
-        if (mtu.length === 1) {
-            const single = Number(mtu[0]) || 1500;
-            return [single, single];
-        }
-        return [Number(mtu[0]) || 1500, Number(mtu[1]) || 1280];
-    }
-
     static fromJson(json = {}) {
+        const rawMtu = json.mtu ?? json.MTU;
+        const mtu = Array.isArray(rawMtu) ? rawMtu[0] : rawMtu;
         return new Inbound.TunSettings(
             Protocols.TUN,
             json.name ?? 'xray0',
-            json.mtu ?? json.MTU ?? [1500, 1280],
+            mtu ?? 1500,
             json.gateway ?? json.Gateway ?? [],
             json.dns ?? json.DNS ?? [],
             json.userLevel ?? 0,
@@ -3186,7 +3212,7 @@ Inbound.TunSettings = class extends Inbound.Settings {
     toJson() {
         return {
             name: this.name || 'xray0',
-            mtu: this._normalizeMtu(this.mtu),
+            mtu: Number(this.mtu) || 1500,
             gateway: this.gateway,
             dns: this.dns,
             userLevel: this.userLevel || 0,

+ 105 - 1
web/assets/js/model/outbound.js

@@ -402,11 +402,35 @@ class HttpUpgradeStreamSettings extends CommonClass {
     }
 }
 
+// Mirrors the outbound (client-side) view of Xray-core's SplitHTTPConfig
+// (infra/conf/transport_internet.go). Only fields the client actually
+// reads at runtime, plus the bidirectional fields the client must match
+// against the server, live here. Server-only fields (noSSEHeader,
+// scMaxBufferedPosts, scStreamUpServerSecs, serverMaxHeaderBytes) belong
+// on the inbound class instead.
 class xHTTPStreamSettings extends CommonClass {
     constructor(
+        // Bidirectional — must match the inbound side
         path = '/',
         host = '',
         mode = '',
+        xPaddingBytes = "100-1000",
+        xPaddingObfsMode = false,
+        xPaddingKey = '',
+        xPaddingHeader = '',
+        xPaddingPlacement = '',
+        xPaddingMethod = '',
+        sessionPlacement = '',
+        sessionKey = '',
+        seqPlacement = '',
+        seqKey = '',
+        uplinkDataPlacement = '',
+        uplinkDataKey = '',
+        scMaxEachPostBytes = "1000000",
+        // Client-side only
+        headers = [],
+        uplinkHTTPMethod = '',
+        uplinkChunkSize = 0,
         noGRPCHeader = false,
         scMinPostsIntervalMs = "30",
         xmux = {
@@ -417,32 +441,112 @@ class xHTTPStreamSettings extends CommonClass {
             hMaxReusableSecs: "1800-3000",
             hKeepAlivePeriod: 0,
         },
+        // UI-only toggle — controls whether the XMUX block is expanded in
+        // the form (mirrors the QUIC Params switch in stream_finalmask).
+        // Never serialized; toJson() only emits the xmux block itself.
+        enableXmux = false,
     ) {
         super();
         this.path = path;
         this.host = host;
         this.mode = mode;
+        this.xPaddingBytes = xPaddingBytes;
+        this.xPaddingObfsMode = xPaddingObfsMode;
+        this.xPaddingKey = xPaddingKey;
+        this.xPaddingHeader = xPaddingHeader;
+        this.xPaddingPlacement = xPaddingPlacement;
+        this.xPaddingMethod = xPaddingMethod;
+        this.sessionPlacement = sessionPlacement;
+        this.sessionKey = sessionKey;
+        this.seqPlacement = seqPlacement;
+        this.seqKey = seqKey;
+        this.uplinkDataPlacement = uplinkDataPlacement;
+        this.uplinkDataKey = uplinkDataKey;
+        this.scMaxEachPostBytes = scMaxEachPostBytes;
+        this.headers = headers;
+        this.uplinkHTTPMethod = uplinkHTTPMethod;
+        this.uplinkChunkSize = uplinkChunkSize;
         this.noGRPCHeader = noGRPCHeader;
         this.scMinPostsIntervalMs = scMinPostsIntervalMs;
         this.xmux = xmux;
+        this.enableXmux = enableXmux;
+    }
+
+    addHeader(name, value) {
+        this.headers.push({ name: name, value: value });
+    }
+
+    removeHeader(index) {
+        this.headers.splice(index, 1);
     }
 
     static fromJson(json = {}) {
+        const headersInput = json.headers;
+        let headers = [];
+        if (Array.isArray(headersInput)) {
+            headers = headersInput;
+        } else if (headersInput && typeof headersInput === 'object') {
+            // Upstream uses a {name: value} map; convert to the panel's [{name, value}] form.
+            headers = Object.entries(headersInput).map(([name, value]) => ({ name, value }));
+        }
         return new xHTTPStreamSettings(
             json.path,
             json.host,
             json.mode,
+            json.xPaddingBytes,
+            json.xPaddingObfsMode,
+            json.xPaddingKey,
+            json.xPaddingHeader,
+            json.xPaddingPlacement,
+            json.xPaddingMethod,
+            json.sessionPlacement,
+            json.sessionKey,
+            json.seqPlacement,
+            json.seqKey,
+            json.uplinkDataPlacement,
+            json.uplinkDataKey,
+            json.scMaxEachPostBytes,
+            headers,
+            json.uplinkHTTPMethod,
+            json.uplinkChunkSize,
             json.noGRPCHeader,
             json.scMinPostsIntervalMs,
-            json.xmux
+            json.xmux,
+            // Auto-toggle the XMUX switch on when an existing outbound has
+            // the xmux key saved, so users editing such configs see their
+            // values immediately.
+            json.xmux !== undefined,
         );
     }
 
     toJson() {
+        // Upstream expects headers as a {name: value} map, not a list of entries.
+        const headersMap = {};
+        if (Array.isArray(this.headers)) {
+            for (const h of this.headers) {
+                if (h && h.name) headersMap[h.name] = h.value || '';
+            }
+        }
         return {
             path: this.path,
             host: this.host,
             mode: this.mode,
+            xPaddingBytes: this.xPaddingBytes,
+            xPaddingObfsMode: this.xPaddingObfsMode,
+            xPaddingKey: this.xPaddingKey,
+            xPaddingHeader: this.xPaddingHeader,
+            xPaddingPlacement: this.xPaddingPlacement,
+            xPaddingMethod: this.xPaddingMethod,
+            sessionPlacement: this.sessionPlacement,
+            sessionKey: this.sessionKey,
+            seqPlacement: this.seqPlacement,
+            seqKey: this.seqKey,
+            uplinkDataPlacement: this.uplinkDataPlacement,
+            uplinkDataKey: this.uplinkDataKey,
+            scMaxEachPostBytes: this.scMaxEachPostBytes,
+            headers: headersMap,
+            uplinkHTTPMethod: this.uplinkHTTPMethod,
+            uplinkChunkSize: this.uplinkChunkSize,
             noGRPCHeader: this.noGRPCHeader,
             scMinPostsIntervalMs: this.scMinPostsIntervalMs,
             xmux: {

+ 0 - 1
web/assets/js/model/reality_targets.js

@@ -6,7 +6,6 @@ const REALITY_TARGETS = [
     { target: 'www.nvidia.com:443', sni: 'www.nvidia.com' },
     { target: 'www.amd.com:443', sni: 'www.amd.com' },
     { target: 'www.intel.com:443', sni: 'www.intel.com' },
-    { target: 'www.tesla.com:443', sni: 'www.tesla.com' },
     { target: 'www.sony.com:443', sni: 'www.sony.com' }
 ];
 

+ 3 - 2
web/assets/js/subscription.js

@@ -135,8 +135,9 @@
         return enabledOk && expiryOk && trafficOk;
       },
       shadowrocketUrl() {
-        const rawUrl = this.app.subUrl + '?flag=shadowrocket';
-        const base64Url = btoa(rawUrl);
+        const separator = this.app.subUrl.includes('?') ? '&' : '?';
+        const rawUrl = this.app.subUrl + separator + 'flag=shadowrocket';
+        const base64Url = encodeURIComponent(btoa(rawUrl));
         const remark = encodeURIComponent(this.app.sId || 'Subscription');
         return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
       },

+ 16 - 1
web/assets/js/websocket.js

@@ -104,15 +104,25 @@ class WebSocketClient {
     }
     this.ws = socket;
 
+    // Every handler must check `this.ws !== socket` first. A previous socket
+    // can still fire events (especially `close`) after we've moved on to a
+    // new one — e.g. connect() called while the old socket is in CLOSING
+    // state. Without the guard, a stale close would null out the freshly
+    // opened socket and silently break send().
     socket.addEventListener('open', () => {
+      if (this.ws !== socket) return;
       this.isConnected = true;
       this.reconnectAttempts = 0;
       this.#emit('connected');
     });
 
-    socket.addEventListener('message', (event) => this.#onMessage(event));
+    socket.addEventListener('message', (event) => {
+      if (this.ws !== socket) return;
+      this.#onMessage(event);
+    });
 
     socket.addEventListener('error', (event) => {
+      if (this.ws !== socket) return;
       // Browsers fire 'error' before 'close' on failure. We surface it for
       // consumers (so polling fallbacks can engage) but don't log every blip
       // — bad networks would flood the console otherwise.
@@ -120,6 +130,7 @@ class WebSocketClient {
     });
 
     socket.addEventListener('close', () => {
+      if (this.ws !== socket) return;
       this.isConnected = false;
       this.ws = null;
       this.#emit('disconnected');
@@ -196,6 +207,10 @@ class WebSocketClient {
 
     this.reconnectTimer = setTimeout(() => {
       this.reconnectTimer = null;
+      // clearTimeout doesn't cancel a callback that has already fired but
+      // whose macrotask hasn't run yet — re-check shouldReconnect here so
+      // disconnect() called in that window can't be overridden.
+      if (!this.shouldReconnect) return;
       this.#openSocket();
     }, delay);
   }

+ 2 - 0
web/controller/api.go

@@ -3,6 +3,7 @@ package controller
 import (
 	"net/http"
 
+	"github.com/mhsanaei/3x-ui/v2/web/middleware"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
 	"github.com/mhsanaei/3x-ui/v2/web/session"
 
@@ -39,6 +40,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
 	// Main API group
 	api := g.Group("/panel/api")
 	api.Use(a.checkAPIAuth)
+	api.Use(middleware.CSRFMiddleware())
 
 	// Inbounds API
 	inbounds := api.Group("/inbounds")

+ 47 - 17
web/controller/index.go

@@ -1,12 +1,12 @@
 package controller
 
 import (
-	"fmt"
 	"net/http"
 	"text/template"
 	"time"
 
 	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/web/middleware"
 	"github.com/mhsanaei/3x-ui/v2/web/service"
 	"github.com/mhsanaei/3x-ui/v2/web/session"
 
@@ -41,8 +41,8 @@ func (a *IndexController) initRouter(g *gin.RouterGroup) {
 	g.GET("/", a.index)
 	g.GET("/logout", a.logout)
 
-	g.POST("/login", a.login)
-	g.POST("/getTwoFactorEnable", a.getTwoFactorEnable)
+	g.POST("/login", middleware.CSRFMiddleware(), a.login)
+	g.POST("/getTwoFactorEnable", middleware.CSRFMiddleware(), a.getTwoFactorEnable)
 }
 
 // index handles the root route, redirecting logged-in users to the panel or showing the login page.
@@ -71,28 +71,51 @@ func (a *IndexController) login(c *gin.Context) {
 		return
 	}
 
-	user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
-	timeStr := time.Now().Format("2006-01-02 15:04:05")
+	remoteIP := getRemoteIp(c)
 	safeUser := template.HTMLEscapeString(form.Username)
-	safePass := template.HTMLEscapeString(form.Password)
-
-	if user == nil {
-		logger.Warningf("wrong username: \"%s\", password: \"%s\", IP: \"%s\"", safeUser, safePass, getRemoteIp(c))
+	timeStr := time.Now().Format("2006-01-02 15:04:05")
+	if blockedUntil, ok := defaultLoginLimiter.allow(remoteIP, form.Username); !ok {
+		reason := "too many failed attempts"
+		logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
+		a.tgbot.UserLoginNotify(service.LoginAttempt{
+			Username: safeUser,
+			IP:       remoteIP,
+			Time:     timeStr,
+			Status:   service.LoginFail,
+			Reason:   reason,
+		})
+		pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
+		return
+	}
 
-		notifyPass := safePass
+	user, checkErr := a.userService.CheckUser(form.Username, form.Password, form.TwoFactorCode)
 
-		if checkErr != nil && checkErr.Error() == "invalid 2fa code" {
-			translatedError := a.tgbot.I18nBot("tgbot.messages.2faFailed")
-			notifyPass = fmt.Sprintf("*** (%s)", translatedError)
+	if user == nil {
+		reason := loginFailureReason(checkErr)
+		if blockedUntil, blocked := defaultLoginLimiter.registerFailure(remoteIP, form.Username); blocked {
+			logger.Warningf("failed login: username=%q, IP=%q, reason=%q, blocked_until=%s", safeUser, remoteIP, reason, blockedUntil.Format(time.RFC3339))
+		} else {
+			logger.Warningf("failed login: username=%q, IP=%q, reason=%q", safeUser, remoteIP, reason)
 		}
-
-		a.tgbot.UserLoginNotify(safeUser, notifyPass, getRemoteIp(c), timeStr, 0)
+		a.tgbot.UserLoginNotify(service.LoginAttempt{
+			Username: safeUser,
+			IP:       remoteIP,
+			Time:     timeStr,
+			Status:   service.LoginFail,
+			Reason:   reason,
+		})
 		pureJsonMsg(c, http.StatusOK, false, I18nWeb(c, "pages.login.toasts.wrongUsernameOrPassword"))
 		return
 	}
 
-	logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, getRemoteIp(c))
-	a.tgbot.UserLoginNotify(safeUser, ``, getRemoteIp(c), timeStr, 1)
+	defaultLoginLimiter.registerSuccess(remoteIP, form.Username)
+	logger.Infof("%s logged in successfully, Ip Address: %s\n", safeUser, remoteIP)
+	a.tgbot.UserLoginNotify(service.LoginAttempt{
+		Username: safeUser,
+		IP:       remoteIP,
+		Time:     timeStr,
+		Status:   service.LoginSuccess,
+	})
 
 	if err := session.SetLoginUser(c, user); err != nil {
 		logger.Warning("Unable to save session:", err)
@@ -103,6 +126,13 @@ func (a *IndexController) login(c *gin.Context) {
 	jsonMsg(c, I18nWeb(c, "pages.login.toasts.successLogin"), nil)
 }
 
+func loginFailureReason(err error) string {
+	if err != nil && err.Error() == "invalid 2fa code" {
+		return "invalid 2FA code"
+	}
+	return "invalid credentials"
+}
+
 // logout handles user logout by clearing the session and redirecting to the login page.
 func (a *IndexController) logout(c *gin.Context) {
 	user := session.GetLoginUser(c)

+ 99 - 0
web/controller/login_limiter.go

@@ -0,0 +1,99 @@
+package controller
+
+import (
+	"strings"
+	"sync"
+	"time"
+)
+
+const (
+	loginLimitMaxFailures = 5
+	loginLimitWindow      = 5 * time.Minute
+	loginLimitCooldown    = 15 * time.Minute
+)
+
+var defaultLoginLimiter = newLoginLimiter(loginLimitMaxFailures, loginLimitWindow, loginLimitCooldown)
+
+type loginLimiter struct {
+	mu          sync.Mutex
+	now         func() time.Time
+	maxFailures int
+	window      time.Duration
+	cooldown    time.Duration
+	attempts    map[string]*loginLimitRecord
+}
+
+type loginLimitRecord struct {
+	failures     []time.Time
+	blockedUntil time.Time
+}
+
+func newLoginLimiter(maxFailures int, window, cooldown time.Duration) *loginLimiter {
+	return &loginLimiter{
+		now:         time.Now,
+		maxFailures: maxFailures,
+		window:      window,
+		cooldown:    cooldown,
+		attempts:    make(map[string]*loginLimitRecord),
+	}
+}
+
+func (l *loginLimiter) allow(ip, username string) (time.Time, bool) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	key := loginLimitKey(ip, username)
+	record := l.attempts[key]
+	if record == nil {
+		return time.Time{}, true
+	}
+	now := l.now()
+	if now.Before(record.blockedUntil) {
+		return record.blockedUntil, false
+	}
+	record.blockedUntil = time.Time{}
+	record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
+	if len(record.failures) == 0 {
+		delete(l.attempts, key)
+	}
+	return time.Time{}, true
+}
+
+func (l *loginLimiter) registerFailure(ip, username string) (time.Time, bool) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+
+	key := loginLimitKey(ip, username)
+	record := l.attempts[key]
+	if record == nil {
+		record = &loginLimitRecord{}
+		l.attempts[key] = record
+	}
+	now := l.now()
+	record.failures = pruneLoginFailures(record.failures, now.Add(-l.window))
+	record.failures = append(record.failures, now)
+	if len(record.failures) >= l.maxFailures {
+		record.failures = nil
+		record.blockedUntil = now.Add(l.cooldown)
+		return record.blockedUntil, true
+	}
+	return time.Time{}, false
+}
+
+func (l *loginLimiter) registerSuccess(ip, username string) {
+	l.mu.Lock()
+	defer l.mu.Unlock()
+	delete(l.attempts, loginLimitKey(ip, username))
+}
+
+func loginLimitKey(ip, username string) string {
+	return strings.TrimSpace(ip) + "\x00" + strings.ToLower(strings.TrimSpace(username))
+}
+
+func pruneLoginFailures(failures []time.Time, cutoff time.Time) []time.Time {
+	keepFrom := 0
+	for keepFrom < len(failures) && failures[keepFrom].Before(cutoff) {
+		keepFrom++
+	}
+	return failures[keepFrom:]
+}

+ 74 - 0
web/controller/login_limiter_test.go

@@ -0,0 +1,74 @@
+package controller
+
+import (
+	"testing"
+	"time"
+)
+
+func TestLoginLimiterBlocksAfterConfiguredFailures(t *testing.T) {
+	now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
+	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
+	limiter.now = func() time.Time { return now }
+
+	for i := range 4 {
+		if _, blocked := limiter.registerFailure("192.0.2.10", "Admin"); blocked {
+			t.Fatalf("failure %d should not block yet", i+1)
+		}
+		if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
+			t.Fatalf("failure %d should still allow login attempts", i+1)
+		}
+	}
+
+	blockedUntil, blocked := limiter.registerFailure("192.0.2.10", "ADMIN")
+	if !blocked {
+		t.Fatal("fifth failure should start cooldown")
+	}
+	if want := now.Add(15 * time.Minute); !blockedUntil.Equal(want) {
+		t.Fatalf("blocked until %s, want %s", blockedUntil, want)
+	}
+	if _, ok := limiter.allow("192.0.2.10", "admin"); ok {
+		t.Fatal("login should be blocked during cooldown")
+	}
+
+	now = blockedUntil
+	if _, ok := limiter.allow("192.0.2.10", "admin"); !ok {
+		t.Fatal("login should be allowed after cooldown")
+	}
+}
+
+func TestLoginLimiterPrunesOldFailuresAndResetsOnSuccess(t *testing.T) {
+	now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
+	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
+	limiter.now = func() time.Time { return now }
+
+	for range 4 {
+		limiter.registerFailure("192.0.2.10", "admin")
+	}
+	now = now.Add(6 * time.Minute)
+	if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
+		t.Fatal("old failures should be pruned outside the rolling window")
+	}
+
+	limiter.registerSuccess("192.0.2.10", "admin")
+	for i := range 4 {
+		if _, blocked := limiter.registerFailure("192.0.2.10", "admin"); blocked {
+			t.Fatalf("success should reset previous failures; failure %d blocked", i+1)
+		}
+	}
+}
+
+func TestLoginLimiterSeparatesIPAndUsername(t *testing.T) {
+	now := time.Date(2026, 5, 6, 12, 0, 0, 0, time.UTC)
+	limiter := newLoginLimiter(5, 5*time.Minute, 15*time.Minute)
+	limiter.now = func() time.Time { return now }
+
+	for range 5 {
+		limiter.registerFailure("192.0.2.10", "admin")
+	}
+	if _, ok := limiter.allow("192.0.2.11", "admin"); !ok {
+		t.Fatal("different IP should not be blocked")
+	}
+	if _, ok := limiter.allow("192.0.2.10", "other-admin"); !ok {
+		t.Fatal("different username should not be blocked")
+	}
+}

+ 7 - 0
web/controller/util.go

@@ -10,6 +10,7 @@ import (
 	"github.com/mhsanaei/3x-ui/v2/config"
 	"github.com/mhsanaei/3x-ui/v2/logger"
 	"github.com/mhsanaei/3x-ui/v2/web/entity"
+	"github.com/mhsanaei/3x-ui/v2/web/session"
 
 	"github.com/gin-gonic/gin"
 )
@@ -121,6 +122,12 @@ func html(c *gin.Context, name string, title string, data gin.H) {
 		data = gin.H{}
 	}
 	data["title"] = title
+	csrfToken, err := session.EnsureCSRFToken(c)
+	if err != nil {
+		logger.Warning("Unable to create CSRF token:", err)
+	} else {
+		data["csrf_token"] = csrfToken
+	}
 	host := c.GetHeader("X-Forwarded-Host")
 	if host == "" {
 		host = c.GetHeader("X-Real-IP")

+ 11 - 84
web/controller/websocket.go

@@ -5,25 +5,15 @@ import (
 	"net/http"
 	"net/url"
 	"strings"
-	"time"
 
-	"github.com/google/uuid"
 	"github.com/mhsanaei/3x-ui/v2/logger"
-	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v2/web/service"
 	"github.com/mhsanaei/3x-ui/v2/web/session"
-	"github.com/mhsanaei/3x-ui/v2/web/websocket"
 
 	"github.com/gin-gonic/gin"
 	ws "github.com/gorilla/websocket"
 )
 
-const (
-	writeWait       = 10 * time.Second
-	pongWait        = 60 * time.Second
-	pingPeriod      = (pongWait * 9) / 10
-	clientReadLimit = 512
-)
-
 var upgrader = ws.Upgrader{
 	ReadBufferSize:    32768,
 	WriteBufferSize:   32768,
@@ -57,18 +47,21 @@ func checkSameOrigin(r *http.Request) bool {
 	return strings.EqualFold(u.Hostname(), host)
 }
 
-// WebSocketController handles WebSocket connections for real-time updates.
+// WebSocketController handles the HTTP→WebSocket upgrade for real-time updates.
+// All per-connection lifecycle (pumps, hub registration) lives in
+// service.WebSocketService — this controller is HTTP-layer only.
 type WebSocketController struct {
 	BaseController
-	hub *websocket.Hub
+	service *service.WebSocketService
 }
 
-// NewWebSocketController creates a new WebSocket controller.
-func NewWebSocketController(hub *websocket.Hub) *WebSocketController {
-	return &WebSocketController{hub: hub}
+// NewWebSocketController creates a controller wired to the given service.
+func NewWebSocketController(svc *service.WebSocketService) *WebSocketController {
+	return &WebSocketController{service: svc}
 }
 
-// HandleWebSocket upgrades the HTTP connection and starts the read/write pumps.
+// HandleWebSocket authenticates the request, upgrades the HTTP connection, and
+// hands ownership of the connection off to the service.
 func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
 	if !session.IsLogin(c) {
 		logger.Warningf("Unauthorized WebSocket connection attempt from %s", getRemoteIp(c))
@@ -82,71 +75,5 @@ func (w *WebSocketController) HandleWebSocket(c *gin.Context) {
 		return
 	}
 
-	client := websocket.NewClient(uuid.New().String())
-	w.hub.Register(client)
-	logger.Debugf("WebSocket client %s registered from %s", client.ID, getRemoteIp(c))
-
-	go w.writePump(client, conn)
-	go w.readPump(client, conn)
-}
-
-// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
-// running. Clients send no commands today; frames are discarded.
-func (w *WebSocketController) readPump(client *websocket.Client, conn *ws.Conn) {
-	defer func() {
-		if r := common.Recover("WebSocket readPump panic"); r != nil {
-			logger.Error("WebSocket readPump panic recovered:", r)
-		}
-		w.hub.Unregister(client)
-		conn.Close()
-	}()
-
-	conn.SetReadLimit(clientReadLimit)
-	conn.SetReadDeadline(time.Now().Add(pongWait))
-	conn.SetPongHandler(func(string) error {
-		return conn.SetReadDeadline(time.Now().Add(pongWait))
-	})
-
-	for {
-		if _, _, err := conn.ReadMessage(); err != nil {
-			if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
-				logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
-			}
-			return
-		}
-	}
-}
-
-// writePump pushes hub messages to the connection and emits keepalive pings.
-func (w *WebSocketController) writePump(client *websocket.Client, conn *ws.Conn) {
-	ticker := time.NewTicker(pingPeriod)
-	defer func() {
-		if r := common.Recover("WebSocket writePump panic"); r != nil {
-			logger.Error("WebSocket writePump panic recovered:", r)
-		}
-		ticker.Stop()
-		conn.Close()
-	}()
-
-	for {
-		select {
-		case msg, ok := <-client.Send:
-			conn.SetWriteDeadline(time.Now().Add(writeWait))
-			if !ok {
-				conn.WriteMessage(ws.CloseMessage, []byte{})
-				return
-			}
-			if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
-				logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
-				return
-			}
-
-		case <-ticker.C:
-			conn.SetWriteDeadline(time.Now().Add(writeWait))
-			if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
-				logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
-				return
-			}
-		}
-	}
+	w.service.HandleConnection(conn, getRemoteIp(c))
 }

+ 3 - 0
web/controller/xui.go

@@ -1,6 +1,8 @@
 package controller
 
 import (
+	"github.com/mhsanaei/3x-ui/v2/web/middleware"
+
 	"github.com/gin-gonic/gin"
 )
 
@@ -23,6 +25,7 @@ func NewXUIController(g *gin.RouterGroup) *XUIController {
 func (a *XUIController) initRouter(g *gin.RouterGroup) {
 	g = g.Group("/panel")
 	g.Use(a.checkLogin)
+	g.Use(middleware.CSRFMiddleware())
 
 	g.GET("/", a.index)
 	g.GET("/inbounds", a.inbounds)

+ 2 - 1
web/html/common/page.html

@@ -7,6 +7,7 @@
   <meta name="renderer" content="webkit">
   <meta name="viewport" content="width=device-width, initial-scale=1.0">
   <meta name="robots" content="noindex,nofollow">
+  {{ if .csrf_token }}<meta name="csrf-token" content="{{ .csrf_token }}">{{ end }}
   <link rel="stylesheet" href="{{ .base_path }}assets/ant-design-vue/antd.min.css">
   <link rel="stylesheet" href="{{ .base_path }}assets/css/custom.min.css?{{ .cur_ver }}">
   <style>
@@ -102,4 +103,4 @@
 </body>
 
 </html>
-{{ end }}
+{{ end }}

+ 85 - 0
web/html/form/fallbacks.html

@@ -0,0 +1,85 @@
+{{define "form/fallbacks"}}
+<div :style="{ display: 'flex', alignItems: 'center', flexWrap: 'wrap', gap: '8px', margin: '8px 0' }">
+  <span :style="{ fontWeight: 500 }">
+    <a-tooltip title="Route incoming TLS traffic to a backend when it doesn't match a valid VLESS/Trojan handshake. Match by SNI, ALPN, and HTTP path; the most precise rule wins. Fallbacks require TCP+TLS transport.">
+      Fallbacks ([[ inbound.settings.fallbacks.length ]])
+      <a-icon type="question-circle"></a-icon>
+    </a-tooltip>
+  </span>
+  <span :style="{ flex: 1 }"></span>
+  <a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()">Add</a-button>
+</div>
+
+<a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false"
+  :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
+  <a-divider :style="{ margin: '0' }">
+    Fallback [[ index + 1 ]]
+    <a-icon type="delete" @click="() => inbound.settings.delFallback(index)"
+      :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '6px' }"></a-icon>
+  </a-divider>
+
+  <a-form-item>
+    <template slot="label">
+      <a-tooltip title="Match TLS SNI (server name). Leave empty to match any SNI.">
+        SNI <a-icon type="question-circle"></a-icon>
+      </a-tooltip>
+    </template>
+    <a-input v-model.trim="fallback.name" placeholder="any (leave empty)"></a-input>
+  </a-form-item>
+
+  <a-form-item>
+    <template slot="label">
+      <a-tooltip title="Match TLS ALPN. 'any' = no ALPN constraint. Use h2/http/1.1 split when the inbound advertises both.">
+        ALPN <a-icon type="question-circle"></a-icon>
+      </a-tooltip>
+    </template>
+    <a-select v-model="fallback.alpn" :style="{ width: '100%' }">
+      <a-select-option value="">any</a-select-option>
+      <a-select-option value="h2">h2</a-select-option>
+      <a-select-option value="http/1.1">http/1.1</a-select-option>
+    </a-select>
+  </a-form-item>
+
+  <a-form-item
+    :validate-status="fallback.path && !fallback.path.startsWith('/') ? 'error' : ''"
+    :help="fallback.path && !fallback.path.startsWith('/') ? 'Path must start with /' : ''">
+    <template slot="label">
+      <a-tooltip title="Match the HTTP request path of the first packet. Must start with '/'. Leave empty to match any.">
+        Path <a-icon type="question-circle"></a-icon>
+      </a-tooltip>
+    </template>
+    <a-input v-model.trim="fallback.path" placeholder="any (leave empty) or /ws"></a-input>
+  </a-form-item>
+
+  <a-form-item
+    :validate-status="!fallback.dest ? 'error' : ''"
+    :help="!fallback.dest ? 'Destination is required' : ''">
+    <template slot="label">
+      <a-tooltip>
+        <template slot="title">
+          <span>
+            Where matching traffic is forwarded. Accepts a port number (<code>80</code>),
+            an <code>addr:port</code> (<code>127.0.0.1:8080</code>), or a Unix socket path
+            (<code>/dev/shm/x.sock</code> or <code>@abstract</code>).
+          </span>
+        </template>
+        Destination <a-icon type="question-circle"></a-icon>
+      </a-tooltip>
+    </template>
+    <a-input v-model.trim="fallback.dest" placeholder="80 | 127.0.0.1:8080 | /dev/shm/x.sock"></a-input>
+  </a-form-item>
+
+  <a-form-item>
+    <template slot="label">
+      <a-tooltip title="PROXY protocol version sent to the destination. Off (0) for plain TCP; v1/v2 to preserve client IP if the backend supports it.">
+        PROXY <a-icon type="question-circle"></a-icon>
+      </a-tooltip>
+    </template>
+    <a-select v-model="fallback.xver" :style="{ width: '100%' }">
+      <a-select-option :value="0">Off</a-select-option>
+      <a-select-option :value="1">v1</a-select-option>
+      <a-select-option :value="2">v2</a-select-option>
+    </a-select>
+  </a-form-item>
+</a-form>
+{{end}}

+ 129 - 15
web/html/form/outbound.html

@@ -566,36 +566,150 @@
           <a-form-item label='{{ i18n "path" }}'>
             <a-input v-model.trim="outbound.stream.xhttp.path"></a-input>
           </a-form-item>
+          <a-form-item label='{{ i18n "pages.inbounds.stream.tcp.requestHeader" }}'>
+            <a-button icon="plus" size="small" @click="outbound.stream.xhttp.addHeader('', '')"></a-button>
+          </a-form-item>
+          <a-form-item :wrapper-col="{span:24}">
+            <a-input-group compact v-for="(header, index) in outbound.stream.xhttp.headers">
+              <a-input :style="{ width: '50%' }" v-model.trim="header.name"
+                placeholder='{{ i18n "pages.inbounds.stream.general.name"}}'>
+                <template slot="addonBefore">[[ index+1 ]]</template>
+              </a-input>
+              <a-input :style="{ width: '50%' }" v-model.trim="header.value"
+                placeholder='{{ i18n "pages.inbounds.stream.general.value" }}'>
+                <a-button icon="minus" slot="addonAfter" size="small"
+                  @click="outbound.stream.xhttp.removeHeader(index)"></a-button>
+              </a-input>
+            </a-input-group>
+          </a-form-item>
           <a-form-item label="Mode">
             <a-select v-model="outbound.stream.xhttp.mode" :dropdown-class-name="themeSwitcher.currentTheme">
               <a-select-option v-for="key in MODE_OPTION" :value="key">[[ key ]]</a-select-option>
             </a-select>
           </a-form-item>
-          <a-form-item label="No gRPC Header"
-            v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
-            <a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
+          <a-form-item label="Max Upload Size (Byte)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
+            <a-input v-model.trim="outbound.stream.xhttp.scMaxEachPostBytes"></a-input>
           </a-form-item>
           <a-form-item label="Min Upload Interval (Ms)" v-if="outbound.stream.xhttp.mode === 'packet-up'">
             <a-input v-model.trim="outbound.stream.xhttp.scMinPostsIntervalMs"></a-input>
           </a-form-item>
-          <a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
-            <a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
+          <a-form-item label="Padding Bytes">
+            <a-input v-model.trim="outbound.stream.xhttp.xPaddingBytes"></a-input>
           </a-form-item>
-          <a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
-            <a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
+          <a-form-item label="Padding Obfs Mode">
+            <a-switch v-model="outbound.stream.xhttp.xPaddingObfsMode"></a-switch>
           </a-form-item>
-          <a-form-item label="Max Reuse Times">
-            <a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
+          <template v-if="outbound.stream.xhttp.xPaddingObfsMode">
+            <a-form-item label="Padding Key">
+              <a-input v-model.trim="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding"></a-input>
+            </a-form-item>
+            <a-form-item label="Padding Header">
+              <a-input v-model.trim="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding"></a-input>
+            </a-form-item>
+            <a-form-item label="Padding Placement">
+              <a-select v-model="outbound.stream.xhttp.xPaddingPlacement"
+                :dropdown-class-name="themeSwitcher.currentTheme">
+                <a-select-option value>Default (queryInHeader)</a-select-option>
+                <a-select-option value="queryInHeader">queryInHeader</a-select-option>
+                <a-select-option value="header">header</a-select-option>
+                <a-select-option value="cookie">cookie</a-select-option>
+                <a-select-option value="query">query</a-select-option>
+              </a-select>
+            </a-form-item>
+            <a-form-item label="Padding Method">
+              <a-select v-model="outbound.stream.xhttp.xPaddingMethod"
+                :dropdown-class-name="themeSwitcher.currentTheme">
+                <a-select-option value>Default (repeat-x)</a-select-option>
+                <a-select-option value="repeat-x">repeat-x</a-select-option>
+                <a-select-option value="tokenish">tokenish</a-select-option>
+              </a-select>
+            </a-form-item>
+          </template>
+          <a-form-item label="Uplink HTTP Method">
+            <a-select v-model="outbound.stream.xhttp.uplinkHTTPMethod"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (POST)</a-select-option>
+              <a-select-option value="POST">POST</a-select-option>
+              <a-select-option value="PUT">PUT</a-select-option>
+              <a-select-option value="GET" :disabled="outbound.stream.xhttp.mode !== 'packet-up'">
+                GET (packet-up only)
+              </a-select-option>
+            </a-select>
           </a-form-item>
-          <a-form-item label="Max Request Times">
-            <a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
+          <a-form-item label="Session Placement">
+            <a-select v-model="outbound.stream.xhttp.sessionPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (path)</a-select-option>
+              <a-select-option value="path">path</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
           </a-form-item>
-          <a-form-item label="Max Reusable Secs">
-            <a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
+          <a-form-item label="Session Key"
+            v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'">
+            <a-input v-model.trim="outbound.stream.xhttp.sessionKey" placeholder="x_session"></a-input>
+          </a-form-item>
+          <a-form-item label="Sequence Placement">
+            <a-select v-model="outbound.stream.xhttp.seqPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (path)</a-select-option>
+              <a-select-option value="path">path</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Sequence Key"
+            v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'">
+            <a-input v-model.trim="outbound.stream.xhttp.seqKey" placeholder="x_seq"></a-input>
+          </a-form-item>
+          <a-form-item label="Uplink Data Placement" v-if="outbound.stream.xhttp.mode === 'packet-up'">
+            <a-select v-model="outbound.stream.xhttp.uplinkDataPlacement"
+              :dropdown-class-name="themeSwitcher.currentTheme">
+              <a-select-option value>Default (body)</a-select-option>
+              <a-select-option value="body">body</a-select-option>
+              <a-select-option value="header">header</a-select-option>
+              <a-select-option value="cookie">cookie</a-select-option>
+              <a-select-option value="query">query</a-select-option>
+            </a-select>
+          </a-form-item>
+          <a-form-item label="Uplink Data Key"
+            v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
+            <a-input v-model.trim="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
+          </a-form-item>
+          <a-form-item label="Uplink Chunk Size"
+            v-if="outbound.stream.xhttp.mode === 'packet-up' && outbound.stream.xhttp.uplinkDataPlacement && outbound.stream.xhttp.uplinkDataPlacement !== 'body'">
+            <a-input-number v-model.number="outbound.stream.xhttp.uplinkChunkSize" :min="0"
+              placeholder="0 (unlimited)"></a-input-number>
+          </a-form-item>
+          <a-form-item label="No gRPC Header"
+            v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'">
+            <a-switch v-model="outbound.stream.xhttp.noGRPCHeader"></a-switch>
           </a-form-item>
-          <a-form-item label="Keep Alive Period">
-            <a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
+          <a-form-item label="XMUX">
+            <a-switch v-model="outbound.stream.xhttp.enableXmux"></a-switch>
           </a-form-item>
+          <template v-if="outbound.stream.xhttp.enableXmux">
+            <a-form-item label="Max Concurrency" v-if="!outbound.stream.xhttp.xmux.maxConnections">
+              <a-input v-model="outbound.stream.xhttp.xmux.maxConcurrency"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Connections" v-if="!outbound.stream.xhttp.xmux.maxConcurrency">
+              <a-input v-model="outbound.stream.xhttp.xmux.maxConnections"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Reuse Times">
+              <a-input v-model="outbound.stream.xhttp.xmux.cMaxReuseTimes"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Request Times">
+              <a-input v-model="outbound.stream.xhttp.xmux.hMaxRequestTimes"></a-input>
+            </a-form-item>
+            <a-form-item label="Max Reusable Secs">
+              <a-input v-model="outbound.stream.xhttp.xmux.hMaxReusableSecs"></a-input>
+            </a-form-item>
+            <a-form-item label="Keep Alive Period">
+              <a-input-number v-model.number="outbound.stream.xhttp.xmux.hKeepAlivePeriod"></a-input-number>
+            </a-form-item>
+          </template>
         </template>
 
         <!-- hysteria -->

+ 1 - 29
web/html/form/protocol/trojan.html

@@ -19,35 +19,7 @@
   </a-collapse-panel>
 </a-collapse>
 <template v-if=" inbound.isTcp">
-  <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
-    <a-form-item label="Fallbacks">
-      <a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
-    </a-form-item>
-  </a-form>
-
-  <!-- trojan fallbacks -->
-  <a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
-    :wrapper-col="{ md: {span:14} }">
-    <a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
-        @click="() => inbound.settings.delFallback(index)"
-        :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
-    </a-divider>
-    <a-form-item label='SNI'>
-      <a-input v-model="fallback.name"></a-input>
-    </a-form-item>
-    <a-form-item label='ALPN'>
-      <a-input v-model="fallback.alpn"></a-input>
-    </a-form-item>
-    <a-form-item label='Path'>
-      <a-input v-model="fallback.path"></a-input>
-    </a-form-item>
-    <a-form-item label='Dest'>
-      <a-input v-model="fallback.dest"></a-input>
-    </a-form-item>
-    <a-form-item label='xVer'>
-      <a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
-    </a-form-item>
-  </a-form>
+  {{template "form/fallbacks" .}}
   <a-divider style="margin:5px 0;"></a-divider>
 </template>
 {{end}}

+ 2 - 5
web/html/form/protocol/tun.html

@@ -18,14 +18,11 @@
         <template slot="title">
           <span>{{ i18n "pages.xray.tun.mtuDesc" }}</span>
         </template>
-        MTU IPv4
+        MTU
         <a-icon type="question-circle"></a-icon>
       </a-tooltip>
     </template>
-    <a-input-number v-model.number="inbound.settings.mtu[0]" :min="1" :max="9000" placeholder="1500"></a-input-number>
-  </a-form-item>
-  <a-form-item label="MTU IPv6">
-    <a-input-number v-model.number="inbound.settings.mtu[1]" :min="1" :max="9000" placeholder="1280"></a-input-number>
+    <a-input-number v-model.number="inbound.settings.mtu" :min="1" :max="9000" placeholder="1500"></a-input-number>
   </a-form-item>
   <a-form-item label="Gateway">
     <a-select mode="tags" v-model="inbound.settings.gateway" :style="{ width: '100%' }" :token-separators="[',']"

+ 1 - 29
web/html/form/protocol/vless.html

@@ -42,35 +42,7 @@
   <a-divider :style="{ margin: '5px 0' }"></a-divider>
 </template>
 <template v-if="inbound.isTcp && (!inbound.settings.encryption || inbound.settings.encryption === 'none')">
-  <a-form :colon="false" :label-col="{ md: {span:8} }" :wrapper-col="{ md: {span:14} }">
-    <a-form-item label="Fallbacks">
-      <a-button icon="plus" type="primary" size="small" @click="inbound.settings.addFallback()"></a-button>
-    </a-form-item>
-  </a-form>
-
-  <!-- vless fallbacks -->
-  <a-form v-for="(fallback, index) in inbound.settings.fallbacks" :colon="false" :label-col="{ md: {span:8} }"
-    :wrapper-col="{ md: {span:14} }">
-    <a-divider :style="{ margin: '0' }"> Fallback [[ index + 1 ]] <a-icon type="delete"
-        @click="() => inbound.settings.delFallback(index)"
-        :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer' }"></a-icon>
-    </a-divider>
-    <a-form-item label='SNI'>
-      <a-input v-model="fallback.name"></a-input>
-    </a-form-item>
-    <a-form-item label='ALPN'>
-      <a-input v-model="fallback.alpn"></a-input>
-    </a-form-item>
-    <a-form-item label='Path'>
-      <a-input v-model="fallback.path"></a-input>
-    </a-form-item>
-    <a-form-item label='Dest'>
-      <a-input v-model="fallback.dest"></a-input>
-    </a-form-item>
-    <a-form-item label='xVer'>
-      <a-input-number v-model.number="fallback.xver" :min="0" :max="2"></a-input-number>
-    </a-form-item>
-  </a-form>
+  {{template "form/fallbacks" .}}
   <a-divider :style="{ margin: '5px 0' }"></a-divider>
 </template>
 <template v-if="inbound.canEnableVisionSeed()">

+ 5 - 14
web/html/form/stream/stream_xhttp.html

@@ -37,6 +37,10 @@
   <a-form-item label="Stream-Up Server" v-if="inbound.stream.xhttp.mode === 'stream-up'">
     <a-input v-model.trim="inbound.stream.xhttp.scStreamUpServerSecs"></a-input>
   </a-form-item>
+  <a-form-item label="Server Max Header Bytes">
+    <a-input-number v-model.number="inbound.stream.xhttp.serverMaxHeaderBytes" :min="0"
+      placeholder="0 (default)"></a-input-number>
+  </a-form-item>
   <a-form-item label="Padding Bytes">
     <a-input v-model.trim="inbound.stream.xhttp.xPaddingBytes"></a-input>
   </a-form-item>
@@ -67,14 +71,6 @@
       </a-select>
     </a-form-item>
   </template>
-  <a-form-item label="Uplink HTTP Method">
-    <a-select v-model="inbound.stream.xhttp.uplinkHTTPMethod" :dropdown-class-name="themeSwitcher.currentTheme">
-      <a-select-option value>Default (POST)</a-select-option>
-      <a-select-option value="POST">POST</a-select-option>
-      <a-select-option value="PUT">PUT</a-select-option>
-      <a-select-option value="GET">GET (packet-up only)</a-select-option>
-    </a-select>
-  </a-form-item>
   <a-form-item label="Session Placement">
     <a-select v-model="inbound.stream.xhttp.sessionPlacement" :dropdown-class-name="themeSwitcher.currentTheme">
       <a-select-option value>Default (path)</a-select-option>
@@ -114,13 +110,8 @@
     v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
     <a-input v-model.trim="inbound.stream.xhttp.uplinkDataKey" placeholder="x_data"></a-input>
   </a-form-item>
-  <a-form-item label="Uplink Chunk Size"
-    v-if="inbound.stream.xhttp.mode === 'packet-up' && inbound.stream.xhttp.uplinkDataPlacement && inbound.stream.xhttp.uplinkDataPlacement !== 'body'">
-    <a-input-number v-model.number="inbound.stream.xhttp.uplinkChunkSize" :min="0"
-      placeholder="0 (unlimited)"></a-input-number>
-  </a-form-item>
   <a-form-item label="No SSE Header">
     <a-switch v-model="inbound.stream.xhttp.noSSEHeader"></a-switch>
   </a-form-item>
 </a-form>
-{{end}}
+{{end}}

+ 22 - 2
web/html/index.html

@@ -1407,14 +1407,34 @@
           class: themeSwitcher.currentTheme,
           cancelText: '{{ i18n "cancel"}}',
           onOk: async () => {
+            const targetVersion = panelUpdateModal.info.latestVersion || '';
+            const baseTip = '{{ i18n "pages.index.dontRefresh"}}';
+            const tipPrefix = targetVersion ? `${baseTip} (${targetVersion})` : baseTip;
             panelUpdateModal.hide();
-            this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
+            this.loading(true, tipPrefix);
             const msg = await HttpUtil.post('/panel/api/server/updatePanel');
             if (!msg.success) {
               this.loading(false);
               return;
             }
-            await PromiseUtil.sleep(15000);
+
+            // Wait for the running process to exit, then poll the new panel
+            // until it answers (up to ~90s). Reload as soon as it's back.
+            await PromiseUtil.sleep(5000);
+            this.loading(true, tipPrefix);
+            const deadline = Date.now() + 90_000;
+            let back = false;
+            while (Date.now() < deadline) {
+              try {
+                const r = await axios.get('/panel/api/server/status', { timeout: 2000 });
+                if (r && r.data && r.data.success) { back = true; break; }
+              } catch (_) { /* still restarting */ }
+              await PromiseUtil.sleep(2000);
+            }
+            if (back) {
+              this.$message.success('{{ i18n "pages.index.panelUpdateStartedPopover" }}');
+              await PromiseUtil.sleep(800);
+            }
             window.location.reload();
           },
         });

+ 11 - 0
web/html/modals/xray_outbound_modal.html

@@ -104,6 +104,17 @@
                 return outModal.outbound;
             },
         },
+        watch: {
+            // xray-core's SplitHTTPConfig.Build() rejects "GET" as
+            // uplinkHTTPMethod outside packet-up mode. Clear the field
+            // instead of carrying an invalid combination through.
+            "outModal.outbound.stream.xhttp.mode"(newMode) {
+                const xhttp = outModal.outbound.stream && outModal.outbound.stream.xhttp;
+                if (xhttp && xhttp.uplinkHTTPMethod === "GET" && newMode !== "packet-up") {
+                    xhttp.uplinkHTTPMethod = "";
+                }
+            },
+        },
         methods: {
             streamNetworkChange() {
                 if (this.outModal.outbound.protocol == Protocols.VLESS && !outModal.outbound

+ 47 - 0
web/middleware/security.go

@@ -0,0 +1,47 @@
+package middleware
+
+import (
+	"net/http"
+
+	"github.com/mhsanaei/3x-ui/v2/web/session"
+
+	"github.com/gin-gonic/gin"
+)
+
+// SecurityHeadersMiddleware adds browser hardening headers to panel responses.
+func SecurityHeadersMiddleware(directHTTPS bool) gin.HandlerFunc {
+	return func(c *gin.Context) {
+		c.Header("X-Content-Type-Options", "nosniff")
+		c.Header("X-Frame-Options", "DENY")
+		c.Header("Referrer-Policy", "no-referrer")
+		c.Header("Content-Security-Policy", "frame-ancestors 'none'; base-uri 'self'; form-action 'self'")
+		if directHTTPS {
+			c.Header("Strict-Transport-Security", "max-age=31536000; includeSubDomains")
+		}
+		c.Next()
+	}
+}
+
+// CSRFMiddleware rejects unsafe requests that do not include the session CSRF token.
+func CSRFMiddleware() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		if isSafeMethod(c.Request.Method) {
+			c.Next()
+			return
+		}
+		if !session.ValidateCSRFToken(c) {
+			c.AbortWithStatus(http.StatusForbidden)
+			return
+		}
+		c.Next()
+	}
+}
+
+func isSafeMethod(method string) bool {
+	switch method {
+	case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodTrace:
+		return true
+	default:
+		return false
+	}
+}

+ 121 - 0
web/middleware/security_test.go

@@ -0,0 +1,121 @@
+package middleware
+
+import (
+	"net/http"
+	"net/http/httptest"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v2/web/session"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-contrib/sessions/cookie"
+	"github.com/gin-gonic/gin"
+)
+
+func TestCSRFMiddlewareAllowsSafeMethods(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.Use(CSRFMiddleware())
+	router.GET("/safe", func(c *gin.Context) {
+		c.String(http.StatusOK, "ok")
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/safe", nil)
+	router.ServeHTTP(rec, req)
+
+	if rec.Code != http.StatusOK {
+		t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
+	}
+}
+
+func TestCSRFMiddlewareRejectsMissingTokenAndAcceptsValidToken(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	store := cookie.NewStore([]byte("01234567890123456789012345678901"))
+	router.Use(sessions.Sessions("3x-ui", store))
+	router.GET("/token", func(c *gin.Context) {
+		token, err := session.EnsureCSRFToken(c)
+		if err != nil {
+			t.Fatal(err)
+		}
+		c.String(http.StatusOK, token)
+	})
+	router.POST("/submit", CSRFMiddleware(), func(c *gin.Context) {
+		c.String(http.StatusOK, "ok")
+	})
+
+	tokenRec := httptest.NewRecorder()
+	tokenReq := httptest.NewRequest(http.MethodGet, "/token", nil)
+	router.ServeHTTP(tokenRec, tokenReq)
+	if tokenRec.Code != http.StatusOK {
+		t.Fatalf("token status = %d, want %d", tokenRec.Code, http.StatusOK)
+	}
+	cookies := tokenRec.Result().Cookies()
+	token := tokenRec.Body.String()
+
+	missingRec := httptest.NewRecorder()
+	missingReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
+	for _, cookie := range cookies {
+		missingReq.AddCookie(cookie)
+	}
+	router.ServeHTTP(missingRec, missingReq)
+	if missingRec.Code != http.StatusForbidden {
+		t.Fatalf("missing token status = %d, want %d", missingRec.Code, http.StatusForbidden)
+	}
+
+	validRec := httptest.NewRecorder()
+	validReq := httptest.NewRequest(http.MethodPost, "/submit", nil)
+	for _, cookie := range cookies {
+		validReq.AddCookie(cookie)
+	}
+	validReq.Header.Set(session.CSRFHeaderName, token)
+	router.ServeHTTP(validRec, validReq)
+	if validRec.Code != http.StatusOK {
+		t.Fatalf("valid token status = %d, want %d", validRec.Code, http.StatusOK)
+	}
+}
+
+func TestSecurityHeadersMiddleware(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.Use(SecurityHeadersMiddleware(true))
+	router.GET("/", func(c *gin.Context) {
+		c.String(http.StatusOK, "ok")
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	router.ServeHTTP(rec, req)
+
+	headers := rec.Result().Header
+	if got := headers.Get("X-Content-Type-Options"); got != "nosniff" {
+		t.Fatalf("X-Content-Type-Options = %q", got)
+	}
+	if got := headers.Get("X-Frame-Options"); got != "DENY" {
+		t.Fatalf("X-Frame-Options = %q", got)
+	}
+	if got := headers.Get("Referrer-Policy"); got != "no-referrer" {
+		t.Fatalf("Referrer-Policy = %q", got)
+	}
+	if got := headers.Get("Strict-Transport-Security"); got == "" {
+		t.Fatal("Strict-Transport-Security should be set for direct HTTPS")
+	}
+}
+
+func TestSecurityHeadersMiddlewareSkipsHSTSWithoutDirectHTTPS(t *testing.T) {
+	gin.SetMode(gin.TestMode)
+	router := gin.New()
+	router.Use(SecurityHeadersMiddleware(false))
+	router.GET("/", func(c *gin.Context) {
+		c.String(http.StatusOK, "ok")
+	})
+
+	rec := httptest.NewRecorder()
+	req := httptest.NewRequest(http.MethodGet, "/", nil)
+	router.ServeHTTP(rec, req)
+
+	if got := rec.Result().Header.Get("Strict-Transport-Security"); got != "" {
+		t.Fatalf("Strict-Transport-Security = %q, want empty", got)
+	}
+}

+ 19 - 7
web/service/tgbot.go

@@ -104,6 +104,16 @@ const (
 	EmptyTelegramUserID             = int64(0) // Default value for empty Telegram user ID
 )
 
+// LoginAttempt contains safe metadata for panel login notifications.
+// It intentionally does not include attempted passwords.
+type LoginAttempt struct {
+	Username string
+	IP       string
+	Time     string
+	Status   LoginStatus
+	Reason   string
+}
+
 // Tgbot provides business logic for Telegram bot integration.
 // It handles bot commands, user interactions, and status reporting via Telegram.
 type Tgbot struct {
@@ -2769,12 +2779,12 @@ func (t *Tgbot) prepareServerUsageInfo() string {
 }
 
 // UserLoginNotify sends a notification about user login attempts to admins.
-func (t *Tgbot) UserLoginNotify(username string, password string, ip string, time string, status LoginStatus) {
+func (t *Tgbot) UserLoginNotify(attempt LoginAttempt) {
 	if !t.IsRunning() {
 		return
 	}
 
-	if username == "" || ip == "" || time == "" {
+	if attempt.Username == "" || attempt.IP == "" || attempt.Time == "" {
 		logger.Warning("UserLoginNotify failed, invalid info!")
 		return
 	}
@@ -2785,18 +2795,20 @@ func (t *Tgbot) UserLoginNotify(username string, password string, ip string, tim
 	}
 
 	msg := ""
-	switch status {
+	switch attempt.Status {
 	case LoginSuccess:
 		msg += t.I18nBot("tgbot.messages.loginSuccess")
 		msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
 	case LoginFail:
 		msg += t.I18nBot("tgbot.messages.loginFailed")
 		msg += t.I18nBot("tgbot.messages.hostname", "Hostname=="+hostname)
-		msg += t.I18nBot("tgbot.messages.password", "Password=="+password)
+		if attempt.Reason != "" {
+			msg += t.I18nBot("tgbot.messages.reason", "Reason=="+attempt.Reason)
+		}
 	}
-	msg += t.I18nBot("tgbot.messages.username", "Username=="+username)
-	msg += t.I18nBot("tgbot.messages.ip", "IP=="+ip)
-	msg += t.I18nBot("tgbot.messages.time", "Time=="+time)
+	msg += t.I18nBot("tgbot.messages.username", "Username=="+attempt.Username)
+	msg += t.I18nBot("tgbot.messages.ip", "IP=="+attempt.IP)
+	msg += t.I18nBot("tgbot.messages.time", "Time=="+attempt.Time)
 	t.SendMsgToTgbotAdmins(msg)
 }
 

+ 13 - 0
web/service/tgbot_test.go

@@ -0,0 +1,13 @@
+package service
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestLoginAttemptDoesNotCarryPassword(t *testing.T) {
+	typ := reflect.TypeOf(LoginAttempt{})
+	if _, ok := typ.FieldByName("Password"); ok {
+		t.Fatal("LoginAttempt must not carry attempted passwords")
+	}
+}

+ 115 - 0
web/service/websocket.go

@@ -0,0 +1,115 @@
+// Package service: WebSocketService owns the per-connection pump goroutines
+// and bridges the HTTP-layer controller to the broadcast hub. The controller
+// handles the upgrade handshake and authentication, then hands the raw
+// connection to this service which takes ownership of its lifecycle.
+package service
+
+import (
+	"time"
+
+	"github.com/mhsanaei/3x-ui/v2/logger"
+	"github.com/mhsanaei/3x-ui/v2/util/common"
+	"github.com/mhsanaei/3x-ui/v2/web/websocket"
+
+	"github.com/google/uuid"
+	ws "github.com/gorilla/websocket"
+)
+
+const (
+	wsWriteWait       = 10 * time.Second
+	wsPongWait        = 60 * time.Second
+	wsPingPeriod      = (wsPongWait * 9) / 10
+	wsClientReadLimit = 512
+)
+
+// WebSocketService manages WebSocket client connections. It owns the
+// read/write pumps for each accepted connection and registers/unregisters
+// clients with the hub.
+type WebSocketService struct {
+	hub *websocket.Hub
+}
+
+// NewWebSocketService creates a service backed by the given hub.
+func NewWebSocketService(hub *websocket.Hub) *WebSocketService {
+	return &WebSocketService{hub: hub}
+}
+
+// HandleConnection takes ownership of an upgraded WebSocket connection:
+// registers a new client, starts the read/write pumps, and returns
+// immediately. The connection is closed when both pumps exit.
+func (s *WebSocketService) HandleConnection(conn *ws.Conn, remoteIP string) {
+	if s == nil || s.hub == nil || conn == nil {
+		if conn != nil {
+			conn.Close()
+		}
+		return
+	}
+
+	client := websocket.NewClient(uuid.New().String())
+	s.hub.Register(client)
+	logger.Debugf("WebSocket client %s registered from %s", client.ID, remoteIP)
+
+	go s.writePump(client, conn)
+	go s.readPump(client, conn)
+}
+
+// readPump consumes inbound frames so the gorilla deadline/pong machinery keeps
+// running. Clients send no commands today; frames are discarded.
+func (s *WebSocketService) readPump(client *websocket.Client, conn *ws.Conn) {
+	defer func() {
+		if r := common.Recover("WebSocket readPump panic"); r != nil {
+			logger.Error("WebSocket readPump panic recovered:", r)
+		}
+		s.hub.Unregister(client)
+		conn.Close()
+	}()
+
+	conn.SetReadLimit(wsClientReadLimit)
+	conn.SetReadDeadline(time.Now().Add(wsPongWait))
+	conn.SetPongHandler(func(string) error {
+		return conn.SetReadDeadline(time.Now().Add(wsPongWait))
+	})
+
+	for {
+		if _, _, err := conn.ReadMessage(); err != nil {
+			if ws.IsUnexpectedCloseError(err, ws.CloseGoingAway, ws.CloseAbnormalClosure) {
+				logger.Debugf("WebSocket read error for client %s: %v", client.ID, err)
+			}
+			return
+		}
+	}
+}
+
+// writePump pushes hub messages to the connection and emits keepalive pings.
+func (s *WebSocketService) writePump(client *websocket.Client, conn *ws.Conn) {
+	ticker := time.NewTicker(wsPingPeriod)
+	defer func() {
+		if r := common.Recover("WebSocket writePump panic"); r != nil {
+			logger.Error("WebSocket writePump panic recovered:", r)
+		}
+		ticker.Stop()
+		conn.Close()
+	}()
+
+	for {
+		select {
+		case msg, ok := <-client.Send:
+			conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
+			if !ok {
+				conn.WriteMessage(ws.CloseMessage, []byte{})
+				return
+			}
+			if err := conn.WriteMessage(ws.TextMessage, msg); err != nil {
+				logger.Debugf("WebSocket write error for client %s: %v", client.ID, err)
+				return
+			}
+
+		case <-ticker.C:
+			conn.SetWriteDeadline(time.Now().Add(wsWriteWait))
+			if err := conn.WriteMessage(ws.PingMessage, nil); err != nil {
+				logger.Debugf("WebSocket ping error for client %s: %v", client.ID, err)
+				return
+			}
+		}
+	}
+}

+ 55 - 0
web/session/csrf.go

@@ -0,0 +1,55 @@
+package session
+
+import (
+	"crypto/rand"
+	"crypto/subtle"
+	"encoding/base64"
+	"io"
+
+	"github.com/gin-contrib/sessions"
+	"github.com/gin-gonic/gin"
+)
+
+const csrfTokenKey = "CSRF_TOKEN"
+
+// CSRFHeaderName is the request header used by browser clients for unsafe methods.
+const CSRFHeaderName = "X-CSRF-Token"
+
+// EnsureCSRFToken returns the current session CSRF token or creates one.
+func EnsureCSRFToken(c *gin.Context) (string, error) {
+	s := sessions.Default(c)
+	if token, ok := s.Get(csrfTokenKey).(string); ok && token != "" {
+		return token, nil
+	}
+	token, err := newCSRFToken()
+	if err != nil {
+		return "", err
+	}
+	s.Set(csrfTokenKey, token)
+	return token, s.Save()
+}
+
+// ValidateCSRFToken checks the submitted CSRF token against the session token.
+func ValidateCSRFToken(c *gin.Context) bool {
+	s := sessions.Default(c)
+	expected, ok := s.Get(csrfTokenKey).(string)
+	if !ok || expected == "" {
+		return false
+	}
+	actual := c.GetHeader(CSRFHeaderName)
+	if actual == "" {
+		actual = c.PostForm("_csrf")
+	}
+	if len(actual) != len(expected) {
+		return false
+	}
+	return subtle.ConstantTimeCompare([]byte(actual), []byte(expected)) == 1
+}
+
+func newCSRFToken() (string, error) {
+	buf := make([]byte, 32)
+	if _, err := io.ReadFull(rand.Reader, buf); err != nil {
+		return "", err
+	}
+	return base64.RawURLEncoding.EncodeToString(buf), nil
+}

+ 1 - 0
web/session/session.go

@@ -73,6 +73,7 @@ func ClearSession(c *gin.Context) error {
 		Path:     cookiePath,
 		MaxAge:   -1,
 		HttpOnly: true,
+		Secure:   c.Request.TLS != nil,
 		SameSite: http.SameSiteLaxMode,
 	})
 	return s.Save()

+ 1 - 1
web/translation/translate.ar_EG.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 الترافيك: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ الحالة: {{ .State }}\r\n"
 "username" = "👤 اسم المستخدم: {{ .Username }}\r\n"
-"password" = "👤 الباسورد: {{ .Password }}\r\n"
+"reason" = "❗️ السبب: {{ .Reason }}\r\n"
 "time" = "⏰ الوقت: {{ .Time }}\r\n"
 "inbound" = "📍 الإدخال: {{ .Remark }}\r\n"
 "port" = "🔌 البورت: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.en_US.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Traffic: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
 "username" = "👤 Username: {{ .Username }}\r\n"
-"password" = "👤 Password: {{ .Password }}\r\n"
+"reason" = "❗️ Reason: {{ .Reason }}\r\n"
 "time" = "⏰ Time: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Port: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.es_ES.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Tráfico: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Estado de Xray: {{ .State }}\r\n"
 "username" = "👤 Nombre de usuario: {{ .Username }}\r\n"
-"password" = "👤 Contraseña: {{ .Password }}\r\n"
+"reason" = "❗️ Motivo: {{ .Reason }}\r\n"
 "time" = "⏰ Hora: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Puerto: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.fa_IR.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 ترافیک: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ وضعیت‌ایکس‌ری: {{ .State }}\r\n"
 "username" = "👤 نام‌کاربری: {{ .Username }}\r\n"
-"password" = "👤 رمز عبور: {{ .Password }}\r\n"
+"reason" = "❗️ دلیل: {{ .Reason }}\r\n"
 "time" = "⏰ زمان: {{ .Time }}\r\n"
 "inbound" = "📍 نام‌ورودی: {{ .Remark }}\r\n"
 "port" = "🔌 پورت: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.id_ID.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Lalu Lintas: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
 "username" = "👤 Nama Pengguna: {{ .Username }}\r\n"
-"password" = "👤 Kata Sandi: {{ .Password }}\r\n"
+"reason" = "❗️ Alasan: {{ .Reason }}\r\n"
 "time" = "⏰ Waktu: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Port: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.ja_JP.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 トラフィック:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Xrayステータス:{{ .State }}\r\n"
 "username" = "👤 ユーザー名:{{ .Username }}\r\n"
-"password" = "👤 パスワード: {{ .Password }}\r\n"
+"reason" = "❗️ 理由:{{ .Reason }}\r\n"
 "time" = "⏰ 時間:{{ .Time }}\r\n"
 "inbound" = "📍 インバウンド:{{ .Remark }}\r\n"
 "port" = "🔌 ポート:{{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.pt_BR.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Tráfego: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Status: {{ .State }}\r\n"
 "username" = "👤 Nome de usuário: {{ .Username }}\r\n"
-"password" = "👤 Senha: {{ .Password }}\r\n"
+"reason" = "❗️ Motivo: {{ .Reason }}\r\n"
 "time" = "⏰ Hora: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Porta: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.ru_RU.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Трафик: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Состояние Xray: {{ .State }}\r\n"
 "username" = "👤 Имя пользователя: {{ .Username }}\r\n"
-"password" = "👤 Пароль: {{ .Password }}\r\n"
+"reason" = "❗️ Причина: {{ .Reason }}\r\n"
 "time" = "⏰ Время: {{ .Time }}\r\n"
 "inbound" = "📍 Входящее подключение: {{ .Remark }}\r\n"
 "port" = "🔌 Порт: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.tr_TR.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Trafik: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Durum: {{ .State }}\r\n"
 "username" = "👤 Kullanıcı Adı: {{ .Username }}\r\n"
-"password" = "👤 Şifre: {{ .Password }}\r\n"
+"reason" = "❗️ Sebep: {{ .Reason }}\r\n"
 "time" = "⏰ Zaman: {{ .Time }}\r\n"
 "inbound" = "📍 Gelen: {{ .Remark }}\r\n"
 "port" = "🔌 Port: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.uk_UA.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Трафік: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Статус: {{ .State }}\r\n"
 "username" = "👤 Ім'я користувача: {{ .Username }}\r\n"
-"password" = "👤 Пароль: {{ .Password }}\r\n"
+"reason" = "❗️ Причина: {{ .Reason }}\r\n"
 "time" = "⏰ Час: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Порт: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.vi_VN.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 Lưu lượng: {{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Trạng thái Xray: {{ .State }}\r\n"
 "username" = "👤 Tên người dùng: {{ .Username }}\r\n"
-"password" = "👤 Mật khẩu: {{ .Password }}\r\n"
+"reason" = "❗️ Lý do: {{ .Reason }}\r\n"
 "time" = "⏰ Thời gian: {{ .Time }}\r\n"
 "inbound" = "📍 Inbound: {{ .Remark }}\r\n"
 "port" = "🔌 Cổng: {{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.zh_CN.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Xray 状态:{{ .State }}\r\n"
 "username" = "👤 用户名:{{ .Username }}\r\n"
-"password" = "👤 密码: {{ .Password }}\r\n"
+"reason" = "❗️ 原因:{{ .Reason }}\r\n"
 "time" = "⏰ 时间:{{ .Time }}\r\n"
 "inbound" = "📍 入站:{{ .Remark }}\r\n"
 "port" = "🔌 端口:{{ .Port }}\r\n"

+ 1 - 1
web/translation/translate.zh_TW.toml

@@ -765,7 +765,7 @@
 "traffic" = "🚦 流量:{{ .Total }} (↑{{ .Upload }},↓{{ .Download }})\r\n"
 "xrayStatus" = "ℹ️ Xray 狀態:{{ .State }}\r\n"
 "username" = "👤 使用者名稱:{{ .Username }}\r\n"
-"password" = "👤 密碼: {{ .Password }}\r\n"
+"reason" = "❗️ 原因:{{ .Reason }}\r\n"
 "time" = "⏰ 時間:{{ .Time }}\r\n"
 "inbound" = "📍 入站:{{ .Remark }}\r\n"
 "port" = "🔌 埠:{{ .Port }}\r\n"

+ 16 - 2
web/web.go

@@ -170,6 +170,16 @@ func (s *Server) getHtmlTemplate(funcMap template.FuncMap) (*template.Template,
 	return t, nil
 }
 
+func (s *Server) isDirectHTTPSConfigured() bool {
+	certFile, certErr := s.settingService.GetCertFile()
+	keyFile, keyErr := s.settingService.GetKeyFile()
+	if certErr != nil || keyErr != nil || certFile == "" || keyFile == "" {
+		return false
+	}
+	_, err := tls.LoadX509KeyPair(certFile, keyFile)
+	return err == nil
+}
+
 // initRouter initializes Gin, registers middleware, templates, static
 // assets, controllers and returns the configured engine.
 func (s *Server) initRouter() (*gin.Engine, error) {
@@ -182,6 +192,8 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	}
 
 	engine := gin.Default()
+	directHTTPS := s.isDirectHTTPSConfigured()
+	engine.Use(middleware.SecurityHeadersMiddleware(directHTTPS))
 
 	webDomain, err := s.settingService.GetWebDomain()
 	if err != nil {
@@ -209,6 +221,7 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	sessionOptions := sessions.Options{
 		Path:     basePath,
 		HttpOnly: true,
+		Secure:   directHTTPS,
 		SameSite: http.SameSiteLaxMode,
 	}
 	if sessionMaxAge, err := s.settingService.GetSessionMaxAge(); err == nil && sessionMaxAge > 0 {
@@ -276,8 +289,9 @@ func (s *Server) initRouter() (*gin.Engine, error) {
 	s.wsHub = websocket.NewHub()
 	go s.wsHub.Run()
 
-	// Initialize WebSocket controller
-	s.ws = controller.NewWebSocketController(s.wsHub)
+	// Initialize WebSocket controller — service owns per-connection pumps,
+	// controller is HTTP-layer only (auth + upgrade).
+	s.ws = controller.NewWebSocketController(service.NewWebSocketService(s.wsHub))
 	// Register WebSocket route with basePath (g already has basePath prefix)
 	g.GET("/ws", s.ws.HandleWebSocket)
 

+ 71 - 12
x-ui.sh

@@ -292,9 +292,35 @@ check_config() {
     local existing_webBasePath=$(echo "$info" | grep -Eo 'webBasePath: .+' | awk '{print $2}')
     local existing_port=$(echo "$info" | grep -Eo 'port: .+' | awk '{print $2}')
     local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
-    local server_ip=$(curl -s --max-time 3 https://api.ipify.org)
-    if [ -z "$server_ip" ]; then
-        server_ip=$(curl -s --max-time 3 https://4.ident.me)
+    local URL_lists=(
+        "https://api4.ipify.org"
+        "https://ipv4.icanhazip.com"
+        "https://v4.api.ipinfo.io/ip"
+        "https://ipv4.myexternalip.com/raw"
+        "https://4.ident.me"
+        "https://check-host.net/ip"
+    )
+    local server_ip=""
+    for ip_address in "${URL_lists[@]}"; do
+        local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
+        local http_code=$(echo "$response" | tail -n1)
+        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
+        if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            server_ip="${ip_result}"
+            break
+        fi
+    done
+
+    if [[ -z "$server_ip" ]]; then
+        echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
+        while [[ -z "$server_ip" ]]; do
+            read -rp "Please enter your server's public IPv4 address: " server_ip
+            server_ip="${server_ip// /}"
+            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                server_ip=""
+            fi
+        done
     fi
 
     if [[ -n "$existing_cert" ]]; then
@@ -1139,14 +1165,35 @@ ssl_cert_issue_for_ip() {
     local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
 
     # Get server IP
-    local server_ip=$(curl -s --max-time 3 https://api.ipify.org)
-    if [ -z "$server_ip" ]; then
-        server_ip=$(curl -s --max-time 3 https://4.ident.me)
-    fi
+    local URL_lists=(
+        "https://api4.ipify.org"
+        "https://ipv4.icanhazip.com"
+        "https://v4.api.ipinfo.io/ip"
+        "https://ipv4.myexternalip.com/raw"
+        "https://4.ident.me"
+        "https://check-host.net/ip"
+    )
+    local server_ip=""
+    for ip_address in "${URL_lists[@]}"; do
+        local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
+        local http_code=$(echo "$response" | tail -n1)
+        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
+        if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+            server_ip="${ip_result}"
+            break
+        fi
+    done
 
-    if [ -z "$server_ip" ]; then
-        LOGE "Failed to get server IP address"
-        return 1
+    if [[ -z "$server_ip" ]]; then
+        LOGI "Could not auto-detect server IP from any provider."
+        while [[ -z "$server_ip" ]]; do
+            read -rp "Please enter your server's public IPv4 address: " server_ip
+            server_ip="${server_ip// /}"
+            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                LOGE "Invalid IPv4 address. Please try again."
+                server_ip=""
+            fi
+        done
     fi
 
     LOGI "Server IP detected: ${server_ip}"
@@ -2104,13 +2151,25 @@ SSH_port_forwarding() {
     for ip_address in "${URL_lists[@]}"; do
         local response=$(curl -s -w "\n%{http_code}" --max-time 3 "${ip_address}" 2> /dev/null)
         local http_code=$(echo "$response" | tail -n1)
-        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]')
-        if [[ "${http_code}" == "200" && -n "${ip_result}" ]]; then
+        local ip_result=$(echo "$response" | head -n-1 | tr -d '[:space:]"')
+        if [[ "${http_code}" == "200" && "${ip_result}" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
             server_ip="${ip_result}"
             break
         fi
     done
 
+    if [[ -z "$server_ip" ]]; then
+        echo -e "${yellow}Could not auto-detect server IP from any provider.${plain}"
+        while [[ -z "$server_ip" ]]; do
+            read -rp "Please enter your server's public IPv4 address: " server_ip
+            server_ip="${server_ip// /}"
+            if [[ ! "$server_ip" =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
+                echo -e "${red}Invalid IPv4 address. Please try again.${plain}"
+                server_ip=""
+            fi
+        done
+    fi
+
     local existing_webBasePath=$(${xui_folder}/x-ui setting -show true | grep -Eo 'webBasePath: .+' | awk '{print $2}')
     local existing_port=$(${xui_folder}/x-ui setting -show true | grep -Eo 'port: .+' | awk '{print $2}')
     local existing_listenIP=$(${xui_folder}/x-ui setting -getListen true | grep -Eo 'listenIP: .+' | awk '{print $2}')

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.