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
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.
## النجوم عبر الزمن
-[](https://starchart.cc/MHSanaei/3x-ui)
+[](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
-[](https://starchart.cc/MHSanaei/3x-ui)
+[](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.
## ستارهها در طول زمان
-[](https://starchart.cc/MHSanaei/3x-ui)
+[](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.
## Звезды с течением времени
-[](https://starchart.cc/MHSanaei/3x-ui)
+[](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.
## 随时间变化的星标数
-[](https://starchart.cc/MHSanaei/3x-ui)
+[](https://starchart.cc/MHSanaei/3x-ui)
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/sub/subService.go b/sub/subService.go
index 4d967a9f..dd6bbcc9 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)
@@ -1103,6 +1121,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 ed72f8f6..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;
}
@@ -1827,7 +1885,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;
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') {
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/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) {
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/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);
}
}
},
diff --git a/web/html/xray.html b/web/html/xray.html
index 01b4e4e2..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
}
}
@@ -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 = '';
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)
}
}
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
}
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)
+}