mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-06 05:05:01 +03:00
Compare commits
18 Commits
0daedd3db9
...
v3.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
| f8e89cc848 | |||
| 9711a9ce22 | |||
| 9acde8da9d | |||
| d9ccf157c3 | |||
| be8bd4e22c | |||
| 5a7de02598 | |||
| a32c6803da | |||
| 9f31d7d056 | |||
| 0d7b6872f7 | |||
| 7d908834a8 | |||
| 1fa51cf0f2 | |||
| b24b8524b6 | |||
| 8ce61f3cb0 | |||
| 3d6ff2b60c | |||
| abf6b8799e | |||
| 94b8196e84 | |||
| e8171ab4f7 | |||
| 1c74b995c3 |
@@ -1,6 +1,7 @@
|
|||||||
# Ignore editor and IDE settings
|
# Ignore editor and IDE settings
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
|
.cursor/
|
||||||
.claude/
|
.claude/
|
||||||
.cache/
|
.cache/
|
||||||
.sync*
|
.sync*
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md)
|
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<picture>
|
<picture>
|
||||||
|
|||||||
+47
-47
@@ -17,28 +17,28 @@
|
|||||||
<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>
|
<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>
|
</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.
|
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş profesyonel, açık kaynaklı bir web kontrol panelidir. Tek bir sanal sunucudan (VPS) çok düğümlü (multi-node) dağıtımlara 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.
|
Orijinal X-UI projesinin geliştirilmiş bir çatallaması (fork) olarak inşa edilen 3X-UI; çok daha geniş protokol desteği, artırılmış kararlılık, kullanıcı başına trafik hesaplama ve kullanım kolaylığı sağlayan birçok yeni özellik sunar.
|
||||||
|
|
||||||
> [!IMPORTANT]
|
> [!IMPORTANT]
|
||||||
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
|
> Bu proje yalnızca kişisel kullanım için tasarlanmıştır. Lütfen yasadışı amaçlar için veya üretim (production) ortamında kullanmayın.
|
||||||
|
|
||||||
## Özellikler
|
## Özellikler
|
||||||
|
|
||||||
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
|
- **Çoklu protokol destekli gelen bağlantılar (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Karma), 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.
|
- **Modern aktarımlar (transports) ve güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvene alınmıştır.
|
||||||
- **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.
|
- **Geri Dönüş (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port üzerinde birden fazla protokole (ör. 443 üzerinde hem VLESS hem Trojan) hizmet verin.
|
||||||
- **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.
|
- **Kullanıcı başına yönetim** — Trafik kotaları, bitiş tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıkla 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.
|
- **Trafik istatistikleri** — Gelen bağlantı (Inbound), istemci ve giden bağlantı (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.
|
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panel üzerinden 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.
|
- **Giden bağlantı (Outbound) ve yönlendirme** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve giden bağlantı proxy zincirleme (proxy chaining).
|
||||||
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
|
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatı ile).
|
||||||
- **Telegram botu** (Uzaktan izleme ve yönetim için).
|
- Uzaktan izleme ve yönetim için **Telegram botu**.
|
||||||
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
|
- Panel içi Swagger dokümantasyonuna sahip **RESTful API**.
|
||||||
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
|
- **Esnek depolama** — SQLite (varsayılan) veya PostgreSQL.
|
||||||
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
|
- Koyu ve açık tema seçenekleriyle **13 farklı UI dili**.
|
||||||
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
|
- Kullanıcı başına IP limitlerini zorunlu kılmak için **Fail2ban entegrasyonu**.
|
||||||
|
|
||||||
## Ekran Görüntüleri
|
## Ekran Görüntüleri
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
|
|||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
|
<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">
|
<img alt="Gelen Bağlantılar (Inbounds)" src="./media/02-add-inbound-light.png">
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
@@ -73,7 +73,7 @@ Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen
|
|||||||
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
|
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.
|
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu oluşturulur. Kurulumdan sonra, hizmeti başlatabileceğiniz/durdurabileceğiniz, giriş bilgilerinizi görüntüleyebileceğiniz veya sıfırlayabileceğiniz, SSL sertifikalarını yönetebileceğiniz ve çok daha fazlasını yapabileceğiniz yönetim menüsünü açmak için terminalde `x-ui` komutunu çalıştırın.
|
||||||
|
|
||||||
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
|
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
|
||||||
|
|
||||||
@@ -85,93 +85,93 @@ Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHS
|
|||||||
|
|
||||||
## Veritabanı Seçenekleri
|
## Veritabanı Seçenekleri
|
||||||
|
|
||||||
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
|
3X-UI kurulum sırasında seçilebilecek 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.
|
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Kurulum gerektirmez, 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.
|
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çoklu düğüm (multi-node) kurulumları için önerilir. Yükleyici sizin için yerel olarak PostgreSQL kurabilir veya mevcut bir sunucuya DSN bağlantısı kabul edebilir.
|
||||||
|
|
||||||
Ç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):
|
Çalışma anında veritabanı türü ortam değişkenleri (environment variables) ile seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
|
||||||
|
|
||||||
```
|
```
|
||||||
XUI_DB_TYPE=postgres
|
XUI_DB_TYPE=postgres
|
||||||
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
|
||||||
```
|
```
|
||||||
|
|
||||||
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
|
### Mevcut bir SQLite Kurulumunu PostgreSQL'e Taşıma
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
|
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:
|
# ardından /etc/default/x-ui içindeki XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
|
||||||
systemctl restart x-ui
|
systemctl restart x-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
|
Kaynak SQLite dosyasına dokunulmaz; yeni veritabanının düzgün çalıştığını doğruladıktan sonra eski SQLite dosyasını manuel olarak silebilirsiniz.
|
||||||
|
|
||||||
### Docker
|
### 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:
|
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Birlikte paketlenmiş PostgreSQL servisi ile çalıştırmak için, `docker-compose.yml` dosyasındaki iki `XUI_DB_*` değişken satırının yorumunu kaldırın ve profille başlatın:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose --profile postgres up -d
|
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:
|
Docker imajı, kullanıcı başına **IP limitlerini** zorunlu kılmak için Fail2ban ile (varsayılan olarak etkindir) paketlenmiştir. Fail2ban, ihlalcileri `iptables` ile engeller ve bunun için `NET_ADMIN` yetkisine ihtiyaç duyar. `docker-compose.yml` bunu zaten `cap_add` üzerinden vermektedir; ancak konteyneri bunun yerine `docker run` ile başlatırsanız bu yetkileri kendiniz eklemelisiniz, aksi takdirde yasaklamalar günlüğe kaydedilir ancak uygulanmaz:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ortam Değişkenleri
|
## Ortam Değişkenleri (Environment Variables)
|
||||||
|
|
||||||
| Değişken | Açıklama | Varsayılan |
|
| Değişken | Açıklama | Varsayılan |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
|
| `XUI_DB_TYPE` | Veritabanı türü: `sqlite` veya `postgres` | `sqlite` |
|
||||||
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
|
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (eğer `XUI_DB_TYPE=postgres` ise) | — |
|
||||||
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
|
| `XUI_DB_FOLDER` | SQLite veritabanı dizini | `/etc/x-ui` |
|
||||||
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
|
| `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_DB_MAX_IDLE_CONNS` | Maksimum boşta bekleme bağlantısı (PostgreSQL havuzu) | — |
|
||||||
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
|
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP limit uygulamasını etkinleştir | `true` |
|
||||||
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
|
| `XUI_LOG_LEVEL` | Günlük (Log) ayrıntı seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
|
||||||
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
|
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
|
||||||
|
|
||||||
## Desteklenen Diller
|
## Desteklenen Diller
|
||||||
|
|
||||||
Panel kullanıcı arayüzü 13 dilde mevcuttur:
|
Panel arayüzü 13 farklı dilde mevcuttur:
|
||||||
|
|
||||||
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
|
İngilizce · Farsça · Arapça · Çince (Basitleştirilmiş) · Çince (Geleneksel) · İspanyolca · Rusça · Ukraynaca · Türkçe · Vietnamca · Japonca · Endonezce · Portekizce (Brezilya)
|
||||||
|
|
||||||
## Katkıda Bulunma
|
## 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.
|
Katkılarınızı her zaman bekliyoruz. Bir sorun (issue) açmadan veya pull request (PR) göndermeden önce lütfen [Katkıda Bulunma Kılavuzunu](/CONTRIBUTING.md) okuyun.
|
||||||
|
|
||||||
## Özel Teşekkürler
|
## Özel Teşekkürler
|
||||||
|
|
||||||
- [alireza0](https://github.com/alireza0/)
|
- [alireza0](https://github.com/alireza0/)
|
||||||
|
|
||||||
## Teşekkür
|
## Teşekkür & Atıf
|
||||||
|
|
||||||
- [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ı._
|
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme (routing) kuralları; yerleşik İran alan adları ile güvenlik ve reklam engelleme odaklıdır._
|
||||||
- [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._
|
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Bu depo, Rusya'daki engellenen alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kurallarını içerir._
|
||||||
|
|
||||||
## Topluluk Araçları
|
## Topluluk Araçları
|
||||||
|
|
||||||
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
|
3x-ui çevresindeki topluluk tarafından oluşturulmuş 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._
|
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Gelen bağlantılarnı, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak (as code) yönetin._
|
||||||
|
|
||||||
## Projeyi Destekleyin
|
## Projeyi Destekleyin
|
||||||
|
|
||||||
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
|
**Eğer bu proje size faydalı olduysa, bir yıldız verebilirsiniz**:star2:
|
||||||
|
|
||||||
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
|
<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;" >
|
<img src="./media/default-yellow.png" alt="Bana Bir Kahve Ismarla" style="height: 70px !important;width: 277px !important;" >
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</br>
|
</br>
|
||||||
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
|
<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">
|
<img src="./media/donation-button-black.svg" alt="NOWPayments üzerinden Kripto Bağış Butonu">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
## Zaman İçindeki Yıldız Sayısı
|
## Yıldız Tablosu
|
||||||
|
|
||||||
[](https://starchart.cc/MHSanaei/3x-ui)
|
[](https://starchart.cc/MHSanaei/3x-ui)
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
3.2.8
|
3.3.0
|
||||||
@@ -2,9 +2,6 @@ package database
|
|||||||
|
|
||||||
import "fmt"
|
import "fmt"
|
||||||
|
|
||||||
// JSONClientsFromInbound returns the FROM clause that yields one row per element
|
|
||||||
// of inbounds.settings -> clients, with a column named `client.value` whose text
|
|
||||||
// fields can be read with JSONFieldText("client.value", "<key>").
|
|
||||||
func JSONClientsFromInbound() string {
|
func JSONClientsFromInbound() string {
|
||||||
if IsPostgres() {
|
if IsPostgres() {
|
||||||
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
return "FROM inbounds, jsonb_array_elements(inbounds.settings::jsonb -> 'clients') AS client(value)"
|
||||||
@@ -27,26 +24,9 @@ func GreatestExpr(a, b string) string {
|
|||||||
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 {
|
func ClientTrafficEnableMergeExpr() string {
|
||||||
if IsPostgres() {
|
if IsPostgres() {
|
||||||
return "CASE WHEN ?::boolean THEN enable::boolean ELSE false END"
|
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"
|
return "CASE WHEN ? THEN enable ELSE 0 END"
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-6
@@ -478,6 +478,12 @@ type Node struct {
|
|||||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
// XrayState and XrayError are captured from the remote node's /panel/api/server/status
|
||||||
|
// during heartbeats. They let the central panel distinguish "panel API reachable"
|
||||||
|
// (status=online) from "Xray core itself has failed on the node" for monitoring.
|
||||||
|
XrayState string `json:"xrayState" gorm:"column:xray_state"`
|
||||||
|
XrayError string `json:"xrayError" gorm:"column:xray_error"`
|
||||||
|
|
||||||
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
|
|
||||||
@@ -514,6 +520,9 @@ type NodeSummary struct {
|
|||||||
LatencyMs int `json:"latencyMs"`
|
LatencyMs int `json:"latencyMs"`
|
||||||
PanelVersion string `json:"panelVersion"`
|
PanelVersion string `json:"panelVersion"`
|
||||||
XrayVersion string `json:"xrayVersion"`
|
XrayVersion string `json:"xrayVersion"`
|
||||||
|
// XrayState/XrayError forwarded so masters can surface xray failure on transitive sub-nodes too.
|
||||||
|
XrayState string `json:"xrayState"`
|
||||||
|
XrayError string `json:"xrayError,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type CustomGeoResource struct {
|
type CustomGeoResource struct {
|
||||||
@@ -713,18 +722,15 @@ type OutboundSubscription struct {
|
|||||||
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
|
AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
|
||||||
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
|
TagPrefix string `json:"tagPrefix" form:"tagPrefix"`
|
||||||
UpdateInterval int `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
|
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)
|
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
|
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"`
|
LastUpdated int64 `json:"lastUpdated" form:"lastUpdated"`
|
||||||
LastError string `json:"lastError" form:"lastError"`
|
LastError string `json:"lastError" form:"lastError"`
|
||||||
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
|
LastFetchedOutbounds string `json:"lastFetchedOutbounds" form:"lastFetchedOutbounds" gorm:"type:text"`
|
||||||
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
|
LinkIdentities string `json:"-" gorm:"type:text;column:link_identities"`
|
||||||
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
|
||||||
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
|
||||||
// OutboundCount is a derived count of the last fetched outbounds (not
|
OutboundCount int `json:"outboundCount" gorm:"-"`
|
||||||
// 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 {
|
||||||
|
|||||||
Generated
+408
-375
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "3x-ui-frontend",
|
"name": "3x-ui-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.2.7",
|
"version": "0.3.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
"description": "3x-ui panel frontend (React 19 + Ant Design 6 + Vite 8).",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -65,5 +65,12 @@
|
|||||||
"react-debounce-input": {
|
"react-debounce-input": {
|
||||||
"react": "^19.0.0"
|
"react": "^19.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"allowScripts": {
|
||||||
|
"@scarf/scarf": false,
|
||||||
|
"@tree-sitter-grammars/tree-sitter-yaml": false,
|
||||||
|
"tree-sitter": false,
|
||||||
|
"core-js-pure": false,
|
||||||
|
"tree-sitter-json": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -243,6 +243,10 @@
|
|||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subThemeDir": {
|
||||||
|
"description": "Absolute path to a folder containing a custom subscription page template",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"description": "Subscription title",
|
"description": "Subscription title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -321,6 +325,11 @@
|
|||||||
"description": "Two-factor authentication token",
|
"description": "Two-factor authentication token",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"warpUpdateInterval": {
|
||||||
|
"description": "WARP",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"webBasePath": {
|
"webBasePath": {
|
||||||
"description": "Base path for web panel URLs",
|
"description": "Base path for web panel URLs",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -404,6 +413,7 @@
|
|||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
"subShowInfo",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -422,6 +432,7 @@
|
|||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"twoFactorEnable",
|
"twoFactorEnable",
|
||||||
"twoFactorToken",
|
"twoFactorToken",
|
||||||
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
"webCertFile",
|
"webCertFile",
|
||||||
"webDomain",
|
"webDomain",
|
||||||
@@ -666,6 +677,10 @@
|
|||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subThemeDir": {
|
||||||
|
"description": "Absolute path to a folder containing a custom subscription page template",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"description": "Subscription title",
|
"description": "Subscription title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -744,6 +759,11 @@
|
|||||||
"description": "Two-factor authentication token",
|
"description": "Two-factor authentication token",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"warpUpdateInterval": {
|
||||||
|
"description": "WARP",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"webBasePath": {
|
"webBasePath": {
|
||||||
"description": "Base path for web panel URLs",
|
"description": "Base path for web panel URLs",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -833,6 +853,7 @@
|
|||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
"subShowInfo",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -851,6 +872,7 @@
|
|||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"twoFactorEnable",
|
"twoFactorEnable",
|
||||||
"twoFactorToken",
|
"twoFactorToken",
|
||||||
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
"webCertFile",
|
"webCertFile",
|
||||||
"webDomain",
|
"webDomain",
|
||||||
@@ -1656,6 +1678,13 @@
|
|||||||
"example": 86400,
|
"example": 86400,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"xrayError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"xrayState": {
|
||||||
|
"description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"xrayVersion": {
|
"xrayVersion": {
|
||||||
"example": "25.10.31",
|
"example": "25.10.31",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1691,6 +1720,8 @@
|
|||||||
"tlsVerifyMode",
|
"tlsVerifyMode",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"uptimeSecs",
|
"uptimeSecs",
|
||||||
|
"xrayError",
|
||||||
|
"xrayState",
|
||||||
"xrayVersion"
|
"xrayVersion"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -1752,6 +1783,13 @@
|
|||||||
"example": 86400,
|
"example": 86400,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"xrayError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"xrayState": {
|
||||||
|
"description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"xrayVersion": {
|
"xrayVersion": {
|
||||||
"example": "25.10.31",
|
"example": "25.10.31",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1765,6 +1803,8 @@
|
|||||||
"panelVersion",
|
"panelVersion",
|
||||||
"status",
|
"status",
|
||||||
"uptimeSecs",
|
"uptimeSecs",
|
||||||
|
"xrayError",
|
||||||
|
"xrayState",
|
||||||
"xrayVersion"
|
"xrayVersion"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -4032,6 +4072,79 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/panel/api/server/clientIps": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.",
|
||||||
|
"operationId": "get_panel_api_server_clientIps",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/InboundClientIps"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"example": {
|
||||||
|
"success": true,
|
||||||
|
"obj": [
|
||||||
|
{
|
||||||
|
"clientEmail": "",
|
||||||
|
"id": 0,
|
||||||
|
"ips": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Server"
|
||||||
|
],
|
||||||
|
"summary": "Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.",
|
||||||
|
"operationId": "post_panel_api_server_clientIps",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Successful response",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"msg": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"obj": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/panel/api/clients/list": {
|
"/panel/api/clients/list": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
@@ -5894,6 +6007,8 @@
|
|||||||
"transitive": false,
|
"transitive": false,
|
||||||
"updatedAt": 1700000000,
|
"updatedAt": 1700000000,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "",
|
||||||
"xrayVersion": "25.10.31"
|
"xrayVersion": "25.10.31"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -6254,6 +6369,8 @@
|
|||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "",
|
||||||
"xrayVersion": "25.10.31"
|
"xrayVersion": "25.10.31"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowInfo": false,
|
"subShowInfo": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -74,6 +75,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"twoFactorEnable": false,
|
"twoFactorEnable": false,
|
||||||
"twoFactorToken": "",
|
"twoFactorToken": "",
|
||||||
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
"webCertFile": "",
|
"webCertFile": "",
|
||||||
"webDomain": "",
|
"webDomain": "",
|
||||||
@@ -143,6 +145,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"subRoutingRules": "",
|
"subRoutingRules": "",
|
||||||
"subShowInfo": false,
|
"subShowInfo": false,
|
||||||
"subSupportUrl": "",
|
"subSupportUrl": "",
|
||||||
|
"subThemeDir": "",
|
||||||
"subTitle": "",
|
"subTitle": "",
|
||||||
"subURI": "",
|
"subURI": "",
|
||||||
"subUpdates": 0,
|
"subUpdates": 0,
|
||||||
@@ -161,6 +164,7 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"trustedProxyCIDRs": "",
|
"trustedProxyCIDRs": "",
|
||||||
"twoFactorEnable": false,
|
"twoFactorEnable": false,
|
||||||
"twoFactorToken": "",
|
"twoFactorToken": "",
|
||||||
|
"warpUpdateInterval": 0,
|
||||||
"webBasePath": "",
|
"webBasePath": "",
|
||||||
"webCertFile": "",
|
"webCertFile": "",
|
||||||
"webDomain": "",
|
"webDomain": "",
|
||||||
@@ -364,6 +368,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"transitive": false,
|
"transitive": false,
|
||||||
"updatedAt": 1700000000,
|
"updatedAt": 1700000000,
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "",
|
||||||
"xrayVersion": "25.10.31"
|
"xrayVersion": "25.10.31"
|
||||||
},
|
},
|
||||||
"OutboundTraffics": {
|
"OutboundTraffics": {
|
||||||
@@ -381,6 +387,8 @@ export const EXAMPLES: Record<string, unknown> = {
|
|||||||
"panelVersion": "v3.x.x",
|
"panelVersion": "v3.x.x",
|
||||||
"status": "online",
|
"status": "online",
|
||||||
"uptimeSecs": 86400,
|
"uptimeSecs": 86400,
|
||||||
|
"xrayError": "",
|
||||||
|
"xrayState": "",
|
||||||
"xrayVersion": "25.10.31"
|
"xrayVersion": "25.10.31"
|
||||||
},
|
},
|
||||||
"Setting": {
|
"Setting": {
|
||||||
|
|||||||
@@ -217,6 +217,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subThemeDir": {
|
||||||
|
"description": "Absolute path to a folder containing a custom subscription page template",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"description": "Subscription title",
|
"description": "Subscription title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -295,6 +299,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Two-factor authentication token",
|
"description": "Two-factor authentication token",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"warpUpdateInterval": {
|
||||||
|
"description": "WARP",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"webBasePath": {
|
"webBasePath": {
|
||||||
"description": "Base path for web panel URLs",
|
"description": "Base path for web panel URLs",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -378,6 +387,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
"subShowInfo",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -396,6 +406,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"twoFactorEnable",
|
"twoFactorEnable",
|
||||||
"twoFactorToken",
|
"twoFactorToken",
|
||||||
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
"webCertFile",
|
"webCertFile",
|
||||||
"webDomain",
|
"webDomain",
|
||||||
@@ -640,6 +651,10 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Subscription support URL",
|
"description": "Subscription support URL",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"subThemeDir": {
|
||||||
|
"description": "Absolute path to a folder containing a custom subscription page template",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"subTitle": {
|
"subTitle": {
|
||||||
"description": "Subscription title",
|
"description": "Subscription title",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -718,6 +733,11 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"description": "Two-factor authentication token",
|
"description": "Two-factor authentication token",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"warpUpdateInterval": {
|
||||||
|
"description": "WARP",
|
||||||
|
"minimum": 0,
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
"webBasePath": {
|
"webBasePath": {
|
||||||
"description": "Base path for web panel URLs",
|
"description": "Base path for web panel URLs",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -807,6 +827,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"subRoutingRules",
|
"subRoutingRules",
|
||||||
"subShowInfo",
|
"subShowInfo",
|
||||||
"subSupportUrl",
|
"subSupportUrl",
|
||||||
|
"subThemeDir",
|
||||||
"subTitle",
|
"subTitle",
|
||||||
"subURI",
|
"subURI",
|
||||||
"subUpdates",
|
"subUpdates",
|
||||||
@@ -825,6 +846,7 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"trustedProxyCIDRs",
|
"trustedProxyCIDRs",
|
||||||
"twoFactorEnable",
|
"twoFactorEnable",
|
||||||
"twoFactorToken",
|
"twoFactorToken",
|
||||||
|
"warpUpdateInterval",
|
||||||
"webBasePath",
|
"webBasePath",
|
||||||
"webCertFile",
|
"webCertFile",
|
||||||
"webDomain",
|
"webDomain",
|
||||||
@@ -1630,6 +1652,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 86400,
|
"example": 86400,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"xrayError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"xrayState": {
|
||||||
|
"description": "XrayState and XrayError are captured from the remote node's /panel/api/server/status\nduring heartbeats. They let the central panel distinguish \"panel API reachable\"\n(status=online) from \"Xray core itself has failed on the node\" for monitoring.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"xrayVersion": {
|
"xrayVersion": {
|
||||||
"example": "25.10.31",
|
"example": "25.10.31",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1665,6 +1694,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"tlsVerifyMode",
|
"tlsVerifyMode",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
"uptimeSecs",
|
"uptimeSecs",
|
||||||
|
"xrayError",
|
||||||
|
"xrayState",
|
||||||
"xrayVersion"
|
"xrayVersion"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
@@ -1726,6 +1757,13 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"example": 86400,
|
"example": 86400,
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
|
"xrayError": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"xrayState": {
|
||||||
|
"description": "XrayState/XrayError are populated on successful probes even when the node's\nXray core is not healthy. The UI uses them for a distinct \"panel ok, xray failed\" indicator.",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"xrayVersion": {
|
"xrayVersion": {
|
||||||
"example": "25.10.31",
|
"example": "25.10.31",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@@ -1739,6 +1777,8 @@ export const SCHEMAS: Record<string, unknown> = {
|
|||||||
"panelVersion",
|
"panelVersion",
|
||||||
"status",
|
"status",
|
||||||
"uptimeSecs",
|
"uptimeSecs",
|
||||||
|
"xrayError",
|
||||||
|
"xrayState",
|
||||||
"xrayVersion"
|
"xrayVersion"
|
||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ export interface AllSetting {
|
|||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowInfo: boolean;
|
subShowInfo: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -79,6 +80,7 @@ export interface AllSetting {
|
|||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
twoFactorEnable: boolean;
|
twoFactorEnable: boolean;
|
||||||
twoFactorToken: string;
|
twoFactorToken: string;
|
||||||
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
webCertFile: string;
|
webCertFile: string;
|
||||||
webDomain: string;
|
webDomain: string;
|
||||||
@@ -149,6 +151,7 @@ export interface AllSettingView {
|
|||||||
subRoutingRules: string;
|
subRoutingRules: string;
|
||||||
subShowInfo: boolean;
|
subShowInfo: boolean;
|
||||||
subSupportUrl: string;
|
subSupportUrl: string;
|
||||||
|
subThemeDir: string;
|
||||||
subTitle: string;
|
subTitle: string;
|
||||||
subURI: string;
|
subURI: string;
|
||||||
subUpdates: number;
|
subUpdates: number;
|
||||||
@@ -167,6 +170,7 @@ export interface AllSettingView {
|
|||||||
trustedProxyCIDRs: string;
|
trustedProxyCIDRs: string;
|
||||||
twoFactorEnable: boolean;
|
twoFactorEnable: boolean;
|
||||||
twoFactorToken: string;
|
twoFactorToken: string;
|
||||||
|
warpUpdateInterval: number;
|
||||||
webBasePath: string;
|
webBasePath: string;
|
||||||
webCertFile: string;
|
webCertFile: string;
|
||||||
webDomain: string;
|
webDomain: string;
|
||||||
@@ -371,6 +375,8 @@ export interface Node {
|
|||||||
transitive?: boolean;
|
transitive?: boolean;
|
||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
uptimeSecs: number;
|
uptimeSecs: number;
|
||||||
|
xrayError: string;
|
||||||
|
xrayState: string;
|
||||||
xrayVersion: string;
|
xrayVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -390,6 +396,8 @@ export interface ProbeResultUI {
|
|||||||
panelVersion: string;
|
panelVersion: string;
|
||||||
status: string;
|
status: string;
|
||||||
uptimeSecs: number;
|
uptimeSecs: number;
|
||||||
|
xrayError: string;
|
||||||
|
xrayState: string;
|
||||||
xrayVersion: string;
|
xrayVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const AllSettingSchema = z.object({
|
|||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowInfo: z.boolean(),
|
subShowInfo: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -89,6 +90,7 @@ export const AllSettingSchema = z.object({
|
|||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
twoFactorEnable: z.boolean(),
|
twoFactorEnable: z.boolean(),
|
||||||
twoFactorToken: z.string(),
|
twoFactorToken: z.string(),
|
||||||
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
webCertFile: z.string(),
|
webCertFile: z.string(),
|
||||||
webDomain: z.string(),
|
webDomain: z.string(),
|
||||||
@@ -160,6 +162,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
subRoutingRules: z.string(),
|
subRoutingRules: z.string(),
|
||||||
subShowInfo: z.boolean(),
|
subShowInfo: z.boolean(),
|
||||||
subSupportUrl: z.string(),
|
subSupportUrl: z.string(),
|
||||||
|
subThemeDir: z.string(),
|
||||||
subTitle: z.string(),
|
subTitle: z.string(),
|
||||||
subURI: z.string(),
|
subURI: z.string(),
|
||||||
subUpdates: z.number().int().min(0).max(525600),
|
subUpdates: z.number().int().min(0).max(525600),
|
||||||
@@ -178,6 +181,7 @@ export const AllSettingViewSchema = z.object({
|
|||||||
trustedProxyCIDRs: z.string(),
|
trustedProxyCIDRs: z.string(),
|
||||||
twoFactorEnable: z.boolean(),
|
twoFactorEnable: z.boolean(),
|
||||||
twoFactorToken: z.string(),
|
twoFactorToken: z.string(),
|
||||||
|
warpUpdateInterval: z.number().int().min(0),
|
||||||
webBasePath: z.string(),
|
webBasePath: z.string(),
|
||||||
webCertFile: z.string(),
|
webCertFile: z.string(),
|
||||||
webDomain: z.string(),
|
webDomain: z.string(),
|
||||||
@@ -398,6 +402,8 @@ export const NodeSchema = z.object({
|
|||||||
transitive: z.boolean().optional(),
|
transitive: z.boolean().optional(),
|
||||||
updatedAt: z.number().int(),
|
updatedAt: z.number().int(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
|
xrayError: z.string(),
|
||||||
|
xrayState: z.string(),
|
||||||
xrayVersion: z.string(),
|
xrayVersion: z.string(),
|
||||||
});
|
});
|
||||||
export type Node = z.infer<typeof NodeSchema>;
|
export type Node = z.infer<typeof NodeSchema>;
|
||||||
@@ -419,6 +425,8 @@ export const ProbeResultUISchema = z.object({
|
|||||||
panelVersion: z.string(),
|
panelVersion: z.string(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
uptimeSecs: z.number().int(),
|
uptimeSecs: z.number().int(),
|
||||||
|
xrayError: z.string(),
|
||||||
|
xrayState: z.string(),
|
||||||
xrayVersion: z.string(),
|
xrayVersion: z.string(),
|
||||||
});
|
});
|
||||||
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
|
||||||
|
|||||||
@@ -684,13 +684,11 @@ export interface GenMtprotoLinkInput {
|
|||||||
inbound: Inbound;
|
inbound: Inbound;
|
||||||
address: string;
|
address: string;
|
||||||
port?: number;
|
port?: number;
|
||||||
remark?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Builds a Telegram proxy deep link for an mtproto inbound:
|
// 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 {
|
export function genMtprotoLink(input: GenMtprotoLinkInput): string {
|
||||||
const { inbound, address, port = inbound.port, remark = '' } = input;
|
const { inbound, address, port = inbound.port } = input;
|
||||||
if (inbound.protocol !== 'mtproto') return '';
|
if (inbound.protocol !== 'mtproto') return '';
|
||||||
const secret = inbound.settings.secret ?? '';
|
const secret = inbound.settings.secret ?? '';
|
||||||
if (secret.length === 0) return '';
|
if (secret.length === 0) return '';
|
||||||
@@ -698,7 +696,6 @@ export function genMtprotoLink(input: GenMtprotoLinkInput): string {
|
|||||||
url.searchParams.set('server', address);
|
url.searchParams.set('server', address);
|
||||||
url.searchParams.set('port', String(port));
|
url.searchParams.set('port', String(port));
|
||||||
url.searchParams.set('secret', secret);
|
url.searchParams.set('secret', secret);
|
||||||
url.hash = encodeURIComponent(remark);
|
|
||||||
return url.toString();
|
return url.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,7 +887,7 @@ export function genLink(input: GenLinkInput): string {
|
|||||||
externalProxy,
|
externalProxy,
|
||||||
});
|
});
|
||||||
case 'mtproto':
|
case 'mtproto':
|
||||||
return genMtprotoLink({ inbound, address, port, remark });
|
return genMtprotoLink({ inbound, address, port });
|
||||||
default:
|
default:
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export class AllSetting {
|
|||||||
subJsonMux = '';
|
subJsonMux = '';
|
||||||
subJsonRules = '';
|
subJsonRules = '';
|
||||||
subJsonFinalMask = '';
|
subJsonFinalMask = '';
|
||||||
|
subThemeDir = '';
|
||||||
|
|
||||||
timeLocation = 'Local';
|
timeLocation = 'Local';
|
||||||
|
|
||||||
|
|||||||
@@ -442,6 +442,21 @@ export const sections: readonly Section[] = [
|
|||||||
body: 'sni=example.com',
|
body: 'sni=example.com',
|
||||||
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
|
response: '{\n "success": true,\n "obj": {\n "echKeySet": "...",\n "echServerKeys": [...],\n "echConfigList": "..."\n }\n}',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: 'GET',
|
||||||
|
path: '/panel/api/server/clientIps',
|
||||||
|
summary: 'Fetch the fully aggregated inbound_client_ips database table. Used by nodes to sync recently active IPs across the cluster.',
|
||||||
|
responseSchema: 'InboundClientIps',
|
||||||
|
responseSchemaArray: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
path: '/panel/api/server/clientIps',
|
||||||
|
summary: 'Submit a list of recently active IP timestamps. The panel merges them with the existing database to maintain a unified global IP-limit view.',
|
||||||
|
params: [
|
||||||
|
{ name: 'ips', in: 'body (json)', type: 'object[]', desc: 'Array of InboundClientIps to merge.' },
|
||||||
|
],
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ import { useTheme } from '@/hooks/useTheme';
|
|||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
import { usePageTitle } from '@/hooks/usePageTitle';
|
import { usePageTitle } from '@/hooks/usePageTitle';
|
||||||
import { useClients } from '@/hooks/useClients';
|
import { useClients } from '@/hooks/useClients';
|
||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil, SizeFormatter } from '@/utils';
|
||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
import AppSidebar from '@/layouts/AppSidebar';
|
import AppSidebar from '@/layouts/AppSidebar';
|
||||||
import { LazyMount } from '@/components/utility';
|
import { LazyMount } from '@/components/utility';
|
||||||
@@ -161,8 +161,8 @@ export default function GroupsPage() {
|
|||||||
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
|
() => groups.reduce((acc, g) => acc + (g.clientCount || 0), 0),
|
||||||
[groups],
|
[groups],
|
||||||
);
|
);
|
||||||
const emptyGroups = useMemo(
|
const totalTraffic = useMemo(
|
||||||
() => groups.filter((g) => (g.clientCount || 0) === 0).length,
|
() => groups.reduce((acc, g) => acc + (g.trafficUsed || 0), 0),
|
||||||
[groups],
|
[groups],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -417,6 +417,13 @@ export default function GroupsPage() {
|
|||||||
width: 180,
|
width: 180,
|
||||||
render: (count: number) => <span>{count || 0}</span>,
|
render: (count: number) => <span>{count || 0}</span>,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('pages.groups.trafficUsed'),
|
||||||
|
dataIndex: 'trafficUsed',
|
||||||
|
key: 'trafficUsed',
|
||||||
|
width: 160,
|
||||||
|
render: (bytes: number) => <span>{SizeFormatter.sizeFormat(bytes || 0)}</span>,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const pageClass = useMemo(() => {
|
const pageClass = useMemo(() => {
|
||||||
@@ -465,8 +472,9 @@ export default function GroupsPage() {
|
|||||||
</Col>
|
</Col>
|
||||||
<Col xs={12} sm={8} md={6}>
|
<Col xs={12} sm={8} md={6}>
|
||||||
<Statistic
|
<Statistic
|
||||||
title={t('pages.groups.emptyGroups')}
|
title={t('pages.groups.totalTraffic')}
|
||||||
value={String(emptyGroups)}
|
value={SizeFormatter.sizeFormat(totalTraffic)}
|
||||||
|
prefix={<RetweetOutlined />}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export function useInboundColumns({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tag color="green" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.active.length}</Tag>
|
<Tag color="green" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.active.length}</Tag>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
{cc.deactive.length > 0 && (
|
{cc.deactive.length > 0 && (
|
||||||
@@ -183,7 +183,7 @@ export function useInboundColumns({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tag className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.deactive.length}</Tag>
|
<Tag className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.deactive.length}</Tag>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
{cc.depleted.length > 0 && (
|
{cc.depleted.length > 0 && (
|
||||||
@@ -195,7 +195,7 @@ export function useInboundColumns({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Tag color="red" className="client-count-tag" style={{ margin: 0, padding: '0 2px' }}>{cc.depleted.length}</Tag>
|
<Tag color="red" className="client-count-tag" style={{ margin: 0, marginRight: 4, padding: '0 2px' }}>{cc.depleted.length}</Tag>
|
||||||
</Popover>
|
</Popover>
|
||||||
)}
|
)}
|
||||||
{cc.online.length > 0 && (
|
{cc.online.length > 0 && (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
|
|||||||
|
|
||||||
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
|
||||||
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
import { useMediaQuery } from '@/hooks/useMediaQuery';
|
||||||
|
import { parseLogLine } from './logParse';
|
||||||
import './LogModal.css';
|
import './LogModal.css';
|
||||||
|
|
||||||
interface LogModalProps {
|
interface LogModalProps {
|
||||||
@@ -12,50 +13,6 @@ interface LogModalProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ParsedLog {
|
|
||||||
date: string;
|
|
||||||
time: string;
|
|
||||||
stamp: string;
|
|
||||||
levelText: string;
|
|
||||||
levelClass: string;
|
|
||||||
service: string;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
|
||||||
const LEVEL_CLASSES = ['level-debug', 'level-info', 'level-notice', 'level-warning', 'level-error'];
|
|
||||||
|
|
||||||
function parseLogLine(line: string): ParsedLog {
|
|
||||||
const [head, ...rest] = (line || '').split(' - ');
|
|
||||||
const message = rest.join(' - ');
|
|
||||||
const parts = head.split(' ');
|
|
||||||
|
|
||||||
let date = '';
|
|
||||||
let time = '';
|
|
||||||
let levelText: string;
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
[date, time, levelText] = parts;
|
|
||||||
} else {
|
|
||||||
levelText = head;
|
|
||||||
}
|
|
||||||
|
|
||||||
const li = LEVELS.indexOf(levelText);
|
|
||||||
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
|
||||||
|
|
||||||
let service = '';
|
|
||||||
let body = message || '';
|
|
||||||
if (body.startsWith('XRAY:')) {
|
|
||||||
service = 'XRAY:';
|
|
||||||
body = body.slice('XRAY:'.length).trimStart();
|
|
||||||
} else if (body) {
|
|
||||||
service = 'X-UI:';
|
|
||||||
}
|
|
||||||
|
|
||||||
const stamp = [date, time].filter(Boolean).join(' ');
|
|
||||||
|
|
||||||
return { date, time, stamp, levelText, levelClass, service, body };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function LogModal({ open, onClose }: LogModalProps) {
|
export default function LogModal({ open, onClose }: LogModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { isMobile } = useMediaQuery();
|
const { isMobile } = useMediaQuery();
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// Parser for the panel log viewer. Logs reach the UI in two shapes:
|
||||||
|
//
|
||||||
|
// - App log (SysLog off): the in-memory buffer, formatted as
|
||||||
|
// "2006/01/02 15:04:05 LEVEL - message"
|
||||||
|
// - SysLog (journalctl -o short): every entry is prefixed with
|
||||||
|
// "Mon DD HH:MM:SS host ident[pid]: " before the real message, and the
|
||||||
|
// message itself is one of several shapes depending on which subsystem
|
||||||
|
// emitted it:
|
||||||
|
// "INFO - mtproto: ..." go-logging (x-ui + xray)
|
||||||
|
// "2026/06/08 19:22:22 http: ..." Go std log (net/http, runtime)
|
||||||
|
// "[Mon Jun 8 23:56:52 UTC 2026] ERROR ..." telego bot
|
||||||
|
// "Stopping x-ui.service - ..." systemd
|
||||||
|
//
|
||||||
|
// parseLogLine normalises all of these into a stamp + level + service + body so
|
||||||
|
// the viewer renders a readable line instead of a bare timestamp.
|
||||||
|
|
||||||
|
export interface ParsedLog {
|
||||||
|
date: string;
|
||||||
|
time: string;
|
||||||
|
stamp: string;
|
||||||
|
levelText: string;
|
||||||
|
levelClass: string;
|
||||||
|
service: string;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEVELS = ['DEBUG', 'INFO', 'NOTICE', 'WARNING', 'ERROR'];
|
||||||
|
export const LEVEL_CLASSES = [
|
||||||
|
'level-debug',
|
||||||
|
'level-info',
|
||||||
|
'level-notice',
|
||||||
|
'level-warning',
|
||||||
|
'level-error',
|
||||||
|
];
|
||||||
|
|
||||||
|
// "Mon DD HH:MM:SS host ident[pid]: <message>" — captures the journal date,
|
||||||
|
// time, and the message that follows the syslog identifier.
|
||||||
|
const SYSLOG_PREFIX = /^([A-Za-z]{3}\s+\d{1,2})\s+(\d{2}:\d{2}:\d{2})\s+\S+\s+\S+?:\s+(.*)$/;
|
||||||
|
// Redundant Go std-log date prefix ("2006/01/02 15:04:05 ") to strip — the
|
||||||
|
// journal already carries the timestamp.
|
||||||
|
const GO_LOG_DATE = /^\d{4}\/\d{2}\/\d{2}\s+\d{2}:\d{2}:\d{2}\s+/;
|
||||||
|
// telego's own line prefix: "[Mon Jan _2 15:04:05 MST 2006] LEVEL rest".
|
||||||
|
const TELEGO = /^\[[^\]]+\]\s+([A-Z]+)\s+(.*)$/;
|
||||||
|
|
||||||
|
// splitLevelDash pulls a leading "LEVEL - " off a message, returning the level
|
||||||
|
// and the remainder. Returns null when the message does not start with a level.
|
||||||
|
function splitLevelDash(message: string): { level: string; rest: string } | null {
|
||||||
|
const dash = message.indexOf(' - ');
|
||||||
|
if (dash < 0) return null;
|
||||||
|
const level = message.slice(0, dash).trim();
|
||||||
|
if (LEVELS.indexOf(level) < 0) return null;
|
||||||
|
return { level, rest: message.slice(dash + 3) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLogLine(line: string): ParsedLog {
|
||||||
|
const raw = (line || '').trim();
|
||||||
|
|
||||||
|
let date = '';
|
||||||
|
let time = '';
|
||||||
|
let levelText = '';
|
||||||
|
let body: string;
|
||||||
|
|
||||||
|
const sys = raw.match(SYSLOG_PREFIX);
|
||||||
|
if (sys) {
|
||||||
|
date = sys[1];
|
||||||
|
time = sys[2];
|
||||||
|
let message = sys[3];
|
||||||
|
|
||||||
|
const ld = splitLevelDash(message);
|
||||||
|
if (ld) {
|
||||||
|
// go-logging: "LEVEL - message"
|
||||||
|
levelText = ld.level;
|
||||||
|
body = ld.rest;
|
||||||
|
} else {
|
||||||
|
// Strip the redundant Go std-log date, then try to lift a level out of a
|
||||||
|
// telego "[timestamp] LEVEL ..." line; otherwise keep the message as-is.
|
||||||
|
message = message.replace(GO_LOG_DATE, '');
|
||||||
|
const tg = message.match(TELEGO);
|
||||||
|
if (tg && LEVELS.indexOf(tg[1]) >= 0) {
|
||||||
|
levelText = tg[1];
|
||||||
|
body = tg[2];
|
||||||
|
} else {
|
||||||
|
body = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// App-log format: "2006/01/02 15:04:05 LEVEL - body"
|
||||||
|
const [head, ...rest] = raw.split(' - ');
|
||||||
|
const message = rest.join(' - ');
|
||||||
|
const parts = head.split(' ');
|
||||||
|
if (parts.length >= 3) {
|
||||||
|
[date, time, levelText] = parts;
|
||||||
|
} else {
|
||||||
|
levelText = head;
|
||||||
|
}
|
||||||
|
body = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const li = LEVELS.indexOf(levelText);
|
||||||
|
const levelClass = li >= 0 ? LEVEL_CLASSES[li] : 'level-unknown';
|
||||||
|
|
||||||
|
let service = '';
|
||||||
|
if (body.startsWith('XRAY:')) {
|
||||||
|
service = 'XRAY:';
|
||||||
|
body = body.slice('XRAY:'.length).trimStart();
|
||||||
|
} else if (body) {
|
||||||
|
service = 'X-UI:';
|
||||||
|
}
|
||||||
|
|
||||||
|
const stamp = [date, time].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return { date, time, stamp, levelText, levelClass, service, body };
|
||||||
|
}
|
||||||
@@ -68,18 +68,63 @@ function badgeStatus(status?: string): BadgeProps['status'] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusDot({ status }: { status?: string }) {
|
interface HealthProps {
|
||||||
if (status === 'online') return <span className="online-dot" />;
|
status?: string;
|
||||||
|
xrayState?: string;
|
||||||
|
xrayError?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Purple: the node's panel API is reachable (status=online) but its Xray core
|
||||||
|
// has failed or been stopped. Distinct from a normal offline/unknown node.
|
||||||
|
const XRAY_ERROR_COLOR = '#722ED1';
|
||||||
|
|
||||||
|
// True when the panel is online but Xray itself reports error/stop.
|
||||||
|
function hasXrayProblem(status?: string, xrayState?: string): boolean {
|
||||||
|
if (status !== 'online') return false;
|
||||||
|
const xs = (xrayState || '').toLowerCase().trim();
|
||||||
|
return xs === 'error' || xs === 'stop';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tooltip text + icon color for the status cell. A real probe error (lastError)
|
||||||
|
// is a warning and takes precedence; otherwise an Xray-core problem shows purple.
|
||||||
|
function statusIssue(record: Pick<NodeRecord, 'status' | 'xrayState' | 'xrayError' | 'lastError'>) {
|
||||||
|
const tip = record.lastError || (hasXrayProblem(record.status, record.xrayState) ? record.xrayError : '') || '';
|
||||||
|
const iconColor = !record.lastError && hasXrayProblem(record.status, record.xrayState)
|
||||||
|
? XRAY_ERROR_COLOR
|
||||||
|
: 'var(--ant-color-warning)';
|
||||||
|
return { tip, iconColor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusDot({ status, xrayState }: HealthProps) {
|
||||||
|
if (status === 'online') {
|
||||||
|
return hasXrayProblem(status, xrayState)
|
||||||
|
? <span className="xray-error-dot" />
|
||||||
|
: <span className="online-dot" />;
|
||||||
|
}
|
||||||
return <Badge status={badgeStatus(status)} />;
|
return <Badge status={badgeStatus(status)} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatusLabel({ status }: { status?: string }) {
|
function StatusLabel({ status, xrayState }: HealthProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
if (status === 'online') {
|
||||||
<span style={status === 'online' ? { color: 'var(--ant-color-success)' } : undefined}>
|
const xs = (xrayState || '').toLowerCase().trim();
|
||||||
{t(`pages.nodes.statusValues.${status || 'unknown'}`)}
|
if (xs === 'error' || xs === 'stop') {
|
||||||
</span>
|
const detail = xs === 'error'
|
||||||
);
|
? t('pages.nodes.statusValues.xrayError')
|
||||||
|
: t('pages.nodes.statusValues.xrayStopped');
|
||||||
|
return (
|
||||||
|
<span style={{ color: XRAY_ERROR_COLOR }}>
|
||||||
|
{t('pages.nodes.statusValues.online')} ({detail})
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span style={{ color: 'var(--ant-color-success)' }}>
|
||||||
|
{t('pages.nodes.statusValues.online')}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <span>{t(`pages.nodes.statusValues.${status || 'unknown'}`)}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatPct(p?: number): string {
|
function formatPct(p?: number): string {
|
||||||
@@ -271,17 +316,20 @@ export default function NodeList({
|
|||||||
title: t('pages.nodes.status'),
|
title: t('pages.nodes.status'),
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
align: 'center',
|
align: 'center',
|
||||||
render: (_value, record) => (
|
render: (_value, record) => {
|
||||||
<Space size={4}>
|
const { tip, iconColor } = statusIssue(record);
|
||||||
<StatusDot status={record.status} />
|
return (
|
||||||
<StatusLabel status={record.status} />
|
<Space size={4}>
|
||||||
{record.lastError && (
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||||
<Tooltip title={record.lastError}>
|
<StatusLabel status={record.status} xrayState={record.xrayState} />
|
||||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
{tip && (
|
||||||
</Tooltip>
|
<Tooltip title={tip}>
|
||||||
)}
|
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
||||||
</Space>
|
</Tooltip>
|
||||||
),
|
)}
|
||||||
|
</Space>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('pages.nodes.cpu'),
|
title: t('pages.nodes.cpu'),
|
||||||
@@ -389,7 +437,7 @@ export default function NodeList({
|
|||||||
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
|
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
<ApartmentOutlined style={{ opacity: 0.6 }} />
|
||||||
<StatusDot status={record.status} />
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||||
<span className="node-name">{record.name}</span>
|
<span className="node-name">{record.name}</span>
|
||||||
<div className="card-actions">
|
<div className="card-actions">
|
||||||
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
|
||||||
@@ -400,7 +448,7 @@ export default function NodeList({
|
|||||||
<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' : ''}`} />
|
||||||
<StatusDot status={record.status} />
|
<StatusDot status={record.status} xrayState={record.xrayState} />
|
||||||
<span className="node-name">{record.name}</span>
|
<span className="node-name">{record.name}</span>
|
||||||
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<Tooltip title={t('info')}>
|
<Tooltip title={t('info')}>
|
||||||
@@ -494,13 +542,16 @@ export default function NodeList({
|
|||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.status')}</span>
|
<span className="stat-label">{t('pages.nodes.status')}</span>
|
||||||
<StatusDot status={statsNode.status} />
|
<StatusDot status={statsNode.status} xrayState={statsNode.xrayState} />
|
||||||
<StatusLabel status={statsNode.status} />
|
<StatusLabel status={statsNode.status} xrayState={statsNode.xrayState} />
|
||||||
{statsNode.lastError && (
|
{(() => {
|
||||||
<Tooltip title={statsNode.lastError}>
|
const { tip, iconColor } = statusIssue(statsNode);
|
||||||
<ExclamationCircleOutlined style={{ color: 'var(--ant-color-warning)' }} />
|
return tip ? (
|
||||||
</Tooltip>
|
<Tooltip title={tip}>
|
||||||
)}
|
<ExclamationCircleOutlined style={{ color: iconColor }} />
|
||||||
|
</Tooltip>
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-row">
|
<div className="stat-row">
|
||||||
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
<span className="stat-label">{t('pages.nodes.cpu')}</span>
|
||||||
|
|||||||
@@ -83,12 +83,15 @@ export default function NodesPage() {
|
|||||||
const msg = await probe(node.id);
|
const msg = await probe(node.id);
|
||||||
if (msg?.success && msg.obj) {
|
if (msg?.success && msg.obj) {
|
||||||
if (msg.obj.status === 'online') {
|
if (msg.obj.status === 'online') {
|
||||||
|
// Even if xray is in error/stop on the node we still reached its panel API.
|
||||||
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
|
||||||
} else {
|
} else {
|
||||||
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [probe, t, messageApi]);
|
// Refresh the list so the new xrayState / xrayError (if any) appears immediately in the row.
|
||||||
|
refetch();
|
||||||
|
}, [probe, t, messageApi, refetch]);
|
||||||
|
|
||||||
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
|
||||||
await setEnable(node.id, next);
|
await setEnable(node.id, next);
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ export default function SubscriptionGeneralTab({ allSetting, updateSetting }: Su
|
|||||||
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
onChange={(e) => updateSetting({ subAnnounce: e.target.value })} />
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
|
|
||||||
|
<SettingListItem paddings="small" title={t('pages.settings.subThemeDir')} description={t('pages.settings.subThemeDirDesc')}>
|
||||||
|
<Input value={allSetting.subThemeDir} placeholder="/etc/3x-ui/sub_templates/my-theme/"
|
||||||
|
onChange={(e) => updateSetting({ subThemeDir: e.target.value })} />
|
||||||
|
</SettingListItem>
|
||||||
|
|
||||||
<Divider>Happ</Divider>
|
<Divider>Happ</Divider>
|
||||||
|
|
||||||
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
|
<SettingListItem paddings="small" title={t('pages.settings.subEnableRouting')} description={t('pages.settings.subEnableRoutingDesc')}>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { JsonEditor } from '@/components/form';
|
|||||||
import { setMessageInstance } from '@/utils/messageBus';
|
import { setMessageInstance } from '@/utils/messageBus';
|
||||||
|
|
||||||
import { BasicsTab } from './basics';
|
import { BasicsTab } from './basics';
|
||||||
|
import { propagateOutboundTagRename } from './basics/helpers';
|
||||||
import { RoutingTab } from './routing';
|
import { RoutingTab } from './routing';
|
||||||
import { OutboundsTab } from './outbounds';
|
import { OutboundsTab } from './outbounds';
|
||||||
import { BalancersTab } from './balancers';
|
import { BalancersTab } from './balancers';
|
||||||
@@ -118,11 +119,8 @@ export default function XrayPage() {
|
|||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!tt.outbounds || payload.index < 0) return;
|
if (!tt.outbounds || payload.index < 0) return;
|
||||||
tt.outbounds[payload.index] = payload.outbound as never;
|
tt.outbounds[payload.index] = payload.outbound as never;
|
||||||
if (payload.oldTag && payload.newTag && payload.oldTag !== payload.newTag) {
|
if (payload.oldTag && payload.newTag) {
|
||||||
const rules = tt.routing?.rules || [];
|
propagateOutboundTagRename(tt, payload.oldTag, payload.newTag);
|
||||||
for (const r of rules) {
|
|
||||||
if (r?.outboundTag === payload.oldTag) r.outboundTag = payload.newTag;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,3 +54,36 @@ export function syncOutbound(t: XraySettingsValue, tag: string, settings: Record
|
|||||||
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
|
if (!haveRules && idx > 0) t.outbounds.splice(idx, 1);
|
||||||
if (haveRules && idx < 0) t.outbounds.push(settings as never);
|
if (haveRules && idx < 0) t.outbounds.push(settings as never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function propagateOutboundTagRename(
|
||||||
|
t: XraySettingsValue,
|
||||||
|
oldTag: string,
|
||||||
|
newTag: string,
|
||||||
|
): void {
|
||||||
|
if (!oldTag || !newTag || oldTag === newTag) return;
|
||||||
|
|
||||||
|
const rules = t.routing?.rules;
|
||||||
|
if (Array.isArray(rules)) {
|
||||||
|
for (const rule of rules) {
|
||||||
|
if (rule?.outboundTag === oldTag) rule.outboundTag = newTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const balancers = t.routing?.balancers;
|
||||||
|
if (Array.isArray(balancers)) {
|
||||||
|
for (const balancer of balancers) {
|
||||||
|
if (balancer?.fallbackTag === oldTag) balancer.fallbackTag = newTag;
|
||||||
|
if (Array.isArray(balancer?.selector)) {
|
||||||
|
balancer.selector = balancer.selector.map((sel) => (sel === oldTag ? newTag : sel));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(t.outbounds)) {
|
||||||
|
for (const outbound of t.outbounds) {
|
||||||
|
const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
||||||
|
?.streamSettings?.sockopt;
|
||||||
|
if (sockopt?.dialerProxy === oldTag) sockopt.dialerProxy = newTag;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { HttpUtil } from '@/utils';
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
import OutboundFormModal from './OutboundFormModal';
|
import OutboundFormModal from './OutboundFormModal';
|
||||||
|
import { propagateOutboundTagRename } from '../basics/helpers';
|
||||||
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
||||||
import './OutboundsTab.css';
|
import './OutboundsTab.css';
|
||||||
|
|
||||||
@@ -172,11 +173,16 @@ export default function OutboundsTab({
|
|||||||
function onConfirm(outbound: Record<string, unknown>) {
|
function onConfirm(outbound: Record<string, unknown>) {
|
||||||
mutate((tt) => {
|
mutate((tt) => {
|
||||||
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
||||||
|
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
|
||||||
if (editingIndex == null) {
|
if (editingIndex == null) {
|
||||||
if (!outbound.tag) return;
|
if (!newTag) return;
|
||||||
tt.outbounds.push(outbound as never);
|
tt.outbounds.push(outbound as never);
|
||||||
} else {
|
} else {
|
||||||
|
const oldTag = tt.outbounds[editingIndex]?.tag;
|
||||||
tt.outbounds[editingIndex] = outbound as never;
|
tt.outbounds[editingIndex] = outbound as never;
|
||||||
|
if (oldTag && newTag && oldTag !== newTag) {
|
||||||
|
propagateOutboundTagRename(tt, oldTag, newTag);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setModalOpen(false);
|
setModalOpen(false);
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ export default function WarpModal({
|
|||||||
const [warpData, setWarpData] = useState<WarpData | null>(null);
|
const [warpData, setWarpData] = useState<WarpData | null>(null);
|
||||||
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
|
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
|
||||||
const [warpPlus, setWarpPlus] = useState('');
|
const [warpPlus, setWarpPlus] = useState('');
|
||||||
|
const [updateInterval, setUpdateInterval] = useState<number>(0);
|
||||||
const [licenseError, setLicenseError] = useState('');
|
const [licenseError, setLicenseError] = useState('');
|
||||||
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
|
const [stagedOutbound, setStagedOutbound] = useState<Record<string, unknown> | null>(null);
|
||||||
|
|
||||||
@@ -89,24 +90,29 @@ export default function WarpModal({
|
|||||||
return list.findIndex((o) => o?.tag === 'warp');
|
return list.findIndex((o) => o?.tag === 'warp');
|
||||||
}, [templateSettings?.outbounds]);
|
}, [templateSettings?.outbounds]);
|
||||||
|
|
||||||
const collectConfig = useCallback((data: WarpData | null, config: WarpConfig | null) => {
|
const collectConfig = useCallback(
|
||||||
const cfg = config?.config;
|
(data: WarpData | null, config: WarpConfig | null): Record<string, unknown> | null => {
|
||||||
if (!cfg?.peers?.length) return;
|
const cfg = config?.config;
|
||||||
const peer = cfg.peers[0];
|
if (!cfg?.peers?.length) return null;
|
||||||
setStagedOutbound({
|
const peer = cfg.peers[0];
|
||||||
tag: 'warp',
|
const outbound: Record<string, unknown> = {
|
||||||
protocol: 'wireguard',
|
tag: 'warp',
|
||||||
settings: {
|
protocol: 'wireguard',
|
||||||
mtu: 1420,
|
settings: {
|
||||||
secretKey: data?.private_key,
|
mtu: 1420,
|
||||||
address: addressesFor(cfg.interface?.addresses || {}),
|
secretKey: data?.private_key,
|
||||||
reserved: reservedFor(cfg.client_id ?? data?.client_id),
|
address: addressesFor(cfg.interface?.addresses || {}),
|
||||||
domainStrategy: 'ForceIP',
|
reserved: reservedFor(cfg.client_id ?? data?.client_id),
|
||||||
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
|
domainStrategy: 'ForceIP',
|
||||||
noKernelTun: false,
|
peers: [{ publicKey: peer.public_key, endpoint: peer.endpoint?.host }],
|
||||||
},
|
noKernelTun: false,
|
||||||
});
|
},
|
||||||
}, []);
|
};
|
||||||
|
setStagedOutbound(outbound);
|
||||||
|
return outbound;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -116,6 +122,10 @@ export default function WarpModal({
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
const settingMsg = await HttpUtil.post<Record<string, unknown>>('/panel/api/setting/all');
|
||||||
|
if (settingMsg?.success && settingMsg.obj) {
|
||||||
|
setUpdateInterval(Number(settingMsg.obj.warpUpdateInterval) || 0);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -159,6 +169,40 @@ export default function WarpModal({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function changeIp() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/changeIp');
|
||||||
|
if (msg?.success && msg.obj) {
|
||||||
|
const parsed = JSON.parse(msg.obj);
|
||||||
|
setWarpData(parsed.data);
|
||||||
|
setWarpConfig(parsed.config);
|
||||||
|
const built = collectConfig(parsed.data, parsed.config);
|
||||||
|
// The backend already persisted the new keys into the saved Xray
|
||||||
|
// template; keep the in-memory editor in sync so a later template
|
||||||
|
// save doesn't revert them to the old keys.
|
||||||
|
if (built && warpOutboundIndex >= 0) {
|
||||||
|
onResetOutbound({ index: warpOutboundIndex, outbound: built });
|
||||||
|
}
|
||||||
|
messageApi.success(t('pages.xray.warp.changeIpSuccess', 'WARP IP changed successfully!'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInterval() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const msg = await HttpUtil.post('/panel/api/xray/warp/interval', { interval: updateInterval });
|
||||||
|
if (msg?.success) {
|
||||||
|
messageApi.success(t('pages.setting.toasts.saveSuccess', 'Settings saved successfully'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function updateLicense() {
|
async function updateLicense() {
|
||||||
if (warpPlus.length < 26) return;
|
if (warpPlus.length < 26) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -281,13 +325,37 @@ export default function WarpModal({
|
|||||||
</Form>
|
</Form>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: t('pages.xray.warp.autoUpdateIp', 'Auto Update IP Address'),
|
||||||
|
children: (
|
||||||
|
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 12 } }}>
|
||||||
|
<Form.Item label={t('pages.xray.warp.intervalDays', 'Interval (Days)')} extra={t('pages.xray.warp.intervalDesc', '0 to disable. Changes IP address automatically.')}>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={updateInterval}
|
||||||
|
onChange={(e) => setUpdateInterval(Number(e.target.value))}
|
||||||
|
/>
|
||||||
|
<Button className="mt-8" type="primary" loading={loading} onClick={saveInterval}>
|
||||||
|
{t('save', 'Save')}
|
||||||
|
</Button>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
),
|
||||||
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
|
<Divider className="zero-margin">{t('pages.xray.warp.accountInfo')}</Divider>
|
||||||
<Button className="my-8" loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
|
<div className="my-8">
|
||||||
{t('refresh')}
|
<Button loading={loading} type="primary" icon={<SyncOutlined />} onClick={getConfig}>
|
||||||
</Button>
|
{t('refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button loading={loading} type="primary" className="ml-8" icon={<SyncOutlined />} onClick={changeIp}>
|
||||||
|
{t('pages.xray.warp.changeIp', 'Change IP')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasConfig && (
|
{hasConfig && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ export const ActiveInboundsByNodeSchema = z
|
|||||||
export const GroupSummarySchema = z.object({
|
export const GroupSummarySchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
clientCount: z.number(),
|
clientCount: z.number(),
|
||||||
|
trafficUsed: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
export const GroupSummaryListSchema = z.array(GroupSummarySchema).nullable().transform((v) => v ?? []);
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ export const NodeRecordSchema = z.object({
|
|||||||
depletedCount: z.number().optional(),
|
depletedCount: z.number().optional(),
|
||||||
lastHeartbeat: z.number().optional(),
|
lastHeartbeat: z.number().optional(),
|
||||||
lastError: z.string().optional(),
|
lastError: z.string().optional(),
|
||||||
|
// Xray state captured from the remote node's own /panel/api/server/status.
|
||||||
|
// Lets the nodes list show a distinct indicator when the panel API is reachable
|
||||||
|
// (status=online) but the Xray core on that node has failed.
|
||||||
|
xrayState: z.string().optional(),
|
||||||
|
xrayError: z.string().optional(),
|
||||||
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(),
|
||||||
@@ -40,6 +45,9 @@ export const ProbeResultSchema = z.object({
|
|||||||
latencyMs: z.number().optional(),
|
latencyMs: z.number().optional(),
|
||||||
xrayVersion: z.string().optional(),
|
xrayVersion: z.string().optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
|
// Present on successful probe; used to surface "connected to panel, but xray failed on node".
|
||||||
|
xrayState: z.string().optional(),
|
||||||
|
xrayError: z.string().optional(),
|
||||||
}).loose();
|
}).loose();
|
||||||
|
|
||||||
export const NodeFormSchema = z.object({
|
export const NodeFormSchema = z.object({
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
.nodes-page .ant-card.ant-card-hoverable:hover,
|
.nodes-page .ant-card.ant-card-hoverable:hover,
|
||||||
.groups-page .ant-card.ant-card-hoverable:hover,
|
.groups-page .ant-card.ant-card-hoverable:hover,
|
||||||
.api-docs-page .ant-card.ant-card-hoverable:hover {
|
.api-docs-page .ant-card.ant-card-hoverable:hover {
|
||||||
|
cursor: default;
|
||||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,3 +41,26 @@
|
|||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.online-dot { animation: none; }
|
.online-dot { animation: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Purple indicator for nodes that are reachable via the panel API (status=online)
|
||||||
|
but have Xray core in "error" or "stop" state. This is the new "xray failed on node"
|
||||||
|
monitoring state. */
|
||||||
|
.xray-error-dot {
|
||||||
|
display: inline-block;
|
||||||
|
width: 7px;
|
||||||
|
height: 7px;
|
||||||
|
border-radius: 50%;
|
||||||
|
margin-inline-end: 5px;
|
||||||
|
vertical-align: middle;
|
||||||
|
background: #722ED1;
|
||||||
|
animation: xray-error-blink 1.1s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes xray-error-blink {
|
||||||
|
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 rgba(114, 46, 209, 0.55); }
|
||||||
|
50% { opacity: 0.35; box-shadow: 0 0 0 4px rgba(114, 46, 209, 0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.xray-error-dot { animation: none; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import { parseLogLine } from '@/pages/index/logParse';
|
||||||
|
|
||||||
|
// Fixtures are real lines captured from `journalctl -u x-ui` on a production
|
||||||
|
// host (the SysLog view) plus the in-memory app-log format. Each journald entry
|
||||||
|
// carries a "Mon DD HH:MM:SS host ident[pid]: " prefix that the viewer used to
|
||||||
|
// mistake for the level, leaving only a bare timestamp on screen.
|
||||||
|
describe('parseLogLine — SysLog (journalctl) formats', () => {
|
||||||
|
it('x-ui go-logging line: keeps level, strips prefix, tags X-UI', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 08 23:57:28 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443',
|
||||||
|
);
|
||||||
|
expect(r.stamp).toBe('Jun 08 23:57:28');
|
||||||
|
expect(r.levelText).toBe('INFO');
|
||||||
|
expect(r.service).toBe('X-UI:');
|
||||||
|
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('xray go-logging line: lifts the XRAY service tag', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 08 23:56:52 ubuntu-4gb-fsn1-1 /usr/local/x-ui/x-ui[72297]: WARNING - XRAY: core: Xray 26.6.1 started',
|
||||||
|
);
|
||||||
|
expect(r.stamp).toBe('Jun 08 23:56:52');
|
||||||
|
expect(r.levelText).toBe('WARNING');
|
||||||
|
expect(r.service).toBe('XRAY:');
|
||||||
|
expect(r.body).toBe('core: Xray 26.6.1 started');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Go std-log line: strips the redundant embedded date, keeps the message', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 08 19:22:22 ubuntu-4gb-fsn1-1 x-ui[1439]: 2026/06/08 19:22:22 http: TLS handshake error from 18.97.5.1:36022: EOF',
|
||||||
|
);
|
||||||
|
expect(r.stamp).toBe('Jun 08 19:22:22');
|
||||||
|
expect(r.levelText).toBe('');
|
||||||
|
expect(r.body).toBe('http: TLS handshake error from 18.97.5.1:36022: EOF');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('telego bracketed line: lifts the ERROR level out of "[ts] ERROR ..."', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 09 00:14:52 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:14:52 UTC 2026] ERROR Retrying getting updates in 8s...',
|
||||||
|
);
|
||||||
|
expect(r.stamp).toBe('Jun 09 00:14:52');
|
||||||
|
expect(r.levelText).toBe('ERROR');
|
||||||
|
expect(r.body).toBe('Retrying getting updates in 8s...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('systemd line: shows the body rather than a bare timestamp', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 08 23:56:47 ubuntu-4gb-fsn1-1 systemd[1]: Stopping x-ui.service - x-ui Service...',
|
||||||
|
);
|
||||||
|
expect(r.stamp).toBe('Jun 08 23:56:47');
|
||||||
|
expect(r.body).toBe('Stopping x-ui.service - x-ui Service...');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('never collapses a journald entry to just its timestamp', () => {
|
||||||
|
const r = parseLogLine(
|
||||||
|
'Jun 09 00:15:00 ubuntu-4gb-fsn1-1 x-ui[72297]: [Tue Jun 9 00:15:00 UTC 2026] ERROR Getting updates: telego: getUpdates: api: 409 "Conflict"',
|
||||||
|
);
|
||||||
|
expect(r.body.length).toBeGreaterThan(0);
|
||||||
|
expect(r.body).toContain('Conflict');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parseLogLine — app-log format (SysLog off)', () => {
|
||||||
|
it('parses "YYYY/MM/DD HH:MM:SS LEVEL - body"', () => {
|
||||||
|
const r = parseLogLine('2026/06/09 00:35:09 INFO - mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
|
||||||
|
expect(r.date).toBe('2026/06/09');
|
||||||
|
expect(r.time).toBe('00:35:09');
|
||||||
|
expect(r.levelText).toBe('INFO');
|
||||||
|
expect(r.service).toBe('X-UI:');
|
||||||
|
expect(r.body).toBe('mtproto: started mtg for inbound 3 on 0.0.0.0:8443');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty line without throwing', () => {
|
||||||
|
const r = parseLogLine('');
|
||||||
|
expect(r.stamp).toBe('');
|
||||||
|
expect(r.body).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
|
||||||
|
import type { XraySettingsValue } from '@/hooks/useXraySetting';
|
||||||
|
import { propagateOutboundTagRename } from '@/pages/xray/basics/helpers';
|
||||||
|
|
||||||
|
function baseTemplate(): XraySettingsValue {
|
||||||
|
return {
|
||||||
|
outbounds: [
|
||||||
|
{ tag: 'To-External-Proxy', protocol: 'vless' },
|
||||||
|
{ tag: 'direct', protocol: 'freedom' },
|
||||||
|
],
|
||||||
|
routing: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
type: 'field',
|
||||||
|
inboundTag: ['iran-in'],
|
||||||
|
outboundTag: 'To-External-Proxy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
balancers: [
|
||||||
|
{
|
||||||
|
tag: 'lb-1',
|
||||||
|
selector: ['To-External-Proxy', 'direct'],
|
||||||
|
fallbackTag: 'To-External-Proxy',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as XraySettingsValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('propagateOutboundTagRename', () => {
|
||||||
|
it('updates routing rule outboundTag when outbound is renamed', () => {
|
||||||
|
const t = baseTemplate();
|
||||||
|
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||||
|
expect(t.routing?.rules?.[0]?.outboundTag).toBe('external-vps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates balancer selector and fallbackTag', () => {
|
||||||
|
const t = baseTemplate();
|
||||||
|
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||||
|
expect(t.routing?.balancers?.[0]?.selector).toEqual(['external-vps', 'direct']);
|
||||||
|
expect(t.routing?.balancers?.[0]?.fallbackTag).toBe('external-vps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates sockopt dialerProxy references in other outbounds', () => {
|
||||||
|
const t = baseTemplate();
|
||||||
|
(t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } }).streamSettings = {
|
||||||
|
sockopt: { dialerProxy: 'To-External-Proxy' },
|
||||||
|
};
|
||||||
|
propagateOutboundTagRename(t, 'To-External-Proxy', 'external-vps');
|
||||||
|
const dialerProxy = (t.outbounds![1] as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
|
||||||
|
.streamSettings?.sockopt?.dialerProxy;
|
||||||
|
expect(dialerProxy).toBe('external-vps');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when old and new tags are equal', () => {
|
||||||
|
const t = baseTemplate();
|
||||||
|
propagateOutboundTagRename(t, 'To-External-Proxy', 'To-External-Proxy');
|
||||||
|
expect(t.routing?.rules?.[0]?.outboundTag).toBe('To-External-Proxy');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,3 +74,19 @@ afterEach(async () => {
|
|||||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
import { HttpUtil } from '@/utils';
|
||||||
|
|
||||||
|
vi.mock('axios', () => {
|
||||||
|
return {
|
||||||
|
default: {
|
||||||
|
get: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
|
||||||
|
post: vi.fn().mockResolvedValue({ data: { success: true, obj: {} } }),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
vi.spyOn(HttpUtil, 'post').mockResolvedValue({ success: true, obj: {} } as any);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
vi.spyOn(HttpUtil, 'get').mockResolvedValue({ success: true, obj: {} } as any);
|
||||||
|
|||||||
@@ -23,9 +23,9 @@ require (
|
|||||||
github.com/xlzd/gotp v0.1.0
|
github.com/xlzd/gotp v0.1.0
|
||||||
github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
|
github.com/xtls/xray-core v1.260327.1-0.20260601021109-94ffd50060f1
|
||||||
go.uber.org/atomic v1.11.0
|
go.uber.org/atomic v1.11.0
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.53.0
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.46.0
|
||||||
golang.org/x/text v0.37.0
|
golang.org/x/text v0.38.0
|
||||||
google.golang.org/grpc v1.81.1
|
google.golang.org/grpc v1.81.1
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
@@ -34,10 +34,10 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/pion/dtls/v3 v3.1.2 // indirect
|
github.com/pion/dtls/v3 v3.1.4 // indirect
|
||||||
github.com/pion/logging v0.2.4 // indirect
|
github.com/pion/logging v0.2.4 // indirect
|
||||||
github.com/pion/stun/v3 v3.1.2 // indirect
|
github.com/pion/stun/v3 v3.1.5 // indirect
|
||||||
github.com/pion/transport/v4 v4.0.1 // indirect
|
github.com/pion/transport/v4 v4.0.2 // indirect
|
||||||
github.com/wlynxg/anet v0.0.5 // indirect
|
github.com/wlynxg/anet v0.0.5 // indirect
|
||||||
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
golang.zx2c4.com/wireguard/windows v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
@@ -47,7 +47,7 @@ require (
|
|||||||
github.com/andybalholm/brotli v1.2.1 // indirect
|
github.com/andybalholm/brotli v1.2.1 // indirect
|
||||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
|
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
github.com/bytedance/sonic v1.15.1 // indirect
|
github.com/bytedance/sonic v1.15.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||||
github.com/cloudflare/circl v1.6.3 // indirect
|
github.com/cloudflare/circl v1.6.3 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
@@ -76,7 +76,7 @@ require (
|
|||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e // indirect
|
||||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||||
github.com/mattn/go-sqlite3 v1.14.44 // indirect
|
github.com/mattn/go-sqlite3 v1.14.45 // indirect
|
||||||
github.com/miekg/dns v1.1.72 // indirect
|
github.com/miekg/dns v1.1.72 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
@@ -84,7 +84,7 @@ require (
|
|||||||
github.com/pires/go-proxyproto v0.12.0 // indirect
|
github.com/pires/go-proxyproto v0.12.0 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/quic-go/qpack v0.6.0 // indirect
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
||||||
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
github.com/rogpeppe/go-internal v1.15.0 // indirect
|
||||||
github.com/sagernet/sing v0.8.10 // indirect
|
github.com/sagernet/sing v0.8.10 // indirect
|
||||||
@@ -101,16 +101,16 @@ require (
|
|||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
|
||||||
golang.org/x/arch v0.27.0 // indirect
|
golang.org/x/arch v0.28.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 // indirect
|
golang.org/x/exp v0.0.0-20260603202125-055de637280b // indirect
|
||||||
golang.org/x/mod v0.36.0 // indirect
|
golang.org/x/mod v0.37.0 // indirect
|
||||||
golang.org/x/net v0.55.0
|
golang.org/x/net v0.55.0
|
||||||
golang.org/x/sync v0.20.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/time v0.15.0 // indirect
|
golang.org/x/time v0.15.0 // indirect
|
||||||
golang.org/x/tools v0.45.0 // indirect
|
golang.org/x/tools v0.45.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
golang.zx2c4.com/wireguard v0.0.0-20260522210424-ecfc5a8d5446 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect
|
||||||
google.golang.org/protobuf v1.36.11 // indirect
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
|
||||||
lukechampine.com/blake3 v1.4.1 // indirect
|
lukechampine.com/blake3 v1.4.1 // indirect
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkd
|
|||||||
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
|
||||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
@@ -129,8 +129,8 @@ github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e h1:Q6MvJtQK/iRcRt
|
|||||||
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
github.com/lufia/plan9stats v0.0.0-20260330125221-c963978e514e/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44 h1:3VSe+xafpbzsLbdr2AWlAZk9yRHiBhTBakioXaCKTF8=
|
github.com/mattn/go-sqlite3 v1.14.45 h1:6KA/spDguL3KV8rnybG7ezSaE4SeMR3KC9VbUoAQaIk=
|
||||||
github.com/mattn/go-sqlite3 v1.14.44/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
github.com/mattn/go-sqlite3 v1.14.45/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@@ -148,24 +148,26 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v
|
|||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=
|
github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY=
|
||||||
github.com/pion/dtls/v3 v3.1.2/go.mod h1:Hw/igcX4pdY69z1Hgv5x7wJFrUkdgHwAn/Q/uo7YHRo=
|
github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc=
|
||||||
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8=
|
||||||
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so=
|
||||||
github.com/pion/stun/v3 v3.1.2 h1:86IhD8wFn6IDW4b1/0QzoQS+f5PeA8OHHRn8UZW5ErY=
|
github.com/pion/stun/v3 v3.1.5 h1:Y1FHlhaI6+4UoC5i/zQf4F7JvdZtB24/05oyy/GF1x8=
|
||||||
github.com/pion/stun/v3 v3.1.2/go.mod h1:H7gDic7nNwlUL05pbs6T1dtaBehh/KjupxfWw3ZI7cA=
|
github.com/pion/stun/v3 v3.1.5/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs=
|
||||||
github.com/pion/transport/v4 v4.0.1 h1:sdROELU6BZ63Ab7FrOLn13M6YdJLY20wldXW2Cu2k8o=
|
github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk=
|
||||||
github.com/pion/transport/v4 v4.0.1/go.mod h1:nEuEA4AD5lPdcIegQDpVLgNoDGreqM/YqmEx3ovP4jM=
|
github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM=
|
||||||
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM=
|
||||||
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
@@ -244,27 +246,27 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
|||||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
golang.org/x/arch v0.28.0 h1:wVwVdqsTuUbJvhYVCspQYwZXHNYeLSoZnmHD+ggddpQ=
|
||||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
golang.org/x/arch v0.28.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9 h1:4d4PbuBNwaxMXkXI8yiIYjydtMU+04RHeuSxJdgKftM=
|
golang.org/x/exp v0.0.0-20260603202125-055de637280b h1:v1uXiEBHo8QA0LiGCo7UgHMzHT4Kdfpl2zmtH5vaP1Q=
|
||||||
golang.org/x/exp v0.0.0-20260529124908-c761662dc8c9/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
golang.org/x/exp v0.0.0-20260603202125-055de637280b/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
|
||||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||||
@@ -277,8 +279,8 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH
|
|||||||
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs=
|
||||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||||
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
|
||||||
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
|
||||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
|
|||||||
@@ -1177,6 +1177,11 @@ install_x-ui() {
|
|||||||
else
|
else
|
||||||
systemctl stop x-ui
|
systemctl stop x-ui
|
||||||
fi
|
fi
|
||||||
|
# Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
|
||||||
|
# lifecycle, so on Linux a stale one can survive the stop and keep holding
|
||||||
|
# an inbound port with an outdated secret, silently breaking new clients.
|
||||||
|
# The freshly installed panel respawns a clean mtg per inbound on start.
|
||||||
|
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
|
||||||
rm ${xui_folder}/ -rf
|
rm ${xui_folder}/ -rf
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+23
-4
@@ -58,6 +58,9 @@ type managed struct {
|
|||||||
type Manager struct {
|
type Manager struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
procs map[int]*managed
|
procs map[int]*managed
|
||||||
|
// swept records that the one-time startup cleanup of orphaned mtg
|
||||||
|
// processes (survivors of a previous x-ui run) has already run.
|
||||||
|
swept bool
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -107,9 +110,24 @@ func InstanceFromInbound(ib *model.Inbound) (Instance, bool) {
|
|||||||
func (m *Manager) Ensure(inst Instance) error {
|
func (m *Manager) Ensure(inst Instance) error {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
m.sweepOrphansLocked()
|
||||||
return m.ensureLocked(inst)
|
return m.ensureLocked(inst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sweepOrphansLocked kills mtg processes left running by a previous x-ui run,
|
||||||
|
// exactly once per process lifetime and before any of our own mtg are started.
|
||||||
|
// Because x-ui owns every mtg process, anything alive at this point is an orphan
|
||||||
|
// that would otherwise keep holding an inbound port with a stale secret.
|
||||||
|
func (m *Manager) sweepOrphansLocked() {
|
||||||
|
if m.swept {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
m.swept = true
|
||||||
|
if n := killStrayMtgProcesses(GetBinaryPath()); n > 0 {
|
||||||
|
logger.Warningf("mtproto: terminated %d orphaned mtg process(es) from a previous run", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) ensureLocked(inst Instance) error {
|
func (m *Manager) ensureLocked(inst Instance) error {
|
||||||
fp := inst.fingerprint()
|
fp := inst.fingerprint()
|
||||||
if cur, ok := m.procs[inst.Id]; ok {
|
if cur, ok := m.procs[inst.Id]; ok {
|
||||||
@@ -128,7 +146,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
|||||||
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
|
if err := writeConfig(cfgPath, inst.Secret, inst.bindTo(), metricsPort); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
proc := newProcess(cfgPath)
|
proc := newProcess(cfgPath, fmt.Sprintf("inbound %d", inst.Id))
|
||||||
if err := proc.Start(); err != nil {
|
if err := proc.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -138,7 +156,7 @@ func (m *Manager) ensureLocked(inst Instance) error {
|
|||||||
fingerprint: fp,
|
fingerprint: fp,
|
||||||
metricsPort: metricsPort,
|
metricsPort: metricsPort,
|
||||||
}
|
}
|
||||||
logger.Info("mtproto: started mtg for inbound", inst.Id, "on", inst.bindTo())
|
logger.Infof("mtproto: started mtg for inbound %d on %s", inst.Id, inst.bindTo())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +168,7 @@ func (m *Manager) Remove(id int) {
|
|||||||
cur.proc.Stop()
|
cur.proc.Stop()
|
||||||
delete(m.procs, id)
|
delete(m.procs, id)
|
||||||
_ = os.Remove(configPathForID(id))
|
_ = os.Remove(configPathForID(id))
|
||||||
logger.Info("mtproto: stopped mtg for inbound", id)
|
logger.Infof("mtproto: stopped mtg for inbound %d", id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,6 +178,7 @@ func (m *Manager) Remove(id int) {
|
|||||||
func (m *Manager) Reconcile(desired []Instance) {
|
func (m *Manager) Reconcile(desired []Instance) {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
defer m.mu.Unlock()
|
defer m.mu.Unlock()
|
||||||
|
m.sweepOrphansLocked()
|
||||||
want := make(map[int]struct{}, len(desired))
|
want := make(map[int]struct{}, len(desired))
|
||||||
for _, inst := range desired {
|
for _, inst := range desired {
|
||||||
want[inst.Id] = struct{}{}
|
want[inst.Id] = struct{}{}
|
||||||
@@ -173,7 +192,7 @@ func (m *Manager) Reconcile(desired []Instance) {
|
|||||||
}
|
}
|
||||||
for _, inst := range desired {
|
for _, inst := range desired {
|
||||||
if err := m.ensureLocked(inst); err != nil {
|
if err := m.ensureLocked(inst); err != nil {
|
||||||
logger.Warning("mtproto: reconcile failed for inbound", inst.Id, ":", err)
|
logger.Warningf("mtproto: reconcile failed for inbound %d: %v", inst.Id, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
//go:build linux
|
||||||
|
|
||||||
|
package mtproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// killStrayMtgProcesses terminates orphaned mtg sidecars left over from a
|
||||||
|
// previous x-ui run and returns how many were killed.
|
||||||
|
//
|
||||||
|
// x-ui starts one mtg process per mtproto inbound outside its own lifecycle, and
|
||||||
|
// on Linux a child is not guaranteed to die with the panel (there is no
|
||||||
|
// kill-on-exit, unlike the Windows job object). A survivor keeps holding the
|
||||||
|
// inbound port with a now-stale secret, so new clients are silently
|
||||||
|
// domain-fronted to the FakeTLS domain instead of proxied to Telegram. x-ui is
|
||||||
|
// the sole owner of mtg, so any process matching our binary name at startup is
|
||||||
|
// an orphan and is safe to kill before we start our own.
|
||||||
|
//
|
||||||
|
// binaryPath is the configured mtg path (e.g. "bin/mtg-linux-amd64"); matching
|
||||||
|
// is done on the executable's base name so it is independent of the bin folder
|
||||||
|
// and still works after an update has deleted the binary (the running process's
|
||||||
|
// /proc/<pid>/exe then reads as "<path> (deleted)", so argv[0] is used too).
|
||||||
|
func killStrayMtgProcesses(binaryPath string) int {
|
||||||
|
base := filepath.Base(binaryPath)
|
||||||
|
if base == "" || base == "." || base == string(filepath.Separator) {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
self := os.Getpid()
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
killed := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
pid, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil || pid == self {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if procExeBase(pid) != base && cmdlineArgv0Base(pid) != base {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := syscall.Kill(pid, syscall.SIGKILL); err == nil {
|
||||||
|
killed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return killed
|
||||||
|
}
|
||||||
|
|
||||||
|
// procExeBase returns the base name of /proc/<pid>/exe, or "" if unreadable.
|
||||||
|
func procExeBase(pid int) string {
|
||||||
|
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Base(exe)
|
||||||
|
}
|
||||||
|
|
||||||
|
// cmdlineArgv0Base returns the base name of argv[0] from /proc/<pid>/cmdline,
|
||||||
|
// the reliable fallback when the binary has been replaced or exe is unreadable.
|
||||||
|
func cmdlineArgv0Base(pid int) string {
|
||||||
|
data, err := os.ReadFile(fmt.Sprintf("/proc/%d/cmdline", pid))
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
argv0 := data
|
||||||
|
if i := strings.IndexByte(string(data), 0); i >= 0 {
|
||||||
|
argv0 = data[:i]
|
||||||
|
}
|
||||||
|
if len(argv0) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return filepath.Base(string(argv0))
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build !linux
|
||||||
|
|
||||||
|
package mtproto
|
||||||
|
|
||||||
|
// killStrayMtgProcesses is a no-op off Linux. On Windows the kill-on-exit job
|
||||||
|
// object already terminates mtg together with the panel (see
|
||||||
|
// attachChildLifetime), so orphans do not arise there; other platforms are not
|
||||||
|
// a supported deployment target for the mtg sidecar.
|
||||||
|
func killStrayMtgProcesses(_ string) int { return 0 }
|
||||||
+46
-12
@@ -49,22 +49,55 @@ var (
|
|||||||
forceStopTimeout = 2 * time.Second
|
forceStopTimeout = 2 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type lastLineWriter struct {
|
// procLogWriter consumes the mtg child process's stdout/stderr. It splits the
|
||||||
|
// stream into lines, forwards each one to the x-ui log — so mtg's own messages,
|
||||||
|
// including why it cannot reach Telegram, become visible in the panel log viewer
|
||||||
|
// and journald — and remembers the most recent line for GetResult.
|
||||||
|
type procLogWriter struct {
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
label string
|
||||||
|
buf string
|
||||||
lastLine string
|
lastLine string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *lastLineWriter) Write(p []byte) (int, error) {
|
func (w *procLogWriter) Write(p []byte) (int, error) {
|
||||||
line := strings.TrimSpace(string(p))
|
w.mu.Lock()
|
||||||
if line != "" {
|
defer w.mu.Unlock()
|
||||||
w.mu.Lock()
|
w.buf += string(p)
|
||||||
w.lastLine = line
|
for {
|
||||||
w.mu.Unlock()
|
i := strings.IndexByte(w.buf, '\n')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := w.buf[:i]
|
||||||
|
w.buf = w.buf[i+1:]
|
||||||
|
w.emitLocked(line)
|
||||||
}
|
}
|
||||||
return len(p), nil
|
return len(p), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (w *lastLineWriter) LastLine() string {
|
// Flush emits any buffered partial line; called once the process exits so a
|
||||||
|
// final un-terminated error line is not lost.
|
||||||
|
func (w *procLogWriter) Flush() {
|
||||||
|
w.mu.Lock()
|
||||||
|
defer w.mu.Unlock()
|
||||||
|
if w.buf != "" {
|
||||||
|
line := w.buf
|
||||||
|
w.buf = ""
|
||||||
|
w.emitLocked(line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *procLogWriter) emitLocked(line string) {
|
||||||
|
trimmed := strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||||
|
if trimmed == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.lastLine = trimmed
|
||||||
|
logger.Infof("mtproto: mtg %s | %s", w.label, trimmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *procLogWriter) LastLine() string {
|
||||||
w.mu.Lock()
|
w.mu.Lock()
|
||||||
defer w.mu.Unlock()
|
defer w.mu.Unlock()
|
||||||
return w.lastLine
|
return w.lastLine
|
||||||
@@ -75,15 +108,15 @@ type Process struct {
|
|||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
configPath string
|
configPath string
|
||||||
logWriter *lastLineWriter
|
logWriter *procLogWriter
|
||||||
exitErr error
|
exitErr error
|
||||||
intentionalStop atomic.Bool
|
intentionalStop atomic.Bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newProcess(configPath string) *Process {
|
func newProcess(configPath, label string) *Process {
|
||||||
return &Process{
|
return &Process{
|
||||||
configPath: configPath,
|
configPath: configPath,
|
||||||
logWriter: &lastLineWriter{},
|
logWriter: &procLogWriter{label: label},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,6 +174,7 @@ func (p *Process) Start() error {
|
|||||||
func (p *Process) wait(cmd *exec.Cmd) {
|
func (p *Process) wait(cmd *exec.Cmd) {
|
||||||
defer close(p.done)
|
defer close(p.done)
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
|
p.logWriter.Flush()
|
||||||
if err == nil || p.intentionalStop.Load() {
|
if err == nil || p.intentionalStop.Load() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -150,7 +184,7 @@ func (p *Process) wait(cmd *exec.Cmd) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
logger.Error("mtproto: mtg process exited:", err)
|
logger.Errorf("mtproto: mtg process exited: %v", err)
|
||||||
p.exitErr = err
|
p.exitErr = err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -51,16 +51,16 @@ func attachChildLifetime(cmd *exec.Cmd) {
|
|||||||
}
|
}
|
||||||
job, err := ensureKillOnExitJob()
|
job, err := ensureKillOnExitJob()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("mtproto: kill-on-exit job unavailable:", err)
|
logger.Warningf("mtproto: kill-on-exit job unavailable: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
h, err := windows.OpenProcess(windows.PROCESS_SET_QUOTA|windows.PROCESS_TERMINATE, false, uint32(cmd.Process.Pid))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("mtproto: OpenProcess for job attach failed:", err)
|
logger.Warningf("mtproto: OpenProcess for job attach failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer windows.CloseHandle(h)
|
defer windows.CloseHandle(h)
|
||||||
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
if err := windows.AssignProcessToJobObject(job, h); err != nil {
|
||||||
logger.Warning("mtproto: AssignProcessToJobObject failed:", err)
|
logger.Warningf("mtproto: AssignProcessToJobObject failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+111
-21
@@ -5,15 +5,19 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html/template"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
// writeSubError translates a service-layer result into an HTTP response.
|
// writeSubError translates a service-layer result into an HTTP response.
|
||||||
@@ -28,6 +32,14 @@ func writeSubError(c *gin.Context, err error) {
|
|||||||
c.Status(http.StatusInternalServerError)
|
c.Status(http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cachedSubTemplate holds a parsed custom subscription template together with
|
||||||
|
// the modification time of the file it was parsed from, so the cache can be
|
||||||
|
// invalidated when an admin edits the template on disk.
|
||||||
|
type cachedSubTemplate struct {
|
||||||
|
tmpl *template.Template
|
||||||
|
modTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
// SUBController handles HTTP requests for subscription links and JSON configurations.
|
||||||
type SUBController struct {
|
type SUBController struct {
|
||||||
subTitle string
|
subTitle string
|
||||||
@@ -48,6 +60,9 @@ type SUBController struct {
|
|||||||
subJsonService *SubJsonService
|
subJsonService *SubJsonService
|
||||||
subClashService *SubClashService
|
subClashService *SubClashService
|
||||||
settingService service.SettingService
|
settingService service.SettingService
|
||||||
|
|
||||||
|
subTemplateMu sync.RWMutex
|
||||||
|
subTemplateCache map[string]*cachedSubTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSUBController creates a new subscription controller with the given configuration.
|
// NewSUBController creates a new subscription controller with the given configuration.
|
||||||
@@ -93,6 +108,8 @@ func NewSUBController(
|
|||||||
subService: sub,
|
subService: sub,
|
||||||
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
subJsonService: NewSubJsonService(jsonMux, jsonRules, jsonFinalMask, sub),
|
||||||
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
subClashService: NewSubClashService(clashEnableRouting, clashRules, sub),
|
||||||
|
|
||||||
|
subTemplateCache: map[string]*cachedSubTemplate{},
|
||||||
}
|
}
|
||||||
a.initRouter(g)
|
a.initRouter(g)
|
||||||
return a
|
return a
|
||||||
@@ -202,25 +219,49 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
|||||||
}
|
}
|
||||||
|
|
||||||
subData := map[string]any{
|
subData := map[string]any{
|
||||||
"sId": page.SId,
|
"sId": page.SId,
|
||||||
"enabled": page.Enabled,
|
"enabled": page.Enabled,
|
||||||
"download": page.Download,
|
"download": page.Download,
|
||||||
"upload": page.Upload,
|
"upload": page.Upload,
|
||||||
"total": page.Total,
|
"total": page.Total,
|
||||||
"used": page.Used,
|
"used": page.Used,
|
||||||
"remained": page.Remained,
|
"remained": page.Remained,
|
||||||
"expire": page.Expire,
|
"expire": page.Expire,
|
||||||
"lastOnline": page.LastOnline,
|
"lastOnline": page.LastOnline,
|
||||||
"downloadByte": page.DownloadByte,
|
"downloadByte": page.DownloadByte,
|
||||||
"uploadByte": page.UploadByte,
|
"uploadByte": page.UploadByte,
|
||||||
"totalByte": page.TotalByte,
|
"totalByte": page.TotalByte,
|
||||||
"subUrl": page.SubUrl,
|
"subUrl": page.SubUrl,
|
||||||
"subJsonUrl": page.SubJsonUrl,
|
"subJsonUrl": page.SubJsonUrl,
|
||||||
"subClashUrl": page.SubClashUrl,
|
"subClashUrl": page.SubClashUrl,
|
||||||
"links": page.Result,
|
"subTitle": page.SubTitle,
|
||||||
"emails": page.Emails,
|
"subSupportUrl": page.SubSupportUrl,
|
||||||
"datepicker": datepicker,
|
"links": page.Result,
|
||||||
|
"emails": page.Emails,
|
||||||
|
"datepicker": datepicker,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When an admin has configured a custom subscription theme, render it
|
||||||
|
// instead of the default SPA. We render into a buffer first so a template
|
||||||
|
// that fails mid-execution can't leave a partially-written (corrupt)
|
||||||
|
// response — on any error we log and fall through to the default page.
|
||||||
|
if themeDir, _ := a.settingService.GetSubThemeDir(); themeDir != "" {
|
||||||
|
if tmpl, err := a.loadSubTemplate(themeDir); err != nil {
|
||||||
|
logger.Error("sub: custom template parse failed, using default page:", err)
|
||||||
|
} else if tmpl == nil {
|
||||||
|
logger.Warning("sub: subThemeDir set but no usable template found, using default page:", themeDir)
|
||||||
|
} else {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if execErr := tmpl.Execute(&buf, subData); execErr != nil {
|
||||||
|
logger.Error("sub: custom template execution failed, using default page:", execErr)
|
||||||
|
} else {
|
||||||
|
setNoCacheHeaders(c)
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", buf.Bytes())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
subDataJSON, err := json.Marshal(subData)
|
subDataJSON, err := json.Marshal(subData)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
subDataJSON = []byte("{}")
|
subDataJSON = []byte("{}")
|
||||||
@@ -243,10 +284,59 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
|
|||||||
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
`window.__SUB_PAGE_DATA__=` + string(subDataJSON) + `;</script></head>`)
|
||||||
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
out := bytes.Replace(body, []byte("</head>"), inject, 1)
|
||||||
|
|
||||||
|
setNoCacheHeaders(c)
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setNoCacheHeaders marks a subscription page response as non-cacheable so VPN
|
||||||
|
// clients and browsers always fetch fresh traffic/expiry data.
|
||||||
|
func setNoCacheHeaders(c *gin.Context) {
|
||||||
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
c.Header("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
c.Header("Pragma", "no-cache")
|
c.Header("Pragma", "no-cache")
|
||||||
c.Header("Expires", "0")
|
c.Header("Expires", "0")
|
||||||
c.Data(http.StatusOK, "text/html; charset=utf-8", out)
|
}
|
||||||
|
|
||||||
|
// loadSubTemplate returns the parsed custom subscription template located in
|
||||||
|
// themeDir, preferring sub.html over index.html. Parsed templates are cached and
|
||||||
|
// only re-parsed when the underlying file's modification time changes, so admin
|
||||||
|
// edits are picked up without paying a disk read + HTML parse on every request.
|
||||||
|
//
|
||||||
|
// It returns (nil, nil) when themeDir is not a usable directory or contains no
|
||||||
|
// template file — the caller should fall back to the default page. A non-nil
|
||||||
|
// error means a template file exists but failed to parse.
|
||||||
|
func (a *SUBController) loadSubTemplate(themeDir string) (*template.Template, error) {
|
||||||
|
info, err := os.Stat(themeDir)
|
||||||
|
if err != nil || !info.IsDir() {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
templatePath := filepath.Join(themeDir, "index.html")
|
||||||
|
if _, err := os.Stat(filepath.Join(themeDir, "sub.html")); err == nil {
|
||||||
|
templatePath = filepath.Join(themeDir, "sub.html")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(templatePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
modTime := fi.ModTime()
|
||||||
|
|
||||||
|
a.subTemplateMu.RLock()
|
||||||
|
cached := a.subTemplateCache[templatePath]
|
||||||
|
a.subTemplateMu.RUnlock()
|
||||||
|
if cached != nil && cached.modTime.Equal(modTime) {
|
||||||
|
return cached.tmpl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
tmpl, err := template.ParseFiles(templatePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.subTemplateMu.Lock()
|
||||||
|
a.subTemplateCache[templatePath] = &cachedSubTemplate{tmpl: tmpl, modTime: modTime}
|
||||||
|
a.subTemplateMu.Unlock()
|
||||||
|
return tmpl, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// subJsons handles HTTP requests for JSON subscription configurations.
|
// subJsons handles HTTP requests for JSON subscription configurations.
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package sub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestSUBController builds a controller with just the bits loadSubTemplate
|
||||||
|
// needs, so the template tests don't require a database.
|
||||||
|
func newTestSUBController() *SUBController {
|
||||||
|
return &SUBController{subTemplateCache: map[string]*cachedSubTemplate{}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFile(t *testing.T, path, content string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
t.Fatalf("write %s: %v", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderTemplate(t *testing.T, a *SUBController, dir string, data map[string]any) string {
|
||||||
|
t.Helper()
|
||||||
|
tmpl, err := a.loadSubTemplate(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadSubTemplate: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if tmpl == nil {
|
||||||
|
t.Fatal("loadSubTemplate: expected a template, got nil")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := tmpl.Execute(&buf, data); err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSubTemplate_RendersIndex(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId }}</h1>`)
|
||||||
|
|
||||||
|
got := renderTemplate(t, newTestSUBController(), dir, map[string]any{"sId": "abc-123"})
|
||||||
|
if want := `<h1>abc-123</h1>`; got != want {
|
||||||
|
t.Fatalf("rendered = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSubTemplate_PrefersSubHTML(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeFile(t, filepath.Join(dir, "index.html"), `from-index`)
|
||||||
|
writeFile(t, filepath.Join(dir, "sub.html"), `from-sub`)
|
||||||
|
|
||||||
|
got := renderTemplate(t, newTestSUBController(), dir, nil)
|
||||||
|
if got != "from-sub" {
|
||||||
|
t.Fatalf("rendered = %q, want %q (sub.html should take precedence)", got, "from-sub")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSubTemplate_FallbackCases(t *testing.T) {
|
||||||
|
a := newTestSUBController()
|
||||||
|
|
||||||
|
t.Run("missing dir", func(t *testing.T) {
|
||||||
|
tmpl, err := a.loadSubTemplate(filepath.Join(t.TempDir(), "does-not-exist"))
|
||||||
|
if tmpl != nil || err != nil {
|
||||||
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("path is a file not a dir", func(t *testing.T) {
|
||||||
|
file := filepath.Join(t.TempDir(), "index.html")
|
||||||
|
writeFile(t, file, `whatever`)
|
||||||
|
tmpl, err := a.loadSubTemplate(file)
|
||||||
|
if tmpl != nil || err != nil {
|
||||||
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dir without template file", func(t *testing.T) {
|
||||||
|
tmpl, err := a.loadSubTemplate(t.TempDir())
|
||||||
|
if tmpl != nil || err != nil {
|
||||||
|
t.Fatalf("got (%v, %v), want (nil, nil)", tmpl, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSubTemplate_MalformedTemplate(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// Unterminated action — html/template fails to parse this.
|
||||||
|
writeFile(t, filepath.Join(dir, "index.html"), `<h1>{{ .sId </h1>`)
|
||||||
|
|
||||||
|
tmpl, err := newTestSUBController().loadSubTemplate(dir)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected a parse error for a malformed template, got nil")
|
||||||
|
}
|
||||||
|
if tmpl != nil {
|
||||||
|
t.Fatalf("expected nil template on parse error, got %v", tmpl)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadSubTemplate_CacheHitAndInvalidation(t *testing.T) {
|
||||||
|
a := newTestSUBController()
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "index.html")
|
||||||
|
|
||||||
|
// v1 with a fixed mtime.
|
||||||
|
writeFile(t, path, `v1`)
|
||||||
|
t1 := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
if err := os.Chtimes(path, t1, t1); err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := a.loadSubTemplate(dir)
|
||||||
|
if err != nil || first == nil {
|
||||||
|
t.Fatalf("first load: (%v, %v)", first, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Same mtime → cache hit returns the identical parsed template.
|
||||||
|
second, err := a.loadSubTemplate(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second load: %v", err)
|
||||||
|
}
|
||||||
|
if second != first {
|
||||||
|
t.Fatal("expected cache hit to return the same *template.Template pointer")
|
||||||
|
}
|
||||||
|
|
||||||
|
// New content + newer mtime → cache invalidated, fresh content served.
|
||||||
|
writeFile(t, path, `v2`)
|
||||||
|
t2 := t1.Add(time.Hour)
|
||||||
|
if err := os.Chtimes(path, t2, t2); err != nil {
|
||||||
|
t.Fatalf("chtimes: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
third, err := a.loadSubTemplate(dir)
|
||||||
|
if err != nil || third == nil {
|
||||||
|
t.Fatalf("third load: (%v, %v)", third, err)
|
||||||
|
}
|
||||||
|
if third == first {
|
||||||
|
t.Fatal("expected cache invalidation to re-parse the template after mtime change")
|
||||||
|
}
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := third.Execute(&buf, nil); err != nil {
|
||||||
|
t.Fatalf("execute: %v", err)
|
||||||
|
}
|
||||||
|
if buf.String() != "v2" {
|
||||||
|
t.Fatalf("rendered = %q, want %q after edit", buf.String(), "v2")
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-3
@@ -381,8 +381,7 @@ func (s *SubService) GetLink(inbound *model.Inbound, email string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// genMtprotoLink builds a Telegram proxy deep link for an mtproto inbound:
|
// 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, _ string) string {
|
||||||
func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string {
|
|
||||||
if inbound.Protocol != model.MTProto {
|
if inbound.Protocol != model.MTProto {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -403,7 +402,7 @@ func (s *SubService) genMtprotoLink(inbound *model.Inbound, email string) string
|
|||||||
"port": fmt.Sprintf("%d", inbound.Port),
|
"port": fmt.Sprintf("%d", inbound.Port),
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
}
|
}
|
||||||
return buildLinkWithParams("tg://proxy", params, s.genRemark(inbound, email, ""))
|
return buildLinkWithParams("tg://proxy", params, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Protocol link generators are intentionally ordered as:
|
// Protocol link generators are intentionally ordered as:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 3x-ui Custom Subscription Templates
|
||||||
|
|
||||||
|
This directory allows you to use custom HTML templates for your users' subscription pages.
|
||||||
|
|
||||||
|
## How to use a Custom Template
|
||||||
|
|
||||||
|
1. Go to the 3x-ui panel settings.
|
||||||
|
2. Under **Settings → Subscription → Information**, locate the **Sub Theme Directory** field.
|
||||||
|
3. Provide the absolute path to the folder containing your template (e.g. `/etc/3x-ui/sub_templates/my-theme/`).
|
||||||
|
4. Save the settings.
|
||||||
|
|
||||||
|
> **Note:** 3x-ui does not ship any templates by default. Create your own template folder anywhere
|
||||||
|
> on the server, put an `index.html` (or `sub.html`) inside it, and point **Sub Theme Directory** at
|
||||||
|
> that absolute path. Leave the field empty to use the default built-in page.
|
||||||
|
|
||||||
|
## Creating a Template
|
||||||
|
|
||||||
|
A custom template must be an HTML file named `index.html` or `sub.html` located within the directory you specified in the settings.
|
||||||
|
The panel uses standard Go `html/template` to render the subscription page.
|
||||||
|
|
||||||
|
### Available Variables
|
||||||
|
|
||||||
|
When rendering the template, the following variables are injected into the template context (`{{ .variable }}`):
|
||||||
|
|
||||||
|
* `{{ .sId }}`: Subscription ID (UUID).
|
||||||
|
* `{{ .enabled }}`: Whether the subscription/client is enabled (boolean).
|
||||||
|
* `{{ .download }}`: Formatted download traffic (e.g. "2.5 GB").
|
||||||
|
* `{{ .upload }}`: Formatted upload traffic.
|
||||||
|
* `{{ .total }}`: Formatted total traffic limit.
|
||||||
|
* `{{ .used }}`: Formatted used traffic (download + upload).
|
||||||
|
* `{{ .remained }}`: Formatted remaining traffic.
|
||||||
|
* `{{ .expire }}`: Expiration time as an int64 Unix timestamp in **seconds** (`0` means never). Multiply by 1000 for a JavaScript `Date`.
|
||||||
|
* `{{ .lastOnline }}`: Last online time as an int64 Unix timestamp in **milliseconds** (`0` means never seen).
|
||||||
|
* `{{ .downloadByte }}`: Download traffic in exact bytes (int64).
|
||||||
|
* `{{ .uploadByte }}`: Upload traffic in exact bytes (int64).
|
||||||
|
* `{{ .totalByte }}`: Total traffic limit in exact bytes (int64).
|
||||||
|
* `{{ .subUrl }}`: The URL of the subscription page.
|
||||||
|
* `{{ .subJsonUrl }}`: The URL for the JSON configuration of the subscription.
|
||||||
|
* `{{ .subClashUrl }}`: The URL for the Clash/Mihomo configuration.
|
||||||
|
* `{{ .subTitle }}`: The subscription title configured in the panel (Subscription → Information). Useful for page branding/headings. May be empty.
|
||||||
|
* `{{ .subSupportUrl }}`: The support URL configured in the panel. Useful for a "Contact support" link. May be empty.
|
||||||
|
* `{{ .links }}`: A list (slice) of string configurations (VMess, VLESS, etc. URLs). You can loop through them using `{{ range .links }} ... {{ end }}`.
|
||||||
|
* `{{ .emails }}`: A list (slice) of emails related to the subscription.
|
||||||
|
* `{{ .datepicker }}`: Current calendar format used by the panel (e.g. "gregorian" or "jalali").
|
||||||
@@ -890,6 +890,11 @@ update_x-ui() {
|
|||||||
_fail "ERROR: x-ui systemd unit not installed."
|
_fail "ERROR: x-ui systemd unit not installed."
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
# Kill any leftover mtg (MTProto) sidecars. x-ui runs them outside its own
|
||||||
|
# lifecycle, so on Linux a stale one can survive the stop and keep holding
|
||||||
|
# an inbound port with an outdated secret, silently breaking new clients.
|
||||||
|
# The new panel respawns a clean mtg per inbound on next start.
|
||||||
|
pkill -f 'mtg-linux-[^ ]* run ' > /dev/null 2>&1 || true
|
||||||
echo -e "${green}Removing old x-ui version...${plain}"
|
echo -e "${green}Removing old x-ui version...${plain}"
|
||||||
rm ${xui_folder} -f > /dev/null 2>&1
|
rm ${xui_folder} -f > /dev/null 2>&1
|
||||||
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
|
rm ${xui_folder}/x-ui.service -f > /dev/null 2>&1
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/curve25519"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateWireguardKeypair generates a base64 encoded private and public key pair for Wireguard.
|
||||||
|
func GenerateWireguardKeypair() (privateKey string, publicKey string, err error) {
|
||||||
|
var priv [32]byte
|
||||||
|
if _, err := rand.Read(priv[:]); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
priv[0] &= 248
|
||||||
|
priv[31] &= 127
|
||||||
|
priv[31] |= 64
|
||||||
|
|
||||||
|
var pub [32]byte
|
||||||
|
curve25519.ScalarBaseMult(&pub, &priv)
|
||||||
|
|
||||||
|
return base64.StdEncoding.EncodeToString(priv[:]), base64.StdEncoding.EncodeToString(pub[:]), nil
|
||||||
|
}
|
||||||
@@ -350,8 +350,16 @@ func (a *InboundController) importInbound(c *gin.Context) {
|
|||||||
user := session.GetLoginUser(c)
|
user := session.GetLoginUser(c)
|
||||||
inbound.Id = 0
|
inbound.Id = 0
|
||||||
inbound.UserId = user.Id
|
inbound.UserId = user.Id
|
||||||
if inbound.NodeID != nil && *inbound.NodeID == 0 {
|
// Node IDs are panel-local and not portable across panels. Drop a node
|
||||||
inbound.NodeID = nil
|
// reference that is zero or that points to a node which doesn't exist on
|
||||||
|
// this panel, so a cross-panel export imports as a local inbound instead of
|
||||||
|
// failing with "record not found" when nodePushPlan looks the node up.
|
||||||
|
if inbound.NodeID != nil {
|
||||||
|
if *inbound.NodeID == 0 {
|
||||||
|
inbound.NodeID = nil
|
||||||
|
} else if exists, err := (&service.NodeService{}).NodeExists(*inbound.NodeID); err == nil && !exists {
|
||||||
|
inbound.NodeID = nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for index := range inbound.ClientStats {
|
for index := range inbound.ClientStats {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/database"
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
"github.com/mhsanaei/3x-ui/v3/logger"
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
"github.com/mhsanaei/3x-ui/v3/web/entity"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/global"
|
"github.com/mhsanaei/3x-ui/v3/web/global"
|
||||||
@@ -61,6 +62,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
g.GET("/getNewmldsa65", a.getNewmldsa65)
|
||||||
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
g.GET("/getNewmlkem768", a.getNewmlkem768)
|
||||||
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
g.GET("/getNewVlessEnc", a.getNewVlessEnc)
|
||||||
|
g.GET("/clientIps", a.getClientIps)
|
||||||
|
|
||||||
g.POST("/stopXrayService", a.stopXrayService)
|
g.POST("/stopXrayService", a.stopXrayService)
|
||||||
g.POST("/restartXrayService", a.restartXrayService)
|
g.POST("/restartXrayService", a.restartXrayService)
|
||||||
@@ -72,6 +74,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
|
|||||||
g.POST("/xraylogs/:count", a.getXrayLogs)
|
g.POST("/xraylogs/:count", a.getXrayLogs)
|
||||||
g.POST("/importDB", a.importDB)
|
g.POST("/importDB", a.importDB)
|
||||||
g.POST("/getNewEchCert", a.getNewEchCert)
|
g.POST("/getNewEchCert", a.getNewEchCert)
|
||||||
|
g.POST("/clientIps", a.setClientIps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTask registers the @2s ticker that refreshes server status, samples
|
// startTask registers the @2s ticker that refreshes server status, samples
|
||||||
@@ -420,3 +423,18 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
jsonObj(c, out, nil)
|
jsonObj(c, out, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *ServerController) getClientIps(c *gin.Context) {
|
||||||
|
ips, err := (&service.InboundService{}).GetAllInboundClientIps()
|
||||||
|
jsonObj(c, ips, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *ServerController) setClientIps(c *gin.Context) {
|
||||||
|
var ips []model.InboundClientIps
|
||||||
|
if err := c.ShouldBindJSON(&ips); err != nil {
|
||||||
|
jsonMsg(c, "invalid data", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
err := (&service.InboundService{}).MergeInboundClientIps(ips)
|
||||||
|
jsonMsg(c, "Client IPs merged", err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
"github.com/mhsanaei/3x-ui/v3/web/service"
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
@@ -12,13 +14,13 @@ import (
|
|||||||
|
|
||||||
// XraySettingController handles Xray configuration and settings operations.
|
// XraySettingController handles Xray configuration and settings operations.
|
||||||
type XraySettingController struct {
|
type XraySettingController struct {
|
||||||
XraySettingService service.XraySettingService
|
XraySettingService service.XraySettingService
|
||||||
SettingService service.SettingService
|
SettingService service.SettingService
|
||||||
InboundService service.InboundService
|
InboundService service.InboundService
|
||||||
OutboundService service.OutboundService
|
OutboundService service.OutboundService
|
||||||
XrayService service.XrayService
|
XrayService service.XrayService
|
||||||
WarpService service.WarpService
|
WarpService service.WarpService
|
||||||
NordService service.NordService
|
NordService service.NordService
|
||||||
OutboundSubscriptionService service.OutboundSubscriptionService
|
OutboundSubscriptionService service.OutboundSubscriptionService
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,9 +167,26 @@ func (a *XraySettingController) warp(c *gin.Context) {
|
|||||||
skey := c.PostForm("privateKey")
|
skey := c.PostForm("privateKey")
|
||||||
pkey := c.PostForm("publicKey")
|
pkey := c.PostForm("publicKey")
|
||||||
resp, err = a.WarpService.RegWarp(skey, pkey)
|
resp, err = a.WarpService.RegWarp(skey, pkey)
|
||||||
|
case "changeIp":
|
||||||
|
resp, err = a.WarpService.ChangeWarpIP()
|
||||||
|
if err == nil {
|
||||||
|
a.XrayService.SetToNeedRestart()
|
||||||
|
// Restart the auto-update clock so a scheduled rotation
|
||||||
|
// doesn't fire right after this manual one.
|
||||||
|
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
||||||
|
}
|
||||||
case "license":
|
case "license":
|
||||||
license := c.PostForm("license")
|
license := c.PostForm("license")
|
||||||
resp, err = a.WarpService.SetWarpLicense(license)
|
resp, err = a.WarpService.SetWarpLicense(license)
|
||||||
|
case "interval":
|
||||||
|
interval, convErr := strconv.Atoi(c.PostForm("interval"))
|
||||||
|
if convErr != nil || interval < 0 {
|
||||||
|
err = common.NewError("invalid warp update interval")
|
||||||
|
} else if err = a.SettingService.SetWarpUpdateInterval(interval); err == nil && interval > 0 {
|
||||||
|
// Count the interval from now rather than from epoch 0,
|
||||||
|
// otherwise the job would rotate on its next tick.
|
||||||
|
_ = a.SettingService.SetWarpLastUpdate(time.Now().Unix())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonObj(c, resp, err)
|
jsonObj(c, resp, err)
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ type AllSetting struct {
|
|||||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||||
|
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template
|
||||||
|
|
||||||
// LDAP settings
|
// LDAP settings
|
||||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||||
@@ -112,6 +113,9 @@ type AllSetting struct {
|
|||||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
||||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
||||||
// JSON subscription routing rules
|
// JSON subscription routing rules
|
||||||
|
|
||||||
|
// WARP
|
||||||
|
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AllSettingView is the browser-safe settings read model. Secret values
|
// AllSettingView is the browser-safe settings read model. Secret values
|
||||||
|
|||||||
@@ -242,9 +242,13 @@ func mergeClientIps(old, new []IPWithTimestamp, staleCutoff int64) map[string]in
|
|||||||
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
func partitionLiveIps(ipMap map[string]int64, observedThisScan map[string]bool) (live, historical []IPWithTimestamp) {
|
||||||
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
live = make([]IPWithTimestamp, 0, len(observedThisScan))
|
||||||
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
historical = make([]IPWithTimestamp, 0, len(ipMap))
|
||||||
|
now := time.Now().Unix()
|
||||||
for ip, ts := range ipMap {
|
for ip, ts := range ipMap {
|
||||||
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
entry := IPWithTimestamp{IP: ip, Timestamp: ts}
|
||||||
if observedThisScan[ip] {
|
// Consider an IP "live" if it was seen locally in this scan, OR if its
|
||||||
|
// timestamp from the synced database is very recent (e.g. within 2 minutes).
|
||||||
|
// This ensures cluster-wide limits work even if the IP was seen on another node.
|
||||||
|
if observedThisScan[ip] || now-ts < 120 {
|
||||||
live = append(live, entry)
|
live = append(live, entry)
|
||||||
} else {
|
} else {
|
||||||
historical = append(historical, entry)
|
historical = append(historical, entry)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"reflect"
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
func TestMergeClientIps_EvictsStaleOldEntries(t *testing.T) {
|
||||||
@@ -149,6 +150,26 @@ func TestPartitionLiveIps_EmptyScanLeavesDbIntact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPartitionLiveIps_RecentSyncedIpIsLive(t *testing.T) {
|
||||||
|
// Synced IPs from other nodes within 2 minutes should be counted as live
|
||||||
|
// even if they weren't observed in the local scan.
|
||||||
|
now := time.Now().Unix()
|
||||||
|
ipMap := map[string]int64{
|
||||||
|
"A": now - 30, // synced 30s ago -> live
|
||||||
|
"B": now - 150, // synced 2m30s ago -> historical
|
||||||
|
}
|
||||||
|
observed := map[string]bool{}
|
||||||
|
|
||||||
|
live, historical := partitionLiveIps(ipMap, observed)
|
||||||
|
|
||||||
|
if got := collectIps(live); !reflect.DeepEqual(got, []string{"A"}) {
|
||||||
|
t.Fatalf("recent IP should be live\ngot: %v\nwant: [A]", got)
|
||||||
|
}
|
||||||
|
if got := collectIps(historical); !reflect.DeepEqual(got, []string{"B"}) {
|
||||||
|
t.Fatalf("older IP should be historical\ngot: %v\nwant: [B]", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
|
func TestCheckFail2BanInstalled_DisabledEnvSkipsClientProbe(t *testing.T) {
|
||||||
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
t.Setenv("XUI_ENABLE_FAIL2BAN", "false")
|
||||||
marker := fakeFail2BanClient(t)
|
marker := fakeFail2BanClient(t)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const (
|
|||||||
nodeTrafficSyncConcurrency = 8
|
nodeTrafficSyncConcurrency = 8
|
||||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||||
nodeReconcileTimeout = 30 * time.Second
|
nodeReconcileTimeout = 30 * time.Second
|
||||||
|
nodeClientIpSyncInterval = 10 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
@@ -25,6 +26,8 @@ type NodeTrafficSyncJob struct {
|
|||||||
xrayService service.XrayService
|
xrayService service.XrayService
|
||||||
running sync.Mutex
|
running sync.Mutex
|
||||||
structural atomicBool
|
structural atomicBool
|
||||||
|
ipSyncMu sync.Mutex
|
||||||
|
lastIpSync int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type atomicBool struct {
|
type atomicBool struct {
|
||||||
@@ -70,6 +73,16 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decide once per tick whether this run also syncs client IPs, and stamp the
|
||||||
|
// clock before the loop so two back-to-back 5s ticks can't both qualify.
|
||||||
|
doIpSync := false
|
||||||
|
j.ipSyncMu.Lock()
|
||||||
|
if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) {
|
||||||
|
doIpSync = true
|
||||||
|
j.lastIpSync = now
|
||||||
|
}
|
||||||
|
j.ipSyncMu.Unlock()
|
||||||
|
|
||||||
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
sem := make(chan struct{}, nodeTrafficSyncConcurrency)
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
@@ -81,7 +94,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
go func(n *model.Node) {
|
go func(n *model.Node) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
defer func() { <-sem }()
|
defer func() { <-sem }()
|
||||||
j.syncOne(mgr, n)
|
j.syncOne(mgr, n, doIpSync)
|
||||||
}(n)
|
}(n)
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -151,7 +164,7 @@ func (j *NodeTrafficSyncJob) Run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) {
|
||||||
rt, err := mgr.RemoteFor(n)
|
rt, err := mgr.RemoteFor(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||||
@@ -190,4 +203,28 @@ func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
|||||||
if changed {
|
if changed {
|
||||||
j.structural.set()
|
j.structural.set()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !doIpSync {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeIps, err := rt.FetchAllClientIps(ctx)
|
||||||
|
if err == nil && len(nodeIps) > 0 {
|
||||||
|
if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil {
|
||||||
|
logger.Warning("node traffic sync: merge client ips from", n.Name, "failed:", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
logger.Warning("node traffic sync: fetch client ips from", n.Name, "failed:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
masterIps, err := j.inboundService.GetAllInboundClientIps()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("node traffic sync: load client ips for push to", n.Name, "failed:", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(masterIps) > 0 {
|
||||||
|
if err := rt.PushAllClientIps(ctx, masterIps); err != nil {
|
||||||
|
logger.Warning("node traffic sync: push client ips to", n.Name, "failed:", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/web/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WarpIpJob struct {
|
||||||
|
settingService service.SettingService
|
||||||
|
warpService service.WarpService
|
||||||
|
xrayService service.XrayService
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewWarpIpJob() *WarpIpJob {
|
||||||
|
return &WarpIpJob{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *WarpIpJob) Run() {
|
||||||
|
allSetting, err := j.settingService.GetAllSetting()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
interval := allSetting.WarpUpdateInterval
|
||||||
|
if interval <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lastUpdate, _ := j.settingService.GetWarpLastUpdate()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// First run after the feature is enabled (e.g. interval set via direct
|
||||||
|
// DB edit): establish a baseline instead of rotating immediately.
|
||||||
|
if lastUpdate == 0 {
|
||||||
|
_ = j.settingService.SetWarpLastUpdate(now)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if now-lastUpdate >= int64(interval*24*3600) {
|
||||||
|
logger.Info("Starting scheduled WARP IP update...")
|
||||||
|
_, err := j.warpService.ChangeWarpIP()
|
||||||
|
if err != nil {
|
||||||
|
logger.Warning("Failed to update WARP IP: ", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = j.settingService.SetWarpLastUpdate(now)
|
||||||
|
j.xrayService.SetToNeedRestart()
|
||||||
|
logger.Info("Successfully updated WARP IP and scheduled Xray restart")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,3 +159,7 @@ func (l *Local) ResetClientTraffic(_ context.Context, _ *model.Inbound, _ string
|
|||||||
func (l *Local) ResetAllTraffics(_ context.Context) error {
|
func (l *Local) ResetAllTraffics(_ context.Context) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *Local) ResetInboundTraffic(_ context.Context, _ *model.Inbound) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -399,6 +399,11 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error {
|
||||||
|
_, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
type TrafficSnapshot struct {
|
type TrafficSnapshot struct {
|
||||||
Inbounds []*model.Inbound
|
Inbounds []*model.Inbound
|
||||||
OnlineEmails []string
|
OnlineEmails []string
|
||||||
@@ -533,3 +538,22 @@ func isNonEmptySlice(v any) bool {
|
|||||||
s, ok := v.([]any)
|
s, ok := v.([]any)
|
||||||
return ok && len(s) > 0
|
return ok && len(s) > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) {
|
||||||
|
env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var ips []model.InboundClientIps
|
||||||
|
if len(env.Obj) > 0 {
|
||||||
|
if err := json.Unmarshal(env.Obj, &ips); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode client ips: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ips, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error {
|
||||||
|
_, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|||||||
@@ -26,5 +26,6 @@ type Runtime interface {
|
|||||||
RestartXray(ctx context.Context) error
|
RestartXray(ctx context.Context) error
|
||||||
|
|
||||||
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
|
ResetClientTraffic(ctx context.Context, ib *model.Inbound, email string) error
|
||||||
|
ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error
|
||||||
ResetAllTraffics(ctx context.Context) error
|
ResetAllTraffics(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-9
@@ -1714,15 +1714,19 @@ func (s *ClientService) ListPaged(inboundSvc *InboundService, settingSvc *Settin
|
|||||||
type GroupSummary struct {
|
type GroupSummary struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
ClientCount int `json:"clientCount"`
|
ClientCount int `json:"clientCount"`
|
||||||
|
TrafficUsed int64 `json:"trafficUsed"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
// email is unique in both clients and client_traffics, so the LEFT JOIN
|
||||||
|
// never double-counts a client's traffic.
|
||||||
var derived []GroupSummary
|
var derived []GroupSummary
|
||||||
if err := db.Model(&model.ClientRecord{}).
|
if err := db.Table("clients AS c").
|
||||||
Select("group_name AS name, COUNT(*) AS client_count").
|
Select("c.group_name AS name, COUNT(*) AS client_count, COALESCE(SUM(ct.up + ct.down), 0) AS traffic_used").
|
||||||
Where("group_name <> ''").
|
Joins("LEFT JOIN client_traffics ct ON ct.email = c.email").
|
||||||
Group("group_name").
|
Where("c.group_name <> ''").
|
||||||
|
Group("c.group_name").
|
||||||
Scan(&derived).Error; err != nil {
|
Scan(&derived).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -1730,16 +1734,20 @@ func (s *ClientService) ListGroups() ([]GroupSummary, error) {
|
|||||||
if err := db.Find(&stored).Error; err != nil {
|
if err := db.Find(&stored).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
merged := make(map[string]int, len(derived)+len(stored))
|
type groupAgg struct {
|
||||||
|
count int
|
||||||
|
traffic int64
|
||||||
|
}
|
||||||
|
merged := make(map[string]groupAgg, len(derived)+len(stored))
|
||||||
for _, g := range stored {
|
for _, g := range stored {
|
||||||
merged[g.Name] = 0
|
merged[g.Name] = groupAgg{}
|
||||||
}
|
}
|
||||||
for _, g := range derived {
|
for _, g := range derived {
|
||||||
merged[g.Name] = g.ClientCount
|
merged[g.Name] = groupAgg{count: g.ClientCount, traffic: g.TrafficUsed}
|
||||||
}
|
}
|
||||||
out := make([]GroupSummary, 0, len(merged))
|
out := make([]GroupSummary, 0, len(merged))
|
||||||
for name, count := range merged {
|
for name, agg := range merged {
|
||||||
out = append(out, GroupSummary{Name: name, ClientCount: count})
|
out = append(out, GroupSummary{Name: name, ClientCount: agg.count, TrafficUsed: agg.traffic})
|
||||||
}
|
}
|
||||||
sort.Slice(out, func(i, j int) bool {
|
sort.Slice(out, func(i, j int) bool {
|
||||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||||
|
|||||||
+198
-39
@@ -399,6 +399,137 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) {
|
||||||
|
db := database.GetDB()
|
||||||
|
var ips []model.InboundClientIps
|
||||||
|
err := db.Model(&model.InboundClientIps{}).Find(&ips).Error
|
||||||
|
return ips, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than
|
||||||
|
// 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps
|
||||||
|
// the synced blob bounded and stops the master's push-back from resurrecting IPs that
|
||||||
|
// a node has already pruned (otherwise the merge defeats the eviction cluster-wide).
|
||||||
|
const clientIpStaleAfterSeconds = int64(30 * 60)
|
||||||
|
|
||||||
|
// clientIpEntry is the on-disk shape of each element of InboundClientIps.Ips. Tags
|
||||||
|
// match job.IPWithTimestamp so the blob round-trips with the access.log scanner.
|
||||||
|
type clientIpEntry struct {
|
||||||
|
IP string `json:"ip"`
|
||||||
|
Timestamp int64 `json:"timestamp"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// mergeClientIpEntries unions old and incoming IP observations, dropping anything
|
||||||
|
// older than cutoff, keeping the most recent timestamp per IP, and returning the
|
||||||
|
// result sorted newest-first.
|
||||||
|
func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientIpEntry {
|
||||||
|
ipMap := make(map[string]int64, len(old)+len(incoming))
|
||||||
|
for _, e := range old {
|
||||||
|
if e.Timestamp < cutoff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ipMap[e.IP] = e.Timestamp
|
||||||
|
}
|
||||||
|
for _, e := range incoming {
|
||||||
|
if e.Timestamp < cutoff {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if cur, ok := ipMap[e.IP]; !ok || e.Timestamp > cur {
|
||||||
|
ipMap[e.IP] = e.Timestamp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]clientIpEntry, 0, len(ipMap))
|
||||||
|
for ip, ts := range ipMap {
|
||||||
|
out = append(out, clientIpEntry{IP: ip, Timestamp: ts})
|
||||||
|
}
|
||||||
|
sort.Slice(out, func(i, j int) bool { return out[i].Timestamp > out[j].Timestamp })
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeInboundClientIps folds client IPs synced from another node into the local
|
||||||
|
// inbound_client_ips table without double-counting an IP seen on multiple nodes and
|
||||||
|
// without resurrecting stale entries. Existing rows are updated in place; brand-new
|
||||||
|
// clients (typically node-only clients with no local row) are created with a fresh
|
||||||
|
// local id.
|
||||||
|
func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error {
|
||||||
|
db := database.GetDB()
|
||||||
|
var currentIps []model.InboundClientIps
|
||||||
|
if err := db.Model(&model.InboundClientIps{}).Find(¤tIps).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
currentMap := make(map[string]*model.InboundClientIps, len(currentIps))
|
||||||
|
for i := range currentIps {
|
||||||
|
currentMap[currentIps[i].ClientEmail] = ¤tIps[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
cutoff := now - clientIpStaleAfterSeconds
|
||||||
|
|
||||||
|
tx := db.Begin()
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
for _, incoming := range incomingIps {
|
||||||
|
if incoming.ClientEmail == "" || incoming.Ips == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var incomingEntries []clientIpEntry
|
||||||
|
_ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries)
|
||||||
|
|
||||||
|
current, exists := currentMap[incoming.ClientEmail]
|
||||||
|
if !exists {
|
||||||
|
// New client we've never seen locally. Drop stale entries up front and
|
||||||
|
// skip the row entirely if nothing is fresh, so we don't persist a row
|
||||||
|
// that is dead on arrival.
|
||||||
|
fresh := mergeClientIpEntries(nil, incomingEntries, cutoff)
|
||||||
|
if len(fresh) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(fresh)
|
||||||
|
incoming.Ips = string(b)
|
||||||
|
// Never carry the remote node's primary key into the local table: id
|
||||||
|
// spaces are independent across nodes and the remote id would collide
|
||||||
|
// with an unrelated local row. OnConflict guards the race where
|
||||||
|
// check_client_ip_job creates the same brand-new email between the
|
||||||
|
// snapshot above and this insert.
|
||||||
|
incoming.Id = 0
|
||||||
|
if err := tx.Clauses(clause.OnConflict{
|
||||||
|
Columns: []clause.Column{{Name: "client_email"}},
|
||||||
|
DoNothing: true,
|
||||||
|
}).Create(&incoming).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var oldEntries []clientIpEntry
|
||||||
|
if current.Ips != "" {
|
||||||
|
_ = json.Unmarshal([]byte(current.Ips), &oldEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff)
|
||||||
|
b, _ := json.Marshal(merged)
|
||||||
|
mergedStr := string(b)
|
||||||
|
|
||||||
|
// A concurrent check_client_ip_job db.Save on the same row can interleave
|
||||||
|
// with this update (benign last-writer-wins; any dropped IP reappears on the
|
||||||
|
// next scan/sync), so only write when the blob actually changed.
|
||||||
|
if current.Ips != mergedStr {
|
||||||
|
if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit().Error
|
||||||
|
}
|
||||||
|
|
||||||
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
|
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
|
||||||
// the client UI can generate a valid PSK (base64 of the method's key length)
|
// the client UI can generate a valid PSK (base64 of the method's key length)
|
||||||
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
|
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
|
||||||
@@ -1665,23 +1796,24 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
newIb := model.Inbound{
|
newIb := model.Inbound{
|
||||||
UserId: defaultUserId,
|
UserId: defaultUserId,
|
||||||
NodeID: &nodeID,
|
NodeID: &nodeID,
|
||||||
OriginNodeGuid: originGuidFor(snapIb),
|
OriginNodeGuid: originGuidFor(snapIb),
|
||||||
Tag: chosenTag,
|
Tag: chosenTag,
|
||||||
Listen: snapIb.Listen,
|
Listen: snapIb.Listen,
|
||||||
Port: snapIb.Port,
|
Port: snapIb.Port,
|
||||||
Protocol: snapIb.Protocol,
|
Protocol: snapIb.Protocol,
|
||||||
Settings: snapIb.Settings,
|
Settings: snapIb.Settings,
|
||||||
StreamSettings: snapIb.StreamSettings,
|
StreamSettings: snapIb.StreamSettings,
|
||||||
Sniffing: snapIb.Sniffing,
|
Sniffing: snapIb.Sniffing,
|
||||||
TrafficReset: snapIb.TrafficReset,
|
TrafficReset: snapIb.TrafficReset,
|
||||||
Enable: snapIb.Enable,
|
LastTrafficResetTime: snapIb.LastTrafficResetTime,
|
||||||
Remark: snapIb.Remark,
|
Enable: snapIb.Enable,
|
||||||
Total: snapIb.Total,
|
Remark: snapIb.Remark,
|
||||||
ExpiryTime: snapIb.ExpiryTime,
|
Total: snapIb.Total,
|
||||||
Up: snapIb.Up,
|
ExpiryTime: snapIb.ExpiryTime,
|
||||||
Down: snapIb.Down,
|
Up: snapIb.Up,
|
||||||
|
Down: snapIb.Down,
|
||||||
}
|
}
|
||||||
if err := tx.Create(&newIb).Error; err != nil {
|
if err := tx.Create(&newIb).Error; err != nil {
|
||||||
logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
|
logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err)
|
||||||
@@ -1710,6 +1842,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
updates["stream_settings"] = snapIb.StreamSettings
|
updates["stream_settings"] = snapIb.StreamSettings
|
||||||
updates["sniffing"] = snapIb.Sniffing
|
updates["sniffing"] = snapIb.Sniffing
|
||||||
updates["traffic_reset"] = snapIb.TrafficReset
|
updates["traffic_reset"] = snapIb.TrafficReset
|
||||||
|
updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime
|
||||||
}
|
}
|
||||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||||
updates["up"] = snapIb.Up
|
updates["up"] = snapIb.Up
|
||||||
@@ -1755,9 +1888,13 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if len(goneEmails) > 0 {
|
if len(goneEmails) > 0 {
|
||||||
if err := tx.Where("node_id = ? AND email IN ?", nodeID, goneEmails).
|
// Chunk to avoid SQLite bind var limit when a node has many clients
|
||||||
Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
// removed (e.g. after API bulk delete or structural change on node inbound).
|
||||||
return false, err
|
for _, batch := range chunkStrings(goneEmails, sqliteMaxVars) {
|
||||||
|
if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch).
|
||||||
|
Delete(&model.NodeClientTraffic{}).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := tx.Where("inbound_id = ?", c.Id).
|
if err := tx.Where("inbound_id = ?", c.Id).
|
||||||
@@ -1836,18 +1973,6 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
|||||||
structuralChange = true
|
structuralChange = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only allow the node to disable a client (cs.Enable=false), never
|
|
||||||
// to re-enable one the panel has already disabled. A stale snapshot
|
|
||||||
// from the node arriving after a central disable would otherwise
|
|
||||||
// overwrite enable=false back to true, letting the client accumulate
|
|
||||||
// far more traffic than their limit before being disabled again.
|
|
||||||
//
|
|
||||||
// We use a dialect-aware expression (see database.ClientTrafficEnableMergeExpr)
|
|
||||||
// because the old "enable AND ?" form (and naive CASE with :: casts)
|
|
||||||
// caused type mismatches on PostgreSQL after public API inbound updates
|
|
||||||
// (which go through updateClientTraffics + SyncInbound and can touch
|
|
||||||
// client_traffics rows) and would also break on SQLite due to PG-only
|
|
||||||
// ::boolean syntax.
|
|
||||||
enableExpr := database.ClientTrafficEnableMergeExpr()
|
enableExpr := database.ClientTrafficEnableMergeExpr()
|
||||||
if err := tx.Exec(
|
if err := tx.Exec(
|
||||||
fmt.Sprintf(
|
fmt.Sprintf(
|
||||||
@@ -3017,15 +3142,41 @@ func (s *InboundService) resetAllTrafficsLocked() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
nodes, err := (&NodeService{}).GetAll()
|
||||||
|
if err == nil {
|
||||||
|
for _, node := range nodes {
|
||||||
|
if rt, err := runtime.GetManager().RuntimeFor(&node.Id); err == nil {
|
||||||
|
if e := rt.ResetAllTraffics(context.Background()); e != nil {
|
||||||
|
logger.Warning("ResetAllTraffics: remote propagation to", rt.Name(), "failed:", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) ResetInboundTraffic(id int) error {
|
func (s *InboundService) ResetInboundTraffic(id int) error {
|
||||||
return submitTrafficWrite(func() error {
|
return submitTrafficWrite(func() error {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
return db.Model(model.Inbound{}).
|
if err := db.Model(model.Inbound{}).
|
||||||
Where("id = ?", id).
|
Where("id = ?", id).
|
||||||
Updates(map[string]any{"up": 0, "down": 0}).Error
|
Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
inbound, err := s.GetInbound(id)
|
||||||
|
if err == nil && inbound != nil && inbound.NodeID != nil {
|
||||||
|
if rt, rterr := s.runtimeFor(inbound); rterr == nil {
|
||||||
|
if e := rt.ResetInboundTraffic(context.Background(), inbound); e != nil {
|
||||||
|
logger.Warning("ResetInboundTraffic: remote propagation to", rt.Name(), "failed:", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Warning("ResetInboundTraffic: runtime lookup failed:", rterr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3697,7 +3848,7 @@ func (s *InboundService) MigrationRequirements() {
|
|||||||
var externalProxy []struct {
|
var externalProxy []struct {
|
||||||
Id int
|
Id int
|
||||||
Port int
|
Port int
|
||||||
StreamSettings []byte
|
StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
|
||||||
}
|
}
|
||||||
externalProxyQuery := `select id, port, stream_settings
|
externalProxyQuery := `select id, port, stream_settings
|
||||||
from inbounds
|
from inbounds
|
||||||
@@ -3719,7 +3870,7 @@ func (s *InboundService) MigrationRequirements() {
|
|||||||
for _, ep := range externalProxy {
|
for _, ep := range externalProxy {
|
||||||
var reverses any
|
var reverses any
|
||||||
var stream map[string]any
|
var stream map[string]any
|
||||||
json.Unmarshal(ep.StreamSettings, &stream)
|
json.Unmarshal([]byte(ep.StreamSettings), &stream)
|
||||||
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
|
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
|
||||||
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
|
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
|
||||||
if domains, ok := settings["domains"].([]any); ok {
|
if domains, ok := settings["domains"].([]any); ok {
|
||||||
@@ -3741,9 +3892,17 @@ func (s *InboundService) MigrationRequirements() {
|
|||||||
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
|
tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Raw(`UPDATE inbounds
|
// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
|
||||||
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
// Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
|
||||||
WHERE INSTR(tag, '0.0.0.0:') > 0;`).Error
|
tagCleanup := `UPDATE inbounds
|
||||||
|
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
||||||
|
WHERE INSTR(tag, '0.0.0.0:') > 0;`
|
||||||
|
if database.IsPostgres() {
|
||||||
|
tagCleanup = `UPDATE inbounds
|
||||||
|
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
||||||
|
WHERE position('0.0.0.0:' in tag) > 0;`
|
||||||
|
}
|
||||||
|
err = tx.Raw(tagCleanup).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders)
|
||||||
|
// for a single test, mirroring the harness used by the other service tests.
|
||||||
|
func setupClientIpTestDB(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
dbDir := t.TempDir()
|
||||||
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
||||||
|
if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
|
||||||
|
t.Fatalf("InitDB: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = database.CloseDB() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalIps(t *testing.T, entries ...clientIpEntry) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := json.Marshal(entries)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("marshal ips: %v", err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readClientIps returns the stored IP entries for an email as a map[ip]timestamp,
|
||||||
|
// plus whether the row exists at all.
|
||||||
|
func readClientIps(t *testing.T, email string) (map[string]int64, bool) {
|
||||||
|
t.Helper()
|
||||||
|
var row model.InboundClientIps
|
||||||
|
err := database.GetDB().Where("client_email = ?", email).First(&row).Error
|
||||||
|
if database.IsNotFound(err) {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read client ips for %s: %v", email, err)
|
||||||
|
}
|
||||||
|
var entries []clientIpEntry
|
||||||
|
if row.Ips != "" {
|
||||||
|
if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil {
|
||||||
|
t.Fatalf("unmarshal stored ips for %s: %v", email, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make(map[string]int64, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
out[e.IP] = e.Timestamp
|
||||||
|
}
|
||||||
|
return out, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
// Local client occupies id 1.
|
||||||
|
local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}
|
||||||
|
if err := db.Create(local).Error; err != nil {
|
||||||
|
t.Fatalf("seed local row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incoming node-only client carries the remote node's id 1, which must not
|
||||||
|
// collide with the local row.
|
||||||
|
incoming := []model.InboundClientIps{{
|
||||||
|
Id: 1,
|
||||||
|
ClientEmail: "node@x",
|
||||||
|
Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}),
|
||||||
|
}}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local row is untouched.
|
||||||
|
if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now {
|
||||||
|
t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Node row exists with its own ip and a freshly assigned id (not the remote 1).
|
||||||
|
var nodeRow model.InboundClientIps
|
||||||
|
if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil {
|
||||||
|
t.Fatalf("node@x not created: %v", err)
|
||||||
|
}
|
||||||
|
if nodeRow.Id == local.Id {
|
||||||
|
t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id)
|
||||||
|
}
|
||||||
|
if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now {
|
||||||
|
t.Fatalf("node@x missing expected ip: %v", ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if err := db.Create(&model.InboundClientIps{
|
||||||
|
ClientEmail: "a@x",
|
||||||
|
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming := []model.InboundClientIps{{
|
||||||
|
ClientEmail: "a@x",
|
||||||
|
Ips: marshalIps(t,
|
||||||
|
clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins
|
||||||
|
clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, _ := readClientIps(t, "a@x")
|
||||||
|
if len(ips) != 2 {
|
||||||
|
t.Fatalf("want 2 ips, got %v", ips)
|
||||||
|
}
|
||||||
|
if ips["1.1.1.1"] != now-50 {
|
||||||
|
t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"])
|
||||||
|
}
|
||||||
|
if ips["2.2.2.2"] != now-10 {
|
||||||
|
t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
if err := db.Create(&model.InboundClientIps{
|
||||||
|
ClientEmail: "a@x",
|
||||||
|
Ips: marshalIps(t,
|
||||||
|
clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale
|
||||||
|
clientIpEntry{IP: "fresh", Timestamp: now - 60},
|
||||||
|
),
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("seed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
incoming := []model.InboundClientIps{{
|
||||||
|
ClientEmail: "a@x",
|
||||||
|
Ips: marshalIps(t,
|
||||||
|
clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale
|
||||||
|
clientIpEntry{IP: "incFresh", Timestamp: now - 10},
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ips, _ := readClientIps(t, "a@x")
|
||||||
|
if len(ips) != 2 {
|
||||||
|
t.Fatalf("want only fresh ips, got %v", ips)
|
||||||
|
}
|
||||||
|
if _, ok := ips["old"]; ok {
|
||||||
|
t.Fatalf("stale local ip not dropped: %v", ips)
|
||||||
|
}
|
||||||
|
if _, ok := ips["incStale"]; ok {
|
||||||
|
t.Fatalf("stale incoming ip not dropped: %v", ips)
|
||||||
|
}
|
||||||
|
if ips["fresh"] != now-60 || ips["incFresh"] != now-10 {
|
||||||
|
t.Fatalf("fresh ips wrong: %v", ips)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
incoming := []model.InboundClientIps{{
|
||||||
|
ClientEmail: "b@x",
|
||||||
|
Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}),
|
||||||
|
}}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := readClientIps(t, "b@x"); ok {
|
||||||
|
t.Fatalf("all-stale node-only client should not create a row")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) {
|
||||||
|
setupClientIpTestDB(t)
|
||||||
|
now := time.Now().Unix()
|
||||||
|
|
||||||
|
incoming := []model.InboundClientIps{
|
||||||
|
{ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})},
|
||||||
|
{ClientEmail: "c@x", Ips: ""},
|
||||||
|
}
|
||||||
|
if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil {
|
||||||
|
t.Fatalf("merge: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil {
|
||||||
|
t.Fatalf("count: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Fatalf("blank rows should be skipped, but %d row(s) created", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
-15
@@ -35,6 +35,11 @@ type HeartbeatPatch struct {
|
|||||||
MemPct float64
|
MemPct float64
|
||||||
UptimeSecs uint64
|
UptimeSecs uint64
|
||||||
LastError string
|
LastError string
|
||||||
|
// XrayState and XrayError come from the remote /panel/api/server/status when the
|
||||||
|
// panel API is reachable. They allow distinguishing panel connectivity from
|
||||||
|
// Xray core health on the node.
|
||||||
|
XrayState string
|
||||||
|
XrayError string
|
||||||
}
|
}
|
||||||
|
|
||||||
type NodeService struct{}
|
type NodeService struct{}
|
||||||
@@ -221,11 +226,20 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|||||||
for id := range nodeByInbound {
|
for id := range nodeByInbound {
|
||||||
inboundIDs = append(inboundIDs, id)
|
inboundIDs = append(inboundIDs, id)
|
||||||
}
|
}
|
||||||
if err := db.Table("client_traffics").
|
// Chunk the IN clause to avoid "too many SQL variables" on SQLite
|
||||||
Select("inbound_id, email, enable, total, up, down, expiry_time").
|
// when there are many node-owned inbounds (common with many nodes).
|
||||||
Where("inbound_id IN ?", inboundIDs).
|
// sqliteMaxVars is defined in this package (inbound.go).
|
||||||
Scan(&trafficRows).Error; err == nil {
|
for _, batch := range chunkInts(inboundIDs, sqliteMaxVars) {
|
||||||
depletedByNode := make(map[int]int)
|
var page []trafficRow
|
||||||
|
if err := db.Table("client_traffics").
|
||||||
|
Select("inbound_id, email, enable, total, up, down, expiry_time").
|
||||||
|
Where("inbound_id IN ?", batch).
|
||||||
|
Scan(&page).Error; err == nil {
|
||||||
|
trafficRows = append(trafficRows, page...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
depletedByNode := make(map[int]int)
|
||||||
|
if len(trafficRows) > 0 {
|
||||||
for _, row := range trafficRows {
|
for _, row := range trafficRows {
|
||||||
nodeID, ok := nodeByInbound[row.InboundID]
|
nodeID, ok := nodeByInbound[row.InboundID]
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -237,15 +251,15 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
|
|||||||
depletedByNode[nodeID]++
|
depletedByNode[nodeID]++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onlineByGuid := s.onlineEmailsByGuid()
|
}
|
||||||
for _, n := range nodes {
|
onlineByGuid := s.onlineEmailsByGuid()
|
||||||
n.InboundCount = len(inboundsByNode[n.Id])
|
for _, n := range nodes {
|
||||||
n.DepletedCount = depletedByNode[n.Id]
|
n.InboundCount = len(inboundsByNode[n.Id])
|
||||||
// Online is attributed to the node that physically hosts the client
|
n.DepletedCount = depletedByNode[n.Id]
|
||||||
// (by GUID): a client on a sub-node counts under the sub-node, not
|
// Online is attributed to the node that physically hosts the client
|
||||||
// the intermediate node it syncs through (#4983).
|
// (by GUID): a client on a sub-node counts under the sub-node, not
|
||||||
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
|
// the intermediate node it syncs through (#4983).
|
||||||
}
|
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
|
||||||
}
|
}
|
||||||
|
|
||||||
return nodes, nil
|
return nodes, nil
|
||||||
@@ -284,6 +298,20 @@ func (s *NodeService) GetById(id int) (*model.Node, error) {
|
|||||||
return n, nil
|
return n, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NodeExists reports whether a node with the given id exists on this panel.
|
||||||
|
// Used to drop stale, cross-panel node references on inbound import. A Count
|
||||||
|
// query distinguishes "no such node" (count 0, no error) from a real DB error.
|
||||||
|
func (s *NodeService) NodeExists(id int) (bool, error) {
|
||||||
|
if id <= 0 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).Count(&count).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return count > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeBasePath(p string) string {
|
func normalizeBasePath(p string) string {
|
||||||
p = strings.TrimSpace(p)
|
p = strings.TrimSpace(p)
|
||||||
if p == "" {
|
if p == "" {
|
||||||
@@ -474,6 +502,8 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
|||||||
"mem_pct": p.MemPct,
|
"mem_pct": p.MemPct,
|
||||||
"uptime_secs": p.UptimeSecs,
|
"uptime_secs": p.UptimeSecs,
|
||||||
"last_error": p.LastError,
|
"last_error": p.LastError,
|
||||||
|
"xray_state": p.XrayState,
|
||||||
|
"xray_error": p.XrayError,
|
||||||
}
|
}
|
||||||
// Only learn the GUID; never clear a known one if an old-build node (or a
|
// Only learn the GUID; never clear a known one if an old-build node (or a
|
||||||
// failed probe) reports none, so the stable identity survives blips.
|
// failed probe) reports none, so the stable identity survives blips.
|
||||||
@@ -607,7 +637,9 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
|
|||||||
Total uint64 `json:"total"`
|
Total uint64 `json:"total"`
|
||||||
} `json:"mem"`
|
} `json:"mem"`
|
||||||
Xray struct {
|
Xray struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
|
State string `json:"state"`
|
||||||
|
ErrorMsg string `json:"errorMsg"`
|
||||||
} `json:"xray"`
|
} `json:"xray"`
|
||||||
PanelVersion string `json:"panelVersion"`
|
PanelVersion string `json:"panelVersion"`
|
||||||
PanelGuid string `json:"panelGuid"`
|
PanelGuid string `json:"panelGuid"`
|
||||||
@@ -628,6 +660,8 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
|
|||||||
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total)
|
||||||
}
|
}
|
||||||
patch.XrayVersion = o.Xray.Version
|
patch.XrayVersion = o.Xray.Version
|
||||||
|
patch.XrayState = o.Xray.State
|
||||||
|
patch.XrayError = o.Xray.ErrorMsg
|
||||||
patch.PanelVersion = o.PanelVersion
|
patch.PanelVersion = o.PanelVersion
|
||||||
patch.Guid = o.PanelGuid
|
patch.Guid = o.PanelGuid
|
||||||
patch.UptimeSecs = o.Uptime
|
patch.UptimeSecs = o.Uptime
|
||||||
@@ -643,6 +677,10 @@ type ProbeResultUI struct {
|
|||||||
MemPct float64 `json:"memPct" example:"45.2"`
|
MemPct float64 `json:"memPct" example:"45.2"`
|
||||||
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
|
||||||
Error string `json:"error"`
|
Error string `json:"error"`
|
||||||
|
// XrayState/XrayError are populated on successful probes even when the node's
|
||||||
|
// Xray core is not healthy. The UI uses them for a distinct "panel ok, xray failed" indicator.
|
||||||
|
XrayState string `json:"xrayState"`
|
||||||
|
XrayError string `json:"xrayError"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
|
func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
|
||||||
@@ -654,6 +692,8 @@ func (p HeartbeatPatch) ToUI(ok bool) ProbeResultUI {
|
|||||||
MemPct: p.MemPct,
|
MemPct: p.MemPct,
|
||||||
UptimeSecs: p.UptimeSecs,
|
UptimeSecs: p.UptimeSecs,
|
||||||
Error: FriendlyProbeError(p.LastError),
|
Error: FriendlyProbeError(p.LastError),
|
||||||
|
XrayState: p.XrayState,
|
||||||
|
XrayError: p.XrayError,
|
||||||
}
|
}
|
||||||
if ok {
|
if ok {
|
||||||
r.Status = "online"
|
r.Status = "online"
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
|
|||||||
LatencyMs: n.LatencyMs,
|
LatencyMs: n.LatencyMs,
|
||||||
PanelVersion: n.PanelVersion,
|
PanelVersion: n.PanelVersion,
|
||||||
XrayVersion: n.XrayVersion,
|
XrayVersion: n.XrayVersion,
|
||||||
|
XrayState: n.XrayState,
|
||||||
|
XrayError: n.XrayError,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
@@ -140,6 +142,8 @@ func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
|
|||||||
LatencyMs: sum.LatencyMs,
|
LatencyMs: sum.LatencyMs,
|
||||||
PanelVersion: sum.PanelVersion,
|
PanelVersion: sum.PanelVersion,
|
||||||
XrayVersion: sum.XrayVersion,
|
XrayVersion: sum.XrayVersion,
|
||||||
|
XrayState: sum.XrayState,
|
||||||
|
XrayError: sum.XrayError,
|
||||||
Transitive: true,
|
Transitive: true,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,8 +86,10 @@ var defaultValueMap = map[string]string{
|
|||||||
"subJsonMux": "",
|
"subJsonMux": "",
|
||||||
"subJsonRules": "",
|
"subJsonRules": "",
|
||||||
"subJsonFinalMask": "",
|
"subJsonFinalMask": "",
|
||||||
|
"subThemeDir": "",
|
||||||
"datepicker": "gregorian",
|
"datepicker": "gregorian",
|
||||||
"warp": "",
|
"warp": "",
|
||||||
|
"warpUpdateInterval": "0",
|
||||||
"nord": "",
|
"nord": "",
|
||||||
"externalTrafficInformEnable": "false",
|
"externalTrafficInformEnable": "false",
|
||||||
"externalTrafficInformURI": "",
|
"externalTrafficInformURI": "",
|
||||||
@@ -322,6 +324,22 @@ func (s *SettingService) setInt(key string, value int) error {
|
|||||||
return s.setString(key, strconv.Itoa(value))
|
return s.setString(key, strconv.Itoa(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetWarpLastUpdate() (int64, error) {
|
||||||
|
val, err := s.getString("warpLastUpdate")
|
||||||
|
if err != nil || val == "" {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseInt(val, 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetWarpLastUpdate(val int64) error {
|
||||||
|
return s.saveSetting("warpLastUpdate", strconv.FormatInt(val, 10))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetWarpUpdateInterval(val int) error {
|
||||||
|
return s.setInt("warpUpdateInterval", val)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
func (s *SettingService) GetXrayConfigTemplate() (string, error) {
|
||||||
return s.getString("xrayTemplateConfig")
|
return s.getString("xrayTemplateConfig")
|
||||||
}
|
}
|
||||||
@@ -699,6 +717,10 @@ func (s *SettingService) GetSubJsonFinalMask() (string, error) {
|
|||||||
return s.getString("subJsonFinalMask")
|
return s.getString("subJsonFinalMask")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) GetSubThemeDir() (string, error) {
|
||||||
|
return s.getString("subThemeDir")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingService) GetDatepicker() (string, error) {
|
func (s *SettingService) GetDatepicker() (string, error) {
|
||||||
return s.getString("datepicker")
|
return s.getString("datepicker")
|
||||||
}
|
}
|
||||||
@@ -973,6 +995,7 @@ func (s *SettingService) GetDefaultSettings(host string) (any, error) {
|
|||||||
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
"defaultCert": func() (any, error) { return s.GetCertFile() },
|
||||||
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
"defaultKey": func() (any, error) { return s.GetKeyFile() },
|
||||||
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
"tgBotEnable": func() (any, error) { return s.GetTgbotEnabled() },
|
||||||
|
"subThemeDir": func() (any, error) { return s.GetSubThemeDir() },
|
||||||
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
"subEnable": func() (any, error) { return s.GetSubEnable() },
|
||||||
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
"subJsonEnable": func() (any, error) { return s.GetSubJsonEnable() },
|
||||||
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
"subClashEnable": func() (any, error) { return s.GetSubClashEnable() },
|
||||||
|
|||||||
+46
-7
@@ -9,6 +9,8 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/logger"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/util"
|
||||||
"github.com/mhsanaei/3x-ui/v3/util/common"
|
"github.com/mhsanaei/3x-ui/v3/util/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,8 +25,6 @@ const (
|
|||||||
warpClientVer = "a-6.30-3596"
|
warpClientVer = "a-6.30-3596"
|
||||||
)
|
)
|
||||||
|
|
||||||
var warpHTTPClient = &http.Client{Timeout: 15 * time.Second}
|
|
||||||
|
|
||||||
func (s *WarpService) GetWarpData() (string, error) {
|
func (s *WarpService) GetWarpData() (string, error) {
|
||||||
return s.SettingService.GetWarp()
|
return s.SettingService.GetWarp()
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ func (s *WarpService) GetWarpConfig() (string, error) {
|
|||||||
}
|
}
|
||||||
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
|
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
|
||||||
|
|
||||||
body, err := doWarpRequest(req)
|
body, err := s.doWarpRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -73,7 +73,7 @@ func (s *WarpService) RegWarp(secretKey string, publicKey string) (string, error
|
|||||||
req.Header.Set("CF-Client-Version", warpClientVer)
|
req.Header.Set("CF-Client-Version", warpClientVer)
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
body, err := doWarpRequest(req)
|
body, err := s.doWarpRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -148,7 +148,7 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
|
|||||||
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
|
req.Header.Set("Authorization", "Bearer "+warpData["access_token"])
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
body, err := doWarpRequest(req)
|
body, err := s.doWarpRequest(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -172,6 +172,44 @@ func (s *WarpService) SetWarpLicense(license string) (string, error) {
|
|||||||
return string(newWarpData), nil
|
return string(newWarpData), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *WarpService) ChangeWarpIP() (string, error) {
|
||||||
|
warpDataMap, err := s.loadWarpCreds()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
privKey, pubKey, err := util.GenerateWireguardKeypair()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := s.RegWarp(privKey, pubKey)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed struct {
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
Config map[string]interface{} `json:"config"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(result), &parsed); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
xraySvc := XraySettingService{}
|
||||||
|
if err := xraySvc.UpdateWarpXraySetting(parsed.Data, parsed.Config); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if license, ok := warpDataMap["license_key"]; ok && len(license) >= 26 {
|
||||||
|
if _, licErr := s.SetWarpLicense(license); licErr != nil {
|
||||||
|
logger.Warning("ChangeWarpIP: failed to re-apply WARP license: ", licErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
|
// loadWarpCreds reads the stored warp JSON and ensures access_token + device_id are set.
|
||||||
func (s *WarpService) loadWarpCreds() (map[string]string, error) {
|
func (s *WarpService) loadWarpCreds() (map[string]string, error) {
|
||||||
warp, err := s.SettingService.GetWarp()
|
warp, err := s.SettingService.GetWarp()
|
||||||
@@ -190,8 +228,9 @@ func (s *WarpService) loadWarpCreds() (map[string]string, error) {
|
|||||||
|
|
||||||
// doWarpRequest sends the request and returns the response body on 2xx.
|
// doWarpRequest sends the request and returns the response body on 2xx.
|
||||||
// Non-2xx responses are returned as errors including the status code and body.
|
// Non-2xx responses are returned as errors including the status code and body.
|
||||||
func doWarpRequest(req *http.Request) ([]byte, error) {
|
func (s *WarpService) doWarpRequest(req *http.Request) ([]byte, error) {
|
||||||
resp, err := warpHTTPClient.Do(req)
|
client := s.NewProxiedHTTPClient(15 * time.Second)
|
||||||
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
_ "embed"
|
_ "embed"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
@@ -40,6 +41,94 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *XraySettingService) UpdateWarpXraySetting(warpData map[string]string, warpConfig map[string]interface{}) error {
|
||||||
|
template, err := s.GetXrayConfigTemplate()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
var cfg map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(template), &cfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
outbounds, ok := cfg["outbounds"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := false
|
||||||
|
for _, outIface := range outbounds {
|
||||||
|
out, ok := outIface.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if tag, ok := out["tag"].(string); ok && tag == "warp" {
|
||||||
|
settings, ok := out["settings"].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
settings["secretKey"] = warpData["private_key"]
|
||||||
|
|
||||||
|
if conf, ok := warpConfig["config"].(map[string]interface{}); ok {
|
||||||
|
if iface, ok := conf["interface"].(map[string]interface{}); ok {
|
||||||
|
if addrs, ok := iface["addresses"].(map[string]interface{}); ok {
|
||||||
|
var addrList []string
|
||||||
|
if v4, ok := addrs["v4"].(string); ok && v4 != "" {
|
||||||
|
addrList = append(addrList, v4+"/32")
|
||||||
|
}
|
||||||
|
if v6, ok := addrs["v6"].(string); ok && v6 != "" {
|
||||||
|
addrList = append(addrList, v6+"/128")
|
||||||
|
}
|
||||||
|
settings["address"] = addrList
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId string
|
||||||
|
if id, ok := conf["client_id"].(string); ok {
|
||||||
|
clientId = id
|
||||||
|
} else if id, ok := warpData["client_id"]; ok {
|
||||||
|
clientId = id
|
||||||
|
}
|
||||||
|
if clientId != "" {
|
||||||
|
decoded, _ := base64.StdEncoding.DecodeString(clientId)
|
||||||
|
var res []int
|
||||||
|
for _, b := range decoded {
|
||||||
|
res = append(res, int(b))
|
||||||
|
}
|
||||||
|
settings["reserved"] = res
|
||||||
|
}
|
||||||
|
|
||||||
|
if peers, ok := conf["peers"].([]interface{}); ok && len(peers) > 0 {
|
||||||
|
if peer, ok := peers[0].(map[string]interface{}); ok {
|
||||||
|
if pSettings, ok := settings["peers"].([]interface{}); ok && len(pSettings) > 0 {
|
||||||
|
if pSet, ok := pSettings[0].(map[string]interface{}); ok {
|
||||||
|
pSet["publicKey"] = peer["public_key"]
|
||||||
|
if endpoint, ok := peer["endpoint"].(map[string]interface{}); ok {
|
||||||
|
pSet["endpoint"] = endpoint["host"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if updated {
|
||||||
|
outJSON, err := json.MarshalIndent(cfg, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.SaveXraySetting(string(outJSON))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
|
// UnwrapXrayTemplateConfig returns the raw xray config JSON from `raw`,
|
||||||
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
|
// peeling off any number of `{ "inboundTags": ..., "outboundTestUrl": ...,
|
||||||
// "xraySetting": <real config> }` response-shaped wrappers that may have
|
// "xraySetting": <real config> }` response-shaped wrappers that may have
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "عملاء في المجموعة",
|
"clientCount": "عملاء في المجموعة",
|
||||||
"totalGroups": "إجمالي المجموعات",
|
"totalGroups": "إجمالي المجموعات",
|
||||||
"totalGroupedClients": "العملاء بمجموعة",
|
"totalGroupedClients": "العملاء بمجموعة",
|
||||||
"emptyGroups": "مجموعات فارغة",
|
"trafficUsed": "حركة المرور المستخدمة",
|
||||||
|
"totalTraffic": "إجمالي حركة المرور",
|
||||||
"addGroup": "إضافة مجموعة",
|
"addGroup": "إضافة مجموعة",
|
||||||
"createSuccess": "تم إنشاء المجموعة «{name}».",
|
"createSuccess": "تم إنشاء المجموعة «{name}».",
|
||||||
"rename": "إعادة تسمية",
|
"rename": "إعادة تسمية",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "متصل",
|
"online": "متصل",
|
||||||
"offline": "غير متصل",
|
"offline": "غير متصل",
|
||||||
"unknown": "غير معروف"
|
"unknown": "غير معروف",
|
||||||
|
"xrayError": "خطأ Xray",
|
||||||
|
"xrayStopped": "متوقف"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "فشل تحميل النودز",
|
"list": "فشل تحميل النودز",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN",
|
"subProfileUrlDesc": "رابط لموقعك الإلكتروني يظهر في عميل VPN",
|
||||||
"subAnnounce": "إعلان",
|
"subAnnounce": "إعلان",
|
||||||
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
|
"subAnnounceDesc": "نص الإعلان المعروض في عميل VPN",
|
||||||
|
"subThemeDir": "مجلد قالب الاشتراك",
|
||||||
|
"subThemeDirDesc": "المسار المطلق لمجلد يحتوي على قالب مخصص (index.html/sub.html) لصفحة الاشتراك (مثل /etc/3x-ui/sub_templates/my-theme/). اتركه فارغًا لاستخدام الصفحة الافتراضية.",
|
||||||
"subEnableRouting": "تفعيل التوجيه",
|
"subEnableRouting": "تفعيل التوجيه",
|
||||||
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
"subEnableRoutingDesc": "إعداد عام لتمكين التوجيه (Routing) في عميل VPN. (فقط لـ Happ)",
|
||||||
"subRoutingRules": "قواعد التوجيه",
|
"subRoutingRules": "قواعد التوجيه",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "تم تحديث صادر NordVPN"
|
"outboundUpdated": "تم تحديث صادر NordVPN"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "تغيير الـ IP",
|
||||||
|
"changeIpSuccess": "تم تغيير عنوان IP الخاص بـ WARP بنجاح!",
|
||||||
|
"autoUpdateIp": "التحديث التلقائي لعنوان IP",
|
||||||
|
"intervalDays": "الفاصل الزمني (أيام)",
|
||||||
|
"intervalDesc": "0 للتعطيل. يغيّر عنوان IP تلقائيًا.",
|
||||||
"licenseError": "فشل تعيين رخصة WARP.",
|
"licenseError": "فشل تعيين رخصة WARP.",
|
||||||
"fetchFirst": "احصل على تكوين WARP أولاً.",
|
"fetchFirst": "احصل على تكوين WARP أولاً.",
|
||||||
"createAccount": "إنشاء حساب WARP",
|
"createAccount": "إنشاء حساب WARP",
|
||||||
|
|||||||
@@ -813,7 +813,8 @@
|
|||||||
"clientCount": "Clients in group",
|
"clientCount": "Clients in group",
|
||||||
"totalGroups": "Total groups",
|
"totalGroups": "Total groups",
|
||||||
"totalGroupedClients": "Clients with a group",
|
"totalGroupedClients": "Clients with a group",
|
||||||
"emptyGroups": "Empty groups",
|
"trafficUsed": "Traffic used",
|
||||||
|
"totalTraffic": "Total traffic",
|
||||||
"addGroup": "Add Group",
|
"addGroup": "Add Group",
|
||||||
"createSuccess": "Group \"{name}\" created.",
|
"createSuccess": "Group \"{name}\" created.",
|
||||||
"rename": "Rename",
|
"rename": "Rename",
|
||||||
@@ -897,7 +898,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"unknown": "Unknown"
|
"unknown": "Unknown",
|
||||||
|
"xrayError": "Xray Error",
|
||||||
|
"xrayStopped": "Stopped"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Failed to load nodes",
|
"list": "Failed to load nodes",
|
||||||
@@ -1010,6 +1013,8 @@
|
|||||||
"subProfileUrlDesc": "A link to your website displayed in the VPN client",
|
"subProfileUrlDesc": "A link to your website displayed in the VPN client",
|
||||||
"subAnnounce": "Announce",
|
"subAnnounce": "Announce",
|
||||||
"subAnnounceDesc": "The announcement text displayed in the VPN client",
|
"subAnnounceDesc": "The announcement text displayed in the VPN client",
|
||||||
|
"subThemeDir": "Sub Theme Directory",
|
||||||
|
"subThemeDirDesc": "Absolute path to a folder containing a custom index.html/sub.html subscription page template (e.g. /etc/3x-ui/sub_templates/my-theme/). Leave empty to use the default page.",
|
||||||
"subEnableRouting": "Enable routing",
|
"subEnableRouting": "Enable routing",
|
||||||
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
|
||||||
"subRoutingRules": "Routing rules",
|
"subRoutingRules": "Routing rules",
|
||||||
@@ -1451,6 +1456,11 @@
|
|||||||
"outboundUpdated": "NordVPN outbound updated"
|
"outboundUpdated": "NordVPN outbound updated"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Change IP",
|
||||||
|
"changeIpSuccess": "WARP IP changed successfully!",
|
||||||
|
"autoUpdateIp": "Auto Update IP Address",
|
||||||
|
"intervalDays": "Interval (Days)",
|
||||||
|
"intervalDesc": "0 to disable. Changes IP address automatically.",
|
||||||
"licenseError": "Failed to set WARP license.",
|
"licenseError": "Failed to set WARP license.",
|
||||||
"fetchFirst": "Fetch the WARP config first.",
|
"fetchFirst": "Fetch the WARP config first.",
|
||||||
"createAccount": "Create WARP account",
|
"createAccount": "Create WARP account",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Clientes en el grupo",
|
"clientCount": "Clientes en el grupo",
|
||||||
"totalGroups": "Total de grupos",
|
"totalGroups": "Total de grupos",
|
||||||
"totalGroupedClients": "Clientes con grupo",
|
"totalGroupedClients": "Clientes con grupo",
|
||||||
"emptyGroups": "Grupos vacíos",
|
"trafficUsed": "Tráfico usado",
|
||||||
|
"totalTraffic": "Tráfico total",
|
||||||
"addGroup": "Añadir grupo",
|
"addGroup": "Añadir grupo",
|
||||||
"createSuccess": "Grupo «{name}» creado.",
|
"createSuccess": "Grupo «{name}» creado.",
|
||||||
"rename": "Renombrar",
|
"rename": "Renombrar",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "En línea",
|
"online": "En línea",
|
||||||
"offline": "Sin conexión",
|
"offline": "Sin conexión",
|
||||||
"unknown": "Desconocido"
|
"unknown": "Desconocido",
|
||||||
|
"xrayError": "Error de Xray",
|
||||||
|
"xrayStopped": "Detenido"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Error al cargar los nodos",
|
"list": "Error al cargar los nodos",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN",
|
"subProfileUrlDesc": "Un enlace a tu sitio web mostrado en el cliente VPN",
|
||||||
"subAnnounce": "Anuncio",
|
"subAnnounce": "Anuncio",
|
||||||
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
|
"subAnnounceDesc": "El texto del anuncio mostrado en el cliente VPN",
|
||||||
|
"subThemeDir": "Directorio del tema de suscripción",
|
||||||
|
"subThemeDirDesc": "Ruta absoluta a una carpeta que contiene una plantilla personalizada (index.html/sub.html) para la página de suscripción (p. ej. /etc/3x-ui/sub_templates/my-theme/). Déjalo vacío para usar la página predeterminada.",
|
||||||
"subEnableRouting": "Habilitar enrutamiento",
|
"subEnableRouting": "Habilitar enrutamiento",
|
||||||
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
"subEnableRoutingDesc": "Configuración global para habilitar el enrutamiento en el cliente VPN. (Solo para Happ)",
|
||||||
"subRoutingRules": "Reglas de enrutamiento",
|
"subRoutingRules": "Reglas de enrutamiento",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Salida NordVPN actualizada"
|
"outboundUpdated": "Salida NordVPN actualizada"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Cambiar IP",
|
||||||
|
"changeIpSuccess": "¡IP de WARP cambiada correctamente!",
|
||||||
|
"autoUpdateIp": "Actualizar IP automáticamente",
|
||||||
|
"intervalDays": "Intervalo (días)",
|
||||||
|
"intervalDesc": "0 para desactivar. Cambia la dirección IP automáticamente.",
|
||||||
"licenseError": "No se pudo establecer la licencia WARP.",
|
"licenseError": "No se pudo establecer la licencia WARP.",
|
||||||
"fetchFirst": "Obtén primero la configuración WARP.",
|
"fetchFirst": "Obtén primero la configuración WARP.",
|
||||||
"createAccount": "Crear cuenta WARP",
|
"createAccount": "Crear cuenta WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "کاربران در گروه",
|
"clientCount": "کاربران در گروه",
|
||||||
"totalGroups": "تعداد گروهها",
|
"totalGroups": "تعداد گروهها",
|
||||||
"totalGroupedClients": "کاربران دارای گروه",
|
"totalGroupedClients": "کاربران دارای گروه",
|
||||||
"emptyGroups": "گروههای خالی",
|
"trafficUsed": "ترافیک مصرفشده",
|
||||||
|
"totalTraffic": "مجموع ترافیک",
|
||||||
"addGroup": "افزودن گروه",
|
"addGroup": "افزودن گروه",
|
||||||
"createSuccess": "گروه «{name}» ایجاد شد.",
|
"createSuccess": "گروه «{name}» ایجاد شد.",
|
||||||
"rename": "تغییر نام",
|
"rename": "تغییر نام",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "آنلاین",
|
"online": "آنلاین",
|
||||||
"offline": "آفلاین",
|
"offline": "آفلاین",
|
||||||
"unknown": "نامشخص"
|
"unknown": "نامشخص",
|
||||||
|
"xrayError": "خطای Xray",
|
||||||
|
"xrayStopped": "متوقف"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "بارگذاری نودها ناموفق",
|
"list": "بارگذاری نودها ناموفق",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "لینک وبسایت شما که در کلاینت VPN نمایش داده میشود",
|
"subProfileUrlDesc": "لینک وبسایت شما که در کلاینت VPN نمایش داده میشود",
|
||||||
"subAnnounce": "اعلان",
|
"subAnnounce": "اعلان",
|
||||||
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده میشود",
|
"subAnnounceDesc": "متن اعلانی که در کلاینت VPN نمایش داده میشود",
|
||||||
|
"subThemeDir": "پوشه قالب صفحه اشتراک",
|
||||||
|
"subThemeDirDesc": "مسیر مطلق پوشهای که شامل یک قالب سفارشی (index.html/sub.html) برای صفحه اشتراک است (مثلاً /etc/3x-ui/sub_templates/my-theme/). برای استفاده از صفحه پیشفرض خالی بگذارید.",
|
||||||
"subEnableRouting": "فعالسازی مسیریابی",
|
"subEnableRouting": "فعالسازی مسیریابی",
|
||||||
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
"subEnableRoutingDesc": "تنظیمات سراسری برای فعالسازی مسیریابی در کلاینت VPN. (فقط برای Happ)",
|
||||||
"subRoutingRules": "قوانین مسیریابی",
|
"subRoutingRules": "قوانین مسیریابی",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد"
|
"outboundUpdated": "خروجی NordVPN بهروزرسانی شد"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "تغییر IP",
|
||||||
|
"changeIpSuccess": "آدرس IP وارپ با موفقیت تغییر کرد!",
|
||||||
|
"autoUpdateIp": "بهروزرسانی خودکار آدرس IP",
|
||||||
|
"intervalDays": "بازه (روز)",
|
||||||
|
"intervalDesc": "برای غیرفعالسازی ۰ بگذارید. آدرس IP را بهصورت خودکار تغییر میدهد.",
|
||||||
"licenseError": "تنظیم لایسنس WARP ناموفق بود.",
|
"licenseError": "تنظیم لایسنس WARP ناموفق بود.",
|
||||||
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
|
"fetchFirst": "ابتدا پیکربندی WARP را دریافت کنید.",
|
||||||
"createAccount": "ایجاد حساب WARP",
|
"createAccount": "ایجاد حساب WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Klien di grup",
|
"clientCount": "Klien di grup",
|
||||||
"totalGroups": "Total grup",
|
"totalGroups": "Total grup",
|
||||||
"totalGroupedClients": "Klien dengan grup",
|
"totalGroupedClients": "Klien dengan grup",
|
||||||
"emptyGroups": "Grup kosong",
|
"trafficUsed": "Trafik terpakai",
|
||||||
|
"totalTraffic": "Total trafik",
|
||||||
"addGroup": "Tambah grup",
|
"addGroup": "Tambah grup",
|
||||||
"createSuccess": "Grup «{name}» dibuat.",
|
"createSuccess": "Grup «{name}» dibuat.",
|
||||||
"rename": "Ubah nama",
|
"rename": "Ubah nama",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"unknown": "Tidak diketahui"
|
"unknown": "Tidak diketahui",
|
||||||
|
"xrayError": "Kesalahan Xray",
|
||||||
|
"xrayStopped": "Berhenti"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Gagal memuat node",
|
"list": "Gagal memuat node",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN",
|
"subProfileUrlDesc": "Tautan ke situs web Anda yang ditampilkan di klien VPN",
|
||||||
"subAnnounce": "Pengumuman",
|
"subAnnounce": "Pengumuman",
|
||||||
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
|
"subAnnounceDesc": "Teks pengumuman yang ditampilkan di klien VPN",
|
||||||
|
"subThemeDir": "Direktori Tema Langganan",
|
||||||
|
"subThemeDirDesc": "Path absolut ke folder yang berisi template kustom (index.html/sub.html) untuk halaman langganan (mis. /etc/3x-ui/sub_templates/my-theme/). Biarkan kosong untuk menggunakan halaman default.",
|
||||||
"subEnableRouting": "Aktifkan perutean",
|
"subEnableRouting": "Aktifkan perutean",
|
||||||
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
"subEnableRoutingDesc": "Pengaturan global untuk mengaktifkan perutean (routing) di klien VPN. (Hanya untuk Happ)",
|
||||||
"subRoutingRules": "Aturan routing",
|
"subRoutingRules": "Aturan routing",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Outbound NordVPN diperbarui"
|
"outboundUpdated": "Outbound NordVPN diperbarui"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Ganti IP",
|
||||||
|
"changeIpSuccess": "IP WARP berhasil diganti!",
|
||||||
|
"autoUpdateIp": "Perbarui Alamat IP Otomatis",
|
||||||
|
"intervalDays": "Interval (Hari)",
|
||||||
|
"intervalDesc": "0 untuk menonaktifkan. Mengganti alamat IP secara otomatis.",
|
||||||
"licenseError": "Gagal mengatur lisensi WARP.",
|
"licenseError": "Gagal mengatur lisensi WARP.",
|
||||||
"fetchFirst": "Ambil konfig WARP terlebih dahulu.",
|
"fetchFirst": "Ambil konfig WARP terlebih dahulu.",
|
||||||
"createAccount": "Buat akun WARP",
|
"createAccount": "Buat akun WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "グループ内のクライアント",
|
"clientCount": "グループ内のクライアント",
|
||||||
"totalGroups": "グループ合計",
|
"totalGroups": "グループ合計",
|
||||||
"totalGroupedClients": "グループのあるクライアント",
|
"totalGroupedClients": "グループのあるクライアント",
|
||||||
"emptyGroups": "空のグループ",
|
"trafficUsed": "使用済みトラフィック",
|
||||||
|
"totalTraffic": "合計トラフィック",
|
||||||
"addGroup": "グループ追加",
|
"addGroup": "グループ追加",
|
||||||
"createSuccess": "グループ「{name}」を作成しました。",
|
"createSuccess": "グループ「{name}」を作成しました。",
|
||||||
"rename": "名前変更",
|
"rename": "名前変更",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "オンライン",
|
"online": "オンライン",
|
||||||
"offline": "オフライン",
|
"offline": "オフライン",
|
||||||
"unknown": "不明"
|
"unknown": "不明",
|
||||||
|
"xrayError": "Xray エラー",
|
||||||
|
"xrayStopped": "停止"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "ノードの読み込みに失敗しました",
|
"list": "ノードの読み込みに失敗しました",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク",
|
"subProfileUrlDesc": "VPNクライアントに表示されるWebサイトへのリンク",
|
||||||
"subAnnounce": "お知らせ",
|
"subAnnounce": "お知らせ",
|
||||||
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
|
"subAnnounceDesc": "VPNクライアントに表示されるお知らせのテキスト",
|
||||||
|
"subThemeDir": "サブスクリプションテーマディレクトリ",
|
||||||
|
"subThemeDirDesc": "サブスクリプションページのカスタムテンプレート (index.html/sub.html) を含むフォルダーの絶対パス(例: /etc/3x-ui/sub_templates/my-theme/)。空欄の場合はデフォルトのページを使用します。",
|
||||||
"subEnableRouting": "ルーティングを有効化",
|
"subEnableRouting": "ルーティングを有効化",
|
||||||
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
"subEnableRoutingDesc": "VPNクライアントでルーティングを有効にするためのグローバル設定。(Happのみ)",
|
||||||
"subRoutingRules": "ルーティングルール",
|
"subRoutingRules": "ルーティングルール",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
|
"outboundUpdated": "NordVPN アウトバウンドを更新しました"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "IP を変更",
|
||||||
|
"changeIpSuccess": "WARP の IP を変更しました!",
|
||||||
|
"autoUpdateIp": "IP アドレスの自動更新",
|
||||||
|
"intervalDays": "間隔(日)",
|
||||||
|
"intervalDesc": "0 で無効。IP アドレスを自動的に変更します。",
|
||||||
"licenseError": "WARP ライセンスの設定に失敗しました。",
|
"licenseError": "WARP ライセンスの設定に失敗しました。",
|
||||||
"fetchFirst": "先に WARP 構成を取得してください。",
|
"fetchFirst": "先に WARP 構成を取得してください。",
|
||||||
"createAccount": "WARP アカウントを作成",
|
"createAccount": "WARP アカウントを作成",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Clientes no grupo",
|
"clientCount": "Clientes no grupo",
|
||||||
"totalGroups": "Total de grupos",
|
"totalGroups": "Total de grupos",
|
||||||
"totalGroupedClients": "Clientes com grupo",
|
"totalGroupedClients": "Clientes com grupo",
|
||||||
"emptyGroups": "Grupos vazios",
|
"trafficUsed": "Tráfego usado",
|
||||||
|
"totalTraffic": "Tráfego total",
|
||||||
"addGroup": "Adicionar grupo",
|
"addGroup": "Adicionar grupo",
|
||||||
"createSuccess": "Grupo «{name}» criado.",
|
"createSuccess": "Grupo «{name}» criado.",
|
||||||
"rename": "Renomear",
|
"rename": "Renomear",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "Online",
|
"online": "Online",
|
||||||
"offline": "Offline",
|
"offline": "Offline",
|
||||||
"unknown": "Desconhecido"
|
"unknown": "Desconhecido",
|
||||||
|
"xrayError": "Erro do Xray",
|
||||||
|
"xrayStopped": "Parado"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Falha ao carregar os nós",
|
"list": "Falha ao carregar os nós",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN",
|
"subProfileUrlDesc": "Um link para o seu site exibido no cliente VPN",
|
||||||
"subAnnounce": "Anúncio",
|
"subAnnounce": "Anúncio",
|
||||||
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
|
"subAnnounceDesc": "O texto do anúncio exibido no cliente VPN",
|
||||||
|
"subThemeDir": "Diretório do tema de assinatura",
|
||||||
|
"subThemeDirDesc": "Caminho absoluto para uma pasta contendo um modelo personalizado (index.html/sub.html) para a página de assinatura (ex.: /etc/3x-ui/sub_templates/my-theme/). Deixe vazio para usar a página padrão.",
|
||||||
"subEnableRouting": "Ativar roteamento",
|
"subEnableRouting": "Ativar roteamento",
|
||||||
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
"subEnableRoutingDesc": "Configuração global para habilitar o roteamento no cliente VPN. (Apenas para Happ)",
|
||||||
"subRoutingRules": "Regras de roteamento",
|
"subRoutingRules": "Regras de roteamento",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Saída NordVPN atualizada"
|
"outboundUpdated": "Saída NordVPN atualizada"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Alterar IP",
|
||||||
|
"changeIpSuccess": "IP do WARP alterado com sucesso!",
|
||||||
|
"autoUpdateIp": "Atualizar endereço IP automaticamente",
|
||||||
|
"intervalDays": "Intervalo (dias)",
|
||||||
|
"intervalDesc": "0 para desativar. Altera o endereço IP automaticamente.",
|
||||||
"licenseError": "Falha ao definir licença WARP.",
|
"licenseError": "Falha ao definir licença WARP.",
|
||||||
"fetchFirst": "Obtenha primeiro a configuração WARP.",
|
"fetchFirst": "Obtenha primeiro a configuração WARP.",
|
||||||
"createAccount": "Criar conta WARP",
|
"createAccount": "Criar conta WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Клиентов в группе",
|
"clientCount": "Клиентов в группе",
|
||||||
"totalGroups": "Всего групп",
|
"totalGroups": "Всего групп",
|
||||||
"totalGroupedClients": "Клиенты с группой",
|
"totalGroupedClients": "Клиенты с группой",
|
||||||
"emptyGroups": "Пустые группы",
|
"trafficUsed": "Использованный трафик",
|
||||||
|
"totalTraffic": "Общий трафик",
|
||||||
"addGroup": "Добавить группу",
|
"addGroup": "Добавить группу",
|
||||||
"createSuccess": "Группа «{name}» создана.",
|
"createSuccess": "Группа «{name}» создана.",
|
||||||
"rename": "Переименовать",
|
"rename": "Переименовать",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "В сети",
|
"online": "В сети",
|
||||||
"offline": "Не в сети",
|
"offline": "Не в сети",
|
||||||
"unknown": "Неизвестно"
|
"unknown": "Неизвестно",
|
||||||
|
"xrayError": "Ошибка Xray",
|
||||||
|
"xrayStopped": "Остановлен"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Не удалось загрузить узлы",
|
"list": "Не удалось загрузить узлы",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте",
|
"subProfileUrlDesc": "Ссылка на ваш сайт, отображаемая в VPN-клиенте",
|
||||||
"subAnnounce": "Объявление",
|
"subAnnounce": "Объявление",
|
||||||
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
|
"subAnnounceDesc": "Текст объявления, отображаемый в VPN-клиенте",
|
||||||
|
"subThemeDir": "Каталог темы подписки",
|
||||||
|
"subThemeDirDesc": "Абсолютный путь к папке с пользовательским шаблоном (index.html/sub.html) для страницы подписки (например, /etc/3x-ui/sub_templates/my-theme/). Оставьте пустым, чтобы использовать страницу по умолчанию.",
|
||||||
"subEnableRouting": "Включить маршрутизацию",
|
"subEnableRouting": "Включить маршрутизацию",
|
||||||
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
"subEnableRoutingDesc": "Глобальная настройка для включения маршрутизации в VPN-клиенте. (Только для Happ)",
|
||||||
"subRoutingRules": "Правила маршрутизации",
|
"subRoutingRules": "Правила маршрутизации",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Исходящий NordVPN обновлён"
|
"outboundUpdated": "Исходящий NordVPN обновлён"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Сменить IP",
|
||||||
|
"changeIpSuccess": "IP-адрес WARP успешно изменён!",
|
||||||
|
"autoUpdateIp": "Автоматическое обновление IP-адреса",
|
||||||
|
"intervalDays": "Интервал (дни)",
|
||||||
|
"intervalDesc": "0 — отключить. Автоматически меняет IP-адрес.",
|
||||||
"licenseError": "Не удалось установить лицензию WARP.",
|
"licenseError": "Не удалось установить лицензию WARP.",
|
||||||
"fetchFirst": "Сначала получите WARP-конфиг.",
|
"fetchFirst": "Сначала получите WARP-конфиг.",
|
||||||
"createAccount": "Создать аккаунт WARP",
|
"createAccount": "Создать аккаунт WARP",
|
||||||
|
|||||||
+567
-557
File diff suppressed because it is too large
Load Diff
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Клієнтів у групі",
|
"clientCount": "Клієнтів у групі",
|
||||||
"totalGroups": "Всього груп",
|
"totalGroups": "Всього груп",
|
||||||
"totalGroupedClients": "Клієнти з групою",
|
"totalGroupedClients": "Клієнти з групою",
|
||||||
"emptyGroups": "Порожні групи",
|
"trafficUsed": "Використаний трафік",
|
||||||
|
"totalTraffic": "Загальний трафік",
|
||||||
"addGroup": "Додати групу",
|
"addGroup": "Додати групу",
|
||||||
"createSuccess": "Групу «{name}» створено.",
|
"createSuccess": "Групу «{name}» створено.",
|
||||||
"rename": "Перейменувати",
|
"rename": "Перейменувати",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "У мережі",
|
"online": "У мережі",
|
||||||
"offline": "Не в мережі",
|
"offline": "Не в мережі",
|
||||||
"unknown": "Невідомо"
|
"unknown": "Невідомо",
|
||||||
|
"xrayError": "Помилка Xray",
|
||||||
|
"xrayStopped": "Зупинено"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Не вдалося завантажити вузли",
|
"list": "Не вдалося завантажити вузли",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті",
|
"subProfileUrlDesc": "Посилання на ваш вебсайт, що відображається у VPN-клієнті",
|
||||||
"subAnnounce": "Оголошення",
|
"subAnnounce": "Оголошення",
|
||||||
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
|
"subAnnounceDesc": "Текст оголошення, що відображається у VPN-клієнті",
|
||||||
|
"subThemeDir": "Каталог теми підписки",
|
||||||
|
"subThemeDirDesc": "Абсолютний шлях до теки з користувацьким шаблоном (index.html/sub.html) для сторінки підписки (наприклад, /etc/3x-ui/sub_templates/my-theme/). Залиште порожнім, щоб використовувати сторінку за замовчуванням.",
|
||||||
"subEnableRouting": "Увімкнути маршрутизацію",
|
"subEnableRouting": "Увімкнути маршрутизацію",
|
||||||
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
"subEnableRoutingDesc": "Глобальне налаштування для увімкнення маршрутизації у VPN-клієнті. (Тільки для Happ)",
|
||||||
"subRoutingRules": "Правила маршрутизації",
|
"subRoutingRules": "Правила маршрутизації",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Вихідний NordVPN оновлено"
|
"outboundUpdated": "Вихідний NordVPN оновлено"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Змінити IP",
|
||||||
|
"changeIpSuccess": "IP-адресу WARP успішно змінено!",
|
||||||
|
"autoUpdateIp": "Автоматичне оновлення IP-адреси",
|
||||||
|
"intervalDays": "Інтервал (дні)",
|
||||||
|
"intervalDesc": "0 — вимкнути. Автоматично змінює IP-адресу.",
|
||||||
"licenseError": "Не вдалося встановити ліцензію WARP.",
|
"licenseError": "Не вдалося встановити ліцензію WARP.",
|
||||||
"fetchFirst": "Спочатку отримайте WARP-конфіг.",
|
"fetchFirst": "Спочатку отримайте WARP-конфіг.",
|
||||||
"createAccount": "Створити акаунт WARP",
|
"createAccount": "Створити акаунт WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "Client trong nhóm",
|
"clientCount": "Client trong nhóm",
|
||||||
"totalGroups": "Tổng số nhóm",
|
"totalGroups": "Tổng số nhóm",
|
||||||
"totalGroupedClients": "Client có nhóm",
|
"totalGroupedClients": "Client có nhóm",
|
||||||
"emptyGroups": "Nhóm trống",
|
"trafficUsed": "Lưu lượng đã dùng",
|
||||||
|
"totalTraffic": "Tổng lưu lượng",
|
||||||
"addGroup": "Thêm nhóm",
|
"addGroup": "Thêm nhóm",
|
||||||
"createSuccess": "Đã tạo nhóm «{name}».",
|
"createSuccess": "Đã tạo nhóm «{name}».",
|
||||||
"rename": "Đổi tên",
|
"rename": "Đổi tên",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "Trực tuyến",
|
"online": "Trực tuyến",
|
||||||
"offline": "Ngoại tuyến",
|
"offline": "Ngoại tuyến",
|
||||||
"unknown": "Không xác định"
|
"unknown": "Không xác định",
|
||||||
|
"xrayError": "Lỗi Xray",
|
||||||
|
"xrayStopped": "Đã dừng"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "Không tải được danh sách nút",
|
"list": "Không tải được danh sách nút",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN",
|
"subProfileUrlDesc": "Liên kết đến trang web của bạn hiển thị trong ứng dụng VPN",
|
||||||
"subAnnounce": "Thông báo",
|
"subAnnounce": "Thông báo",
|
||||||
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
|
"subAnnounceDesc": "Văn bản thông báo hiển thị trong ứng dụng VPN",
|
||||||
|
"subThemeDir": "Thư mục giao diện Đăng ký",
|
||||||
|
"subThemeDirDesc": "Đường dẫn tuyệt đối đến thư mục chứa mẫu tùy chỉnh (index.html/sub.html) cho trang đăng ký (ví dụ: /etc/3x-ui/sub_templates/my-theme/). Để trống để dùng trang mặc định.",
|
||||||
"subEnableRouting": "Bật định tuyến",
|
"subEnableRouting": "Bật định tuyến",
|
||||||
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
|
"subEnableRoutingDesc": "Cài đặt toàn cục để bật định tuyến trong ứng dụng khách VPN. (Chỉ dành cho Happ)",
|
||||||
"subRoutingRules": "Quy tắc định tuyến",
|
"subRoutingRules": "Quy tắc định tuyến",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "Đã cập nhật outbound NordVPN"
|
"outboundUpdated": "Đã cập nhật outbound NordVPN"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "Đổi IP",
|
||||||
|
"changeIpSuccess": "Đã đổi IP WARP thành công!",
|
||||||
|
"autoUpdateIp": "Tự động cập nhật địa chỉ IP",
|
||||||
|
"intervalDays": "Khoảng thời gian (ngày)",
|
||||||
|
"intervalDesc": "0 để tắt. Tự động đổi địa chỉ IP.",
|
||||||
"licenseError": "Không thiết lập được giấy phép WARP.",
|
"licenseError": "Không thiết lập được giấy phép WARP.",
|
||||||
"fetchFirst": "Hãy lấy cấu hình WARP trước.",
|
"fetchFirst": "Hãy lấy cấu hình WARP trước.",
|
||||||
"createAccount": "Tạo tài khoản WARP",
|
"createAccount": "Tạo tài khoản WARP",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "分组中的客户端",
|
"clientCount": "分组中的客户端",
|
||||||
"totalGroups": "分组总数",
|
"totalGroups": "分组总数",
|
||||||
"totalGroupedClients": "有分组的客户端",
|
"totalGroupedClients": "有分组的客户端",
|
||||||
"emptyGroups": "空分组",
|
"trafficUsed": "已用流量",
|
||||||
|
"totalTraffic": "总流量",
|
||||||
"addGroup": "添加分组",
|
"addGroup": "添加分组",
|
||||||
"createSuccess": "已创建分组 “{name}”。",
|
"createSuccess": "已创建分组 “{name}”。",
|
||||||
"rename": "重命名",
|
"rename": "重命名",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "在线",
|
"online": "在线",
|
||||||
"offline": "离线",
|
"offline": "离线",
|
||||||
"unknown": "未知"
|
"unknown": "未知",
|
||||||
|
"xrayError": "Xray 错误",
|
||||||
|
"xrayStopped": "已停止"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "加载节点失败",
|
"list": "加载节点失败",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "VPN 客户端中显示的网站链接",
|
"subProfileUrlDesc": "VPN 客户端中显示的网站链接",
|
||||||
"subAnnounce": "公告",
|
"subAnnounce": "公告",
|
||||||
"subAnnounceDesc": "VPN 客户端中显示的公告文本",
|
"subAnnounceDesc": "VPN 客户端中显示的公告文本",
|
||||||
|
"subThemeDir": "订阅主题目录",
|
||||||
|
"subThemeDirDesc": "包含自定义订阅页面模板 (index.html/sub.html) 的文件夹的绝对路径(例如 /etc/3x-ui/sub_templates/my-theme/)。留空则使用默认页面。",
|
||||||
"subEnableRouting": "启用路由",
|
"subEnableRouting": "启用路由",
|
||||||
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
"subEnableRoutingDesc": "在 VPN 客户端中启用路由的全局设置。(僅限 Happ)",
|
||||||
"subRoutingRules": "路由規則",
|
"subRoutingRules": "路由規則",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "NordVPN 出站已更新"
|
"outboundUpdated": "NordVPN 出站已更新"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "更换 IP",
|
||||||
|
"changeIpSuccess": "WARP IP 更换成功!",
|
||||||
|
"autoUpdateIp": "自动更新 IP 地址",
|
||||||
|
"intervalDays": "间隔(天)",
|
||||||
|
"intervalDesc": "设为 0 禁用。自动更换 IP 地址。",
|
||||||
"licenseError": "设置 WARP 许可证失败。",
|
"licenseError": "设置 WARP 许可证失败。",
|
||||||
"fetchFirst": "请先获取 WARP 配置。",
|
"fetchFirst": "请先获取 WARP 配置。",
|
||||||
"createAccount": "创建 WARP 账户",
|
"createAccount": "创建 WARP 账户",
|
||||||
|
|||||||
@@ -812,7 +812,8 @@
|
|||||||
"clientCount": "群組中的客戶端",
|
"clientCount": "群組中的客戶端",
|
||||||
"totalGroups": "群組總數",
|
"totalGroups": "群組總數",
|
||||||
"totalGroupedClients": "有群組的客戶端",
|
"totalGroupedClients": "有群組的客戶端",
|
||||||
"emptyGroups": "空群組",
|
"trafficUsed": "已用流量",
|
||||||
|
"totalTraffic": "總流量",
|
||||||
"addGroup": "新增群組",
|
"addGroup": "新增群組",
|
||||||
"createSuccess": "已建立群組「{name}」。",
|
"createSuccess": "已建立群組「{name}」。",
|
||||||
"rename": "重新命名",
|
"rename": "重新命名",
|
||||||
@@ -896,7 +897,9 @@
|
|||||||
"statusValues": {
|
"statusValues": {
|
||||||
"online": "上線",
|
"online": "上線",
|
||||||
"offline": "離線",
|
"offline": "離線",
|
||||||
"unknown": "未知"
|
"unknown": "未知",
|
||||||
|
"xrayError": "Xray 錯誤",
|
||||||
|
"xrayStopped": "已停止"
|
||||||
},
|
},
|
||||||
"toasts": {
|
"toasts": {
|
||||||
"list": "載入節點失敗",
|
"list": "載入節點失敗",
|
||||||
@@ -1009,6 +1012,8 @@
|
|||||||
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結",
|
"subProfileUrlDesc": "VPN 用戶端中顯示的網站連結",
|
||||||
"subAnnounce": "公告",
|
"subAnnounce": "公告",
|
||||||
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
|
"subAnnounceDesc": "VPN 用戶端中顯示的公告文字",
|
||||||
|
"subThemeDir": "訂閱主題目錄",
|
||||||
|
"subThemeDirDesc": "包含自訂訂閱頁面範本 (index.html/sub.html) 的資料夾的絕對路徑(例如 /etc/3x-ui/sub_templates/my-theme/)。留空則使用預設頁面。",
|
||||||
"subEnableRouting": "啟用路由",
|
"subEnableRouting": "啟用路由",
|
||||||
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
"subEnableRoutingDesc": "在 VPN 用戶端中啟用路由的全域設定。(僅限 Happ)",
|
||||||
"subRoutingRules": "路由規則",
|
"subRoutingRules": "路由規則",
|
||||||
@@ -1450,6 +1455,11 @@
|
|||||||
"outboundUpdated": "NordVPN 出站已更新"
|
"outboundUpdated": "NordVPN 出站已更新"
|
||||||
},
|
},
|
||||||
"warp": {
|
"warp": {
|
||||||
|
"changeIp": "更換 IP",
|
||||||
|
"changeIpSuccess": "WARP IP 更換成功!",
|
||||||
|
"autoUpdateIp": "自動更新 IP 位址",
|
||||||
|
"intervalDays": "間隔(天)",
|
||||||
|
"intervalDesc": "設為 0 停用。自動更換 IP 位址。",
|
||||||
"licenseError": "設定 WARP 授權失敗。",
|
"licenseError": "設定 WARP 授權失敗。",
|
||||||
"fetchFirst": "請先取得 WARP 設定。",
|
"fetchFirst": "請先取得 WARP 設定。",
|
||||||
"createAccount": "建立 WARP 帳號",
|
"createAccount": "建立 WARP 帳號",
|
||||||
|
|||||||
+3
-4
@@ -299,6 +299,7 @@ func (s *Server) startTask(restartXray bool) {
|
|||||||
|
|
||||||
// check client ips from log file every day
|
// check client ips from log file every day
|
||||||
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
s.cron.AddJob("@daily", job.NewClearLogsJob())
|
||||||
|
s.cron.AddJob("@hourly", job.NewWarpIpJob())
|
||||||
|
|
||||||
// Inbound traffic reset jobs
|
// Inbound traffic reset jobs
|
||||||
// Run every hour
|
// Run every hour
|
||||||
@@ -358,9 +359,8 @@ func (s *Server) Start() (err error) {
|
|||||||
return s.start(true, true)
|
return s.start(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartPanelOnly initializes the panel during an in-process panel restart without cycling Xray.
|
|
||||||
func (s *Server) StartPanelOnly() (err error) {
|
func (s *Server) StartPanelOnly() (err error) {
|
||||||
return s.start(false, false)
|
return s.start(false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
func (s *Server) start(restartXray bool, startTgBot bool) (err error) {
|
||||||
@@ -465,9 +465,8 @@ func (s *Server) Stop() error {
|
|||||||
return s.stop(true, true)
|
return s.stop(true, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopPanelOnly stops only panel-owned HTTP/background resources for an in-process panel restart.
|
|
||||||
func (s *Server) StopPanelOnly() error {
|
func (s *Server) StopPanelOnly() error {
|
||||||
return s.stop(false, false)
|
return s.stop(false, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
func (s *Server) stop(stopXray bool, stopTgBot bool) error {
|
||||||
|
|||||||
@@ -864,6 +864,7 @@ show_status() {
|
|||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
show_xray_status
|
show_xray_status
|
||||||
|
show_mtproto_status
|
||||||
}
|
}
|
||||||
|
|
||||||
show_enable_status() {
|
show_enable_status() {
|
||||||
@@ -897,6 +898,34 @@ show_xray_status() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# show_mtproto_status reports each mtproto inbound's mtg sidecar (one process per
|
||||||
|
# inbound, run outside xray). Silent when no mtproto inbound is configured.
|
||||||
|
show_mtproto_status() {
|
||||||
|
local cfg_dir="${xui_folder}/bin/mtproto"
|
||||||
|
local cfgs=()
|
||||||
|
if [[ -d "${cfg_dir}" ]]; then
|
||||||
|
for f in "${cfg_dir}"/mtg-*.toml; do
|
||||||
|
[[ -e "$f" ]] && cfgs+=("$f")
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
[[ ${#cfgs[@]} -eq 0 ]] && return
|
||||||
|
|
||||||
|
local running
|
||||||
|
running=$(ps -ef | grep "mtg-linux" | grep -v "grep" | grep -oE 'mtg-[0-9]+\.toml')
|
||||||
|
for f in "${cfgs[@]}"; do
|
||||||
|
local name id bind
|
||||||
|
name=$(basename "$f")
|
||||||
|
id=$(echo "${name}" | sed -E 's/mtg-([0-9]+)\.toml/\1/')
|
||||||
|
bind=$(grep -E '^[[:space:]]*bind-to' "$f" | head -1 | cut -d'"' -f2)
|
||||||
|
if echo "${running}" | grep -qx "${name}"; then
|
||||||
|
echo -e "mtproto inbound ${id} (${bind}): ${green}Running${plain}"
|
||||||
|
else
|
||||||
|
echo -e "mtproto inbound ${id} (${bind}): ${red}Not Running${plain}"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
echo -e " ${yellow}mtg logs:${plain} journalctl -u x-ui --no-pager -n 200 | grep -i mtproto"
|
||||||
|
}
|
||||||
|
|
||||||
firewall_menu() {
|
firewall_menu() {
|
||||||
echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
|
echo -e "${green}\t1.${plain} ${green}Install${plain} Firewall"
|
||||||
echo -e "${green}\t2.${plain} Port List [numbered]"
|
echo -e "${green}\t2.${plain} Port List [numbered]"
|
||||||
@@ -1181,7 +1210,7 @@ install_acme() {
|
|||||||
|
|
||||||
ssl_cert_issue_main() {
|
ssl_cert_issue_main() {
|
||||||
echo -e "${green}\t1.${plain} Get SSL (Domain)"
|
echo -e "${green}\t1.${plain} Get SSL (Domain)"
|
||||||
echo -e "${green}\t2.${plain} Revoke"
|
echo -e "${green}\t2.${plain} Revoke & Remove"
|
||||||
echo -e "${green}\t3.${plain} Force Renew"
|
echo -e "${green}\t3.${plain} Force Renew"
|
||||||
echo -e "${green}\t4.${plain} Show Existing Domains"
|
echo -e "${green}\t4.${plain} Show Existing Domains"
|
||||||
echo -e "${green}\t5.${plain} Set Cert paths for the panel"
|
echo -e "${green}\t5.${plain} Set Cert paths for the panel"
|
||||||
@@ -1204,10 +1233,34 @@ ssl_cert_issue_main() {
|
|||||||
else
|
else
|
||||||
echo "Existing domains:"
|
echo "Existing domains:"
|
||||||
echo "$domains"
|
echo "$domains"
|
||||||
read -rp "Please enter a domain from the list to revoke the certificate: " domain
|
read -rp "Please enter a domain from the list to revoke and remove the certificate: " domain
|
||||||
if echo "$domains" | grep -qw "$domain"; then
|
if echo "$domains" | grep -qw "$domain"; then
|
||||||
~/.acme.sh/acme.sh --revoke -d ${domain}
|
# The IP-cert flow (option 6) stores files under /root/cert/ip, but acme.sh
|
||||||
LOGI "Certificate revoked for domain: $domain"
|
# tracks the cert under the actual IP address(es). Resolve those so renewal
|
||||||
|
# state is torn down too; otherwise the acme.sh cron re-creates the deleted cert.
|
||||||
|
local acme_ids="${domain}"
|
||||||
|
if [[ "${domain}" == "ip" ]]; then
|
||||||
|
acme_ids=$(~/.acme.sh/acme.sh --list 2> /dev/null | awk 'NR>1 {print $1}' | grep -E '^([0-9]{1,3}\.){3}[0-9]{1,3}$|:')
|
||||||
|
fi
|
||||||
|
for id in ${acme_ids}; do
|
||||||
|
# Best-effort revoke at the CA, then drop acme.sh renewal tracking.
|
||||||
|
~/.acme.sh/acme.sh --revoke -d "${id}" 2> /dev/null
|
||||||
|
~/.acme.sh/acme.sh --remove -d "${id}" 2> /dev/null
|
||||||
|
# --remove leaves the cert files on disk, so delete the state dirs (RSA + ECC).
|
||||||
|
rm -rf ~/.acme.sh/"${id}" ~/.acme.sh/"${id}_ecc"
|
||||||
|
done
|
||||||
|
# Delete the local certificate files for this domain.
|
||||||
|
rm -rf "/root/cert/${domain}"
|
||||||
|
LOGI "Certificate revoked and removed for domain: ${domain}"
|
||||||
|
|
||||||
|
# If the panel currently serves this domain's cert, clear the stored paths
|
||||||
|
# so it stops loading the now-deleted files, then restart.
|
||||||
|
local existing_cert=$(${xui_folder}/x-ui setting -getCert true | grep 'cert:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
|
||||||
|
if [[ "${existing_cert}" == "/root/cert/${domain}/"* ]]; then
|
||||||
|
${xui_folder}/x-ui cert -reset
|
||||||
|
LOGI "Cleared panel certificate paths referencing ${domain}; restarting panel."
|
||||||
|
restart
|
||||||
|
fi
|
||||||
else
|
else
|
||||||
echo "Invalid domain entered."
|
echo "Invalid domain entered."
|
||||||
fi
|
fi
|
||||||
|
|||||||
Reference in New Issue
Block a user