mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
Compare commits
25 Commits
v3.2.8
...
0daedd3db9
| Author | SHA1 | Date | |
|---|---|---|---|
| 0daedd3db9 | |||
| 21e01cc1e6 | |||
| 46684dd164 | |||
| 1ca5924a44 | |||
| af3c808444 | |||
| 98ba88037c | |||
| d739bcf71e | |||
| b0fe21c804 | |||
| f6558571b4 | |||
| 4e253588ae | |||
| c6f15cd53f | |||
| a014c01725 | |||
| e56f6c63f6 | |||
| 83799d71b0 | |||
| 483952cfa0 | |||
| 668c0922ca | |||
| 1b2a17f7e3 | |||
| e6c1ce9aa9 | |||
| 6ed6f57b5c | |||
| e409bc305d | |||
| 2b4e199a97 | |||
| 75bc6e8076 | |||
| eeb19b7240 | |||
| 5b9db13e55 | |||
| 0706b0b3a8 |
@@ -37,6 +37,23 @@ jobs:
|
|||||||
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
|
||||||
go test $(cat /tmp/go-packages.txt)
|
go test $(cat /tmp/go-packages.txt)
|
||||||
|
|
||||||
|
codegen:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v6
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
- name: Regenerate schemas, examples and OpenAPI
|
||||||
|
run: npm run gen
|
||||||
|
working-directory: frontend
|
||||||
|
- name: Fail if generated files are stale (run 'npm run gen' and commit)
|
||||||
|
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
|
||||||
|
|
||||||
govulncheck:
|
govulncheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
@@ -150,6 +150,16 @@ jobs:
|
|||||||
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
wget -q -O geoip_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||||
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
wget -q -O geosite_RU.dat https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
mv xray xray-linux-${{ matrix.platform }}
|
mv xray xray-linux-${{ matrix.platform }}
|
||||||
|
# mtg (MTProto sidecar) - only for arches mtg publishes
|
||||||
|
MTG_VER="2.2.8"
|
||||||
|
case "${{ matrix.platform }}" in
|
||||||
|
amd64|arm64|armv7|armv6|386)
|
||||||
|
wget -q "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
|
||||||
|
tar -xzf "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
|
||||||
|
mv "mtg-${MTG_VER}-linux-${{ matrix.platform }}/mtg" "mtg-linux-${{ matrix.platform }}" 2>/dev/null || mv mtg "mtg-linux-${{ matrix.platform }}"
|
||||||
|
rm -rf "mtg-${MTG_VER}-linux-${{ matrix.platform }}" "mtg-${MTG_VER}-linux-${{ matrix.platform }}.tar.gz"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
cd ../..
|
cd ../..
|
||||||
|
|
||||||
- name: Package
|
- name: Package
|
||||||
@@ -258,6 +268,15 @@ jobs:
|
|||||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat" -OutFile "geoip_RU.dat"
|
||||||
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
Invoke-WebRequest -Uri "https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat" -OutFile "geosite_RU.dat"
|
||||||
Rename-Item xray.exe xray-windows-amd64.exe
|
Rename-Item xray.exe xray-windows-amd64.exe
|
||||||
|
|
||||||
|
# Download mtg (MTProto sidecar) for Windows
|
||||||
|
$MTG_VER = "2.2.8"
|
||||||
|
Invoke-WebRequest -Uri "https://github.com/9seconds/mtg/releases/download/v$MTG_VER/mtg-$MTG_VER-windows-amd64.zip" -OutFile "mtg-windows-amd64.zip"
|
||||||
|
Expand-Archive -Path "mtg-windows-amd64.zip" -DestinationPath "mtg-tmp"
|
||||||
|
$mtgExe = Get-ChildItem -Path "mtg-tmp" -Recurse -Filter "mtg.exe" | Select-Object -First 1
|
||||||
|
Move-Item $mtgExe.FullName "mtg-windows-amd64.exe"
|
||||||
|
Remove-Item "mtg-windows-amd64.zip", "mtg-tmp" -Recurse -Force
|
||||||
|
|
||||||
cd ..
|
cd ..
|
||||||
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
|
Copy-Item -Path ..\windows_files\* -Destination . -Recurse
|
||||||
cd ..
|
cd ..
|
||||||
|
|||||||
+1
-1
@@ -162,7 +162,7 @@ Locale strings live in `web/translation/<locale>.json`, **not** under `frontend/
|
|||||||
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
|
| Iterate on UI changes with HMR | `cd frontend && npm run dev` (Vite on `:5173`, proxies `/panel/*` and the WebSocket to the Go panel on `:2053`). Start the Go panel first. |
|
||||||
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
| Verify what end users actually see | `cd frontend && npm run build`, then `go run .`. The Go binary serves the built bundle — embedded in release mode, off disk in debug mode. |
|
||||||
|
|
||||||
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/setting/*`, `/panel/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
|
The Vite dev proxy serves the admin SPA for any `/panel/*` URL — `bypassMigratedRoute` in `vite.config.js` rewrites those requests to `index.html` and lets React Router take over — while forwarding `/panel/api/*`, `/panel/api/setting/*`, `/panel/api/xray/*`, and the WebSocket to the Go panel. Because routing is now client-side, new panel routes need no proxy or allowlist changes.
|
||||||
|
|
||||||
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
> **`XUI_DEBUG=true` gotcha** — in debug mode the panel serves HTML from the embedded FS (frozen at the last `go build` / `go run`) but JS/CSS off disk. Re-running `npm run build` without restarting Go leaves the embedded HTML pointing at the *old* hashed asset names, producing a blank page with 404s in the console. Always restart `go run .` after a frontend rebuild.
|
||||||
|
|
||||||
|
|||||||
@@ -3,34 +3,46 @@ case $1 in
|
|||||||
amd64)
|
amd64)
|
||||||
ARCH="64"
|
ARCH="64"
|
||||||
FNAME="amd64"
|
FNAME="amd64"
|
||||||
|
MTG_ARCH="amd64"
|
||||||
;;
|
;;
|
||||||
i386)
|
i386)
|
||||||
ARCH="32"
|
ARCH="32"
|
||||||
FNAME="i386"
|
FNAME="i386"
|
||||||
|
MTG_ARCH="386"
|
||||||
;;
|
;;
|
||||||
armv8 | arm64 | aarch64)
|
armv8 | arm64 | aarch64)
|
||||||
ARCH="arm64-v8a"
|
ARCH="arm64-v8a"
|
||||||
FNAME="arm64"
|
FNAME="arm64"
|
||||||
|
MTG_ARCH="arm64"
|
||||||
;;
|
;;
|
||||||
armv7 | arm | arm32)
|
armv7 | arm | arm32)
|
||||||
ARCH="arm32-v7a"
|
ARCH="arm32-v7a"
|
||||||
FNAME="arm32"
|
FNAME="arm32"
|
||||||
|
MTG_ARCH="armv7"
|
||||||
;;
|
;;
|
||||||
armv6)
|
armv6)
|
||||||
ARCH="arm32-v6"
|
ARCH="arm32-v6"
|
||||||
FNAME="armv6"
|
FNAME="armv6"
|
||||||
|
MTG_ARCH="armv6"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
ARCH="64"
|
ARCH="64"
|
||||||
FNAME="amd64"
|
FNAME="amd64"
|
||||||
|
MTG_ARCH="amd64"
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
MTG_VER="2.2.8"
|
||||||
mkdir -p build/bin
|
mkdir -p build/bin
|
||||||
cd build/bin
|
cd build/bin
|
||||||
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
|
curl -sfLRO "https://github.com/XTLS/Xray-core/releases/download/v26.6.1/Xray-linux-${ARCH}.zip"
|
||||||
unzip "Xray-linux-${ARCH}.zip"
|
unzip "Xray-linux-${ARCH}.zip"
|
||||||
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
rm -f "Xray-linux-${ARCH}.zip" geoip.dat geosite.dat
|
||||||
mv xray "xray-linux-${FNAME}"
|
mv xray "xray-linux-${FNAME}"
|
||||||
|
curl -sfLRO "https://github.com/9seconds/mtg/releases/download/v${MTG_VER}/mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
|
||||||
|
tar -xzf "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
|
||||||
|
mv "mtg-${MTG_VER}-linux-${MTG_ARCH}/mtg" "mtg-linux-${FNAME}" 2>/dev/null || mv mtg "mtg-linux-${FNAME}"
|
||||||
|
rm -rf "mtg-${MTG_VER}-linux-${MTG_ARCH}" "mtg-${MTG_VER}-linux-${MTG_ARCH}.tar.gz"
|
||||||
|
chmod +x "mtg-linux-${FNAME}"
|
||||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat
|
||||||
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
curl -sfLRO https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat
|
||||||
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
curl -sfLRo geoip_IR.dat https://github.com/chocolate4u/Iran-v2ray-rules/releases/latest/download/geoip.dat
|
||||||
|
|||||||
+177
@@ -0,0 +1,177 @@
|
|||||||
|
[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) | [Türkçe](/README.tr_TR.md)
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
|
||||||
|
<img alt="3x-ui" src="./media/3x-ui-light.png">
|
||||||
|
</picture>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
|
||||||
|
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
|
||||||
|
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
|
||||||
|
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
|
||||||
|
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
|
||||||
|
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
|
||||||
|
|
||||||
|
Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
|
||||||
|
|
||||||
|
> [!IMPORTANT]
|
||||||
|
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
|
||||||
|
|
||||||
|
## Özellikler
|
||||||
|
|
||||||
|
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
|
||||||
|
- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
|
||||||
|
- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
|
||||||
|
- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
|
||||||
|
- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
|
||||||
|
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
|
||||||
|
- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
|
||||||
|
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
|
||||||
|
- **Telegram botu** (Uzaktan izleme ve yönetim için).
|
||||||
|
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
|
||||||
|
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
|
||||||
|
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
|
||||||
|
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
|
||||||
|
|
||||||
|
## Ekran Görüntüleri
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>Genişletmek için tıklayın</summary>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
|
||||||
|
<img alt="Genel Bakış" src="./media/01-overview-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
|
||||||
|
<img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
|
||||||
|
<img alt="Kullanıcı Ekle" src="./media/03-add-client-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
|
||||||
|
<img alt="Yapılandırmalar" src="./media/05-add-nodes-light.png">
|
||||||
|
</picture>
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## Hızlı Başlangıç
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
||||||
|
```
|
||||||
|
|
||||||
|
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
|
||||||
|
|
||||||
|
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
|
||||||
|
|
||||||
|
## Desteklenen Platformlar
|
||||||
|
|
||||||
|
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
|
||||||
|
|
||||||
|
**Mimariler:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
|
||||||
|
|
||||||
|
## Veritabanı Seçenekleri
|
||||||
|
|
||||||
|
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
|
||||||
|
|
||||||
|
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
|
||||||
|
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğümlü (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
|
||||||
|
|
||||||
|
Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
|
||||||
|
|
||||||
|
```
|
||||||
|
XUI_DB_TYPE=postgres
|
||||||
|
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
|
||||||
|
|
||||||
|
```bash
|
||||||
|
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
||||||
|
# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
|
||||||
|
systemctl restart x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile postgres up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||||
|
```
|
||||||
|
|
||||||
|
## Ortam Değişkenleri
|
||||||
|
|
||||||
|
| Değişken | Açıklama | Varsayılan |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
|
||||||
|
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
|
||||||
|
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
|
||||||
|
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
|
||||||
|
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
|
||||||
|
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
|
||||||
|
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
|
||||||
|
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
|
||||||
|
|
||||||
|
## Desteklenen Diller
|
||||||
|
|
||||||
|
Panel kullanıcı arayüzü 13 dilde mevcuttur:
|
||||||
|
|
||||||
|
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
||||||
|
|
||||||
|
## Katkıda Bulunma
|
||||||
|
|
||||||
|
Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
|
||||||
|
|
||||||
|
## Özel Teşekkürler
|
||||||
|
|
||||||
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
||||||
|
## Teşekkür
|
||||||
|
|
||||||
|
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
|
||||||
|
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
|
||||||
|
|
||||||
|
## Topluluk Araçları
|
||||||
|
|
||||||
|
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
|
||||||
|
|
||||||
|
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
|
||||||
|
|
||||||
|
## Projeyi Destekleyin
|
||||||
|
|
||||||
|
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
|
||||||
|
|
||||||
|
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
||||||
|
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
|
||||||
|
</a>
|
||||||
|
|
||||||
|
</br>
|
||||||
|
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
||||||
|
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
|
||||||
|
</a>
|
||||||
|
|
||||||
|
## Zaman İçindeki Yıldız Sayısı
|
||||||
|
|
||||||
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
@@ -73,6 +73,7 @@ func initModels() error {
|
|||||||
&model.ClientGroup{},
|
&model.ClientGroup{},
|
||||||
&model.InboundFallback{},
|
&model.InboundFallback{},
|
||||||
&model.NodeClientTraffic{},
|
&model.NodeClientTraffic{},
|
||||||
|
&model.OutboundSubscription{},
|
||||||
}
|
}
|
||||||
for _, mdl := range models {
|
for _, mdl := range models {
|
||||||
if err := db.AutoMigrate(mdl); err != nil {
|
if err := db.AutoMigrate(mdl); err != nil {
|
||||||
|
|||||||
+25
-1
@@ -22,7 +22,31 @@ func JSONFieldText(expr, key string) string {
|
|||||||
|
|
||||||
func GreatestExpr(a, b string) string {
|
func GreatestExpr(a, b string) string {
|
||||||
if IsPostgres() {
|
if IsPostgres() {
|
||||||
return fmt.Sprintf("GREATEST(%s, %s)", a, b)
|
return fmt.Sprintf("GREATEST(%s::bigint, %s::bigint)", a, b)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ClientTrafficEnableMergeExpr returns the SQL expression used in the
|
||||||
|
// node traffic merge to update client_traffics.enable.
|
||||||
|
//
|
||||||
|
// The intent is: only allow the remote node to *disable* a client
|
||||||
|
// (never re-enable one that the central panel has disabled).
|
||||||
|
//
|
||||||
|
// We use a dialect-specific expression because:
|
||||||
|
// - On PostgreSQL we want strict boolean typing and casts to avoid
|
||||||
|
// "CASE types boolean and integer cannot be matched" errors
|
||||||
|
// (and similar internal expansions of AND/GREATEST).
|
||||||
|
// - On SQLite, enable is stored with INTEGER affinity (0/1), there is
|
||||||
|
// no :: cast syntax, and we must produce a numeric-compatible result.
|
||||||
|
//
|
||||||
|
// The expression must be valid SQL for tx.Exec with a boolean parameter
|
||||||
|
// as the first ?.
|
||||||
|
func ClientTrafficEnableMergeExpr() string {
|
||||||
|
if IsPostgres() {
|
||||||
|
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
|
||||||
|
}
|
||||||
|
// SQLite: no :: casts. Use numeric CASE. 1/0 work as true/false
|
||||||
|
// thanks to SQLite's affinity and how GORM/drivers bind bools.
|
||||||
|
return "CASE WHEN ? THEN enable ELSE 0 END"
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,9 +20,20 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// migrationModels is the FK-aware order in which tables are created and copied.
|
// migrationModels is the FK-aware order in which tables are created and copied
|
||||||
// Parents come before their children so foreign-key constraints stay satisfied
|
// during `x-ui migrate-db --dsn` (SQLite → PostgreSQL data migration) and in
|
||||||
// even when checks are not explicitly disabled.
|
// related tests.
|
||||||
|
//
|
||||||
|
// Important: When adding a new top-level model (like OutboundSubscription),
|
||||||
|
// you must add it here **in addition to** the list in database/db.go:initModels().
|
||||||
|
// This list is used for:
|
||||||
|
// - Creating the destination schema during cross-DB migration
|
||||||
|
// - Truncating tables
|
||||||
|
// - Copying data row-by-row
|
||||||
|
// - Resyncing Postgres sequences after bulk insert
|
||||||
|
//
|
||||||
|
// DumpSQLite / RestoreSQLite are schema-introspective (they read sqlite_master)
|
||||||
|
// so they do not need manual updates.
|
||||||
func migrationModels() []any {
|
func migrationModels() []any {
|
||||||
return []any{
|
return []any{
|
||||||
&model.User{},
|
&model.User{},
|
||||||
@@ -39,6 +50,7 @@ func migrationModels() []any {
|
|||||||
&model.ClientInbound{},
|
&model.ClientInbound{},
|
||||||
&model.InboundFallback{},
|
&model.InboundFallback{},
|
||||||
&model.NodeClientTraffic{},
|
&model.NodeClientTraffic{},
|
||||||
|
&model.OutboundSubscription{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+158
-28
@@ -3,6 +3,8 @@ package model
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -29,6 +31,7 @@ const (
|
|||||||
Mixed Protocol = "mixed"
|
Mixed Protocol = "mixed"
|
||||||
WireGuard Protocol = "wireguard"
|
WireGuard Protocol = "wireguard"
|
||||||
Hysteria Protocol = "hysteria"
|
Hysteria Protocol = "hysteria"
|
||||||
|
MTProto Protocol = "mtproto"
|
||||||
)
|
)
|
||||||
|
|
||||||
// User represents a user account in the 3x-ui panel.
|
// User represents a user account in the 3x-ui panel.
|
||||||
@@ -41,13 +44,13 @@ type User struct {
|
|||||||
|
|
||||||
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
|
||||||
type Inbound struct {
|
type Inbound struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
|
||||||
UserId int `json:"-"` // Associated user ID
|
UserId int `json:"-"` // Associated user ID
|
||||||
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
|
||||||
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
Down int64 `json:"down" form:"down"` // Download traffic in bytes
|
||||||
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
|
||||||
Remark string `json:"remark" form:"remark"` // Human-readable remark
|
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
|
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
|
||||||
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
|
||||||
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
|
||||||
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
|
||||||
@@ -55,14 +58,22 @@ type Inbound struct {
|
|||||||
|
|
||||||
// Xray configuration fields
|
// Xray configuration fields
|
||||||
Listen string `json:"listen" form:"listen"`
|
Listen string `json:"listen" form:"listen"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
|
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
|
||||||
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
|
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun mtproto" example:"vless"`
|
||||||
Settings string `json:"settings" form:"settings"`
|
Settings string `json:"settings" form:"settings"`
|
||||||
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
StreamSettings string `json:"streamSettings" form:"streamSettings"`
|
||||||
Tag string `json:"tag" form:"tag" gorm:"unique"`
|
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
|
||||||
Sniffing string `json:"sniffing" form:"sniffing"`
|
Sniffing string `json:"sniffing" form:"sniffing"`
|
||||||
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
|
||||||
|
|
||||||
|
// OriginNodeGuid is the panelGuid of the node that physically hosts this
|
||||||
|
// inbound, propagated up across hops (#4983). Empty for an inbound that
|
||||||
|
// lives on this panel's own xray; set to the originating node's GUID when
|
||||||
|
// the inbound was synced from a node (kept as-is across further hops). Lets
|
||||||
|
// the master attribute a deeply nested inbound to the real node instead of
|
||||||
|
// the intermediate one it was fetched through.
|
||||||
|
OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
|
||||||
|
|
||||||
// FallbackParent is populated by the API layer when this inbound is
|
// FallbackParent is populated by the API layer when this inbound is
|
||||||
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
|
||||||
// The frontend uses it to rewrite client-share links so they advertise
|
// The frontend uses it to rewrite client-share links so they advertise
|
||||||
@@ -358,6 +369,70 @@ func HealShadowsocksClientMethods(settings string) (string, bool) {
|
|||||||
return string(out), true
|
return string(out), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GenerateFakeTLSSecret builds an MTProto FakeTLS secret for the given domain:
|
||||||
|
// the "ee" FakeTLS marker, 16 random bytes, then the domain encoded as hex.
|
||||||
|
// This single value is what mtg's config and the client tg:// link both use.
|
||||||
|
func GenerateFakeTLSSecret(domain string) string {
|
||||||
|
return "ee" + mtprotoRandomMiddle() + hex.EncodeToString([]byte(domain))
|
||||||
|
}
|
||||||
|
|
||||||
|
func mtprotoRandomMiddle() string {
|
||||||
|
buf := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
panic(fmt.Errorf("mtproto: crypto/rand read failed: %w", err))
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// mtprotoSecretMiddle returns the 16-byte random middle of an existing secret
|
||||||
|
// when it is well-formed, otherwise a freshly generated one. Reusing the middle
|
||||||
|
// keeps the secret stable when only the FakeTLS domain changes.
|
||||||
|
func mtprotoSecretMiddle(secret string) string {
|
||||||
|
s := secret
|
||||||
|
if strings.HasPrefix(s, "ee") || strings.HasPrefix(s, "dd") {
|
||||||
|
s = s[2:]
|
||||||
|
}
|
||||||
|
if len(s) >= 32 {
|
||||||
|
mid := s[:32]
|
||||||
|
if _, err := hex.DecodeString(mid); err == nil {
|
||||||
|
return mid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mtprotoRandomMiddle()
|
||||||
|
}
|
||||||
|
|
||||||
|
// HealMtprotoSecret normalises an mtproto inbound's settings JSON before the
|
||||||
|
// value leaves for the mtg sidecar or a share link: it rebuilds `secret` so it
|
||||||
|
// is always a valid FakeTLS secret whose trailing domain matches
|
||||||
|
// `fakeTlsDomain`, generating the random middle when one is missing and
|
||||||
|
// rewriting the domain suffix when the domain changed. Returns the rewritten
|
||||||
|
// settings and true when anything changed.
|
||||||
|
func HealMtprotoSecret(settings string) (string, bool) {
|
||||||
|
if settings == "" {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
domain, _ := parsed["fakeTlsDomain"].(string)
|
||||||
|
domain = strings.TrimSpace(domain)
|
||||||
|
if domain == "" {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
secret, _ := parsed["secret"].(string)
|
||||||
|
expected := "ee" + mtprotoSecretMiddle(secret) + hex.EncodeToString([]byte(domain))
|
||||||
|
if secret == expected {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
parsed["secret"] = expected
|
||||||
|
out, err := json.MarshalIndent(parsed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return settings, false
|
||||||
|
}
|
||||||
|
return string(out), true
|
||||||
|
}
|
||||||
|
|
||||||
// Setting stores key-value configuration settings for the 3x-ui panel.
|
// Setting stores key-value configuration settings for the 3x-ui panel.
|
||||||
type Setting struct {
|
type Setting struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
@@ -370,42 +445,75 @@ type Setting struct {
|
|||||||
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
// endpoint over HTTP using the per-node ApiToken to populate the runtime
|
||||||
// status fields below.
|
// status fields below.
|
||||||
type Node struct {
|
type Node struct {
|
||||||
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
|
||||||
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
|
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
|
||||||
Remark string `json:"remark" form:"remark"`
|
Remark string `json:"remark" form:"remark"`
|
||||||
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
|
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
|
||||||
Address string `json:"address" form:"address" validate:"required"`
|
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
|
||||||
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
|
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
|
||||||
BasePath string `json:"basePath" form:"basePath"`
|
BasePath string `json:"basePath" form:"basePath" example:"/"`
|
||||||
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
|
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
|
||||||
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
|
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
|
||||||
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
|
||||||
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
|
||||||
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
|
||||||
|
|
||||||
|
// Guid is the remote panel's stable self-identifier (its panelGuid),
|
||||||
|
// learned from each heartbeat. It is the globally stable node identity used
|
||||||
|
// to attribute online clients/inbounds to the physical node across a chain
|
||||||
|
// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
|
||||||
|
// Observed-state only — never user-edited.
|
||||||
|
Guid string `json:"guid" gorm:"column:guid;index"`
|
||||||
|
|
||||||
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
|
||||||
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
// the row is otherwise unchanged so the UI's "last seen" tooltip is
|
||||||
// truthful without us having to read LastHeartbeat separately.
|
// truthful without us having to read LastHeartbeat separately.
|
||||||
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
|
Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
|
||||||
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
|
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
|
||||||
LatencyMs int `json:"latencyMs"`
|
LatencyMs int `json:"latencyMs" example:"42"`
|
||||||
XrayVersion string `json:"xrayVersion"`
|
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
|
||||||
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
|
PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
|
||||||
CpuPct float64 `json:"cpuPct"`
|
CpuPct float64 `json:"cpuPct" example:"23.5"`
|
||||||
MemPct float64 `json:"memPct"`
|
MemPct float64 `json:"memPct" example:"45.1"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
|
|
||||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
|
||||||
ClientCount int `json:"clientCount" gorm:"-"`
|
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
|
||||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
|
||||||
DepletedCount int `json:"depletedCount" gorm:"-"`
|
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
|
||||||
|
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
// ParentGuid + Transitive are set only when a node is surfaced as part of a
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
// node tree (#4983): direct nodes carry the master panel's own GUID, a
|
||||||
|
// transitive sub-node carries its parent node's GUID. Transitive nodes are
|
||||||
|
// read-only projections (Id == 0, not persisted) — never edited or deployed.
|
||||||
|
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
|
||||||
|
Transitive bool `json:"transitive,omitempty" gorm:"-"`
|
||||||
|
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NodeSummary is the read-only identity of a node as published one hop up: the
|
||||||
|
// view a panel exposes about the nodes it directly manages, so a master can
|
||||||
|
// surface transitive sub-nodes in a chained topology (#4983). Counts are
|
||||||
|
// computed by the consuming master from its own per-GUID data, never trusted
|
||||||
|
// from the child, so this carries identity/health only.
|
||||||
|
type NodeSummary struct {
|
||||||
|
Guid string `json:"guid"`
|
||||||
|
ParentGuid string `json:"parentGuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Address string `json:"address"`
|
||||||
|
Scheme string `json:"scheme"`
|
||||||
|
Port int `json:"port"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
LastHeartbeat int64 `json:"lastHeartbeat"`
|
||||||
|
LatencyMs int `json:"latencyMs"`
|
||||||
|
PanelVersion string `json:"panelVersion"`
|
||||||
|
XrayVersion string `json:"xrayVersion"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomGeoResource struct {
|
type CustomGeoResource struct {
|
||||||
@@ -597,6 +705,28 @@ type ClientMergeConflict struct {
|
|||||||
Kept any
|
Kept any
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OutboundSubscription struct {
|
||||||
|
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
|
||||||
|
Remark string `json:"remark" form:"remark"`
|
||||||
|
Url string `json:"url" form:"url"`
|
||||||
|
Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"`
|
||||||
|
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
|
||||||
|
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
|
||||||
|
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
|
||||||
|
Priority int `json:"priority" form:"priority" gorm:"default:0"` // order among subscriptions in the merged outbounds (lower = earlier)
|
||||||
|
Prepend bool `json:"prepend" form:"prepend" gorm:"default:false"` // place this subscription's outbounds before the manual template outbounds
|
||||||
|
LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
|
||||||
|
LastError string `json:"lastError" form:"lastError"`
|
||||||
|
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
|
||||||
|
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
|
||||||
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
|
// OutboundCount is a derived count of the last fetched outbounds (not
|
||||||
|
// persisted); List populates it so the UI can show how many outbounds a
|
||||||
|
// subscription produced without shipping the full payload.
|
||||||
|
OutboundCount int `json:"outboundCount" gorm:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
func MergeClientRecord(existing *ClientRecord, incoming *ClientRecord) []ClientMergeConflict {
|
||||||
var conflicts []ClientMergeConflict
|
var conflicts []ClientMergeConflict
|
||||||
keep := func(field string, oldV, newV, kept any) {
|
keep := func(field string, oldV, newV, kept any) {
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateFakeTLSSecret(t *testing.T) {
|
||||||
|
domain := "www.cloudflare.com"
|
||||||
|
s := GenerateFakeTLSSecret(domain)
|
||||||
|
if !strings.HasPrefix(s, "ee") {
|
||||||
|
t.Fatalf("secret must start with ee, got %q", s)
|
||||||
|
}
|
||||||
|
wantSuffix := hex.EncodeToString([]byte(domain))
|
||||||
|
if !strings.HasSuffix(s, wantSuffix) {
|
||||||
|
t.Fatalf("secret must end with hex(domain) %q, got %q", wantSuffix, s)
|
||||||
|
}
|
||||||
|
if len(s) != 2+32+len(wantSuffix) {
|
||||||
|
t.Fatalf("unexpected secret length %d", len(s))
|
||||||
|
}
|
||||||
|
if _, err := hex.DecodeString(s[2:34]); err != nil {
|
||||||
|
t.Fatalf("middle is not valid hex: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHealMtprotoSecret(t *testing.T) {
|
||||||
|
domain := "example.com"
|
||||||
|
suffix := hex.EncodeToString([]byte(domain))
|
||||||
|
|
||||||
|
in := `{"fakeTlsDomain":"example.com","secret":""}`
|
||||||
|
out, changed := HealMtprotoSecret(in)
|
||||||
|
if !changed {
|
||||||
|
t.Fatal("expected heal to populate an empty secret")
|
||||||
|
}
|
||||||
|
var parsed map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(out), &parsed); err != nil {
|
||||||
|
t.Fatalf("healed settings not valid json: %v", err)
|
||||||
|
}
|
||||||
|
got, _ := parsed["secret"].(string)
|
||||||
|
if !strings.HasPrefix(got, "ee") || !strings.HasSuffix(got, suffix) {
|
||||||
|
t.Fatalf("healed secret malformed: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, changed2 := HealMtprotoSecret(out); changed2 {
|
||||||
|
t.Fatal("expected no change for an already-valid secret")
|
||||||
|
}
|
||||||
|
|
||||||
|
mid := got[2:34]
|
||||||
|
newDomain := "telegram.org"
|
||||||
|
in3 := `{"fakeTlsDomain":"telegram.org","secret":"` + got + `"}`
|
||||||
|
out3, changed3 := HealMtprotoSecret(in3)
|
||||||
|
if !changed3 {
|
||||||
|
t.Fatal("expected heal to rewrite the domain suffix")
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(out3), &parsed); err != nil {
|
||||||
|
t.Fatalf("healed settings not valid json: %v", err)
|
||||||
|
}
|
||||||
|
got3, _ := parsed["secret"].(string)
|
||||||
|
if got3[2:34] != mid {
|
||||||
|
t.Fatalf("random middle should be preserved on domain change: %q vs %q", got3[2:34], mid)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got3, hex.EncodeToString([]byte(newDomain))) {
|
||||||
|
t.Fatalf("suffix not updated for new domain: %q", got3)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, changed4 := HealMtprotoSecret(`{"secret":"ee"}`); changed4 {
|
||||||
|
t.Fatal("expected no change when fakeTlsDomain is missing")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
|
"gen": "npm run gen:zod && npm run gen:api",
|
||||||
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
|
||||||
"gen:zod": "cd .. && go run ./tools/openapigen"
|
"gen:zod": "cd .. && go run ./tools/openapigen"
|
||||||
},
|
},
|
||||||
|
|||||||
+2276
-119
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,8 @@ import { join, dirname } from 'node:path';
|
|||||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||||
|
|
||||||
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
import { sections } from '../src/pages/api-docs/endpoints.ts';
|
||||||
|
import { EXAMPLES } from '../src/generated/examples.ts';
|
||||||
|
import { SCHEMAS } from '../src/generated/schemas.ts';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
const outPath = join(__dirname, '..', 'public', 'openapi.json');
|
||||||
@@ -128,7 +130,22 @@ function buildOperation(ep, tag) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const responses = {};
|
const responses = {};
|
||||||
const successExample = tryParseJson(ep.response);
|
let successExample = tryParseJson(ep.response);
|
||||||
|
let objSchema = {};
|
||||||
|
if (ep.responseSchema) {
|
||||||
|
const obj = EXAMPLES[ep.responseSchema];
|
||||||
|
if (obj === undefined) {
|
||||||
|
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
|
||||||
|
}
|
||||||
|
if (SCHEMAS[ep.responseSchema] === undefined) {
|
||||||
|
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
|
||||||
|
}
|
||||||
|
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
|
||||||
|
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
|
||||||
|
if (successExample === undefined) {
|
||||||
|
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
|
||||||
|
}
|
||||||
|
}
|
||||||
responses['200'] = {
|
responses['200'] = {
|
||||||
description: 'Successful response',
|
description: 'Successful response',
|
||||||
content: {
|
content: {
|
||||||
@@ -138,7 +155,7 @@ function buildOperation(ep, tag) {
|
|||||||
properties: {
|
properties: {
|
||||||
success: { type: 'boolean' },
|
success: { type: 'boolean' },
|
||||||
msg: { type: 'string' },
|
msg: { type: 'string' },
|
||||||
obj: {},
|
obj: objSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
...(successExample !== undefined ? { example: successExample } : {}),
|
...(successExample !== undefined ? { example: successExample } : {}),
|
||||||
@@ -192,13 +209,14 @@ function buildSpec() {
|
|||||||
title: '3X-UI Panel API',
|
title: '3X-UI Panel API',
|
||||||
version: PANEL_VERSION,
|
version: PANEL_VERSION,
|
||||||
description:
|
description:
|
||||||
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
|
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
|
||||||
},
|
},
|
||||||
servers: [
|
servers: [
|
||||||
{ url: '/', description: 'Current panel (basePath aware)' },
|
{ url: '/', description: 'Current panel (basePath aware)' },
|
||||||
],
|
],
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: SECURITY_SCHEMES,
|
securitySchemes: SECURITY_SCHEMES,
|
||||||
|
schemas: SCHEMAS,
|
||||||
},
|
},
|
||||||
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
|
||||||
tags,
|
tags,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
|
|||||||
import { keys } from '@/api/queryKeys';
|
import { keys } from '@/api/queryKeys';
|
||||||
|
|
||||||
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
async function fetchAllSetting(): Promise<AllSettingInput | null> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
|
||||||
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
|
||||||
return validated.obj;
|
return validated.obj;
|
||||||
@@ -47,7 +47,7 @@ export function useAllSettings() {
|
|||||||
if (!body.success) {
|
if (!body.success) {
|
||||||
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
console.warn('[zod] setting/update body failed validation', body.error.issues);
|
||||||
}
|
}
|
||||||
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
|
return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export const keys = {
|
|||||||
list: (params: unknown) => ['clients', 'list', params] as const,
|
list: (params: unknown) => ['clients', 'list', params] as const,
|
||||||
all: () => ['clients', 'all'] as const,
|
all: () => ['clients', 'all'] as const,
|
||||||
onlines: () => ['clients', 'onlines'] as const,
|
onlines: () => ['clients', 'onlines'] as const,
|
||||||
onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
|
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
|
||||||
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
activeInbounds: () => ['clients', 'activeInbounds'] as const,
|
||||||
lastOnline: () => ['clients', 'lastOnline'] as const,
|
lastOnline: () => ['clients', 'lastOnline'] as const,
|
||||||
groups: () => ['clients', 'groups'] as const,
|
groups: () => ['clients', 'groups'] as const,
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
|
export const EXAMPLES: Record<string, unknown> = {
|
||||||
|
"AllSetting": {
|
||||||
|
"datepicker": "",
|
||||||
|
"expireDiff": 0,
|
||||||
|
"externalTrafficInformEnable": false,
|
||||||
|
"externalTrafficInformURI": "",
|
||||||
|
"ldapAutoCreate": false,
|
||||||
|
"ldapAutoDelete": false,
|
||||||
|
"ldapBaseDN": "",
|
||||||
|
"ldapBindDN": "",
|
||||||
|
"ldapDefaultExpiryDays": 0,
|
||||||
|
"ldapDefaultLimitIP": 0,
|
||||||
|
"ldapDefaultTotalGB": 0,
|
||||||
|
"ldapEnable": false,
|
||||||
|
"ldapFlagField": "",
|
||||||
|
"ldapHost": "",
|
||||||
|
"ldapInboundTags": "",
|
||||||
|
"ldapInvertFlag": false,
|
||||||
|
"ldapPassword": "",
|
||||||
|
"ldapPort": 0,
|
||||||
|
"ldapSyncCron": "",
|
||||||
|
"ldapTruthyValues": "",
|
||||||
|
"ldapUseTLS": false,
|
||||||
|
"ldapUserAttr": "",
|
||||||
|
"ldapUserFilter": "",
|
||||||
|
"ldapVlessField": "",
|
||||||
|
"pageSize": 0,
|
||||||
|
"panelProxy": "",
|
||||||
|
"remarkModel": "",
|
||||||
|
"restartXrayOnClientDisable": false,
|
||||||
|
"sessionMaxAge": 1,
|
||||||
|
"subAnnounce": "",
|
||||||
|
"subCertFile": "",
|
||||||
|
"subClashEnable": false,
|
||||||
|
"subClashEnableRouting": false,
|
||||||
|
"subClashPath": "",
|
||||||
|
"subClashRules": "",
|
||||||
|
"subClashURI": "",
|
||||||
|
"subDomain": "",
|
||||||
|
"subEmailInRemark": false,
|
||||||
|
"subEnable": false,
|
||||||
|
"subEnableRouting": false,
|
||||||
|
"subEncrypt": false,
|
||||||
|
"subJsonEnable": false,
|
||||||
|
"subJsonFinalMask": "",
|
||||||
|
"subJsonMux": "",
|
||||||
|
"subJsonPath": "",
|
||||||
|
"subJsonRules": "",
|
||||||
|
"subJsonURI": "",
|
||||||
|
"subKeyFile": "",
|
||||||
|
"subListen": "",
|
||||||
|
"subPath": "",
|
||||||
|
"subPort": 1,
|
||||||
|
"subProfileUrl": "",
|
||||||
|
"subRoutingRules": "",
|
||||||
|
"subShowInfo": false,
|
||||||
|
"subSupportUrl": "",
|
||||||
|
"subTitle": "",
|
||||||
|
"subURI": "",
|
||||||
|
"subUpdates": 0,
|
||||||
|
"tgBotAPIServer": "",
|
||||||
|
"tgBotBackup": false,
|
||||||
|
"tgBotChatId": "",
|
||||||
|
"tgBotEnable": false,
|
||||||
|
"tgBotLoginNotify": false,
|
||||||
|
"tgBotProxy": "",
|
||||||
|
"tgBotToken": "",
|
||||||
|
"tgCpu": 0,
|
||||||
|
"tgLang": "",
|
||||||
|
"tgRunTime": "",
|
||||||
|
"timeLocation": "",
|
||||||
|
"trafficDiff": 0,
|
||||||
|
"trustedProxyCIDRs": "",
|
||||||
|
"twoFactorEnable": false,
|
||||||
|
"twoFactorToken": "",
|
||||||
|
"webBasePath": "",
|
||||||
|
"webCertFile": "",
|
||||||
|
"webDomain": "",
|
||||||
|
"webKeyFile": "",
|
||||||
|
"webListen": "",
|
||||||
|
"webPort": 1
|
||||||
|
},
|
||||||
|
"AllSettingView": {
|
||||||
|
"datepicker": "",
|
||||||
|
"expireDiff": 0,
|
||||||
|
"externalTrafficInformEnable": false,
|
||||||
|
"externalTrafficInformURI": "",
|
||||||
|
"hasApiToken": false,
|
||||||
|
"hasLdapPassword": false,
|
||||||
|
"hasNordSecret": false,
|
||||||
|
"hasTgBotToken": false,
|
||||||
|
"hasTwoFactorToken": false,
|
||||||
|
"hasWarpSecret": false,
|
||||||
|
"ldapAutoCreate": false,
|
||||||
|
"ldapAutoDelete": false,
|
||||||
|
"ldapBaseDN": "",
|
||||||
|
"ldapBindDN": "",
|
||||||
|
"ldapDefaultExpiryDays": 0,
|
||||||
|
"ldapDefaultLimitIP": 0,
|
||||||
|
"ldapDefaultTotalGB": 0,
|
||||||
|
"ldapEnable": false,
|
||||||
|
"ldapFlagField": "",
|
||||||
|
"ldapHost": "",
|
||||||
|
"ldapInboundTags": "",
|
||||||
|
"ldapInvertFlag": false,
|
||||||
|
"ldapPassword": "",
|
||||||
|
"ldapPort": 0,
|
||||||
|
"ldapSyncCron": "",
|
||||||
|
"ldapTruthyValues": "",
|
||||||
|
"ldapUseTLS": false,
|
||||||
|
"ldapUserAttr": "",
|
||||||
|
"ldapUserFilter": "",
|
||||||
|
"ldapVlessField": "",
|
||||||
|
"pageSize": 0,
|
||||||
|
"panelProxy": "",
|
||||||
|
"remarkModel": "",
|
||||||
|
"restartXrayOnClientDisable": false,
|
||||||
|
"sessionMaxAge": 1,
|
||||||
|
"subAnnounce": "",
|
||||||
|
"subCertFile": "",
|
||||||
|
"subClashEnable": false,
|
||||||
|
"subClashEnableRouting": false,
|
||||||
|
"subClashPath": "",
|
||||||
|
"subClashRules": "",
|
||||||
|
"subClashURI": "",
|
||||||
|
"subDomain": "",
|
||||||
|
"subEmailInRemark": false,
|
||||||
|
"subEnable": false,
|
||||||
|
"subEnableRouting": false,
|
||||||
|
"subEncrypt": false,
|
||||||
|
"subJsonEnable": false,
|
||||||
|
"subJsonFinalMask": "",
|
||||||
|
"subJsonMux": "",
|
||||||
|
"subJsonPath": "",
|
||||||
|
"subJsonRules": "",
|
||||||
|
"subJsonURI": "",
|
||||||
|
"subKeyFile": "",
|
||||||
|
"subListen": "",
|
||||||
|
"subPath": "",
|
||||||
|
"subPort": 1,
|
||||||
|
"subProfileUrl": "",
|
||||||
|
"subRoutingRules": "",
|
||||||
|
"subShowInfo": false,
|
||||||
|
"subSupportUrl": "",
|
||||||
|
"subTitle": "",
|
||||||
|
"subURI": "",
|
||||||
|
"subUpdates": 0,
|
||||||
|
"tgBotAPIServer": "",
|
||||||
|
"tgBotBackup": false,
|
||||||
|
"tgBotChatId": "",
|
||||||
|
"tgBotEnable": false,
|
||||||
|
"tgBotLoginNotify": false,
|
||||||
|
"tgBotProxy": "",
|
||||||
|
"tgBotToken": "",
|
||||||
|
"tgCpu": 0,
|
||||||
|
"tgLang": "",
|
||||||
|
"tgRunTime": "",
|
||||||
|
"timeLocation": "",
|
||||||
|
"trafficDiff": 0,
|
||||||
|
"trustedProxyCIDRs": "",
|
||||||
|
"twoFactorEnable": false,
|
||||||
|
"twoFactorToken": "",
|
||||||
|
"webBasePath": "",
|
||||||
|
"webCertFile": "",
|
||||||
|
"webDomain": "",
|
||||||
|
"webKeyFile": "",
|
||||||
|
"webListen": "",
|
||||||
|
"webPort": 1
|
||||||
|
},
|
||||||
|
"ApiToken": {
|
||||||
|
"createdAt": 0,
|
||||||
|
"enabled": false,
|
||||||
|
"id": 0,
|
||||||
|
"name": "",
|
||||||
|
"token": ""
|
||||||
|
},
|
||||||
|
"ApiTokenView": {
|
||||||
|
"createdAt": 1736000000,
|
||||||
|
"enabled": true,
|
||||||
|
"id": 2,
|
||||||
|
"name": "central-panel-a",
|
||||||
|
"token": "new-token-string"
|
||||||
|
},
|
||||||
|
"Client": {
|
||||||
|
"auth": "",
|
||||||
|
"comment": "",
|
||||||
|
"created_at": 0,
|
||||||
|
"email": "",
|
||||||
|
"enable": false,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"flow": "",
|
||||||
|
"group": "",
|
||||||
|
"id": "",
|
||||||
|
"limitIp": 0,
|
||||||
|
"password": "",
|
||||||
|
"reset": 0,
|
||||||
|
"reverse": null,
|
||||||
|
"security": "",
|
||||||
|
"subId": "",
|
||||||
|
"tgId": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
"updated_at": 0
|
||||||
|
},
|
||||||
|
"ClientInbound": {
|
||||||
|
"clientId": 0,
|
||||||
|
"createdAt": 0,
|
||||||
|
"flowOverride": "",
|
||||||
|
"inboundId": 0
|
||||||
|
},
|
||||||
|
"ClientRecord": {
|
||||||
|
"auth": "",
|
||||||
|
"comment": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"email": "",
|
||||||
|
"enable": false,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"flow": "",
|
||||||
|
"group": "",
|
||||||
|
"id": 0,
|
||||||
|
"limitIp": 0,
|
||||||
|
"password": "",
|
||||||
|
"reset": 0,
|
||||||
|
"reverse": null,
|
||||||
|
"security": "",
|
||||||
|
"subId": "",
|
||||||
|
"tgId": 0,
|
||||||
|
"totalGB": 0,
|
||||||
|
"updatedAt": 0,
|
||||||
|
"uuid": ""
|
||||||
|
},
|
||||||
|
"ClientReverse": {
|
||||||
|
"tag": ""
|
||||||
|
},
|
||||||
|
"ClientTraffic": {
|
||||||
|
"down": 2097152,
|
||||||
|
"email": "user1",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 1735689600000,
|
||||||
|
"id": 14825,
|
||||||
|
"inboundId": 1,
|
||||||
|
"lastOnline": 1735680000000,
|
||||||
|
"reset": 0,
|
||||||
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
|
"total": 10737418240,
|
||||||
|
"up": 1048576,
|
||||||
|
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||||
|
},
|
||||||
|
"CustomGeoResource": {
|
||||||
|
"alias": "",
|
||||||
|
"createdAt": 0,
|
||||||
|
"id": 0,
|
||||||
|
"lastModified": "",
|
||||||
|
"lastUpdatedAt": 0,
|
||||||
|
"localPath": "",
|
||||||
|
"type": "",
|
||||||
|
"updatedAt": 0,
|
||||||
|
"url": ""
|
||||||
|
},
|
||||||
|
"FallbackParentInfo": {
|
||||||
|
"masterId": 0,
|
||||||
|
"path": ""
|
||||||
|
},
|
||||||
|
"HistoryOfSeeders": {
|
||||||
|
"id": 0,
|
||||||
|
"seederName": ""
|
||||||
|
},
|
||||||
|
"Inbound": {
|
||||||
|
"clientStats": [
|
||||||
|
{
|
||||||
|
"down": 2097152,
|
||||||
|
"email": "user1",
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 1735689600000,
|
||||||
|
"id": 14825,
|
||||||
|
"inboundId": 1,
|
||||||
|
"lastOnline": 1735680000000,
|
||||||
|
"reset": 0,
|
||||||
|
"subId": "i7tvdpeffi0hvvf1",
|
||||||
|
"total": 10737418240,
|
||||||
|
"up": 1048576,
|
||||||
|
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"down": 0,
|
||||||
|
"enable": true,
|
||||||
|
"expiryTime": 0,
|
||||||
|
"fallbackParent": null,
|
||||||
|
"id": 1,
|
||||||
|
"lastTrafficResetTime": 0,
|
||||||
|
"listen": "",
|
||||||
|
"nodeId": null,
|
||||||
|
"originNodeGuid": "",
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"remark": "VLESS-443",
|
||||||
|
"settings": null,
|
||||||
|
"sniffing": null,
|
||||||
|
"streamSettings": null,
|
||||||
|
"tag": "in-443-tcp",
|
||||||
|
"total": 0,
|
||||||
|
"trafficReset": "never",
|
||||||
|
"up": 0
|
||||||
|
},
|
||||||
|
"InboundClientIps": {
|
||||||
|
"clientEmail": "",
|
||||||
|
"id": 0,
|
||||||
|
"ips": null
|
||||||
|
},
|
||||||
|
"InboundFallback": {
|
||||||
|
"alpn": "",
|
||||||
|
"childId": 0,
|
||||||
|
"dest": "",
|
||||||
|
"id": 0,
|
||||||
|
"masterId": 0,
|
||||||
|
"name": "",
|
||||||
|
"path": "",
|
||||||
|
"sortOrder": 0,
|
||||||
|
"xver": 0
|
||||||
|
},
|
||||||
|
"InboundOption": {
|
||||||
|
"id": 1,
|
||||||
|
"port": 443,
|
||||||
|
"protocol": "vless",
|
||||||
|
"remark": "VLESS-443",
|
||||||
|
"ssMethod": "",
|
||||||
|
"tag": "in-443-tcp",
|
||||||
|
"tlsFlowCapable": true
|
||||||
|
},
|
||||||
|
"Msg": {
|
||||||
|
"msg": "",
|
||||||
|
"obj": null,
|
||||||
|
"success": false
|
||||||
|
},
|
||||||
|
"Node": {
|
||||||
|
"address": "node1.example.com",
|
||||||
|
"allowPrivateAddress": false,
|
||||||
|
"apiToken": "abcdef0123456789",
|
||||||
|
"basePath": "/",
|
||||||
|
"clientCount": 27,
|
||||||
|
"configDirty": false,
|
||||||
|
"configDirtyAt": 0,
|
||||||
|
"cpuPct": 23.5,
|
||||||
|
"createdAt": 1700000000,
|
||||||
|
"depletedCount": 1,
|
||||||
|
"enable": true,
|
||||||
|
"guid": "",
|
||||||
|
"id": 1,
|
||||||
|
"inboundCount": 5,
|
||||||
|
"lastError": "",
|
||||||
|
"lastHeartbeat": 1700000000,
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.1,
|
||||||
|
"name": "de-fra-1",
|
||||||
|
"onlineCount": 3,
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"parentGuid": "",
|
||||||
|
"pinnedCertSha256": "",
|
||||||
|
"port": 2053,
|
||||||
|
"remark": "",
|
||||||
|
"scheme": "https",
|
||||||
|
"status": "online",
|
||||||
|
"tlsVerifyMode": "verify",
|
||||||
|
"transitive": false,
|
||||||
|
"updatedAt": 1700000000,
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
|
},
|
||||||
|
"OutboundTraffics": {
|
||||||
|
"down": 0,
|
||||||
|
"id": 0,
|
||||||
|
"tag": "",
|
||||||
|
"total": 0,
|
||||||
|
"up": 0
|
||||||
|
},
|
||||||
|
"ProbeResultUI": {
|
||||||
|
"cpuPct": 12.5,
|
||||||
|
"error": "",
|
||||||
|
"latencyMs": 42,
|
||||||
|
"memPct": 45.2,
|
||||||
|
"panelVersion": "v3.x.x",
|
||||||
|
"status": "online",
|
||||||
|
"uptimeSecs": 86400,
|
||||||
|
"xrayVersion": "25.10.31"
|
||||||
|
},
|
||||||
|
"Setting": {
|
||||||
|
"id": 0,
|
||||||
|
"key": "",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
"User": {
|
||||||
|
"id": 0,
|
||||||
|
"password": "",
|
||||||
|
"username": ""
|
||||||
|
}
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
|
export type LoginStatus = number;
|
||||||
|
export type ProcessState = string;
|
||||||
export type Protocol = string;
|
export type Protocol = string;
|
||||||
|
export type SubLinkProvider = unknown;
|
||||||
|
export type transportBits = number;
|
||||||
|
|
||||||
export interface AllSetting {
|
export interface AllSetting {
|
||||||
datepicker: string;
|
datepicker: string;
|
||||||
@@ -179,6 +183,14 @@ export interface ApiToken {
|
|||||||
token: string;
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiTokenView {
|
||||||
|
createdAt: number;
|
||||||
|
enabled: boolean;
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Client {
|
export interface Client {
|
||||||
auth?: string;
|
auth?: string;
|
||||||
comment: string;
|
comment: string;
|
||||||
@@ -280,6 +292,7 @@ export interface Inbound {
|
|||||||
lastTrafficResetTime: number;
|
lastTrafficResetTime: number;
|
||||||
listen: string;
|
listen: string;
|
||||||
nodeId?: number | null;
|
nodeId?: number | null;
|
||||||
|
originNodeGuid?: string;
|
||||||
port: number;
|
port: number;
|
||||||
protocol: Protocol;
|
protocol: Protocol;
|
||||||
remark: string;
|
remark: string;
|
||||||
@@ -310,6 +323,16 @@ export interface InboundFallback {
|
|||||||
xver: number;
|
xver: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InboundOption {
|
||||||
|
id: number;
|
||||||
|
port: number;
|
||||||
|
protocol: string;
|
||||||
|
remark: string;
|
||||||
|
ssMethod: string;
|
||||||
|
tag: string;
|
||||||
|
tlsFlowCapable: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Msg {
|
export interface Msg {
|
||||||
msg: string;
|
msg: string;
|
||||||
obj: unknown;
|
obj: unknown;
|
||||||
@@ -328,6 +351,7 @@ export interface Node {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
enable: boolean;
|
enable: boolean;
|
||||||
|
guid: string;
|
||||||
id: number;
|
id: number;
|
||||||
inboundCount: number;
|
inboundCount: number;
|
||||||
lastError: string;
|
lastError: string;
|
||||||
@@ -337,12 +361,14 @@ export interface Node {
|
|||||||
name: string;
|
name: string;
|
||||||
onlineCount: number;
|
onlineCount: number;
|
||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
|
parentGuid?: string;
|
||||||
pinnedCertSha256: string;
|
pinnedCertSha256: string;
|
||||||
port: number;
|
port: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
scheme: string;
|
scheme: string;
|
||||||
status: string;
|
status: string;
|
||||||
tlsVerifyMode: string;
|
tlsVerifyMode: string;
|
||||||
|
transitive?: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
uptimeSecs: number;
|
uptimeSecs: number;
|
||||||
xrayVersion: string;
|
xrayVersion: string;
|
||||||
@@ -356,6 +382,17 @@ export interface OutboundTraffics {
|
|||||||
up: number;
|
up: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ProbeResultUI {
|
||||||
|
cpuPct: number;
|
||||||
|
error: string;
|
||||||
|
latencyMs: number;
|
||||||
|
memPct: number;
|
||||||
|
panelVersion: string;
|
||||||
|
status: string;
|
||||||
|
uptimeSecs: number;
|
||||||
|
xrayVersion: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Setting {
|
export interface Setting {
|
||||||
id: number;
|
id: number;
|
||||||
key: string;
|
key: string;
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
// Code generated by tools/openapigen. DO NOT EDIT.
|
// Code generated by tools/openapigen. DO NOT EDIT.
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
export const LoginStatusSchema = z.number().int();
|
||||||
|
export type LoginStatus = z.infer<typeof LoginStatusSchema>;
|
||||||
|
|
||||||
|
export const ProcessStateSchema = z.string();
|
||||||
|
export type ProcessState = z.infer<typeof ProcessStateSchema>;
|
||||||
|
|
||||||
export const ProtocolSchema = z.string();
|
export const ProtocolSchema = z.string();
|
||||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||||
|
|
||||||
|
export const SubLinkProviderSchema = z.unknown();
|
||||||
|
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
|
||||||
|
|
||||||
|
export const transportBitsSchema = z.number().int();
|
||||||
|
export type transportBits = z.infer<typeof transportBitsSchema>;
|
||||||
|
|
||||||
export const AllSettingSchema = z.object({
|
export const AllSettingSchema = z.object({
|
||||||
datepicker: z.string(),
|
datepicker: z.string(),
|
||||||
expireDiff: z.number().int().min(0),
|
expireDiff: z.number().int().min(0),
|
||||||
@@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
export type ApiToken = z.infer<typeof ApiTokenSchema>;
|
||||||
|
|
||||||
|
export const ApiTokenViewSchema = z.object({
|
||||||
|
createdAt: z.number().int(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
id: z.number().int(),
|
||||||
|
name: z.string(),
|
||||||
|
token: z.string().optional(),
|
||||||
|
});
|
||||||
|
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
|
||||||
|
|
||||||
export const ClientSchema = z.object({
|
export const ClientSchema = z.object({
|
||||||
auth: z.string().optional(),
|
auth: z.string().optional(),
|
||||||
comment: z.string(),
|
comment: z.string(),
|
||||||
@@ -293,8 +314,9 @@ export const InboundSchema = z.object({
|
|||||||
lastTrafficResetTime: z.number().int(),
|
lastTrafficResetTime: z.number().int(),
|
||||||
listen: z.string(),
|
listen: z.string(),
|
||||||
nodeId: z.number().int().nullable().optional(),
|
nodeId: z.number().int().nullable().optional(),
|
||||||
|
originNodeGuid: z.string().optional(),
|
||||||
port: z.number().int().min(0).max(65535),
|
port: z.number().int().min(0).max(65535),
|
||||||
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
|
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun', 'mtproto']),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
settings: z.unknown(),
|
settings: z.unknown(),
|
||||||
sniffing: z.unknown(),
|
sniffing: z.unknown(),
|
||||||
@@ -326,6 +348,17 @@ export const InboundFallbackSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
|
||||||
|
|
||||||
|
export const InboundOptionSchema = z.object({
|
||||||
|
id: z.number().int(),
|
||||||
|
port: z.number().int(),
|
||||||
|
protocol: z.string(),
|
||||||
|
remark: z.string(),
|
||||||
|
ssMethod: z.string(),
|
||||||
|
tag: z.string(),
|
||||||
|
tlsFlowCapable: z.boolean(),
|
||||||
|
});
|
||||||
|
export type InboundOption = z.infer<typeof InboundOptionSchema>;
|
||||||
|
|
||||||
export const MsgSchema = z.object({
|
export const MsgSchema = z.object({
|
||||||
msg: z.string(),
|
msg: z.string(),
|
||||||
obj: z.unknown(),
|
obj: z.unknown(),
|
||||||
@@ -345,6 +378,7 @@ export const NodeSchema = z.object({
|
|||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
enable: z.boolean(),
|
enable: z.boolean(),
|
||||||
|
guid: z.string(),
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
inboundCount: z.number().int(),
|
inboundCount: z.number().int(),
|
||||||
lastError: z.string(),
|
lastError: z.string(),
|
||||||
@@ -354,12 +388,14 @@ export const NodeSchema = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
onlineCount: z.number().int(),
|
onlineCount: z.number().int(),
|
||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
|
parentGuid: z.string().optional(),
|
||||||
pinnedCertSha256: z.string(),
|
pinnedCertSha256: z.string(),
|
||||||
port: z.number().int().min(1).max(65535),
|
port: z.number().int().min(1).max(65535),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
scheme: z.enum(['http', 'https']),
|
scheme: z.enum(['http', 'https']),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
|
||||||
|
transitive: z.boolean().optional(),
|
||||||
updatedAt: z.number().int(),
|
updatedAt: z.number().int(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
xrayVersion: z.string(),
|
xrayVersion: z.string(),
|
||||||
@@ -375,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
|
||||||
|
|
||||||
|
export const ProbeResultUISchema = z.object({
|
||||||
|
cpuPct: z.number(),
|
||||||
|
error: z.string(),
|
||||||
|
latencyMs: z.number().int(),
|
||||||
|
memPct: z.number(),
|
||||||
|
panelVersion: z.string(),
|
||||||
|
status: z.string(),
|
||||||
|
uptimeSecs: z.number().int(),
|
||||||
|
xrayVersion: z.string(),
|
||||||
|
});
|
||||||
|
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
||||||
|
|
||||||
export const SettingSchema = z.object({
|
export const SettingSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
key: z.string(),
|
key: z.string(),
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
async function fetchDefaults(): Promise<Record<string, unknown>> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
return validated.obj || {};
|
return validated.obj || {};
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ async function loadOnce(): Promise<void> {
|
|||||||
}
|
}
|
||||||
pending = (async () => {
|
pending = (async () => {
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
cachedValue = validated.obj?.datepicker || 'gregorian';
|
cachedValue = validated.obj?.datepicker || 'gregorian';
|
||||||
|
|||||||
@@ -51,9 +51,12 @@ export interface UseXraySettingResult {
|
|||||||
setOutboundTestUrl: (v: string) => void;
|
setOutboundTestUrl: (v: string) => void;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
clientReverseTags: string[];
|
clientReverseTags: string[];
|
||||||
|
subscriptionOutbounds: unknown[];
|
||||||
|
subscriptionOutboundTags: string[];
|
||||||
restartResult: string;
|
restartResult: string;
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
outboundsTraffic: OutboundTrafficRow[];
|
||||||
outboundTestStates: Record<number, OutboundTestState>;
|
outboundTestStates: Record<number, OutboundTestState>;
|
||||||
|
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||||
testingAll: boolean;
|
testingAll: boolean;
|
||||||
fetchAll: () => Promise<void>;
|
fetchAll: () => Promise<void>;
|
||||||
fetchOutboundsTraffic: () => Promise<void>;
|
fetchOutboundsTraffic: () => Promise<void>;
|
||||||
@@ -63,6 +66,11 @@ export interface UseXraySettingResult {
|
|||||||
outbound: unknown,
|
outbound: unknown,
|
||||||
mode?: string,
|
mode?: string,
|
||||||
) => Promise<OutboundTestResult | null>;
|
) => Promise<OutboundTestResult | null>;
|
||||||
|
testSubscriptionOutbound: (
|
||||||
|
tag: string,
|
||||||
|
outbound: unknown,
|
||||||
|
mode?: string,
|
||||||
|
) => Promise<OutboundTestResult | null>;
|
||||||
testAllOutbounds: (mode?: string) => Promise<void>;
|
testAllOutbounds: (mode?: string) => Promise<void>;
|
||||||
saveAll: () => Promise<void>;
|
saveAll: () => Promise<void>;
|
||||||
resetToDefault: () => Promise<void>;
|
resetToDefault: () => Promise<void>;
|
||||||
@@ -72,7 +80,7 @@ export interface UseXraySettingResult {
|
|||||||
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
|
||||||
|
|
||||||
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
||||||
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
|
||||||
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
@@ -91,7 +99,7 @@ async function fetchXrayConfig(): Promise<XrayConfigPayload> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
|
||||||
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
|
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
|
||||||
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
|
||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
@@ -118,8 +126,13 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
const [outboundTestUrl, setOutboundTestUrlState] = useState(DEFAULT_TEST_URL);
|
||||||
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
const [inboundTags, setInboundTags] = useState<string[]>([]);
|
||||||
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
const [clientReverseTags, setClientReverseTags] = useState<string[]>([]);
|
||||||
|
const [subscriptionOutbounds, setSubscriptionOutbounds] = useState<unknown[]>([]);
|
||||||
|
const [subscriptionOutboundTags, setSubscriptionOutboundTags] = useState<string[]>([]);
|
||||||
const [restartResult, setRestartResult] = useState('');
|
const [restartResult, setRestartResult] = useState('');
|
||||||
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
const [outboundTestStates, setOutboundTestStates] = useState<Record<number, OutboundTestState>>({});
|
||||||
|
// Subscription outbounds aren't in templateSettings.outbounds, so their test
|
||||||
|
// results are keyed by tag rather than by index.
|
||||||
|
const [subscriptionTestStates, setSubscriptionTestStates] = useState<Record<string, OutboundTestState>>({});
|
||||||
const [testingAll, setTestingAll] = useState(false);
|
const [testingAll, setTestingAll] = useState(false);
|
||||||
|
|
||||||
const oldXraySettingRef = useRef('');
|
const oldXraySettingRef = useRef('');
|
||||||
@@ -146,6 +159,8 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
syncingRef.current = false;
|
syncingRef.current = false;
|
||||||
setInboundTags(obj.inboundTags || []);
|
setInboundTags(obj.inboundTags || []);
|
||||||
setClientReverseTags(obj.clientReverseTags || []);
|
setClientReverseTags(obj.clientReverseTags || []);
|
||||||
|
setSubscriptionOutbounds(obj.subscriptionOutbounds || []);
|
||||||
|
setSubscriptionOutboundTags(obj.subscriptionOutboundTags || []);
|
||||||
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
const nextUrl = obj.outboundTestUrl || DEFAULT_TEST_URL;
|
||||||
setOutboundTestUrlState(nextUrl);
|
setOutboundTestUrlState(nextUrl);
|
||||||
oldOutboundTestUrlRef.current = nextUrl;
|
oldOutboundTestUrlRef.current = nextUrl;
|
||||||
@@ -200,7 +215,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
const sentXraySetting = xraySettingRef.current;
|
const sentXraySetting = xraySettingRef.current;
|
||||||
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
|
||||||
const msg = await HttpUtil.post('/panel/xray/update', {
|
const msg = await HttpUtil.post('/panel/api/xray/update', {
|
||||||
xraySetting: sentXraySetting,
|
xraySetting: sentXraySetting,
|
||||||
outboundTestUrl: sentTestUrl,
|
outboundTestUrl: sentTestUrl,
|
||||||
});
|
});
|
||||||
@@ -217,7 +232,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const resetTrafficMut = useMutation({
|
const resetTrafficMut = useMutation({
|
||||||
mutationFn: (tag: string) =>
|
mutationFn: (tag: string) =>
|
||||||
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
|
HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
|
||||||
},
|
},
|
||||||
@@ -228,7 +243,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
|
||||||
if (!msg?.success) return msg;
|
if (!msg?.success) return msg;
|
||||||
await PromiseUtil.sleep(500);
|
await PromiseUtil.sleep(500);
|
||||||
const r = await HttpUtil.get('/panel/xray/getXrayResult');
|
const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
|
||||||
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
|
||||||
if (validated?.success) setRestartResult(validated.obj || '');
|
if (validated?.success) setRestartResult(validated.obj || '');
|
||||||
return msg;
|
return msg;
|
||||||
@@ -237,7 +252,7 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const resetDefaultMut = useMutation({
|
const resetDefaultMut = useMutation({
|
||||||
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
|
||||||
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
|
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
|
||||||
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
|
||||||
},
|
},
|
||||||
onSuccess: (msg) => {
|
onSuccess: (msg) => {
|
||||||
@@ -255,6 +270,26 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
|
|
||||||
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
|
const spinning = saveMut.isPending || restartMut.isPending || resetDefaultMut.isPending;
|
||||||
|
|
||||||
|
// Shared POST + parse for a single outbound test. Returns an OutboundTestResult
|
||||||
|
// (success or a failure-shaped result); callers store it under their own key.
|
||||||
|
const postOutboundTest = useCallback(
|
||||||
|
async (outbound: unknown, effMode: string): Promise<OutboundTestResult> => {
|
||||||
|
try {
|
||||||
|
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
|
||||||
|
outbound: JSON.stringify(outbound),
|
||||||
|
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
||||||
|
mode: effMode,
|
||||||
|
});
|
||||||
|
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
|
||||||
|
if (msg?.success && msg.obj) return msg.obj;
|
||||||
|
return { success: false, error: msg?.msg || 'Unknown error', mode: effMode };
|
||||||
|
} catch (e) {
|
||||||
|
return { success: false, error: String(e), mode: effMode };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const testOutbound = useCallback(
|
const testOutbound = useCallback(
|
||||||
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
async (index: number, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||||
if (!outbound) return null;
|
if (!outbound) return null;
|
||||||
@@ -263,39 +298,28 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
...prev,
|
...prev,
|
||||||
[index]: { testing: true, result: null, mode: effMode },
|
[index]: { testing: true, result: null, mode: effMode },
|
||||||
}));
|
}));
|
||||||
try {
|
const result = await postOutboundTest(outbound, effMode);
|
||||||
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
|
setOutboundTestStates((prev) => ({ ...prev, [index]: { testing: false, result } }));
|
||||||
outbound: JSON.stringify(outbound),
|
return result.success ? result : null;
|
||||||
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
|
|
||||||
mode: effMode,
|
|
||||||
});
|
|
||||||
const msg = parseMsg(raw, OutboundTestResultSchema, 'xray/testOutbound');
|
|
||||||
if (msg?.success && msg.obj) {
|
|
||||||
setOutboundTestStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[index]: { testing: false, result: msg.obj },
|
|
||||||
}));
|
|
||||||
return msg.obj;
|
|
||||||
}
|
|
||||||
setOutboundTestStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[index]: {
|
|
||||||
testing: false,
|
|
||||||
result: { success: false, error: msg?.msg || 'Unknown error', mode: effMode },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} catch (e) {
|
|
||||||
setOutboundTestStates((prev) => ({
|
|
||||||
...prev,
|
|
||||||
[index]: {
|
|
||||||
testing: false,
|
|
||||||
result: { success: false, error: String(e), mode: effMode },
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
[],
|
[postOutboundTest],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test a subscription outbound (not present in templateSettings.outbounds);
|
||||||
|
// results are keyed by tag in subscriptionTestStates.
|
||||||
|
const testSubscriptionOutbound = useCallback(
|
||||||
|
async (tag: string, outbound: unknown, mode = 'tcp'): Promise<OutboundTestResult | null> => {
|
||||||
|
if (!outbound || !tag) return null;
|
||||||
|
const effMode = isUdpOutbound(outbound) ? 'http' : mode;
|
||||||
|
setSubscriptionTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[tag]: { testing: true, result: null, mode: effMode },
|
||||||
|
}));
|
||||||
|
const result = await postOutboundTest(outbound, effMode);
|
||||||
|
setSubscriptionTestStates((prev) => ({ ...prev, [tag]: { testing: false, result } }));
|
||||||
|
return result.success ? result : null;
|
||||||
|
},
|
||||||
|
[postOutboundTest],
|
||||||
);
|
);
|
||||||
|
|
||||||
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
const testAllOutbounds = useCallback(async (mode = 'tcp') => {
|
||||||
@@ -358,14 +382,18 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
setOutboundTestUrl,
|
setOutboundTestUrl,
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
|
subscriptionOutbounds,
|
||||||
|
subscriptionOutboundTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
subscriptionTestStates,
|
||||||
testingAll,
|
testingAll,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
fetchOutboundsTraffic: fetchOutboundsTrafficCb,
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
|
testSubscriptionOutbound,
|
||||||
testAllOutbounds,
|
testAllOutbounds,
|
||||||
saveAll,
|
saveAll,
|
||||||
resetToDefault,
|
resetToDefault,
|
||||||
@@ -384,14 +412,18 @@ export function useXraySetting(): UseXraySettingResult {
|
|||||||
setOutboundTestUrl,
|
setOutboundTestUrl,
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
|
subscriptionOutbounds,
|
||||||
|
subscriptionOutboundTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
subscriptionTestStates,
|
||||||
testingAll,
|
testingAll,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
fetchOutboundsTrafficCb,
|
fetchOutboundsTrafficCb,
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
|
testSubscriptionOutbound,
|
||||||
testAllOutbounds,
|
testAllOutbounds,
|
||||||
saveAll,
|
saveAll,
|
||||||
resetToDefault,
|
resetToDefault,
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const DONATE_URL = 'https://donate.sanaei.dev/';
|
|||||||
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
const REPO_URL = 'https://github.com/MHSanaei/3x-ui';
|
||||||
const LOGOUT_KEY = '__logout__';
|
const LOGOUT_KEY = '__logout__';
|
||||||
|
|
||||||
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs';
|
type IconName = 'dashboard' | 'inbound' | 'team' | 'groups' | 'setting' | 'tool' | 'cluster' | 'logout' | 'apidocs' | 'outbound';
|
||||||
|
|
||||||
const iconByName: Record<IconName, ComponentType> = {
|
const iconByName: Record<IconName, ComponentType> = {
|
||||||
dashboard: DashboardOutlined,
|
dashboard: DashboardOutlined,
|
||||||
@@ -52,6 +52,7 @@ const iconByName: Record<IconName, ComponentType> = {
|
|||||||
cluster: ClusterOutlined,
|
cluster: ClusterOutlined,
|
||||||
logout: LogoutOutlined,
|
logout: LogoutOutlined,
|
||||||
apidocs: ApiOutlined,
|
apidocs: ApiOutlined,
|
||||||
|
outbound: UploadOutlined,
|
||||||
};
|
};
|
||||||
|
|
||||||
function readCollapsed(): boolean {
|
function readCollapsed(): boolean {
|
||||||
@@ -137,6 +138,7 @@ export default function AppSidebar() {
|
|||||||
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
{ key: '/clients', icon: 'team', title: t('menu.clients') },
|
||||||
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
{ key: '/groups', icon: 'groups', title: t('menu.groups') },
|
||||||
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
{ key: '/nodes', icon: 'cluster', title: t('menu.nodes') },
|
||||||
|
{ key: '/xray#outbound', icon: 'outbound', title: t('pages.xray.Outbounds') },
|
||||||
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
{ key: '/settings', icon: 'setting', title: t('menu.settings') },
|
||||||
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
{ key: '/xray', icon: 'tool', title: t('menu.xray') },
|
||||||
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
{ key: '/api-docs', icon: 'apidocs', title: t('menu.apiDocs') },
|
||||||
@@ -162,7 +164,6 @@ export default function AppSidebar() {
|
|||||||
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
|
const xrayChildren = useMemo<NonNullable<MenuProps['items']>>(() => [
|
||||||
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
|
{ key: '/xray#basic', icon: <SettingOutlined />, label: t('pages.xray.basicTemplate') },
|
||||||
{ key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
|
{ key: '/xray#routing', icon: <SwapOutlined />, label: t('pages.xray.Routings') },
|
||||||
{ key: '/xray#outbound', icon: <UploadOutlined />, label: t('pages.xray.Outbounds') },
|
|
||||||
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
|
{ key: '/xray#balancer', icon: <ClusterOutlined />, label: t('pages.xray.Balancers') },
|
||||||
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
|
{ key: '/xray#dns', icon: <DatabaseOutlined />, label: 'DNS' },
|
||||||
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
|
{ key: '/xray#advanced', icon: <CodeOutlined />, label: t('pages.xray.advancedTemplate') },
|
||||||
@@ -176,7 +177,9 @@ export default function AppSidebar() {
|
|||||||
? `/xray${hash || '#basic'}`
|
? `/xray${hash || '#basic'}`
|
||||||
: (pathname === '' ? '/' : pathname);
|
: (pathname === '' ? '/' : pathname);
|
||||||
|
|
||||||
const openSubmenu = settingsActive ? '/settings' : xrayActive ? '/xray' : null;
|
// The Outbounds top-level item lives on /xray#outbound, so don't auto-open the
|
||||||
|
// Xray Configs submenu for it.
|
||||||
|
const openSubmenu = settingsActive ? '/settings' : xrayActive && hash !== '#outbound' ? '/xray' : null;
|
||||||
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
const [openKeys, setOpenKeys] = useState<string[]>(() => (openSubmenu ? [openSubmenu] : []));
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (openSubmenu) {
|
if (openSubmenu) {
|
||||||
|
|||||||
@@ -26,10 +26,10 @@ function asPath(name: NamePath): (string | number)[] {
|
|||||||
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'fragment':
|
case 'fragment':
|
||||||
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
|
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
|
||||||
case 'sudoku':
|
case 'sudoku':
|
||||||
return {
|
return {
|
||||||
password: '', ascii: '', customTable: '', customTables: '',
|
password: '', ascii: '', customTable: '', customTables: [''],
|
||||||
paddingMin: 0, paddingMax: 0,
|
paddingMin: 0, paddingMax: 0,
|
||||||
};
|
};
|
||||||
case 'header-custom':
|
case 'header-custom':
|
||||||
@@ -210,8 +210,12 @@ function TcpMaskItem({
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
|
<Form.Item
|
||||||
<Input />
|
label="Length"
|
||||||
|
name={[fieldName, 'settings', 'length']}
|
||||||
|
rules={[{ validator: validateFragmentLength }]}
|
||||||
|
>
|
||||||
|
<Input placeholder="e.g. 100-200" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
|
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
|
||||||
<Input />
|
<Input />
|
||||||
@@ -228,7 +232,9 @@ function TcpMaskItem({
|
|||||||
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
|
||||||
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
|
||||||
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
|
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
|
||||||
|
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
|
||||||
|
</Form.Item>
|
||||||
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
@@ -257,6 +263,18 @@ function TcpMaskItem({
|
|||||||
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
// Walks a deep object path safely. Used inside shouldUpdate which gets
|
||||||
// the whole form values blob; we need to compare a deep field across
|
// the whole form values blob; we need to compare a deep field across
|
||||||
// prev/curr without crashing on missing intermediates.
|
// prev/curr without crashing on missing intermediates.
|
||||||
|
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
|
||||||
|
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
|
||||||
|
if (str.length === 0) {
|
||||||
|
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
|
||||||
|
}
|
||||||
|
const min = Number(str.split('-')[0]);
|
||||||
|
if (!Number.isFinite(min) || min <= 0) {
|
||||||
|
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
function getDeep(obj: unknown, path: (string | number)[]): unknown {
|
||||||
let cur: unknown = obj;
|
let cur: unknown = obj;
|
||||||
for (const key of path) {
|
for (const key of path) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RandomUtil, Wireguard } from '@/utils';
|
|||||||
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
import type { HttpInboundSettings } from '@/schemas/protocols/inbound/http';
|
||||||
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
import type { HysteriaClient, HysteriaInboundSettings } from '@/schemas/protocols/inbound/hysteria';
|
||||||
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
import type { MixedInboundSettings } from '@/schemas/protocols/inbound/mixed';
|
||||||
|
import type { MtprotoInboundSettings } from '@/schemas/protocols/inbound/mtproto';
|
||||||
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
|
import type { ShadowsocksClient, ShadowsocksInboundSettings } from '@/schemas/protocols/inbound/shadowsocks';
|
||||||
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
|
import type { TrojanClient, TrojanInboundSettings } from '@/schemas/protocols/inbound/trojan';
|
||||||
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
|
import type { TunInboundSettings } from '@/schemas/protocols/inbound/tun';
|
||||||
@@ -200,6 +201,43 @@ export function createDefaultMixedInboundSettings(): MixedInboundSettings {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function domainToHex(domain: string): string {
|
||||||
|
return Array.from(new TextEncoder().encode(domain))
|
||||||
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMtprotoSecret builds an "ee" FakeTLS secret: the marker, 16 random
|
||||||
|
// bytes (32 hex chars), then the domain encoded as hex. Mirrors the Go
|
||||||
|
// model.GenerateFakeTLSSecret; the backend re-derives it on save so this is
|
||||||
|
// only for immediate display in the form.
|
||||||
|
export function generateMtprotoSecret(domain: string): string {
|
||||||
|
return `ee${RandomUtil.randomSeq(32, { type: 'hex' })}${domainToHex(domain)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// mtprotoSecretForDomain rewrites only the domain suffix of an existing secret,
|
||||||
|
// preserving its 16-byte random middle when valid (generating one otherwise).
|
||||||
|
// Mirrors the Go model.HealMtprotoSecret so editing the FakeTLS domain doesn't
|
||||||
|
// needlessly rotate the secret's identity.
|
||||||
|
export function mtprotoSecretForDomain(currentSecret: string, domain: string): string {
|
||||||
|
let body = currentSecret;
|
||||||
|
if (body.startsWith('ee') || body.startsWith('dd')) {
|
||||||
|
body = body.slice(2);
|
||||||
|
}
|
||||||
|
const middle = /^[0-9a-f]{32}/i.test(body)
|
||||||
|
? body.slice(0, 32)
|
||||||
|
: RandomUtil.randomSeq(32, { type: 'hex' });
|
||||||
|
return `ee${middle}${domainToHex(domain)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultMtprotoInboundSettings(): MtprotoInboundSettings {
|
||||||
|
const fakeTlsDomain = 'www.cloudflare.com';
|
||||||
|
return {
|
||||||
|
fakeTlsDomain,
|
||||||
|
secret: generateMtprotoSecret(fakeTlsDomain),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
|
export function createDefaultTunnelInboundSettings(): TunnelInboundSettings {
|
||||||
return {
|
return {
|
||||||
portMap: {},
|
portMap: {},
|
||||||
@@ -261,7 +299,8 @@ export type AnyInboundSettings =
|
|||||||
| MixedInboundSettings
|
| MixedInboundSettings
|
||||||
| TunInboundSettings
|
| TunInboundSettings
|
||||||
| TunnelInboundSettings
|
| TunnelInboundSettings
|
||||||
| WireguardInboundSettings;
|
| WireguardInboundSettings
|
||||||
|
| MtprotoInboundSettings;
|
||||||
|
|
||||||
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
export function createDefaultInboundSettings(protocol: string): AnyInboundSettings | null {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
@@ -275,6 +314,7 @@ export function createDefaultInboundSettings(protocol: string): AnyInboundSettin
|
|||||||
case 'tunnel': return createDefaultTunnelInboundSettings();
|
case 'tunnel': return createDefaultTunnelInboundSettings();
|
||||||
case 'tun': return createDefaultTunInboundSettings();
|
case 'tun': return createDefaultTunInboundSettings();
|
||||||
case 'wireguard': return createDefaultWireguardInboundSettings();
|
case 'wireguard': return createDefaultWireguardInboundSettings();
|
||||||
|
case 'mtproto': return createDefaultMtprotoInboundSettings();
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
import type { StreamSettings } from '@/schemas/api/inbound';
|
import type { StreamSettings } from '@/schemas/api/inbound';
|
||||||
import type { Sniffing } from '@/schemas/primitives';
|
import type { Sniffing } from '@/schemas/primitives';
|
||||||
import type { z } from 'zod';
|
import type { z } from 'zod';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
// Plain-data adapter between the panel's stored inbound row shape and
|
// Plain-data adapter between the panel's stored inbound row shape and
|
||||||
// the typed InboundFormValues that Form.useForm<T> carries inside
|
// the typed InboundFormValues that Form.useForm<T> carries inside
|
||||||
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
|
|||||||
if (Array.isArray(settingsPruned.clients)) {
|
if (Array.isArray(settingsPruned.clients)) {
|
||||||
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
|
||||||
}
|
}
|
||||||
const streamPruned = values.streamSettings
|
let streamPruned = values.streamSettings
|
||||||
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (streamPruned) stripTlsCertUseFile(streamPruned);
|
if (streamPruned) {
|
||||||
|
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
|
||||||
|
stripTlsCertUseFile(streamPruned);
|
||||||
|
}
|
||||||
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
|
||||||
const payload: WireInboundPayload = {
|
const payload: WireInboundPayload = {
|
||||||
up: values.up,
|
up: values.up,
|
||||||
|
|||||||
@@ -680,6 +680,28 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
|||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GenMtprotoLinkInput {
|
||||||
|
inbound: Inbound;
|
||||||
|
address: string;
|
||||||
|
port?: number;
|
||||||
|
remark?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Builds a Telegram proxy deep link for an mtproto inbound:
|
||||||
|
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
|
||||||
|
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
|
||||||
|
const { inbound, address, port = inbound.port, remark = '' } = input;
|
||||||
|
if (inbound.protocol !== 'mtproto') return '';
|
||||||
|
const secret = inbound.settings.secret ?? '';
|
||||||
|
if (secret.length === 0) return '';
|
||||||
|
const url = new URL('tg://proxy');
|
||||||
|
url.searchParams.set('server', address);
|
||||||
|
url.searchParams.set('port', String(port));
|
||||||
|
url.searchParams.set('secret', secret);
|
||||||
|
url.hash = encodeURIComponent(remark);
|
||||||
|
return url.toString();
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenWireguardLinkInput {
|
export interface GenWireguardLinkInput {
|
||||||
settings: WireguardInboundSettings;
|
settings: WireguardInboundSettings;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -867,6 +889,8 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
clientAuth: client.auth ?? '',
|
clientAuth: client.auth ?? '',
|
||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
|
case 'mtproto':
|
||||||
|
return genMtprotoLink({ inbound, address, port, remark });
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
|
||||||
|
|
||||||
|
function defaultCertificate(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
useFile: true,
|
||||||
|
certificateFile: '',
|
||||||
|
keyFile: '',
|
||||||
|
certificate: [],
|
||||||
|
key: [],
|
||||||
|
ocspStapling: 3600,
|
||||||
|
oneTimeLoading: false,
|
||||||
|
usage: 'encipherment',
|
||||||
|
buildChain: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createTlsSettingsWithDefaultCert(): Record<string, unknown> {
|
||||||
|
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
||||||
|
tls.certificates = [defaultCertificate()];
|
||||||
|
return tls;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createHysteriaTlsSettingsWithDefaultCert(): Record<string, unknown> {
|
||||||
|
const tls = createTlsSettingsWithDefaultCert();
|
||||||
|
tls.alpn = ['h3'];
|
||||||
|
|
||||||
|
const settings = tls.settings && typeof tls.settings === 'object' && !Array.isArray(tls.settings)
|
||||||
|
? { ...(tls.settings as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
settings.fingerprint = '';
|
||||||
|
tls.settings = settings;
|
||||||
|
|
||||||
|
return tls;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
|
||||||
|
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
|
||||||
import { Wireguard } from '@/utils';
|
import { Wireguard } from '@/utils';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
|
|||||||
userLevel: s.userLevel || undefined,
|
userLevel: s.userLevel || undefined,
|
||||||
proxyProtocol: s.proxyProtocol || undefined,
|
proxyProtocol: s.proxyProtocol || undefined,
|
||||||
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
|
||||||
noises: s.noises.length > 0 ? s.noises : undefined,
|
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
|
||||||
finalRules: s.finalRules.length > 0
|
finalRules: s.finalRules && s.finalRules.length > 0
|
||||||
? s.finalRules.map((r) => ({
|
? s.finalRules.map((r) => ({
|
||||||
action: r.action,
|
action: r.action,
|
||||||
network: r.network || undefined,
|
network: r.network || undefined,
|
||||||
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
|
|||||||
if (!xmuxEnabled) delete cleaned.xmux;
|
if (!xmuxEnabled) delete cleaned.xmux;
|
||||||
next.xhttpSettings = dropEmptyStrings(cleaned);
|
next.xhttpSettings = dropEmptyStrings(cleaned);
|
||||||
}
|
}
|
||||||
return next;
|
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function muxAllowed(values: OutboundFormValues): boolean {
|
function muxAllowed(values: OutboundFormValues): boolean {
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
// Shapes the streamSettings subtree that 3x-ui persists to match what
|
||||||
|
// xray-core actually consumes. The panel's Zod defaults mirror the full
|
||||||
|
// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
|
||||||
|
// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
|
||||||
|
// them anyway bloats configs and — for sockopt — can inject doc-example
|
||||||
|
// values like tcpWindowClamp: 600 that throttle throughput.
|
||||||
|
|
||||||
|
export type StreamWireSide = 'inbound' | 'outbound';
|
||||||
|
|
||||||
|
const PACKET_UP_FIELDS = [
|
||||||
|
'scMaxEachPostBytes',
|
||||||
|
'scMinPostsIntervalMs',
|
||||||
|
'scMaxBufferedPosts',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
|
||||||
|
|
||||||
|
const PLACEMENT_STRING_FIELDS = [
|
||||||
|
'sessionPlacement',
|
||||||
|
'sessionKey',
|
||||||
|
'seqPlacement',
|
||||||
|
'seqKey',
|
||||||
|
'uplinkDataPlacement',
|
||||||
|
'uplinkDataKey',
|
||||||
|
'uplinkHTTPMethod',
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function isRecord(v: unknown): v is Record<string, unknown> {
|
||||||
|
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmptyString(v: unknown): v is string {
|
||||||
|
return typeof v === 'string' && v.trim() !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasMeaningfulHeaders(headers: unknown): boolean {
|
||||||
|
return isRecord(headers) && Object.keys(headers).length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validates REALITY inbound `target` / `dest` (must include a port). */
|
||||||
|
export function validateRealityTarget(target: string): string | undefined {
|
||||||
|
const trimmed = target.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
return 'pages.inbounds.form.realityTargetRequired';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unix socket destinations (rare, but valid in xray-core).
|
||||||
|
if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pure port → localhost:port in xray-core.
|
||||||
|
if (/^\d+$/.test(trimmed)) {
|
||||||
|
const port = Number(trimmed);
|
||||||
|
if (port >= 1 && port <= 65535) return undefined;
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastColon = trimmed.lastIndexOf(':');
|
||||||
|
if (lastColon <= 0 || lastColon === trimmed.length - 1) {
|
||||||
|
return 'pages.inbounds.form.realityTargetNeedsPort';
|
||||||
|
}
|
||||||
|
|
||||||
|
const portPart = trimmed.slice(lastColon + 1);
|
||||||
|
if (!/^\d+$/.test(portPart)) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
const port = Number(portPart);
|
||||||
|
if (port < 1 || port > 65535) {
|
||||||
|
return 'pages.inbounds.form.realityTargetInvalidPort';
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
const v = obj[key];
|
||||||
|
if (v === '' || v == null) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === false) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
|
||||||
|
for (const key of keys) {
|
||||||
|
if (obj[key] === 0) delete obj[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTlsForWire(raw: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
if (out.fingerprint === '') delete out.fingerprint;
|
||||||
|
|
||||||
|
const settings = out.settings;
|
||||||
|
if (isRecord(settings)) {
|
||||||
|
const settingsOut: Record<string, unknown> = { ...settings };
|
||||||
|
if (settingsOut.fingerprint === '') delete settingsOut.fingerprint;
|
||||||
|
out.settings = settingsOut;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeXhttpForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
side: StreamWireSide,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
|
||||||
|
|
||||||
|
delete out.enableXmux;
|
||||||
|
|
||||||
|
if (side === 'inbound') {
|
||||||
|
delete out.xmux;
|
||||||
|
delete out.scMinPostsIntervalMs;
|
||||||
|
delete out.uplinkChunkSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
|
||||||
|
|
||||||
|
if (!hasMeaningfulHeaders(out.headers)) {
|
||||||
|
delete out.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.xPaddingObfsMode !== true) {
|
||||||
|
delete out.xPaddingObfsMode;
|
||||||
|
dropEmptyStrings(out, [
|
||||||
|
'xPaddingKey',
|
||||||
|
'xPaddingHeader',
|
||||||
|
'xPaddingPlacement',
|
||||||
|
'xPaddingMethod',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
|
||||||
|
if (out.noSSEHeader !== true) delete out.noSSEHeader;
|
||||||
|
if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
|
||||||
|
if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
|
||||||
|
|
||||||
|
if (mode === 'stream-one') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
|
||||||
|
} else if (mode === 'stream-up') {
|
||||||
|
for (const key of PACKET_UP_FIELDS) delete out[key];
|
||||||
|
if (side === 'outbound') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
} else if (mode === 'packet-up') {
|
||||||
|
delete out.scStreamUpServerSecs;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeSockoptForWire(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const out: Record<string, unknown> = { ...raw };
|
||||||
|
|
||||||
|
dropZeroNumbers(out, [
|
||||||
|
'tcpWindowClamp',
|
||||||
|
'tcpMaxSeg',
|
||||||
|
'tcpUserTimeout',
|
||||||
|
'tcpKeepAliveIdle',
|
||||||
|
'tcpKeepAliveInterval',
|
||||||
|
'mark',
|
||||||
|
]);
|
||||||
|
|
||||||
|
dropFalseFlags(out, [
|
||||||
|
'acceptProxyProtocol',
|
||||||
|
'tcpFastOpen',
|
||||||
|
'tcpMptcp',
|
||||||
|
'penetrate',
|
||||||
|
'V6Only',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (out.tproxy === 'off') delete out.tproxy;
|
||||||
|
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
|
||||||
|
if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
|
||||||
|
if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
|
||||||
|
if (nonEmptyString(out.interface) === false) delete out.interface;
|
||||||
|
if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
|
||||||
|
delete out.trustedXForwardedFor;
|
||||||
|
}
|
||||||
|
if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
|
||||||
|
delete out.customSockopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
const he = out.happyEyeballs;
|
||||||
|
if (isRecord(he)) {
|
||||||
|
const heOut: Record<string, unknown> = { ...he };
|
||||||
|
if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
|
||||||
|
if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
|
||||||
|
if (heOut.interleave === 1) delete heOut.interleave;
|
||||||
|
if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
|
||||||
|
if (Object.keys(heOut).length === 0) {
|
||||||
|
delete out.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
out.happyEyeballs = heOut;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
|
||||||
|
|
||||||
|
if (Object.keys(out).length === 0) return undefined;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStreamSettingsForWire(
|
||||||
|
stream: Record<string, unknown>,
|
||||||
|
opts: { side: StreamWireSide },
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const out: Record<string, unknown> = { ...stream };
|
||||||
|
|
||||||
|
const xhttp = out.xhttpSettings;
|
||||||
|
if (isRecord(xhttp)) {
|
||||||
|
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tls = out.tlsSettings;
|
||||||
|
if (isRecord(tls)) {
|
||||||
|
out.tlsSettings = normalizeTlsForWire(tls);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sockopt = out.sockopt;
|
||||||
|
if (isRecord(sockopt)) {
|
||||||
|
const normalized = normalizeSockoptForWire(sockopt);
|
||||||
|
if (normalized) {
|
||||||
|
out.sockopt = normalized;
|
||||||
|
} else {
|
||||||
|
delete out.sockopt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
@@ -83,6 +84,7 @@ export class DBInbound {
|
|||||||
sniffing: RawJsonField;
|
sniffing: RawJsonField;
|
||||||
clientStats: ClientStats[];
|
clientStats: ClientStats[];
|
||||||
nodeId: number | null;
|
nodeId: number | null;
|
||||||
|
originNodeGuid: string;
|
||||||
fallbackParent: FallbackParentRef | null;
|
fallbackParent: FallbackParentRef | null;
|
||||||
|
|
||||||
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
private _clientStatsMap: Map<string, ClientStats> | null = null;
|
||||||
@@ -108,6 +110,7 @@ export class DBInbound {
|
|||||||
this.sniffing = "";
|
this.sniffing = "";
|
||||||
this.clientStats = [];
|
this.clientStats = [];
|
||||||
this.nodeId = null;
|
this.nodeId = null;
|
||||||
|
this.originNodeGuid = "";
|
||||||
this.fallbackParent = null;
|
this.fallbackParent = null;
|
||||||
if (data == null) {
|
if (data == null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
|
|||||||
docExpansion="list"
|
docExpansion="list"
|
||||||
deepLinking={false}
|
deepLinking={false}
|
||||||
tryItOutEnabled
|
tryItOutEnabled
|
||||||
|
persistAuthorization
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Layout.Content>
|
</Layout.Content>
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ export interface Endpoint {
|
|||||||
response?: string;
|
response?: string;
|
||||||
errorResponse?: string;
|
errorResponse?: string;
|
||||||
errorStatus?: number;
|
errorStatus?: number;
|
||||||
|
responseSchema?: string;
|
||||||
|
responseSchemaArray?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubscriptionHeader {
|
export interface SubscriptionHeader {
|
||||||
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/list',
|
path: '/panel/api/inbounds/list',
|
||||||
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
|
||||||
response:
|
responseSchema: 'Inbound',
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/list/slim',
|
path: '/panel/api/inbounds/list/slim',
|
||||||
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
|
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
|
||||||
response:
|
response:
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
|
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/inbounds/options',
|
path: '/panel/api/inbounds/options',
|
||||||
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
|
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
|
||||||
response:
|
responseSchema: 'InboundOption',
|
||||||
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}',
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -324,6 +326,12 @@ export const sections: readonly Section[] = [
|
|||||||
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
|
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/descendants',
|
||||||
|
summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
|
||||||
|
response: '{\n "success": true,\n "obj": [\n {\n "guid": "c3d4-...",\n "parentGuid": "a1b2-...",\n "name": "Node3",\n "address": "10.0.0.3",\n "status": "online"\n }\n ]\n}',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/server/getNewX25519Cert',
|
path: '/panel/api/server/getNewX25519Cert',
|
||||||
@@ -682,15 +690,15 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/onlinesByNode',
|
path: '/panel/api/clients/onlinesByGuid',
|
||||||
summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
|
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "0": ["user1"],\n "3": ["user1", "user2"]\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/api/clients/activeInbounds',
|
path: '/panel/api/clients/activeInbounds',
|
||||||
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
|
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "0": ["inbound-443", "inbound-8443"]\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -705,7 +713,7 @@ export const sections: readonly Section[] = [
|
|||||||
params: [
|
params: [
|
||||||
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
|
||||||
],
|
],
|
||||||
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
|
responseSchema: 'ClientTraffic',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -742,7 +750,8 @@ export const sections: readonly Section[] = [
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/api/nodes/list',
|
path: '/panel/api/nodes/list',
|
||||||
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}',
|
responseSchema: 'Node',
|
||||||
|
responseSchemaArray: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@@ -799,7 +808,7 @@ export const sections: readonly Section[] = [
|
|||||||
path: '/panel/api/nodes/test',
|
path: '/panel/api/nodes/test',
|
||||||
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
|
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
|
||||||
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}',
|
responseSchema: 'ProbeResultUI',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -908,28 +917,28 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'settings',
|
id: 'settings',
|
||||||
title: 'Settings',
|
title: 'Settings',
|
||||||
description:
|
description:
|
||||||
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
|
'Panel configuration and user credentials. All endpoints live under /panel/api/setting and require a logged-in session or Bearer token.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/all',
|
path: '/panel/api/setting/all',
|
||||||
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/defaultSettings',
|
path: '/panel/api/setting/defaultSettings',
|
||||||
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/update',
|
path: '/panel/api/setting/update',
|
||||||
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
|
||||||
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/updateUser',
|
path: '/panel/api/setting/updateUser',
|
||||||
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
|
||||||
@@ -941,12 +950,12 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/restartPanel',
|
path: '/panel/api/setting/restartPanel',
|
||||||
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/setting/getDefaultJsonConfig',
|
path: '/panel/api/setting/getDefaultJsonConfig',
|
||||||
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -956,28 +965,28 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'api-tokens',
|
id: 'api-tokens',
|
||||||
title: 'API Tokens',
|
title: 'API Tokens',
|
||||||
description:
|
description:
|
||||||
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request.',
|
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer <token></code> on any /panel/api/* request — the token is a full-admin credential.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/setting/apiTokens',
|
path: '/panel/api/setting/apiTokens',
|
||||||
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
|
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
|
||||||
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/create',
|
path: '/panel/api/setting/apiTokens/create',
|
||||||
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
|
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
|
||||||
],
|
],
|
||||||
body: '{\n "name": "central-panel-a"\n}',
|
body: '{\n "name": "central-panel-a"\n}',
|
||||||
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
|
responseSchema: 'ApiTokenView',
|
||||||
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/delete/:id',
|
path: '/panel/api/setting/apiTokens/delete/:id',
|
||||||
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
@@ -986,7 +995,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/setting/apiTokens/setEnabled/:id',
|
path: '/panel/api/setting/apiTokens/setEnabled/:id',
|
||||||
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
|
||||||
@@ -1002,32 +1011,32 @@ export const sections: readonly Section[] = [
|
|||||||
id: 'xray-settings',
|
id: 'xray-settings',
|
||||||
title: 'Xray Settings',
|
title: 'Xray Settings',
|
||||||
description:
|
description:
|
||||||
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
|
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
|
||||||
endpoints: [
|
endpoints: [
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/',
|
path: '/panel/api/xray/',
|
||||||
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
|
||||||
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"in-443-tcp\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getDefaultJsonConfig',
|
path: '/panel/api/xray/getDefaultJsonConfig',
|
||||||
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
|
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/api/setting/getDefaultJsonConfig).',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getOutboundsTraffic',
|
path: '/panel/api/xray/getOutboundsTraffic',
|
||||||
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: '/panel/xray/getXrayResult',
|
path: '/panel/api/xray/getXrayResult',
|
||||||
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/update',
|
path: '/panel/api/xray/update',
|
||||||
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
|
||||||
@@ -1036,7 +1045,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/warp/:action',
|
path: '/panel/api/xray/warp/:action',
|
||||||
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
|
||||||
@@ -1047,7 +1056,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/nord/:action',
|
path: '/panel/api/xray/nord/:action',
|
||||||
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
|
||||||
@@ -1058,7 +1067,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/resetOutboundsTraffic',
|
path: '/panel/api/xray/resetOutboundsTraffic',
|
||||||
summary: 'Reset traffic counters for a specific outbound by tag.',
|
summary: 'Reset traffic counters for a specific outbound by tag.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
|
||||||
@@ -1067,7 +1076,7 @@ export const sections: readonly Section[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: '/panel/xray/testOutbound',
|
path: '/panel/api/xray/testOutbound',
|
||||||
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
|
||||||
params: [
|
params: [
|
||||||
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
|
||||||
@@ -1076,6 +1085,74 @@ export const sections: readonly Section[] = [
|
|||||||
],
|
],
|
||||||
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
body: 'outbound={"protocol":"freedom","settings":{}}&mode=tcp',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/xray/outbound-subs',
|
||||||
|
summary: 'List all outbound subscriptions (remote URLs that supply additional outbounds), newest first.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs',
|
||||||
|
summary: 'Create an outbound subscription. The URL is fetched, parsed into outbounds with stable tags, and merged additively into the running Xray config.',
|
||||||
|
params: [
|
||||||
|
{ name: 'remark', in: 'body (form)', type: 'string', desc: 'Optional display label.' },
|
||||||
|
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL (required). Must be a public http(s) address; private/internal targets are blocked unless allowPrivate is true.' },
|
||||||
|
{ name: 'tagPrefix', in: 'body (form)', type: 'string', desc: 'Prefix for generated outbound tags. Defaults to "sub<id>-".' },
|
||||||
|
{ name: 'updateInterval', in: 'body (form)', type: 'integer', desc: 'Seconds between auto-refreshes. Default 600.' },
|
||||||
|
{ name: 'enabled', in: 'body (form)', type: 'boolean', desc: 'Whether the subscription is active. Default true.' },
|
||||||
|
{ name: 'allowPrivate', in: 'body (form)', type: 'boolean', desc: 'Allow the URL to point at a private/internal/loopback address (localhost/LAN). Default false (SSRF guard blocks private targets).' },
|
||||||
|
{ name: 'prepend', in: 'body (form)', type: 'boolean', desc: 'Place this subscription\'s outbounds before the manual template outbounds (so one can become the default). Default false.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs/:id',
|
||||||
|
summary: 'Update an existing outbound subscription by id. Accepts the same form fields as create.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
path: '/panel/api/xray/outbound-subs/:id',
|
||||||
|
summary: 'Delete an outbound subscription by id.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs/:id/del',
|
||||||
|
summary: 'Delete an outbound subscription by id (POST alias of DELETE for axios-friendly clients).',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs/:id/refresh',
|
||||||
|
summary: 'Force an immediate re-fetch of the subscription and return the parsed outbounds. Signals Xray to reload.',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs/:id/move',
|
||||||
|
summary: 'Reorder a subscription one step up or down in priority (controls its position in the merged outbounds).',
|
||||||
|
params: [
|
||||||
|
{ name: 'id', in: 'path', type: 'integer', desc: 'Subscription id.' },
|
||||||
|
{ name: 'dir', in: 'body (form)', type: 'string', desc: '"up" to raise priority, anything else to lower it.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/xray/outbound-subs/parse',
|
||||||
|
summary: 'Preview a subscription URL: fetch and parse it into outbounds without persisting anything.',
|
||||||
|
params: [
|
||||||
|
{ name: 'url', in: 'body (form)', type: 'string', desc: 'Subscription URL to preview (required).' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
|
|||||||
[form.inboundIds, flowCapableIds],
|
[form.inboundIds, flowCapableIds],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const ss2022Method = useMemo(() => {
|
||||||
|
for (const id of form.inboundIds || []) {
|
||||||
|
const ib = (inbounds || []).find((row) => row.id === id);
|
||||||
|
const method = ib?.ssMethod;
|
||||||
|
if (method && method.substring(0, 4) === '2022') return method;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [form.inboundIds, inbounds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!showFlow && form.flow) {
|
if (!showFlow && form.flow) {
|
||||||
|
|
||||||
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
|
|||||||
email,
|
email,
|
||||||
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
subId: form.subId || RandomUtil.randomLowerAndNum(16),
|
||||||
id: RandomUtil.randomUUID(),
|
id: RandomUtil.randomUUID(),
|
||||||
password: RandomUtil.randomLowerAndNum(16),
|
password: ss2022Method
|
||||||
|
? RandomUtil.randomShadowsocksPassword(ss2022Method)
|
||||||
|
: RandomUtil.randomLowerAndNum(16),
|
||||||
auth: RandomUtil.randomLowerAndNum(16),
|
auth: RandomUtil.randomLowerAndNum(16),
|
||||||
flow: showFlow ? (form.flow || '') : '',
|
flow: showFlow ? (form.flow || '') : '',
|
||||||
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
|
||||||
|
|||||||
@@ -228,6 +228,21 @@ export default function ClientFormModal({
|
|||||||
return ids;
|
return ids;
|
||||||
}, [inbounds]);
|
}, [inbounds]);
|
||||||
|
|
||||||
|
const ss2022Method = useMemo(() => {
|
||||||
|
for (const id of form.inboundIds || []) {
|
||||||
|
const ib = (inbounds || []).find((row) => row.id === id);
|
||||||
|
const method = ib?.ssMethod;
|
||||||
|
if (method && method.substring(0, 4) === '2022') return method;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}, [form.inboundIds, inbounds]);
|
||||||
|
|
||||||
|
function regeneratePassword() {
|
||||||
|
update('password', ss2022Method
|
||||||
|
? RandomUtil.randomShadowsocksPassword(ss2022Method)
|
||||||
|
: RandomUtil.randomLowerAndNum(16));
|
||||||
|
}
|
||||||
|
|
||||||
const showFlow = useMemo(
|
const showFlow = useMemo(
|
||||||
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
|
||||||
[form.inboundIds, flowCapableIds],
|
[form.inboundIds, flowCapableIds],
|
||||||
@@ -257,6 +272,15 @@ export default function ClientFormModal({
|
|||||||
}
|
}
|
||||||
}, [showReverseTag, form.reverseTag]);
|
}, [showReverseTag, form.reverseTag]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ss2022Method) return;
|
||||||
|
setForm((prev) => (
|
||||||
|
RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
|
||||||
|
? prev
|
||||||
|
: { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
|
||||||
|
));
|
||||||
|
}, [ss2022Method]);
|
||||||
|
|
||||||
const inboundOptions = useMemo(
|
const inboundOptions = useMemo(
|
||||||
() => (inbounds || [])
|
() => (inbounds || [])
|
||||||
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
|
||||||
@@ -433,7 +457,7 @@ export default function ClientFormModal({
|
|||||||
<Form.Item label={t('pages.clients.password')}>
|
<Form.Item label={t('pages.clients.password')}>
|
||||||
<Space.Compact style={{ display: 'flex' }}>
|
<Space.Compact style={{ display: 'flex' }}>
|
||||||
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
|
||||||
<Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
|
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Col>
|
</Col>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { antdRule } from '@/utils/zodForm';
|
|||||||
import { Protocols } from '@/schemas/primitives';
|
import { Protocols } from '@/schemas/primitives';
|
||||||
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
|
import { HysteriaStreamSettingsSchema } from '@/schemas/protocols/stream/hysteria';
|
||||||
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
|
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||||
import { SniffingSchema } from '@/schemas/primitives/sniffing';
|
import { SniffingSchema } from '@/schemas/primitives/sniffing';
|
||||||
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
|
import { TcpStreamSettingsSchema } from '@/schemas/protocols/stream/tcp';
|
||||||
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
|
import { KcpStreamSettingsSchema } from '@/schemas/protocols/stream/kcp';
|
||||||
@@ -54,6 +54,7 @@ import {
|
|||||||
HttpFields,
|
HttpFields,
|
||||||
HysteriaFields,
|
HysteriaFields,
|
||||||
MixedFields,
|
MixedFields,
|
||||||
|
MtprotoFields,
|
||||||
ShadowsocksFields,
|
ShadowsocksFields,
|
||||||
TunFields,
|
TunFields,
|
||||||
TunnelFields,
|
TunnelFields,
|
||||||
@@ -351,22 +352,11 @@ export default function InboundFormModal({
|
|||||||
// snap back to TCP so the standard network selector has a valid
|
// snap back to TCP so the standard network selector has a valid
|
||||||
// starting point.
|
// starting point.
|
||||||
if (next === Protocols.HYSTERIA) {
|
if (next === Protocols.HYSTERIA) {
|
||||||
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
|
||||||
tls.certificates = [{
|
|
||||||
useFile: true,
|
|
||||||
certificateFile: '',
|
|
||||||
keyFile: '',
|
|
||||||
certificate: [],
|
|
||||||
key: [],
|
|
||||||
oneTimeLoading: false,
|
|
||||||
usage: 'encipherment',
|
|
||||||
buildChain: false,
|
|
||||||
}];
|
|
||||||
form.setFieldValue('streamSettings', {
|
form.setFieldValue('streamSettings', {
|
||||||
network: 'hysteria',
|
network: 'hysteria',
|
||||||
security: 'tls',
|
security: 'tls',
|
||||||
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
|
hysteriaSettings: HysteriaStreamSettingsSchema.parse({}),
|
||||||
tlsSettings: tls,
|
tlsSettings: createHysteriaTlsSettingsWithDefaultCert(),
|
||||||
// Hysteria2 needs an obfs wrapper on the FinalMask side; seed
|
// Hysteria2 needs an obfs wrapper on the FinalMask side; seed
|
||||||
// it with salamander + a random password so the listener boots
|
// it with salamander + a random password so the listener boots
|
||||||
// with a usable default. Re-selecting Hysteria from another
|
// with a usable default. Re-selecting Hysteria from another
|
||||||
@@ -589,6 +579,8 @@ export default function InboundFormModal({
|
|||||||
{protocol === Protocols.HTTP && <HttpFields />}
|
{protocol === Protocols.HTTP && <HttpFields />}
|
||||||
{protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
|
{protocol === Protocols.MIXED && <MixedFields mixedUdpOn={mixedUdpOn} />}
|
||||||
|
|
||||||
|
{protocol === Protocols.MTPROTO && <MtprotoFields />}
|
||||||
|
|
||||||
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
|
{protocol === Protocols.SHADOWSOCKS && <ShadowsocksFields form={form} isSSWith2022={isSSWith2022} />}
|
||||||
|
|
||||||
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
{protocol === Protocols.VLESS && <VlessFields saving={saving} selectedVlessAuth={selectedVlessAuth} network={network} security={security} getNewVlessEnc={getNewVlessEnc} clearVlessEnc={clearVlessEnc} />}
|
||||||
@@ -875,6 +867,7 @@ export default function InboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ sm: { span: 8 } }}
|
labelCol={{ sm: { span: 8 } }}
|
||||||
wrapperCol={{ sm: { span: 14 } }}
|
wrapperCol={{ sm: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs items={[
|
<Tabs items={[
|
||||||
@@ -893,6 +886,7 @@ export default function InboundFormModal({
|
|||||||
Protocols.TUNNEL,
|
Protocols.TUNNEL,
|
||||||
Protocols.TUN,
|
Protocols.TUN,
|
||||||
Protocols.WIREGUARD,
|
Protocols.WIREGUARD,
|
||||||
|
Protocols.MTPROTO,
|
||||||
] as string[]).includes(protocol) || isFallbackHost
|
] as string[]).includes(protocol) || isFallbackHost
|
||||||
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
|
? [{ key: 'protocol', label: t('pages.inbounds.protocol'), children: protocolTab, forceRender: true }]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -5,4 +5,5 @@ export { default as WireguardFields } from './wireguard';
|
|||||||
export { default as HysteriaFields } from './hysteria';
|
export { default as HysteriaFields } from './hysteria';
|
||||||
export { default as HttpFields } from './http';
|
export { default as HttpFields } from './http';
|
||||||
export { default as MixedFields } from './mixed';
|
export { default as MixedFields } from './mixed';
|
||||||
|
export { default as MtprotoFields } from './mtproto';
|
||||||
export { default as VlessFields } from './vless';
|
export { default as VlessFields } from './vless';
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Alert, Button, Form, Input, Space } from 'antd';
|
||||||
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { generateMtprotoSecret, mtprotoSecretForDomain } from '@/lib/xray/inbound-defaults';
|
||||||
|
|
||||||
|
export default function MtprotoFields() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const form = Form.useFormInstance();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Form.Item name={['settings', 'fakeTlsDomain']} label={t('pages.inbounds.form.fakeTlsDomain')}>
|
||||||
|
<Input
|
||||||
|
placeholder="www.cloudflare.com"
|
||||||
|
onChange={(e) => {
|
||||||
|
const current = (form.getFieldValue(['settings', 'secret']) as string) ?? '';
|
||||||
|
form.setFieldValue(['settings', 'secret'], mtprotoSecretForDomain(current, e.target.value));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.inbounds.form.mtprotoSecret')}>
|
||||||
|
<Space.Compact block>
|
||||||
|
<Form.Item name={['settings', 'secret']} noStyle>
|
||||||
|
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
|
||||||
|
</Form.Item>
|
||||||
|
<Button
|
||||||
|
icon={<ReloadOutlined />}
|
||||||
|
onClick={() => {
|
||||||
|
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
|
||||||
|
form.setFieldValue(['settings', 'secret'], generateMtprotoSecret(domain as string));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Space.Compact>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item wrapperCol={{ span: 24 }}>
|
||||||
|
<Alert type="info" showIcon message={t('pages.inbounds.form.mtprotoHint')} />
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
|||||||
import { ReloadOutlined } from '@ant-design/icons';
|
import { ReloadOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
|
||||||
|
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
|
||||||
|
|
||||||
interface RealityFormProps {
|
interface RealityFormProps {
|
||||||
saving: boolean;
|
saving: boolean;
|
||||||
@@ -44,10 +45,24 @@ export default function RealityForm({
|
|||||||
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('pages.inbounds.form.target')}>
|
<Form.Item
|
||||||
|
label={t('pages.inbounds.form.target')}
|
||||||
|
extra={t('pages.inbounds.form.realityTargetHint')}
|
||||||
|
>
|
||||||
<Space.Compact block>
|
<Space.Compact block>
|
||||||
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
|
<Form.Item
|
||||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
name={['streamSettings', 'realitySettings', 'target']}
|
||||||
|
noStyle
|
||||||
|
rules={[
|
||||||
|
{
|
||||||
|
validator: async (_, value) => {
|
||||||
|
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
|
||||||
|
if (errKey) throw new Error(t(errKey));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
|
||||||
</Space.Compact>
|
</Space.Compact>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} />
|
<InputNumber min={0} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { MessageInstance } from 'antd/es/message/interface';
|
|||||||
|
|
||||||
import { HttpUtil, RandomUtil } from '@/utils';
|
import { HttpUtil, RandomUtil } from '@/utils';
|
||||||
import { getRandomRealityTarget } from '@/models/reality-targets';
|
import { getRandomRealityTarget } from '@/models/reality-targets';
|
||||||
import { TlsStreamSettingsSchema } from '@/schemas/protocols/security/tls';
|
import { createTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||||
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
import { RealityStreamSettingsSchema } from '@/schemas/protocols/security/reality';
|
||||||
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
|||||||
// node's own paths (fetched through the central panel), not this panel's.
|
// node's own paths (fetched through the central panel), not this panel's.
|
||||||
const msg = typeof nodeId === 'number'
|
const msg = typeof nodeId === 'number'
|
||||||
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
|
||||||
: await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
|
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
|
||||||
if (!msg?.success) {
|
if (!msg?.success) {
|
||||||
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
|
||||||
return;
|
return;
|
||||||
@@ -160,19 +160,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
|
|||||||
delete cleaned.tlsSettings;
|
delete cleaned.tlsSettings;
|
||||||
delete cleaned.realitySettings;
|
delete cleaned.realitySettings;
|
||||||
if (next === 'tls') {
|
if (next === 'tls') {
|
||||||
const tls = TlsStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
cleaned.tlsSettings = createTlsSettingsWithDefaultCert();
|
||||||
tls.certificates = [{
|
|
||||||
useFile: true,
|
|
||||||
certificateFile: '',
|
|
||||||
keyFile: '',
|
|
||||||
certificate: [],
|
|
||||||
key: [],
|
|
||||||
ocspStapling: 3600,
|
|
||||||
oneTimeLoading: false,
|
|
||||||
usage: 'encipherment',
|
|
||||||
buildChain: false,
|
|
||||||
}];
|
|
||||||
cleaned.tlsSettings = tls;
|
|
||||||
}
|
}
|
||||||
if (next === 'reality') {
|
if (next === 'reality') {
|
||||||
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
const reality = RealityStreamSettingsSchema.parse({}) as Record<string, unknown>;
|
||||||
|
|||||||
@@ -625,6 +625,35 @@ export default function InboundInfoModal({
|
|||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{inbound.protocol === Protocols.MTPROTO && inbound.settings && (
|
||||||
|
<dl className="info-list info-list-block">
|
||||||
|
<div className="info-row">
|
||||||
|
<dt>{t('pages.inbounds.form.fakeTlsDomain')}</dt>
|
||||||
|
<dd><Tag color="green" className="value-tag">{inbound.settings.fakeTlsDomain as string}</Tag></dd>
|
||||||
|
</div>
|
||||||
|
<div className="info-row">
|
||||||
|
<dt>{t('pages.inbounds.form.mtprotoSecret')}</dt>
|
||||||
|
<dd className="value-block">
|
||||||
|
<code className="value-code">{inbound.settings.secret as string}</code>
|
||||||
|
<Tooltip title={t('copy')}>
|
||||||
|
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
|
||||||
|
</Tooltip>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
{links.length > 0 && (
|
||||||
|
<div className="info-row">
|
||||||
|
<dt>{t('pages.inbounds.copyLink')}</dt>
|
||||||
|
<dd className="value-block">
|
||||||
|
<code className="value-code">{links[0].link}</code>
|
||||||
|
<Tooltip title={t('copy')}>
|
||||||
|
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
|
||||||
|
</Tooltip>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
{dbInbound.isMixed && inbound.settings && (
|
{dbInbound.isMixed && inbound.settings && (
|
||||||
<dl className="info-list info-list-block">
|
<dl className="info-list info-list-block">
|
||||||
<div className="info-row">
|
<div className="info-row">
|
||||||
|
|||||||
@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
|
|||||||
return Array.isArray(validated.obj) ? validated.obj : [];
|
return Array.isArray(validated.obj) ? validated.obj : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Online emails grouped by node id (local panel = key 0), used to scope the
|
// Online emails grouped by the panelGuid of the node that physically hosts each
|
||||||
// per-inbound online rollup so a client online on one node is not shown
|
// client, used to scope the per-inbound online rollup so a client online on one
|
||||||
// online on every node's inbounds.
|
// node is not shown online on every node's inbounds — and a client on a
|
||||||
async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
|
// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
|
||||||
const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
|
async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
|
const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
|
||||||
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
|
||||||
|
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
|
||||||
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
|
|||||||
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
|
function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
|
||||||
const map = new Map<number, Set<string>>();
|
const map = new Map<string, Set<string>>();
|
||||||
for (const [key, emails] of Object.entries(data)) {
|
for (const [key, emails] of Object.entries(data)) {
|
||||||
if (!Array.isArray(emails)) continue;
|
if (!Array.isArray(emails)) continue;
|
||||||
map.set(Number(key), new Set(emails));
|
map.set(key, new Set(emails));
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
@@ -96,7 +97,7 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
|
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
|
||||||
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
|
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
|
||||||
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
|
||||||
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
|
||||||
return validated.obj ?? {};
|
return validated.obj ?? {};
|
||||||
@@ -117,9 +118,9 @@ export function useInbounds() {
|
|||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onlinesByNodeQuery = useQuery({
|
const onlinesByGuidQuery = useQuery({
|
||||||
queryKey: keys.clients.onlinesByNode(),
|
queryKey: keys.clients.onlinesByGuid(),
|
||||||
queryFn: fetchOnlineClientsByNode,
|
queryFn: fetchOnlineClientsByGuid,
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -182,16 +183,17 @@ export function useInbounds() {
|
|||||||
const onlineClientsRef = useRef<string[]>([]);
|
const onlineClientsRef = useRef<string[]>([]);
|
||||||
onlineClientsRef.current = onlineClients;
|
onlineClientsRef.current = onlineClients;
|
||||||
|
|
||||||
// Online emails keyed by node id (local inbounds = key 0). The rollup
|
// Online emails keyed by the hosting node's panelGuid. The rollup reads this
|
||||||
// reads this so each inbound only counts clients online on its own node.
|
// so each inbound only counts clients online on the node that physically
|
||||||
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
// hosts it, attributing a sub-node's clients to that sub-node (#4983).
|
||||||
|
const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
// Recently-active inbound tags keyed by node id. A node missing from this
|
// Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
|
||||||
// map means "no per-inbound activity reported" (e.g. remote nodes), so the
|
// missing from this map means "no per-inbound activity reported" (e.g. remote
|
||||||
// rollup leaves that node's inbounds ungated and falls back to the email
|
// nodes), so the rollup leaves that node's inbounds ungated and falls back to
|
||||||
// signal. A present node gates: a client only counts online on an inbound
|
// the email signal. A present GUID gates: a client only counts online on an
|
||||||
// whose tag carried traffic this window.
|
// inbound whose tag carried traffic this window.
|
||||||
const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
|
const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
|
||||||
|
|
||||||
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
@@ -209,13 +211,17 @@ export function useInbounds() {
|
|||||||
const comments = new Map<string, string>();
|
const comments = new Map<string, string>();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
|
||||||
const nodeId = dbInbound.nodeId ?? 0;
|
// Attribution key: the GUID of the node that physically hosts this
|
||||||
const nodeOnline = onlineByNodeRef.current.get(nodeId);
|
// inbound. Local inbounds carry the panel's own GUID (filled server-side);
|
||||||
|
// a node-managed inbound carries its origin node's GUID, or falls back to
|
||||||
|
// the master-local synthetic id for an old-build node without one (#4983).
|
||||||
|
const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
|
||||||
|
const nodeOnline = onlineByGuidRef.current.get(guid);
|
||||||
// A node absent from the active map reports no per-inbound activity, so
|
// A node absent from the active map reports no per-inbound activity, so
|
||||||
// leave its inbounds ungated. When present, only mark a client online on
|
// leave its inbounds ungated. When present, only mark a client online on
|
||||||
// this inbound if its tag actually carried traffic — that's what stops a
|
// this inbound if its tag actually carried traffic — that's what stops a
|
||||||
// multi-inbound client lighting up every inbound it's attached to.
|
// multi-inbound client lighting up every inbound it's attached to.
|
||||||
const activeForNode = activeByNodeRef.current.get(nodeId);
|
const activeForNode = activeByGuidRef.current.get(guid);
|
||||||
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
|
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
|
||||||
|
|
||||||
if (dbInbound.enable) {
|
if (dbInbound.enable) {
|
||||||
@@ -305,15 +311,15 @@ export function useInbounds() {
|
|||||||
}, [onlinesQuery.data]);
|
}, [onlinesQuery.data]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (onlinesByNodeQuery.data) {
|
if (onlinesByGuidQuery.data) {
|
||||||
onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
|
onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
}, [onlinesByNodeQuery.data, rebuildClientCount]);
|
}, [onlinesByGuidQuery.data, rebuildClientCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeInboundsQuery.data) {
|
if (activeInboundsQuery.data) {
|
||||||
activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
|
activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
|
||||||
rebuildClientCount();
|
rebuildClientCount();
|
||||||
}
|
}
|
||||||
}, [activeInboundsQuery.data, rebuildClientCount]);
|
}, [activeInboundsQuery.data, rebuildClientCount]);
|
||||||
@@ -336,7 +342,7 @@ export function useInbounds() {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
|
||||||
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
|
||||||
@@ -367,16 +373,16 @@ export function useInbounds() {
|
|||||||
const applyTrafficEvent = useCallback(
|
const applyTrafficEvent = useCallback(
|
||||||
(payload: unknown) => {
|
(payload: unknown) => {
|
||||||
if (!payload || typeof payload !== 'object') return;
|
if (!payload || typeof payload !== 'object') return;
|
||||||
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
|
||||||
if (Array.isArray(p.onlineClients)) {
|
if (Array.isArray(p.onlineClients)) {
|
||||||
onlineClientsRef.current = p.onlineClients;
|
onlineClientsRef.current = p.onlineClients;
|
||||||
setOnlineClients(p.onlineClients);
|
setOnlineClients(p.onlineClients);
|
||||||
}
|
}
|
||||||
if (p.onlineByNode && typeof p.onlineByNode === 'object') {
|
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
|
||||||
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
|
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
|
||||||
}
|
}
|
||||||
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
|
||||||
activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
|
activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
|
||||||
}
|
}
|
||||||
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
|
||||||
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
|
|||||||
}
|
}
|
||||||
|
|
||||||
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
|
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
|
||||||
const restart = await HttpUtil.post('/panel/setting/restartPanel');
|
const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
|
||||||
if (restart?.success) {
|
if (restart?.success) {
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function IndexPage() {
|
|||||||
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
const [loadingTip, setLoadingTip] = useState(t('loading'));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
|
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
|
||||||
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
|
||||||
});
|
});
|
||||||
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
import type { BadgeProps } from 'antd';
|
import type { BadgeProps } from 'antd';
|
||||||
import type { ColumnsType } from 'antd/es/table';
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
import {
|
import {
|
||||||
|
ApartmentOutlined,
|
||||||
ClusterOutlined,
|
ClusterOutlined,
|
||||||
CloudDownloadOutlined,
|
CloudDownloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
|
|||||||
|
|
||||||
interface NodeRow extends NodeRecord {
|
interface NodeRow extends NodeRecord {
|
||||||
url: string;
|
url: string;
|
||||||
key: number;
|
key: string | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function badgeStatus(status?: string): BadgeProps['status'] {
|
function badgeStatus(status?: string): BadgeProps['status'] {
|
||||||
@@ -131,14 +132,49 @@ export default function NodeList({
|
|||||||
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
|
||||||
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
|
||||||
|
|
||||||
const dataSource = useMemo<NodeRow[]>(
|
// Map a node GUID to its display name so a transitive sub-node can show which
|
||||||
() => nodes.map((n) => ({
|
// parent it is reached through (#4983).
|
||||||
|
const nameByGuid = useMemo(() => {
|
||||||
|
const m = new Map<string, string>();
|
||||||
|
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
|
||||||
|
return m;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
|
// Order direct nodes first, each immediately followed by its transitive
|
||||||
|
// sub-nodes, so the table reads as a parent -> child tree without colliding
|
||||||
|
// with the per-row history expander (transitive nodes carry id 0).
|
||||||
|
const dataSource = useMemo<NodeRow[]>(() => {
|
||||||
|
const toRow = (n: NodeRecord): NodeRow => ({
|
||||||
...n,
|
...n,
|
||||||
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
|
||||||
key: n.id,
|
key: n.transitive ? `t-${n.guid || ''}` : n.id,
|
||||||
})),
|
});
|
||||||
[nodes],
|
const childrenByParent = new Map<string, NodeRecord[]>();
|
||||||
);
|
for (const n of nodes) {
|
||||||
|
if (n.transitive && n.parentGuid) {
|
||||||
|
const arr = childrenByParent.get(n.parentGuid) || [];
|
||||||
|
arr.push(n);
|
||||||
|
childrenByParent.set(n.parentGuid, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const ordered: NodeRow[] = [];
|
||||||
|
const added = new Set<string>();
|
||||||
|
const push = (n: NodeRecord) => {
|
||||||
|
const row = toRow(n);
|
||||||
|
ordered.push(row);
|
||||||
|
added.add(String(row.key));
|
||||||
|
};
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.transitive) continue;
|
||||||
|
push(n);
|
||||||
|
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
|
||||||
|
}
|
||||||
|
// Transitive nodes whose parent isn't in the list still get shown.
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
|
||||||
|
}
|
||||||
|
return ordered;
|
||||||
|
}, [nodes]);
|
||||||
|
|
||||||
function toggleExpanded(id: number) {
|
function toggleExpanded(id: number) {
|
||||||
setExpandedIds((prev) => {
|
setExpandedIds((prev) => {
|
||||||
@@ -153,7 +189,11 @@ export default function NodeList({
|
|||||||
title: t('pages.nodes.actions'),
|
title: t('pages.nodes.actions'),
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 190,
|
width: 190,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => record.transitive ? (
|
||||||
|
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
|
||||||
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
<Space>
|
<Space>
|
||||||
<Tooltip title={t('pages.nodes.probe')}>
|
<Tooltip title={t('pages.nodes.probe')}>
|
||||||
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
|
||||||
@@ -177,7 +217,9 @@ export default function NodeList({
|
|||||||
dataIndex: 'enable',
|
dataIndex: 'enable',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
width: 80,
|
width: 80,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => record.transitive ? (
|
||||||
|
<span style={{ opacity: 0.4 }}>—</span>
|
||||||
|
) : (
|
||||||
<Switch
|
<Switch
|
||||||
checked={!!record.enable}
|
checked={!!record.enable}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -190,8 +232,11 @@ export default function NodeList({
|
|||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
ellipsis: true,
|
ellipsis: true,
|
||||||
render: (_value, record) => (
|
render: (_value, record) => (
|
||||||
<div className="name-cell">
|
<div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
|
||||||
<span className="name">{record.name}</span>
|
<span className="name">
|
||||||
|
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
|
||||||
|
{record.name}
|
||||||
|
</span>
|
||||||
{record.remark && <span className="remark">{record.remark}</span>}
|
{record.remark && <span className="remark">{record.remark}</span>}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -316,7 +361,7 @@ export default function NodeList({
|
|||||||
width: 120,
|
width: 120,
|
||||||
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
render: (_value, record) => relativeTime(record.lastHeartbeat),
|
||||||
},
|
},
|
||||||
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
|
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size="small" hoverable>
|
<Card size="small" hoverable>
|
||||||
@@ -340,7 +385,18 @@ export default function NodeList({
|
|||||||
<div>{t('noData')}</div>
|
<div>{t('noData')}</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
dataSource.map((record) => (
|
dataSource.map((record) => record.transitive ? (
|
||||||
|
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
|
||||||
|
<div className="card-head">
|
||||||
|
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||||
|
<StatusDot status={record.status} />
|
||||||
|
<span className="node-name">{record.name}</span>
|
||||||
|
<div className="card-actions">
|
||||||
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div key={record.id} className="node-card">
|
<div key={record.id} className="node-card">
|
||||||
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
|
||||||
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
|
||||||
@@ -501,8 +557,8 @@ export default function NodeList({
|
|||||||
rowKey="id"
|
rowKey="id"
|
||||||
rowSelection={dataSource.length > 1 ? {
|
rowSelection={dataSource.length > 1 ? {
|
||||||
selectedRowKeys: selectedIds,
|
selectedRowKeys: selectedIds,
|
||||||
onChange: (keys) => onSelectionChange(keys as number[]),
|
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
|
||||||
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
|
getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
|
||||||
} : undefined}
|
} : undefined}
|
||||||
locale={{
|
locale={{
|
||||||
emptyText: (
|
emptyText: (
|
||||||
@@ -514,6 +570,7 @@ export default function NodeList({
|
|||||||
}}
|
}}
|
||||||
expandable={{
|
expandable={{
|
||||||
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
|
||||||
|
rowExpandable: (record) => !record.transitive,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
const sendUpdateUser = useCallback(async () => {
|
const sendUpdateUser = useCallback(async () => {
|
||||||
setUpdating(true);
|
setUpdating(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
|
const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
await HttpUtil.post('/logout');
|
await HttpUtil.post('/logout');
|
||||||
const basePath = window.X_UI_BASE_PATH || '/';
|
const basePath = window.X_UI_BASE_PATH || '/';
|
||||||
@@ -124,7 +124,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
const loadApiTokens = useCallback(async () => {
|
const loadApiTokens = useCallback(async () => {
|
||||||
setApiTokensLoading(true);
|
setApiTokensLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
|
const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
|
||||||
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
|
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
|
||||||
} finally {
|
} finally {
|
||||||
setApiTokensLoading(false);
|
setApiTokensLoading(false);
|
||||||
@@ -156,7 +156,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
}
|
}
|
||||||
setCreating(true);
|
setCreating(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
|
const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
await loadApiTokens();
|
await loadApiTokens();
|
||||||
@@ -178,7 +178,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
|
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
|
||||||
if (msg?.success) await loadApiTokens();
|
if (msg?.success) await loadApiTokens();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -186,7 +186,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
|
|||||||
|
|
||||||
async function toggleTokenEnabled(row: ApiTokenRow) {
|
async function toggleTokenEnabled(row: ApiTokenRow) {
|
||||||
const target = !row.enabled;
|
const target = !row.enabled;
|
||||||
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
|
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
|
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export default function SettingsPage() {
|
|||||||
onOk: async () => {
|
onOk: async () => {
|
||||||
setSpinning(true);
|
setSpinning(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg;
|
const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
|
||||||
if (!msg?.success) return;
|
if (!msg?.success) return;
|
||||||
await PromiseUtil.sleep(5000);
|
await PromiseUtil.sleep(5000);
|
||||||
window.location.replace(rebuildUrlAfterRestart());
|
window.location.replace(rebuildUrlAfterRestart());
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ export default function SubPage() {
|
|||||||
if (!subUrl) return '';
|
if (!subUrl) return '';
|
||||||
const separator = subUrl.includes('?') ? '&' : '?';
|
const separator = subUrl.includes('?') ? '&' : '?';
|
||||||
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
const rawUrl = subUrl + separator + 'flag=shadowrocket';
|
||||||
const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
const base64Url = btoa(rawUrl);
|
||||||
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
|
||||||
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
|
||||||
}, []);
|
}, []);
|
||||||
|
|||||||
@@ -60,13 +60,17 @@ export default function XrayPage() {
|
|||||||
setOutboundTestUrl,
|
setOutboundTestUrl,
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
|
subscriptionOutbounds,
|
||||||
|
subscriptionOutboundTags,
|
||||||
restartResult,
|
restartResult,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
subscriptionTestStates,
|
||||||
testingAll,
|
testingAll,
|
||||||
fetchAll,
|
fetchAll,
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
|
testSubscriptionOutbound,
|
||||||
testAllOutbounds,
|
testAllOutbounds,
|
||||||
saveAll,
|
saveAll,
|
||||||
resetToDefault,
|
resetToDefault,
|
||||||
@@ -99,6 +103,11 @@ export default function XrayPage() {
|
|||||||
if (outbound) await testOutbound(idx, outbound, mode);
|
if (outbound) await testOutbound(idx, outbound, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onTestSubscription(outbound: Record<string, unknown>, mode: string) {
|
||||||
|
const tag = typeof outbound?.tag === 'string' ? outbound.tag : '';
|
||||||
|
if (tag) await testSubscriptionOutbound(tag, outbound, mode);
|
||||||
|
}
|
||||||
|
|
||||||
function onAddOutbound(outbound: Record<string, unknown>) {
|
function onAddOutbound(outbound: Record<string, unknown>) {
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
||||||
@@ -214,6 +223,7 @@ export default function XrayPage() {
|
|||||||
setTemplateSettings={setTemplateSettings}
|
setTemplateSettings={setTemplateSettings}
|
||||||
inboundTags={inboundTags}
|
inboundTags={inboundTags}
|
||||||
clientReverseTags={clientReverseTags}
|
clientReverseTags={clientReverseTags}
|
||||||
|
subscriptionOutboundTags={subscriptionOutboundTags}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -224,14 +234,18 @@ export default function XrayPage() {
|
|||||||
setTemplateSettings={setTemplateSettings}
|
setTemplateSettings={setTemplateSettings}
|
||||||
outboundsTraffic={outboundsTraffic}
|
outboundsTraffic={outboundsTraffic}
|
||||||
outboundTestStates={outboundTestStates}
|
outboundTestStates={outboundTestStates}
|
||||||
|
subscriptionTestStates={subscriptionTestStates}
|
||||||
testingAll={testingAll}
|
testingAll={testingAll}
|
||||||
inboundTags={inboundTags}
|
inboundTags={inboundTags}
|
||||||
|
subscriptionOutbounds={subscriptionOutbounds}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
onResetTraffic={resetOutboundsTraffic}
|
onResetTraffic={resetOutboundsTraffic}
|
||||||
onTest={onTestOutbound}
|
onTest={onTestOutbound}
|
||||||
|
onTestSubscription={onTestSubscription}
|
||||||
onTestAll={testAllOutbounds}
|
onTestAll={testAllOutbounds}
|
||||||
onShowWarp={() => setWarpOpen(true)}
|
onShowWarp={() => setWarpOpen(true)}
|
||||||
onShowNord={() => setNordOpen(true)}
|
onShowNord={() => setNordOpen(true)}
|
||||||
|
onRefreshXrayData={fetchAll}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'balancer':
|
case 'balancer':
|
||||||
@@ -240,6 +254,7 @@ export default function XrayPage() {
|
|||||||
templateSettings={templateSettings}
|
templateSettings={templateSettings}
|
||||||
setTemplateSettings={setTemplateSettings}
|
setTemplateSettings={setTemplateSettings}
|
||||||
clientReverseTags={clientReverseTags}
|
clientReverseTags={clientReverseTags}
|
||||||
|
subscriptionOutboundTags={subscriptionOutboundTags}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ interface BalancersTabProps {
|
|||||||
templateSettings: XraySettingsValue | null;
|
templateSettings: XraySettingsValue | null;
|
||||||
setTemplateSettings: SetTemplate;
|
setTemplateSettings: SetTemplate;
|
||||||
clientReverseTags: string[];
|
clientReverseTags: string[];
|
||||||
|
subscriptionOutboundTags?: string[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +91,7 @@ export default function BalancersTab({
|
|||||||
templateSettings,
|
templateSettings,
|
||||||
setTemplateSettings,
|
setTemplateSettings,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
|
subscriptionOutboundTags,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: BalancersTabProps) {
|
}: BalancersTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -118,8 +120,11 @@ export default function BalancersTab({
|
|||||||
for (const tag of clientReverseTags || []) {
|
for (const tag of clientReverseTags || []) {
|
||||||
if (tag) tags.add(tag);
|
if (tag) tags.add(tag);
|
||||||
}
|
}
|
||||||
|
for (const tag of subscriptionOutboundTags || []) {
|
||||||
|
if (tag) tags.add(tag);
|
||||||
|
}
|
||||||
return [...tags];
|
return [...tags];
|
||||||
}, [templateSettings?.outbounds, clientReverseTags]);
|
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
|
||||||
|
|
||||||
const otherTags = useMemo(() => {
|
const otherTags = useMemo(() => {
|
||||||
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
|
if (editingIndex == null) return rows.map((b) => b.tag).filter(Boolean);
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
import { OutboundDomainStrategies } from '@/schemas/primitives';
|
||||||
|
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
|
||||||
import { SettingListItem } from '@/components/ui';
|
import { SettingListItem } from '@/components/ui';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
import { catTabLabel } from '@/pages/settings/catTabLabel';
|
||||||
@@ -84,6 +85,49 @@ export default function BasicsTab({
|
|||||||
| { domainStrategy?: string }
|
| { domainStrategy?: string }
|
||||||
| undefined)?.domainStrategy ?? 'AsIs';
|
| undefined)?.domainStrategy ?? 'AsIs';
|
||||||
|
|
||||||
|
const directFreedomOutbound = templateSettings?.outbounds?.find(
|
||||||
|
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
|
||||||
|
);
|
||||||
|
const directHappyEyeballs = (() => {
|
||||||
|
const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
|
||||||
|
?.sockopt;
|
||||||
|
const raw = sockopt?.happyEyeballs;
|
||||||
|
if (raw == null || typeof raw !== 'object') return null;
|
||||||
|
return HappyEyeballsSchema.parse(raw);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const setDirectHappyEyeballs = useCallback(
|
||||||
|
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
|
||||||
|
mutate((tt) => {
|
||||||
|
if (!tt.outbounds) tt.outbounds = [];
|
||||||
|
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
|
||||||
|
if (idx < 0) {
|
||||||
|
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
|
||||||
|
idx = tt.outbounds.length - 1;
|
||||||
|
}
|
||||||
|
const ob = tt.outbounds[idx];
|
||||||
|
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
|
||||||
|
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
|
||||||
|
if (next == null) {
|
||||||
|
delete sockopt.happyEyeballs;
|
||||||
|
} else {
|
||||||
|
sockopt.happyEyeballs = next;
|
||||||
|
}
|
||||||
|
if (Object.keys(sockopt).length === 0) {
|
||||||
|
delete stream.sockopt;
|
||||||
|
} else {
|
||||||
|
stream.sockopt = sockopt;
|
||||||
|
}
|
||||||
|
if (Object.keys(stream).length === 0) {
|
||||||
|
delete ob.streamSettings;
|
||||||
|
} else {
|
||||||
|
ob.streamSettings = stream;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[mutate],
|
||||||
|
);
|
||||||
|
|
||||||
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
|
||||||
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
const log = (templateSettings?.log || {}) as Record<string, unknown>;
|
||||||
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
|
||||||
@@ -124,6 +168,53 @@ export default function BasicsTab({
|
|||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.xray.FreedomHappyEyeballs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs != null}
|
||||||
|
onChange={(checked) => {
|
||||||
|
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{directHappyEyeballs != null && (
|
||||||
|
<>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.tryDelayMs')}
|
||||||
|
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={directHappyEyeballs.tryDelayMs}
|
||||||
|
placeholder="150"
|
||||||
|
onChange={(v) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
tryDelayMs: typeof v === 'number' ? v : 0,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingListItem
|
||||||
|
title={t('pages.inbounds.form.prioritizeIPv6')}
|
||||||
|
paddings="small"
|
||||||
|
control={
|
||||||
|
<Switch
|
||||||
|
checked={directHappyEyeballs.prioritizeIPv6}
|
||||||
|
onChange={(checked) => setDirectHappyEyeballs({
|
||||||
|
...directHappyEyeballs,
|
||||||
|
prioritizeIPv6: checked,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<SettingListItem
|
<SettingListItem
|
||||||
title={t('pages.xray.RoutingStrategy')}
|
title={t('pages.xray.RoutingStrategy')}
|
||||||
description={t('pages.xray.RoutingStrategyDesc')}
|
description={t('pages.xray.RoutingStrategyDesc')}
|
||||||
|
|||||||
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
|
|||||||
colon={false}
|
colon={false}
|
||||||
labelCol={{ md: { span: 8 } }}
|
labelCol={{ md: { span: 8 } }}
|
||||||
wrapperCol={{ md: { span: 14 } }}
|
wrapperCol={{ md: { span: 14 } }}
|
||||||
|
labelWrap
|
||||||
onValuesChange={onValuesChange}
|
onValuesChange={onValuesChange}
|
||||||
>
|
>
|
||||||
<Tabs
|
<Tabs
|
||||||
|
|||||||
@@ -209,3 +209,17 @@
|
|||||||
.outbound-test-popover .dot-fail {
|
.outbound-test-popover .dot-fail {
|
||||||
color: #e04141;
|
color: #e04141;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.subscription-outbounds-head {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-outbounds-title {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subscription-outbounds-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,22 +3,40 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Col,
|
Col,
|
||||||
|
Dropdown,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
Radio,
|
Radio,
|
||||||
Row,
|
Row,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
|
Tag,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
CloudOutlined,
|
CloudOutlined,
|
||||||
ApiOutlined,
|
ApiOutlined,
|
||||||
|
MoreOutlined,
|
||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
PlayCircleOutlined,
|
PlayCircleOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
ArrowUpOutlined,
|
||||||
|
ArrowDownOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
WarningOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
import OutboundFormModal from './OutboundFormModal';
|
import OutboundFormModal from './OutboundFormModal';
|
||||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
import './OutboundsTab.css';
|
import './OutboundsTab.css';
|
||||||
@@ -26,20 +44,40 @@ import './OutboundsTab.css';
|
|||||||
import type { OutboundRow } from './outbounds-tab-types';
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
import { useOutboundColumns } from './useOutboundColumns';
|
import { useOutboundColumns } from './useOutboundColumns';
|
||||||
import OutboundCardList from './OutboundCardList';
|
import OutboundCardList from './OutboundCardList';
|
||||||
|
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
||||||
|
|
||||||
|
interface OutboundSub {
|
||||||
|
id: number;
|
||||||
|
remark?: string;
|
||||||
|
url?: string;
|
||||||
|
enabled?: boolean;
|
||||||
|
allowPrivate?: boolean;
|
||||||
|
prepend?: boolean;
|
||||||
|
priority?: number;
|
||||||
|
tagPrefix?: string;
|
||||||
|
updateInterval?: number;
|
||||||
|
lastUpdated?: number;
|
||||||
|
lastError?: string;
|
||||||
|
outboundCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface OutboundsTabProps {
|
interface OutboundsTabProps {
|
||||||
templateSettings: XraySettingsValue | null;
|
templateSettings: XraySettingsValue | null;
|
||||||
setTemplateSettings: SetTemplate;
|
setTemplateSettings: SetTemplate;
|
||||||
outboundsTraffic: OutboundTrafficRow[];
|
outboundsTraffic: OutboundTrafficRow[];
|
||||||
outboundTestStates: Record<number, OutboundTestState>;
|
outboundTestStates: Record<number, OutboundTestState>;
|
||||||
|
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||||
testingAll: boolean;
|
testingAll: boolean;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
|
subscriptionOutbounds?: unknown[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
onResetTraffic: (tag: string) => void;
|
onResetTraffic: (tag: string) => void;
|
||||||
onTest: (index: number, mode: string) => void;
|
onTest: (index: number, mode: string) => void;
|
||||||
|
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||||
onTestAll: (mode: string) => void;
|
onTestAll: (mode: string) => void;
|
||||||
onShowWarp: () => void;
|
onShowWarp: () => void;
|
||||||
onShowNord: () => void;
|
onShowNord: () => void;
|
||||||
|
onRefreshXrayData?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OutboundsTab({
|
export default function OutboundsTab({
|
||||||
@@ -47,23 +85,49 @@ export default function OutboundsTab({
|
|||||||
setTemplateSettings,
|
setTemplateSettings,
|
||||||
outboundsTraffic,
|
outboundsTraffic,
|
||||||
outboundTestStates,
|
outboundTestStates,
|
||||||
|
subscriptionTestStates,
|
||||||
testingAll,
|
testingAll,
|
||||||
inboundTags: _inboundTags,
|
inboundTags: _inboundTags,
|
||||||
|
subscriptionOutbounds,
|
||||||
isMobile,
|
isMobile,
|
||||||
onResetTraffic,
|
onResetTraffic,
|
||||||
onTest,
|
onTest,
|
||||||
|
onTestSubscription,
|
||||||
onTestAll,
|
onTestAll,
|
||||||
onShowWarp,
|
onShowWarp,
|
||||||
onShowNord,
|
onShowNord,
|
||||||
|
onRefreshXrayData,
|
||||||
}: OutboundsTabProps) {
|
}: OutboundsTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [modal, modalContextHolder] = Modal.useModal();
|
const [modal, modalContextHolder] = Modal.useModal();
|
||||||
|
const [messageApi, messageContextHolder] = message.useMessage();
|
||||||
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
|
const [testMode, setTestMode] = useState<'tcp' | 'http'>('tcp');
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
||||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||||
const [existingTags, setExistingTags] = useState<string[]>([]);
|
const [existingTags, setExistingTags] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// Subscription manager (CRUD + reorder + refresh + preview)
|
||||||
|
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
||||||
|
const [subs, setSubs] = useState<OutboundSub[]>([]);
|
||||||
|
const [subsLoading, setSubsLoading] = useState(false);
|
||||||
|
const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
|
||||||
|
const [editingSubId, setEditingSubId] = useState<number | null>(null);
|
||||||
|
const [savingSub, setSavingSub] = useState(false);
|
||||||
|
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
||||||
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
||||||
|
const [busyId, setBusyId] = useState<number | null>(null);
|
||||||
|
const [previewing, setPreviewing] = useState(false);
|
||||||
|
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
|
||||||
|
|
||||||
|
// Convenience: expose hours/minutes for the interval input
|
||||||
|
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
|
||||||
|
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
|
||||||
|
function setIntervalHM(h: number, m: number) {
|
||||||
|
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
|
||||||
|
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
|
||||||
|
}
|
||||||
|
|
||||||
const outbounds = useMemo(
|
const outbounds = useMemo(
|
||||||
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
|
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
|
||||||
[templateSettings?.outbounds],
|
[templateSettings?.outbounds],
|
||||||
@@ -89,6 +153,11 @@ export default function OutboundsTab({
|
|||||||
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
|
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSubManager() {
|
||||||
|
setSubDrawerOpen(true);
|
||||||
|
loadSubs();
|
||||||
|
}
|
||||||
function openEdit(idx: number) {
|
function openEdit(idx: number) {
|
||||||
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
|
setEditingOutbound((templateSettings?.outbounds || [])[idx] as Record<string, unknown>);
|
||||||
setEditingIndex(idx);
|
setEditingIndex(idx);
|
||||||
@@ -147,6 +216,169 @@ export default function OutboundsTab({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Subscription management (minimal inline UI) ---
|
||||||
|
async function loadSubs() {
|
||||||
|
setSubsLoading(true);
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
|
||||||
|
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
|
||||||
|
} finally {
|
||||||
|
setSubsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
|
||||||
|
return {
|
||||||
|
remark: src.remark ?? '',
|
||||||
|
url: src.url ?? '',
|
||||||
|
tagPrefix: src.tagPrefix ?? '',
|
||||||
|
updateInterval: src.updateInterval ?? 600,
|
||||||
|
enabled: src.enabled ?? true,
|
||||||
|
allowPrivate: src.allowPrivate ?? false,
|
||||||
|
prepend: src.prepend ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function resetSubForm() {
|
||||||
|
setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
|
||||||
|
setEditingSubId(null);
|
||||||
|
setPreviewData(null);
|
||||||
|
}
|
||||||
|
function openEditSub(sub: OutboundSub) {
|
||||||
|
setNewSub({
|
||||||
|
remark: sub.remark ?? '',
|
||||||
|
url: sub.url ?? '',
|
||||||
|
tagPrefix: sub.tagPrefix ?? '',
|
||||||
|
updateInterval: sub.updateInterval ?? 600,
|
||||||
|
enabled: sub.enabled ?? true,
|
||||||
|
allowPrivate: sub.allowPrivate ?? false,
|
||||||
|
prepend: sub.prepend ?? false,
|
||||||
|
});
|
||||||
|
setEditingSubId(sub.id);
|
||||||
|
setPreviewData(null);
|
||||||
|
}
|
||||||
|
async function saveSub() {
|
||||||
|
if (!newSub.url.trim()) {
|
||||||
|
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSavingSub(true);
|
||||||
|
try {
|
||||||
|
const url = editingSubId != null
|
||||||
|
? `/panel/api/xray/outbound-subs/${editingSubId}`
|
||||||
|
: '/panel/api/xray/outbound-subs';
|
||||||
|
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
|
||||||
|
if (r?.success) {
|
||||||
|
messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
|
||||||
|
const createdId = editingSubId == null ? r.obj?.id : undefined;
|
||||||
|
resetSubForm();
|
||||||
|
await loadSubs();
|
||||||
|
if (createdId) await refreshOne(createdId);
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
} else {
|
||||||
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
||||||
|
} finally {
|
||||||
|
setSavingSub(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function previewSub() {
|
||||||
|
if (!newSub.url.trim()) {
|
||||||
|
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPreviewing(true);
|
||||||
|
setPreviewData(null);
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
|
||||||
|
if (r?.success && Array.isArray(r.obj)) {
|
||||||
|
setPreviewData(r.obj);
|
||||||
|
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
|
||||||
|
} else {
|
||||||
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
|
||||||
|
} finally {
|
||||||
|
setPreviewing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function toggleEnabled(sub: OutboundSub) {
|
||||||
|
setBusyId(sub.id);
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
|
||||||
|
if (r?.success) {
|
||||||
|
await loadSubs();
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
} else {
|
||||||
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function moveSub(id: number, dir: 'up' | 'down') {
|
||||||
|
setBusyId(id);
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
|
||||||
|
if (r?.success) {
|
||||||
|
await loadSubs();
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
} finally {
|
||||||
|
setBusyId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshOne(id: number) {
|
||||||
|
setRefreshingId(id);
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
|
||||||
|
if (r?.success) {
|
||||||
|
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
||||||
|
await loadSubs();
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
} else {
|
||||||
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
|
||||||
|
} finally {
|
||||||
|
setRefreshingId(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function refreshAllSubs() {
|
||||||
|
if (subs.length === 0) return;
|
||||||
|
setRefreshingAll(true);
|
||||||
|
try {
|
||||||
|
for (const s of subs) {
|
||||||
|
try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
|
||||||
|
}
|
||||||
|
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
||||||
|
await loadSubs();
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
} finally {
|
||||||
|
setRefreshingAll(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function deleteOne(id: number) {
|
||||||
|
try {
|
||||||
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
|
||||||
|
if (r?.success) {
|
||||||
|
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
|
||||||
|
await loadSubs();
|
||||||
|
onRefreshXrayData?.();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const columns = useOutboundColumns({
|
const columns = useOutboundColumns({
|
||||||
testMode,
|
testMode,
|
||||||
rows,
|
rows,
|
||||||
@@ -164,6 +396,7 @@ export default function OutboundsTab({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{modalContextHolder}
|
{modalContextHolder}
|
||||||
|
{messageContextHolder}
|
||||||
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
<Row gutter={[12, 12]} align="middle" justify="space-between">
|
<Row gutter={[12, 12]} align="middle" justify="space-between">
|
||||||
<Col xs={24} sm={12}>
|
<Col xs={24} sm={12}>
|
||||||
@@ -171,12 +404,20 @@ export default function OutboundsTab({
|
|||||||
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
||||||
{!isMobile && t('pages.xray.Outbounds')}
|
{!isMobile && t('pages.xray.Outbounds')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" icon={<CloudOutlined />} onClick={onShowWarp}>
|
<Button icon={<CloudOutlined />} onClick={openSubManager}>
|
||||||
WARP
|
{t('pages.xray.outboundSub.manage')}
|
||||||
</Button>
|
|
||||||
<Button type="primary" icon={<ApiOutlined />} onClick={onShowNord}>
|
|
||||||
NordVPN
|
|
||||||
</Button>
|
</Button>
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
||||||
|
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button icon={<MoreOutlined />}>{t('more')}</Button>
|
||||||
|
</Dropdown>
|
||||||
</Space>
|
</Space>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} className="toolbar-right">
|
<Col xs={24} sm={12} className="toolbar-right">
|
||||||
@@ -232,7 +473,182 @@ export default function OutboundsTab({
|
|||||||
onClose={() => setModalOpen(false)}
|
onClose={() => setModalOpen(false)}
|
||||||
onConfirm={onConfirm}
|
onConfirm={onConfirm}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Subscription outbounds (read-only, merged at runtime) */}
|
||||||
|
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
|
||||||
|
<SubscriptionOutbounds
|
||||||
|
subscriptionOutbounds={subscriptionOutbounds}
|
||||||
|
outboundsTraffic={outboundsTraffic}
|
||||||
|
subscriptionTestStates={subscriptionTestStates}
|
||||||
|
testMode={testMode}
|
||||||
|
isMobile={isMobile}
|
||||||
|
onTestSubscription={onTestSubscription}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={t('pages.xray.outboundSub.title')}
|
||||||
|
open={subDrawerOpen}
|
||||||
|
onCancel={() => setSubDrawerOpen(false)}
|
||||||
|
footer={null}
|
||||||
|
width={isMobile ? '100%' : 640}
|
||||||
|
destroyOnHidden
|
||||||
|
>
|
||||||
|
<Space orientation="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
<div>
|
||||||
|
{editingSubId != null && (
|
||||||
|
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
<Tag color="blue">{t('edit')}</Tag>
|
||||||
|
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Form layout="vertical" size="small">
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.remark')}>
|
||||||
|
<Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.url')} required>
|
||||||
|
<Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
|
||||||
|
<Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.interval')}>
|
||||||
|
<Space>
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
value={intervalHours}
|
||||||
|
onChange={(v) => setIntervalHM(Number(v) || 0, intervalMinutes)}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/> {t('pages.xray.outboundSub.hours')}
|
||||||
|
<InputNumber
|
||||||
|
min={0}
|
||||||
|
max={59}
|
||||||
|
value={intervalMinutes}
|
||||||
|
onChange={(v) => setIntervalHM(intervalHours, Number(v) || 0)}
|
||||||
|
style={{ width: 80 }}
|
||||||
|
/> {t('pages.xray.outboundSub.minutes')}
|
||||||
|
</Space>
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||||
|
{t('pages.xray.outboundSub.intervalHint')}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
|
||||||
|
<Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
|
||||||
|
<Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||||
|
{t('pages.xray.outboundSub.allowPrivateHint')}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
|
||||||
|
<Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
||||||
|
{t('pages.xray.outboundSub.prependHint')}
|
||||||
|
</div>
|
||||||
|
</Form.Item>
|
||||||
|
<Space wrap>
|
||||||
|
<Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
|
||||||
|
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
|
||||||
|
{t('pages.xray.outboundSub.preview')}
|
||||||
|
</Button>
|
||||||
|
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
|
||||||
|
</Space>
|
||||||
|
{previewData && previewData.length > 0 && (
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
|
||||||
|
{previewData.map((o, i) => (
|
||||||
|
<Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||||
|
{t('pages.xray.outboundSub.active')}
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
|
||||||
|
{subs.length > 0 && (
|
||||||
|
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
|
||||||
|
{t('pages.xray.outboundSub.refreshAll')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{subs.length === 0 ? (
|
||||||
|
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
size="small"
|
||||||
|
dataSource={subs}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ x: true }}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'order',
|
||||||
|
width: 56,
|
||||||
|
render: (_: unknown, r: OutboundSub, index: number) => (
|
||||||
|
<Space size={0}>
|
||||||
|
<Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
|
||||||
|
<Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.xray.outboundSub.colRemark'),
|
||||||
|
key: 'remark',
|
||||||
|
render: (_: unknown, r: OutboundSub) => (
|
||||||
|
<div>
|
||||||
|
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
|
||||||
|
{r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{ title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
|
||||||
|
{
|
||||||
|
title: t('status'),
|
||||||
|
key: 'status',
|
||||||
|
align: 'center',
|
||||||
|
render: (_: unknown, r: OutboundSub) => (r.lastError
|
||||||
|
? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
|
||||||
|
: <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
|
||||||
|
},
|
||||||
|
{ title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
|
||||||
|
{
|
||||||
|
title: t('pages.xray.outboundSub.colEnabled'),
|
||||||
|
key: 'enabled',
|
||||||
|
align: 'center',
|
||||||
|
render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '',
|
||||||
|
key: 'actions',
|
||||||
|
render: (_: unknown, r: OutboundSub) => (
|
||||||
|
<Space>
|
||||||
|
<Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
|
||||||
|
<Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
|
||||||
|
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
|
||||||
|
<Button size="small" danger icon={<DeleteOutlined />} />
|
||||||
|
</Popconfirm>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
||||||
|
{t('pages.xray.outboundSub.restartHint')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Button, Popover, Table, Tag, Tooltip } from 'antd';
|
||||||
|
import {
|
||||||
|
ThunderboltOutlined,
|
||||||
|
CheckCircleFilled,
|
||||||
|
CloseCircleFilled,
|
||||||
|
LoadingOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
import { SizeFormatter } from '@/utils';
|
||||||
|
import { OutboundProtocols as Protocols } from '@/schemas/primitives';
|
||||||
|
import { isUdpOutbound } from '@/hooks/useXraySetting';
|
||||||
|
import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
|
|
||||||
|
import type { OutboundRow } from './outbounds-tab-types';
|
||||||
|
import {
|
||||||
|
hasBreakdown,
|
||||||
|
isTesting,
|
||||||
|
isUntestable,
|
||||||
|
outboundAddresses,
|
||||||
|
showSecurity,
|
||||||
|
testResult,
|
||||||
|
trafficFor,
|
||||||
|
} from './outbounds-tab-helpers';
|
||||||
|
|
||||||
|
interface SubscriptionOutboundsProps {
|
||||||
|
subscriptionOutbounds: unknown[];
|
||||||
|
outboundsTraffic: OutboundTrafficRow[];
|
||||||
|
subscriptionTestStates: Record<string, OutboundTestState>;
|
||||||
|
testMode: 'tcp' | 'http';
|
||||||
|
isMobile: boolean;
|
||||||
|
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read-only view of outbounds imported from active subscriptions. They are not
|
||||||
|
// part of the editable template (so no edit/delete/move), but traffic is matched
|
||||||
|
// by tag and they can be latency-tested via the same backend endpoint.
|
||||||
|
export default function SubscriptionOutbounds({
|
||||||
|
subscriptionOutbounds,
|
||||||
|
outboundsTraffic,
|
||||||
|
subscriptionTestStates,
|
||||||
|
testMode,
|
||||||
|
isMobile,
|
||||||
|
onTestSubscription,
|
||||||
|
}: SubscriptionOutboundsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const rows = useMemo<OutboundRow[]>(
|
||||||
|
() => (subscriptionOutbounds || []).map((o, i) => ({ ...(o as object), key: i }) as OutboundRow),
|
||||||
|
[subscriptionOutbounds],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) return null;
|
||||||
|
|
||||||
|
const identityCell = (record: OutboundRow) => (
|
||||||
|
<div className="identity-cell">
|
||||||
|
<Tooltip title={record.tag}>
|
||||||
|
<span className="tag-name">{record.tag || '—'}</span>
|
||||||
|
</Tooltip>
|
||||||
|
<div className="protocol-line">
|
||||||
|
<Tag color="green">{record.protocol}</Tag>
|
||||||
|
{[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && (
|
||||||
|
<>
|
||||||
|
<Tag>{record.streamSettings?.network}</Tag>
|
||||||
|
{showSecurity(record.streamSettings?.security) && <Tag color="purple">{record.streamSettings?.security}</Tag>}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const addressCell = (record: OutboundRow) => {
|
||||||
|
const addrs = outboundAddresses(record);
|
||||||
|
return (
|
||||||
|
<div className="address-list">
|
||||||
|
{addrs.length === 0 ? (
|
||||||
|
<span className="empty">—</span>
|
||||||
|
) : (
|
||||||
|
addrs.map((addr) => (
|
||||||
|
<Tooltip key={addr} title={addr}>
|
||||||
|
<span className="address-pill">{addr}</span>
|
||||||
|
</Tooltip>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const trafficCell = (record: OutboundRow) => {
|
||||||
|
const tr = trafficFor(outboundsTraffic, record);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className="traffic-up">↑ {SizeFormatter.sizeFormat(tr.up)}</span>
|
||||||
|
<span className="traffic-sep" />
|
||||||
|
<span className="traffic-down">↓ {SizeFormatter.sizeFormat(tr.down)}</span>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const latencyCell = (record: OutboundRow) => {
|
||||||
|
const key = record.tag || '';
|
||||||
|
const r = testResult(subscriptionTestStates, key);
|
||||||
|
if (!r) return isTesting(subscriptionTestStates, key) ? <LoadingOutlined /> : <span className="empty">—</span>;
|
||||||
|
return (
|
||||||
|
<Popover
|
||||||
|
placement="topLeft"
|
||||||
|
rootClassName="outbound-test-popover"
|
||||||
|
content={
|
||||||
|
<div className="timing-breakdown">
|
||||||
|
<div className={`td-head ${r.success ? 'ok' : 'fail'}`}>
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>{r.error || 'failed'}</span>}
|
||||||
|
{r.mode && <span className="mode-badge">{String(r.mode).toUpperCase()}</span>}
|
||||||
|
</div>
|
||||||
|
{hasBreakdown(r) && (
|
||||||
|
<>
|
||||||
|
{(r.endpoints || []).map((ep) => (
|
||||||
|
<div key={ep.address} className="endpoint-row">
|
||||||
|
<span className={ep.success ? 'dot-ok' : 'dot-fail'}>●</span>
|
||||||
|
<span className="ep-addr">{ep.address}</span>
|
||||||
|
<span className="ep-meta">{ep.success ? `${ep.delay} ms` : ep.error || 'failed'}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className={r.success ? 'pill-ok' : 'pill-fail'}>
|
||||||
|
{r.success ? <CheckCircleFilled /> : <CloseCircleFilled />}
|
||||||
|
{r.success ? <span>{r.delay} ms</span> : <span>failed</span>}
|
||||||
|
</span>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const testButton = (record: OutboundRow) => {
|
||||||
|
const key = record.tag || '';
|
||||||
|
return (
|
||||||
|
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
shape="circle"
|
||||||
|
size={isMobile ? 'small' : undefined}
|
||||||
|
loading={isTesting(subscriptionTestStates, key)}
|
||||||
|
disabled={!record.tag || isUntestable(record, testMode) || isTesting(subscriptionTestStates, key)}
|
||||||
|
icon={<ThunderboltOutlined />}
|
||||||
|
onClick={() => onTestSubscription(record as unknown as Record<string, unknown>, testMode)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const header = (
|
||||||
|
<div className="subscription-outbounds-head">
|
||||||
|
<div className="subscription-outbounds-title">{t('pages.xray.outboundSub.fromSubsTitle')}</div>
|
||||||
|
<div className="subscription-outbounds-desc">{t('pages.xray.outboundSub.fromSubsDesc')}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
|
||||||
|
{header}
|
||||||
|
{rows.map((record, index) => (
|
||||||
|
<div key={record.key} className="outbound-card">
|
||||||
|
<div className="card-head">
|
||||||
|
<div className="card-identity">
|
||||||
|
<span className="card-num">{index + 1}</span>
|
||||||
|
{identityCell(record)}
|
||||||
|
</div>
|
||||||
|
{testButton(record)}
|
||||||
|
</div>
|
||||||
|
{outboundAddresses(record).length > 0 && addressCell(record)}
|
||||||
|
<div className="card-foot">
|
||||||
|
{trafficCell(record)}
|
||||||
|
<span className="card-test">{latencyCell(record)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: ColumnsType<OutboundRow> = [
|
||||||
|
{
|
||||||
|
title: '#',
|
||||||
|
key: 'num',
|
||||||
|
align: 'center',
|
||||||
|
width: 60,
|
||||||
|
render: (_v, _record, index) => <span className="row-index">{index + 1}</span>,
|
||||||
|
},
|
||||||
|
{ title: t('pages.xray.outbound.tag'), key: 'identity', align: 'left', render: (_v, record) => identityCell(record) },
|
||||||
|
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', render: (_v, record) => addressCell(record) },
|
||||||
|
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200, render: (_v, record) => trafficCell(record) },
|
||||||
|
{ title: t('pages.nodes.latency'), key: 'testResult', align: 'left', width: 140, render: (_v, record) => latencyCell(record) },
|
||||||
|
{ title: t('check'), key: 'test', align: 'center', width: 80, render: (_v, record) => testButton(record) },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="subscription-outbounds" style={{ marginTop: 16 }}>
|
||||||
|
{header}
|
||||||
|
<Table columns={columns} dataSource={rows} rowKey={(r) => r.key} pagination={false} size="small" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -53,10 +53,10 @@ export function trafficFor(outboundsTraffic: OutboundTrafficRow[], o: OutboundRo
|
|||||||
return { up: tr?.up || 0, down: tr?.down || 0 };
|
return { up: tr?.up || 0, down: tr?.down || 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isTesting(states: Record<number, OutboundTestState>, idx: number): boolean {
|
export function isTesting<K extends string | number>(states: Record<K, OutboundTestState>, idx: K): boolean {
|
||||||
return !!states?.[idx]?.testing;
|
return !!states?.[idx]?.testing;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function testResult(states: Record<number, OutboundTestState>, idx: number) {
|
export function testResult<K extends string | number>(states: Record<K, OutboundTestState>, idx: K) {
|
||||||
return states?.[idx]?.result || null;
|
return states?.[idx]?.result || null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ export default function SockoptForm({
|
|||||||
<Form.Item
|
<Form.Item
|
||||||
label={t('pages.inbounds.form.tcpWindowClamp')}
|
label={t('pages.inbounds.form.tcpWindowClamp')}
|
||||||
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
|
||||||
|
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
|
||||||
>
|
>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ export default function NordModal({
|
|||||||
}, [filteredServers]);
|
}, [filteredServers]);
|
||||||
|
|
||||||
const fetchCountries = useCallback(async () => {
|
const fetchCountries = useCallback(async () => {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
|
||||||
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
|
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const next = msg.obj ? JSON.parse(msg.obj) : null;
|
const next = msg.obj ? JSON.parse(msg.obj) : null;
|
||||||
setNordData(next);
|
setNordData(next);
|
||||||
@@ -113,7 +113,7 @@ export default function NordModal({
|
|||||||
async function login() {
|
async function login() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setNordData(JSON.parse(msg.obj));
|
setNordData(JSON.parse(msg.obj));
|
||||||
await fetchCountries();
|
await fetchCountries();
|
||||||
@@ -126,7 +126,7 @@ export default function NordModal({
|
|||||||
async function saveKey() {
|
async function saveKey() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setNordData(JSON.parse(msg.obj));
|
setNordData(JSON.parse(msg.obj));
|
||||||
await fetchCountries();
|
await fetchCountries();
|
||||||
@@ -139,7 +139,7 @@ export default function NordModal({
|
|||||||
async function logout() {
|
async function logout() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/nord/del');
|
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
onRemoveOutbound(nordOutboundIndex);
|
onRemoveOutbound(nordOutboundIndex);
|
||||||
onRemoveRoutingRules({ prefix: 'nord-' });
|
onRemoveRoutingRules({ prefix: 'nord-' });
|
||||||
@@ -166,7 +166,7 @@ export default function NordModal({
|
|||||||
setServerId(null);
|
setServerId(null);
|
||||||
setCityId(null);
|
setCityId(null);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
|
||||||
if (!msg?.success || !msg.obj) return;
|
if (!msg?.success || !msg.obj) return;
|
||||||
const data = JSON.parse(msg.obj);
|
const data = JSON.parse(msg.obj);
|
||||||
const locations = data.locations || [];
|
const locations = data.locations || [];
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function WarpModal({
|
|||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
const raw = msg.obj;
|
const raw = msg.obj;
|
||||||
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
|
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
|
||||||
@@ -133,7 +133,7 @@ export default function WarpModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const keys = Wireguard.generateKeypair();
|
const keys = Wireguard.generateKeypair();
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
const resp = JSON.parse(msg.obj);
|
const resp = JSON.parse(msg.obj);
|
||||||
setWarpData(resp.data);
|
setWarpData(resp.data);
|
||||||
@@ -148,7 +148,7 @@ export default function WarpModal({
|
|||||||
async function getConfig() {
|
async function getConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
const parsed = JSON.parse(msg.obj);
|
const parsed = JSON.parse(msg.obj);
|
||||||
setWarpConfig(parsed);
|
setWarpConfig(parsed);
|
||||||
@@ -164,7 +164,7 @@ export default function WarpModal({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLicenseError('');
|
setLicenseError('');
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
setWarpData(JSON.parse(msg.obj));
|
setWarpData(JSON.parse(msg.obj));
|
||||||
setWarpConfig(null);
|
setWarpConfig(null);
|
||||||
@@ -180,7 +180,7 @@ export default function WarpModal({
|
|||||||
async function delConfig() {
|
async function delConfig() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const msg = await HttpUtil.post('/panel/xray/warp/del');
|
const msg = await HttpUtil.post('/panel/api/xray/warp/del');
|
||||||
if (msg?.success) {
|
if (msg?.success) {
|
||||||
setWarpData(null);
|
setWarpData(null);
|
||||||
setWarpConfig(null);
|
setWarpConfig(null);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface RoutingTabProps {
|
|||||||
setTemplateSettings: SetTemplate;
|
setTemplateSettings: SetTemplate;
|
||||||
inboundTags: string[];
|
inboundTags: string[];
|
||||||
clientReverseTags: string[];
|
clientReverseTags: string[];
|
||||||
|
subscriptionOutboundTags?: string[];
|
||||||
isMobile: boolean;
|
isMobile: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ export default function RoutingTab({
|
|||||||
setTemplateSettings,
|
setTemplateSettings,
|
||||||
inboundTags,
|
inboundTags,
|
||||||
clientReverseTags,
|
clientReverseTags,
|
||||||
|
subscriptionOutboundTags,
|
||||||
isMobile,
|
isMobile,
|
||||||
}: RoutingTabProps) {
|
}: RoutingTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -116,8 +118,11 @@ export default function RoutingTab({
|
|||||||
for (const tag of clientReverseTags || []) {
|
for (const tag of clientReverseTags || []) {
|
||||||
if (tag) out.add(tag);
|
if (tag) out.add(tag);
|
||||||
}
|
}
|
||||||
|
for (const tag of subscriptionOutboundTags || []) {
|
||||||
|
if (tag) out.add(tag);
|
||||||
|
}
|
||||||
return [...out];
|
return [...out];
|
||||||
}, [templateSettings?.outbounds, clientReverseTags]);
|
}, [templateSettings?.outbounds, clientReverseTags, subscriptionOutboundTags]);
|
||||||
|
|
||||||
const balancerTagOptions = useMemo(() => {
|
const balancerTagOptions = useMemo(() => {
|
||||||
const out: string[] = [''];
|
const out: string[] = [''];
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
|
|||||||
protocol: z.string().optional(),
|
protocol: z.string().optional(),
|
||||||
port: z.number().optional(),
|
port: z.number().optional(),
|
||||||
tlsFlowCapable: z.boolean().optional(),
|
tlsFlowCapable: z.boolean().optional(),
|
||||||
|
ssMethod: z.string().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
export const InboundOptionsSchema = z.array(InboundOptionSchema);
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
|
|||||||
allowPrivateAddress: z.boolean().optional(),
|
allowPrivateAddress: z.boolean().optional(),
|
||||||
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
|
||||||
pinnedCertSha256: z.string().optional(),
|
pinnedCertSha256: z.string().optional(),
|
||||||
|
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
|
||||||
|
// whether it's a read-only transitive sub-node surfaced from a downstream node.
|
||||||
|
guid: z.string().optional(),
|
||||||
|
parentGuid: z.string().optional(),
|
||||||
|
transitive: z.boolean().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const NodeListSchema = z.array(NodeRecordSchema);
|
export const NodeListSchema = z.array(NodeRecordSchema);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const ProtocolSchema = z.enum([
|
|||||||
'mixed',
|
'mixed',
|
||||||
'tunnel',
|
'tunnel',
|
||||||
'tun',
|
'tun',
|
||||||
|
'mtproto',
|
||||||
]);
|
]);
|
||||||
export type Protocol = z.infer<typeof ProtocolSchema>;
|
export type Protocol = z.infer<typeof ProtocolSchema>;
|
||||||
|
|
||||||
@@ -31,4 +32,5 @@ export const Protocols = Object.freeze({
|
|||||||
MIXED: 'mixed',
|
MIXED: 'mixed',
|
||||||
TUNNEL: 'tunnel',
|
TUNNEL: 'tunnel',
|
||||||
TUN: 'tun',
|
TUN: 'tun',
|
||||||
|
MTPROTO: 'mtproto',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||||||
import { HttpInboundSettingsSchema } from './http';
|
import { HttpInboundSettingsSchema } from './http';
|
||||||
import { HysteriaInboundSettingsSchema } from './hysteria';
|
import { HysteriaInboundSettingsSchema } from './hysteria';
|
||||||
import { MixedInboundSettingsSchema } from './mixed';
|
import { MixedInboundSettingsSchema } from './mixed';
|
||||||
|
import { MtprotoInboundSettingsSchema } from './mtproto';
|
||||||
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
|
import { ShadowsocksInboundSettingsSchema } from './shadowsocks';
|
||||||
import { TrojanInboundSettingsSchema } from './trojan';
|
import { TrojanInboundSettingsSchema } from './trojan';
|
||||||
import { TunInboundSettingsSchema } from './tun';
|
import { TunInboundSettingsSchema } from './tun';
|
||||||
@@ -14,6 +15,7 @@ import { WireguardInboundSettingsSchema } from './wireguard';
|
|||||||
export * from './http';
|
export * from './http';
|
||||||
export * from './hysteria';
|
export * from './hysteria';
|
||||||
export * from './mixed';
|
export * from './mixed';
|
||||||
|
export * from './mtproto';
|
||||||
export * from './shadowsocks';
|
export * from './shadowsocks';
|
||||||
export * from './trojan';
|
export * from './trojan';
|
||||||
export * from './tun';
|
export * from './tun';
|
||||||
@@ -38,5 +40,6 @@ export const InboundSettingsSchema = z.discriminatedUnion('protocol', [
|
|||||||
z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }),
|
z.object({ protocol: z.literal('mixed'), settings: MixedInboundSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
|
z.object({ protocol: z.literal('tunnel'), settings: TunnelInboundSettingsSchema }),
|
||||||
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
|
z.object({ protocol: z.literal('tun'), settings: TunInboundSettingsSchema }),
|
||||||
|
z.object({ protocol: z.literal('mtproto'), settings: MtprotoInboundSettingsSchema }),
|
||||||
]);
|
]);
|
||||||
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
|
export type InboundSettings = z.infer<typeof InboundSettingsSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// MTProto (Telegram) inbound. Served by an mtg sidecar process, not Xray, so
|
||||||
|
// it has no clients and no stream settings. `secret` is the FakeTLS secret
|
||||||
|
// (ee-prefixed); the backend rebuilds it to match `fakeTlsDomain` on save.
|
||||||
|
export const MtprotoInboundSettingsSchema = z.object({
|
||||||
|
fakeTlsDomain: z.string().default('www.cloudflare.com'),
|
||||||
|
secret: z.string().default(''),
|
||||||
|
});
|
||||||
|
export type MtprotoInboundSettings = z.infer<typeof MtprotoInboundSettingsSchema>;
|
||||||
@@ -22,6 +22,9 @@ export const UtlsFingerprintSchema = z.enum([
|
|||||||
]);
|
]);
|
||||||
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
|
export type UtlsFingerprint = z.infer<typeof UtlsFingerprintSchema>;
|
||||||
|
|
||||||
|
export const TlsFingerprintSchema = z.union([UtlsFingerprintSchema, z.literal('')]);
|
||||||
|
export type TlsFingerprint = z.infer<typeof TlsFingerprintSchema>;
|
||||||
|
|
||||||
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
|
export const AlpnSchema = z.enum(['h3', 'h2', 'http/1.1']);
|
||||||
export type Alpn = z.infer<typeof AlpnSchema>;
|
export type Alpn = z.infer<typeof AlpnSchema>;
|
||||||
|
|
||||||
@@ -51,7 +54,7 @@ export const TlsCertSchema = z.union([TlsCertFileSchema, TlsCertInlineSchema]);
|
|||||||
export type TlsCert = z.infer<typeof TlsCertSchema>;
|
export type TlsCert = z.infer<typeof TlsCertSchema>;
|
||||||
|
|
||||||
export const TlsClientSettingsSchema = z.object({
|
export const TlsClientSettingsSchema = z.object({
|
||||||
fingerprint: UtlsFingerprintSchema.default('chrome'),
|
fingerprint: TlsFingerprintSchema.default('chrome'),
|
||||||
echConfigList: z.string().default(''),
|
echConfigList: z.string().default(''),
|
||||||
pinnedPeerCertSha256: z.array(z.string()).default([]),
|
pinnedPeerCertSha256: z.array(z.string()).default([]),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
|
|||||||
tcpMptcp: z.boolean().default(false),
|
tcpMptcp: z.boolean().default(false),
|
||||||
penetrate: z.boolean().default(false),
|
penetrate: z.boolean().default(false),
|
||||||
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
|
||||||
tcpMaxSeg: z.number().int().min(0).default(1440),
|
// 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
|
||||||
|
// Non-zero defaults here previously came from the xray docs *example* (clamp 600,
|
||||||
|
// maxSeg 1440, userTimeout 10000) and were written into every config when the
|
||||||
|
// panel sockopt switch was enabled, throttling long-haul links.
|
||||||
|
tcpMaxSeg: z.number().int().min(0).default(0),
|
||||||
dialerProxy: z.string().default(''),
|
dialerProxy: z.string().default(''),
|
||||||
tcpKeepAliveInterval: z.number().int().min(0).default(45),
|
tcpKeepAliveInterval: z.number().int().min(0).default(0),
|
||||||
tcpKeepAliveIdle: z.number().int().min(0).default(45),
|
tcpKeepAliveIdle: z.number().int().min(0).default(0),
|
||||||
tcpUserTimeout: z.number().int().min(0).default(10000),
|
tcpUserTimeout: z.number().int().min(0).default(0),
|
||||||
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
tcpcongestion: TcpCongestionSchema.default('bbr'),
|
||||||
V6Only: z.boolean().default(false),
|
V6Only: z.boolean().default(false),
|
||||||
tcpWindowClamp: z.number().int().min(0).default(600),
|
tcpWindowClamp: z.number().int().min(0).default(0),
|
||||||
interface: z.string().default(''),
|
interface: z.string().default(''),
|
||||||
trustedXForwardedFor: z.array(z.string()).default([]),
|
trustedXForwardedFor: z.array(z.string()).default([]),
|
||||||
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
addressPortStrategy: AddressPortStrategySchema.default('none'),
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ export const XrayConfigPayloadSchema = z.object({
|
|||||||
inboundTags: z.array(z.string()).optional(),
|
inboundTags: z.array(z.string()).optional(),
|
||||||
clientReverseTags: z.array(z.string()).optional(),
|
clientReverseTags: z.array(z.string()).optional(),
|
||||||
outboundTestUrl: z.string().optional(),
|
outboundTestUrl: z.string().optional(),
|
||||||
|
// Subscription outbounds are injected at runtime (not persisted in xraySetting).
|
||||||
|
// They are provided here so the UI can display them and use their tags in
|
||||||
|
// balancers / routing rules.
|
||||||
|
subscriptionOutbounds: z.array(z.unknown()).optional(),
|
||||||
|
subscriptionOutboundTags: z.array(z.string()).optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const OutboundTrafficRowSchema = z.object({
|
export const OutboundTrafficRowSchema = z.object({
|
||||||
|
|||||||
@@ -504,6 +504,174 @@ exports[`protocol capability predicates > mixed-basic :: xhttp/tls 1`] = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: grpc/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: grpc/reality 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: grpc/tls 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: httpupgrade/tls 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: kcp/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: tcp/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: tcp/reality 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: tcp/tls 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: ws/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: ws/tls 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: xhttp/none 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: xhttp/reality 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`protocol capability predicates > mtproto-basic :: xhttp/tls 1`] = `
|
||||||
|
{
|
||||||
|
"canEnableReality": false,
|
||||||
|
"canEnableStream": false,
|
||||||
|
"canEnableTls": false,
|
||||||
|
"canEnableTlsFlow": false,
|
||||||
|
"canEnableVisionSeed": false,
|
||||||
|
"isSS2022": false,
|
||||||
|
"isSSMultiUser": true,
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
|
exports[`protocol capability predicates > shadowsocks-2022 :: grpc/none 1`] = `
|
||||||
{
|
{
|
||||||
"canEnableReality": false,
|
"canEnableReality": false,
|
||||||
|
|||||||
@@ -59,6 +59,16 @@ exports[`InboundSettingsSchema fixtures > parses mixed-basic byte-stably 1`] = `
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
exports[`InboundSettingsSchema fixtures > parses mtproto-basic byte-stably 1`] = `
|
||||||
|
{
|
||||||
|
"protocol": "mtproto",
|
||||||
|
"settings": {
|
||||||
|
"fakeTlsDomain": "www.cloudflare.com",
|
||||||
|
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
|
exports[`InboundSettingsSchema fixtures > parses shadowsocks-2022 byte-stably 1`] = `
|
||||||
{
|
{
|
||||||
"protocol": "shadowsocks",
|
"protocol": "shadowsocks",
|
||||||
|
|||||||
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
|
|||||||
"mark": 0,
|
"mark": 0,
|
||||||
"penetrate": false,
|
"penetrate": false,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "off",
|
"tproxy": "off",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
|
|||||||
"mark": 255,
|
"mark": 255,
|
||||||
"penetrate": true,
|
"penetrate": true,
|
||||||
"tcpFastOpen": false,
|
"tcpFastOpen": false,
|
||||||
"tcpKeepAliveIdle": 45,
|
"tcpKeepAliveIdle": 0,
|
||||||
"tcpKeepAliveInterval": 45,
|
"tcpKeepAliveInterval": 0,
|
||||||
"tcpMaxSeg": 1440,
|
"tcpMaxSeg": 0,
|
||||||
"tcpMptcp": false,
|
"tcpMptcp": false,
|
||||||
"tcpUserTimeout": 10000,
|
"tcpUserTimeout": 0,
|
||||||
"tcpWindowClamp": 600,
|
"tcpWindowClamp": 0,
|
||||||
"tcpcongestion": "bbr",
|
"tcpcongestion": "bbr",
|
||||||
"tproxy": "tproxy",
|
"tproxy": "tproxy",
|
||||||
"trustedXForwardedFor": [],
|
"trustedXForwardedFor": [],
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import type { ZodType } from 'zod';
|
||||||
|
|
||||||
|
import { EXAMPLES } from '@/generated/examples';
|
||||||
|
import * as zodSchemas from '@/generated/zod';
|
||||||
|
|
||||||
|
const registry = zodSchemas as unknown as Record<string, ZodType>;
|
||||||
|
const names = Object.keys(EXAMPLES);
|
||||||
|
|
||||||
|
describe('generated response examples', () => {
|
||||||
|
it('has at least one example to validate', () => {
|
||||||
|
expect(names.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('pairs every example with a generated zod schema', () => {
|
||||||
|
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
|
||||||
|
expect(missing).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
|
||||||
|
const schema = registry[`${name}Schema`];
|
||||||
|
const result = schema.safeParse(EXAMPLES[name]);
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(
|
||||||
|
`EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"protocol": "mtproto",
|
||||||
|
"settings": {
|
||||||
|
"fakeTlsDomain": "www.cloudflare.com",
|
||||||
|
"secret": "ee0123456789abcdef0123456789abcdef7777772e636c6f7564666c6172652e636f6d"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
createDefaultVmessInboundSettings,
|
createDefaultVmessInboundSettings,
|
||||||
createDefaultWireguardInboundSettings,
|
createDefaultWireguardInboundSettings,
|
||||||
} from '@/lib/xray/inbound-defaults';
|
} from '@/lib/xray/inbound-defaults';
|
||||||
|
import { createHysteriaTlsSettingsWithDefaultCert } from '@/lib/xray/inbound-tls-defaults';
|
||||||
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
|
import { HttpInboundSettingsSchema } from '@/schemas/protocols/inbound/http';
|
||||||
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
|
import { HysteriaClientSchema, HysteriaInboundSettingsSchema } from '@/schemas/protocols/inbound/hysteria';
|
||||||
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
|
import { MixedInboundSettingsSchema } from '@/schemas/protocols/inbound/mixed';
|
||||||
@@ -147,3 +148,18 @@ describe('createDefault*InboundSettings factories', () => {
|
|||||||
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
|
expect(WireguardInboundSettingsSchema.parse(s)).toEqual(s);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('createHysteriaTlsSettingsWithDefaultCert', () => {
|
||||||
|
it('defaults Hysteria TLS to uTLS None and h3 ALPN', () => {
|
||||||
|
const tls = createHysteriaTlsSettingsWithDefaultCert();
|
||||||
|
expect(tls.alpn).toEqual(['h3']);
|
||||||
|
expect((tls.settings as Record<string, unknown>).fingerprint).toBe('');
|
||||||
|
expect(tls.certificates).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
useFile: true,
|
||||||
|
certificateFile: '',
|
||||||
|
keyFile: '',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
|
||||||
|
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
|
||||||
|
import {
|
||||||
|
normalizeSockoptForWire,
|
||||||
|
normalizeStreamSettingsForWire,
|
||||||
|
normalizeXhttpForWire,
|
||||||
|
validateRealityTarget,
|
||||||
|
} from '@/lib/xray/stream-wire-normalize';
|
||||||
|
import { InboundFormSchema } from '@/schemas/forms/inbound-form';
|
||||||
|
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
|
||||||
|
|
||||||
|
describe('validateRealityTarget', () => {
|
||||||
|
it('accepts host:port and bare port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
|
||||||
|
expect(validateRealityTarget('443')).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects host without port', () => {
|
||||||
|
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
|
||||||
|
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeXhttpForWire stream-one', () => {
|
||||||
|
it('drops packet-up and stream-up-only fields on inbound', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
scMaxBufferedPosts: 30,
|
||||||
|
scStreamUpServerSecs: '20-80',
|
||||||
|
enableXmux: false,
|
||||||
|
headers: {},
|
||||||
|
}, 'inbound');
|
||||||
|
|
||||||
|
expect(out).toMatchObject({
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
});
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
|
||||||
|
expect(out).not.toHaveProperty('scMaxBufferedPosts');
|
||||||
|
expect(out).not.toHaveProperty('scStreamUpServerSecs');
|
||||||
|
expect(out).not.toHaveProperty('enableXmux');
|
||||||
|
expect(out).not.toHaveProperty('headers');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps xmux on outbound stream-one', () => {
|
||||||
|
const out = normalizeXhttpForWire({
|
||||||
|
path: '/app',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
xmux: { maxConcurrency: '16-32' },
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
}, 'outbound');
|
||||||
|
|
||||||
|
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
|
||||||
|
expect(out).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeSockoptForWire', () => {
|
||||||
|
it('omits doc-example defaults that throttle throughput', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
domainStrategy: 'AsIs',
|
||||||
|
tproxy: 'off',
|
||||||
|
mark: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out).toEqual({
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves happyEyeballs on freedom-style outbound', () => {
|
||||||
|
const out = normalizeSockoptForWire({
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(out?.happyEyeballs).toMatchObject({
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
});
|
||||||
|
expect(out?.domainStrategy).toBe('UseIP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeStreamSettingsForWire reality', () => {
|
||||||
|
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['abcd'],
|
||||||
|
settings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
spiderX: '/',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { side: 'inbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.target).toBe('play.google.com:443');
|
||||||
|
expect(reality.privateKey).toBe('priv');
|
||||||
|
const settings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(settings.publicKey).toBe('pub');
|
||||||
|
expect(settings.spiderX).toBe('/');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes client realitySettings through unchanged on outbound', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
publicKey: 'pub',
|
||||||
|
fingerprint: 'chrome',
|
||||||
|
serverName: 'play.google.com',
|
||||||
|
shortId: 'abcd',
|
||||||
|
spiderX: '/x',
|
||||||
|
},
|
||||||
|
}, { side: 'outbound' });
|
||||||
|
|
||||||
|
const reality = out.realitySettings as Record<string, unknown>;
|
||||||
|
expect(reality.publicKey).toBe('pub');
|
||||||
|
expect(reality.serverName).toBe('play.google.com');
|
||||||
|
expect(reality.spiderX).toBe('/x');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('normalizeStreamSettingsForWire tls', () => {
|
||||||
|
it('drops empty uTLS fingerprints from inbound and outbound TLS shapes', () => {
|
||||||
|
const out = normalizeStreamSettingsForWire({
|
||||||
|
network: 'hysteria',
|
||||||
|
security: 'tls',
|
||||||
|
tlsSettings: {
|
||||||
|
fingerprint: '',
|
||||||
|
settings: {
|
||||||
|
fingerprint: '',
|
||||||
|
echConfigList: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, { side: 'inbound' });
|
||||||
|
|
||||||
|
const tls = out.tlsSettings as Record<string, unknown>;
|
||||||
|
const settings = tls.settings as Record<string, unknown>;
|
||||||
|
expect(tls).not.toHaveProperty('fingerprint');
|
||||||
|
expect(settings).not.toHaveProperty('fingerprint');
|
||||||
|
expect(settings.echConfigList).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('inbound formValuesToWirePayload integration', () => {
|
||||||
|
it('emits lean stream-one xhttp + sockopt on save', () => {
|
||||||
|
const values = {
|
||||||
|
remark: 't',
|
||||||
|
enable: true,
|
||||||
|
port: 443,
|
||||||
|
listen: '0.0.0.0',
|
||||||
|
tag: 'in-443',
|
||||||
|
expiryTime: 0,
|
||||||
|
sniffing: { enabled: false },
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
trafficReset: 'never',
|
||||||
|
lastTrafficResetTime: 0,
|
||||||
|
nodeId: null,
|
||||||
|
protocol: 'vless',
|
||||||
|
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
|
||||||
|
streamSettings: {
|
||||||
|
network: 'xhttp',
|
||||||
|
security: 'reality',
|
||||||
|
realitySettings: {
|
||||||
|
target: 'play.google.com:443',
|
||||||
|
privateKey: 'priv',
|
||||||
|
serverNames: ['play.google.com'],
|
||||||
|
shortIds: ['44003d86dc1e'],
|
||||||
|
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
|
||||||
|
},
|
||||||
|
xhttpSettings: {
|
||||||
|
path: '/app',
|
||||||
|
host: 'play.google.com',
|
||||||
|
mode: 'stream-one',
|
||||||
|
xPaddingBytes: '100-1000',
|
||||||
|
scMaxEachPostBytes: '1000000',
|
||||||
|
scMinPostsIntervalMs: '30',
|
||||||
|
enableXmux: false,
|
||||||
|
},
|
||||||
|
sockopt: {
|
||||||
|
tcpWindowClamp: 0,
|
||||||
|
tcpMaxSeg: 0,
|
||||||
|
tcpUserTimeout: 0,
|
||||||
|
tcpFastOpen: true,
|
||||||
|
tcpcongestion: 'bbr',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as InboundFormValues;
|
||||||
|
|
||||||
|
const payload = formValuesToWirePayload(values);
|
||||||
|
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
|
||||||
|
const xhttp = stream.xhttpSettings as Record<string, unknown>;
|
||||||
|
const sockopt = stream.sockopt as Record<string, unknown>;
|
||||||
|
const reality = stream.realitySettings as Record<string, unknown>;
|
||||||
|
|
||||||
|
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
|
||||||
|
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
|
||||||
|
expect(sockopt.tcpFastOpen).toBe(true);
|
||||||
|
const realitySettings = reality.settings as Record<string, unknown>;
|
||||||
|
expect(realitySettings.publicKey).toBe('pub');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts Hysteria TLS with uTLS None and omits fingerprint on save', () => {
|
||||||
|
const values = {
|
||||||
|
remark: 'hy2',
|
||||||
|
enable: true,
|
||||||
|
port: 443,
|
||||||
|
listen: '',
|
||||||
|
tag: 'hy2-443',
|
||||||
|
expiryTime: 0,
|
||||||
|
sniffing: { enabled: false },
|
||||||
|
up: 0,
|
||||||
|
down: 0,
|
||||||
|
total: 0,
|
||||||
|
trafficReset: 'never',
|
||||||
|
lastTrafficResetTime: 0,
|
||||||
|
nodeId: null,
|
||||||
|
protocol: 'hysteria',
|
||||||
|
settings: { version: 2, clients: [] },
|
||||||
|
streamSettings: {
|
||||||
|
network: 'hysteria',
|
||||||
|
security: 'tls',
|
||||||
|
hysteriaSettings: {
|
||||||
|
version: 2,
|
||||||
|
auth: 'auth',
|
||||||
|
udpIdleTimeout: 60,
|
||||||
|
},
|
||||||
|
tlsSettings: {
|
||||||
|
alpn: ['h3'],
|
||||||
|
settings: {
|
||||||
|
fingerprint: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const parsed = InboundFormSchema.safeParse(values);
|
||||||
|
expect(parsed.success).toBe(true);
|
||||||
|
if (!parsed.success) throw parsed.error;
|
||||||
|
|
||||||
|
const payload = formValuesToWirePayload(parsed.data);
|
||||||
|
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
|
||||||
|
const tls = stream.tlsSettings as Record<string, unknown>;
|
||||||
|
const settings = tls.settings as Record<string, unknown>;
|
||||||
|
expect(settings).not.toHaveProperty('fingerprint');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('freedom outbound sockopt wire payload', () => {
|
||||||
|
it('preserves happyEyeballs on direct freedom outbound', () => {
|
||||||
|
const wire = outboundToWire({
|
||||||
|
protocol: 'freedom',
|
||||||
|
tag: 'direct',
|
||||||
|
settings: { domainStrategy: 'UseIP' },
|
||||||
|
streamSettings: {
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
interleave: 1,
|
||||||
|
maxConcurrentTry: 4,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as Parameters<typeof outboundToWire>[0]);
|
||||||
|
|
||||||
|
expect(wire.streamSettings).toMatchObject({
|
||||||
|
sockopt: {
|
||||||
|
domainStrategy: 'UseIP',
|
||||||
|
happyEyeballs: {
|
||||||
|
tryDelayMs: 150,
|
||||||
|
prioritizeIPv6: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -183,15 +183,22 @@ export class RandomUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
|
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
|
||||||
let length = 32;
|
const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
|
||||||
if (method === '2022-blake3-aes-128-gcm') {
|
|
||||||
length = 16;
|
|
||||||
}
|
|
||||||
const array = new Uint8Array(length);
|
const array = new Uint8Array(length);
|
||||||
window.crypto.getRandomValues(array);
|
window.crypto.getRandomValues(array);
|
||||||
return Base64.alternativeEncode(String.fromCharCode(...array));
|
return Base64.alternativeEncode(String.fromCharCode(...array));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static isShadowsocks2022Password(password: string, method: string): boolean {
|
||||||
|
if (!method || method.substring(0, 4) !== '2022') return true;
|
||||||
|
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
|
||||||
|
try {
|
||||||
|
return window.atob(password).length === expected;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static randomBase64(length: number = 16): string {
|
static randomBase64(length: number = 16): string {
|
||||||
const array = new Uint8Array(length);
|
const array = new Uint8Array(length);
|
||||||
window.crypto.getRandomValues(array);
|
window.crypto.getRandomValues(array);
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ function resolveDBPath() {
|
|||||||
return '/etc/x-ui/x-ui.db';
|
return '/etc/x-ui/x-ui.db';
|
||||||
}
|
}
|
||||||
|
|
||||||
const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
|
const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
|
||||||
|
|
||||||
let cachedBasePath = '/';
|
let cachedBasePath = '/';
|
||||||
|
|
||||||
|
|||||||
@@ -1192,8 +1192,17 @@ install_x-ui() {
|
|||||||
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
if [[ $(arch) == "armv5" || $(arch) == "armv6" || $(arch) == "armv7" ]]; then
|
||||||
mv bin/xray-linux-$(arch) bin/xray-linux-arm
|
mv bin/xray-linux-$(arch) bin/xray-linux-arm
|
||||||
chmod +x bin/xray-linux-arm
|
chmod +x bin/xray-linux-arm
|
||||||
|
if [[ -f bin/mtg-linux-$(arch) ]]; then
|
||||||
|
mv bin/mtg-linux-$(arch) bin/mtg-linux-arm
|
||||||
|
chmod +x bin/mtg-linux-arm
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
chmod +x x-ui bin/xray-linux-$(arch)
|
chmod +x x-ui bin/xray-linux-$(arch)
|
||||||
|
if [[ -f bin/mtg-linux-arm ]]; then
|
||||||
|
chmod +x bin/mtg-linux-arm
|
||||||
|
elif [[ -f bin/mtg-linux-$(arch) ]]; then
|
||||||
|
chmod +x bin/mtg-linux-$(arch)
|
||||||
|
fi
|
||||||
|
|
||||||
# Update x-ui cli and se set permission
|
# Update x-ui cli and se set permission
|
||||||
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
|
mv -f /usr/bin/x-ui-temp /usr/bin/x-ui
|
||||||
|
|||||||
@@ -0,0 +1,348 @@
|
|||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance is the desired runtime configuration of one mtproto inbound.
|
||||||
|
type Instance struct {
|
||||||
|
Id int
|
||||||
|
Tag string
|
||||||
|
Listen string
|
||||||
|
Port int
|
||||||
|
Secret string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (inst Instance) bindTo() string {
|
||||||
|
listen := inst.Listen
|
||||||
|
if listen == "" {
|
||||||
|
listen = "0.0.0.0"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s:%d", listen, inst.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (inst Instance) fingerprint() string {
|
||||||
|
return fmt.Sprintf("%s|%s", inst.bindTo(), inst.Secret)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traffic is a per-inbound traffic delta scraped from an mtg metrics endpoint.
|
||||||
|
type Traffic struct {
|
||||||
|
Tag string
|
||||||
|
Up int64
|
||||||
|
Down int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type managed struct {
|
||||||
|
proc *Process
|
||||||
|
tag string
|
||||||
|
fingerprint string
|
||||||
|
metricsPort int
|
||||||
|
lastUp int64
|
||||||
|
lastDown int64
|
||||||
|
haveLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manager owns the set of running mtg processes keyed by inbound id.
|
||||||
|
type Manager struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
procs map[int]*managed
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
managerOnce sync.Once
|
||||||
|
manager *Manager
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetManager returns the process-wide mtg manager singleton.
|
||||||
|
func GetManager() *Manager {
|
||||||
|
managerOnce.Do(func() {
|
||||||
|
manager = &Manager{procs: map[int]*managed{}}
|
||||||
|
})
|
||||||
|
return manager
|
||||||
|
}
|
||||||
|
|
||||||
|
// InstanceFromInbound derives a desired Instance from an mtproto inbound,
|
||||||
|
// healing the FakeTLS secret so it always matches the configured domain.
|
||||||
|
// Returns false when the inbound is not a usable mtproto inbound.
|
||||||
|
func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
||||||
|
if ib == nil || ib.Protocol != model.MTProto {
|
||||||
|
return Instance{}, false
|
||||||
|
}
|
||||||
|
settings := ib.Settings
|
||||||
|
if healed, ok := model.HealMtprotoSecret(settings); ok {
|
||||||
|
settings = healed
|
||||||
|
}
|
||||||
|
var parsed struct {
|
||||||
|
Secret string `json:"secret"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(settings), &parsed); err != nil {
|
||||||
|
return Instance{}, false
|
||||||
|
}
|
||||||
|
if parsed.Secret == "" {
|
||||||
|
return Instance{}, false
|
||||||
|
}
|
||||||
|
return Instance{
|
||||||
|
Id: ib.Id,
|
||||||
|
Tag: ib.Tag,
|
||||||
|
Listen: ib.Listen,
|
||||||
|
Port: ib.Port,
|
||||||
|
Secret: parsed.Secret,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure starts the mtg process for an instance, or restarts it when its
|
||||||
|
// configuration changed. A no-op when the desired process is already running.
|
||||||
|
func (m *Manager) Ensure(inst Instance) error {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
return m.ensureLocked(inst)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) ensureLocked(inst Instance) error {
|
||||||
|
fp := inst.fingerprint()
|
||||||
|
if cur, ok := m.procs[inst.Id]; ok {
|
||||||
|
if cur.fingerprint == fp && cur.proc.IsRunning() {
|
||||||
|
cur.tag = inst.Tag
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cur.proc.Stop()
|
||||||
|
delete(m.procs, inst.Id)
|
||||||
|
}
|
||||||
|
metricsPort, err := freeLocalPort()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cfgPath := configPathForID(inst.Id)
|
||||||
|
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
proc := newProcess(cfgPath)
|
||||||
|
if err := proc.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.procs[inst.Id] = &managed{
|
||||||
|
proc: proc,
|
||||||
|
tag: inst.Tag,
|
||||||
|
fingerprint: fp,
|
||||||
|
metricsPort: metricsPort,
|
||||||
|
}
|
||||||
|
logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove stops and forgets the mtg process for an inbound id.
|
||||||
|
func (m *Manager) Remove(id int) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if cur, ok := m.procs[id]; ok {
|
||||||
|
cur.proc.Stop()
|
||||||
|
delete(m.procs, id)
|
||||||
|
_ = os.Remove(configPathForID(id))
|
||||||
|
logger.Info("mtproto: stopped mtg for inbound", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile drives the running set toward the desired instances: it stops
|
||||||
|
// processes that are no longer wanted and (re)starts the rest. Used at boot
|
||||||
|
// and periodically to recover from crashes.
|
||||||
|
func (m *Manager) Reconcile(desired []Instance) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
want := make(map[int]struct{}, len(desired))
|
||||||
|
for _, inst := range desired {
|
||||||
|
want[inst.Id] = struct{}{}
|
||||||
|
}
|
||||||
|
for id, cur := range m.procs {
|
||||||
|
if _, ok := want[id]; !ok {
|
||||||
|
cur.proc.Stop()
|
||||||
|
delete(m.procs, id)
|
||||||
|
_ = os.Remove(configPathForID(id))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, inst := range desired {
|
||||||
|
if err := m.ensureLocked(inst); err != nil {
|
||||||
|
logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopAll stops every managed mtg process. Called on panel shutdown.
|
||||||
|
func (m *Manager) StopAll() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
for id, cur := range m.procs {
|
||||||
|
_ = cur.proc.Stop()
|
||||||
|
_ = os.Remove(configPathForID(id))
|
||||||
|
delete(m.procs, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CollectTraffic scrapes each running mtg metrics endpoint and returns the
|
||||||
|
// per-inbound byte deltas since the previous scrape.
|
||||||
|
func (m *Manager) CollectTraffic() []Traffic {
|
||||||
|
// Snapshot the state we need under the lock, then release before doing
|
||||||
|
// network I/O so that Ensure/Reconcile/Remove are not blocked.
|
||||||
|
type snap struct {
|
||||||
|
id int
|
||||||
|
metricsPort int
|
||||||
|
tag string
|
||||||
|
haveLast bool
|
||||||
|
lastUp int64
|
||||||
|
lastDown int64
|
||||||
|
}
|
||||||
|
m.mu.Lock()
|
||||||
|
snaps := make([]snap, 0, len(m.procs))
|
||||||
|
for id, cur := range m.procs {
|
||||||
|
if cur.proc == nil || !cur.proc.IsRunning() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
snaps = append(snaps, snap{
|
||||||
|
id: id,
|
||||||
|
metricsPort: cur.metricsPort,
|
||||||
|
tag: cur.tag,
|
||||||
|
haveLast: cur.haveLast,
|
||||||
|
lastUp: cur.lastUp,
|
||||||
|
lastDown: cur.lastDown,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
out := make([]Traffic, 0, len(snaps))
|
||||||
|
for _, s := range snaps {
|
||||||
|
up, down, ok := scrapeTraffic(s.metricsPort)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var du, dd int64
|
||||||
|
if s.haveLast {
|
||||||
|
du = up - s.lastUp
|
||||||
|
dd = down - s.lastDown
|
||||||
|
if du < 0 {
|
||||||
|
du = 0
|
||||||
|
}
|
||||||
|
if dd < 0 {
|
||||||
|
dd = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-acquire lock to persist the new baseline, but only if the entry
|
||||||
|
// still exists (it may have been removed during the scrape).
|
||||||
|
m.mu.Lock()
|
||||||
|
if cur, ok := m.procs[s.id]; ok {
|
||||||
|
cur.lastUp = up
|
||||||
|
cur.lastDown = down
|
||||||
|
cur.haveLast = true
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
|
||||||
|
if s.haveLast && (du > 0 || dd > 0) {
|
||||||
|
out = append(out, Traffic{Tag: s.tag, Up: du, Down: dd})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func freeLocalPort() (int, error) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer l.Close()
|
||||||
|
return l.Addr().(*net.TCPAddr).Port, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeConfig(path, secret, bindTo string, metricsPort int) error {
|
||||||
|
if err := os.MkdirAll(configDir(), 0o750); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
content := fmt.Sprintf("secret = %q\nbind-to = %q\n\n[stats.prometheus]\nenabled = true\nbind-to = \"127.0.0.1:%d\"\nhttp-path = \"/metrics\"\nmetric-prefix = \"mtg\"\n",
|
||||||
|
secret, bindTo, metricsPort)
|
||||||
|
return os.WriteFile(path, []byte(content), 0o640)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrapeTraffic reads the mtg Prometheus metrics endpoint and sums byte
|
||||||
|
// counters by direction. mtg exposes a traffic counter labelled with a
|
||||||
|
// direction; "to_telegram" is treated as upload and "to_client" as download.
|
||||||
|
// Best-effort: an unreachable endpoint or unrecognised format yields ok=false.
|
||||||
|
func scrapeTraffic(port int) (up int64, down int64, ok bool) {
|
||||||
|
client := http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/metrics", port))
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
scanner := bufio.NewScanner(resp.Body)
|
||||||
|
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||||
|
found := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || line[0] == '#' || !strings.Contains(line, "traffic") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, labels, value, perr := parseMetricLine(line)
|
||||||
|
if perr != nil || !strings.HasPrefix(name, "mtg") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch labels["direction"] {
|
||||||
|
case "to_telegram", "egress", "up":
|
||||||
|
up += int64(value)
|
||||||
|
case "to_client", "ingress", "down":
|
||||||
|
down += int64(value)
|
||||||
|
default:
|
||||||
|
down += int64(value)
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
logger.Debug("mtproto: metrics scan error:", err)
|
||||||
|
}
|
||||||
|
return up, down, found
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMetricLine(line string) (name string, labels map[string]string, value float64, err error) {
|
||||||
|
labels = map[string]string{}
|
||||||
|
rest := line
|
||||||
|
if brace := strings.IndexByte(line, '{'); brace >= 0 {
|
||||||
|
name = line[:brace]
|
||||||
|
end := strings.IndexByte(line, '}')
|
||||||
|
if end < brace {
|
||||||
|
return "", nil, 0, fmt.Errorf("malformed metric line")
|
||||||
|
}
|
||||||
|
for _, kv := range strings.Split(line[brace+1:end], ",") {
|
||||||
|
eq := strings.IndexByte(kv, '=')
|
||||||
|
if eq < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labels[strings.TrimSpace(kv[:eq])] = strings.Trim(strings.TrimSpace(kv[eq+1:]), `"`)
|
||||||
|
}
|
||||||
|
rest = strings.TrimSpace(line[end+1:])
|
||||||
|
} else {
|
||||||
|
fields := strings.Fields(line)
|
||||||
|
if len(fields) < 2 {
|
||||||
|
return "", nil, 0, fmt.Errorf("malformed metric line")
|
||||||
|
}
|
||||||
|
name = fields[0]
|
||||||
|
rest = fields[1]
|
||||||
|
}
|
||||||
|
valFields := strings.Fields(rest)
|
||||||
|
if len(valFields) == 0 {
|
||||||
|
return "", nil, 0, fmt.Errorf("missing metric value")
|
||||||
|
}
|
||||||
|
value, err = strconv.ParseFloat(valFields[0], 64)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, 0, err
|
||||||
|
}
|
||||||
|
return name, labels, value, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseMetricLine(t *testing.T) {
|
||||||
|
name, labels, val, err := parseMetricLine(`mtg_traffic{direction="to_client"} 12345`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if name != "mtg_traffic" {
|
||||||
|
t.Fatalf("name=%q", name)
|
||||||
|
}
|
||||||
|
if labels["direction"] != "to_client" {
|
||||||
|
t.Fatalf("labels=%v", labels)
|
||||||
|
}
|
||||||
|
if val != 12345 {
|
||||||
|
t.Fatalf("val=%v", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
name2, _, val2, err2 := parseMetricLine(`mtg_concurrency 7`)
|
||||||
|
if err2 != nil {
|
||||||
|
t.Fatal(err2)
|
||||||
|
}
|
||||||
|
if name2 != "mtg_concurrency" || val2 != 7 {
|
||||||
|
t.Fatalf("got %q %v", name2, val2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInstanceFromInbound(t *testing.T) {
|
||||||
|
ib := &model.Inbound{
|
||||||
|
Id: 3,
|
||||||
|
Tag: "inbound-3",
|
||||||
|
Listen: "0.0.0.0",
|
||||||
|
Port: 8443,
|
||||||
|
Protocol: model.MTProto,
|
||||||
|
Settings: `{"fakeTlsDomain":"example.com","secret":""}`,
|
||||||
|
}
|
||||||
|
inst, ok := InstanceFromInbound(ib)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected a usable instance")
|
||||||
|
}
|
||||||
|
if inst.Secret == "" {
|
||||||
|
t.Fatal("secret should be healed to a non-empty value")
|
||||||
|
}
|
||||||
|
if inst.Port != 8443 || inst.Id != 3 {
|
||||||
|
t.Fatalf("bad instance %+v", inst)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := InstanceFromInbound(&model.Inbound{Protocol: model.VLESS}); ok {
|
||||||
|
t.Fatal("non-mtproto inbound should not produce an instance")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Package mtproto manages mtg (github.com/9seconds/mtg) sidecar processes that
|
||||||
|
// serve MTProto FakeTLS proxies. Xray-core has no mtproto protocol, so mtproto
|
||||||
|
// inbounds are run as standalone mtg processes — one process per inbound —
|
||||||
|
// entirely outside the Xray config and lifecycle.
|
||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/config"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBinaryName returns the mtg binary filename for the current OS and arch,
|
||||||
|
// matching the naming scheme used for the Xray binary. On Windows the ".exe"
|
||||||
|
// extension is appended so a natural "mtg-windows-amd64.exe" is found.
|
||||||
|
func GetBinaryName() string {
|
||||||
|
name := fmt.Sprintf("mtg-%s-%s", runtime.GOOS, runtime.GOARCH)
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
name += ".exe"
|
||||||
|
}
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBinaryPath returns the full path to the mtg binary, alongside the Xray binary.
|
||||||
|
func GetBinaryPath() string {
|
||||||
|
return config.GetBinFolderPath() + "/" + GetBinaryName()
|
||||||
|
}
|
||||||
|
|
||||||
|
func configDir() string {
|
||||||
|
return config.GetBinFolderPath() + "/mtproto"
|
||||||
|
}
|
||||||
|
|
||||||
|
func configPathForID(id int) string {
|
||||||
|
return fmt.Sprintf("%s/mtg-%d.toml", configDir(), id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
gracefulStopTimeout = 5 * time.Second
|
||||||
|
forceStopTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
type lastLineWriter struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
lastLine string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *lastLineWriter) Write(p []byte) (int, error) {
|
||||||
|
line := strings.TrimSpace(string(p))
|
||||||
|
if line != "" {
|
||||||
|
w.mu.Lock()
|
||||||
|
w.lastLine = line
|
||||||
|
w.mu.Unlock()
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *lastLineWriter) LastLine() string {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
return w.lastLine
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process wraps a single mtg process invocation for one mtproto inbound.
|
||||||
|
type Process struct {
|
||||||
|
cmd *exec.Cmd
|
||||||
|
done chan struct{}
|
||||||
|
configPath string
|
||||||
|
logWriter *lastLineWriter
|
||||||
|
exitErr error
|
||||||
|
intentionalStop atomic.Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newProcess(configPath string) *Process {
|
||||||
|
return &Process{
|
||||||
|
configPath: configPath,
|
||||||
|
logWriter: &lastLineWriter{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsRunning reports whether the mtg process is currently running.
|
||||||
|
func (p *Process) IsRunning() bool {
|
||||||
|
if p.cmd == nil || p.cmd.Process == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if p.done != nil {
|
||||||
|
select {
|
||||||
|
case <-p.done:
|
||||||
|
return false
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.cmd.ProcessState == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetResult returns the last log line or the exit error from the mtg process.
|
||||||
|
func (p *Process) GetResult() string {
|
||||||
|
if line := p.logWriter.LastLine(); line != "" {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
if p.exitErr != nil {
|
||||||
|
return p.exitErr.Error()
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the mtg process against its generated config file.
|
||||||
|
func (p *Process) Start() error {
|
||||||
|
if p.IsRunning() {
|
||||||
|
return errors.New("mtg is already running")
|
||||||
|
}
|
||||||
|
cmd := exec.Command(GetBinaryPath(), "run", p.configPath)
|
||||||
|
cmd.Stdout = p.logWriter
|
||||||
|
cmd.Stderr = p.logWriter
|
||||||
|
p.cmd = cmd
|
||||||
|
p.done = make(chan struct{})
|
||||||
|
p.exitErr = nil
|
||||||
|
p.intentionalStop.Store(false)
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
close(p.done)
|
||||||
|
p.cmd = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
attachChildLifetime(cmd)
|
||||||
|
go p.wait(cmd)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) wait(cmd *exec.Cmd) {
|
||||||
|
defer close(p.done)
|
||||||
|
err := cmd.Wait()
|
||||||
|
if err == nil || p.intentionalStop.Load() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if strings.Contains(strings.ToLower(err.Error()), "exit status 1") {
|
||||||
|
p.exitErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
logger.Error("mtproto: mtg process exited:", err)
|
||||||
|
p.exitErr = err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop terminates the running mtg process gracefully, falling back to a kill.
|
||||||
|
func (p *Process) Stop() error {
|
||||||
|
if !p.IsRunning() {
|
||||||
|
return errors.New("mtg is not running")
|
||||||
|
}
|
||||||
|
p.intentionalStop.Store(true)
|
||||||
|
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.waitForExit(forceStopTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.cmd.Process.Signal(syscall.SIGTERM); err != nil {
|
||||||
|
if errors.Is(err, os.ErrProcessDone) {
|
||||||
|
return p.waitForExit(forceStopTimeout)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.waitForExit(gracefulStopTimeout); err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Warning("mtproto: mtg did not stop after SIGTERM, killing process")
|
||||||
|
if err := p.cmd.Process.Kill(); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return p.waitForExit(forceStopTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Process) waitForExit(timeout time.Duration) error {
|
||||||
|
if p.done == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case <-p.done:
|
||||||
|
return nil
|
||||||
|
case <-timer.C:
|
||||||
|
return fmt.Errorf("timed out waiting for mtg process to stop after %s", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package mtproto
|
||||||
|
|
||||||
|
import "os/exec"
|
||||||
|
|
||||||
|
func attachChildLifetime(_ *exec.Cmd) {}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//go:build windows
|
||||||
|
|
||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
killOnExitJobOnce sync.Once
|
||||||
|
killOnExitJob windows.Handle
|
||||||
|
killOnExitJobErr error
|
||||||
|
)
|
||||||
|
|
||||||
|
func ensureKillOnExitJob() (windows.Handle, error) {
|
||||||
|
killOnExitJobOnce.Do(func() {
|
||||||
|
h, err := windows.CreateJobObject(nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
killOnExitJobErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info := windows.JOBOBJECT_EXTENDED_LIMIT_INFORMATION{
|
||||||
|
BasicLimitInformation: windows.JOBOBJECT_BASIC_LIMIT_INFORMATION{
|
||||||
|
LimitFlags: windows.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err = windows.SetInformationJobObject(
|
||||||
|
h,
|
||||||
|
windows.JobObjectExtendedLimitInformation,
|
||||||
|
uintptr(unsafe.Pointer(&info)),
|
||||||
|
uint32(unsafe.Sizeof(info)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
windows.CloseHandle(h)
|
||||||
|
killOnExitJobErr = err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
killOnExitJob = h
|
||||||
|
})
|
||||||
|
return killOnExitJob, killOnExitJobErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachChildLifetime(cmd *exec.Cmd) {
|
||||||
|
if cmd == nil || cmd.Process == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
job, err := ensureKillOnExitJob()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("mtproto: kill-on-exit job unavailable:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("mtproto: OpenProcess for job attach failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(h)
|
||||||
|
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||||
|
logger.Warning("mtproto: AssignProcessToJobObject failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-5
@@ -221,8 +221,11 @@ func (s *SubClashService) buildProxy(inbound *model.Inbound, client model.Client
|
|||||||
}
|
}
|
||||||
var inboundSettings map[string]any
|
var inboundSettings map[string]any
|
||||||
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
|
||||||
if encryption, ok := inboundSettings["encryption"].(string); ok && encryption != "" {
|
if encryption, ok := inboundSettings["encryption"].(string); ok {
|
||||||
proxy["packet-encoding"] = encryption
|
encryption = strings.TrimSpace(encryption)
|
||||||
|
if encryption != "" && encryption != "none" {
|
||||||
|
proxy["encryption"] = encryption
|
||||||
|
}
|
||||||
}
|
}
|
||||||
case model.Trojan:
|
case model.Trojan:
|
||||||
proxy["type"] = "trojan"
|
proxy["type"] = "trojan"
|
||||||
@@ -573,10 +576,14 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
|
|||||||
case []any:
|
case []any:
|
||||||
mergeClashRules(base, typed)
|
mergeClashRules(base, typed)
|
||||||
case map[string]any:
|
case map[string]any:
|
||||||
if rules, ok := typed["rules"]; ok {
|
for key, value := range typed {
|
||||||
if ruleList, ok := asAnySlice(rules); ok {
|
if key == "rules" {
|
||||||
mergeClashRules(base, ruleList)
|
if ruleList, ok := asAnySlice(value); ok {
|
||||||
|
mergeClashRules(base, ruleList)
|
||||||
|
}
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
|
base[key] = value
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
mergeClashRules(base, linesToClashRules(raw))
|
mergeClashRules(base, linesToClashRules(raw))
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package sub
|
|||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureUniqueProxyNames(t *testing.T) {
|
func TestEnsureUniqueProxyNames(t *testing.T) {
|
||||||
@@ -113,3 +115,60 @@ func TestApplyTransport_HTTPUpgrade(t *testing.T) {
|
|||||||
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
|
t.Fatalf("headers.Host = %v, want example.com", headers["Host"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildProxy_VLESSPostQuantumEncryptionUsesMihomoEncryptionField(t *testing.T) {
|
||||||
|
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||||
|
encryption := "mlkem768x25519plus.native.0rtt.client"
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
Listen: "203.0.113.1",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Remark: "pq",
|
||||||
|
Settings: `{"encryption":"` + encryption + `"}`,
|
||||||
|
}
|
||||||
|
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
|
||||||
|
stream := map[string]any{
|
||||||
|
"network": "xhttp",
|
||||||
|
"xhttpSettings": map[string]any{
|
||||||
|
"path": "/",
|
||||||
|
"mode": "auto",
|
||||||
|
},
|
||||||
|
"security": "reality",
|
||||||
|
"realitySettings": map[string]any{
|
||||||
|
"publicKey": "pub",
|
||||||
|
"serverName": "example.com",
|
||||||
|
"shortId": "abcd",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := svc.buildProxy(inbound, client, stream, "")
|
||||||
|
|
||||||
|
if proxy["encryption"] != encryption {
|
||||||
|
t.Fatalf("encryption = %v, want %q", proxy["encryption"], encryption)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildProxy_VLESSNoneEncryptionOmittedForClash(t *testing.T) {
|
||||||
|
svc := &SubClashService{SubService: &SubService{remarkModel: "-i"}}
|
||||||
|
inbound := &model.Inbound{
|
||||||
|
Listen: "203.0.113.1",
|
||||||
|
Port: 443,
|
||||||
|
Protocol: model.VLESS,
|
||||||
|
Remark: "plain",
|
||||||
|
Settings: `{"encryption":"none"}`,
|
||||||
|
}
|
||||||
|
client := model.Client{ID: "11111111-2222-4333-8444-555555555555"}
|
||||||
|
stream := map[string]any{
|
||||||
|
"network": "tcp",
|
||||||
|
"security": "none",
|
||||||
|
"tcpSettings": map[string]any{
|
||||||
|
"header": map[string]any{"type": "none"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := svc.buildProxy(inbound, client, stream, "")
|
||||||
|
|
||||||
|
if _, ok := proxy["encryption"]; ok {
|
||||||
|
t.Fatalf("plain vless encryption should be omitted for mihomo: %#v", proxy)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+58
-3
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
|
|||||||
return ip != nil && ip.IsLoopback()
|
return ip != nil && ip.IsLoopback()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listenIsInternalOnly reports whether a bind address is reachable only from
|
||||||
|
// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
|
||||||
|
// be dialed directly by a remote client, so when it is the child side of a
|
||||||
|
// fallback its share link must be projected through the master. A public or
|
||||||
|
// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
|
||||||
|
// itself.
|
||||||
|
func listenIsInternalOnly(listen string) bool {
|
||||||
|
if listen == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if listen[0] == '@' || listen[0] == '/' {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return isLoopbackHost(listen)
|
||||||
|
}
|
||||||
|
|
||||||
// GetSubs retrieves subscription links for a given subscription ID and host.
|
// GetSubs retrieves subscription links for a given subscription ID and host.
|
||||||
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
|
||||||
s.PrepareForRequest(host)
|
s.PrepareForRequest(host)
|
||||||
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
|
|||||||
// Returns true when a projection happened; sub services call this before
|
// Returns true when a projection happened; sub services call this before
|
||||||
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
|
||||||
// master's :443 + TLS state instead of its own loopback endpoint.
|
// master's :443 + TLS state instead of its own loopback endpoint.
|
||||||
|
//
|
||||||
|
// Projection only applies to a child that is not directly reachable on its
|
||||||
|
// own listen (loopback or a unix-domain socket). An inbound on a public or
|
||||||
|
// wildcard listen is reachable on its own port, so it advertises its own
|
||||||
|
// port + security even when a stale fallback rule still names it as a child —
|
||||||
|
// otherwise its share link would leak the master's port and Reality/TLS
|
||||||
|
// settings (#4987).
|
||||||
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
|
||||||
if inbound == nil {
|
if inbound == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !listenIsInternalOnly(inbound.Listen) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
var master *model.Inbound
|
var master *model.Inbound
|
||||||
|
|
||||||
@@ -348,10 +374,38 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
|||||||
return s.genShadowsocksLink(inbound, email)
|
return s.genShadowsocksLink(inbound, email)
|
||||||
case "hysteria":
|
case "hysteria":
|
||||||
return s.genHysteriaLink(inbound, email)
|
return s.genHysteriaLink(inbound, email)
|
||||||
|
case "mtproto":
|
||||||
|
return s.genMtprotoLink(inbound, email)
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
|
||||||
|
// tg://proxy?server=<addr>&port=<port>&secret=<ee FakeTLS secret>.
|
||||||
|
func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
|
||||||
|
if inbound.Protocol != model.MTProto {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
settings := map[string]any{}
|
||||||
|
json.Unmarshal([]byte(inbound.Settings), &settings)
|
||||||
|
secret, _ := settings["secret"].(string)
|
||||||
|
if secret == "" {
|
||||||
|
if healed, ok := model.HealMtprotoSecret(inbound.Settings); ok {
|
||||||
|
_ = json.Unmarshal([]byte(healed), &settings)
|
||||||
|
secret, _ = settings["secret"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if secret == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
params := map[string]string{
|
||||||
|
"server": s.resolveInboundAddress(inbound),
|
||||||
|
"port": fmt.Sprintf("%d", inbound.Port),
|
||||||
|
"secret": secret,
|
||||||
|
}
|
||||||
|
return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, ""))
|
||||||
|
}
|
||||||
|
|
||||||
// Protocol link generators are intentionally ordered as:
|
// Protocol link generators are intentionally ordered as:
|
||||||
// vmess -> vless -> trojan -> shadowsocks -> hysteria.
|
// vmess -> vless -> trojan -> shadowsocks -> hysteria.
|
||||||
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
func (s *SubService) genVmessLink(inbound *model.Inbound, email string) string {
|
||||||
@@ -717,9 +771,10 @@ func (s *SubService) loadNodes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// resolveInboundAddress picks the host an external client should connect to:
|
// resolveInboundAddress picks the host an external client should connect to:
|
||||||
// 1. node-managed inbound -> the node's address
|
// 1. node-managed inbound -> the node's address
|
||||||
// 2. an explicit, client-reachable bind Listen -> that Listen
|
// 2. an explicit, client-reachable bind Listen -> that Listen
|
||||||
// 3. otherwise the subscriber's request host (s.address)
|
// 3. otherwise the subscriber's request host (s.address)
|
||||||
|
//
|
||||||
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side
|
// A loopback/wildcard bind or a unix-domain-socket listen is a server-side
|
||||||
// detail and is never advertised; External Proxy remains the way to advertise
|
// detail and is never advertised; External Proxy remains the way to advertise
|
||||||
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and
|
// an arbitrary endpoint. Mirrors the frontend's resolveAddr so the panel QR and
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListenIsInternalOnly(t *testing.T) {
|
||||||
|
// Reachable only from the same host -> a fallback child here must be
|
||||||
|
// projected through its master.
|
||||||
|
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
|
||||||
|
for _, v := range internalOnly {
|
||||||
|
if !listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Directly reachable on its own port -> never projected, even if a stale
|
||||||
|
// fallback rule names it as a child (#4987).
|
||||||
|
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
|
||||||
|
for _, v := range reachable {
|
||||||
|
if listenIsInternalOnly(v) {
|
||||||
|
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestResolveInboundAddress(t *testing.T) {
|
func TestResolveInboundAddress(t *testing.T) {
|
||||||
const reqHost = "sub.example.com"
|
const reqHost = "sub.example.com"
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
|
||||||
|
byName := make(map[string]Schema, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
aliasByName := make(map[string]Alias, len(aliases))
|
||||||
|
for _, a := range aliases {
|
||||||
|
aliasByName[a.Name] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
out[s.Name] = gen.forSchema(s, map[string]bool{})
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type exampleGen struct {
|
||||||
|
byName map[string]Schema
|
||||||
|
aliasByName map[string]Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
|
||||||
|
obj := make(map[string]any, len(s.Fields))
|
||||||
|
for _, f := range s.Fields {
|
||||||
|
obj[f.JSONName] = g.forField(f, visited)
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
|
||||||
|
if f.Example != "" {
|
||||||
|
return coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
if v, ok := firstOneOf(f.Validate); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
bk := baseKind(f.Type)
|
||||||
|
if bk.Kind == KindInt || bk.Kind == KindNumber {
|
||||||
|
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return g.forType(f.Type, visited)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindString:
|
||||||
|
if t.Name == "datetime" {
|
||||||
|
return "2025-01-01T00:00:00Z"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
case KindInt, KindNumber:
|
||||||
|
return 0
|
||||||
|
case KindBool:
|
||||||
|
return false
|
||||||
|
case KindArray:
|
||||||
|
if isVisitedRef(*t.Element, visited) {
|
||||||
|
return []any{}
|
||||||
|
}
|
||||||
|
return []any{g.forType(*t.Element, visited)}
|
||||||
|
case KindMap:
|
||||||
|
return map[string]any{}
|
||||||
|
case KindRef:
|
||||||
|
if t.Name == "nullable" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if alias, ok := g.aliasByName[t.Name]; ok {
|
||||||
|
return g.forType(alias.Underlying, visited)
|
||||||
|
}
|
||||||
|
schema, ok := g.byName[t.Name]
|
||||||
|
if !ok || visited[t.Name] {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
next := cloneVisited(visited)
|
||||||
|
next[t.Name] = true
|
||||||
|
return g.forSchema(schema, next)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseKind(t TypeRef) TypeRef {
|
||||||
|
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
|
||||||
|
return *t.Inner
|
||||||
|
}
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
|
||||||
|
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
|
||||||
|
}
|
||||||
|
|
||||||
|
func cloneVisited(in map[string]bool) map[string]bool {
|
||||||
|
out := make(map[string]bool, len(in)+1)
|
||||||
|
for k, v := range in {
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
|
||||||
|
for _, r := range rules {
|
||||||
|
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
|
||||||
|
return coerceExample(r.Param, TypeRef{Kind: kind}), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstOneOf(rules []ValidateRule) (string, bool) {
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Name == "oneof" {
|
||||||
|
fields := strings.Fields(r.Param)
|
||||||
|
if len(fields) > 0 {
|
||||||
|
return fields[0], true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceExample(ex string, t TypeRef) any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindInt:
|
||||||
|
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case KindNumber:
|
||||||
|
if n, err := strconv.ParseFloat(ex, 64); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
case KindBool:
|
||||||
|
return ex == "true"
|
||||||
|
case KindString:
|
||||||
|
return ex
|
||||||
|
default:
|
||||||
|
var parsed any
|
||||||
|
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
return ex
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
|
||||||
|
byName := make(map[string]Schema, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
byName[s.Name] = s
|
||||||
|
}
|
||||||
|
aliasByName := make(map[string]Alias, len(aliases))
|
||||||
|
for _, a := range aliases {
|
||||||
|
aliasByName[a.Name] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
gen := &schemaGen{byName: byName, aliasByName: aliasByName}
|
||||||
|
|
||||||
|
out := make(map[string]any, len(schemas))
|
||||||
|
for _, s := range schemas {
|
||||||
|
out[s.Name] = gen.objectSchema(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := json.MarshalIndent(out, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type schemaGen struct {
|
||||||
|
byName map[string]Schema
|
||||||
|
aliasByName map[string]Alias
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) objectSchema(s Schema) map[string]any {
|
||||||
|
props := make(map[string]any, len(s.Fields))
|
||||||
|
var required []string
|
||||||
|
for _, f := range s.Fields {
|
||||||
|
props[f.JSONName] = g.fieldSchema(f)
|
||||||
|
if !f.Optional {
|
||||||
|
required = append(required, f.JSONName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
obj := map[string]any{"type": "object", "properties": props}
|
||||||
|
if len(required) > 0 {
|
||||||
|
sort.Strings(required)
|
||||||
|
obj["required"] = required
|
||||||
|
}
|
||||||
|
if s.Doc != "" {
|
||||||
|
obj["description"] = s.Doc
|
||||||
|
}
|
||||||
|
return obj
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) fieldSchema(f Field) map[string]any {
|
||||||
|
sch := g.typeSchema(f.Type)
|
||||||
|
if ref, ok := sch["$ref"]; ok {
|
||||||
|
if f.Doc == "" && f.Example == "" {
|
||||||
|
return sch
|
||||||
|
}
|
||||||
|
wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
|
||||||
|
if f.Doc != "" {
|
||||||
|
wrap["description"] = f.Doc
|
||||||
|
}
|
||||||
|
if f.Example != "" {
|
||||||
|
wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
return wrap
|
||||||
|
}
|
||||||
|
applyConstraints(sch, f.Type, f.Validate)
|
||||||
|
if f.Doc != "" {
|
||||||
|
sch["description"] = f.Doc
|
||||||
|
}
|
||||||
|
if f.Example != "" {
|
||||||
|
sch["example"] = coerceExample(f.Example, baseKind(f.Type))
|
||||||
|
}
|
||||||
|
return sch
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
|
||||||
|
switch t.Kind {
|
||||||
|
case KindString:
|
||||||
|
if t.Name == "datetime" {
|
||||||
|
return map[string]any{"type": "string", "format": "date-time"}
|
||||||
|
}
|
||||||
|
return map[string]any{"type": "string"}
|
||||||
|
case KindInt:
|
||||||
|
return map[string]any{"type": "integer"}
|
||||||
|
case KindNumber:
|
||||||
|
return map[string]any{"type": "number"}
|
||||||
|
case KindBool:
|
||||||
|
return map[string]any{"type": "boolean"}
|
||||||
|
case KindArray:
|
||||||
|
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
|
||||||
|
case KindMap:
|
||||||
|
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
|
||||||
|
case KindAny, KindUnknown, KindRaw:
|
||||||
|
return map[string]any{}
|
||||||
|
case KindRef:
|
||||||
|
if t.Name == "nullable" {
|
||||||
|
inner := g.typeSchema(*t.Inner)
|
||||||
|
if ref, ok := inner["$ref"]; ok {
|
||||||
|
return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
|
||||||
|
}
|
||||||
|
inner["nullable"] = true
|
||||||
|
return inner
|
||||||
|
}
|
||||||
|
if alias, ok := g.aliasByName[t.Name]; ok {
|
||||||
|
return g.typeSchema(alias.Underlying)
|
||||||
|
}
|
||||||
|
if _, ok := g.byName[t.Name]; ok {
|
||||||
|
return map[string]any{"$ref": "#/components/schemas/" + t.Name}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
|
||||||
|
base := baseKind(t)
|
||||||
|
numeric := base.Kind == KindInt || base.Kind == KindNumber
|
||||||
|
str := base.Kind == KindString
|
||||||
|
for _, r := range rules {
|
||||||
|
switch r.Name {
|
||||||
|
case "gte":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
}
|
||||||
|
case "lte":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
}
|
||||||
|
case "gt":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
sch["exclusiveMinimum"] = true
|
||||||
|
}
|
||||||
|
case "lt":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
sch["exclusiveMaximum"] = true
|
||||||
|
}
|
||||||
|
case "min":
|
||||||
|
if numeric {
|
||||||
|
sch["minimum"] = coerceExample(r.Param, base)
|
||||||
|
} else if str {
|
||||||
|
if n, err := strconv.Atoi(r.Param); err == nil {
|
||||||
|
sch["minLength"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "max":
|
||||||
|
if numeric {
|
||||||
|
sch["maximum"] = coerceExample(r.Param, base)
|
||||||
|
} else if str {
|
||||||
|
if n, err := strconv.Atoi(r.Param); err == nil {
|
||||||
|
sch["maxLength"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "oneof":
|
||||||
|
vals := strings.Fields(r.Param)
|
||||||
|
if len(vals) > 0 {
|
||||||
|
enum := make([]any, len(vals))
|
||||||
|
for i, v := range vals {
|
||||||
|
enum[i] = v
|
||||||
|
}
|
||||||
|
sch["enum"] = enum
|
||||||
|
}
|
||||||
|
case "email":
|
||||||
|
if str {
|
||||||
|
sch["format"] = "email"
|
||||||
|
}
|
||||||
|
case "url":
|
||||||
|
if str {
|
||||||
|
sch["format"] = "uri"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
|
|||||||
"ClientTraffic",
|
"ClientTraffic",
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Path: resolveRel(root, "web/service"),
|
||||||
|
StructAllow: setOf(
|
||||||
|
"InboundOption",
|
||||||
|
"ApiTokenView",
|
||||||
|
"ProbeResultUI",
|
||||||
|
),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
schemas, aliases, err := walkPackages(requests)
|
schemas, aliases, err := walkPackages(requests)
|
||||||
@@ -94,6 +102,14 @@ func run(root, outDir string) error {
|
|||||||
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
|
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
examplesBuf := &bytes.Buffer{}
|
||||||
|
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
schemasBuf := &bytes.Buffer{}
|
||||||
|
if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -101,6 +117,12 @@ func run(root, outDir string) error {
|
|||||||
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
|
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ type Field struct {
|
|||||||
Skip bool
|
Skip bool
|
||||||
Validate []ValidateRule
|
Validate []ValidateRule
|
||||||
Doc string
|
Doc string
|
||||||
|
Example string
|
||||||
}
|
}
|
||||||
|
|
||||||
type TypeRef struct {
|
type TypeRef struct {
|
||||||
@@ -59,10 +60,11 @@ type ValidateRule struct {
|
|||||||
Param string
|
Param string
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
|
func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
|
||||||
tag := reflect.StructTag(strings.Trim(raw, "`"))
|
tag := reflect.StructTag(strings.Trim(raw, "`"))
|
||||||
json = tag.Get("json")
|
json = tag.Get("json")
|
||||||
validate = tag.Get("validate")
|
validate = tag.Get("validate")
|
||||||
|
example = tag.Get("example")
|
||||||
if g := tag.Get("gorm"); g != "" {
|
if g := tag.Get("gorm"); g != "" {
|
||||||
for part := range strings.SplitSeq(g, ";") {
|
for part := range strings.SplitSeq(g, ";") {
|
||||||
if strings.TrimSpace(part) == "-" {
|
if strings.TrimSpace(part) == "-" {
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
if fld.Tag != nil {
|
if fld.Tag != nil {
|
||||||
tag = fld.Tag.Value
|
tag = fld.Tag.Value
|
||||||
}
|
}
|
||||||
jsonTag, validateTag, gormDash := parseStructTag(tag)
|
jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
|
||||||
if gormDash && jsonTag == "" {
|
if gormDash && jsonTag == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
Optional: omitempty || isPointer(fld.Type),
|
Optional: omitempty || isPointer(fld.Type),
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
|
Example: exampleTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
|
|||||||
Optional: omitempty || isPointer(fld.Type),
|
Optional: omitempty || isPointer(fld.Type),
|
||||||
Validate: validate,
|
Validate: validate,
|
||||||
Doc: doc,
|
Doc: doc,
|
||||||
|
Example: exampleTag,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -582,12 +582,14 @@ prompt_and_setup_ssl() {
|
|||||||
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
|
||||||
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
|
||||||
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
|
||||||
|
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
|
||||||
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
|
||||||
|
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
|
||||||
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
read -rp "Choose an option (default 2 for IP): " ssl_choice
|
||||||
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
ssl_choice="${ssl_choice// /}" # Trim whitespace
|
||||||
|
|
||||||
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
|
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
|
||||||
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
|
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
|
||||||
ssl_choice="2"
|
ssl_choice="2"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -706,6 +708,41 @@ prompt_and_setup_ssl() {
|
|||||||
|
|
||||||
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
;;
|
;;
|
||||||
|
4)
|
||||||
|
echo ""
|
||||||
|
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
|
||||||
|
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
|
||||||
|
echo -e "${yellow}Only safe when:${plain}"
|
||||||
|
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
|
||||||
|
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
SSL_SCHEME="http"
|
||||||
|
SSL_HOST="${server_ip}"
|
||||||
|
|
||||||
|
local bind_local=""
|
||||||
|
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
|
||||||
|
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
|
||||||
|
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
|
||||||
|
SSL_HOST="127.0.0.1"
|
||||||
|
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
|
||||||
|
echo -e " Standard SSH command:"
|
||||||
|
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " If using an SSH key:"
|
||||||
|
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
|
||||||
|
echo -e " Then open in your browser:"
|
||||||
|
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
|
||||||
|
echo ""
|
||||||
|
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
|
||||||
|
else
|
||||||
|
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
|
||||||
|
echo -e "${green}✓ SSL setup skipped.${plain}"
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
|
||||||
SSL_HOST="${server_ip}"
|
SSL_HOST="${server_ip}"
|
||||||
|
|||||||
@@ -0,0 +1,809 @@
|
|||||||
|
// Package link provides parsers for VPN share links (vmess://, vless://, etc.)
|
||||||
|
// and subscription bodies (typically base64-encoded newline lists of such links).
|
||||||
|
// The output shape matches the wire format used by the panel's Xray template
|
||||||
|
// outbounds array so that parsed objects can be injected directly.
|
||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Outbound is the minimal shape we emit for each parsed link.
|
||||||
|
// Extra fields (mux, etc.) are carried inside settings/streamSettings.
|
||||||
|
type Outbound map[string]any
|
||||||
|
|
||||||
|
// ParseResult holds a parsed outbound together with a stable identity string
|
||||||
|
// that can be used to correlate the same logical server across refreshes
|
||||||
|
// (even if the remark changes).
|
||||||
|
type ParseResult struct {
|
||||||
|
Outbound Outbound
|
||||||
|
Identity string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseSubscriptionBody accepts the raw body returned by a subscription URL.
|
||||||
|
// It handles the common case where the body is a base64-encoded blob of
|
||||||
|
// newline-separated links, and also tolerates an already-decoded text body.
|
||||||
|
// It returns the list of successfully parsed outbounds (in order) and their
|
||||||
|
// corresponding identities.
|
||||||
|
func ParseSubscriptionBody(body []byte) ([]Outbound, []string, error) {
|
||||||
|
text := strings.TrimSpace(string(body))
|
||||||
|
if text == "" {
|
||||||
|
return nil, nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try base64 decode first (standard and URL-safe variants).
|
||||||
|
if decoded, ok := tryBase64(text); ok {
|
||||||
|
text = strings.TrimSpace(decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
lines := splitLines(text)
|
||||||
|
var outbounds []Outbound
|
||||||
|
var identities []string
|
||||||
|
|
||||||
|
for _, ln := range lines {
|
||||||
|
ln = strings.TrimSpace(ln)
|
||||||
|
if ln == "" || strings.HasPrefix(ln, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
res, err := ParseLink(ln)
|
||||||
|
if err != nil || res == nil {
|
||||||
|
// Ignore unparseable lines (comments, unsupported protocols, etc.)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
outbounds = append(outbounds, res.Outbound)
|
||||||
|
identities = append(identities, res.Identity)
|
||||||
|
}
|
||||||
|
return outbounds, identities, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryBase64(s string) (string, bool) {
|
||||||
|
// Remove whitespace that some providers insert.
|
||||||
|
clean := strings.Map(func(r rune) rune {
|
||||||
|
if r == ' ' || r == '\n' || r == '\r' || r == '\t' {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
|
||||||
|
// Common padding fix
|
||||||
|
for len(clean)%4 != 0 {
|
||||||
|
clean += "="
|
||||||
|
}
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(clean); err == nil {
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
// URL-safe (no padding)
|
||||||
|
if b, err := base64.RawURLEncoding.DecodeString(clean); err == nil {
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
// URL-safe with padding
|
||||||
|
if b, err := base64.URLEncoding.DecodeString(clean); err == nil {
|
||||||
|
return string(b), true
|
||||||
|
}
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitLines(s string) []string {
|
||||||
|
// Accept \n, \r\n, and also some providers use literal \n in the text.
|
||||||
|
s = strings.ReplaceAll(s, `\n`, "\n")
|
||||||
|
return strings.FieldsFunc(s, func(r rune) bool { return r == '\n' || r == '\r' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLink parses a single share link and returns the outbound object plus
|
||||||
|
// a stable identity for tag correlation. Supported schemes:
|
||||||
|
// - vmess://
|
||||||
|
// - vless://
|
||||||
|
// - trojan://
|
||||||
|
// - ss:// (modern and legacy)
|
||||||
|
// - hysteria2:// (also hy2://)
|
||||||
|
// - wireguard:// (also wg://)
|
||||||
|
func ParseLink(link string) (*ParseResult, error) {
|
||||||
|
link = strings.TrimSpace(link)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(link, "vmess://"):
|
||||||
|
return parseVmess(link)
|
||||||
|
case strings.HasPrefix(link, "vless://"):
|
||||||
|
return parseVless(link)
|
||||||
|
case strings.HasPrefix(link, "trojan://"):
|
||||||
|
return parseTrojan(link)
|
||||||
|
case strings.HasPrefix(link, "ss://"):
|
||||||
|
return parseShadowsocks(link)
|
||||||
|
case strings.HasPrefix(link, "hysteria2://"), strings.HasPrefix(link, "hy2://"):
|
||||||
|
return parseHysteria2(link)
|
||||||
|
case strings.HasPrefix(link, "wireguard://"), strings.HasPrefix(link, "wg://"):
|
||||||
|
return parseWireguard(link)
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("unsupported link scheme")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- vmess ---
|
||||||
|
|
||||||
|
func parseVmess(link string) (*ParseResult, error) {
|
||||||
|
b64 := strings.TrimPrefix(link, "vmess://")
|
||||||
|
// vmess:// base64(json)
|
||||||
|
raw, err := base64.StdEncoding.DecodeString(padBase64(b64))
|
||||||
|
if err != nil {
|
||||||
|
// Some providers use raw URL-safe
|
||||||
|
raw, err = base64.RawURLEncoding.DecodeString(b64)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("vmess decode: %w", err)
|
||||||
|
}
|
||||||
|
var j map[string]any
|
||||||
|
if err := json.Unmarshal(raw, &j); err != nil {
|
||||||
|
return nil, fmt.Errorf("vmess json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := vmessIdentity(j)
|
||||||
|
|
||||||
|
network := getString(j, "net", "tcp")
|
||||||
|
security := "none"
|
||||||
|
if tls, _ := j["tls"].(string); tls == "tls" {
|
||||||
|
security = "tls"
|
||||||
|
}
|
||||||
|
stream := buildStream(network, security)
|
||||||
|
|
||||||
|
// Map known fields (best effort, matching frontend parser coverage)
|
||||||
|
switch network {
|
||||||
|
case "ws":
|
||||||
|
if host, ok := j["host"].(string); ok {
|
||||||
|
setWS(stream, host, getString(j, "path", "/"))
|
||||||
|
}
|
||||||
|
case "grpc":
|
||||||
|
svc := getString(j, "path", "")
|
||||||
|
if auth, ok := j["authority"].(string); ok && auth != "" {
|
||||||
|
(stream["grpcSettings"].(map[string]any))["authority"] = auth
|
||||||
|
}
|
||||||
|
(stream["grpcSettings"].(map[string]any))["serviceName"] = svc
|
||||||
|
(stream["grpcSettings"].(map[string]any))["multiMode"] = getString(j, "type", "") == "multi"
|
||||||
|
case "httpupgrade":
|
||||||
|
setHTTPUpgrade(stream, getString(j, "host", ""), getString(j, "path", "/"))
|
||||||
|
case "xhttp":
|
||||||
|
xh := stream["xhttpSettings"].(map[string]any)
|
||||||
|
xh["host"] = getString(j, "host", "")
|
||||||
|
xh["path"] = getString(j, "path", "/")
|
||||||
|
if m := getString(j, "mode", ""); m != "" {
|
||||||
|
xh["mode"] = m
|
||||||
|
}
|
||||||
|
// xhttp advanced keys are passed through if present in the json
|
||||||
|
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs"} {
|
||||||
|
if v, ok := j[k]; ok {
|
||||||
|
xh[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tcp":
|
||||||
|
if getString(j, "type", "") == "http" {
|
||||||
|
stream["tcpSettings"] = map[string]any{
|
||||||
|
"header": map[string]any{
|
||||||
|
"type": "http",
|
||||||
|
"request": map[string]any{
|
||||||
|
"version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"path": splitComma(getString(j, "path", "/")),
|
||||||
|
"headers": map[string]any{"Host": splitComma(getString(j, "host", ""))},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if security == "tls" {
|
||||||
|
tls := stream["tlsSettings"].(map[string]any)
|
||||||
|
tls["serverName"] = getString(j, "sni", "")
|
||||||
|
tls["fingerprint"] = getString(j, "fp", "")
|
||||||
|
if alpn := getString(j, "alpn", ""); alpn != "" {
|
||||||
|
tls["alpn"] = splitComma(alpn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
port := num(j["port"])
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "vmess",
|
||||||
|
"tag": getString(j, "ps", ""),
|
||||||
|
"settings": map[string]any{
|
||||||
|
"vnext": []any{
|
||||||
|
map[string]any{
|
||||||
|
"address": getString(j, "add", ""),
|
||||||
|
"port": port,
|
||||||
|
"users": []any{
|
||||||
|
map[string]any{
|
||||||
|
"id": getString(j, "id", ""),
|
||||||
|
"security": getString(j, "scy", "auto"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"streamSettings": stream,
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func vmessIdentity(j map[string]any) string {
|
||||||
|
// Remove ps (remark) for identity
|
||||||
|
core := map[string]any{}
|
||||||
|
for k, v := range j {
|
||||||
|
if k == "ps" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
core[k] = v
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(core)
|
||||||
|
return "vmess:" + string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- vless / trojan (URL forms) ---
|
||||||
|
|
||||||
|
func parseVless(link string) (*ParseResult, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Scheme != "vless" {
|
||||||
|
return nil, fmt.Errorf("not vless")
|
||||||
|
}
|
||||||
|
id := u.User.Username()
|
||||||
|
host := u.Hostname()
|
||||||
|
port := defaultPort(u.Port(), 443)
|
||||||
|
params := u.Query()
|
||||||
|
network := params.Get("type")
|
||||||
|
if network == "" {
|
||||||
|
network = "tcp"
|
||||||
|
}
|
||||||
|
security := params.Get("security")
|
||||||
|
if security == "" {
|
||||||
|
security = "none"
|
||||||
|
}
|
||||||
|
stream := buildStream(network, security)
|
||||||
|
applyTransport(stream, params)
|
||||||
|
applySecurity(stream, params)
|
||||||
|
applyFinalMask(stream, params)
|
||||||
|
|
||||||
|
identity := "vless:" + u.Scheme + "://" + id + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||||
|
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "vless",
|
||||||
|
"tag": decodeHash(u.Fragment),
|
||||||
|
"settings": map[string]any{
|
||||||
|
"address": host,
|
||||||
|
"port": port,
|
||||||
|
"id": id,
|
||||||
|
"flow": params.Get("flow"),
|
||||||
|
"encryption": firstNonEmpty(params.Get("encryption"), "none"),
|
||||||
|
},
|
||||||
|
"streamSettings": stream,
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTrojan(link string) (*ParseResult, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Scheme != "trojan" {
|
||||||
|
return nil, fmt.Errorf("not trojan")
|
||||||
|
}
|
||||||
|
pw := u.User.Username()
|
||||||
|
host := u.Hostname()
|
||||||
|
port := defaultPort(u.Port(), 443)
|
||||||
|
params := u.Query()
|
||||||
|
network := params.Get("type")
|
||||||
|
if network == "" {
|
||||||
|
network = "tcp"
|
||||||
|
}
|
||||||
|
security := params.Get("security")
|
||||||
|
if security == "" {
|
||||||
|
security = "tls"
|
||||||
|
}
|
||||||
|
stream := buildStream(network, security)
|
||||||
|
applyTransport(stream, params)
|
||||||
|
applySecurity(stream, params)
|
||||||
|
applyFinalMask(stream, params)
|
||||||
|
|
||||||
|
identity := "trojan:" + u.Scheme + "://" + pw + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||||
|
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "trojan",
|
||||||
|
"tag": decodeHash(u.Fragment),
|
||||||
|
"settings": map[string]any{
|
||||||
|
"servers": []any{
|
||||||
|
map[string]any{"address": host, "port": port, "password": pw},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"streamSettings": stream,
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- shadowsocks ---
|
||||||
|
|
||||||
|
func parseShadowsocks(link string) (*ParseResult, error) {
|
||||||
|
// Two shapes:
|
||||||
|
// ss://base64(method:pass)@host:port#remark
|
||||||
|
// ss://base64(method:pass@host:port)#remark
|
||||||
|
remark := ""
|
||||||
|
if i := strings.Index(link, "#"); i >= 0 {
|
||||||
|
remark, _ = url.QueryUnescape(link[i+1:])
|
||||||
|
link = link[:i]
|
||||||
|
}
|
||||||
|
core := strings.TrimPrefix(link, "ss://")
|
||||||
|
at := strings.Index(core, "@")
|
||||||
|
if at >= 0 {
|
||||||
|
// modern
|
||||||
|
userB64 := core[:at]
|
||||||
|
hp := core[at+1:]
|
||||||
|
userInfo, err := base64DecodeFlexible(userB64)
|
||||||
|
if err != nil {
|
||||||
|
userInfo = userB64 // not b64, rare
|
||||||
|
}
|
||||||
|
colon := strings.LastIndex(hp, ":")
|
||||||
|
if colon < 0 {
|
||||||
|
return nil, fmt.Errorf("bad ss host:port")
|
||||||
|
}
|
||||||
|
host := hp[:colon]
|
||||||
|
port, _ := strconv.Atoi(hp[colon+1:])
|
||||||
|
method, pass := splitMethodPass(userInfo)
|
||||||
|
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"tag": remark,
|
||||||
|
"settings": map[string]any{
|
||||||
|
"servers": []any{
|
||||||
|
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
// legacy: whole thing b64
|
||||||
|
dec, err := base64DecodeFlexible(core)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
at = strings.Index(dec, "@")
|
||||||
|
if at < 0 {
|
||||||
|
return nil, fmt.Errorf("bad legacy ss")
|
||||||
|
}
|
||||||
|
userInfo := dec[:at]
|
||||||
|
hp := dec[at+1:]
|
||||||
|
colon := strings.LastIndex(hp, ":")
|
||||||
|
if colon < 0 {
|
||||||
|
return nil, fmt.Errorf("bad legacy ss hp")
|
||||||
|
}
|
||||||
|
host := hp[:colon]
|
||||||
|
port, _ := strconv.Atoi(hp[colon+1:])
|
||||||
|
method, pass := splitMethodPass(userInfo)
|
||||||
|
identity := "ss:" + method + ":" + pass + "@" + host + ":" + strconv.Itoa(port)
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "shadowsocks",
|
||||||
|
"tag": remark,
|
||||||
|
"settings": map[string]any{
|
||||||
|
"servers": []any{
|
||||||
|
map[string]any{"address": host, "port": port, "password": pass, "method": method},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitMethodPass(userInfo string) (string, string) {
|
||||||
|
colon := strings.Index(userInfo, ":")
|
||||||
|
if colon < 0 {
|
||||||
|
return "2022-blake3-aes-128-gcm", userInfo // guess
|
||||||
|
}
|
||||||
|
return userInfo[:colon], userInfo[colon+1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- hysteria2 ---
|
||||||
|
|
||||||
|
func parseHysteria2(link string) (*ParseResult, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Scheme != "hysteria2" && u.Scheme != "hy2" {
|
||||||
|
return nil, fmt.Errorf("not hysteria2")
|
||||||
|
}
|
||||||
|
auth := u.User.Username()
|
||||||
|
host := u.Hostname()
|
||||||
|
port := defaultPort(u.Port(), 443)
|
||||||
|
params := u.Query()
|
||||||
|
|
||||||
|
stream := map[string]any{
|
||||||
|
"network": "hysteria",
|
||||||
|
"security": "tls",
|
||||||
|
"hysteriaSettings": map[string]any{
|
||||||
|
"version": 2,
|
||||||
|
"auth": auth,
|
||||||
|
"udpIdleTimeout": 60,
|
||||||
|
},
|
||||||
|
"tlsSettings": map[string]any{
|
||||||
|
"serverName": params.Get("sni"),
|
||||||
|
"alpn": splitCommaOrDefault(params.Get("alpn"), []string{"h3"}),
|
||||||
|
"fingerprint": params.Get("fp"),
|
||||||
|
"echConfigList": params.Get("ech"),
|
||||||
|
"verifyPeerCertByName": "",
|
||||||
|
"pinnedPeerCertSha256": params.Get("pinSHA256"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
applyFinalMask(stream, params)
|
||||||
|
|
||||||
|
identity := "hysteria2:" + auth + "@" + host + ":" + strconv.Itoa(port) + "?" + canonicalQuery(params)
|
||||||
|
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "hysteria",
|
||||||
|
"tag": decodeHash(u.Fragment),
|
||||||
|
"settings": map[string]any{"address": host, "port": port, "version": 2},
|
||||||
|
"streamSettings": stream,
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- wireguard ---
|
||||||
|
|
||||||
|
func parseWireguard(link string) (*ParseResult, error) {
|
||||||
|
u, err := url.Parse(link)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if u.Scheme != "wireguard" && u.Scheme != "wg" {
|
||||||
|
return nil, fmt.Errorf("not wireguard")
|
||||||
|
}
|
||||||
|
secret, _ := url.QueryUnescape(u.User.Username())
|
||||||
|
params := u.Query()
|
||||||
|
host := u.Hostname()
|
||||||
|
portStr := u.Port()
|
||||||
|
endpoint := host
|
||||||
|
if portStr != "" {
|
||||||
|
endpoint = host + ":" + portStr
|
||||||
|
}
|
||||||
|
|
||||||
|
addrRaw := firstParam(params, "address", "ip")
|
||||||
|
allowedRaw := firstParam(params, "allowedips", "allowed_ips")
|
||||||
|
addrs := splitComma(addrRaw)
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
addrs = []string{"0.0.0.0/0", "::/0"}
|
||||||
|
}
|
||||||
|
allowed := splitComma(allowedRaw)
|
||||||
|
if len(allowed) == 0 {
|
||||||
|
allowed = []string{"0.0.0.0/0", "::/0"}
|
||||||
|
}
|
||||||
|
|
||||||
|
peer := map[string]any{
|
||||||
|
"publicKey": firstParam(params, "publickey", "publicKey", "public_key", "peerPublicKey"),
|
||||||
|
"endpoint": endpoint,
|
||||||
|
"allowedIPs": allowed,
|
||||||
|
}
|
||||||
|
if psk := firstParam(params, "presharedkey", "preshared_key", "pre-shared-key", "psk"); psk != "" {
|
||||||
|
peer["preSharedKey"] = psk
|
||||||
|
}
|
||||||
|
if ka := firstParam(params, "keepalive", "persistentkeepalive", "persistent_keepalive"); ka != "" {
|
||||||
|
if n, err := strconv.Atoi(ka); err == nil {
|
||||||
|
peer["keepAlive"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
settings := map[string]any{
|
||||||
|
"secretKey": secret,
|
||||||
|
"address": addrs,
|
||||||
|
"peers": []any{peer},
|
||||||
|
}
|
||||||
|
if mtu := params.Get("mtu"); mtu != "" {
|
||||||
|
if n, err := strconv.Atoi(mtu); err == nil {
|
||||||
|
settings["mtu"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if res := params.Get("reserved"); res != "" {
|
||||||
|
parts := splitComma(res)
|
||||||
|
var iv []int
|
||||||
|
for _, p := range parts {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(p)); err == nil {
|
||||||
|
iv = append(iv, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(iv) > 0 {
|
||||||
|
settings["reserved"] = iv
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
identity := "wireguard:" + secret + "@" + endpoint + "?" + canonicalQuery(params)
|
||||||
|
|
||||||
|
ob := Outbound{
|
||||||
|
"protocol": "wireguard",
|
||||||
|
"tag": decodeHash(u.Fragment),
|
||||||
|
"settings": settings,
|
||||||
|
}
|
||||||
|
return &ParseResult{Outbound: ob, Identity: identity}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
func buildStream(network, security string) map[string]any {
|
||||||
|
stream := map[string]any{"network": network, "security": security}
|
||||||
|
switch network {
|
||||||
|
case "tcp":
|
||||||
|
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
||||||
|
case "kcp":
|
||||||
|
stream["kcpSettings"] = map[string]any{
|
||||||
|
"mtu": 1350, "tti": 20, "uplinkCapacity": 5, "downlinkCapacity": 20,
|
||||||
|
"cwndMultiplier": 1, "maxSendingWindow": 2097152,
|
||||||
|
}
|
||||||
|
case "ws":
|
||||||
|
stream["wsSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}, "heartbeatPeriod": 0}
|
||||||
|
case "grpc":
|
||||||
|
stream["grpcSettings"] = map[string]any{"serviceName": "", "authority": "", "multiMode": false}
|
||||||
|
case "httpupgrade":
|
||||||
|
stream["httpupgradeSettings"] = map[string]any{"path": "/", "host": "", "headers": map[string]any{}}
|
||||||
|
case "xhttp":
|
||||||
|
stream["xhttpSettings"] = map[string]any{
|
||||||
|
"path": "/", "host": "", "mode": "auto", "headers": map[string]any{},
|
||||||
|
"xPaddingBytes": "100-1000", "scMaxEachPostBytes": "1000000",
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
stream["tcpSettings"] = map[string]any{"header": map[string]any{"type": "none"}}
|
||||||
|
}
|
||||||
|
if security == "tls" {
|
||||||
|
stream["tlsSettings"] = map[string]any{
|
||||||
|
"serverName": "", "alpn": []any{}, "fingerprint": "",
|
||||||
|
"echConfigList": "", "verifyPeerCertByName": "", "pinnedPeerCertSha256": "",
|
||||||
|
}
|
||||||
|
} else if security == "reality" {
|
||||||
|
stream["realitySettings"] = map[string]any{
|
||||||
|
"publicKey": "", "fingerprint": "chrome", "serverName": "",
|
||||||
|
"shortId": "", "spiderX": "", "mldsa65Verify": "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return stream
|
||||||
|
}
|
||||||
|
|
||||||
|
func setWS(stream map[string]any, host, path string) {
|
||||||
|
ws := stream["wsSettings"].(map[string]any)
|
||||||
|
ws["host"] = host
|
||||||
|
ws["path"] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func setHTTPUpgrade(stream map[string]any, host, path string) {
|
||||||
|
h := stream["httpupgradeSettings"].(map[string]any)
|
||||||
|
h["host"] = host
|
||||||
|
h["path"] = path
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyTransport(stream map[string]any, p url.Values) {
|
||||||
|
net := stream["network"].(string)
|
||||||
|
host := p.Get("host")
|
||||||
|
path := firstNonEmpty(p.Get("path"), "/")
|
||||||
|
switch net {
|
||||||
|
case "ws":
|
||||||
|
setWS(stream, host, path)
|
||||||
|
case "grpc":
|
||||||
|
gs := stream["grpcSettings"].(map[string]any)
|
||||||
|
gs["serviceName"] = firstNonEmpty(p.Get("serviceName"), p.Get("path"))
|
||||||
|
gs["authority"] = p.Get("authority")
|
||||||
|
gs["multiMode"] = p.Get("mode") == "multi"
|
||||||
|
case "httpupgrade":
|
||||||
|
setHTTPUpgrade(stream, host, path)
|
||||||
|
case "xhttp":
|
||||||
|
xh := stream["xhttpSettings"].(map[string]any)
|
||||||
|
xh["host"] = host
|
||||||
|
xh["path"] = path
|
||||||
|
if m := p.Get("mode"); m != "" {
|
||||||
|
xh["mode"] = m
|
||||||
|
}
|
||||||
|
// A few advanced xhttp fields that are commonly carried
|
||||||
|
for _, k := range []string{"xPaddingBytes", "scMaxEachPostBytes", "scMinPostsIntervalMs", "uplinkChunkSize"} {
|
||||||
|
if v := p.Get(k); v != "" {
|
||||||
|
xh[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "tcp":
|
||||||
|
if p.Get("headerType") == "http" || p.Get("type") == "http" {
|
||||||
|
stream["tcpSettings"] = map[string]any{
|
||||||
|
"header": map[string]any{
|
||||||
|
"type": "http",
|
||||||
|
"request": map[string]any{
|
||||||
|
"version": "1.1",
|
||||||
|
"method": "GET",
|
||||||
|
"path": splitComma(path),
|
||||||
|
"headers": map[string]any{"Host": splitComma(host)},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applySecurity(stream map[string]any, p url.Values) {
|
||||||
|
sec := stream["security"].(string)
|
||||||
|
if sec == "tls" {
|
||||||
|
tls := stream["tlsSettings"].(map[string]any)
|
||||||
|
tls["serverName"] = p.Get("sni")
|
||||||
|
tls["fingerprint"] = p.Get("fp")
|
||||||
|
if alpn := p.Get("alpn"); alpn != "" {
|
||||||
|
tls["alpn"] = splitComma(alpn)
|
||||||
|
}
|
||||||
|
tls["echConfigList"] = p.Get("ech")
|
||||||
|
tls["pinnedPeerCertSha256"] = p.Get("pcs")
|
||||||
|
} else if sec == "reality" {
|
||||||
|
re := stream["realitySettings"].(map[string]any)
|
||||||
|
re["serverName"] = p.Get("sni")
|
||||||
|
re["fingerprint"] = firstNonEmpty(p.Get("fp"), "chrome")
|
||||||
|
re["publicKey"] = p.Get("pbk")
|
||||||
|
re["shortId"] = p.Get("sid")
|
||||||
|
re["spiderX"] = p.Get("spx")
|
||||||
|
re["mldsa65Verify"] = p.Get("pqv")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyFinalMask(stream map[string]any, p url.Values) {
|
||||||
|
if fm := p.Get("fm"); fm != "" {
|
||||||
|
var parsed any
|
||||||
|
if json.Unmarshal([]byte(fm), &parsed) == nil {
|
||||||
|
stream["finalmask"] = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(a, b string) string {
|
||||||
|
if a != "" {
|
||||||
|
return a
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstParam(p url.Values, keys ...string) string {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := p.Get(k); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalQuery(p url.Values) string {
|
||||||
|
// Sort keys for stable identity
|
||||||
|
keys := make([]string, 0, len(p))
|
||||||
|
for k := range p {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
// simple sort
|
||||||
|
for i := 0; i < len(keys); i++ {
|
||||||
|
for j := i + 1; j < len(keys); j++ {
|
||||||
|
if keys[j] < keys[i] {
|
||||||
|
keys[i], keys[j] = keys[j], keys[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts := make([]string, 0, len(keys))
|
||||||
|
for _, k := range keys {
|
||||||
|
for _, v := range p[k] {
|
||||||
|
parts = append(parts, k+"="+v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(parts, "&")
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeHash(h string) string {
|
||||||
|
if h == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if dec, err := url.QueryUnescape(h); err == nil {
|
||||||
|
return dec
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultPort(p string, def int) int {
|
||||||
|
if p == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(p)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(v any) int {
|
||||||
|
switch x := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(x)
|
||||||
|
case int:
|
||||||
|
return x
|
||||||
|
case int64:
|
||||||
|
return int(x)
|
||||||
|
case string:
|
||||||
|
n, _ := strconv.Atoi(x)
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(m map[string]any, key, def string) string {
|
||||||
|
if v, ok := m[key]; ok {
|
||||||
|
if s, ok := v.(string); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitComma(s string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parts := strings.Split(s, ",")
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, p := range parts {
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p != "" {
|
||||||
|
out = append(out, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitCommaOrDefault(s string, def []string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return splitComma(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
func padBase64(s string) string {
|
||||||
|
for len(s)%4 != 0 {
|
||||||
|
s += "="
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func base64DecodeFlexible(s string) (string, error) {
|
||||||
|
s = padBase64(s)
|
||||||
|
if b, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
if b, err := base64.RawURLEncoding.DecodeString(strings.TrimRight(s, "=")); err == nil {
|
||||||
|
return string(b), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("base64 decode failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SlugRemark turns a free-form remark into a conservative DNS-ish tag segment.
|
||||||
|
var slugRe = regexp.MustCompile(`[^a-z0-9]+`)
|
||||||
|
|
||||||
|
func SlugRemark(remark string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(remark))
|
||||||
|
s = slugRe.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
if s == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// collapse runs of dashes
|
||||||
|
for strings.Contains(s, "--") {
|
||||||
|
s = strings.ReplaceAll(s, "--", "-")
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SuggestTag builds a tag from a prefix and a remark (or index fallback).
|
||||||
|
// It is intended for initial assignment; stability is handled by the service layer.
|
||||||
|
func SuggestTag(prefix, remark string, idx int) string {
|
||||||
|
base := SlugRemark(remark)
|
||||||
|
if base == "" {
|
||||||
|
base = fmt.Sprintf("%d", idx)
|
||||||
|
}
|
||||||
|
p := strings.TrimSuffix(prefix, "-")
|
||||||
|
if p != "" {
|
||||||
|
return p + "-" + base
|
||||||
|
}
|
||||||
|
return base
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package link
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseVmessLink(t *testing.T) {
|
||||||
|
// vmess:// + base64 of:
|
||||||
|
// {"v":"2","ps":"test","add":"1.2.3.4","port":443,"id":"uuid","aid":"0","net":"ws","type":"","host":"ex.com","path":"/","tls":"tls"}
|
||||||
|
link := "vmess://eyJ2IjoiMiIsInBzIjoidGVzdCIsImFkZCI6IjEuMi4zLjQiLCJwb3J0Ijo0NDMsImlkIjoidXVpZCIsImFpZCI6IjAiLCJuZXQiOiJ3cyIsInR5cGUiOiIiLCJob3N0IjoiZXguY29tIiwicGF0aCI6Ii8iLCJ0bHMiOiJ0bHMifQ=="
|
||||||
|
res, err := ParseLink(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse vmess: %v", err)
|
||||||
|
}
|
||||||
|
if res.Outbound["protocol"] != "vmess" {
|
||||||
|
t.Errorf("expected vmess protocol, got %v", res.Outbound["protocol"])
|
||||||
|
}
|
||||||
|
if res.Outbound["tag"] != "test" {
|
||||||
|
t.Errorf("expected tag 'test', got %v", res.Outbound["tag"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseVlessLink(t *testing.T) {
|
||||||
|
link := "vless://uuid@1.2.3.4:443?type=ws&security=tls&path=/&host=ex.com#node1"
|
||||||
|
res, err := ParseLink(link)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse vless: %v", err)
|
||||||
|
}
|
||||||
|
if res.Outbound["protocol"] != "vless" {
|
||||||
|
t.Fatalf("bad protocol")
|
||||||
|
}
|
||||||
|
if res.Outbound["tag"] != "node1" {
|
||||||
|
t.Errorf("tag mismatch: %v", res.Outbound["tag"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseSubscriptionBody_Base64(t *testing.T) {
|
||||||
|
// base64 of the two joined links:
|
||||||
|
// vless://u@h:443?type=tcp#A\nvless://u2@h2:443?type=tcp#B
|
||||||
|
b64 := "dmxlc3M6Ly91QGg6NDQzP3R5cGU9dGNwI0EKdmxlc3M6Ly91MkBoMjo0NDM/dHlwZT10Y3AjQg=="
|
||||||
|
obs, ids, err := ParseSubscriptionBody([]byte(b64))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parse sub body: %v", err)
|
||||||
|
}
|
||||||
|
if len(obs) != 2 {
|
||||||
|
t.Fatalf("expected 2 outbounds, got %d", len(obs))
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(ids[0], "vless:") || !strings.HasPrefix(ids[1], "vless:") {
|
||||||
|
t.Errorf("bad identities: %v", ids)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlugAndSuggest(t *testing.T) {
|
||||||
|
if SlugRemark("Hello World!") != "hello-world" {
|
||||||
|
t.Errorf("slug failed")
|
||||||
|
}
|
||||||
|
tag := SuggestTag("hk-", " SG 01 !! ", 0)
|
||||||
|
if tag != "hk-sg-01" {
|
||||||
|
t.Errorf("suggest tag got %q", tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
+15
-7
@@ -14,13 +14,15 @@ import (
|
|||||||
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
|
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
|
||||||
type APIController struct {
|
type APIController struct {
|
||||||
BaseController
|
BaseController
|
||||||
inboundController *InboundController
|
inboundController *InboundController
|
||||||
serverController *ServerController
|
serverController *ServerController
|
||||||
nodeController *NodeController
|
nodeController *NodeController
|
||||||
settingService service.SettingService
|
settingController *SettingController
|
||||||
userService service.UserService
|
xraySettingController *XraySettingController
|
||||||
apiTokenService service.ApiTokenService
|
settingService service.SettingService
|
||||||
Tgbot service.Tgbot
|
userService service.UserService
|
||||||
|
apiTokenService service.ApiTokenService
|
||||||
|
Tgbot service.Tgbot
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAPIController creates a new APIController instance and initializes its routes.
|
// NewAPIController creates a new APIController instance and initializes its routes.
|
||||||
@@ -79,6 +81,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
|
|||||||
|
|
||||||
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
|
||||||
|
|
||||||
|
// Settings + Xray config management live under the API surface too, so the
|
||||||
|
// same API token drives them. Paths are /panel/api/setting/* and
|
||||||
|
// /panel/api/xray/*.
|
||||||
|
a.settingController = NewSettingController(api)
|
||||||
|
a.xraySettingController = NewXraySettingController(api)
|
||||||
|
|
||||||
// Extra routes
|
// Extra routes
|
||||||
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
api.POST("/backuptotgbot", a.BackuptoTgbot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,9 +96,9 @@ func TestAPIRoutesDocumented(t *testing.T) {
|
|||||||
case "node.go":
|
case "node.go":
|
||||||
basePath = "/panel/api/nodes"
|
basePath = "/panel/api/nodes"
|
||||||
case "setting.go":
|
case "setting.go":
|
||||||
basePath = "/panel/setting"
|
basePath = "/panel/api/setting"
|
||||||
case "xray_setting.go":
|
case "xray_setting.go":
|
||||||
basePath = "/panel/xray"
|
basePath = "/panel/api/xray"
|
||||||
case "custom_geo.go":
|
case "custom_geo.go":
|
||||||
basePath = "/panel/api/custom-geo"
|
basePath = "/panel/api/custom-geo"
|
||||||
case "websocket.go":
|
case "websocket.go":
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/ips/:email", a.getIps)
|
g.POST("/ips/:email", a.getIps)
|
||||||
g.POST("/clearIps/:email", a.clearIps)
|
g.POST("/clearIps/:email", a.clearIps)
|
||||||
g.POST("/onlines", a.onlines)
|
g.POST("/onlines", a.onlines)
|
||||||
g.POST("/onlinesByNode", a.onlinesByNode)
|
g.POST("/onlinesByGuid", a.onlinesByGuid)
|
||||||
g.POST("/activeInbounds", a.activeInbounds)
|
g.POST("/activeInbounds", a.activeInbounds)
|
||||||
g.POST("/lastOnline", a.lastOnline)
|
g.POST("/lastOnline", a.lastOnline)
|
||||||
}
|
}
|
||||||
@@ -417,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
|
|||||||
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) onlinesByNode(c *gin.Context) {
|
func (a *ClientController) onlinesByGuid(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
|
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) activeInbounds(c *gin.Context) {
|
func (a *ClientController) activeInbounds(c *gin.Context) {
|
||||||
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
|
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *ClientController) lastOnline(c *gin.Context) {
|
func (a *ClientController) lastOnline(c *gin.Context) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"encoding/json"
|
||||||
htmlpkg "html"
|
htmlpkg "html"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) {
|
|||||||
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
|
||||||
|
// under a non-root web base path, Swagger UI "Try it out" and external
|
||||||
|
// generators must target that prefix, so rewrite the single server entry to
|
||||||
|
// the runtime base path before serving.
|
||||||
|
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
|
||||||
|
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
|
||||||
|
logger.Warning("openapi.json: could not inject base path:", err)
|
||||||
|
} else {
|
||||||
|
body = rebuilt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.Header("Cache-Control", "public, max-age=300")
|
c.Header("Cache-Control", "public, max-age=300")
|
||||||
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withServerBasePath rewrites the spec's `servers` entry so requests target the
|
||||||
|
// panel's configured web base path. Only the top-level `servers` field is
|
||||||
|
// replaced; every other field is preserved verbatim via json.RawMessage.
|
||||||
|
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
|
||||||
|
var doc map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(spec, &doc); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
servers, err := json.Marshal([]map[string]string{{
|
||||||
|
"url": strings.TrimSuffix(basePath, "/"),
|
||||||
|
"description": "Current panel",
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
doc["servers"] = servers
|
||||||
|
return json.Marshal(doc)
|
||||||
|
}
|
||||||
|
|
||||||
func serveDistPage(c *gin.Context, name string) {
|
func serveDistPage(c *gin.Context, name string) {
|
||||||
body, err := distFS.ReadFile("dist/" + name)
|
body, err := distFS.ReadFile("dist/" + name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user