Compare commits

...

22 commits
v3.0.2 ... main

Author SHA1 Message Date
Komar
f9ae0347c6
fix(translation): correct typos and improve phrasing in English localization (#4430) 2026-05-16 10:24:04 +02:00
MHSanaei
2928b52b04
feat(tgbot): add Flow picker when creating a VLESS client
Some checks failed
CI / go-test (push) Has been cancelled
CI / govulncheck (push) Has been cancelled
CI / frontend (push) Has been cancelled
CodeQL Advanced / Analyze (go) (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Release 3X-UI / build (386) (push) Has been cancelled
Release 3X-UI / build (amd64) (push) Has been cancelled
Release 3X-UI / build (arm64) (push) Has been cancelled
Release 3X-UI / build (armv5) (push) Has been cancelled
Release 3X-UI / build (armv6) (push) Has been cancelled
Release 3X-UI / build (armv7) (push) Has been cancelled
Release 3X-UI / build (s390x) (push) Has been cancelled
Release 3X-UI / Build for Windows (push) Has been cancelled
The bot's add-client flow already serialised client_Flow into the VLESS
JSON template but never exposed a way to set it from Telegram, so every
client ended up with an empty flow regardless of the inbound's transport.

Added an inline "Flow" row to the VLESS protocol keyboard with three
choices — None, xtls-rprx-vision, and xtls-rprx-vision-udp443 — and a
matching i18n key in all 13 locale files. The row is only shown when
the inbound can actually use Vision flow (mirrors the frontend's
canEnableTlsFlow check: VLESS over TCP with TLS or Reality); on other
transports it's hidden and any stale client_Flow value is reset, so the
generated JSON stays consistent with the inbound's stream settings.
2026-05-15 13:12:54 +02:00
MHSanaei
07cdb82027
fix(inbounds): don't delete remote inbound when toggling enable
SetInboundEnable called rt.DelInbound for every runtime, but Remote.DelInbound
hits panel/api/inbounds/del/:id on the node — a real row delete, not just a
"stop serving" hint like Local.DelInbound. Flipping the enable switch on a
remote inbound therefore wiped the row on the node entirely.

Route remote inbounds through UpdateInbound instead so the row stays and only
the enable flag is patched. Local path keeps the Del+Add flow since that's
how Xray's gRPC API expects to be driven.

Fixes #4402
2026-05-15 12:43:16 +02:00
MHSanaei
f00f82b392
fix(outbound): probe UDP-based outbounds over UDP instead of TCP
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
The fast-probe mode hard-coded net.DialTimeout("tcp", ...), so testing a
WARP/WireGuard or Hysteria outbound always failed with an i/o timeout —
those transports only listen on UDP, never on TCP.

Probe is now transport-aware: extractOutboundEndpoints tags each endpoint
with the network the proxy actually listens on (UDP for wireguard,
hysteria, and any outbound whose streamSettings.network is hysteria, kcp,
or quic; TCP otherwise). probeUDPEndpoint dials UDP, writes a single
sentinel byte so the kernel can surface ICMP errors, and treats a read
timeout as success (WireGuard ignores invalid packets, so silence is the
expected reply from a reachable server). The result's mode field now
reflects what was probed, so the UI badge shows UDP for these outbounds
instead of mislabelling them as TCP.
2026-05-15 12:29:53 +02:00
MHSanaei
5a1019534f
refactor(inbounds): tighten advanced JSON helpers and fix dark-mode subtitles
Collapsed repeated stream/sniffing/settings handling in InboundFormModal
into shared helpers (stampAdvancedTextFor, parseAdvancedSliceWithLabel,
compactAdvancedJson, withSaving) plus a wrapped-config factory for the
single-key editors. Cuts ~120 lines from the script section with no
behavior change.

The advanced-panel subtitle and editor-meta text used a fixed dark color
that was unreadable on the dark and ultra-dark modal backgrounds.
Switched both to opacity-on-inherit so they pick up AntD's theme-aware
foreground color, the same pattern .section-heading already uses.
2026-05-15 12:12:47 +02:00
Abdalrahman
78f1719c6d
fix: prevent online clients from randomly disappearing from panel UI (#4387)
* fix: prevent online clients from randomly disappearing from panel UI

Online status was determined solely by whether a client transferred
bytes in the current 5-second polling window. The online list was
completely replaced each cycle, so idle-but-connected clients with no
traffic delta in that window were dropped from the UI.

Now online status is computed from lastOnline DB timestamps with a
5-second grace period via RefreshOnlineClientsFromMap(), so clients
remain visible across idle polling windows.

Closes #4384

* fix: extend online client grace period to survive idle poll cycles

The 5s grace period equalled the traffic-poll interval, so a client
whose Xray stats reported a zero delta for one cycle was still dropped
on the very next tick. Bump to 20s (~4 polls) so idle-but-connected
sessions stay visible across momentary counter gaps without lingering
long after a real disconnect.

Refs #4384

---------

Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-05-15 11:41:29 +02:00
MHSanaei
5cf8a08540
fix: disable balancer fallbackTag for random / roundRobin strategies
Xray-core's RandomStrategy and RoundRobinStrategy register a pending
dependency on the Observatory feature whenever fallbackTag is non-empty.
Since the panel only provisions observatory for leastPing / leastLoad
balancers, picking roundRobin with a fallbackTag caused xray to fail
boot with "not all dependencies are resolved". Disable the fallback
field for the two strategies that cannot resolve it, and strip
fallbackTag from the wire balancer as a defensive backstop for users
who edit the JSON template directly.
2026-05-15 11:24:50 +02:00
MHSanaei
79a9be7b22
fix: split locale chunks by removing eager i18n glob
The eager `import.meta.glob` was statically pulling all 13 locale JSON
files into the main bundle, defeating the sibling lazy glob and emitting
INEFFECTIVE_DYNAMIC_IMPORT warnings. Statically import only the en-US
fallback, lazy-load the rest, and await `readyI18n()` in each entry
before mount so the first paint still uses the active locale.
2026-05-15 10:50:40 +02:00
Abdalrahman
19d50bd16c
fix: add i18n translations for Allow private address node option across all locales (#4386)
* fix: add Chinese locale translations for Allow private address node option

* fix: add Allow private address translations to all remaining locale files
2026-05-15 09:51:14 +02:00
MHSanaei
3af45c1462
fix: Add base-path meta tag for Cloudflare Rocket Loader compatibility
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
When Cloudflare Rocket Loader is enabled, it interferes with inline scripts that set window.X_UI_BASE_PATH, causing the frontend to fail to configure the correct base URL for API calls. This results in 404 errors on the login page when calling /getTwoFactorEnable.

Solution: Add meta name='base-path' tag to HTML (similar to csrf-token), update axios initialization to read from meta tag as fallback. Meta tags are not affected by CSP or Rocket Loader delays.

Fixes #4393
2026-05-14 23:37:25 +02:00
MHSanaei
6badd829df
Remove streamSettings for protocols that don't support it
- Frontend: Only include streamSettings in toJson() for vmess, vless, trojan, shadowsocks, and hysteria protocols
- Frontend: Hide Stream tab in Advanced section for unsupported protocols
- Frontend: Clear streamSettings in Advanced tab when switching to unsupported protocols
- Frontend: Add CodeMirror JSON editor to config view in index page with mobile responsive design
- Backend: Add normalizeStreamSettings() to clear streamSettings for tunnel, mixed, http, tun, and wireguard protocols
- Backend: Apply normalization in AddInbound() and UpdateInbound()
- Backend: Add omitempty JSON tag to StreamSettings field to exclude null values from Xray config
2026-05-14 23:18:23 +02:00
MHSanaei
b79abc8bc9
refactor: remove legacy advancedJson state 2026-05-14 20:32:38 +02:00
MHSanaei
05b68c3b13
fix: remove Auth password
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
#4388
2026-05-14 19:28:09 +02:00
Abdalrahman
f3c7660f84
fix: correct Hysteria2 Obfs password label to Auth password (#4388)
The Obfs password field in the Hysteria2 stream settings tab was incorrectly
labeled. It binds to hysteriaSettings.auth (the server-wide authentication
password), not to the salamander obfuscation password. Per Xray-core docs,
Hysteria2 salamander obfuscation belongs in finalmask.udp[].salamander.password,
which is correctly handled by the FinalMaskForm (UDP Masks section).

Fixed the label to Auth password with an accurate tooltip explaining that
salamander obfuscation is configured via the UDP Masks section below.
2026-05-14 18:53:04 +02:00
MHSanaei
9b0fd047cb
fix: guard certificate and key against undefined before join 2026-05-14 17:46:24 +02:00
MHSanaei
e4218a1029
feat: click QR to copy/save image instead of link text 2026-05-14 17:40:40 +02:00
Fedor Batonogov
7065d41be6
docs(readme): add Community Tools section (#4114)
3x-ui has a growing ecosystem of community tools (Terraform, scripts,
exporters, etc.). This adds a Community Tools section between
Acknowledgment and Support project in all 6 localized READMEs so users
can discover them from the main project page.

The format mirrors the existing Acknowledgment section so future
maintainers of 3x-ui-related tools can extend it with one-line PRs.
2026-05-14 15:54:52 +02:00
MHSanaei
1284756f8a
fix(outbound): restore TLS, QUIC params and TCP masks when importing share links
- fromHysteriaLink: parse security= URL param and populate stream.tls
  (SNI, fingerprint, ALPN, ECH) when security=tls; previously always
  forced security to 'none'
- fromHysteriaLink: parse fm JSON param and populate both
  stream.finalmask.quicParams (drives the QUIC Params toggle in
  FinalMaskForm) and the mirrored stream.hysteria fields
- fromParamLink (VLESS/Trojan/SS): parse fm JSON param and restore
  stream.finalmask (TCP masks, UDP masks, QUIC params)
- fromVmessLink (VMess): same fm handling for the base64-JSON path

Closes #4376
2026-05-14 13:27:55 +02:00
MHSanaei
1f052c0e8f
fix: preserve TLS cert file paths when deploying inbound to remote node
When creating a Hysteria (or any TLS-required) inbound from the central
panel and deploying it to a remote node, sanitizeStreamSettingsForRemote
was unconditionally stripping certificateFile / keyFile from the TLS
settings. This left Xray on the remote node with a TLS block containing
no certificate, causing Xray to crash and the inbounds page to hang.

The fix: only strip cert file paths when inline certificate content
(certificate / key arrays) is also present in the same entry — those
file paths are then truly redundant. When only file paths are present
the user explicitly entered paths that live on the remote node's
filesystem; they are now passed through untouched.

Fixes #4370
2026-05-14 12:41:08 +02:00
MHSanaei
ae6f13b533
fix: also hide QR code for ML-KEM-768 links (too long for QR generation) 2026-05-14 12:34:23 +02:00
MHSanaei
1cf2582e6d
fix: hide QR code for mldsa65 links (too long for QR generation) 2026-05-14 12:30:48 +02:00
Abdalrahman
eacb9f63b0
fix: protocol filter placeholder not showing on initial load (#4372) 2026-05-14 12:12:44 +02:00
50 changed files with 1119 additions and 378 deletions

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (الترخيص: **GPL-3.0**): _قواعد توجيه v2ray/xray و v2ray/xray-clients المحسنة مع النطاقات الإيرانية المدمجة وتركيز على الأمان وحظر الإعلانات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (الترخيص: **GPL-3.0**): _يحتوي هذا المستودع على قواعد توجيه V2Ray محدثة تلقائيًا بناءً على بيانات النطاقات والعناوين المحظورة في روسيا._
## أدوات المجتمع
أدوات وتكاملات بناها المجتمع حول 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (الترخيص: **MIT**): _إدارة الاتصالات الواردة والعملاء وإعدادات اللوحة وتكوين Xray كرمز باستخدام Terraform / OpenTofu._
## دعم المشروع
**إذا كان هذا المشروع مفيدًا لك، فقد ترغب في إعطائه**:star2:

View file

@ -39,6 +39,12 @@ Para documentación completa, visita la [Wiki del proyecto](https://github.com/M
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Licencia: **GPL-3.0**): _Reglas de enrutamiento mejoradas para v2ray/xray y v2ray/xray-clients con dominios iraníes incorporados y un enfoque en seguridad y bloqueo de anuncios._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Licencia: **GPL-3.0**): _Este repositorio contiene reglas de enrutamiento V2Ray actualizadas automáticamente basadas en datos de dominios y direcciones bloqueadas en Rusia._
## Herramientas de la Comunidad
Herramientas e integraciones construidas por la comunidad alrededor de 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Licencia: **MIT**): _Gestiona inbounds, clientes, configuración del panel y configuración de Xray como código con Terraform / OpenTofu._
## Apoyar el Proyecto
**Si este proyecto te es útil, puedes darle una**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (مجوز: **GPL-3.0**): _قوانین مسیریابی بهبود یافته v2ray/xray و v2ray/xray-clients با دامنه‌های ایرانی داخلی و تمرکز بر امنیت و مسدود کردن تبلیغات._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (مجوز: **GPL-3.0**): _این مخزن شامل قوانین مسیریابی V2Ray به‌روزرسانی شده خودکار بر اساس داده‌های دامنه‌ها و آدرس‌های مسدود شده در روسیه است._
## ابزارهای جامعه
ابزارها و یکپارچه‌سازی‌هایی که توسط جامعه پیرامون 3x-ui ساخته شده‌اند.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (مجوز: **MIT**): _مدیریت اینباندها، کلاینت‌ها، تنظیمات پنل و پیکربندی Xray به‌صورت کد با Terraform / OpenTofu._
## پشتیبانی از پروژه
**اگر این پروژه برای شما مفید است، می‌توانید به آن یک**:star2: بدهید

View file

@ -39,6 +39,12 @@ For full documentation, please visit the [project Wiki](https://github.com/MHSan
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (License: **GPL-3.0**): _Enhanced v2ray/xray and v2ray/xray-clients routing rules with built-in Iranian domains and a focus on security and adblocking._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (License: **GPL-3.0**): _This repository contains automatically updated V2Ray routing rules based on data on blocked domains and addresses in Russia._
## Community Tools
Tools and integrations built by the community around 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (License: **MIT**): _Manage inbounds, clients, panel settings, and Xray configuration as code with Terraform / OpenTofu._
## Support project
**If this project is helpful to you, you may wish to give it a**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Лицензия: **GPL-3.0**): _Улучшенные правила маршрутизации для v2ray/xray и v2ray/xray-clients со встроенными иранскими доменами и фокусом на безопасность и блокировку рекламы._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Лицензия: **GPL-3.0**): _Этот репозиторий содержит автоматически обновляемые правила маршрутизации V2Ray на основе данных о заблокированных доменах и адресах в России._
## Инструменты сообщества
Инструменты и интеграции, созданные сообществом вокруг 3x-ui.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Лицензия: **MIT**): _Управление входящими, клиентами, настройками панели и конфигурацией Xray через код с помощью Terraform / OpenTofu._
## Поддержка проекта
**Если этот проект полезен для вас, вы можете поставить ему**:star2:

View file

@ -39,6 +39,12 @@ bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (许可证: **GPL-3.0**): _增强的 v2ray/xray 和 v2ray/xray-clients 路由规则内置伊朗域名专注于安全性和广告拦截。_
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (许可证: **GPL-3.0**): _此仓库包含基于俄罗斯被阻止域名和地址数据自动更新的 V2Ray 路由规则。_
## 社区工具
社区围绕 3x-ui 构建的工具和集成。
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (许可证: **MIT**): _使用 Terraform / OpenTofu 通过代码管理入站、客户端、面板设置和 Xray 配置。_
## 支持项目
**如果这个项目对您有帮助,您可以给它一个**:star2:

View file

@ -210,9 +210,9 @@
}
},
"node_modules/@codemirror/view": {
"version": "6.42.1",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz",
"integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==",
"version": "6.43.0",
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.0.tgz",
"integrity": "sha512-V7ZCLQO3Jus9hzh2jVCCPW3mO4IBMr43O37PqSUYautJSnnJF41YlgLw21x0fLJTYvJ+Vkm6Gp+qKGH9pltgXA==",
"license": "MIT",
"dependencies": {
"@codemirror/state": "^6.6.0",
@ -610,9 +610,9 @@
}
},
"node_modules/@oxc-project/types": {
"version": "0.129.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
"version": "0.130.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
"dev": true,
"license": "MIT",
"funding": {
@ -620,9 +620,9 @@
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
"cpu": [
"arm64"
],
@ -637,9 +637,9 @@
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
"cpu": [
"arm64"
],
@ -654,9 +654,9 @@
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
"cpu": [
"x64"
],
@ -671,9 +671,9 @@
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
"cpu": [
"x64"
],
@ -688,9 +688,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
"cpu": [
"arm"
],
@ -705,9 +705,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
"cpu": [
"arm64"
],
@ -725,9 +725,9 @@
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
"cpu": [
"arm64"
],
@ -745,9 +745,9 @@
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
"cpu": [
"ppc64"
],
@ -765,9 +765,9 @@
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
"cpu": [
"s390x"
],
@ -785,9 +785,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
"cpu": [
"x64"
],
@ -805,9 +805,9 @@
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
"cpu": [
"x64"
],
@ -825,9 +825,9 @@
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
"cpu": [
"arm64"
],
@ -842,9 +842,9 @@
}
},
"node_modules/@rolldown/binding-wasm32-wasi": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
"cpu": [
"wasm32"
],
@ -861,9 +861,9 @@
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
"cpu": [
"arm64"
],
@ -878,9 +878,9 @@
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
"cpu": [
"x64"
],
@ -2715,14 +2715,14 @@
"license": "MIT"
},
"node_modules/rolldown": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@oxc-project/types": "=0.129.0",
"@rolldown/pluginutils": "1.0.0"
"@oxc-project/types": "=0.130.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
@ -2731,27 +2731,27 @@
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm64": "1.0.0",
"@rolldown/binding-darwin-arm64": "1.0.0",
"@rolldown/binding-darwin-x64": "1.0.0",
"@rolldown/binding-freebsd-x64": "1.0.0",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
"@rolldown/binding-linux-arm64-musl": "1.0.0",
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
"@rolldown/binding-linux-x64-gnu": "1.0.0",
"@rolldown/binding-linux-x64-musl": "1.0.0",
"@rolldown/binding-openharmony-arm64": "1.0.0",
"@rolldown/binding-wasm32-wasi": "1.0.0",
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
"@rolldown/binding-win32-x64-msvc": "1.0.0"
"@rolldown/binding-android-arm64": "1.0.1",
"@rolldown/binding-darwin-arm64": "1.0.1",
"@rolldown/binding-darwin-x64": "1.0.1",
"@rolldown/binding-freebsd-x64": "1.0.1",
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
"@rolldown/binding-linux-arm64-musl": "1.0.1",
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
"@rolldown/binding-linux-x64-gnu": "1.0.1",
"@rolldown/binding-linux-x64-musl": "1.0.1",
"@rolldown/binding-openharmony-arm64": "1.0.1",
"@rolldown/binding-wasm32-wasi": "1.0.1",
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
"@rolldown/binding-win32-x64-msvc": "1.0.1"
}
},
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT"
},
@ -2964,16 +2964,16 @@
"license": "MIT"
},
"node_modules/vite": {
"version": "8.0.12",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
"version": "8.0.13",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
"dev": true,
"license": "MIT",
"dependencies": {
"lightningcss": "^1.32.0",
"picomatch": "^4.0.4",
"postcss": "^8.5.14",
"rolldown": "1.0.0",
"rolldown": "1.0.1",
"tinyglobby": "^0.2.16"
},
"bin": {

View file

@ -51,7 +51,12 @@ export function setupAxios() {
axios.defaults.headers.post['Content-Type'] = 'application/x-www-form-urlencoded; charset=UTF-8';
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
const basePath = window.X_UI_BASE_PATH;
// Read base path from window object or fallback to meta tag (for Cloudflare Rocket Loader compatibility)
let basePath = window.X_UI_BASE_PATH;
if (!basePath) {
const metaTag = document.querySelector('meta[name="base-path"]');
basePath = metaTag ? metaTag.getAttribute('content') : null;
}
if (typeof basePath === 'string' && basePath !== '' && basePath !== '/') {
axios.defaults.baseURL = basePath;
}

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import ApiDocsPage from '@/pages/api-docs/ApiDocsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(ApiDocsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import InboundsPage from '@/pages/inbounds/InboundsPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(InboundsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import IndexPage from '@/pages/index/IndexPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(IndexPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,18 +6,18 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing this module triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue renders anything.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import LoginPage from '@/pages/login/LoginPage.vue';
setupAxios();
applyDocumentTitle();
// Toasts attach to a #message div the page provides — keeps theme
// styling in sync with the rest of the panel.
const messageContainer = document.getElementById('message');
if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(LoginPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import NodesPage from '@/pages/nodes/NodesPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(NodesPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -6,7 +6,7 @@ import { setupAxios } from '@/api/axios-init.js';
// Importing useTheme triggers the boot side-effect that applies the
// stored theme to <body>/<html> before Vue mounts.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import SettingsPage from '@/pages/settings/SettingsPage.vue';
@ -18,4 +18,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(SettingsPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -7,7 +7,7 @@ import 'ant-design-vue/dist/reset.css';
// with the parsed traffic/quota/expiry view-model and the rendered
// share links — the SPA reads those at mount.
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import SubPage from '@/pages/sub/SubPage.vue';
const messageContainer = document.getElementById('message');
@ -15,4 +15,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(SubPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(SubPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -4,7 +4,7 @@ import 'ant-design-vue/dist/reset.css';
import { setupAxios } from '@/api/axios-init.js';
import '@/composables/useTheme.js';
import { i18n } from '@/i18n/index.js';
import { i18n, readyI18n } from '@/i18n/index.js';
import { applyDocumentTitle } from '@/utils';
import XrayPage from '@/pages/xray/XrayPage.vue';
@ -16,4 +16,6 @@ if (messageContainer) {
message.config({ getContainer: () => messageContainer });
}
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
readyI18n().then(() => {
createApp(XrayPage).use(Antd).use(i18n).mount('#app');
});

View file

@ -1,93 +1,54 @@
// vue-i18n setup. Locale files live in web/translation/*.json — the same
// directory the Go binary embeds, so SPA + Telegram bot + subscription
// page all read from a single source.
//
// Usage in a component:
// import { useI18n } from 'vue-i18n';
// const { t } = useI18n();
// ...
// <span>{{ t('pages.inbounds.email') }}</span>
//
// Or via the global helper exposed on the app:
// <span>{{ $t('pages.inbounds.email') }}</span>
//
// The locale follows the `lang` cookie that LanguageManager already
// reads/writes — switching language anywhere in the app continues to
// trigger a full page reload (matches legacy ergonomics), so we don't
// need a runtime locale switcher here.
import { createI18n } from 'vue-i18n';
import { LanguageManager } from '@/utils';
import enUS from '../../../web/translation/en-US.json';
// Lazy-loaded locales — Vite splits each one into its own chunk. We
// eager-load only the active language plus the en-US fallback so the
// initial page payload stays small (the inbounds bundle was sitting
// at ~700kB gzipped with all 13 locales eager; now ~480kB).
//
// LanguageManager.setLanguage() does a full reload on change, so
// "lazy" here effectively means "load only what this page needs for
// its lifetime."
const FALLBACK = 'en-US';
const lazyModules = import.meta.glob('../../../web/translation/*.json');
const eagerModules = import.meta.glob('../../../web/translation/*.json', { eager: true });
const lazyModules = import.meta.glob([
'../../../web/translation/*.json',
'!../../../web/translation/en-US.json',
]);
function moduleKeyFor(code) {
return `../../../web/translation/${code}.json`;
}
// Resolve the active locale via LanguageManager so the cookie set on
// the legacy panel keeps working after a user upgrades. Falls back
// to en-US when the cookie names a language we don't have.
let active = LanguageManager.getLanguage();
if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
if (active !== FALLBACK && !Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) {
active = FALLBACK;
}
const messages = {};
// Eagerly include the active locale + the fallback (when distinct)
// so the very first render has strings ready. Vite still emits these
// as their own chunks so the user pays for at most two locales.
for (const code of new Set([active, FALLBACK])) {
const mod = eagerModules[moduleKeyFor(code)];
if (mod) messages[code] = mod.default || mod;
}
export const i18n = createI18n({
legacy: false,
// `composition` mode (legacy: false) so `useI18n()` works in
// <script setup> blocks.
globalInjection: true,
locale: active,
fallbackLocale: FALLBACK,
// Locale JSON is nested by namespace ({pages: {inbounds: {email: ...}}})
// so vue-i18n's default `.`-delimited lookups walk straight into it.
messages,
// The Go side sometimes interpolates `#variable#` into translated
// strings (e.g. xraySwitchVersionDialogDesc). vue-i18n's default
// expects `{var}` — disable warnings about strings that look like
// they don't use the new syntax.
messages: { [FALLBACK]: enUS },
warnHtmlMessage: false,
missingWarn: false,
fallbackWarn: false,
});
// Convenience export for non-component contexts (HTTP error toasts,
// stores, etc.) that need to look up a translation outside a setup
// scope.
export function t(key, params) {
return i18n.global.t(key, params || {});
}
// loadLocale fetches a locale module on demand and registers it with
// vue-i18n. Pages that switch language at runtime (rather than via
// LanguageManager's reload) can call this to swap strings live.
export async function loadLocale(code) {
const key = moduleKeyFor(code);
const loader = lazyModules[key];
if (code === FALLBACK) {
i18n.global.locale.value = FALLBACK;
return true;
}
const loader = lazyModules[moduleKeyFor(code)];
if (!loader) return false;
const mod = await loader();
i18n.global.setLocaleMessage(code, mod.default || mod);
i18n.global.locale.value = code;
return true;
}
export async function readyI18n() {
if (active !== FALLBACK) {
await loadLocale(active);
}
return i18n;
}

View file

@ -827,8 +827,8 @@ TlsStreamSettings.Cert = class extends XrayCommonClass {
} else {
return new TlsStreamSettings.Cert(
false, '', '',
json.certificate.join('\n'),
json.key.join('\n'),
Array.isArray(json.certificate) ? json.certificate.join('\n') : (json.certificate ?? ''),
Array.isArray(json.key) ? json.key.join('\n') : (json.key ?? ''),
json.oneTimeLoading,
json.usage,
json.buildChain,
@ -2412,20 +2412,25 @@ export class Inbound extends XrayCommonClass {
}
toJson() {
let streamSettings;
if (this.canEnableStream() || this.stream?.sockopt) {
streamSettings = this.stream.toJson();
}
return {
// Only these protocols use streamSettings
const streamProtocols = [Protocols.VLESS, Protocols.VMESS, Protocols.TROJAN, Protocols.SHADOWSOCKS, Protocols.HYSTERIA];
const result = {
port: this.port,
listen: this.listen,
protocol: this.protocol,
settings: this.settings instanceof XrayCommonClass ? this.settings.toJson() : this.settings,
streamSettings: streamSettings,
tag: this.tag,
sniffing: this.sniffing.toJson(),
clientStats: this.clientStats
};
// Only add streamSettings if protocol supports it
if (streamProtocols.includes(this.protocol)) {
result.streamSettings = this.stream.toJson();
}
return result;
}
}

View file

@ -1397,6 +1397,13 @@ export class Outbound extends CommonClass {
const port = json.port * 1;
// Parse fm (finalmask) JSON string — TCP/UDP masks + QUIC params from 3x-ui share links
if (json.fm) {
try {
stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(json.fm));
} catch (_) { /* ignore malformed fm */ }
}
return new Outbound(json.ps, Protocols.VMess, new Outbound.VmessSettings(json.add, port, json.id, json.scy), stream);
}
@ -1496,6 +1503,14 @@ export class Outbound extends CommonClass {
default:
return null;
}
// Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links
const fmRaw = url.searchParams.get('fm');
if (fmRaw) {
try {
stream.finalmask = FinalMaskStreamSettings.fromJson(JSON.parse(fmRaw));
} catch (_) { /* ignore malformed fm */ }
}
let remark = decodeURIComponent(url.hash);
// Remove '#' from url.hash
remark = remark.length > 0 ? remark.substring(1) : 'out-' + protocol + '-' + port;
@ -1516,7 +1531,17 @@ export class Outbound extends CommonClass {
let urlParams = new URLSearchParams(params);
// Create stream settings with hysteria network
let stream = new StreamSettings('hysteria', 'none');
let security = urlParams.get('security') ?? 'none';
let stream = new StreamSettings('hysteria', security);
// Parse TLS settings when security=tls
if (security === 'tls') {
let fp = urlParams.get('fp') ?? 'none';
let alpn = urlParams.get('alpn');
let sni = urlParams.get('sni') ?? '';
let ech = urlParams.get('ech') ?? '';
stream.tls = new TlsStreamSettings(sni, alpn ? alpn.split(',') : [], fp, ech);
}
// Set hysteria stream settings
stream.hysteria.auth = password;
@ -1534,7 +1559,7 @@ export class Outbound extends CommonClass {
stream.hysteria.udphopIntervalMax = parseInt(urlParams.get('udphopIntervalMax') ?? '30');
}
// Optional QUIC parameters
// Optional QUIC parameters for FinalMask support and hysteria2 share links
if (urlParams.has('initStreamReceiveWindow')) {
stream.hysteria.initStreamReceiveWindow = parseInt(urlParams.get('initStreamReceiveWindow'));
}
@ -1557,6 +1582,38 @@ export class Outbound extends CommonClass {
stream.hysteria.disablePathMTUDiscovery = urlParams.get('disablePathMTUDiscovery') === 'true';
}
// Parse fm (finalmask) JSON param — TCP/UDP masks + QUIC params from 3x-ui share links, with special handling to mirror QUIC params into both stream.finalmask and stream.hysteria
const fmRaw = urlParams.get('fm');
if (fmRaw) {
try {
const fm = JSON.parse(fmRaw);
const qp = fm.quicParams;
if (qp && typeof qp === 'object') {
// Populate stream.finalmask.quicParams — this enables the "QUIC Params"
// toggle in FinalMaskForm and carries all QUIC tuning settings.
stream.finalmask.quicParams = QuicParams.fromJson(qp);
// Also mirror the overlapping fields into stream.hysteria so the
// Hysteria transport section of the form shows consistent values.
if (qp.congestion) stream.hysteria.congestion = qp.congestion;
if (Number.isInteger(qp.initStreamReceiveWindow)) stream.hysteria.initStreamReceiveWindow = qp.initStreamReceiveWindow;
if (Number.isInteger(qp.maxStreamReceiveWindow)) stream.hysteria.maxStreamReceiveWindow = qp.maxStreamReceiveWindow;
if (Number.isInteger(qp.initConnectionReceiveWindow)) stream.hysteria.initConnectionReceiveWindow = qp.initConnectionReceiveWindow;
if (Number.isInteger(qp.maxConnectionReceiveWindow)) stream.hysteria.maxConnectionReceiveWindow = qp.maxConnectionReceiveWindow;
if (Number.isInteger(qp.maxIdleTimeout)) stream.hysteria.maxIdleTimeout = qp.maxIdleTimeout;
if (Number.isInteger(qp.keepAlivePeriod)) stream.hysteria.keepAlivePeriod = qp.keepAlivePeriod;
if (qp.disablePathMTUDiscovery === true) stream.hysteria.disablePathMTUDiscovery = true;
if (qp.udpHop) {
stream.hysteria.udphopPort = qp.udpHop.ports ?? stream.hysteria.udphopPort;
if (qp.udpHop.interval !== undefined) {
stream.hysteria.udphopIntervalMin = qp.udpHop.interval;
stream.hysteria.udphopIntervalMax = qp.udpHop.interval;
}
}
}
} catch (_) { /* ignore malformed fm */ }
}
// Create settings
let settings = new Outbound.HysteriaSettings(address, port, 2);

View file

@ -70,8 +70,11 @@ const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const inbound = ref(null);
const dbForm = ref(null);
const saving = ref(false);
const advancedJson = ref({ stream: '', sniffing: '', settings: '' });
const advancedStreamText = ref('');
const advancedSniffingText = ref('');
const advancedSettingsText = ref('');
const activeTabKey = ref('basic');
const advancedSectionKey = ref('all');
// Cached default cert/key paths from /panel/setting/defaultSettings
// powers the "Set default cert" button on the TLS form.
const defaultCert = ref('');
@ -223,15 +226,7 @@ function freshDbForm() {
function primeAdvancedJson() {
if (!inbound.value) return;
try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
['stream', 'sniffing', 'settings'].forEach(stampAdvancedTextFor);
}
watch(() => props.open, (next) => {
@ -244,39 +239,28 @@ watch(() => props.open, (next) => {
primeAdvancedJson();
}
activeTabKey.value = 'basic';
advancedSectionKey.value = 'all';
fetchDefaultCertSettings();
});
function applyAdvancedJsonToBasic() {
if (!inbound.value) return true;
let parsedSettings;
let parsedStream;
let parsedSniffing;
let settings; let streamSettings; let sniffing;
try {
parsedSettings = advancedJson.value.settings.trim()
? JSON.parse(advancedJson.value.settings)
: inbound.value.settings?.toJson?.();
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return false; }
try {
parsedStream = advancedJson.value.stream.trim()
? JSON.parse(advancedJson.value.stream)
: inbound.value.stream?.toJson?.();
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return false; }
try {
parsedSniffing = advancedJson.value.sniffing.trim()
? JSON.parse(advancedJson.value.sniffing)
: inbound.value.sniffing?.toJson?.();
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return false; }
settings = parseAdvancedSliceWithLabel(advancedSettingsText.value, settingsFallback(), 'Settings');
streamSettings = parseAdvancedSliceWithLabel(advancedStreamText.value, streamFallback(), 'Stream');
sniffing = parseAdvancedSliceWithLabel(advancedSniffingText.value, sniffingFallback(), 'Sniffing');
} catch (_e) { return false; }
try {
inbound.value = Inbound.fromJson({
port: inbound.value.port,
listen: inbound.value.listen,
protocol: inbound.value.protocol,
settings: parsedSettings,
streamSettings: parsedStream,
settings,
streamSettings,
tag: inbound.value.tag,
sniffing: parsedSniffing,
sniffing,
clientStats: inbound.value.clientStats,
});
} catch (e) {
@ -324,6 +308,181 @@ function onNetworkChange(next) {
}
}
function parseAdvancedSliceOrFallback(rawText, fallbackValue) {
if (!rawText?.trim()) return fallbackValue;
return JSON.parse(rawText);
}
function unwrapWrappedObject(parsed, key) {
if (
parsed
&& typeof parsed === 'object'
&& !Array.isArray(parsed)
&& parsed[key] !== undefined
) {
return parsed[key];
}
return parsed;
}
const settingsFallback = () => inbound.value?.settings?.toJson?.() || {};
const sniffingFallback = () => inbound.value?.sniffing?.toJson?.() || {};
const streamFallback = () => inbound.value?.stream?.toJson?.() || {};
const advancedTextRefs = {
stream: advancedStreamText,
sniffing: advancedSniffingText,
settings: advancedSettingsText,
};
function stampAdvancedTextFor(slice) {
const textRef = advancedTextRefs[slice];
if (!textRef) return;
if (slice === 'stream' && !canEnableStream.value) {
textRef.value = '{}';
return;
}
const obj = inbound.value?.[slice];
if (!obj) return;
try {
textRef.value = JSON.stringify(JSON.parse(obj.toString()), null, 2);
} catch (_e) { /* keep prior text */ }
}
function parseAdvancedSliceWithLabel(rawText, fallback, label) {
try {
return parseAdvancedSliceOrFallback(rawText, fallback);
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
function compactAdvancedJson(raw, fallback, label) {
try {
return JSON.stringify(JSON.parse(raw || fallback));
} catch (e) {
message.error(`${label} JSON invalid: ${e.message}`);
throw e;
}
}
async function withSaving(fn) {
saving.value = true;
try { return await fn(); } finally { saving.value = false; }
}
function makeWrappedAdvancedConfig({ key, textRef, getFallback, label }) {
const invalid = `${label} JSON invalid`;
return computed({
get: () => {
if (!inbound.value) return '';
try {
const value = parseAdvancedSliceOrFallback(textRef.value, getFallback());
return JSON.stringify({ [key]: value }, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, key);
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
return;
}
try {
textRef.value = JSON.stringify(unwrapped, null, 2);
} catch (e) {
message.error(`${invalid}: ${e.message}`);
}
},
});
}
const advancedAllConfig = computed({
get: () => {
if (!inbound.value) return '';
try {
const result = {
listen: inbound.value.listen,
port: inbound.value.port,
protocol: inbound.value.protocol,
settings: parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback()),
sniffing: parseAdvancedSliceOrFallback(advancedSniffingText.value, sniffingFallback()),
tag: inbound.value.tag,
};
if (canEnableStream.value) {
result.streamSettings = parseAdvancedSliceOrFallback(advancedStreamText.value, streamFallback());
}
return JSON.stringify(result, null, 2);
} catch (_e) {
return '';
}
},
set: (next) => {
let parsed;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
return;
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('All JSON must be an inbound object.');
return;
}
try {
if (typeof parsed.listen === 'string') inbound.value.listen = parsed.listen;
if (parsed.port !== undefined) {
const port = Number(parsed.port);
if (Number.isFinite(port)) inbound.value.port = port;
}
if (typeof parsed.protocol === 'string' && PROTOCOLS.includes(parsed.protocol)) {
inbound.value.protocol = parsed.protocol;
}
if (typeof parsed.tag === 'string') inbound.value.tag = parsed.tag;
const existingSettings = parseAdvancedSliceOrFallback(advancedSettingsText.value, settingsFallback());
advancedSettingsText.value = JSON.stringify(parsed.settings ?? existingSettings, null, 2);
advancedSniffingText.value = JSON.stringify(parsed.sniffing ?? sniffingFallback(), null, 2);
advancedStreamText.value = canEnableStream.value
? JSON.stringify(parsed.streamSettings ?? streamFallback(), null, 2)
: '{}';
} catch (e) {
message.error(`All JSON invalid: ${e.message}`);
}
},
});
const advancedSettingsConfig = makeWrappedAdvancedConfig({
key: 'settings',
textRef: advancedSettingsText,
getFallback: settingsFallback,
label: 'Settings',
});
const advancedSniffingConfig = makeWrappedAdvancedConfig({
key: 'sniffing',
textRef: advancedSniffingText,
getFallback: sniffingFallback,
label: 'Sniffing',
});
const advancedStreamConfig = makeWrappedAdvancedConfig({
key: 'streamSettings',
textRef: advancedStreamText,
getFallback: streamFallback,
label: 'Stream',
});
// === Random helpers wired to the form's sync icons ==================
function randomEmail(target) {
if (target) target.email = RandomUtil.randomLowerAndNum(9);
@ -356,16 +515,13 @@ function regenInboundWg() {
// === Reality keygen via existing API =================================
async function genRealityKeypair() {
saving.value = true;
try {
await withSaving(async () => {
const msg = await HttpUtil.get('/panel/api/server/getNewX25519Cert');
if (msg?.success) {
inbound.value.stream.reality.privateKey = msg.obj.privateKey;
inbound.value.stream.reality.settings.publicKey = msg.obj.publicKey;
}
} finally {
saving.value = false;
}
});
}
function clearRealityKeypair() {
@ -375,16 +531,13 @@ function clearRealityKeypair() {
}
async function genMldsa65() {
saving.value = true;
try {
await withSaving(async () => {
const msg = await HttpUtil.get('/panel/api/server/getNewmldsa65');
if (msg?.success) {
inbound.value.stream.reality.mldsa65Seed = msg.obj.seed;
inbound.value.stream.reality.settings.mldsa65Verify = msg.obj.verify;
}
} finally {
saving.value = false;
}
});
}
function clearMldsa65() {
@ -408,8 +561,7 @@ function randomizeShortIds() {
// === ECH cert helpers ================================================
async function getNewEchCert() {
if (!inbound.value?.stream?.tls) return;
saving.value = true;
try {
await withSaving(async () => {
const msg = await HttpUtil.post('/panel/api/server/getNewEchCert', {
sni: inbound.value.stream.tls.sni,
});
@ -417,9 +569,7 @@ async function getNewEchCert() {
inbound.value.stream.tls.echServerKeys = msg.obj.echServerKeys;
inbound.value.stream.tls.settings.echConfigList = msg.obj.echConfigList;
}
} finally {
saving.value = false;
}
});
}
function clearEchCert() {
@ -463,17 +613,14 @@ function matchesVlessAuth(block, authId) {
async function getNewVlessEnc(authId) {
if (!authId || !inbound.value?.settings) return;
saving.value = true;
try {
await withSaving(async () => {
const msg = await HttpUtil.get('/panel/api/server/getNewVlessEnc');
if (!msg?.success) return;
const block = (msg.obj?.auths || []).find((a) => matchesVlessAuth(a, authId));
if (!block) return;
inbound.value.settings.decryption = block.decryption;
inbound.value.settings.encryption = block.encryption;
} finally {
saving.value = false;
}
});
}
function clearVlessEnc() {
@ -518,24 +665,16 @@ async function submit() {
if (!inbound.value || !dbForm.value) return;
saving.value = true;
try {
// Sniffing tab is structured; stream stays JSON for unsupported
// transports both go to wire as serialized JSON.
let streamSettings;
let sniffing;
let settings;
let streamSettings; let sniffing; let settings;
try {
streamSettings = canEnableStream.value
? JSON.stringify(JSON.parse(advancedJson.value.stream))
? compactAdvancedJson(advancedStreamText.value, '', 'Stream')
: (inbound.value.stream?.sockopt
? JSON.stringify({ sockopt: inbound.value.stream.sockopt.toJson() })
: '');
} catch (e) { message.error(`Stream JSON invalid: ${e.message}`); return; }
try {
sniffing = JSON.stringify(JSON.parse(advancedJson.value.sniffing || inbound.value.sniffing.toString()));
} catch (e) { message.error(`Sniffing JSON invalid: ${e.message}`); return; }
try {
settings = JSON.stringify(JSON.parse(advancedJson.value.settings || inbound.value.settings.toString()));
} catch (e) { message.error(`Settings JSON invalid: ${e.message}`); return; }
sniffing = compactAdvancedJson(advancedSniffingText.value, inbound.value.sniffing.toString(), 'Sniffing');
settings = compactAdvancedJson(advancedSettingsText.value, inbound.value.settings.toString(), 'Settings');
} catch (_e) { return; }
// The structured form mutates `inbound.stream` directly when the
// user edits TCP/WS/gRPC/HTTPUpgrade fields, but if they touched
@ -591,35 +730,15 @@ const okText = computed(() =>
// Whenever the structured form mutates stream / sniffing / settings,
// refresh the matching slice of the Advanced JSON tab so the user
// always sees the live state flipping a switch in Sniffing or
// editing encryption in Protocol now reflects in Advanced.
watch(
() => inbound.value && JSON.stringify(inbound.value.stream?.toJson?.() || {}),
() => {
if (!inbound.value?.stream) return;
try {
advancedJson.value.stream = JSON.stringify(JSON.parse(inbound.value.stream.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.sniffing?.toJson?.() || {}),
() => {
if (!inbound.value?.sniffing) return;
try {
advancedJson.value.sniffing = JSON.stringify(JSON.parse(inbound.value.sniffing.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
watch(
() => inbound.value && JSON.stringify(inbound.value.settings?.toJson?.() || {}),
() => {
if (!inbound.value?.settings) return;
try {
advancedJson.value.settings = JSON.stringify(JSON.parse(inbound.value.settings.toString()), null, 2);
} catch (_e) { /* leave as is */ }
},
);
// always sees the live state.
['stream', 'sniffing', 'settings'].forEach((slice) => {
watch(
() => inbound.value && JSON.stringify(inbound.value[slice]?.toJson?.() || {}),
() => stampAdvancedTextFor(slice),
);
});
watch(() => inbound.value?.protocol, () => stampAdvancedTextFor('stream'));
</script>
<template>
@ -1005,7 +1124,7 @@ watch(
<a-form-item>
<template #label>
<a-tooltip
title='Physical interface for outbound traffic. Use "auto" to detect; auto-enabled when Auto system routes is set.'>
title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">
Auto outbounds interface
</a-tooltip>
</template>
@ -1834,14 +1953,6 @@ watch(
</template>
<a-input-number v-model:value="inbound.stream.hysteria.version" :min="2" :max="2" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Obfuscation password. Must match between server and client.">
Obfs password
</a-tooltip>
</template>
<a-input v-model:value="inbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item>
<template #label>
<a-tooltip title="Idle timeout (seconds) for a single QUIC native UDP connection.">
@ -1952,20 +2063,48 @@ watch(
<!-- ============================== ADVANCED ============================== -->
<a-tab-pane key="advanced" :tab="t('pages.xray.advancedTemplate')">
<a-alert type="info" show-icon
message="Edit raw stream JSON to access advanced fields we don't yet expose through the form."
class="mb-12" />
<a-form layout="vertical">
<a-form-item label="settings (clients, encryption, fallbacks, …)">
<JsonEditor v-model:value="advancedJson.settings" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="streamSettings">
<JsonEditor v-model:value="advancedJson.stream" min-height="280px" max-height="520px" />
</a-form-item>
<a-form-item label="sniffing (overrides the Sniffing tab when set)">
<JsonEditor v-model:value="advancedJson.sniffing" min-height="180px" max-height="360px" />
</a-form-item>
</a-form>
<div class="advanced-shell">
<div class="advanced-panel">
<div class="advanced-panel__header">
<div>
<div class="advanced-panel__title">Inbound JSON sections</div>
<div class="advanced-panel__subtitle">
Full inbound JSON and focused editors for settings, sniffing, and streamSettings.
</div>
</div>
</div>
<a-tabs v-model:active-key="advancedSectionKey" class="advanced-inner-tabs">
<a-tab-pane key="all" tab="All">
<div class="advanced-editor-meta">
Full inbound object with all fields in one editor.
</div>
<JsonEditor v-model:value="advancedAllConfig" min-height="340px" max-height="560px" />
</a-tab-pane>
<a-tab-pane key="settings" tab="Settings">
<div class="advanced-editor-meta">
Xray settings block wrapper:
<code>{ settings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSettingsConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
<a-tab-pane key="sniffingSection" tab="Sniffing">
<div class="advanced-editor-meta">
Xray sniffing block wrapper:
<code>{ sniffing: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedSniffingConfig" min-height="240px" max-height="420px" />
</a-tab-pane>
<a-tab-pane v-if="canEnableStream" key="streamSection" tab="Stream">
<div class="advanced-editor-meta">
Xray stream block wrapper:
<code>{ streamSettings: { ... } }</code>.
</div>
<JsonEditor v-model:value="advancedStreamConfig" min-height="320px" max-height="540px" />
</a-tab-pane>
</a-tabs>
</div>
</div>
</a-tab-pane>
</a-tabs>
</a-modal>
@ -2041,6 +2180,66 @@ watch(
margin-top: 4px;
}
.advanced-shell {
display: flex;
flex-direction: column;
gap: 12px;
}
.advanced-panel {
padding: 14px;
border: 1px solid rgba(128, 128, 128, 0.18);
border-radius: 12px;
background: rgba(128, 128, 128, 0.04);
}
.advanced-panel__header {
margin-bottom: 12px;
}
.advanced-panel__title {
font-size: 14px;
font-weight: 600;
line-height: 1.4;
}
.advanced-panel__subtitle {
margin-top: 4px;
opacity: 0.7;
line-height: 1.5;
}
.advanced-inner-tabs :deep(.ant-tabs-nav) {
margin-bottom: 12px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 14px;
}
.advanced-editor-meta {
margin-bottom: 10px;
opacity: 0.75;
line-height: 1.5;
}
@media (max-width: 768px) {
.advanced-panel {
padding: 12px;
border-radius: 10px;
}
.advanced-inner-tabs :deep(.ant-tabs-tab) {
padding-inline: 10px;
}
}
:global(body.dark) .advanced-panel,
:global(html[data-theme='ultra-dark']) .advanced-panel {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.03);
}
.section-heading {
font-weight: 500;
margin: 12px 0 6px;

View file

@ -80,7 +80,7 @@ const savedFilterState = (() => {
const enableFilter = ref(!!savedFilterState.enableFilter);
const searchKey = ref(savedFilterState.searchKey || '');
const filterBy = ref(savedFilterState.filterBy || '');
const protocolFilter = ref(savedFilterState.protocolFilter || '');
const protocolFilter = ref(savedFilterState.protocolFilter || undefined);
const nodeFilter = ref(savedFilterState.nodeFilter || '');
watch([enableFilter, searchKey, filterBy, protocolFilter, nodeFilter], () => {

View file

@ -111,7 +111,8 @@ function close() {
<template v-if="dbInbound">
<a-collapse v-model:active-key="activeKeys" ghost class="qr-collapse">
<a-collapse-panel v-for="item in qrItems" :key="item.key" :header="item.header">
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''" />
<QrPanel :value="item.value" :remark="item.header" :download-name="item.downloadName || ''"
:show-qr="!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')" />
</a-collapse-panel>
</a-collapse>
</template>

View file

@ -1,6 +1,7 @@
<script setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { CopyOutlined, DownloadOutlined } from '@ant-design/icons-vue';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons-vue';
import { message } from 'ant-design-vue';
import { ClipboardManager, FileManager } from '@/utils';
@ -15,6 +16,8 @@ const props = defineProps({
showQr: { type: Boolean, default: true },
});
const qrRef = ref(null);
async function copy() {
const ok = await ClipboardManager.copyText(props.value);
if (ok) message.success(t('copied'));
@ -24,6 +27,55 @@ function download() {
if (!props.downloadName) return;
FileManager.downloadTextFile(props.value, props.downloadName);
}
function svgToPngBlob(size = 360) {
const svgEl = qrRef.value?.querySelector('svg');
if (!svgEl) return Promise.resolve(null);
const svgData = new XMLSerializer().serializeToString(svgEl);
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
const url = URL.createObjectURL(svgBlob);
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, size, size);
ctx.drawImage(img, 0, 0, size, size);
URL.revokeObjectURL(url);
canvas.toBlob(resolve, 'image/png');
};
img.onerror = () => { URL.revokeObjectURL(url); resolve(null); };
img.src = url;
});
}
async function copyImage() {
const blob = await svgToPngBlob(props.size);
if (!blob) return;
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied'));
} catch {
downloadImageBlob(blob);
}
}
function downloadImageBlob(blob) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${props.remark || 'qrcode'}.png`;
link.click();
URL.revokeObjectURL(url);
}
async function downloadImage() {
const blob = await svgToPngBlob(props.size);
if (blob) downloadImageBlob(blob);
}
</script>
<template>
@ -37,6 +89,13 @@ function download() {
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="showQr" :title="t('downloadImage', 'Download Image')">
<a-button size="small" @click="downloadImage">
<template #icon>
<PictureOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip v-if="downloadName" :title="t('download')">
<a-button size="small" @click="download">
<template #icon>
@ -45,9 +104,11 @@ function download() {
</a-button>
</a-tooltip>
</div>
<div v-if="showQr" class="qr-panel-canvas">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false"
color="#000000" bg-color="#ffffff" :title="t('copy')" @click="copy" />
<div v-if="showQr" ref="qrRef" class="qr-panel-canvas">
<a-tooltip :title="t('copy')">
<a-qrcode class="qr-code" :value="value" :size="size" type="svg" :bordered="false" color="#000000"
bg-color="#ffffff" @click="copyImage" />
</a-tooltip>
</div>
</div>
</template>

View file

@ -1,6 +1,7 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import {
BarsOutlined,
ControlOutlined,
@ -18,17 +19,18 @@ import {
DesktopOutlined,
DatabaseOutlined,
ForkOutlined,
CopyOutlined,
} from '@ant-design/icons-vue';
const { t } = useI18n();
import { HttpUtil, SizeFormatter, TimeFormatter } from '@/utils';
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { theme as themeState, antdThemeConfig } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js';
import { useMediaQuery } from '@/composables/useMediaQuery.js';
import AppSidebar from '@/components/AppSidebar.vue';
import CustomStatistic from '@/components/CustomStatistic.vue';
import TextModal from '@/components/TextModal.vue';
import JsonEditor from '@/components/JsonEditor.vue';
import StatusCard from './StatusCard.vue';
import XrayStatusCard from './XrayStatusCard.vue';
import PanelUpdateModal from './PanelUpdateModal.vue';
@ -117,7 +119,7 @@ function openTelegram() {
}
// Legacy "Config" action fetch the rendered xray config and show
// it as JSON in the shared TextModal (same UX as main).
// it as JSON in the config modal with syntax highlighting.
async function openConfig() {
loading.value = true;
try {
@ -129,6 +131,17 @@ async function openConfig() {
loading.value = false;
}
}
async function copyConfig() {
const ok = await ClipboardManager.copyText(configText.value || '');
if (ok) {
message.success('Copied');
}
}
function downloadConfig() {
FileManager.downloadTextFile(configText.value, 'config.json');
}
</script>
<template>
@ -360,8 +373,27 @@ async function openConfig() {
<XrayMetricsModal v-model:open="xrayMetricsOpen" />
<XrayLogModal v-model:open="xrayLogsOpen" />
<VersionModal v-model:open="versionOpen" :status="status" @busy="setBusy" />
<TextModal v-model:open="configTextOpen" :title="t('pages.index.config')" :content="configText"
file-name="config.json" />
<a-modal v-model:open="configTextOpen" :title="t('pages.index.config')" :width="isMobile ? '100%' : '900px'"
:style="isMobile ? { top: '20px', maxWidth: 'calc(100vw - 16px)' } : {}" :closable="true">
<JsonEditor v-model:value="configText" :min-height="isMobile ? '300px' : '420px'"
:max-height="isMobile ? '500px' : '720px'" :readonly="true" />
<template #footer>
<a-button @click="downloadConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CloudDownloadOutlined />
</template>
<span v-if="!isMobile">config.json</span>
<span v-else>Download</span>
</a-button>
<a-button type="primary" @click="copyConfig" :size="isMobile ? 'small' : 'middle'">
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>
</a-modal>
</a-layout>
</a-config-provider>
</template>

View file

@ -163,9 +163,9 @@ async function onSave() {
</a-col>
</a-row>
<a-form-item label="Allow private address">
<a-form-item :label="t('pages.nodes.allowPrivateAddress')">
<a-switch v-model:checked="form.allowPrivateAddress" />
<div class="hint">Enable only for nodes on a private network or VPN.</div>
<div class="hint">{{ t('pages.nodes.allowPrivateAddressHint') }}</div>
</a-form-item>
<a-form-item :label="t('pages.nodes.apiToken')" required>

View file

@ -61,6 +61,16 @@ const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
);
const fallbackSupported = computed(
() => form.strategy === 'leastPing' || form.strategy === 'leastLoad',
);
watch(() => form.strategy, (next) => {
if (next !== 'leastPing' && next !== 'leastLoad') {
form.fallbackTag = '';
}
});
const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning';
@ -111,8 +121,9 @@ const okText = computed(() =>
</a-select>
</a-form-item>
<a-form-item label="Fallback">
<a-select v-model:value="form.fallbackTag" allow-clear>
<a-form-item label="Fallback"
:help="fallbackSupported ? '' : 'Available only with Least ping / Least load'">
<a-select v-model:value="form.fallbackTag" allow-clear :disabled="!fallbackSupported">
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }}
</a-select-option>

View file

@ -145,10 +145,11 @@ function syncObservatories() {
}
function buildWireBalancer(form) {
const supportsFallback = form.strategy === 'leastPing' || form.strategy === 'leastLoad';
const out = {
tag: form.tag,
selector: [...form.selector],
fallbackTag: form.fallbackTag,
fallbackTag: supportsFallback ? form.fallbackTag : '',
};
if (form.strategy && form.strategy !== 'random') {
out.strategy = { type: form.strategy };

View file

@ -56,6 +56,7 @@ func serveDistPage(c *gin.Context, name string) {
csrfToken = ""
}
csrfMeta := []byte(`<meta name="csrf-token" content="` + htmlpkg.EscapeString(csrfToken) + `">`)
basePathMeta := []byte(`<meta name="base-path" content="` + htmlpkg.EscapeString(basePath) + `">`)
nonceAttr := ""
if nonce := c.GetString("csp_nonce"); nonce != "" {
@ -69,6 +70,7 @@ func serveDistPage(c *gin.Context, name string) {
script += `;</script>`
inject := []byte(script)
inject = append(inject, csrfMeta...)
inject = append(inject, basePathMeta...)
inject = append(inject, []byte(`</head>`)...)
out := bytes.Replace(body, []byte("</head>"), inject, 1)

View file

@ -87,10 +87,6 @@ func (j *NodeTrafficSyncJob) Run() {
return
}
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
lastOnline, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("node traffic sync: get last-online failed:", err)
@ -98,6 +94,13 @@ func (j *NodeTrafficSyncJob) Run() {
if lastOnline == nil {
lastOnline = map[string]int64{}
}
j.inboundService.RefreshOnlineClientsFromMap(lastOnline)
online := j.inboundService.GetOnlineClients()
if online == nil {
online = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"lastOnlineMap": lastOnline,

View file

@ -77,10 +77,6 @@ func (j *XrayTrafficJob) Run() {
// a missing/null onlineClients field as "no update", so without this the
// "everyone went offline" transition was silently dropped — stale online
// users lingered in the list and the online filter kept showing them.
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
lastOnlineMap, err := j.inboundService.GetClientsLastOnline()
if err != nil {
logger.Warning("get clients last online failed:", err)
@ -88,6 +84,17 @@ func (j *XrayTrafficJob) Run() {
if lastOnlineMap == nil {
lastOnlineMap = make(map[string]int64)
}
// Determine online clients from lastOnline timestamps with a 5-second
// grace period instead of just the current 5-second traffic poll. This
// prevents idle-but-connected clients from randomly disappearing from
// the UI between polling windows.
j.inboundService.RefreshOnlineClientsFromMap(lastOnlineMap)
onlineClients := j.inboundService.GetOnlineClients()
if onlineClients == nil {
onlineClients = []string{}
}
websocket.BroadcastTraffic(map[string]any{
"traffics": traffics,
"clientTraffics": clientTraffics,

View file

@ -344,10 +344,15 @@ func wireInbound(ib *model.Inbound) url.Values {
}
// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths
// from the StreamSettings before sending to a remote node. File paths
// (certificateFile / keyFile) are local to the main panel's filesystem
// and will cause Xray on the remote node to crash if they don't exist there.
// Inline certificate content (certificate / key) is kept intact.
// from the StreamSettings before sending to a remote node, but ONLY when
// inline certificate content (certificate / key) is also present in the same
// entry. In that case the file paths are redundant and stripping them avoids
// confusion when the central panel's local paths don't exist on the remote.
//
// When a certificate entry contains ONLY file paths (no inline content) the
// paths are left untouched: the user explicitly entered paths that exist on
// the remote node's filesystem, and removing them would leave Xray with TLS
// configured but no certificate, causing Xray to crash on the remote node.
func sanitizeStreamSettingsForRemote(streamSettings string) string {
if streamSettings == "" {
return streamSettings
@ -368,18 +373,40 @@ func sanitizeStreamSettingsForRemote(streamSettings string) string {
return streamSettings
}
changed := false
for _, cert := range certificates {
c, ok := cert.(map[string]any)
if !ok {
continue
}
delete(c, "certificateFile")
delete(c, "keyFile")
// Only strip file paths when inline content is present so that the
// remote Xray still has a valid certificate to use.
hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != ""
hasKeyFile := c["keyFile"] != nil && c["keyFile"] != ""
hasCertInline := isNonEmptySlice(c["certificate"])
hasKeyInline := isNonEmptySlice(c["key"])
if hasCertFile && hasCertInline {
delete(c, "certificateFile")
changed = true
}
if hasKeyFile && hasKeyInline {
delete(c, "keyFile")
changed = true
}
}
if !changed {
return streamSettings
}
out, err := json.Marshal(stream)
if err != nil {
return streamSettings
}
return string(out)
}
// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value.
func isNonEmptySlice(v any) bool {
s, ok := v.([]any)
return ok && len(s) > 0
}

View file

@ -0,0 +1,96 @@
package runtime
import (
"encoding/json"
"testing"
)
func TestSanitizeStreamSettingsForRemote(t *testing.T) {
tests := []struct {
name string
input string
// wantCertFile / wantKeyFile: expected presence after sanitize
wantCertFile bool
wantKeyFile bool
}{
{
name: "file paths only — kept intact (remote node paths)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key"
}]
}
}`,
wantCertFile: true,
wantKeyFile: true,
},
{
name: "inline content only — unchanged",
input: `{
"tlsSettings": {
"certificates": [{
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "both file paths and inline content — file paths stripped (redundant)",
input: `{
"tlsSettings": {
"certificates": [{
"certificateFile": "/etc/ssl/cert.crt",
"keyFile": "/etc/ssl/key.key",
"certificate": ["-----BEGIN CERTIFICATE-----"],
"key": ["-----BEGIN PRIVATE KEY-----"]
}]
}
}`,
wantCertFile: false,
wantKeyFile: false,
},
{
name: "empty stream settings",
input: "",
// empty input returns empty, nothing to check
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
if tc.input == "" {
if got := sanitizeStreamSettingsForRemote(tc.input); got != "" {
t.Errorf("expected empty string, got %q", got)
}
return
}
got := sanitizeStreamSettingsForRemote(tc.input)
var out map[string]any
if err := json.Unmarshal([]byte(got), &out); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got)
}
tls, _ := out["tlsSettings"].(map[string]any)
certs, _ := tls["certificates"].([]any)
if len(certs) == 0 {
t.Fatal("certificates array missing in output")
}
cert, _ := certs[0].(map[string]any)
_, hasCertFile := cert["certificateFile"]
_, hasKeyFile := cert["keyFile"]
if hasCertFile != tc.wantCertFile {
t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile)
}
if hasKeyFile != tc.wantKeyFile {
t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile)
}
})
}
}

View file

@ -278,11 +278,31 @@ func (s *InboundService) checkEmailsExistForClients(clients []model.Client) (str
return "", nil
}
// normalizeStreamSettings clears StreamSettings for protocols that don't use it.
// Only vmess, vless, trojan, shadowsocks, and hysteria protocols use streamSettings.
func (s *InboundService) normalizeStreamSettings(inbound *model.Inbound) {
protocolsWithStream := map[model.Protocol]bool{
model.VMESS: true,
model.VLESS: true,
model.Trojan: true,
model.Shadowsocks: true,
model.Hysteria: true,
model.Hysteria2: true,
}
if !protocolsWithStream[inbound.Protocol] {
inbound.StreamSettings = ""
}
}
// AddInbound creates a new inbound configuration.
// It validates port uniqueness, client email uniqueness, and required fields,
// then saves the inbound to the database and optionally adds it to the running Xray instance.
// Returns the created inbound, whether Xray needs restart, and any error.
func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, 0)
if err != nil {
return inbound, false, err
@ -501,6 +521,19 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return true, nil
}
// Remote nodes interpret DelInbound as a real row delete (it hits
// panel/api/inbounds/del/:id on the remote), so toggling the enable
// switch on a remote inbound used to wipe the row entirely (#4402).
// PATCH the remote row via UpdateInbound instead — preserves the
// settings/client history and just flips the enable flag.
if inbound.NodeID != nil {
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
return false, err
}
return false, nil
}
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
!strings.Contains(err.Error(), "not found") {
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
@ -510,26 +543,22 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
return needRestart, nil
}
addTarget := inbound
if inbound.NodeID == nil {
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
addTarget = runtimeInbound
runtimeInbound, err := s.buildRuntimeInboundForAPI(db, inbound)
if err != nil {
logger.Debug("SetInboundEnable: build runtime config failed:", err)
return true, nil
}
if err := rt.AddInbound(context.Background(), addTarget); err != nil {
if err := rt.AddInbound(context.Background(), runtimeInbound); err != nil {
logger.Debug("SetInboundEnable: AddInbound on", rt.Name(), "failed:", err)
if inbound.NodeID != nil {
return false, err
}
needRestart = true
}
return needRestart, nil
}
func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, bool, error) {
// Normalize streamSettings based on protocol
s.normalizeStreamSettings(inbound)
exist, err := s.checkPortConflict(inbound, inbound.Id)
if err != nil {
return inbound, false, err
@ -1516,6 +1545,13 @@ func (s *InboundService) UpdateInboundClient(data *model.Inbound, clientId strin
const resetGracePeriodMs int64 = 30000
// onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval —
// Xray's stats counters often report a zero delta for an active session across
// a single poll, so a 5s grace would still drop the client on the next tick.
// ~4 polls of slack keeps idle-but-connected clients visible without lingering
// long after a real disconnect.
const onlineGracePeriodMs int64 = 20000
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
var structuralChange bool
err := submitTrafficWrite(func() error {
@ -1857,15 +1893,9 @@ func (s *InboundService) addInboundTraffic(tx *gorm.DB, traffics []*xray.Traffic
func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTraffic) (err error) {
if len(traffics) == 0 {
// Empty onlineUsers
if p != nil {
p.SetOnlineClients(make([]string, 0))
}
return nil
}
onlineClients := make([]string, 0)
emails := make([]string, 0, len(traffics))
for _, traffic := range traffics {
emails = append(emails, traffic.Email)
@ -1908,14 +1938,10 @@ func (s *InboundService) addClientTraffic(tx *gorm.DB, traffics []*xray.ClientTr
dbClientTraffics[dbTraffic_index].Down += t.Down
dbClientTraffics[dbTraffic_index].AllTime += t.Up + t.Down
if t.Up+t.Down > 0 {
onlineClients = append(onlineClients, t.Email)
dbClientTraffics[dbTraffic_index].LastOnline = now
}
}
// Set onlineUsers
p.SetOnlineClients(onlineClients)
err = tx.Save(dbClientTraffics).Error
if err != nil {
logger.Warning("AddClientTraffic update data ", err)
@ -3741,6 +3767,19 @@ func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
return result, nil
}
func (s *InboundService) RefreshOnlineClientsFromMap(lastOnlineMap map[string]int64) {
now := time.Now().UnixMilli()
newOnlineClients := make([]string, 0, len(lastOnlineMap))
for email, lastOnline := range lastOnlineMap {
if now-lastOnline < onlineGracePeriodMs {
newOnlineClients = append(newOnlineClients, email)
}
}
if p != nil {
p.SetOnlineClients(newOnlineClients)
}
}
func (s *InboundService) FilterAndSortClientEmails(emails []string) ([]string, []string, error) {
db := database.GetDB()

View file

@ -190,7 +190,7 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
wg.Add(1)
go func(i int) {
defer wg.Done()
results[i] = probeTCPEndpoint(endpoints[i], 5*time.Second)
results[i] = probeEndpoint(endpoints[i], 5*time.Second)
}(i)
}
wg.Wait()
@ -207,7 +207,11 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
}
}
out := &TestOutboundResult{Mode: "tcp", Endpoints: results}
mode := "tcp"
if endpoints[0].Network == "udp" {
mode = "udp"
}
out := &TestOutboundResult{Mode: mode, Endpoints: results}
if bestDelay >= 0 {
out.Success = true
out.Delay = bestDelay
@ -220,6 +224,22 @@ func (s *OutboundService) testOutboundTCP(outboundJSON string) (*TestOutboundRes
return out, nil
}
// outboundEndpoint is a host:port plus the transport its proxy actually
// listens on. WireGuard (and WARP, which is WireGuard) is UDP-only, so a
// TCP dial to its peer endpoint always times out — the probe must match
// the transport of the outbound being tested.
type outboundEndpoint struct {
Address string
Network string
}
func probeEndpoint(ep outboundEndpoint, timeout time.Duration) TestEndpointResult {
if ep.Network == "udp" {
return probeUDPEndpoint(ep.Address, timeout)
}
return probeTCPEndpoint(ep.Address, timeout)
}
func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
@ -234,18 +254,69 @@ func probeTCPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult
return r
}
func extractOutboundEndpoints(ob map[string]any) []string {
// probeUDPEndpoint sends a single byte and waits briefly for a reply or
// an ICMP-driven error. WireGuard won't answer an unauthenticated byte,
// so a read timeout is the normal "endpoint reachable" outcome; a
// concrete error (e.g. ECONNREFUSED, "host unreachable") fails the probe.
func probeUDPEndpoint(endpoint string, timeout time.Duration) TestEndpointResult {
r := TestEndpointResult{Address: endpoint}
start := time.Now()
conn, err := net.DialTimeout("udp", endpoint, timeout)
if err != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = err.Error()
return r
}
defer conn.Close()
if _, werr := conn.Write([]byte{0}); werr != nil {
r.Delay = time.Since(start).Milliseconds()
r.Error = werr.Error()
return r
}
_ = conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 64)
_, rerr := conn.Read(buf)
r.Delay = time.Since(start).Milliseconds()
if rerr != nil {
if nerr, ok := rerr.(net.Error); ok && nerr.Timeout() {
r.Success = true
return r
}
r.Error = rerr.Error()
return r
}
r.Success = true
return r
}
func extractOutboundEndpoints(ob map[string]any) []outboundEndpoint {
protocol, _ := ob["protocol"].(string)
settings, _ := ob["settings"].(map[string]any)
if settings == nil {
return nil
}
var out []string
// Hysteria (and hysteria2 over trojan) is QUIC/UDP. Detect it via the
// outer protocol or via streamSettings.network so trojan-with-hysteria2
// transport gets probed over UDP too. kcp and quic are also UDP-based.
network := "tcp"
if protocol == "hysteria" || protocol == "wireguard" {
network = "udp"
}
if stream, ok := ob["streamSettings"].(map[string]any); ok {
if n, _ := stream["network"].(string); n == "hysteria" || n == "kcp" || n == "quic" {
network = "udp"
}
}
var out []outboundEndpoint
addServer := func(addr any, port any) {
host, _ := addr.(string)
p := numAsInt(port)
if host != "" && p > 0 {
out = append(out, fmt.Sprintf("%s:%d", host, p))
out = append(out, outboundEndpoint{Address: fmt.Sprintf("%s:%d", host, p), Network: network})
}
}
switch protocol {
@ -259,6 +330,8 @@ func extractOutboundEndpoints(ob map[string]any) []string {
}
case "vless":
addServer(settings["address"], settings["port"])
case "hysteria":
addServer(settings["address"], settings["port"])
case "trojan", "shadowsocks", "http", "socks":
if servers, ok := settings["servers"].([]any); ok {
for _, sv := range servers {
@ -272,7 +345,7 @@ func extractOutboundEndpoints(ob map[string]any) []string {
for _, p := range peers {
if pm, ok := p.(map[string]any); ok {
if ep, _ := pm["endpoint"].(string); ep != "" {
out = append(out, ep)
out = append(out, outboundEndpoint{Address: ep, Network: network})
}
}
}

View file

@ -1398,6 +1398,25 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_set_flow":
if dataArray[1] == "none" {
client_Flow = ""
} else {
client_Flow = dataArray[1]
}
messageId := callbackQuery.Message.GetMessageID()
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
message_text, err := t.BuildInboundClientDataMessage(inbound.Remark, inbound.Protocol)
if err != nil {
t.sendCallbackAnswerTgBot(callbackQuery.ID, err.Error())
return
}
t.addClient(callbackQuery.Message.GetChat().ID, message_text, messageId)
t.sendCallbackAnswerTgBot(callbackQuery.ID, t.I18nBot("tgbot.answers.successfulOperation"))
case "add_client_ip_limit_in":
@ -1865,6 +1884,22 @@ func (t *Tgbot) answerCallback(callbackQuery *telego.CallbackQuery, isAdmin bool
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_flow":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.cancel")).WithCallbackData(t.encodeQuery("add_client_default_traffic_exp")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("None").WithCallbackData(t.encodeQuery("add_client_set_flow none")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision")),
),
tu.InlineKeyboardRow(
tu.InlineKeyboardButton("xtls-rprx-vision-udp443").WithCallbackData(t.encodeQuery("add_client_set_flow xtls-rprx-vision-udp443")),
),
)
t.editMessageCallbackTgBot(chatId, callbackQuery.Message.GetMessageID(), inlineKeyboard)
case "add_client_ch_default_ip_limit":
inlineKeyboard := tu.InlineKeyboard(
tu.InlineKeyboardRow(
@ -3345,6 +3380,25 @@ func (t *Tgbot) getCommonClientButtons() [][]telego.InlineKeyboardButton {
}
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend
// model: xtls-rprx-vision is only valid on VLESS-over-TCP with TLS or Reality.
func inboundCanEnableTlsFlow(ib *model.Inbound) bool {
if ib == nil || ib.Protocol != model.VLESS {
return false
}
var stream struct {
Network string `json:"network"`
Security string `json:"security"`
}
if err := json.Unmarshal([]byte(ib.StreamSettings), &stream); err != nil {
return false
}
if stream.Network != "tcp" {
return false
}
return stream.Security == "tls" || stream.Security == "reality"
}
// addClient handles the process of adding a new client to an inbound.
func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
inbound, err := t.inboundService.GetInbound(receiver_inbound_ID)
@ -3357,13 +3411,31 @@ func (t *Tgbot) addClient(chatId int64, msg string, messageID ...int) {
var protocolRows [][]telego.InlineKeyboardButton
switch protocol {
case model.VMESS, model.VLESS:
case model.VMESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
case model.VLESS:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_email")).WithCallbackData("add_client_ch_default_email"),
tu.InlineKeyboardButton(t.I18nBot("tgbot.buttons.change_id")).WithCallbackData("add_client_ch_default_id"),
),
}
if inboundCanEnableTlsFlow(inbound) {
flowLabel := t.I18nBot("tgbot.buttons.change_flow")
if client_Flow != "" {
flowLabel = flowLabel + ": " + client_Flow
}
protocolRows = append(protocolRows, tu.InlineKeyboardRow(
tu.InlineKeyboardButton(flowLabel).WithCallbackData("add_client_ch_default_flow"),
))
} else if client_Flow != "" {
client_Flow = ""
}
case model.Trojan:
protocolRows = [][]telego.InlineKeyboardButton{
tu.InlineKeyboardRow(

View file

@ -418,6 +418,8 @@
"apiTokenHint": "البانل البعيد بيعرض توكن API بتاعه في الإعدادات → توكن API.",
"regenerate": "تجديد التوكن",
"regenerateConfirm": "تجديد التوكن هيلغي التوكن الحالي. أي بانل مركزي بيستخدمه هيفقد الصلاحية لحد ما تحدّث التوكن. تكمّل؟",
"allowPrivateAddress": "السماح بالعنوان الخاص",
"allowPrivateAddressHint": "التفعيل فقط للعقد على شبكة خاصة أو VPN.",
"enable": "مفعل",
"status": "الحالة",
"cpu": "المعالج",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 كلمة السر",
"change_email": "⚙️📧 البريد الإلكتروني",
"change_comment": "⚙️💬 تعليق",
"change_flow": "⚙️🚦 التدفق",
"ResetAllTraffics": "إعادة ضبط جميع الترافيك",
"SortedTrafficUsageReport": "تقرير استخدام الترافيك المرتب"
},

View file

@ -32,7 +32,7 @@
"delete": "Delete",
"reset": "Reset",
"noData": "No data.",
"copySuccess": "Copied Successful",
"copySuccess": "Copied successfully",
"sure": "Sure",
"encryption": "Encryption",
"useIPv4ForHost": "Use IPv4 for host",
@ -52,14 +52,14 @@
"certificate": "Digital Certificate",
"fail": "Failed",
"comment": "Comment",
"success": "Successfully",
"success": "Success",
"lastOnline": "Last Online",
"getVersion": "Get Version",
"install": "Install",
"clients": "Clients",
"usage": "Usage",
"twoFactorCode": "Code",
"remained": "Remained",
"remained": "Remaining",
"security": "Security",
"secAlertTitle": "Security Alert",
"secAlertSsl": "This connection is not secure. Please avoid entering sensitive information until TLS is activated for data protection.",
@ -107,7 +107,7 @@
"title": "Welcome",
"loginAgain": "Your session has expired, please log in again",
"toasts": {
"invalidFormData": "The Input data format is invalid.",
"invalidFormData": "The input data format is invalid.",
"emptyUsername": "Username is required",
"emptyPassword": "Password is required",
"wrongUsernameOrPassword": "Invalid username or password or two-factor code.",
@ -138,7 +138,7 @@
"upToDate": "Up to date",
"xrayStatusUnknown": "Unknown",
"xrayStatusRunning": "Running",
"xrayStatusStop": "Stop",
"xrayStatusStop": "Stopped",
"xrayStatusError": "Error",
"xrayErrorPopoverTitle": "An error occurred while running Xray",
"operationHours": "Uptime",
@ -238,7 +238,7 @@
},
"inbounds": {
"allTimeTraffic": "All-time Traffic",
"allTimeTrafficUsage": "All Time Total Usage",
"allTimeTrafficUsage": "All-Time Total Usage",
"title": "Inbounds",
"totalDownUp": "Total Sent/Received",
"totalUsage": "Total Usage",
@ -263,9 +263,9 @@
"generalActions": "General Actions",
"modifyInbound": "Modify Inbound",
"deleteInbound": "Delete Inbound",
"deleteInboundContent": "Are you sure you want to delete inbound?",
"deleteInboundContent": "Are you sure you want to delete this inbound?",
"deleteClient": "Delete Client",
"deleteClientContent": "Are you sure you want to delete client?",
"deleteClientContent": "Are you sure you want to delete this client?",
"resetTrafficContent": "Are you sure you want to reset traffic?",
"copyLink": "Copy URL",
"address": "Address",
@ -288,14 +288,14 @@
"cloneInbound": "Clone",
"cloneInboundContent": "All settings of this inbound, except Port, Listening IP, and Clients, will be applied to the clone.",
"cloneInboundOk": "Clone",
"resetAllTraffic": "Reset All Inbounds Traffic",
"resetAllTrafficTitle": "Reset All Inbounds Traffic",
"resetAllTraffic": "Reset Traffic for All Inbounds",
"resetAllTrafficTitle": "Reset Traffic for All Inbounds",
"resetAllTrafficContent": "Are you sure you want to reset the traffic of all inbounds?",
"resetInboundClientTraffics": "Reset Clients Traffic",
"resetInboundClientTrafficTitle": "Reset Clients Traffic",
"resetInboundClientTraffics": "Reset Clients' Traffic",
"resetInboundClientTrafficTitle": "Reset Clients' Traffic",
"resetInboundClientTrafficContent": "Are you sure you want to reset the traffic of this inbound's clients?",
"resetAllClientTraffics": "Reset All Clients Traffic",
"resetAllClientTrafficTitle": "Reset All Clients Traffic",
"resetAllClientTraffics": "Reset All Clients' Traffic",
"resetAllClientTrafficTitle": "Reset All Clients' Traffic",
"resetAllClientTrafficContent": "Are you sure you want to reset the traffic of all clients?",
"delDepletedClients": "Delete Depleted Clients",
"delDepletedClientsTitle": "Delete Depleted Clients",
@ -305,8 +305,8 @@
"IPLimit": "IP Limit",
"IPLimitDesc": "Disables inbound if the count exceeds the set value. (0 = disable)",
"IPLimitlog": "IP Log",
"IPLimitlogDesc": "The IPs history log. (to enable inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear The Log",
"IPLimitlogDesc": "The IP history log. (to re-enable the inbound after disabling, clear the log)",
"IPLimitlogclear": "Clear the Log",
"setDefaultCert": "Set Cert from Panel",
"telegramDesc": "Please provide Telegram Chat ID. (use '/id' command in the bot) or ({'@'}userinfobot)",
"subscriptionDesc": "To find your subscription URL, navigate to the 'Details'. Additionally, you can use the same name for several clients.",
@ -337,12 +337,12 @@
"inboundClientAddSuccess": "Inbound client(s) have been added.",
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
"inboundClientUpdateSuccess": "Inbound client has been updated.",
"delDepletedClientsSuccess": "All depleted clients are deleted.",
"resetAllClientTrafficSuccess": "All traffic from the client has been reset.",
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
"resetAllTrafficSuccess": "All traffic has been reset.",
"resetInboundClientTrafficSuccess": "Traffic has been reset.",
"resetInboundTrafficSuccess": "Inbound traffic has been reset.",
"trafficGetError": "Error getting traffics.",
"trafficGetError": "Error getting traffic.",
"getNewX25519CertError": "Error while obtaining the X25519 certificate.",
"getNewmldsa65Error": "Error while obtaining mldsa65.",
"getNewVlessEncError": "Error while obtaining VlessEnc."
@ -418,6 +418,8 @@
"apiTokenHint": "The remote panel exposes its API token under Settings → API Token.",
"regenerate": "Regenerate Token",
"regenerateConfirm": "Regenerating invalidates the current token. Any central panel using it will lose access until updated. Continue?",
"allowPrivateAddress": "Allow private address",
"allowPrivateAddressHint": "Enable only for nodes on a private network or VPN.",
"enable": "Enabled",
"status": "Status",
"cpu": "CPU",
@ -524,7 +526,7 @@
"subProfileUrl": "Profile URL",
"subProfileUrlDesc": "A link to your website displayed in the VPN client",
"subAnnounce": "Announce",
"subAnnounceDesc": "The text of the announce displayed in the VPN client",
"subAnnounceDesc": "The announcement text displayed in the VPN client",
"subEnableRouting": "Enable routing",
"subEnableRoutingDesc": "Global setting to enable routing in the VPN client. (Only for Happ)",
"subRoutingRules": "Routing rules",
@ -568,7 +570,7 @@
"direct": "Direct Connection",
"directDesc": "Directly establishes connections with domains or IP ranges of a specific country.",
"notifications": "Notifications",
"certs": "Certificaties",
"certs": "Certificates",
"externalTraffic": "External Traffic",
"dateAndTime": "Date and Time",
"proxyAndServer": "Proxy and Server",
@ -606,10 +608,10 @@
"getSettings": "An error occurred while retrieving parameters.",
"modifyUserError": "An error occurred while changing administrator credentials.",
"modifyUser": "You have successfully changed the credentials of the administrator.",
"originalUserPassIncorrect": "The сurrent username or password is invalid",
"userPassMustBeNotEmpty": "The new username and password is empty",
"getOutboundTrafficError": "Error getting traffics",
"resetOutboundTrafficError": "Error in reset outbound traffics"
"originalUserPassIncorrect": "The current username or password is invalid",
"userPassMustBeNotEmpty": "The new username and password are empty",
"getOutboundTrafficError": "Error getting traffic",
"resetOutboundTrafficError": "Error resetting outbound traffic"
}
},
"xray": {
@ -660,9 +662,9 @@
"logLevel": "Log Level",
"logLevelDesc": "The log level for error logs, indicating the information that needs to be recorded.",
"accessLog": "Access Log",
"accessLogDesc": "The file path for the access log. The special value 'none' disabled access logs",
"accessLogDesc": "The file path for the access log. The special value 'none' disables access logs",
"errorLog": "Error Log",
"errorLogDesc": "The file path for the error log. The special value 'none' disabled error logs",
"errorLogDesc": "The file path for the error log. The special value 'none' disables error logs",
"dnsLog": "DNS Log",
"dnsLogDesc": "Whether to enable DNS query logs",
"maskAddress": "Mask Address",
@ -772,7 +774,7 @@
"edit": "Edit Server",
"domains": "Domains",
"expectIPs": "Expect IPs",
"unexpectIPs": "Unexpect IPs",
"unexpectIPs": "Unexpected IPs",
"useSystemHosts": "Use System Hosts",
"useSystemHostsDesc": "Use the hosts file from an installed system",
"serveStale": "Serve Stale",
@ -894,10 +896,10 @@
"received_password": "🔑📥 Password updated.",
"received_email": "📧📥 Email updated.",
"received_comment": "💬📥 Comment updated.",
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your id.",
"id_prompt": "🔑 Default ID: {{ .ClientId }}\n\nEnter your ID.",
"pass_prompt": "🔑 Default Password: {{ .ClientPassword }}\n\nEnter your password.",
"email_prompt": "📧 Default Email: {{ .ClientEmail }}\n\nEnter your email.",
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your Comment.",
"comment_prompt": "💬 Default Comment: {{ .ClientComment }}\n\nEnter your comment.",
"inbound_client_data_id": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 ID: {{ .ClientId }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"inbound_client_data_pass": "🔄 Inbound: {{ .InboundRemark }}\n\n🔑 Password: {{ .ClientPass }}\n📧 Email: {{ .ClientEmail }}\n📊 Traffic: {{ .ClientTraffic }}\n📅 Expire Date: {{ .ClientExp }}\n🌐 IP Limit: {{ .IpLimit }}\n💬 Comment: {{ .ClientComment }}\n\nYou can add the client to inbound now!",
"cancel": "❌ Process Canceled! \n\nYou can /start again anytime. 🔄",
@ -950,7 +952,8 @@
"change_password": "⚙️🔑 Password",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Comment",
"ResetAllTraffics": "Reset All Traffics",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset All Traffic",
"SortedTrafficUsageReport": "Sorted Traffic Usage Report"
},
"answers": {

View file

@ -418,6 +418,8 @@
"apiTokenHint": "El panel remoto expone su token de API en Configuración → Token de API.",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida el token actual. Cualquier panel central que lo use perderá el acceso hasta que se actualice. ¿Continuar?",
"allowPrivateAddress": "Permitir dirección privada",
"allowPrivateAddressHint": "Habilitar solo para nodos en una red privada o VPN.",
"enable": "Habilitado",
"status": "Estado",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Contraseña",
"change_email": "⚙️📧 Correo electrónico",
"change_comment": "⚙️💬 Comentario",
"change_flow": "⚙️🚦 Flujo",
"ResetAllTraffics": "Reiniciar todo el tráfico",
"SortedTrafficUsageReport": "Informe de uso de tráfico ordenado"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "پنل ریموت توکن API خودش را در بخش تنظیمات → توکن API نمایش می‌دهد.",
"regenerate": "تولید مجدد توکن",
"regenerateConfirm": "تولید مجدد، توکن فعلی را باطل می‌کند. هر پنل مرکزی‌ای که از این توکن استفاده می‌کند تا زمان به‌روزرسانی، دسترسی‌اش قطع می‌شود. ادامه می‌دهید؟",
"allowPrivateAddress": "اجازه آدرس خصوصی",
"allowPrivateAddressHint": "فقط برای نودهای روی شبکه خصوصی یا VPN فعال شود.",
"enable": "فعال",
"status": "وضعیت",
"cpu": "پردازنده",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 گذرواژه",
"change_email": "⚙️📧 ایمیل",
"change_comment": "⚙️💬 نظر",
"change_flow": "⚙️🚦 جریان",
"ResetAllTraffics": "بازنشانی همه ترافیک‌ها",
"SortedTrafficUsageReport": "گزارش استفاده از ترافیک مرتب‌شده"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel jarak jauh menampilkan token API-nya di Pengaturan → Token API.",
"regenerate": "Buat Ulang Token",
"regenerateConfirm": "Membuat ulang akan membatalkan token saat ini. Setiap panel pusat yang menggunakannya akan kehilangan akses sampai diperbarui. Lanjutkan?",
"allowPrivateAddress": "Izinkan alamat pribadi",
"allowPrivateAddressHint": "Aktifkan hanya untuk node di jaringan pribadi atau VPN.",
"enable": "Aktif",
"status": "Status",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Kata Sandi",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Komentar",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Reset Semua Lalu Lintas",
"SortedTrafficUsageReport": "Laporan Penggunaan Lalu Lintas yang Terurut"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "リモートパネルでは、設定 → APIトークン でAPIトークンを確認できます。",
"regenerate": "トークンを再生成",
"regenerateConfirm": "再生成すると現在のトークンは無効になります。これを使用しているすべての中央パネルは更新されるまでアクセスできなくなります。続行しますか?",
"allowPrivateAddress": "プライベートアドレスを許可",
"allowPrivateAddressHint": "プライベートネットワークまたはVPN上のードにのみ有効にします。",
"enable": "有効",
"status": "ステータス",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 パスワード",
"change_email": "⚙️📧 メールアドレス",
"change_comment": "⚙️💬 コメント",
"change_flow": "⚙️🚦 フロー",
"ResetAllTraffics": "すべてのトラフィックをリセット",
"SortedTrafficUsageReport": "ソートされたトラフィック使用レポート"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "O painel remoto exibe o token da API em Configurações → Token da API.",
"regenerate": "Regenerar token",
"regenerateConfirm": "Regenerar invalida o token atual. Qualquer painel central que o utilize perderá acesso até ser atualizado. Continuar?",
"allowPrivateAddress": "Permitir endereço privado",
"allowPrivateAddressHint": "Ativar apenas para nós em uma rede privada ou VPN.",
"enable": "Ativado",
"status": "Status",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Senha",
"change_email": "⚙️📧 E-mail",
"change_comment": "⚙️💬 Comentário",
"change_flow": "⚙️🚦 Fluxo",
"ResetAllTraffics": "Redefinir Todo o Tráfego",
"SortedTrafficUsageReport": "Relatório de Uso de Tráfego Ordenado"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Удалённая панель показывает свой токен API в разделе Настройки → Токен API.",
"regenerate": "Сгенерировать токен заново",
"regenerateConfirm": "Повторная генерация аннулирует текущий токен. Любая центральная панель, использующая его, потеряет доступ до обновления. Продолжить?",
"allowPrivateAddress": "Разрешить частный адрес",
"allowPrivateAddressHint": "Включить только для узлов в частной сети или VPN.",
"enable": "Включён",
"status": "Статус",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Комментарий",
"change_flow": "⚙️🚦 Поток",
"ResetAllTraffics": "Сбросить весь трафик",
"SortedTrafficUsageReport": "Отсортированный отчет об использовании трафика"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Uzak panel API token'ını Ayarlar → API Token altında gösterir.",
"regenerate": "Token'ı Yeniden Oluştur",
"regenerateConfirm": "Yeniden oluşturmak mevcut token'ı geçersiz kılar. Onu kullanan tüm merkezi paneller, güncellenene kadar erişimini kaybeder. Devam edilsin mi?",
"allowPrivateAddress": "Özel adrese izin ver",
"allowPrivateAddressHint": "Yalnızca özel ağ veya VPN üzerindeki düğümler için etkinleştir.",
"enable": "Etkin",
"status": "Durum",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Şifre",
"change_email": "⚙️📧 E-posta",
"change_comment": "⚙️💬 Yorum",
"change_flow": "⚙️🚦 Akış",
"ResetAllTraffics": "Tüm Trafikleri Sıfırla",
"SortedTrafficUsageReport": "Sıralı Trafik Kullanım Raporu"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Віддалена панель показує свій токен API в Налаштуваннях → Токен API.",
"regenerate": "Перегенерувати токен",
"regenerateConfirm": "Перегенерація скасовує поточний токен. Будь-яка центральна панель, що його використовує, втратить доступ до оновлення. Продовжити?",
"allowPrivateAddress": "Дозволити приватну адресу",
"allowPrivateAddressHint": "Увімкнути лише для вузлів у приватній мережі або VPN.",
"enable": "Увімкнено",
"status": "Статус",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Пароль",
"change_email": "⚙️📧 Електронна пошта",
"change_comment": "⚙️💬 Коментар",
"change_flow": "⚙️🚦 Потік",
"ResetAllTraffics": "Скинути весь трафік",
"SortedTrafficUsageReport": "Відсортований звіт про використання трафіку"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "Panel từ xa hiển thị token API tại Cài đặt → Token API.",
"regenerate": "Tạo lại token",
"regenerateConfirm": "Tạo lại sẽ vô hiệu hóa token hiện tại. Mọi panel trung tâm dùng nó sẽ mất quyền truy cập cho đến khi được cập nhật. Tiếp tục?",
"allowPrivateAddress": "Cho phép địa chỉ riêng",
"allowPrivateAddressHint": "Chỉ bật cho các nút trên mạng riêng hoặc VPN.",
"enable": "Kích hoạt",
"status": "Trạng thái",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 Mật Khẩu",
"change_email": "⚙️📧 Email",
"change_comment": "⚙️💬 Bình Luận",
"change_flow": "⚙️🚦 Flow",
"ResetAllTraffics": "Đặt lại tất cả lưu lượng",
"SortedTrafficUsageReport": "Báo cáo sử dụng lưu lượng đã sắp xếp"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "远程面板在 设置 → API 令牌 中显示其 API 令牌。",
"regenerate": "重新生成令牌",
"regenerateConfirm": "重新生成会使当前令牌失效。任何使用该令牌的中央面板都会失去访问权限,直至更新。是否继续?",
"allowPrivateAddress": "允许私有地址",
"allowPrivateAddressHint": "仅对私有网络或VPN上的节点启用。",
"enable": "已启用",
"status": "状态",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密码",
"change_email": "⚙️📧 邮箱",
"change_comment": "⚙️💬 评论",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重置所有流量",
"SortedTrafficUsageReport": "排序的流量使用报告"
},

View file

@ -418,6 +418,8 @@
"apiTokenHint": "遠端面板在 設定 → API 權杖 中顯示其 API 權杖。",
"regenerate": "重新產生權杖",
"regenerateConfirm": "重新產生會使目前的權杖失效。任何使用該權杖的中央面板將失去存取權,直到更新為止。是否繼續?",
"allowPrivateAddress": "允許私有地址",
"allowPrivateAddressHint": "僅對私有網路或VPN上的節點啟用。",
"enable": "已啟用",
"status": "狀態",
"cpu": "CPU",
@ -950,6 +952,7 @@
"change_password": "⚙️🔑 密碼",
"change_email": "⚙️📧 電子郵件",
"change_comment": "⚙️💬 評論",
"change_flow": "⚙️🚦 流控",
"ResetAllTraffics": "重設所有流量",
"SortedTrafficUsageReport": "排序過的流量使用報告"
},

View file

@ -11,17 +11,17 @@ import (
type Config struct {
LogConfig json_util.RawMessage `json:"log"`
RouterConfig json_util.RawMessage `json:"routing"`
DNSConfig json_util.RawMessage `json:"dns"`
DNSConfig json_util.RawMessage `json:"dns,omitempty"`
InboundConfigs []InboundConfig `json:"inbounds"`
OutboundConfigs json_util.RawMessage `json:"outbounds"`
Transport json_util.RawMessage `json:"transport"`
Transport json_util.RawMessage `json:"transport,omitempty"`
Policy json_util.RawMessage `json:"policy"`
API json_util.RawMessage `json:"api"`
Stats json_util.RawMessage `json:"stats"`
Reverse json_util.RawMessage `json:"reverse"`
FakeDNS json_util.RawMessage `json:"fakedns"`
Observatory json_util.RawMessage `json:"observatory"`
BurstObservatory json_util.RawMessage `json:"burstObservatory"`
Reverse json_util.RawMessage `json:"reverse,omitempty"`
FakeDNS json_util.RawMessage `json:"fakedns,omitempty"`
Observatory json_util.RawMessage `json:"observatory,omitempty"`
BurstObservatory json_util.RawMessage `json:"burstObservatory,omitempty"`
Metrics json_util.RawMessage `json:"metrics"`
}

View file

@ -13,9 +13,9 @@ type InboundConfig struct {
Port int `json:"port"`
Protocol string `json:"protocol"`
Settings json_util.RawMessage `json:"settings"`
StreamSettings json_util.RawMessage `json:"streamSettings"`
StreamSettings json_util.RawMessage `json:"streamSettings,omitempty"`
Tag string `json:"tag"`
Sniffing json_util.RawMessage `json:"sniffing"`
Sniffing json_util.RawMessage `json:"sniffing,omitempty"`
}
// Equals compares two InboundConfig instances for deep equality.