mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +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
12
.github/workflows/cleanup_caches.yml
vendored
12
.github/workflows/cleanup_caches.yml
vendored
|
|
@ -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
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<picture>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<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.
|
||||
|
||||
## 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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<picture>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
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">
|
||||
<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.
|
||||
|
||||
## 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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<picture>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
<picture>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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=<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 {
|
||||
data, _ := headers.(map[string]any)
|
||||
for k, v := range data {
|
||||
|
|
|
|||
|
|
@ -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=<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() {
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
<a-form-item label="Authentication">
|
||||
<a-select v-model="inbound.settings.selectedAuth" @change="getNewVlessEnc"
|
||||
:dropdown-class-name="themeSwitcher.currentTheme">
|
||||
<a-select-option :value="undefined">None</a-select-option>
|
||||
<a-select-option value="X25519, not Post-Quantum">X25519 (not
|
||||
Post-Quantum)</a-select-option>
|
||||
<a-select-option value="ML-KEM-768, Post-Quantum">ML-KEM-768
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = '';
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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": <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