mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-07 13:44:24 +00:00
Merge branch 'main' into fix/hysteria-external-proxy-and-clash
This commit is contained in:
commit
863f767838
21 changed files with 407 additions and 128 deletions
6
.github/workflows/cleanup_caches.yml
vendored
6
.github/workflows/cleanup_caches.yml
vendored
|
|
@ -1,7 +1,7 @@
|
||||||
name: Cleanup Caches
|
name: Cleanup Caches
|
||||||
on:
|
on:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 3 * * 0' # every Sunday
|
- cron: "0 3 * * *" # every day
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -10,11 +10,11 @@ jobs:
|
||||||
permissions:
|
permissions:
|
||||||
actions: write
|
actions: write
|
||||||
steps:
|
steps:
|
||||||
- name: Delete caches older than 3 days
|
- name: Delete caches older than 1 day
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
run: |
|
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"
|
echo "Deleting caches older than: $CUTOFF_DATE"
|
||||||
|
|
||||||
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
CACHE_IDS=$(gh api --paginate repos/${{ github.repository }}/actions/caches \
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -22,14 +22,6 @@
|
||||||
|
|
||||||
كمشروع محسن من مشروع X-UI الأصلي، يوفر 3X-UI استقرارًا محسنًا ودعمًا أوسع للبروتوكولات وميزات إضافية.
|
كمشروع محسن من مشروع 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` يصطدمان بنفس الحجز.
|
|
||||||
|
|
||||||
## البدء السريع
|
## البدء السريع
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -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.
|
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
|
## Inicio Rápido
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -22,14 +22,6 @@
|
||||||
|
|
||||||
به عنوان یک نسخه بهبود یافته از پروژه اصلی X-UI، 3X-UI پایداری بهتر، پشتیبانی گستردهتر از پروتکلها و ویژگیهای اضافی را ارائه میدهد.
|
به عنوان یک نسخه بهبود یافته از پروژه اصلی 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` به یک رزرو یکسان میخورند.
|
|
||||||
|
|
||||||
## شروع سریع
|
## شروع سریع
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
10
README.md
10
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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -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.
|
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
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -22,14 +22,6 @@
|
||||||
|
|
||||||
Как улучшенная версия оригинального проекта X-UI, 3X-UI обеспечивает повышенную стабильность, более широкую поддержку протоколов и дополнительные функции.
|
Как улучшенная версия оригинального проекта 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` попадают под одну и ту же зарезервированную запись.
|
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|
@ -22,14 +22,6 @@
|
||||||
|
|
||||||
作为原始 X-UI 项目的增强版本,3X-UI 提供了更好的稳定性、更广泛的协议支持和额外的功能。
|
作为原始 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` 视为同一保留项。
|
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,7 @@ var defaultJson string
|
||||||
type SubJsonService struct {
|
type SubJsonService struct {
|
||||||
configJson map[string]any
|
configJson map[string]any
|
||||||
defaultOutbounds []json_util.RawMessage
|
defaultOutbounds []json_util.RawMessage
|
||||||
fragment string
|
fragmentOrNoises bool
|
||||||
noises string
|
|
||||||
mux string
|
mux string
|
||||||
|
|
||||||
inboundService service.InboundService
|
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 != "" {
|
if rules != "" {
|
||||||
var newRules []any
|
var newRules []any
|
||||||
routing, _ := configJson["routing"].(map[string]any)
|
routing, _ := configJson["routing"].(map[string]any)
|
||||||
|
|
@ -52,19 +76,10 @@ func NewSubJsonService(fragment string, noises string, mux string, rules string,
|
||||||
configJson["routing"] = routing
|
configJson["routing"] = routing
|
||||||
}
|
}
|
||||||
|
|
||||||
if fragment != "" {
|
|
||||||
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(fragment))
|
|
||||||
}
|
|
||||||
|
|
||||||
if noises != "" {
|
|
||||||
defaultOutbounds = append(defaultOutbounds, json_util.RawMessage(noises))
|
|
||||||
}
|
|
||||||
|
|
||||||
return &SubJsonService{
|
return &SubJsonService{
|
||||||
configJson: configJson,
|
configJson: configJson,
|
||||||
defaultOutbounds: defaultOutbounds,
|
defaultOutbounds: defaultOutbounds,
|
||||||
fragment: fragment,
|
fragmentOrNoises: fragmentOrNoises,
|
||||||
noises: noises,
|
|
||||||
mux: mux,
|
mux: mux,
|
||||||
SubService: subService,
|
SubService: subService,
|
||||||
}
|
}
|
||||||
|
|
@ -224,8 +239,8 @@ func (s *SubJsonService) streamData(stream string) map[string]any {
|
||||||
}
|
}
|
||||||
delete(streamSettings, "sockopt")
|
delete(streamSettings, "sockopt")
|
||||||
|
|
||||||
if s.fragment != "" {
|
if s.fragmentOrNoises {
|
||||||
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "fragment", "tcpKeepAliveIdle": 100, "tcpMptcp": true, "penetrate": true}`)
|
streamSettings["sockopt"] = json_util.RawMessage(`{"dialerProxy": "direct_out", "tcpKeepAliveIdle": 100}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// remove proxy protocol
|
// remove proxy protocol
|
||||||
|
|
|
||||||
|
|
@ -250,6 +250,21 @@ func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
obj["host"] = searchHost(headers)
|
obj["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
obj["mode"], _ = xhttp["mode"].(string)
|
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)
|
security, _ := stream["security"].(string)
|
||||||
obj["tls"] = security
|
obj["tls"] = security
|
||||||
|
|
@ -408,6 +423,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"], _ = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
|
applyXhttpPaddingParams(xhttp, params)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
|
|
@ -604,6 +620,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"], _ = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
|
applyXhttpPaddingParams(xhttp, params)
|
||||||
}
|
}
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
if security == "tls" {
|
if security == "tls" {
|
||||||
|
|
@ -803,6 +820,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
||||||
params["host"] = searchHost(headers)
|
params["host"] = searchHost(headers)
|
||||||
}
|
}
|
||||||
params["mode"], _ = xhttp["mode"].(string)
|
params["mode"], _ = xhttp["mode"].(string)
|
||||||
|
applyXhttpPaddingParams(xhttp, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
security, _ := stream["security"].(string)
|
security, _ := stream["security"].(string)
|
||||||
|
|
@ -1103,6 +1121,59 @@ func searchKey(data any, key string) (any, bool) {
|
||||||
return nil, false
|
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=<range> — flat param, understood by sing-box and its
|
||||||
|
// derivatives (Podkop, OpenWRT sing-box, Karing, NekoBox, …).
|
||||||
|
// - extra=<url-encoded-json> — full xhttp settings blob, which is how
|
||||||
|
// xray-core clients (v2rayNG, Happ, Furious, Exclave, …) pick up the
|
||||||
|
// obfs-mode key / header / placement / method.
|
||||||
|
//
|
||||||
|
// 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 {
|
func searchHost(headers any) string {
|
||||||
data, _ := headers.(map[string]any)
|
data, _ := headers.(map[string]any)
|
||||||
for k, v := range data {
|
for k, v := range data {
|
||||||
|
|
|
||||||
|
|
@ -1317,6 +1317,60 @@ class Inbound extends XrayCommonClass {
|
||||||
return this.clientStats;
|
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=<range> flat, for sing-box-family clients
|
||||||
|
// - extra=<url-encoded-json> 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() {
|
get clients() {
|
||||||
switch (this.protocol) {
|
switch (this.protocol) {
|
||||||
case Protocols.VMESS: return this.settings.vmesses;
|
case Protocols.VMESS: return this.settings.vmesses;
|
||||||
|
|
@ -1530,6 +1584,7 @@ class Inbound extends XrayCommonClass {
|
||||||
obj.path = xhttp.path;
|
obj.path = xhttp.path;
|
||||||
obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
|
obj.host = xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host');
|
||||||
obj.type = xhttp.mode;
|
obj.type = xhttp.mode;
|
||||||
|
Inbound.applyXhttpPaddingToObj(xhttp, obj);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tls === 'tls') {
|
if (tls === 'tls') {
|
||||||
|
|
@ -1594,6 +1649,7 @@ class Inbound extends XrayCommonClass {
|
||||||
params.set("path", xhttp.path);
|
params.set("path", xhttp.path);
|
||||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||||
params.set("mode", xhttp.mode);
|
params.set("mode", xhttp.mode);
|
||||||
|
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1694,6 +1750,7 @@ class Inbound extends XrayCommonClass {
|
||||||
params.set("path", xhttp.path);
|
params.set("path", xhttp.path);
|
||||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||||
params.set("mode", xhttp.mode);
|
params.set("mode", xhttp.mode);
|
||||||
|
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1770,6 +1827,7 @@ class Inbound extends XrayCommonClass {
|
||||||
params.set("path", xhttp.path);
|
params.set("path", xhttp.path);
|
||||||
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
params.set("host", xhttp.host?.length > 0 ? xhttp.host : this.getHeader(xhttp, 'host'));
|
||||||
params.set("mode", xhttp.mode);
|
params.set("mode", xhttp.mode);
|
||||||
|
Inbound.applyXhttpPaddingToParams(xhttp, params);
|
||||||
break;
|
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.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.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.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);
|
if (this.stream.tls.sni?.length > 0) params.set("sni", this.stream.tls.sni);
|
||||||
|
|
||||||
const udpMasks = this.stream?.finalmask?.udp;
|
const udpMasks = this.stream?.finalmask?.udp;
|
||||||
|
|
|
||||||
|
|
@ -930,7 +930,13 @@ class Outbound extends CommonClass {
|
||||||
} else if (network === 'httpupgrade') {
|
} else if (network === 'httpupgrade') {
|
||||||
stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
|
stream.httpupgrade = new HttpUpgradeStreamSettings(json.path, json.host);
|
||||||
} else if (network === 'xhttp') {
|
} 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') {
|
if (json.tls && json.tls == 'tls') {
|
||||||
|
|
@ -972,7 +978,25 @@ class Outbound extends CommonClass {
|
||||||
} else if (type === 'httpupgrade') {
|
} else if (type === 'httpupgrade') {
|
||||||
stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
|
stream.httpupgrade = new HttpUpgradeStreamSettings(path, host);
|
||||||
} else if (type === 'xhttp') {
|
} 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') {
|
if (security == 'tls') {
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,23 @@ func (a *XraySettingController) getXraySetting(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
return
|
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()
|
inboundTags, err := a.InboundService.GetInboundTags()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
jsonMsg(c, I18nWeb(c, "pages.settings.toasts.getSettings"), err)
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
<a-form-item label="Authentication">
|
<a-form-item label="Authentication">
|
||||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||||
|
<a-select-option :value="undefined">None</a-select-option>
|
||||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||||
Post-Quantum)</a-select-option>
|
Post-Quantum)</a-select-option>
|
||||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||||
|
|
|
||||||
|
|
@ -307,6 +307,12 @@
|
||||||
this.inbound.stream.tls.settings.echConfigList = "";
|
this.inbound.stream.tls.settings.echConfigList = "";
|
||||||
},
|
},
|
||||||
async getNewVlessEnc() {
|
async getNewVlessEnc() {
|
||||||
|
const selected = inModal.inbound.settings.selectedAuth;
|
||||||
|
if (!selected) {
|
||||||
|
this.clearVlessEnc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
inModal.loading(true);
|
inModal.loading(true);
|
||||||
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
const msg = await HttpUtil.get("/panel/api/server/getNewVlessEnc");
|
||||||
inModal.loading(false);
|
inModal.loading(false);
|
||||||
|
|
@ -316,7 +322,6 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
const auths = msg.obj.auths || [];
|
const auths = msg.obj.auths || [];
|
||||||
const selected = inModal.inbound.settings.selectedAuth;
|
|
||||||
const block = auths.find((a) => a.label === selected);
|
const block = auths.find((a) => a.label === selected);
|
||||||
|
|
||||||
if (!block) {
|
if (!block) {
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,7 @@
|
||||||
}
|
}
|
||||||
if (app.templateSettings.reverse.portals) this.outboundTags.push(...app.templateSettings.reverse.portals.map(b => b.tag));
|
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) {
|
if (app.templateSettings.routing && app.templateSettings.routing.balancers) {
|
||||||
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
|
this.balancerTags = ["", ...app.templateSettings.routing.balancers.filter((o) => !ObjectUtil.isEmpty(o.tag)).map(obj => obj.tag)];
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -129,35 +129,14 @@
|
||||||
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
datepickerList: [{ name: 'Gregorian (Standard)', value: 'gregorian' }, { name: 'Jalalian (شمسی)', value: 'jalalian' }],
|
||||||
remarkSample: '',
|
remarkSample: '',
|
||||||
defaultFragment: {
|
defaultFragment: {
|
||||||
tag: "fragment",
|
packets: "tlshello",
|
||||||
protocol: "freedom",
|
length: "100-200",
|
||||||
settings: {
|
interval: "10-20",
|
||||||
domainStrategy: "AsIs",
|
maxSplit: "300-400"
|
||||||
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" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
defaultNoises: [
|
||||||
|
{ type: "rand", packet: "10-20", delay: "10-16", applyTo: "ip" }
|
||||||
|
],
|
||||||
defaultMux: {
|
defaultMux: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
concurrency: 8,
|
concurrency: 8,
|
||||||
|
|
@ -451,41 +430,41 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentPackets: {
|
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) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.packets = v;
|
newFragment.packets = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentLength: {
|
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) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.length = v;
|
newFragment.length = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentInterval: {
|
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) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.interval = v;
|
newFragment.interval = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
fragmentMaxSplit: {
|
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) {
|
set: function (v) {
|
||||||
if (v != "") {
|
if (v != "") {
|
||||||
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
newFragment = JSON.parse(this.allSetting.subJsonFragment);
|
||||||
newFragment.settings.fragment.maxSplit = v;
|
newFragment.maxSplit = v;
|
||||||
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
this.allSetting.subJsonFragment = JSON.stringify(newFragment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -504,13 +483,11 @@
|
||||||
},
|
},
|
||||||
noisesArray: {
|
noisesArray: {
|
||||||
get() {
|
get() {
|
||||||
return this.noises ? JSON.parse(this.allSetting.subJsonNoises).settings.noises : [];
|
return this.noises ? JSON.parse(this.allSetting.subJsonNoises) : [];
|
||||||
},
|
},
|
||||||
set(value) {
|
set(value) {
|
||||||
if (this.noises) {
|
if (this.noises) {
|
||||||
const newNoises = JSON.parse(this.allSetting.subJsonNoises);
|
this.allSetting.subJsonNoises = JSON.stringify(value);
|
||||||
newNoises.settings.noises = value;
|
|
||||||
this.allSetting.subJsonNoises = JSON.stringify(newNoises);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -365,16 +365,16 @@
|
||||||
defaultObservatory: {
|
defaultObservatory: {
|
||||||
subjectSelector: [],
|
subjectSelector: [],
|
||||||
probeURL: "https://www.google.com/generate_204",
|
probeURL: "https://www.google.com/generate_204",
|
||||||
probeInterval: "10m",
|
probeInterval: "1m",
|
||||||
enableConcurrency: true
|
enableConcurrency: true
|
||||||
},
|
},
|
||||||
defaultBurstObservatory: {
|
defaultBurstObservatory: {
|
||||||
subjectSelector: [],
|
subjectSelector: [],
|
||||||
pingConfig: {
|
pingConfig: {
|
||||||
destination: "https://www.google.com/generate_204",
|
destination: "https://www.google.com/generate_204",
|
||||||
interval: "30m",
|
interval: "1m",
|
||||||
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
connectivity: "http://connectivitycheck.platform.hicloud.com/generate_204",
|
||||||
timeout: "10s",
|
timeout: "5s",
|
||||||
sampling: 2
|
sampling: 2
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -938,6 +938,15 @@
|
||||||
if (newTemplateSettings.routing.balancers.length === 0) {
|
if (newTemplateSettings.routing.balancers.length === 0) {
|
||||||
delete newTemplateSettings.routing.balancers;
|
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.templateSettings = newTemplateSettings;
|
||||||
this.updateObservatorySelectors();
|
this.updateObservatorySelectors();
|
||||||
this.obsSettings = '';
|
this.obsSettings = '';
|
||||||
|
|
|
||||||
|
|
@ -567,7 +567,7 @@ func (s *ServerService) GetXrayVersions() ([]string, error) {
|
||||||
continue
|
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)
|
versions = append(versions, release.TagName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -462,7 +462,12 @@ func (s *SettingService) GetTimeLocation() (*time.Location, error) {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
defaultLocation := defaultValueMap["timeLocation"]
|
defaultLocation := defaultValueMap["timeLocation"]
|
||||||
logger.Errorf("location <%v> not exist, using default location: %v", l, defaultLocation)
|
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
|
return location, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,12 @@ type XraySettingService struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
|
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 {
|
if err := s.CheckXrayConfig(newXraySettings); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
@ -29,3 +35,51 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
|
||||||
|
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
|
||||||
|
// "xraySetting": <real config> }` 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
|
||||||
|
}
|
||||||
|
|
|
||||||
90
web/service/xray_setting_test.go
Normal file
90
web/service/xray_setting_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue