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. ## النجوم عبر الزمن -[![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) 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) +}