Compare commits

...

16 Commits

Author SHA1 Message Date
MHSanaei 772d2b6de4 v2.9.2 2026-04-22 11:20:56 +02:00
MHSanaei 8f30d14716 Extract bot command setup into trySetBotCommands 2026-04-22 10:47:30 +02:00
pwnnex 9611c9def6 Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053) (#4073)
* Fix Hysteria External Proxy + include Hysteria in Clash subscription (#4053)

Two related gaps on the Hysteria side of the subscription layer:

1) `genHysteriaLink` ignored `externalProxy` entirely, so an admin who
   pointed a Hysteria inbound at an alternate endpoint (e.g. a CDN
   hostname forwarding UDP back to the node) still got a link with the
   original server address. Mirror what `genVlessLink` / `genTrojanLink`
   already do: fan out one link per entry, substituting `dest` / `port`
   and picking up the entry's remark suffix. As a bonus, the salamander
   obfs password is now copied into the URL too — the panel-side link
   generator already did this, so the subscription output was lagging
   behind it.

2) `buildProxy` in `subClashService.go` had a protocol switch with cases
   for VMESS / VLESS / Trojan / Shadowsocks and a `default: return nil`.
   Hysteria inbounds fell into the default branch and silently vanished
   from the Clash YAML. Route Hysteria to a dedicated
   `buildHysteriaProxy` helper before the transport/security helpers run
   (applyTransport / applySecurity model xray streams, which Hysteria
   doesn't use).

   `buildHysteriaProxy` reads `inbound.StreamSettings` directly instead
   of going through `streamData` / `tlsData`, because those prune
   fields (`allowInsecure`, the salamander `finalmask.udp` block) that
   the mihomo Hysteria proxy wants preserved. Output shape matches
   mihomo's expectations:

     type: hysteria2                  # or "hysteria" for v1
     password / auth-str: <client auth>
     sni, alpn, skip-cert-verify, client-fingerprint
     obfs: salamander
     obfs-password: <finalmask.udp[salamander].settings.password>

The existing `getProxies` fanout over `externalProxy` already plugs in
for Clash, so with Hysteria now recognised, External Proxy entries
also flow through to the Clash output for Hysteria inbounds.

Closes #4053

* gofmt: align map keys in buildHysteriaProxy

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-22 10:01:21 +02:00
Imgodmaoyouknow 292eb992f4 fix(panel): set ALPN to h3 when switching to Hysteria protocol (#4076)
- Automatically explicitly set ALPN to ['h3'] for Hysteria to prevent QUIC handshake mismatch.
2026-04-22 09:56:03 +02:00
MHSanaei 814e6ad69c Lower minimum Xray version
Update GetXrayVersions filter to accept Xray releases >= 26.3.10 instead of the previous >= 26.4.17. This changes the conditional in web/service/server.go so releases from 26.3.10 onward are included when building the versions list.
2026-04-21 21:20:59 +02:00
MHSanaei 0a38624ba7 Add None option VLESS auth selection 2026-04-21 21:18:59 +02:00
MHSanaei b86473df02 Run cache cleanup daily and reduce cutoff to 1 day 2026-04-21 20:36:28 +02:00
pwnnex 15be803da9 Fix blank Xray Settings page from wrapped xrayTemplateConfig (#4059) (#4069)
`getXraySetting` builds its response as

    { "xraySetting": <db value>, "inboundTags": ..., "outboundTestUrl": ... }

and embeds the raw DB value as the `xraySetting` field without
checking whether the stored value already has that exact shape.

The frontend pulls the textarea content from `result.xraySetting`
and saves it back verbatim. If the DB ever ends up holding the
response-shaped wrapper instead of a real xray config (older
installs where this happened at least once, users who imported a
copy-pasted response into the textarea, a botched migration, etc.),
the next save nests another layer, the one after that nests a
third, and the Vue-side JSON.parse of the resulting blob silently
fails — the Xray Settings page goes blank.

Fix both ends of the round-trip:

* Add `service.UnwrapXrayTemplateConfig`. It peels off any number of
  `xraySetting`-keyed layers, leaving a real xray config behind.
  The check is conservative: if the outer object already contains
  any top-level xray key (`inbounds`, `outbounds`, `routing`, `api`,
  `dns`, `log`, `policy`, `stats`), it is returned unchanged, and
  there is a depth cap to avoid pathological inputs.

* `SaveXraySetting` unwraps before validation so a round-tripped
  wrapper from an already-corrupted page can no longer re-poison
  the DB on save.

* `getXraySetting` unwraps on read and, when it finds a wrapper,
  rewrites the DB with the corrected value. Existing broken installs
  heal themselves on the next visit to the page.

Includes unit tests for the passthrough, single-wrap, multi-wrap,
string-encoded-inner, and false-positive cases.

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 20:30:02 +02:00
MHSanaei c79b45e512 Readme: Remove custom GeoSite/GeoIP DAT section
Remove the "Custom GeoSite / GeoIP DAT" section from the main README and all localized READMEs (ar_EG, es_ES, fa_IR, ru_RU, zh_CN). Also apply minor formatting cleanups: normalize language header spacing and remove trailing spaces from the Stargazers badge lines.
2026-04-21 20:20:43 +02:00
MHSanaei 86a8eb16b4 fix timelocation for windows
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:05:52 +02:00
MHSanaei 0fd0389d5c sub json fix fragment noises effect
Co-Authored-By: Alireza Ahmadi <alireza7@gmail.com>
2026-04-21 20:02:39 +02:00
pwnnex 2983ac3f8e Fix xhttp xPadding settings missing from generated links (panel + subs) (#4065)
* Fix: propagate xhttp xPadding settings into generated subscription links

The four `genXLink` helpers in `sub/subService.go` only copied `path`,
`host` and `mode` out of `xhttpSettings` when building vmess:// /
vless:// / trojan:// / ss:// URLs. Everything else — `xPaddingBytes`,
`xPaddingObfsMode`, `xPaddingKey`, `xPaddingHeader`,
`xPaddingPlacement`, `xPaddingMethod` — was silently dropped.

That meant an admin who set, say, `xPaddingBytes: "80-600"` plus obfs
mode with a custom `xPaddingKey` on the inbound had a server config
that no client could match from the copy-pasted link: the client kept
the xray/sing-box internal defaults (`100-1000`, `x_padding`,
`Referer`), hit the server, and was rejected by

    invalid padding (queryInHeader=Referer, key=x_padding) length: 0

The user-visible symptom on OpenWRT / Podkop / sing-box was
"xhttp inbound just won't connect" — no obvious pointer to what was
actually wrong because the link itself *looks* complete.

Fix:

  * New helper `applyXhttpPaddingParams(xhttp, params)` writes
    `x_padding_bytes=<range>` (flat, sing-box family reads this) and
    an `extra=<url-encoded-json>` blob carrying the full set of xhttp
    settings (xray-core family reads this). Both encodings are emitted
    side-by-side so every mainstream client can pick at least one up.
  * All four link generators (`genVmessLink` via the obj map,
    `genVlessLink`, `genTrojanLink`, `genShadowsocksLink`) now invoke
    the copy.
  * Obfs-only fields (`xPaddingKey`, `xPaddingHeader`,
    `xPaddingPlacement`, `xPaddingMethod`) are only included when
    `xPaddingObfsMode` is actually true and the admin filled them in.
    An inbound with no custom padding produces exactly the same URL
    as before — existing subscriptions are unaffected.

* Also propagate xhttp xPadding settings into the panel's own Info/QR links

The previous commit covered the subscription service
(sub/subService.go). The admin-panel side — the "Copy URL" / QR /
Info buttons inside inbound details — has four more
xhttp-emitting link generators in `web/assets/js/model/inbound.js`
(`genVmessLink`, `genVLESSLink`, `genTrojanLink`, `genSSLink`) that
had the exact same gap: only `path`, `host` and `mode` were copied.

Mirror the server-side fix on the client:

* Add two static helpers on `Inbound`:
  - `Inbound.applyXhttpPaddingToParams(xhttp, params)` for
    `vless://` / `trojan://` / `ss://` style URLs — writes
    `x_padding_bytes=<range>` (sing-box family) and
    `extra=<url-encoded-json>` (xray-core family).
  - `Inbound.applyXhttpPaddingToObj(xhttp, obj)` for the VMess base64
    JSON body — sets the same fields directly on the object.
* Call them from all four link generators so an admin who enables
  obfs mode + a custom `xPaddingKey` / `xPaddingHeader` actually
  gets a working URL from the panel.
* Only non-empty fields are emitted, so default inbounds produce
  exactly the same URL as before.

Also fixes a latent positional-args bug in
`web/assets/js/model/outbound.js`: both VMess-JSON (L933) and
`fromParamLink` (L975) were calling
`new xHTTPStreamSettings(path, host, mode)` — but the 3rd positional
arg of the constructor is `headers`, not `mode`, so `mode` was
landing in the `headers` slot and the actual `mode` field stayed at
its default. Construct explicitly and set `mode` by name; while
here, also pick up `x_padding_bytes` and the `extra` JSON blob from
the imported URL so the symmetric case of importing a padded link
works too.

---------

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:15:51 +02:00
pwnnex 975d6d1bad Fix: hysteria link gen crashes when echConfigList is a string (#4064)
`genHysteriaLink` was calling `.join(',')` on
`this.stream.tls.settings.echConfigList`, but that field is bound to an
`<a-input>` (single-line string) in `tls_settings.html` and defaults to
`''` in `TlsStreamSettings.Settings`. Calling `.join()` on a string
throws `TypeError: echConfigList.join is not a function`, which breaks
the Info / QR buttons for every hysteria / hysteria2 inbound.

All three sibling link generators (`genVmessLink`, `genVlessLink`,
`genTrojanLink`) already pass the value directly:

    params.set("ech", this.stream.tls.settings.echConfigList)

`URLSearchParams.set` will stringify arrays with `,` on its own, so the
same one-liner works for both string and array inputs. Align
`genHysteriaLink` with the other three.

Fixes #4063

Co-authored-by: pwnnex <eternxles@gmail.com>
2026-04-21 19:05:53 +02:00
MHSanaei ab7a7f7c6b Reduce observatory probe intervals and timeout 2026-04-21 18:47:38 +02:00
MHSanaei 733f44ef0f balancerTags with a default empty entry 2026-04-21 17:24:42 +02:00
MHSanaei faec3ca038 CodeQL: ignore v* tag pushes 2026-04-21 15:17:59 +02:00
25 changed files with 587 additions and 163 deletions
+6 -6
View File
@@ -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
+20 -18
View File
@@ -2,9 +2,11 @@ name: "CodeQL Advanced"
on:
push:
tags-ignore:
- "v*"
pull_request:
schedule:
- cron: '18 2 * * 2'
- cron: "18 2 * * 2"
jobs:
analyze:
@@ -22,24 +24,24 @@ jobs:
fail-fast: false
matrix:
include:
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
- language: actions
build-mode: none
- language: go
build-mode: autobuild
- language: javascript-typescript
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Checkout repository
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+2 -10
View File
@@ -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_&lt;alias&gt;.dat` و`geoip_&lt;alias&gt;.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)
+2 -10
View File
@@ -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_&lt;alias&gt;.dat` y `geoip_&lt;alias&gt;.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)
+2 -10
View File
@@ -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_&lt;alias&gt;.dat` و `geoip_&lt;alias&gt;.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)
+1 -9
View File
@@ -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_&lt;alias&gt;.dat` and `geoip_&lt;alias&gt;.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
+2 -10
View File
@@ -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_&lt;alias&gt;.dat` и `geoip_&lt;alias&gt;.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)
+2 -10
View File
@@ -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 / GeoIPDAT
管理员可在面板中从 URL 添加自定义 GeoSite 与 GeoIP `.dat` 文件(与内置地理文件相同的管理流程)。文件保存在 Xray 可执行文件所在目录(`XUI_BIN_FOLDER`,默认 `bin/`),文件名为 `geosite_&lt;alias&gt;.dat``geoip_&lt;alias&gt;.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)
+1 -1
View File
@@ -1 +1 @@
2.9.1
2.9.2
+86
View File
@@ -159,6 +159,16 @@ func (s *SubClashService) getProxies(inbound *model.Inbound, client model.Client
}
func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client, stream map[string]any, extraRemark string) map[string]any {
// Hysteria (v1 / v2) doesn't ride an xray `streamSettings.network`
// transport and the TLS story is handled inside hysteria itself, so
// applyTransport / applySecurity below don't model it. Build the
// proxy directly. Without this, hysteria inbounds fell into the
// `default: return nil` branch and silently vanished from the
// generated Clash config.
if inbound.Protocol == model.Hysteria {
return s.buildHysteriaProxy(inbound, client, extraRemark)
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"server": inbound.Listen,
@@ -222,6 +232,82 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
return proxy
}
// buildHysteriaProxy produces a mihomo-compatible Clash entry for a
// Hysteria (v1) or Hysteria2 inbound. It reads `inbound.StreamSettings`
// directly instead of going through streamData/tlsData, because those
// helpers prune fields (like `allowInsecure` / the salamander obfs
// block) that the hysteria proxy wants preserved.
func (s *SubClashService) buildHysteriaProxy(inbound *model.Inbound, client model.Client, extraRemark string) map[string]any {
var inboundSettings map[string]any
_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
proxyType := "hysteria2"
authKey := "password"
if v, ok := inboundSettings["version"].(float64); ok && int(v) == 1 {
proxyType = "hysteria"
authKey = "auth-str"
}
proxy := map[string]any{
"name": s.SubService.genRemark(inbound, client.Email, extraRemark),
"type": proxyType,
"server": inbound.Listen,
"port": inbound.Port,
"udp": true,
authKey: client.Auth,
}
var rawStream map[string]any
_ = json.Unmarshal([]byte(inbound.StreamSettings), &rawStream)
// TLS details — hysteria always uses TLS.
if tlsSettings, ok := rawStream["tlsSettings"].(map[string]any); ok {
if serverName, ok := tlsSettings["serverName"].(string); ok && serverName != "" {
proxy["sni"] = serverName
}
if alpnList, ok := tlsSettings["alpn"].([]any); ok && len(alpnList) > 0 {
out := make([]string, 0, len(alpnList))
for _, a := range alpnList {
if s, ok := a.(string); ok && s != "" {
out = append(out, s)
}
}
if len(out) > 0 {
proxy["alpn"] = out
}
}
if inner, ok := tlsSettings["settings"].(map[string]any); ok {
if insecure, ok := inner["allowInsecure"].(bool); ok && insecure {
proxy["skip-cert-verify"] = true
}
if fp, ok := inner["fingerprint"].(string); ok && fp != "" {
proxy["client-fingerprint"] = fp
}
}
}
// Salamander obfs (Hysteria2). Read the same finalmask.udp[salamander]
// block the subscription link generator uses.
if finalmask, ok := rawStream["finalmask"].(map[string]any); ok {
if udpMasks, ok := finalmask["udp"].([]any); ok {
for _, m := range udpMasks {
mask, _ := m.(map[string]any)
if mask == nil || mask["type"] != "salamander" {
continue
}
settings, _ := mask["settings"].(map[string]any)
if pw, ok := settings["password"].(string); ok && pw != "" {
proxy["obfs"] = "salamander"
proxy["obfs-password"] = pw
break
}
}
}
}
return proxy
}
func (s *SubClashService) applyTransport(proxy map[string]any, network string, stream map[string]any) bool {
switch network {
case "", "tcp":
+29 -14
View File
@@ -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
+120 -3
View File
@@ -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)
@@ -888,7 +906,6 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
}
func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) string {
address := s.address
if inbound.Protocol != model.Hysteria {
return ""
}
@@ -903,7 +920,6 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
}
auth := clients[clientIndex].Auth
port := inbound.Port
params := make(map[string]string)
params["security"] = "tls"
@@ -932,6 +948,26 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
}
}
// salamander obfs (Hysteria2). The panel-side link generator already
// emits these; keep the subscription output in sync so a client has
// the obfs password to match the server.
if finalmask, ok := stream["finalmask"].(map[string]interface{}); ok {
if udpMasks, ok := finalmask["udp"].([]interface{}); ok {
for _, m := range udpMasks {
mask, _ := m.(map[string]interface{})
if mask == nil || mask["type"] != "salamander" {
continue
}
settings, _ := mask["settings"].(map[string]interface{})
if pw, ok := settings["password"].(string); ok && pw != "" {
params["obfs"] = "salamander"
params["obfs-password"] = pw
break
}
}
}
}
var settings map[string]interface{}
json.Unmarshal([]byte(inbound.Settings), &settings)
version, _ := settings["version"].(float64)
@@ -940,7 +976,35 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
protocol = "hysteria"
}
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, address, port)
// Fan out one link per External Proxy entry if any. Previously this
// generator ignored `externalProxy` entirely, so the link kept the
// server's own IP/port even when the admin configured an alternate
// endpoint (e.g. a CDN hostname + port that forwards to the node).
// Matches the behaviour of genVlessLink / genTrojanLink / ….
externalProxies, _ := stream["externalProxy"].([]interface{})
if len(externalProxies) > 0 {
links := make([]string, 0, len(externalProxies))
for _, externalProxy := range externalProxies {
ep, _ := externalProxy.(map[string]interface{})
dest, _ := ep["dest"].(string)
epPort := int(ep["port"].(float64))
epRemark, _ := ep["remark"].(string)
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, epPort)
u, _ := url.Parse(link)
q := u.Query()
for k, v := range params {
q.Add(k, v)
}
u.RawQuery = q.Encode()
u.Fragment = s.genRemark(inbound, email, epRemark)
links = append(links, u.String())
}
return strings.Join(links, "\n")
}
// No external proxy configured — fall back to the request host.
link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.address, inbound.Port)
url, _ := url.Parse(link)
q := url.Query()
for k, v := range params {
@@ -1057,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 {
+62 -2
View File
@@ -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;
@@ -1342,6 +1396,8 @@ class Inbound extends XrayCommonClass {
if (protocol === Protocols.HYSTERIA) {
this.stream.network = 'hysteria';
this.stream.security = 'tls';
// Hysteria runs over QUIC and must not inherit TCP TLS ALPN defaults.
this.stream.tls.alpn = [ALPN_OPTION.H3];
}
}
@@ -1530,6 +1586,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 +1651,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 +1752,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 +1829,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 +1887,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;
@@ -2805,4 +2865,4 @@ Inbound.TunSettings = class extends Inbound.Settings {
autoOutboundsInterface: this.autoOutboundsInterface,
};
}
};
};
+26 -2
View File
@@ -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') {
+17
View File
@@ -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)
+1
View File
@@ -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
+6 -1
View File
@@ -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) {
+1
View File
@@ -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)];
}
+17 -40
View File
@@ -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);
}
}
},
+12 -3
View File
@@ -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 = '';
+1 -1
View File
@@ -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)
}
}
+6 -1
View File
@@ -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
}
+21 -12
View File
@@ -249,18 +249,7 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
return err
}
// After bot initialization, set up bot commands with localized descriptions
err = bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
Commands: []telego.BotCommand{
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
},
})
if err != nil {
logger.Warning("Failed to set bot commands:", err)
}
t.trySetBotCommands(bot)
// Start receiving Telegram bot messages
tgBotMutex.Lock()
@@ -274,6 +263,26 @@ func (t *Tgbot) Start(i18nFS embed.FS) error {
return nil
}
func (t *Tgbot) trySetBotCommands(bot *telego.Bot) {
defer func() {
if r := recover(); r != nil {
logger.Warning("Failed to register bot commands (Telegram may be rate-limiting); bot will continue without them:", r)
}
}()
err := bot.SetMyCommands(context.Background(), &telego.SetMyCommandsParams{
Commands: []telego.BotCommand{
{Command: "start", Description: t.I18nBot("tgbot.commands.startDesc")},
{Command: "help", Description: t.I18nBot("tgbot.commands.helpDesc")},
{Command: "status", Description: t.I18nBot("tgbot.commands.statusDesc")},
{Command: "id", Description: t.I18nBot("tgbot.commands.idDesc")},
},
})
if err != nil {
logger.Warning("Failed to set bot commands:", err)
}
}
// createRobustFastHTTPClient creates a fasthttp.Client with proper connection handling
func (t *Tgbot) createRobustFastHTTPClient(proxyUrl string) *fasthttp.Client {
client := &fasthttp.Client{
+54
View File
@@ -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
View 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)
}