From 733f44ef0f593d8eb987e54da7a26d1bc46a6b6e Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 17:24:42 +0200 Subject: [PATCH 01/11] balancerTags with a default empty entry --- web/html/modals/xray_rule_modal.html | 1 + web/html/xray.html | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/web/html/modals/xray_rule_modal.html b/web/html/modals/xray_rule_modal.html index e6a8bf46..ab5389c7 100644 --- a/web/html/modals/xray_rule_modal.html +++ b/web/html/modals/xray_rule_modal.html @@ -203,6 +203,7 @@ } if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag)); } + this.balancerTags = [""]; if (app.templateSettings.routing && app.templateSettings.routing.balancers) { this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)]; } diff --git a/web/html/xray.html b/web/html/xray.html index 01b4e4e2..9c62ba69 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -938,6 +938,15 @@ if (newTemplateSettings.routing.balancers.length === 0) { delete newTemplateSettings.routing.balancers; } + + // Remove orphaned balancer references from routing rules + if (newTemplateSettings.routing.rules) { + newTemplateSettings.routing.rules.forEach((rule) => { + if (rule.balancerTag && rule.balancerTag === removedBalancer.tag) { + delete rule.balancerTag; + } + }); + } this.templateSettings = newTemplateSettings; this.updateObservatorySelectors(); this.obsSettings = ''; From ab7a7f7c6b4f6962bb792bc929577189bde01e75 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 18:47:38 +0200 Subject: [PATCH 02/11] Reduce observatory probe intervals and timeout --- web/html/xray.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/html/xray.html b/web/html/xray.html index 9c62ba69..a4d17459 100644 --- a/web/html/xray.html +++ b/web/html/xray.html @@ -365,16 +365,16 @@ defaultObservatory: { subjectSelector: [], probeURL: "https://www.google.com/generate_204", - probeInterval: "10m", + probeInterval: "1m", enableConcurrency: true }, defaultBurstObservatory: { subjectSelector: [], pingConfig: { destination: "https://www.google.com/generate_204", - interval: "30m", + interval: "1m", connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204", - timeout: "10s", + timeout: "5s", sampling: 2 } } From 975d6d1bad4899f1123243c8b8c0392e17859295 Mon Sep 17 00:00:00 2001 From: pwnnex Date: Tue, 21 Apr 2026 20:05:53 +0300 Subject: [PATCH 03/11] Fix: hysteria link gen crashes when echConfigList is a string (#4064) `genHysteriaLink` was calling `.join(',')` on `this.stream.tls.settings.echConfigList`, but that field is bound to an `` (single-line string) in `tls_settings.html` and defaults to `''` in `TlsStreamSettings.Settings`. Calling `.join()` on a string throws `TypeError: echConfigList.join is not a function`, which breaks the Info / QR buttons for every hysteria / hysteria2 inbound. All three sibling link generators (`genVmessLink`, `genVlessLink`, `genTrojanLink`) already pass the value directly: params.set("ech", this.stream.tls.settings.echConfigList) `URLSearchParams.set` will stringify arrays with `,` on its own, so the same one-liner works for both string and array inputs. Align `genHysteriaLink` with the other three. Fixes #4063 Co-authored-by: pwnnex --- web/assets/js/model/inbound.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index ed72f8f6..d0060fb4 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1827,7 +1827,7 @@ class Inbound extends XrayCommonClass { if (this.stream.tls.settings.fingerprint?.length > 0) params.set("fp", this.stream.tls.settings.fingerprint); if (this.stream.tls.alpn?.length > 0) params.set("alpn", this.stream.tls.alpn); if (this.stream.tls.settings.allowInsecure) params.set("insecure", "1"); - if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList.join(',')); + if (this.stream.tls.settings.echConfigList?.length > 0) params.set("ech", this.stream.tls.settings.echConfigList); if (this.stream.tls.sni?.length > 0) params.set("sni", this.stream.tls.sni); const udpMasks = this.stream?.finalmask?.udp; From 2983ac3f8eb40499a5dbb8c0fd450fdc8a43ee08 Mon Sep 17 00:00:00 2001 From: pwnnex Date: Tue, 21 Apr 2026 20:15:51 +0300 Subject: [PATCH 04/11] Fix xhttp xPadding settings missing from generated links (panel + subs) (#4065) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix: propagate xhttp xPadding settings into generated subscription links The four `genXLink` helpers in `sub/subService.go` only copied `path`, `host` and `mode` out of `xhttpSettings` when building vmess:// / vless:// / trojan:// / ss:// URLs. Everything else — `xPaddingBytes`, `xPaddingObfsMode`, `xPaddingKey`, `xPaddingHeader`, `xPaddingPlacement`, `xPaddingMethod` — was silently dropped. That meant an admin who set, say, `xPaddingBytes: "80-600"` plus obfs mode with a custom `xPaddingKey` on the inbound had a server config that no client could match from the copy-pasted link: the client kept the xray/sing-box internal defaults (`100-1000`, `x_padding`, `Referer`), hit the server, and was rejected by invalid padding (queryInHeader=Referer, key=x_padding) length: 0 The user-visible symptom on OpenWRT / Podkop / sing-box was "xhttp inbound just won't connect" — no obvious pointer to what was actually wrong because the link itself *looks* complete. Fix: * New helper `applyXhttpPaddingParams(xhttp, params)` writes `x_padding_bytes=` (flat, sing-box family reads this) and an `extra=` blob carrying the full set of xhttp settings (xray-core family reads this). Both encodings are emitted side-by-side so every mainstream client can pick at least one up. * All four link generators (`genVmessLink` via the obj map, `genVlessLink`, `genTrojanLink`, `genShadowsocksLink`) now invoke the copy. * Obfs-only fields (`xPaddingKey`, `xPaddingHeader`, `xPaddingPlacement`, `xPaddingMethod`) are only included when `xPaddingObfsMode` is actually true and the admin filled them in. An inbound with no custom padding produces exactly the same URL as before — existing subscriptions are unaffected. * Also propagate xhttp xPadding settings into the panel's own Info/QR links The previous commit covered the subscription service (sub/subService.go). The admin-panel side — the "Copy URL" / QR / Info buttons inside inbound details — has four more xhttp-emitting link generators in `web/assets/js/model/inbound.js` (`genVmessLink`, `genVLESSLink`, `genTrojanLink`, `genSSLink`) that had the exact same gap: only `path`, `host` and `mode` were copied. Mirror the server-side fix on the client: * Add two static helpers on `Inbound`: - `Inbound.applyXhttpPaddingToParams(xhttp, params)` for `vless://` / `trojan://` / `ss://` style URLs — writes `x_padding_bytes=` (sing-box family) and `extra=` (xray-core family). - `Inbound.applyXhttpPaddingToObj(xhttp, obj)` for the VMess base64 JSON body — sets the same fields directly on the object. * Call them from all four link generators so an admin who enables obfs mode + a custom `xPaddingKey` / `xPaddingHeader` actually gets a working URL from the panel. * Only non-empty fields are emitted, so default inbounds produce exactly the same URL as before. Also fixes a latent positional-args bug in `web/assets/js/model/outbound.js`: both VMess-JSON (L933) and `fromParamLink` (L975) were calling `new xHTTPStreamSettings(path, host, mode)` — but the 3rd positional arg of the constructor is `headers`, not `mode`, so `mode` was landing in the `headers` slot and the actual `mode` field stayed at its default. Construct explicitly and set `mode` by name; while here, also pick up `x_padding_bytes` and the `extra` JSON blob from the imported URL so the symmetric case of importing a padded link works too. --------- Co-authored-by: pwnnex --- sub/subService.go | 71 +++++++++++++++++++++++++++++++++ web/assets/js/model/inbound.js | 58 +++++++++++++++++++++++++++ web/assets/js/model/outbound.js | 28 ++++++++++++- 3 files changed, 155 insertions(+), 2 deletions(-) diff --git a/sub/subService.go b/sub/subService.go index a4d38485..bf9c029c 100644 --- a/sub/subService.go +++ b/sub/subService.go @@ -250,6 +250,21 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string { obj["host"] = searchHost(headers) } obj["mode"], _ = xhttp["mode"].(string) + // 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. + 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 + } + } + } } security, _ := stream["security"].(string) obj["tls"] = security @@ -408,6 +423,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) if security == "tls" { @@ -604,6 +620,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) if security == "tls" { @@ -803,6 +820,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st params["host"] = searchHost(headers) } params["mode"], _ = xhttp["mode"].(string) + applyXhttpPaddingParams(xhttp, params) } security, _ := stream["security"].(string) @@ -1057,6 +1075,59 @@ 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. +// +// 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= — flat param, understood by sing-box and its +// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …). +// - extra= — full xhttp settings blob, which is how +// xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the +// obfs-mode key / header / placement / method. +// +// 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) { + if xhttp == nil { + return + } + + if xpb, ok := xhttp["xPaddingBytes"].(string); ok && len(xpb) > 0 { + params["x_padding_bytes"] = xpb + } + + 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 + } + } + } + + if len(extra) > 0 { + if b, err := json.Marshal(extra); err == nil { + params["extra"] = string(b) + } + } +} + func searchHost(headers any) string { data, _ := headers.(map[string]any) for k, v := range data { diff --git a/web/assets/js/model/inbound.js b/web/assets/js/model/inbound.js index d0060fb4..1cef368b 100644 --- a/web/assets/js/model/inbound.js +++ b/web/assets/js/model/inbound.js @@ -1317,6 +1317,60 @@ 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`. + // + // Two encodings are emitted so each client family can pick at least + // one up: + // - x_padding_bytes= flat, for sing-box-family clients + // - extra= full blob, for xray-core clients + // + // 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); + } + const extra = {}; + if (typeof xhttp.xPaddingBytes === 'string' && xhttp.xPaddingBytes.length > 0) { + extra.xPaddingBytes = xhttp.xPaddingBytes; + } + if (xhttp.xPaddingObfsMode === true) { + extra.xPaddingObfsMode = true; + ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => { + if (typeof xhttp[k] === 'string' && xhttp[k].length > 0) { + extra[k] = xhttp[k]; + } + }); + } + if (Object.keys(extra).length > 0) { + 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) { + 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]; + } + }); + } + } + get clients() { switch (this.protocol) { case Protocols.VMESS: return this.settings.vmesses; @@ -1530,6 +1584,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); } if (tls === 'tls') { @@ -1594,6 +1649,7 @@ class Inbound extends XrayCommonClass { 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); break; } @@ -1694,6 +1750,7 @@ class Inbound extends XrayCommonClass { 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); break; } @@ -1770,6 +1827,7 @@ class Inbound extends XrayCommonClass { 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); break; } diff --git a/web/assets/js/model/outbound.js b/web/assets/js/model/outbound.js index 2a288c49..97602815 100644 --- a/web/assets/js/model/outbound.js +++ b/web/assets/js/model/outbound.js @@ -930,7 +930,13 @@ class Outbound extends CommonClass { } else if (network === 'httpupgrade') { stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host); } else if (network === 'xhttp') { - stream.xhttp = new xHTTPStreamSettings(json.path, json.host, json.mode); + // xHTTPStreamSettings positional args are (path, host, headers, ..., mode); + // passing `json.mode` as the 3rd argument used to land in the `headers` + // slot, dropping the mode on the floor. Build the object and set mode + // explicitly to avoid that. + const xh = new xHTTPStreamSettings(json.path, json.host); + if (json.mode) xh.mode = json.mode; + stream.xhttp = xh; } if (json.tls && json.tls == 'tls') { @@ -972,7 +978,25 @@ class Outbound extends CommonClass { } else if (type === 'httpupgrade') { stream.httpupgrade = new HttpUpgradeStreamSettings(path, host); } else if (type === 'xhttp') { - stream.xhttp = new xHTTPStreamSettings(path, host, mode); + // Same positional bug as in the VMess-JSON branch above: + // passing `mode` as the 3rd positional arg put it into the + // `headers` slot. Build explicitly instead. + const xh = new xHTTPStreamSettings(path, host); + if (mode) xh.mode = mode; + const xpb = url.searchParams.get('x_padding_bytes'); + if (xpb) xh.xPaddingBytes = xpb; + const extraRaw = url.searchParams.get('extra'); + if (extraRaw) { + try { + const extra = JSON.parse(extraRaw); + if (typeof extra.xPaddingBytes === 'string' && extra.xPaddingBytes) xh.xPaddingBytes = extra.xPaddingBytes; + if (extra.xPaddingObfsMode === true) xh.xPaddingObfsMode = true; + ["xPaddingKey", "xPaddingHeader", "xPaddingPlacement", "xPaddingMethod"].forEach(k => { + if (typeof extra[k] === 'string' && extra[k]) xh[k] = extra[k]; + }); + } catch (_) { /* ignore malformed extra */ } + } + stream.xhttp = xh; } if (security == 'tls') { From 0fd0389d5c4bb616bbea2558bbb7de4ae0d3bf51 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 20:02:39 +0200 Subject: [PATCH 05/11] sub json fix fragment noises effect Co-Authored-By: Alireza Ahmadi --- sub/subJsonService.go | 43 ++++++++++++++++++++----------- web/html/settings.html | 57 +++++++++++++----------------------------- 2 files changed, 46 insertions(+), 54 deletions(-) diff --git a/sub/subJsonService.go b/sub/subJsonService.go index 7ce93e22..acb8e05f 100644 --- a/sub/subJsonService.go +++ b/sub/subJsonService.go @@ -22,8 +22,7 @@ var defaultJson string type SubJsonService struct { configJson map[string]any defaultOutbounds []json_util.RawMessage - fragment string - noises string + fragmentOrNoises bool mux string inboundService service.InboundService @@ -42,6 +41,31 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, } } + fragmentOrNoises := false + if fragment != "" || noises != "" { + fragmentOrNoises = true + defaultOutboundsSettings := map[string]interface{}{ + "domainStrategy": "UseIP", + "redirect": "", + } + + if fragment != "" { + defaultOutboundsSettings["fragment"] = json_util.RawMessage(fragment) + } + + if noises != "" { + defaultOutboundsSettings["noises"] = json_util.RawMessage(noises) + } + + defaultDirectOutbound := map[string]interface{}{ + "protocol": "freedom", + "settings": defaultOutboundsSettings, + "tag": "direct_out", + } + jsonBytes, _ := json.MarshalIndent(defaultDirectOutbound, "", " ") + defaultOutbounds = append(defaultOutbounds, jsonBytes) + } + if rules != "" { var newRules []any routing, _ := configJson["routing"].(map[string]any) @@ -52,19 +76,10 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string, configJson["routing"] = routing } - if fragment != "" { - defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(fragment)) - } - - if noises != "" { - defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(noises)) - } - return &SubJsonService{ configJson: configJson, defaultOutbounds: defaultOutbounds, - fragment: fragment, - noises: noises, + fragmentOrNoises: fragmentOrNoises, mux: mux, SubService: subService, } @@ -224,8 +239,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any { } delete(streamSettings, "sockopt") - if s.fragment != "" { - streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "fragment", "tcpKeepAliveIdle": 100, "tcpMptcp": true, "penetrate": true}`) + if s.fragmentOrNoises { + streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`) } // remove proxy protocol diff --git a/web/html/settings.html b/web/html/settings.html index 441e62de..769fbd37 100644 --- a/web/html/settings.html +++ b/web/html/settings.html @@ -129,35 +129,14 @@ datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }], remarkSample: '', defaultFragment: { - tag: "fragment", - protocol: "freedom", - settings: { - domainStrategy: "AsIs", - fragment: { - packets: "tlshello", - length: "100-200", - interval: "10-20", - maxSplit: "300-400" - } - }, - streamSettings: { - sockopt: { - tcpKeepAliveIdle: 100, - tcpMptcp: true, - penetrate: true - } - } - }, - defaultNoises: { - tag: "noises", - protocol: "freedom", - settings: { - domainStrategy: "AsIs", - noises: [ - { type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" }, - ], - }, + packets: "tlshello", + length: "100-200", + interval: "10-20", + maxSplit: "300-400" }, + defaultNoises: [ + { type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" } + ], defaultMux: { enabled: true, concurrency: 8, @@ -451,41 +430,41 @@ } }, fragmentPackets: { - get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.packets : ""; }, + get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).packets : ""; }, set: function (v) { if (v != "") { newFragment = JSON.parse(this.allSetting.subJsonFragment); - newFragment.settings.fragment.packets = v; + newFragment.packets = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } } }, fragmentLength: { - get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.length : ""; }, + get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).length : ""; }, set: function (v) { if (v != "") { newFragment = JSON.parse(this.allSetting.subJsonFragment); - newFragment.settings.fragment.length = v; + newFragment.length = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } } }, fragmentInterval: { - get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.interval : ""; }, + get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).interval : ""; }, set: function (v) { if (v != "") { newFragment = JSON.parse(this.allSetting.subJsonFragment); - newFragment.settings.fragment.interval = v; + newFragment.interval = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } } }, fragmentMaxSplit: { - get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).settings.fragment.maxSplit : ""; }, + get: function () { return this.fragment ? JSON.parse(this.allSetting.subJsonFragment).maxSplit : ""; }, set: function (v) { if (v != "") { newFragment = JSON.parse(this.allSetting.subJsonFragment); - newFragment.settings.fragment.maxSplit = v; + newFragment.maxSplit = v; this.allSetting.subJsonFragment = JSON.stringify(newFragment); } } @@ -504,13 +483,11 @@ }, noisesArray: { get() { - return this.noises ? JSON.parse(this.allSetting.subJsonNoises).settings.noises : []; + return this.noises ? JSON.parse(this.allSetting.subJsonNoises) : []; }, set(value) { if (this.noises) { - const newNoises = JSON.parse(this.allSetting.subJsonNoises); - newNoises.settings.noises = value; - this.allSetting.subJsonNoises = JSON.stringify(newNoises); + this.allSetting.subJsonNoises = JSON.stringify(value); } } }, From 86a8eb16b4d17a02400d712ab218982d3b3d31cc Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 20:05:52 +0200 Subject: [PATCH 06/11] fix timelocation for windows Co-Authored-By: Alireza Ahmadi --- web/service/setting.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/web/service/setting.go b/web/service/setting.go index 04d8f6a8..560dce3a 100644 --- a/web/service/setting.go +++ b/web/service/setting.go @@ -462,7 +462,12 @@ func (s *SettingService) GetTimeLocation() (*time.Location, error) { if err != nil { defaultLocation := defaultValueMap["timeLocation"] logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation) - return time.LoadLocation(defaultLocation) + location, err = time.LoadLocation(defaultLocation) + if err != nil { + logger.Errorf("failed to load default location, using UTC: %v", err) + return time.UTC, nil + } + return location, nil } return location, nil } From c79b45e512bd41d24f668a373fdabb922c437877 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 20:20:43 +0200 Subject: [PATCH 07/11] Readme: Remove custom GeoSite/GeoIP DAT section Remove the "Custom GeoSite / GeoIP DAT" section from the main README and all localized READMEs (ar_EG, es_ES, fa_IR, ru_RU, zh_CN). Also apply minor formatting cleanups: normalize language header spacing and remove trailing spaces from the Stargazers badge lines. --- README.ar_EG.md | 12 ++---------- README.es_ES.md | 12 ++---------- README.fa_IR.md | 12 ++---------- README.md | 10 +--------- README.ru_RU.md | 12 ++---------- README.zh_CN.md | 12 ++---------- 6 files changed, 11 insertions(+), 59 deletions(-) diff --git a/README.ar_EG.md b/README.ar_EG.md index d5a5d90f..eb9c634b 100644 --- a/README.ar_EG.md +++ b/README.ar_EG.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية. -## مصادر DAT مخصصة GeoSite / GeoIP - -يمكن للمسؤولين إضافة ملفات `.dat` لـ GeoSite وGeoIP من عناوين URL في اللوحة (نفس أسلوب تحديث ملفات الجيو المدمجة). تُحفظ الملفات بجانب ثنائي Xray (`XUI_BIN_FOLDER`، الافتراضي `bin/`) بأسماء ثابتة: `geosite_<alias>.dat` و`geoip_<alias>.dat`. - -**التوجيه:** استخدم الصيغة `ext:`، مثل `ext:geosite_myalias.dat:tag` أو `ext:geoip_myalias.dat:tag`، حيث `tag` اسم قائمة داخل ملف DAT (كما في `ext:geoip_IR.dat:ir`). - -**الأسماء المحجوزة:** يُقارَن شكل مُطبَّع فقط لمعرفة التحفظ (`strings.ToLower`، `-` → `_`). لا تُعاد كتابة الأسماء التي يدخلها المستخدم أو سجلات قاعدة البيانات؛ يجب أن تطابق `^[a-z0-9_-]+$`. مثلاً `geoip-ir` و`geoip_ir` يصطدمان بنفس الحجز. - ## البدء السريع ``` @@ -61,4 +53,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install. ## النجوم عبر الزمن -[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) +[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) diff --git a/README.es_ES.md b/README.es_ES.md index 647fb2b3..caddb406 100644 --- a/README.es_ES.md +++ b/README.es_ES.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ Como una versión mejorada del proyecto X-UI original, 3X-UI proporciona mayor estabilidad, soporte más amplio de protocolos y características adicionales. -## Fuentes DAT personalizadas GeoSite / GeoIP - -Los administradores pueden añadir archivos `.dat` de GeoSite y GeoIP desde URLs en el panel (mismo flujo que los geoficheros integrados). Los archivos se guardan junto al binario de Xray (`XUI_BIN_FOLDER`, por defecto `bin/`) con nombres fijos: `geosite_<alias>.dat` y `geoip_<alias>.dat`. - -**Enrutamiento:** use la forma `ext:`, por ejemplo `ext:geosite_myalias.dat:tag` o `ext:geoip_myalias.dat:tag`, donde `tag` es un nombre de lista dentro del DAT (igual que en archivos regionales como `ext:geoip_IR.dat:ir`). - -**Alias reservados:** solo para comprobar si un nombre está reservado se compara una forma normalizada (`strings.ToLower`, `-` → `_`). Los alias introducidos y los nombres en la base de datos no se reescriben; deben cumplir `^[a-z0-9_-]+$`. Por ejemplo, `geoip-ir` y `geoip_ir` chocan con la misma entrada reservada. - ## Inicio Rápido ``` @@ -62,4 +54,4 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M ## Estrellas a lo Largo del Tiempo -[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) +[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) diff --git a/README.fa_IR.md b/README.fa_IR.md index 639f1dd9..67584828 100644 --- a/README.fa_IR.md +++ b/README.fa_IR.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گسترده‌تر از پروتکل‌ها و ویژگی‌های اضافی را ارائه می‌دهد. -## منابع DAT سفارشی GeoSite / GeoIP - -سرپرستان می‌توانند از طریق پنل فایل‌های `.dat` GeoSite و GeoIP را از URL اضافه کنند (همان الگوی به‌روزرسانی ژئوفایل‌های داخلی). فایل‌ها در کنار باینری Xray (`XUI_BIN_FOLDER`، پیش‌فرض `bin/`) با نام‌های ثابت `geosite_<alias>.dat` و `geoip_<alias>.dat` ذخیره می‌شوند. - -**مسیریابی:** از شکل `ext:` استفاده کنید، مثلاً `ext:geosite_myalias.dat:tag` یا `ext:geoip_myalias.dat:tag`؛ `tag` نام لیست داخل همان DAT است (مانند `ext:geoip_IR.dat:ir`). - -**نام‌های رزرو:** فقط برای تشخیص رزرو بودن، نسخه نرمال‌شده (`strings.ToLower`، `-` → `_`) مقایسه می‌شود. نام‌های واردشده و رکورد پایگاه داده بازنویسی نمی‌شوند و باید با `^[a-z0-9_-]+$` سازگار باشند؛ مثلاً `geoip-ir` و `geoip_ir` به یک رزرو یکسان می‌خورند. - ## شروع سریع ``` @@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install. ## ستاره‌ها در طول زمان -[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) +[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) diff --git a/README.md b/README.md index 5b7c03f9..400db1ad 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ As an enhanced fork of the original X-UI project, 3X-UI provides improved stability, broader protocol support, and additional features. -## Custom GeoSite / GeoIP DAT sources - -Administrators can add custom GeoSite and GeoIP `.dat` files from URLs in the panel (same workflow as updating built-in geofiles). Files are stored under the same directory as the Xray binary (`XUI_BIN_FOLDER`, default `bin/`) with deterministic names: `geosite_<alias>.dat` and `geoip_<alias>.dat`. - -**Routing:** Xray resolves extra lists using the `ext:` form, for example `ext:geosite_myalias.dat:tag` or `ext:geoip_myalias.dat:tag`, where `tag` is a list name inside that DAT file (same pattern as built-in regional files such as `ext:geoip_IR.dat:ir`). - -**Reserved aliases:** Only for deciding whether a name is reserved, the panel compares a normalized form of the alias (`strings.ToLower`, `-` → `_`). User-entered aliases and generated file names are not rewritten in the database; they must still match `^[a-z0-9_-]+$`. For example, `geoip-ir` and `geoip_ir` collide with the same reserved entry. - ## Quick Start ```bash diff --git a/README.ru_RU.md b/README.ru_RU.md index 9fa85c19..efc4bf86 100644 --- a/README.ru_RU.md +++ b/README.ru_RU.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции. -## Пользовательские GeoSite / GeoIP (DAT) - -В панели можно задать свои источники `.dat` по URL (тот же сценарий, что и для встроенных геофайлов). Файлы сохраняются в каталоге с бинарником Xray (`XUI_BIN_FOLDER`, по умолчанию `bin/`) как `geosite_<alias>.dat` и `geoip_<alias>.dat`. - -**Маршрутизация:** в правилах используйте форму `ext:имя_файла.dat:тег`, например `ext:geosite_myalias.dat:tag` (как у региональных списков `ext:geoip_IR.dat:ir`). - -**Зарезервированные псевдонимы:** только для проверки на резерв используется нормализованная форма (`strings.ToLower`, `-` → `_`). Введённые пользователем псевдонимы и имена файлов в БД не переписываются и должны соответствовать `^[a-z0-9_-]+$`. Например, `geoip-ir` и `geoip_ir` попадают под одну и ту же зарезервированную запись. - ## Быстрый старт ``` @@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install. ## Звезды с течением времени -[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) +[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) diff --git a/README.zh_CN.md b/README.zh_CN.md index 4ee8d7bd..13d5075d 100644 --- a/README.zh_CN.md +++ b/README.zh_CN.md @@ -1,4 +1,4 @@ -[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) +[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)

@@ -22,14 +22,6 @@ 作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。 -## 自定义 GeoSite / GeoIP(DAT) - -管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_<alias>.dat` 和 `geoip_<alias>.dat`。 - -**路由:** 在规则中使用 `ext:` 形式,例如 `ext:geosite_myalias.dat:tag` 或 `ext:geoip_myalias.dat:tag`,其中 `tag` 为该 DAT 文件内的列表名(与内置区域文件如 `ext:geoip_IR.dat:ir` 相同)。 - -**保留别名:** 仅在为判断是否命中保留名时,会对别名做规范化比较(`strings.ToLower`,`-` → `_`)。用户输入的别名与数据库中的名称不会被改写,且须符合 `^[a-z0-9_-]+$`。例如 `geoip-ir` 与 `geoip_ir` 视为同一保留项。 - ## 快速开始 ``` @@ -62,4 +54,4 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install. ## 随时间变化的星标数 -[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) +[![Stargazers over time](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui) From 15be803da982ab362f6859c0ec91a582c4b1fea6 Mon Sep 17 00:00:00 2001 From: pwnnex Date: Tue, 21 Apr 2026 21:30:02 +0300 Subject: [PATCH 08/11] Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `getXraySetting` builds its response as { "xraySetting": , "inboundTags": ..., "outboundTestUrl": ... } and embeds the raw DB value as the `xraySetting` field without checking whether the stored value already has that exact shape. The frontend pulls the textarea content from `result.xraySetting` and saves it back verbatim. If the DB ever ends up holding the response-shaped wrapper instead of a real xray config (older installs where this happened at least once, users who imported a copy-pasted response into the textarea, a botched migration, etc.), the next save nests another layer, the one after that nests a third, and the Vue-side JSON.parse of the resulting blob silently fails — the Xray Settings page goes blank. Fix both ends of the round-trip: * Add `service.UnwrapXrayTemplateConfig`. It peels off any number of `xraySetting`-keyed layers, leaving a real xray config behind. The check is conservative: if the outer object already contains any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`, `dns`, `log`, `policy`, `stats`), it is returned unchanged, and there is a depth cap to avoid pathological inputs. * `SaveXraySetting` unwraps before validation so a round-tripped wrapper from an already-corrupted page can no longer re-poison the DB on save. * `getXraySetting` unwraps on read and, when it finds a wrapper, rewrites the DB with the corrected value. Existing broken installs heal themselves on the next visit to the page. Includes unit tests for the passthrough, single-wrap, multi-wrap, string-encoded-inner, and false-positive cases. Co-authored-by: pwnnex --- web/controller/xray_setting.go | 17 ++++++ web/service/xray_setting.go | 54 +++++++++++++++++++ web/service/xray_setting_test.go | 90 ++++++++++++++++++++++++++++++++ 3 files changed, 161 insertions(+) create mode 100644 web/service/xray_setting_test.go diff --git a/web/controller/xray_setting.go b/web/controller/xray_setting.go index 0c382fb9..7e4c7966 100644 --- a/web/controller/xray_setting.go +++ b/web/controller/xray_setting.go @@ -49,6 +49,23 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) return } + // Older versions of this handler embedded the raw DB value as + // `xraySetting` in the response without checking if the value + // already had that wrapper shape. When the frontend saved it + // back through the textarea verbatim, the wrapper got persisted + // and every subsequent save nested another layer, which is what + // eventually produced the blank Xray Settings page in #4059. + // Strip any such wrapper here, and heal the DB if we found one so + // the next read is O(1) instead of climbing the same pile again. + if unwrapped := service.UnwrapXrayTemplateConfig(xraySetting); unwrapped != xraySetting { + if saveErr := a.XraySettingService.SaveXraySetting(unwrapped); saveErr == nil { + xraySetting = unwrapped + } else { + // Don't fail the read — just serve the unwrapped value + // and leave the DB healing for a later save. + xraySetting = unwrapped + } + } inboundTags, err := a.InboundService.GetInboundTags() if err != nil { jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err) diff --git a/web/service/xray_setting.go b/web/service/xray_setting.go index 5df8a211..4c3892e4 100644 --- a/web/service/xray_setting.go +++ b/web/service/xray_setting.go @@ -15,6 +15,12 @@ type XraySettingService struct { } func (s *XraySettingService) SaveXraySetting(newXraySettings string) error { + // The frontend round-trips the whole getXraySetting response back + // through the textarea, so if it has ever received a wrapped + // payload (see UnwrapXrayTemplateConfig) it sends that same wrapper + // back here. Strip it before validation/storage, otherwise we save + // garbage the next read can't recover from without this same call. + newXraySettings = UnwrapXrayTemplateConfig(newXraySettings) if err := s.CheckXrayConfig(newXraySettings); err != nil { return err } @@ -29,3 +35,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error { } return nil } + +// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`, +// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ..., +// "xraySetting": }` response-shaped wrappers that may have +// ended up in the database. +// +// How it got there: getXraySetting used to embed the raw DB value as +// `xraySetting` in its response without checking whether the stored +// value was already that exact response shape. If the frontend then +// saved it verbatim (the textarea is a round-trip of the JSON it was +// handed), the wrapper got persisted — and each subsequent save nested +// another layer, producing the blank Xray Settings page reported in +// issue #4059. +// +// If `raw` does not look like a wrapper, it is returned unchanged. +func UnwrapXrayTemplateConfig(raw string) string { + const maxDepth = 8 // defensive cap against pathological multi-nest values + for i := 0; i < maxDepth; i++ { + var top map[string]json.RawMessage + if err := json.Unmarshal([]byte(raw), &top); err != nil { + return raw + } + inner, ok := top["xraySetting"] + if !ok { + return raw + } + // Real xray configs never contain a top-level "xraySetting" key, + // but they do contain things like "inbounds"/"outbounds"/"api". + // If any of those are present, we're already at the real config + // and the "xraySetting" field is either user data or coincidence + // — don't touch it. + for _, k := range []string{"inbounds", "outbounds", "routing", "api", "dns", "log", "policy", "stats"} { + if _, hit := top[k]; hit { + return raw + } + } + // Peel off one layer. + unwrapped := string(inner) + // `xraySetting` may be stored either as a JSON object or as a + // JSON-encoded string of an object. Handle both. + var asStr string + if err := json.Unmarshal(inner, &asStr); err == nil { + unwrapped = asStr + } + raw = unwrapped + } + return raw +} diff --git a/web/service/xray_setting_test.go b/web/service/xray_setting_test.go new file mode 100644 index 00000000..2c165576 --- /dev/null +++ b/web/service/xray_setting_test.go @@ -0,0 +1,90 @@ +package service + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestUnwrapXrayTemplateConfig(t *testing.T) { + real := `{"log":{},"inbounds":[],"outbounds":[],"routing":{}}` + + t.Run("passes through a clean config", func(t *testing.T) { + if got := UnwrapXrayTemplateConfig(real); got != real { + t.Fatalf("clean config was modified: %s", got) + } + }) + + t.Run("passes through invalid JSON unchanged", func(t *testing.T) { + in := "not json at all" + if got := UnwrapXrayTemplateConfig(in); got != in { + t.Fatalf("invalid input was modified: %s", got) + } + }) + + t.Run("unwraps one layer of response-shaped wrapper", func(t *testing.T) { + wrapper := `{"inboundTags":["tag"],"outboundTestUrl":"x","xraySetting":` + real + `}` + got := UnwrapXrayTemplateConfig(wrapper) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("unwraps multiple stacked layers", func(t *testing.T) { + lvl1 := `{"xraySetting":` + real + `}` + lvl2 := `{"xraySetting":` + lvl1 + `}` + lvl3 := `{"xraySetting":` + lvl2 + `}` + got := UnwrapXrayTemplateConfig(lvl3) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("handles an xraySetting stored as a JSON-encoded string", func(t *testing.T) { + encoded, _ := json.Marshal(real) // becomes a quoted string + wrapper := `{"xraySetting":` + string(encoded) + `}` + got := UnwrapXrayTemplateConfig(wrapper) + if !equalJSON(t, got, real) { + t.Fatalf("want %s, got %s", real, got) + } + }) + + t.Run("does not unwrap when top level already has real xray keys", func(t *testing.T) { + // Pathological but defensible: if a user's actual config somehow + // has both the real keys and an unrelated `xraySetting` key, we + // must not strip it. + in := `{"inbounds":[],"xraySetting":{"some":"thing"}}` + got := UnwrapXrayTemplateConfig(in) + if got != in { + t.Fatalf("should have left real config alone, got %s", got) + } + }) + + t.Run("stops at a reasonable depth", func(t *testing.T) { + // Build a deeper-than-maxDepth chain that ends at something + // non-wrapped, and confirm we end up at some valid JSON (we + // don't loop forever and we don't blow the stack). + s := real + for i := 0; i < 16; i++ { + s = `{"xraySetting":` + s + `}` + } + got := UnwrapXrayTemplateConfig(s) + if !strings.Contains(got, `"inbounds"`) && !strings.Contains(got, `"xraySetting"`) { + t.Fatalf("unexpected tail: %s", got) + } + }) +} + +func equalJSON(t *testing.T, a, b string) bool { + t.Helper() + var va, vb any + if err := json.Unmarshal([]byte(a), &va); err != nil { + return false + } + if err := json.Unmarshal([]byte(b), &vb); err != nil { + return false + } + ja, _ := json.Marshal(va) + jb, _ := json.Marshal(vb) + return string(ja) == string(jb) +} From b86473df027f691dd31ff35f57051ea667b11c03 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 20:36:28 +0200 Subject: [PATCH 09/11] Run cache cleanup daily and reduce cutoff to 1 day --- .github/workflows/cleanup_caches.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/cleanup_caches.yml b/.github/workflows/cleanup_caches.yml index dcf50fce..b7d8fc1a 100644 --- a/.github/workflows/cleanup_caches.yml +++ b/.github/workflows/cleanup_caches.yml @@ -1,7 +1,7 @@ name: Cleanup Caches on: schedule: - - cron: '0 3 * * 0' # every Sunday + - cron: "0 3 * * *" # every day workflow_dispatch: jobs: @@ -10,16 +10,16 @@ jobs: permissions: actions: write steps: - - name: Delete caches older than 3 days + - name: Delete caches older than 1 day env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - CUTOFF_DATE=$(date -d "3 days ago" -Ins --utc | sed 's/+0000/Z/') + CUTOFF_DATE=$(date -d "1 days ago" -Ins --utc | sed 's/+0000/Z/') echo "Deleting caches older than: $CUTOFF_DATE" - + CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \ --jq ".actions_caches[] | select(.last_accessed_at < \"$CUTOFF_DATE\") | .id" 2>/dev/null) - + if [ -z "$CACHE_IDS" ]; then echo "No old caches found to delete." else @@ -28,4 +28,4 @@ jobs: gh api -X DELETE repos/${{ github.repository }}/actions/caches/$CACHE_ID done echo "Old caches deleted successfully." - fi \ No newline at end of file + fi From 0a38624ba7701a04faeb6dd1ba9d05454f0b4843 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 21:18:59 +0200 Subject: [PATCH 10/11] Add None option VLESS auth selection --- web/html/form/protocol/vless.html | 1 + web/html/modals/inbound_modal.html | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/web/html/form/protocol/vless.html b/web/html/form/protocol/vless.html index 443737d0..1ff83839 100644 --- a/web/html/form/protocol/vless.html +++ b/web/html/form/protocol/vless.html @@ -25,6 +25,7 @@ + None X25519 (not Post-Quantum) ML-KEM-768 diff --git a/web/html/modals/inbound_modal.html b/web/html/modals/inbound_modal.html index ef93aefb..aab1af6d 100644 --- a/web/html/modals/inbound_modal.html +++ b/web/html/modals/inbound_modal.html @@ -307,6 +307,12 @@ this.inbound.stream.tls.settings.echConfigList = ""; }, async getNewVlessEnc() { + const selected = inModal.inbound.settings.selectedAuth; + if (!selected) { + this.clearVlessEnc(); + return; + } + inModal.loading(true); const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc"); inModal.loading(false); @@ -316,7 +322,6 @@ } const auths = msg.obj.auths || []; - const selected = inModal.inbound.settings.selectedAuth; const block = auths.find((a) => a.label === selected); if (!block) { From 814e6ad69cd92d77258d78bba232c3af0a4a3e68 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Tue, 21 Apr 2026 21:20:59 +0200 Subject: [PATCH 11/11] Lower minimum Xray version Update GetXrayVersions filter to accept Xray releases >= 26.3.10 instead of the previous >= 26.4.17. This changes the conditional in web/service/server.go so releases from 26.3.10 onward are included when building the versions list. --- web/service/server.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/service/server.go b/web/service/server.go index 3292bbab..69534ee2 100644 --- a/web/service/server.go +++ b/web/service/server.go @@ -567,7 +567,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) { continue } - if major > 26 || (major == 26 && minor > 4) || (major == 26 && minor == 4 && patch >= 17) { + if major > 26 || (major == 26 && minor > 3) || (major == 26 && minor == 3 && patch >= 10) { versions = append(versions, release.TagName) } }