Compare commits

..

20 Commits

Author SHA1 Message Date
shazzreab 98ba88037c fix(subClashService): improve merging of clash rules in YAML (#5054) 2026-06-08 09:56:25 +02:00
Roman Gogolev d739bcf71e fix arm architecture xray binary file name (#5060) 2026-06-08 09:55:44 +02:00
Turan b0fe21c804 i18n(tr): Improve Turkish translation consistency and terminology (#5066)
Thank you for this great project!

I've made a comprehensive revision of the Turkish translation to improve consistency, grammatical accuracy, and natural flow for Turkish-speaking network administrators.

**Key Improvements:**
- **Unified "Client" Terminology:** Consistently translated as "Kullanıcı" (User) for human accounts and "İstemci" (Client) for software applications throughout the UI and Telegram Bot.
- **Inbounds & Outbounds:** Replaced the literal translations with professional networking terms: "Bağlantı Noktaları" (Inbounds) and "Çıkış Noktaları" (Outbounds).
- **Vowel Harmony Fixes:** Corrected several Turkish grammatical vowel harmony issues (e.g., *kullanıcısi* → *kullanıcısı*, *kullanıcılarini* → *kullanıcılarını*).
- **Capitalization & Phrasing:** Fixed capitalization inconsistencies (e.g., "Son Çevrimiçi") and improved phrasing for terms like "camouflage" → "Maskeleme" and "transport" → "Aktarım".

Technical English terms (SNI, TLS, REALITY, grpc, Vision, etc.) are intentionally kept in English as they are the standard in network engineering. 

Hope this helps the Turkish community!
2026-06-08 09:55:14 +02:00
Turan f6558571b4 docs(i18n): Add Turkish translation for README (#5067)
Hello! I noticed there was no Turkish README file despite having many other languages.

I have created README.tr_TR.md to help the Turkish community properly understand and deploy 3x-ui. The translation uses highly accurate networking terminology that matches the recent tr-TR.json improvements.

Thank you!
2026-06-08 09:54:13 +02:00
Tokenicrat 词元 4e253588ae fix(update.sh): allow skipping ssl setup when updating (#5071) 2026-06-08 09:53:50 +02:00
MHSanaei c6f15cd53f refactor(api)!: move /panel/setting and /panel/xray under /panel/api
Settings and Xray config endpoints now live at /panel/api/setting/* and /panel/api/xray/*, registered under the existing /panel/api group so they inherit the same Bearer-or-session auth (checkAPIAuth) as the rest of the API. An API token is a full-admin credential, so this just makes the surface consistent. The SPA page routes /panel/settings and /panel/xray are unchanged.

BREAKING CHANGE: the old /panel/setting/* and /panel/xray/* paths are removed. External callers must switch to the /panel/api/ prefix. Frontend call sites, API docs, the dev proxy, and the route-documentation test are updated to match.
2026-06-06 16:22:41 +02:00
MHSanaei a014c01725 feat(api-docs): generate OpenAPI components/schemas from Go structs
A new emit_jsonschema.go walks the same allow-listed structs as the zod/types/examples emitters and writes generated/schemas.ts (SCHEMAS). build-openapi mounts it under components.schemas and points each typed response obj at a $ref instead of an untyped {} blob, so Swagger renders real models and openapi-generator can emit clients.

Also add a vitest guard that safeParses every EXAMPLES entry against its generated zod schema, reviving the previously unused generated/zod.ts and catching drift between the example and schema emitters.
2026-06-06 16:22:21 +02:00
MHSanaei e56f6c63f6 fix(api-docs): target the panel base path in OpenAPI servers
ServeOpenAPISpec shipped servers:[{url:"/"}], so Swagger UI "Try it out" and external generators hit the origin root and ignored a non-root webBasePath. Inject the runtime base path into the single servers entry at serve time, touching only that field via json.RawMessage so the rest of the spec is preserved verbatim.
2026-06-06 16:22:08 +02:00
MHSanaei 83799d71b0 feat(api-docs): generate response examples from Go structs; fix SS2022 PSK regen (#4996)
Stop hand-writing OpenAPI response examples, which kept drifting from the real payloads (clients/traffic missing fields, inbounds/list exposing userId which is json:"-", the fictional inbound-443 tag instead of the real in-<port>-<transport> form).

tools/openapigen now emits frontend/src/generated/examples.ts: a per-struct example instance built from type defaults, validate oneof/min bounds, and example: struct tags, with nested-ref expansion and a cycle guard. build-openapi.mjs composes the {success,obj} envelope from it for any endpoint annotated with responseSchema (+ responseSchemaArray for lists); the hand-written response is dropped for those. Service DTOs InboundOption/ApiTokenView/ProbeResultUI are added to the walker.

#4996: client password regeneration now produces a valid Shadowsocks 2022 PSK (correct base64 length per cipher) when an SS2022 inbound is attached, in both the single and bulk client forms; backend surfaces ssMethod on /inbounds/options so the UI can pick the right length.

Also: Swagger UI persists the Authorization token across reloads (persistAuthorization).
2026-06-06 14:58:15 +02:00
MHSanaei 483952cfa0 fix(finalmask): validate fragment mask length so empty/zero-min can't crash xray
A fragment TCP finalmask with an empty length (the form's default for a
newly added mask) serializes to a 0-0 range, and xray-core rejects
LengthMin == 0 with a fatal config error that aborts the whole process,
taking every inbound offline. Default a new fragment mask to length
100-200 and add a form validator rejecting an empty value or a zero
minimum range before save. Verified against xray 26.6.1 (#4998).
2026-06-06 13:34:53 +02:00
MHSanaei 668c0922ca fix(sub): restore standard base64 for Shadowrocket sub link (#5001)
URL-safe base64 (-/_ with stripped padding) broke Shadowrocket import: it decodes the add/sub path segment as standard base64 and rejects -/_, so the subscription was silently not added. Revert to plain btoa() output as originally shipped in #3489.
2026-06-06 13:10:36 +02:00
MHSanaei 1b2a17f7e3 i18n: translate #4988 sockopt/REALITY-target/Freedom strings for all locales
Commit 6ed6f57b (#4988) added tcpWindowClampHint, the four realityTarget* keys, and the three FreedomHappyEyeballs* keys to en-US only. Fill in the other 12 locales so the new sockopt hint, REALITY target validation messages, and Freedom Happy Eyeballs options are localized. Technical tokens (REALITY, Xray-core, IPv4/IPv6, Happy Eyeballs, port examples, ms) are kept literal.
2026-06-06 12:42:30 +02:00
Sanaei e6c1ce9aa9 feat(nodes): multi-hop node attribution for chained sub-nodes (#4983) (#5005)
* feat(nodes): add stable panel GUID identity (multi-hop phase 0)

Per-panel autoincrement node ids are meaningless one hop away, so in a chained topology (Node1 -> Node2 -> Node3) the master cannot attribute online clients or inbounds to the physical node that hosts them (#4983).

Introduce a stable self-identifier: each panel generates and persists a panelGuid (settings table, mirroring GetSecret), returns it in panel/api/server/status, and the master learns it per node via the heartbeat into a new Node.Guid column. Guarded so an old-build node or a failed probe never clears a known GUID. No behavior change yet - this is the identity foundation Phases 1-2 key on.

Refs #4983

* feat(nodes): attribute inbounds to their origin node by GUID (multi-hop phase 1)

Add Inbound.OriginNodeGuid: the GUID of the panel that physically hosts an inbound. Empty means this panel's own xray; set means it was synced from a node. SetRemoteTraffic now fills it per synced inbound - keeping a non-empty value the node forwarded from its own sub-node (so a transitive inbound stays attributed to the deepest node across hops), and otherwise attributing the node's own local inbounds to that node's GUID. Empty (old-build node without a GUID) leaves the existing node_id-based attribution untouched.

The field rides the existing inbound JSON, so /list propagates it up the chain with no serve-side change. Phase 2 will key per-node online off this instead of the panel-local node_id.

Refs #4983

* feat(nodes): key online status by node GUID end-to-end (multi-hop phase 2)

Replace the panel-local node-id keying of per-node online status with the stable panelGuid, so a client several hops down a node chain is attributed to the node that physically hosts it instead of the intermediate node it syncs through (#4983).

xray/process.go stores each direct node's reported GUID-keyed subtree and merges them (correct at any depth); the service assembles GetOnlineClientsByGuid (own clients under this panel's GUID + every node under its GUID). FetchTrafficSnapshot fetches the new /clients/onlinesByGuid, falling back to the flat /onlines for old-build nodes (keyed under the node's GUID or a master-local synthetic id). The node rollup, the WS onlineByGuid/activeInbounds fields, and the inbounds-page rollup all scope by GUID; local inbounds get their OriginNodeGuid filled with the panel's GUID at serve time so the frontend keys uniformly.

Old-build nodes degrade to the prior flat behaviour via the synthetic node:<id> key. Refs #4983

Refs #4983

* feat(nodes): surface transitive sub-nodes on the master (multi-hop phase 3a)

Each panel publishes read-only summaries of the nodes it manages via GET /panel/api/server/descendants (node API token). The heartbeat job caches each direct node's summaries; GetNodeTree merges them as transitive model.Node projections (Id 0, Transitive=true, ParentGuid = their parent node's GUID) and recomputes InboundCount/OnlineCount/DepletedCount per origin GUID so a direct node shows only its own inbounds and each sub-node shows its own (#4983).

The Nodes-page list endpoint and the heartbeat broadcast now return the tree; GetAll stays direct-only for probing/syncing. One transitive level is surfaced (covers Node1->Node2->Node3); deeper recursion is a follow-up. Backend only - the Nodes-page nested UI lands next.

Refs #4983

* feat(nodes): render transitive sub-nodes nested + read-only on the Nodes page (multi-hop phase 3b)

The Nodes page now shows a node's downstream sub-nodes (learned via the descendants tree) as indented, read-only rows ordered right under their parent: no enable toggle, probe, edit, delete, update, selection, or history expander - just a 'Sub-node' tag whose tooltip names the parent it is reached through. Desktop table and mobile cards both handle it. Transitive rows are keyed by GUID (their Id is 0) so they don't collide with real nodes (#4983).

Rows nest by parentGuid rather than AntD tree-children to avoid clashing with the existing per-row history expander. New labels added to en-US (other locales fall back until translated). Refs #4983

Refs #4983

* i18n(nodes): translate subNode/subNodeTip across all locales

Phase 3b added these two Nodes-page keys (read-only sub-node tag + tooltip) only to en-US; fill in the other 12 locales so the multi-hop sub-node UI is fully localized. The {parent} placeholder is preserved in every translation.

Refs #4983
2026-06-06 12:33:39 +02:00
nima1024m 6ed6f57b5c fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target (#4988)
* fix(panel): normalize XHTTP/sockopt/Reality wire output and validate REALITY target

Strip mode-specific XHTTP fields for stream-one, reset harmful sockopt defaults
to 0, split server/client Reality fields on save, validate target host:port in
the inbound form, and expose Happy Eyeballs for the direct freedom outbound.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(panel): keep REALITY public key on the wire, guard freedom noises

The REALITY server/client wire split deleted realitySettings.settings on save, but the panel stores the REALITY public key there and every share-link / subscription generator reads it back from that path (frontend inbound-link.ts, Go subService/subJsonService/subClashService). Stripping it produced empty pbk= links, breaking client connectivity after save+reload.

Revert the reality normalization (drop normalizeRealityForWire and the key sets), restore the inbound REALITY form fields (uTLS, spiderX, publicKey, mldsa65Verify) while keeping the new validated target field, and restore the mldsa65Verify clear handler.

Also guard freedomToWire against undefined noises/finalRules (same defensive treatment as the existing fragment guard, issue #4686) which the new freedom-outbound test surfaced as a crash. Tests now assert the public key is preserved.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: MHSanaei <ho3ein.sanaei@gmail.com>
2026-06-06 02:40:32 +02:00
MHSanaei e409bc305d fix(iplimit): skip stale access-log emails after client rename/delete
The IP-limit job scrapes the Xray access log, which keeps lines tagged with a client's old email for up to a log-rotation cycle after a rename or delete. For each such email getInboundByEmail (settings LIKE %email%) found nothing, so the job logged 'failed to fetch inbound settings: record not found' every run and recreated an inbound_client_ips row for the dead email (rows reappeared even after manual deletion).

processLogFile now resolves the inbound once per email: if it maps to no inbound (gorm.ErrRecordNotFound) it logs at Debug, drops any orphan tracking row, and skips - so stale entries self-heal instead of spamming ERROR. The resolved inbound is passed into updateInboundClientIps, removing its internal lookup. updateClientTraffics also calls DelClientIPs alongside DelClientStat so a full inbound edit that drops an email doesn't leave a ghost row.

Closes #4963
2026-06-06 02:20:39 +02:00
MHSanaei 2b4e199a97 fix(sub): don't project public inbounds through a fallback master
A standalone inbound bound to a public/wildcard listen that still carried a stale inbound_fallbacks row had its share/subscription link rewritten with the master's port + Reality/TLS settings (keeping only its own transport), producing an unusable link that silently fails - the client connects but no traffic flows. The leak hit every backend link surface: subscription URL, JSON sub, Clash sub, and the panel Client Information link.

Gate projectThroughFallbackMaster on reachability: only project a child that is not directly reachable on its own listen (loopback or a unix-domain socket). A public or wildcard inbound advertises its own port + security regardless of any fallback row. Legit loopback/socket fallback children still project as before.

Closes #4987
2026-06-06 02:13:39 +02:00
MHSanaei 75bc6e8076 fix(inbound-form): wrap long labels and shorten RU pinned-cert label
Long TLS-tab labels overflowed their field in locales with wider strings (e.g. Russian 'Pinned Peer Cert SHA-256'). Add AntD labelWrap to the inbound and outbound form modals so any over-long label wraps onto a second line instead of overflowing, and shorten the Russian pinnedPeerCertSha256 label to fit.

Closes #4986
2026-06-06 01:53:46 +02:00
MHSanaei eeb19b7240 fix(node-sync): merge client enable with boolean AND for PostgreSQL
The per-client traffic merge built enable = CASE WHEN ? = 0 THEN 0 ELSE enable END, mixing an integer literal with the boolean enable column. PostgreSQL rejects this with SQLSTATE 42804, aborting every node traffic merge transaction every 5s and freezing all up/down/last_online accounting on Postgres main panels. Replace with enable AND ?, which is type-safe on Postgres (boolean AND boolean) and identical in semantics on SQLite: the node may only disable a client, never re-enable one the panel already disabled.

Closes #4964
2026-06-06 01:46:55 +02:00
MHSanaei 5b9db13e55 fix(finalmask): treat sudoku customTables as array of tables
customTables is the plural array form of customTable, so default it to [""] and edit it with a tags Select instead of binding a text Input to an array value.
2026-06-06 01:35:14 +02:00
Sanaei 0706b0b3a8 feat(x-ui.sh): add migrateDB command for SQLite .db <-> .dump (#4910)
* feat(x-ui.sh): add migrateDB command and menu for SQLite .db <-> .dump

Adds an "x-ui migrateDB <file>" subcommand and a PostgreSQL-menu option (9)
that convert between a SQLite .db and a portable .dump file. Direction is
auto-detected from the extension and delegated to the bundled binary
(x-ui migrate-db --dump/--restore), so no external sqlite3 client is needed.

Depends on the matching binary support, so it is only usable from the next
panel release.

* fix(x-ui.sh): address review feedback on migrateDB

Per Copilot review on PR #4910:
- Probe the bundled binary for migrate-db --dump support and fail with a clear
  upgrade message instead of a raw "flag not defined" error on old builds.
- Prompt before overwriting an existing .dump in dump mode (parity with restore).
- Refuse to restore into the live database path while x-ui is running, to avoid
  corrupting the running panel.
- Fix the usage/synopsis strings to show input is optional ([file] not <file>).
2026-06-05 11:28:11 +02:00
96 changed files with 7354 additions and 751 deletions
+17
View File
@@ -37,6 +37,23 @@ jobs:
go list ./... | grep -v '/frontend/node_modules/' > /tmp/go-packages.txt
go test $(cat /tmp/go-packages.txt)
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true
- uses: actions/setup-node@v6
with:
node-version-file: .nvmrc
- name: Regenerate schemas, examples and OpenAPI
run: npm run gen
working-directory: frontend
- name: Fail if generated files are stale (run 'npm run gen' and commit)
run: git diff --exit-code -- frontend/src/generated frontend/public/openapi.json
govulncheck:
runs-on: ubuntu-latest
steps:
+177
View File
@@ -0,0 +1,177 @@
[English](/README.md) | [فارسی](/README.fa_IR.md) | [العربية](/README.ar_EG.md) | [中文](/README.zh_CN.md) | [Español](/README.es_ES.md) | [Русский](/README.ru_RU.md) | [Türkçe](/README.tr_TR.md)
<p align="center">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/3x-ui-dark.png">
<img alt="3x-ui" src="./media/3x-ui-light.png">
</picture>
</p>
<p align="center">
<a href="https://github.com/MHSanaei/3x-ui/releases"><img src="https://img.shields.io/github/v/release/mhsanaei/3x-ui" alt="Release"></a>
<a href="https://github.com/MHSanaei/3x-ui/actions"><img src="https://img.shields.io/github/actions/workflow/status/mhsanaei/3x-ui/release.yml.svg" alt="Build"></a>
<a href="#"><img src="https://img.shields.io/github/go-mod/go-version/mhsanaei/3x-ui.svg" alt="GO Version"></a>
<a href="https://github.com/MHSanaei/3x-ui/releases/latest"><img src="https://img.shields.io/github/downloads/mhsanaei/3x-ui/total.svg" alt="Downloads"></a>
<a href="https://www.gnu.org/licenses/gpl-3.0.en.html"><img src="https://img.shields.io/badge/license-GPL%20V3-blue.svg?longCache=true" alt="License"></a>
<a href="https://pkg.go.dev/github.com/mhsanaei/3x-ui/v3"><img src="https://pkg.go.dev/badge/github.com/mhsanaei/3x-ui/v3.svg" alt="Go Reference"></a>
<a href="https://goreportcard.com/report/github.com/mhsanaei/3x-ui/v3"><img src="https://goreportcard.com/badge/github.com/mhsanaei/3x-ui/v3" alt="Go Report Card"></a>
</p>
**3X-UI**, [Xray-core](https://github.com/XTLS/Xray-core) sunucularını yönetmek için geliştirilmiş, gelişmiş ve açık kaynaklı bir web kontrol panelidir. Tek bir VPS'den çok düğümlü (multi-node) kurulumlara kadar çok çeşitli proxy ve VPN protokollerini kurmak, yapılandırmak ve izlemek için temiz, çok dilli bir arayüz sağlar.
Orijinal X-UI projesinin geliştirilmiş bir çatalı (fork) olarak inşa edilen 3X-UI; daha geniş protokol desteği, iyileştirilmiş kararlılık, kullanıcı başına (per-client) trafik takibi ve birçok yaşam kalitesi (QoL) özelliği ekler.
> [!IMPORTANT]
> Bu proje yalnızca kişisel kullanım içindir. Lütfen yasa dışı amaçlarla veya üretim (production) ortamında kullanmayın.
## Özellikler
- **Çoklu protokol destekli bağlantı noktaları (Inbounds)** — VLESS, VMess, Trojan, Shadowsocks, WireGuard, Hysteria2, HTTP, SOCKS (Mixed), Dokodemo-door / Tunnel ve TUN.
- **Modern aktarım (Transport) & güvenlik** — TCP (Raw), mKCP, WebSocket, gRPC, HTTPUpgrade ve XHTTP; TLS, XTLS ve REALITY ile güvenli hale getirilmiştir.
- **Yedek bağlantılar (Fallbacks)** — Xray'in fallback desteğini kullanarak tek bir port (örn. 443) üzerinden birden fazla protokol (örn. VLESS ve Trojan) sunma.
- **Kullanıcı bazlı yönetim** — Trafik kotaları, son kullanma tarihleri, IP sınırları, canlı çevrimiçi (online) durumu ve tek tıklamayla paylaşım bağlantıları, QR kodları ve abonelikler.
- **Trafik istatistikleri** — Bağlantı noktası, kullanıcı ve çıkış noktası (outbound) bazında istatistikler ve sıfırlama kontrolleri.
- **Çoklu düğüm (Multi-node) desteği** — Tek bir panelden birden fazla sunucuyu yönetin ve ölçeklendirin.
- **Çıkış noktaları & yönlendirme (Outbound & Routing)** — WARP, NordVPN, özel yönlendirme kuralları, yük dengeleyiciler (load balancers) ve çıkış noktası proxy zincirleme.
- **Dahili abonelik sunucusu** (Birden fazla çıktı formatıyla).
- **Telegram botu** (Uzaktan izleme ve yönetim için).
- **RESTful API** (Panel içi Swagger dokümantasyonu ile).
- **Esnek veritabanı** — SQLite (varsayılan) veya PostgreSQL.
- **13 Kullanıcı Arayüzü (UI) dili** (Karanlık ve aydınlık tema destekli).
- **Fail2ban entegrasyonu** (Kullanıcı bazlı IP sınırlarını zorlamak için).
## Ekran Görüntüleri
<details>
<summary>Genişletmek için tıklayın</summary>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/01-overview-dark.png">
<img alt="Genel Bakış" src="./media/01-overview-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/02-add-inbound-dark.png">
<img alt="Bağlantı Noktaları" src="./media/02-add-inbound-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/03-add-client-dark.png">
<img alt="Kullanıcı Ekle" src="./media/03-add-client-light.png">
</picture>
<picture>
<source media="(prefers-color-scheme: dark)" srcset="./media/05-add-nodes-dark.png">
<img alt="Yapılandırmalar" src="./media/05-add-nodes-light.png">
</picture>
</details>
## Hızlı Başlangıç
```bash
bash <(curl -Ls https://raw.githubusercontent.com/mhsanaei/3x-ui/master/install.sh)
```
Kurulum sırasında rastgele bir kullanıcı adı, şifre ve erişim yolu (path) oluşturulur. Kurulumdan sonra `x-ui` komutunu çalıştırarak yönetim menüsünü açabilir; buradan hizmeti başlatabilir/durdurabilir, giriş bilgilerinizi görüntüleyebilir veya sıfırlayabilir, SSL sertifikalarını yönetebilir ve daha fazlasını yapabilirsiniz.
Tam dokümantasyon için lütfen [proje Wiki sayfasını](https://github.com/MHSanaei/3x-ui/wiki) ziyaret edin.
## Desteklenen Platformlar
**İşletim sistemleri:** Ubuntu, Debian, Armbian, Fedora, CentOS, RHEL, AlmaLinux, Rocky Linux, Oracle Linux, Amazon Linux, Virtuozzo, Arch, Manjaro, Parch, openSUSE (Tumbleweed / Leap), Alpine ve Windows.
**Mimariler:** `amd64` · `386` · `arm64` (aarch64) · `armv7` · `armv6` · `armv5` · `s390x`.
## Veritabanı Seçenekleri
3X-UI, kurulum sırasında seçilebilen iki arka uç (backend) destekler:
- **SQLite** (varsayılan) — `/etc/x-ui/x-ui.db` konumunda tek bir dosya. Sıfır kurulum gerektirir, küçük ve orta ölçekli dağıtımlar için idealdir.
- **PostgreSQL** — Yüksek kullanıcı sayıları veya çok düğümlü (multi-node) kurulumlar için önerilir. Yükleyici sizin için PostgreSQL'i yerel olarak kurabilir veya mevcut bir sunucuya DSN ile bağlanabilir.
Çalışma anında arka uç, ortam değişkenleri (environment variables) aracılığıyla seçilir (yükleyici bunları sizin için `/etc/default/x-ui` dosyasına yazar):
```
XUI_DB_TYPE=postgres
XUI_DB_DSN=postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable
```
### Mevcut bir SQLite kurulumunu PostgreSQL'e taşıma
```bash
x-ui migrate-db --dsn "postgres://xui:password@127.0.0.1:5432/xui?sslmode=disable"
# Ardından /etc/default/x-ui dosyasında XUI_DB_TYPE ve XUI_DB_DSN değerlerini ayarlayıp yeniden başlatın:
systemctl restart x-ui
```
Kaynak SQLite dosyasına dokunulmaz; yeni arka ucu doğruladıktan sonra eski dosyayı manuel olarak silebilirsiniz.
### Docker
Varsayılan `docker compose up -d` komutu SQLite kullanmaya devam eder. Dahili PostgreSQL hizmetiyle çalıştırmak için `docker-compose.yml` dosyasındaki iki `XUI_DB_*` ortam değişkeni satırının başındaki yorum işaretini kaldırın ve profille başlatın:
```bash
docker compose --profile postgres up -d
```
İmaj, kullanıcı bazlı **IP sınırlarını** zorlamak için Fail2ban'i (varsayılan olarak etkindir) içerir. Fail2ban, ihlalcileri `iptables` ile engeller ve bu işlem `NET_ADMIN` yetkisi gerektirir. `docker-compose.yml` bunu `cap_add` aracılığıyla zaten sağlar; eğer container'ı bunun yerine `docker run` ile başlatırsanız yetkileri kendiniz eklemelisiniz, aksi takdirde engellemeler sadece günlüğe (log) kaydedilir ancak asla uygulanmaz:
```bash
docker run -d --cap-add=NET_ADMIN --cap-add=NET_RAW ... ghcr.io/mhsanaei/3x-ui
```
## Ortam Değişkenleri
| Değişken | Açıklama | Varsayılan |
| --- | --- | --- |
| `XUI_DB_TYPE` | Veritabanı arka ucu: `sqlite` veya `postgres` | `sqlite` |
| `XUI_DB_DSN` | PostgreSQL bağlantı dizesi (`XUI_DB_TYPE=postgres` olduğunda) | — |
| `XUI_DB_FOLDER` | SQLite veritabanı dosyası için dizin | `/etc/x-ui` |
| `XUI_DB_MAX_OPEN_CONNS` | Maksimum açık bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_DB_MAX_IDLE_CONNS` | Maksimum boşta bağlantı sayısı (PostgreSQL havuzu) | — |
| `XUI_ENABLE_FAIL2BAN` | Fail2ban tabanlı IP sınırı zorlamasını etkinleştir | `true` |
| `XUI_LOG_LEVEL` | Log detay seviyesi (`debug`, `info`, `warning`, `error`) | `info` |
| `XUI_DEBUG` | Hata ayıklama (debug) modunu etkinleştir | `false` |
## Desteklenen Diller
Panel kullanıcı arayüzü 13 dilde mevcuttur:
English · فارسی · العربية · 中文(简体) · 中文(繁體) · Español · Русский · Українська · Türkçe · Tiếng Việt · 日本語 · Bahasa Indonesia · Português (Brasil)
## Katkıda Bulunma
Katkılara açığız. Lütfen bir sorun (issue) veya çekme isteği (pull request) açmadan önce [Katkıda Bulunma Rehberi](/CONTRIBUTING.md)'ni okuyun.
## Özel Teşekkürler
- [alireza0](https://github.com/alireza0/)
## Teşekkür
- [Iran v2ray rules](https://github.com/chocolate4u/Iran-v2ray-rules) (Lisans: **GPL-3.0**): _Dahili İran alan adları ve güvenlik/reklam engelleme odaklı geliştirilmiş v2ray/xray ve v2ray/xray-clients yönlendirme kuralları._
- [Russia v2ray rules](https://github.com/runetfreedom/russia-v2ray-rules-dat) (Lisans: **GPL-3.0**): _Rusya'daki engellenmiş alan adları ve adreslere dayalı otomatik olarak güncellenen V2Ray yönlendirme kuralları içerir._
## Topluluk Araçları
3x-ui etrafında topluluk tarafından geliştirilen araçlar ve entegrasyonlar.
- [terraform-provider-3x-ui](https://github.com/batonogov/terraform-provider-threexui) (Lisans: **MIT**): _Bağlantı noktalarını, kullanıcıları, panel ayarlarını ve Xray yapılandırmasını Terraform / OpenTofu ile kod olarak yönetin._
## Projeyi Destekleyin
**Eğer bu proje sizin için faydalıysa, bir :star2: (yıldız) verebilirsiniz.**
<a href="https://www.buymeacoffee.com/MHSanaei" target="_blank">
<img src="./media/default-yellow.png" alt="Buy Me A Coffee" style="height: 70px !important;width: 277px !important;" >
</a>
</br>
<a href="https://nowpayments.io/donation/hsanaei" target="_blank" rel="noreferrer noopener">
<img src="./media/donation-button-black.svg" alt="Crypto donation button by NOWPayments">
</a>
## Zaman İçindeki Yıldız Sayısı
[![Zaman İçindeki Yıldız Sayısı](https://starchart.cc/MHSanaei/3x-ui.svg?variant=adaptive)](https://starchart.cc/MHSanaei/3x-ui)
+69 -28
View File
@@ -41,13 +41,13 @@ type User struct {
// Inbound represents an Xray inbound configuration with traffic statistics and settings.
type Inbound struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"` // Unique identifier
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"` // Unique identifier
UserId int `json:"-"` // Associated user ID
Up int64 `json:"up" form:"up"` // Upload traffic in bytes
Down int64 `json:"down" form:"down"` // Download traffic in bytes
Total int64 `json:"total" form:"total"` // Total traffic limit in bytes
Remark string `json:"remark" form:"remark"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1"` // Whether the inbound is enabled
Remark string `json:"remark" form:"remark" example:"VLESS-443"` // Human-readable remark
Enable bool `json:"enable" form:"enable" gorm:"index:idx_enable_traffic_reset,priority:1" example:"true"` // Whether the inbound is enabled
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"` // Expiration timestamp
TrafficReset string `json:"trafficReset" form:"trafficReset" gorm:"default:never;index:idx_enable_traffic_reset,priority:2" validate:"omitempty,oneof=never hourly daily weekly monthly"` // Traffic reset schedule
LastTrafficResetTime int64 `json:"lastTrafficResetTime" form:"lastTrafficResetTime" gorm:"default:0"` // Last traffic reset timestamp
@@ -55,14 +55,22 @@ type Inbound struct {
// Xray configuration fields
Listen string `json:"listen" form:"listen"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun"`
Port int `json:"port" form:"port" validate:"gte=0,lte=65535" example:"443"`
Protocol Protocol `json:"protocol" form:"protocol" validate:"required,oneof=vmess vless trojan shadowsocks wireguard hysteria http mixed tunnel tun" example:"vless"`
Settings string `json:"settings" form:"settings"`
StreamSettings string `json:"streamSettings" form:"streamSettings"`
Tag string `json:"tag" form:"tag" gorm:"unique"`
Tag string `json:"tag" form:"tag" gorm:"unique" example:"in-443-tcp"`
Sniffing string `json:"sniffing" form:"sniffing"`
NodeID *int `json:"nodeId,omitempty" form:"nodeId" gorm:"index"`
// OriginNodeGuid is the panelGuid of the node that physically hosts this
// inbound, propagated up across hops (#4983). Empty for an inbound that
// lives on this panel's own xray; set to the originating node's GUID when
// the inbound was synced from a node (kept as-is across further hops). Lets
// the master attribute a deeply nested inbound to the real node instead of
// the intermediate one it was fetched through.
OriginNodeGuid string `json:"originNodeGuid,omitempty" form:"originNodeGuid" gorm:"column:origin_node_guid;index"`
// FallbackParent is populated by the API layer when this inbound is
// attached as a fallback child of a VLESS/Trojan TCP-TLS master.
// The frontend uses it to rewrite client-share links so they advertise
@@ -370,42 +378,75 @@ type Setting struct {
// endpoint over HTTP using the per-node ApiToken to populate the runtime
// status fields below.
type Node struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required"`
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"1"`
Name string `json:"name" form:"name" gorm:"uniqueIndex" validate:"required" example:"de-fra-1"`
Remark string `json:"remark" form:"remark"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"`
Address string `json:"address" form:"address" validate:"required"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535"`
BasePath string `json:"basePath" form:"basePath"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required"`
Enable bool `json:"enable" form:"enable" gorm:"default:true"`
Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https" example:"https"`
Address string `json:"address" form:"address" validate:"required" example:"node1.example.com"`
Port int `json:"port" form:"port" validate:"gte=1,lte=65535" example:"2053"`
BasePath string `json:"basePath" form:"basePath" example:"/"`
ApiToken string `json:"apiToken" form:"apiToken" validate:"required" example:"abcdef0123456789"`
Enable bool `json:"enable" form:"enable" gorm:"default:true" example:"true"`
AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress" gorm:"default:false"`
TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" gorm:"column:tls_verify_mode;default:verify" validate:"omitempty,oneof=verify skip pin"`
PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256" gorm:"column:pinned_cert_sha256"`
// Guid is the remote panel's stable self-identifier (its panelGuid),
// learned from each heartbeat. It is the globally stable node identity used
// to attribute online clients/inbounds to the physical node across a chain
// of nodes (#4983); panel-local autoincrement ids don't survive a hop.
// Observed-state only — never user-edited.
Guid string `json:"guid" gorm:"column:guid;index"`
// Heartbeat-updated fields. UpdatedAt advances on every probe even when
// the row is otherwise unchanged so the UI's "last seen" tooltip is
// truthful without us having to read LastHeartbeat separately.
Status string `json:"status" gorm:"default:unknown"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
Status string `json:"status" gorm:"default:unknown" example:"online"` // online|offline|unknown
LastHeartbeat int64 `json:"lastHeartbeat" example:"1700000000"` // unix seconds, 0 = never
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" gorm:"column:panel_version" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"23.5"`
MemPct float64 `json:"memPct" example:"45.1"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
LastError string `json:"lastError"`
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
ConfigDirtyAt int64 `json:"configDirtyAt"`
InboundCount int `json:"inboundCount" gorm:"-"`
ClientCount int `json:"clientCount" gorm:"-"`
OnlineCount int `json:"onlineCount" gorm:"-"`
DepletedCount int `json:"depletedCount" gorm:"-"`
InboundCount int `json:"inboundCount" gorm:"-" example:"5"`
ClientCount int `json:"clientCount" gorm:"-" example:"27"`
OnlineCount int `json:"onlineCount" gorm:"-" example:"3"`
DepletedCount int `json:"depletedCount" gorm:"-" example:"1"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli"`
// ParentGuid + Transitive are set only when a node is surfaced as part of a
// node tree (#4983): direct nodes carry the master panel's own GUID, a
// transitive sub-node carries its parent node's GUID. Transitive nodes are
// read-only projections (Id == 0, not persisted) — never edited or deployed.
ParentGuid string `json:"parentGuid,omitempty" gorm:"-"`
Transitive bool `json:"transitive,omitempty" gorm:"-"`
CreatedAt int64 `json:"createdAt" gorm:"autoCreateTime:milli" example:"1700000000"`
UpdatedAt int64 `json:"updatedAt" gorm:"autoUpdateTime:milli" example:"1700000000"`
}
// NodeSummary is the read-only identity of a node as published one hop up: the
// view a panel exposes about the nodes it directly manages, so a master can
// surface transitive sub-nodes in a chained topology (#4983). Counts are
// computed by the consuming master from its own per-GUID data, never trusted
// from the child, so this carries identity/health only.
type NodeSummary struct {
Guid string `json:"guid"`
ParentGuid string `json:"parentGuid"`
Name string `json:"name"`
Address string `json:"address"`
Scheme string `json:"scheme"`
Port int `json:"port"`
Status string `json:"status"`
LastHeartbeat int64 `json:"lastHeartbeat"`
LatencyMs int `json:"latencyMs"`
PanelVersion string `json:"panelVersion"`
XrayVersion string `json:"xrayVersion"`
}
type CustomGeoResource struct {
+1
View File
@@ -16,6 +16,7 @@
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"gen": "npm run gen:zod && npm run gen:api",
"gen:api": "node --experimental-strip-types --disable-warning=ExperimentalWarning scripts/build-openapi.mjs",
"gen:zod": "cd .. && go run ./tools/openapigen"
},
+1984 -119
View File
File diff suppressed because it is too large Load Diff
+21 -3
View File
@@ -4,6 +4,8 @@ import { join, dirname } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { sections } from '../src/pages/api-docs/endpoints.ts';
import { EXAMPLES } from '../src/generated/examples.ts';
import { SCHEMAS } from '../src/generated/schemas.ts';
const __dirname = dirname(fileURLToPath(import.meta.url));
const outPath = join(__dirname, '..', 'public', 'openapi.json');
@@ -128,7 +130,22 @@ function buildOperation(ep, tag) {
}
const responses = {};
const successExample = tryParseJson(ep.response);
let successExample = tryParseJson(ep.response);
let objSchema = {};
if (ep.responseSchema) {
const obj = EXAMPLES[ep.responseSchema];
if (obj === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated example`);
}
if (SCHEMAS[ep.responseSchema] === undefined) {
throw new Error(`${ep.method} ${ep.path}: responseSchema "${ep.responseSchema}" has no generated schema`);
}
const ref = { $ref: `#/components/schemas/${ep.responseSchema}` };
objSchema = ep.responseSchemaArray ? { type: 'array', items: ref } : ref;
if (successExample === undefined) {
successExample = { success: true, obj: ep.responseSchemaArray ? [obj] : obj };
}
}
responses['200'] = {
description: 'Successful response',
content: {
@@ -138,7 +155,7 @@ function buildOperation(ep, tag) {
properties: {
success: { type: 'boolean' },
msg: { type: 'string' },
obj: {},
obj: objSchema,
},
},
...(successExample !== undefined ? { example: successExample } : {}),
@@ -192,13 +209,14 @@ function buildSpec() {
title: '3X-UI Panel API',
version: PANEL_VERSION,
description:
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes.',
'Programmatic interface to a 3X-UI panel. Authenticate either by logging in (cookie) or with an API token from Settings → Security → API Token (Bearer). All endpoints under /panel/api/* honour both modes — an API token is a full-admin credential, so treat it like the panel password.',
},
servers: [
{ url: '/', description: 'Current panel (basePath aware)' },
],
components: {
securitySchemes: SECURITY_SCHEMES,
schemas: SCHEMAS,
},
security: [{ bearerAuth: [] }, { cookieAuth: [] }],
tags,
+2 -2
View File
@@ -8,7 +8,7 @@ import { AllSettingSchema, type AllSettingInput } from '@/schemas/setting';
import { keys } from '@/api/queryKeys';
async function fetchAllSetting(): Promise<AllSettingInput | null> {
const msg = await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
const msg = await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch settings');
const validated = parseMsg(msg, AllSettingSchema, 'setting/all');
return validated.obj;
@@ -47,7 +47,7 @@ export function useAllSettings() {
if (!body.success) {
console.warn('[zod] setting/update body failed validation', body.error.issues);
}
return HttpUtil.post('/panel/setting/update', body.success ? body.data : next);
return HttpUtil.post('/panel/api/setting/update', body.success ? body.data : next);
},
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.settings.all() });
+1 -1
View File
@@ -21,7 +21,7 @@ export const keys = {
list: (params: unknown) => ['clients', 'list', params] as const,
all: () => ['clients', 'all'] as const,
onlines: () => ['clients', 'onlines'] as const,
onlinesByNode: () => ['clients', 'onlinesByNode'] as const,
onlinesByGuid: () => ['clients', 'onlinesByGuid'] as const,
activeInbounds: () => ['clients', 'activeInbounds'] as const,
lastOnline: () => ['clients', 'lastOnline'] as const,
groups: () => ['clients', 'groups'] as const,
+396
View File
@@ -0,0 +1,396 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export const EXAMPLES: Record<string, unknown> = {
"AllSetting": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"AllSettingView": {
"datepicker": "",
"expireDiff": 0,
"externalTrafficInformEnable": false,
"externalTrafficInformURI": "",
"hasApiToken": false,
"hasLdapPassword": false,
"hasNordSecret": false,
"hasTgBotToken": false,
"hasTwoFactorToken": false,
"hasWarpSecret": false,
"ldapAutoCreate": false,
"ldapAutoDelete": false,
"ldapBaseDN": "",
"ldapBindDN": "",
"ldapDefaultExpiryDays": 0,
"ldapDefaultLimitIP": 0,
"ldapDefaultTotalGB": 0,
"ldapEnable": false,
"ldapFlagField": "",
"ldapHost": "",
"ldapInboundTags": "",
"ldapInvertFlag": false,
"ldapPassword": "",
"ldapPort": 0,
"ldapSyncCron": "",
"ldapTruthyValues": "",
"ldapUseTLS": false,
"ldapUserAttr": "",
"ldapUserFilter": "",
"ldapVlessField": "",
"pageSize": 0,
"panelProxy": "",
"remarkModel": "",
"restartXrayOnClientDisable": false,
"sessionMaxAge": 1,
"subAnnounce": "",
"subCertFile": "",
"subClashEnable": false,
"subClashEnableRouting": false,
"subClashPath": "",
"subClashRules": "",
"subClashURI": "",
"subDomain": "",
"subEmailInRemark": false,
"subEnable": false,
"subEnableRouting": false,
"subEncrypt": false,
"subJsonEnable": false,
"subJsonFinalMask": "",
"subJsonMux": "",
"subJsonPath": "",
"subJsonRules": "",
"subJsonURI": "",
"subKeyFile": "",
"subListen": "",
"subPath": "",
"subPort": 1,
"subProfileUrl": "",
"subRoutingRules": "",
"subShowInfo": false,
"subSupportUrl": "",
"subTitle": "",
"subURI": "",
"subUpdates": 0,
"tgBotAPIServer": "",
"tgBotBackup": false,
"tgBotChatId": "",
"tgBotEnable": false,
"tgBotLoginNotify": false,
"tgBotProxy": "",
"tgBotToken": "",
"tgCpu": 0,
"tgLang": "",
"tgRunTime": "",
"timeLocation": "",
"trafficDiff": 0,
"trustedProxyCIDRs": "",
"twoFactorEnable": false,
"twoFactorToken": "",
"webBasePath": "",
"webCertFile": "",
"webDomain": "",
"webKeyFile": "",
"webListen": "",
"webPort": 1
},
"ApiToken": {
"createdAt": 0,
"enabled": false,
"id": 0,
"name": "",
"token": ""
},
"ApiTokenView": {
"createdAt": 1736000000,
"enabled": true,
"id": 2,
"name": "central-panel-a",
"token": "new-token-string"
},
"Client": {
"auth": "",
"comment": "",
"created_at": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": "",
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updated_at": 0
},
"ClientInbound": {
"clientId": 0,
"createdAt": 0,
"flowOverride": "",
"inboundId": 0
},
"ClientRecord": {
"auth": "",
"comment": "",
"createdAt": 0,
"email": "",
"enable": false,
"expiryTime": 0,
"flow": "",
"group": "",
"id": 0,
"limitIp": 0,
"password": "",
"reset": 0,
"reverse": null,
"security": "",
"subId": "",
"tgId": 0,
"totalGB": 0,
"updatedAt": 0,
"uuid": ""
},
"ClientReverse": {
"tag": ""
},
"ClientTraffic": {
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
},
"CustomGeoResource": {
"alias": "",
"createdAt": 0,
"id": 0,
"lastModified": "",
"lastUpdatedAt": 0,
"localPath": "",
"type": "",
"updatedAt": 0,
"url": ""
},
"FallbackParentInfo": {
"masterId": 0,
"path": ""
},
"HistoryOfSeeders": {
"id": 0,
"seederName": ""
},
"Inbound": {
"clientStats": [
{
"down": 2097152,
"email": "user1",
"enable": true,
"expiryTime": 1735689600000,
"id": 14825,
"inboundId": 1,
"lastOnline": 1735680000000,
"reset": 0,
"subId": "i7tvdpeffi0hvvf1",
"total": 10737418240,
"up": 1048576,
"uuid": "e18c9a96-71bf-48d4-933f-8b9a46d4290c"
}
],
"down": 0,
"enable": true,
"expiryTime": 0,
"fallbackParent": null,
"id": 1,
"lastTrafficResetTime": 0,
"listen": "",
"nodeId": null,
"originNodeGuid": "",
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"settings": null,
"sniffing": null,
"streamSettings": null,
"tag": "in-443-tcp",
"total": 0,
"trafficReset": "never",
"up": 0
},
"InboundClientIps": {
"clientEmail": "",
"id": 0,
"ips": null
},
"InboundFallback": {
"alpn": "",
"childId": 0,
"dest": "",
"id": 0,
"masterId": 0,
"name": "",
"path": "",
"sortOrder": 0,
"xver": 0
},
"InboundOption": {
"id": 1,
"port": 443,
"protocol": "vless",
"remark": "VLESS-443",
"ssMethod": "",
"tag": "in-443-tcp",
"tlsFlowCapable": true
},
"Msg": {
"msg": "",
"obj": null,
"success": false
},
"Node": {
"address": "node1.example.com",
"allowPrivateAddress": false,
"apiToken": "abcdef0123456789",
"basePath": "/",
"clientCount": 27,
"configDirty": false,
"configDirtyAt": 0,
"cpuPct": 23.5,
"createdAt": 1700000000,
"depletedCount": 1,
"enable": true,
"guid": "",
"id": 1,
"inboundCount": 5,
"lastError": "",
"lastHeartbeat": 1700000000,
"latencyMs": 42,
"memPct": 45.1,
"name": "de-fra-1",
"onlineCount": 3,
"panelVersion": "v3.x.x",
"parentGuid": "",
"pinnedCertSha256": "",
"port": 2053,
"remark": "",
"scheme": "https",
"status": "online",
"tlsVerifyMode": "verify",
"transitive": false,
"updatedAt": 1700000000,
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"OutboundTraffics": {
"down": 0,
"id": 0,
"tag": "",
"total": 0,
"up": 0
},
"ProbeResultUI": {
"cpuPct": 12.5,
"error": "",
"latencyMs": 42,
"memPct": 45.2,
"panelVersion": "v3.x.x",
"status": "online",
"uptimeSecs": 86400,
"xrayVersion": "25.10.31"
},
"Setting": {
"id": 0,
"key": "",
"value": ""
},
"User": {
"id": 0,
"password": "",
"username": ""
}
};
File diff suppressed because it is too large Load Diff
+37
View File
@@ -1,5 +1,9 @@
// Code generated by tools/openapigen. DO NOT EDIT.
export type LoginStatus = number;
export type ProcessState = string;
export type Protocol = string;
export type SubLinkProvider = unknown;
export type transportBits = number;
export interface AllSetting {
datepicker: string;
@@ -179,6 +183,14 @@ export interface ApiToken {
token: string;
}
export interface ApiTokenView {
createdAt: number;
enabled: boolean;
id: number;
name: string;
token?: string;
}
export interface Client {
auth?: string;
comment: string;
@@ -280,6 +292,7 @@ export interface Inbound {
lastTrafficResetTime: number;
listen: string;
nodeId?: number | null;
originNodeGuid?: string;
port: number;
protocol: Protocol;
remark: string;
@@ -310,6 +323,16 @@ export interface InboundFallback {
xver: number;
}
export interface InboundOption {
id: number;
port: number;
protocol: string;
remark: string;
ssMethod: string;
tag: string;
tlsFlowCapable: boolean;
}
export interface Msg {
msg: string;
obj: unknown;
@@ -328,6 +351,7 @@ export interface Node {
createdAt: number;
depletedCount: number;
enable: boolean;
guid: string;
id: number;
inboundCount: number;
lastError: string;
@@ -337,12 +361,14 @@ export interface Node {
name: string;
onlineCount: number;
panelVersion: string;
parentGuid?: string;
pinnedCertSha256: string;
port: number;
remark: string;
scheme: string;
status: string;
tlsVerifyMode: string;
transitive?: boolean;
updatedAt: number;
uptimeSecs: number;
xrayVersion: string;
@@ -356,6 +382,17 @@ export interface OutboundTraffics {
up: number;
}
export interface ProbeResultUI {
cpuPct: number;
error: string;
latencyMs: number;
memPct: number;
panelVersion: string;
status: string;
uptimeSecs: number;
xrayVersion: string;
}
export interface Setting {
id: number;
key: string;
+48
View File
@@ -1,8 +1,20 @@
// Code generated by tools/openapigen. DO NOT EDIT.
import { z } from 'zod';
export const LoginStatusSchema = z.number().int();
export type LoginStatus = z.infer<typeof LoginStatusSchema>;
export const ProcessStateSchema = z.string();
export type ProcessState = z.infer<typeof ProcessStateSchema>;
export const ProtocolSchema = z.string();
export type Protocol = z.infer<typeof ProtocolSchema>;
export const SubLinkProviderSchema = z.unknown();
export type SubLinkProvider = z.infer<typeof SubLinkProviderSchema>;
export const transportBitsSchema = z.number().int();
export type transportBits = z.infer<typeof transportBitsSchema>;
export const AllSettingSchema = z.object({
datepicker: z.string(),
expireDiff: z.number().int().min(0),
@@ -184,6 +196,15 @@ export const ApiTokenSchema = z.object({
});
export type ApiToken = z.infer<typeof ApiTokenSchema>;
export const ApiTokenViewSchema = z.object({
createdAt: z.number().int(),
enabled: z.boolean(),
id: z.number().int(),
name: z.string(),
token: z.string().optional(),
});
export type ApiTokenView = z.infer<typeof ApiTokenViewSchema>;
export const ClientSchema = z.object({
auth: z.string().optional(),
comment: z.string(),
@@ -293,6 +314,7 @@ export const InboundSchema = z.object({
lastTrafficResetTime: z.number().int(),
listen: z.string(),
nodeId: z.number().int().nullable().optional(),
originNodeGuid: z.string().optional(),
port: z.number().int().min(0).max(65535),
protocol: z.enum(['vmess', 'vless', 'trojan', 'shadowsocks', 'wireguard', 'hysteria', 'http', 'mixed', 'tunnel', 'tun']),
remark: z.string(),
@@ -326,6 +348,17 @@ export const InboundFallbackSchema = z.object({
});
export type InboundFallback = z.infer<typeof InboundFallbackSchema>;
export const InboundOptionSchema = z.object({
id: z.number().int(),
port: z.number().int(),
protocol: z.string(),
remark: z.string(),
ssMethod: z.string(),
tag: z.string(),
tlsFlowCapable: z.boolean(),
});
export type InboundOption = z.infer<typeof InboundOptionSchema>;
export const MsgSchema = z.object({
msg: z.string(),
obj: z.unknown(),
@@ -345,6 +378,7 @@ export const NodeSchema = z.object({
createdAt: z.number().int(),
depletedCount: z.number().int(),
enable: z.boolean(),
guid: z.string(),
id: z.number().int(),
inboundCount: z.number().int(),
lastError: z.string(),
@@ -354,12 +388,14 @@ export const NodeSchema = z.object({
name: z.string(),
onlineCount: z.number().int(),
panelVersion: z.string(),
parentGuid: z.string().optional(),
pinnedCertSha256: z.string(),
port: z.number().int().min(1).max(65535),
remark: z.string(),
scheme: z.enum(['http', 'https']),
status: z.string(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']),
transitive: z.boolean().optional(),
updatedAt: z.number().int(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
@@ -375,6 +411,18 @@ export const OutboundTrafficsSchema = z.object({
});
export type OutboundTraffics = z.infer<typeof OutboundTrafficsSchema>;
export const ProbeResultUISchema = z.object({
cpuPct: z.number(),
error: z.string(),
latencyMs: z.number().int(),
memPct: z.number(),
panelVersion: z.string(),
status: z.string(),
uptimeSecs: z.number().int(),
xrayVersion: z.string(),
});
export type ProbeResultUI = z.infer<typeof ProbeResultUISchema>;
export const SettingSchema = z.object({
id: z.number().int(),
key: z.string(),
+1 -1
View File
@@ -142,7 +142,7 @@ async function fetchInboundOptions(): Promise<InboundOption[]> {
}
async function fetchDefaults(): Promise<Record<string, unknown>> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj || {};
+1 -1
View File
@@ -22,7 +22,7 @@ async function loadOnce(): Promise<void> {
}
pending = (async () => {
try {
const msg = await HttpUtil.post('/panel/setting/defaultSettings');
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings');
if (msg?.success) {
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
cachedValue = validated.obj?.datepicker || 'gregorian';
+7 -7
View File
@@ -72,7 +72,7 @@ export interface UseXraySettingResult {
type XrayConfigPayload = z.infer<typeof XrayConfigPayloadSchema>;
async function fetchXrayConfig(): Promise<XrayConfigPayload> {
const msg = await HttpUtil.post('/panel/xray/', undefined, { silent: true });
const msg = await HttpUtil.post('/panel/api/xray/', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to load xray config');
if (typeof msg.obj !== 'string') throw new Error('Malformed xray config response: expected string');
let parsed: unknown;
@@ -91,7 +91,7 @@ async function fetchXrayConfig(): Promise<XrayConfigPayload> {
}
async function fetchOutboundsTraffic(): Promise<OutboundTrafficRow[]> {
const msg = await HttpUtil.get('/panel/xray/getOutboundsTraffic', undefined, { silent: true });
const msg = await HttpUtil.get('/panel/api/xray/getOutboundsTraffic', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch outbounds traffic');
const validated = parseMsg(msg, OutboundTrafficListSchema, 'xray/getOutboundsTraffic');
return Array.isArray(validated.obj) ? validated.obj : [];
@@ -200,7 +200,7 @@ export function useXraySetting(): UseXraySettingResult {
mutationFn: async () => {
const sentXraySetting = xraySettingRef.current;
const sentTestUrl = outboundTestUrlRef.current || DEFAULT_TEST_URL;
const msg = await HttpUtil.post('/panel/xray/update', {
const msg = await HttpUtil.post('/panel/api/xray/update', {
xraySetting: sentXraySetting,
outboundTestUrl: sentTestUrl,
});
@@ -217,7 +217,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetTrafficMut = useMutation({
mutationFn: (tag: string) =>
HttpUtil.post('/panel/xray/resetOutboundsTraffic', { tag }),
HttpUtil.post('/panel/api/xray/resetOutboundsTraffic', { tag }),
onSuccess: (msg) => {
if (msg?.success) queryClient.invalidateQueries({ queryKey: keys.xray.outboundsTraffic() });
},
@@ -228,7 +228,7 @@ export function useXraySetting(): UseXraySettingResult {
const msg = await HttpUtil.post('/panel/api/server/restartXrayService');
if (!msg?.success) return msg;
await PromiseUtil.sleep(500);
const r = await HttpUtil.get('/panel/xray/getXrayResult');
const r = await HttpUtil.get('/panel/api/xray/getXrayResult');
const validated = parseMsg(r, z.string(), 'xray/getXrayResult');
if (validated?.success) setRestartResult(validated.obj || '');
return msg;
@@ -237,7 +237,7 @@ export function useXraySetting(): UseXraySettingResult {
const resetDefaultMut = useMutation({
mutationFn: async (): Promise<Msg<XraySettingsValue>> => {
const raw = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
const raw = await HttpUtil.get('/panel/api/setting/getDefaultJsonConfig');
return parseMsg(raw, XraySettingsValueSchema, 'setting/getDefaultJsonConfig');
},
onSuccess: (msg) => {
@@ -264,7 +264,7 @@ export function useXraySetting(): UseXraySettingResult {
[index]: { testing: true, result: null, mode: effMode },
}));
try {
const raw = await HttpUtil.post('/panel/xray/testOutbound', {
const raw = await HttpUtil.post('/panel/api/xray/testOutbound', {
outbound: JSON.stringify(outbound),
allOutbounds: JSON.stringify(templateSettingsRef.current?.outbounds || []),
mode: effMode,
@@ -26,10 +26,10 @@ function asPath(name: NamePath): (string | number)[] {
function defaultTcpMaskSettings(type: string): Record<string, unknown> {
switch (type) {
case 'fragment':
return { packets: '1-3', length: '', delay: '', maxSplit: '' };
return { packets: '1-3', length: '100-200', delay: '', maxSplit: '' };
case 'sudoku':
return {
password: '', ascii: '', customTable: '', customTables: '',
password: '', ascii: '', customTable: '', customTables: [''],
paddingMin: 0, paddingMax: 0,
};
case 'header-custom':
@@ -210,8 +210,12 @@ function TcpMaskItem({
]}
/>
</Form.Item>
<Form.Item label="Length" name={[fieldName, 'settings', 'length']}>
<Input />
<Form.Item
label="Length"
name={[fieldName, 'settings', 'length']}
rules={[{ validator: validateFragmentLength }]}
>
<Input placeholder="e.g. 100-200" />
</Form.Item>
<Form.Item label="Delay" name={[fieldName, 'settings', 'delay']}>
<Input />
@@ -228,7 +232,9 @@ function TcpMaskItem({
<Form.Item label="Password" name={[fieldName, 'settings', 'password']}><Input /></Form.Item>
<Form.Item label="ASCII" name={[fieldName, 'settings', 'ascii']}><Input /></Form.Item>
<Form.Item label="Custom Table" name={[fieldName, 'settings', 'customTable']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}><Input /></Form.Item>
<Form.Item label="Custom Tables" name={[fieldName, 'settings', 'customTables']}>
<Select mode="tags" style={{ width: '100%' }} tokenSeparators={[',']} />
</Form.Item>
<Form.Item label="Padding Min" name={[fieldName, 'settings', 'paddingMin']}>
<InputNumber min={0} />
</Form.Item>
@@ -257,6 +263,18 @@ function TcpMaskItem({
// Walks a deep object path safely. Used inside shouldUpdate which gets
// the whole form values blob; we need to compare a deep field across
// prev/curr without crashing on missing intermediates.
function validateFragmentLength(_rule: unknown, value: unknown): Promise<void> {
const str = typeof value === 'string' ? value.trim() : String(value ?? '').trim();
if (str.length === 0) {
return Promise.reject(new Error('Length is required — xray rejects a fragment mask whose LengthMin is 0'));
}
const min = Number(str.split('-')[0]);
if (!Number.isFinite(min) || min <= 0) {
return Promise.reject(new Error('Length minimum must be greater than 0 (e.g. 100-200)'));
}
return Promise.resolve();
}
function getDeep(obj: unknown, path: (string | number)[]): unknown {
let cur: unknown = obj;
for (const key of path) {
@@ -10,6 +10,7 @@ import {
import type { StreamSettings } from '@/schemas/api/inbound';
import type { Sniffing } from '@/schemas/primitives';
import type { z } from 'zod';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
// Plain-data adapter between the panel's stored inbound row shape and
// the typed InboundFormValues that Form.useForm<T> carries inside
@@ -279,10 +280,13 @@ export function formValuesToWirePayload(values: InboundFormValues): WireInboundP
if (Array.isArray(settingsPruned.clients)) {
settingsPruned.clients = normalizeClients(values.protocol, settingsPruned.clients);
}
const streamPruned = values.streamSettings
let streamPruned = values.streamSettings
? ((pruneEmpty(values.streamSettings) ?? {}) as Record<string, unknown>)
: undefined;
if (streamPruned) stripTlsCertUseFile(streamPruned);
if (streamPruned) {
streamPruned = normalizeStreamSettingsForWire(streamPruned, { side: 'inbound' });
stripTlsCertUseFile(streamPruned);
}
dropLegacyOptionalEmpties(settingsPruned, streamPruned);
const payload: WireInboundPayload = {
up: values.up,
@@ -1,4 +1,5 @@
import { XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
import { Wireguard } from '@/utils';
import type {
@@ -519,8 +520,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
userLevel: s.userLevel || undefined,
proxyProtocol: s.proxyProtocol || undefined,
fragment: fragmentEnabled ? Object.fromEntries(fragmentEntries) : undefined,
noises: s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules.length > 0
noises: s.noises && s.noises.length > 0 ? s.noises : undefined,
finalRules: s.finalRules && s.finalRules.length > 0
? s.finalRules.map((r) => ({
action: r.action,
network: r.network || undefined,
@@ -588,7 +589,7 @@ function stripUiOnlyStreamFields(stream: unknown): Raw {
if (!xmuxEnabled) delete cleaned.xmux;
next.xhttpSettings = dropEmptyStrings(cleaned);
}
return next;
return normalizeStreamSettingsForWire(next, { side: 'outbound' }) as Raw;
}
function muxAllowed(values: OutboundFormValues): boolean {
@@ -0,0 +1,225 @@
// Shapes the streamSettings subtree that 3x-ui persists to match what
// xray-core actually consumes. The panel's Zod defaults mirror the full
// SplitHTTPConfig / SockoptObject schema, but many fields are mode-specific
// (packet-up vs stream-one) or side-specific (inbound vs outbound). Emitting
// them anyway bloats configs and — for sockopt — can inject doc-example
// values like tcpWindowClamp: 600 that throttle throughput.
export type StreamWireSide = 'inbound' | 'outbound';
const PACKET_UP_FIELDS = [
'scMaxEachPostBytes',
'scMinPostsIntervalMs',
'scMaxBufferedPosts',
] as const;
const STREAM_UP_SERVER_FIELDS = ['scStreamUpServerSecs'] as const;
const PLACEMENT_STRING_FIELDS = [
'sessionPlacement',
'sessionKey',
'seqPlacement',
'seqKey',
'uplinkDataPlacement',
'uplinkDataKey',
'uplinkHTTPMethod',
'xPaddingKey',
'xPaddingHeader',
'xPaddingPlacement',
'xPaddingMethod',
] as const;
function isRecord(v: unknown): v is Record<string, unknown> {
return v != null && typeof v === 'object' && !Array.isArray(v);
}
function nonEmptyString(v: unknown): v is string {
return typeof v === 'string' && v.trim() !== '';
}
function hasMeaningfulHeaders(headers: unknown): boolean {
return isRecord(headers) && Object.keys(headers).length > 0;
}
/** Validates REALITY inbound `target` / `dest` (must include a port). */
export function validateRealityTarget(target: string): string | undefined {
const trimmed = target.trim();
if (!trimmed) {
return 'pages.inbounds.form.realityTargetRequired';
}
// Unix socket destinations (rare, but valid in xray-core).
if (trimmed.startsWith('/') || trimmed.startsWith('@')) {
return undefined;
}
// Pure port → localhost:port in xray-core.
if (/^\d+$/.test(trimmed)) {
const port = Number(trimmed);
if (port >= 1 && port <= 65535) return undefined;
return 'pages.inbounds.form.realityTargetInvalidPort';
}
const lastColon = trimmed.lastIndexOf(':');
if (lastColon <= 0 || lastColon === trimmed.length - 1) {
return 'pages.inbounds.form.realityTargetNeedsPort';
}
const portPart = trimmed.slice(lastColon + 1);
if (!/^\d+$/.test(portPart)) {
return 'pages.inbounds.form.realityTargetInvalidPort';
}
const port = Number(portPart);
if (port < 1 || port > 65535) {
return 'pages.inbounds.form.realityTargetInvalidPort';
}
return undefined;
}
function dropEmptyStrings(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
const v = obj[key];
if (v === '' || v == null) delete obj[key];
}
}
function dropFalseFlags(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
if (obj[key] === false) delete obj[key];
}
}
function dropZeroNumbers(obj: Record<string, unknown>, keys: readonly string[]): void {
for (const key of keys) {
if (obj[key] === 0) delete obj[key];
}
}
export function normalizeXhttpForWire(
raw: Record<string, unknown>,
side: StreamWireSide,
): Record<string, unknown> {
const out: Record<string, unknown> = { ...raw };
const mode = typeof out.mode === 'string' && out.mode !== '' ? out.mode : 'auto';
delete out.enableXmux;
if (side === 'inbound') {
delete out.xmux;
delete out.scMinPostsIntervalMs;
delete out.uplinkChunkSize;
}
dropEmptyStrings(out, PLACEMENT_STRING_FIELDS);
if (!hasMeaningfulHeaders(out.headers)) {
delete out.headers;
}
if (out.xPaddingObfsMode !== true) {
delete out.xPaddingObfsMode;
dropEmptyStrings(out, [
'xPaddingKey',
'xPaddingHeader',
'xPaddingPlacement',
'xPaddingMethod',
]);
}
if (out.noGRPCHeader !== true) delete out.noGRPCHeader;
if (out.noSSEHeader !== true) delete out.noSSEHeader;
if (out.serverMaxHeaderBytes === 0) delete out.serverMaxHeaderBytes;
if (out.uplinkChunkSize === 0) delete out.uplinkChunkSize;
if (mode === 'stream-one') {
for (const key of PACKET_UP_FIELDS) delete out[key];
for (const key of STREAM_UP_SERVER_FIELDS) delete out[key];
} else if (mode === 'stream-up') {
for (const key of PACKET_UP_FIELDS) delete out[key];
if (side === 'outbound') {
delete out.scStreamUpServerSecs;
}
} else if (mode === 'packet-up') {
delete out.scStreamUpServerSecs;
}
return out;
}
export function normalizeSockoptForWire(
raw: Record<string, unknown>,
): Record<string, unknown> | undefined {
const out: Record<string, unknown> = { ...raw };
dropZeroNumbers(out, [
'tcpWindowClamp',
'tcpMaxSeg',
'tcpUserTimeout',
'tcpKeepAliveIdle',
'tcpKeepAliveInterval',
'mark',
]);
dropFalseFlags(out, [
'acceptProxyProtocol',
'tcpFastOpen',
'tcpMptcp',
'penetrate',
'V6Only',
]);
if (out.tproxy === 'off') delete out.tproxy;
if (out.domainStrategy === 'AsIs') delete out.domainStrategy;
if (out.addressPortStrategy === 'none') delete out.addressPortStrategy;
if (nonEmptyString(out.dialerProxy) === false) delete out.dialerProxy;
if (nonEmptyString(out.interface) === false) delete out.interface;
if (Array.isArray(out.trustedXForwardedFor) && out.trustedXForwardedFor.length === 0) {
delete out.trustedXForwardedFor;
}
if (Array.isArray(out.customSockopt) && out.customSockopt.length === 0) {
delete out.customSockopt;
}
const he = out.happyEyeballs;
if (isRecord(he)) {
const heOut: Record<string, unknown> = { ...he };
if (heOut.tryDelayMs === 0) delete heOut.tryDelayMs;
if (heOut.prioritizeIPv6 === false) delete heOut.prioritizeIPv6;
if (heOut.interleave === 1) delete heOut.interleave;
if (heOut.maxConcurrentTry === 4) delete heOut.maxConcurrentTry;
if (Object.keys(heOut).length === 0) {
delete out.happyEyeballs;
} else {
out.happyEyeballs = heOut;
}
}
if (nonEmptyString(out.tcpcongestion) === false) delete out.tcpcongestion;
if (Object.keys(out).length === 0) return undefined;
return out;
}
export function normalizeStreamSettingsForWire(
stream: Record<string, unknown>,
opts: { side: StreamWireSide },
): Record<string, unknown> {
const out: Record<string, unknown> = { ...stream };
const xhttp = out.xhttpSettings;
if (isRecord(xhttp)) {
out.xhttpSettings = normalizeXhttpForWire(xhttp, opts.side);
}
const sockopt = out.sockopt;
if (isRecord(sockopt)) {
const normalized = normalizeSockoptForWire(sockopt);
if (normalized) {
out.sockopt = normalized;
} else {
delete out.sockopt;
}
}
return out;
}
+3
View File
@@ -40,6 +40,7 @@ export type DBInboundInit = Partial<{
sniffing: RawJsonField;
clientStats: ClientStats[];
nodeId: number | null;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
}>;
@@ -83,6 +84,7 @@ export class DBInbound {
sniffing: RawJsonField;
clientStats: ClientStats[];
nodeId: number | null;
originNodeGuid: string;
fallbackParent: FallbackParentRef | null;
private _clientStatsMap: Map<string, ClientStats> | null = null;
@@ -108,6 +110,7 @@ export class DBInbound {
this.sniffing = "";
this.clientStats = [];
this.nodeId = null;
this.originNodeGuid = "";
this.fallbackParent = null;
if (data == null) {
return;
@@ -33,6 +33,7 @@ export default function ApiDocsPage() {
docExpansion="list"
deepLinking={false}
tryItOutEnabled
persistAuthorization
/>
</div>
</Layout.Content>
+48 -39
View File
@@ -38,6 +38,8 @@ export interface Endpoint {
response?: string;
errorResponse?: string;
errorStatus?: number;
responseSchema?: string;
responseSchemaArray?: boolean;
}
export interface SubscriptionHeader {
@@ -107,22 +109,22 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/inbounds/list',
summary: 'List every inbound owned by the authenticated user, including each inbound’s clientStats traffic counters. settings, streamSettings, and sniffing are returned as nested JSON objects (no escaped strings); legacy callers that send them back as JSON-encoded strings are still accepted on write.',
response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "up": 0,\n "down": 0,\n "total": 0,\n "remark": "VLESS-443",\n "enable": true,\n "expiryTime": 0,\n "listen": "",\n "port": 443,\n "protocol": "vless",\n "settings": {\n "clients": [],\n "decryption": "none"\n },\n "streamSettings": {\n "network": "tcp",\n "security": "reality",\n "realitySettings": { "show": false, "dest": "..." }\n },\n "tag": "inbound-443",\n "sniffing": {\n "enabled": true,\n "destOverride": ["http", "tls"]\n },\n "clientStats": []\n }\n ]\n}',
responseSchema: 'Inbound',
responseSchemaArray: true,
},
{
method: 'GET',
path: '/panel/api/inbounds/list/slim',
summary: 'Same shape as /list but with settings.clients[] stripped down to {email, enable, comment} and ClientStats not enriched with UUID/SubId. Use this for list pages; fetch /get/:id when you need the full per-client payload (uuid, password, flow, ...).',
response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "userId": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "settings": {\n "clients": [\n { "email": "alice", "enable": true }\n ],\n "decryption": "none"\n },\n "clientStats": []\n }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/inbounds/options',
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns only id, remark, protocol, port, and a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
response:
'{\n "success": true,\n "obj": [\n {\n "id": 1,\n "remark": "VLESS-443",\n "protocol": "vless",\n "port": 443,\n "tlsFlowCapable": true\n }\n ]\n}',
summary: 'Lightweight picker projection of the authenticated user’s inbounds. Returns id, remark, tag, protocol, port, a server-computed tlsFlowCapable flag (true for VLESS / port-fallback on TCP with tls or reality), and ssMethod (the Shadowsocks cipher, empty for non-Shadowsocks inbounds — used by the client UI to generate a valid Shadowsocks 2022 PSK). Use this for dropdowns and attach pickers — it skips settings, streamSettings, and clientStats so the payload stays small even on panels with thousands of clients.',
responseSchema: 'InboundOption',
responseSchemaArray: true,
},
{
method: 'GET',
@@ -324,6 +326,12 @@ export const sections: readonly Section[] = [
summary: 'Return this panel\'s own web TLS certificate and key file paths. The central panel calls it on a node (via the node API token) so "Set Cert from Panel" fills a node-assigned inbound with paths that exist on the node.',
response: '{\n "success": true,\n "obj": {\n "webCertFile": "/root/cert/example.com/fullchain.pem",\n "webKeyFile": "/root/cert/example.com/privkey.pem"\n }\n}',
},
{
method: 'GET',
path: '/panel/api/server/descendants',
summary: 'Read-only summaries (guid, parentGuid, name, address, status, versions) of the nodes this panel manages. A parent panel calls it on a node (via the node API token) to surface transitive sub-nodes in a chained topology. Counts are computed by the parent, not returned here.',
response: '{\n "success": true,\n "obj": [\n {\n "guid": "c3d4-...",\n "parentGuid": "a1b2-...",\n "name": "Node3",\n "address": "10.0.0.3",\n "status": "online"\n }\n ]\n}',
},
{
method: 'GET',
path: '/panel/api/server/getNewX25519Cert',
@@ -682,15 +690,15 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/api/clients/onlinesByNode',
summary: 'Online client emails grouped by the node that reported them. The local panel uses key "0"; each remote node uses its node id. Lets the inbounds page show online status per node instead of merging every node together.',
response: '{\n "success": true,\n "obj": {\n "0": ["user1"],\n "3": ["user1", "user2"]\n }\n}',
path: '/panel/api/clients/onlinesByGuid',
summary: 'Online client emails grouped by the panelGuid of the node that physically hosts each client. The local panel uses its own GUID; each node (at any depth in a chain) uses its GUID. Lets the inbounds page attribute online status to the real node instead of the intermediate one it syncs through.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["user1"],\n "c3d4-...": ["user1", "user2"]\n }\n}',
},
{
method: 'POST',
path: '/panel/api/clients/activeInbounds',
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by node (local panel uses key "0"). Pairs with onlinesByNode so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
response: '{\n "success": true,\n "obj": {\n "0": ["inbound-443", "inbound-8443"]\n }\n}',
summary: 'Inbound tags that carried traffic within the heartbeat window, grouped by the hosting node\'s panelGuid. Pairs with onlinesByGuid so the inbounds page only marks a multi-inbound client online on the inbounds it actually used. Nodes that do not report per-inbound activity are absent.',
response: '{\n "success": true,\n "obj": {\n "a1b2-...": ["in-443-tcp", "in-8443-tcp"]\n }\n}',
},
{
method: 'POST',
@@ -705,7 +713,7 @@ export const sections: readonly Section[] = [
params: [
{ name: 'email', in: 'path', type: 'string', desc: 'Client email (unique across the panel).' },
],
response: '{\n "success": true,\n "obj": {\n "email": "user1",\n "up": 1048576,\n "down": 2097152,\n "total": 10737418240,\n "expiryTime": 1735689600000\n }\n}',
responseSchema: 'ClientTraffic',
},
{
method: 'GET',
@@ -742,7 +750,8 @@ export const sections: readonly Section[] = [
method: 'GET',
path: '/panel/api/nodes/list',
summary: 'List every configured node with its connection details, health, and last heartbeat patch.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "de-fra-1",\n "remark": "",\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef...",\n "enable": true,\n "allowPrivateAddress": false,\n "status": "online",\n "lastHeartbeat": 1700000000,\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 23.5,\n "memPct": 45.1,\n "uptimeSecs": 86400,\n "lastError": "",\n "inboundCount": 5,\n "clientCount": 27,\n "onlineCount": 3,\n "depletedCount": 1,\n "createdAt": 1700000000,\n "updatedAt": 1700000000\n }\n ]\n}',
responseSchema: 'Node',
responseSchemaArray: true,
},
{
method: 'GET',
@@ -799,7 +808,7 @@ export const sections: readonly Section[] = [
path: '/panel/api/nodes/test',
summary: 'Probe a node without saving it. Uses the body as connection details and returns the same heartbeat snapshot a registered node would have.',
body: '{\n "scheme": "https",\n "address": "node1.example.com",\n "port": 2053,\n "basePath": "/",\n "apiToken": "abcdef..."\n}',
response: '{\n "success": true,\n "obj": {\n "status": "online",\n "latencyMs": 42,\n "xrayVersion": "25.x.x",\n "panelVersion": "v3.x.x",\n "cpuPct": 12.5,\n "memPct": 45.2,\n "uptimeSecs": 86400,\n "error": ""\n }\n}',
responseSchema: 'ProbeResultUI',
},
{
method: 'POST',
@@ -908,28 +917,28 @@ export const sections: readonly Section[] = [
id: 'settings',
title: 'Settings',
description:
'Panel configuration and user credentials. All endpoints live under /panel/setting and require a logged-in session or Bearer token.',
'Panel configuration and user credentials. All endpoints live under /panel/api/setting and require a logged-in session or Bearer token.',
endpoints: [
{
method: 'POST',
path: '/panel/setting/all',
path: '/panel/api/setting/all',
summary: 'Return every panel setting: web server, Telegram bot, subscription, security, LDAP. The full JSON blob that the Settings page edits.',
response: '{\n "success": true,\n "obj": {\n "webPort": 2053,\n "webCertFile": "",\n "webKeyFile": "",\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n "tgBotToken": "",\n ...\n }\n}',
},
{
method: 'POST',
path: '/panel/setting/defaultSettings',
path: '/panel/api/setting/defaultSettings',
summary: 'Return the computed default settings based on the request host. Useful to preview what a fresh install would use.',
},
{
method: 'POST',
path: '/panel/setting/update',
path: '/panel/api/setting/update',
summary: 'Persist every setting at once. The body mirrors the shape returned by /all. Invalid values (bad ports, missing cert pairs, etc.) are rejected before write.',
body: '{\n "webPort": 2053,\n "webBasePath": "/",\n "subPort": 10882,\n "subPath": "/sub/",\n "tgBotEnable": false,\n ...\n}',
},
{
method: 'POST',
path: '/panel/setting/updateUser',
path: '/panel/api/setting/updateUser',
summary: 'Change the panel admin username and password. Requires the current credentials for verification. The session is refreshed with the new values on success.',
params: [
{ name: 'oldUsername', in: 'body', type: 'string', desc: 'Current admin username.' },
@@ -941,12 +950,12 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/setting/restartPanel',
path: '/panel/api/setting/restartPanel',
summary: 'Restart the entire 3x-ui process after a 3-second grace period. The connection drops immediately; the panel comes back online ~5-10 seconds later.',
},
{
method: 'GET',
path: '/panel/setting/getDefaultJsonConfig',
path: '/panel/api/setting/getDefaultJsonConfig',
summary: 'Return the built-in default Xray JSON config template that ships with this panel version.',
},
],
@@ -956,28 +965,28 @@ export const sections: readonly Section[] = [
id: 'api-tokens',
title: 'API Tokens',
description:
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request.',
'Manage Bearer tokens used for programmatic auth (bots, central panels acting on this node, CI). Each token has a unique name and an enabled flag — disable to revoke without deleting, delete to revoke permanently. Tokens are stored as SHA-256 hashes and the plaintext is returned only once, in the create response — it cannot be retrieved afterwards, so copy it then. Send one as <code>Authorization: Bearer &lt;token&gt;</code> on any /panel/api/* request — the token is a full-admin credential.',
endpoints: [
{
method: 'GET',
path: '/panel/setting/apiTokens',
path: '/panel/api/setting/apiTokens',
summary: 'List every API token, enabled or not. The token value is never returned — only metadata.',
response: '{\n "success": true,\n "obj": [\n {\n "id": 1,\n "name": "default",\n "enabled": true,\n "createdAt": 1736000000\n }\n ]\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/create',
path: '/panel/api/setting/apiTokens/create',
summary: 'Mint a new API token. Name must be unique and 1-64 characters; the token string is server-generated and returned only in this response — it is stored hashed and cannot be retrieved later.',
params: [
{ name: 'name', in: 'body', type: 'string', desc: 'Human-readable label, e.g. "central-panel-a".' },
],
body: '{\n "name": "central-panel-a"\n}',
response: '{\n "success": true,\n "obj": {\n "id": 2,\n "name": "central-panel-a",\n "token": "new-token-string",\n "enabled": true,\n "createdAt": 1736000000\n }\n}',
responseSchema: 'ApiTokenView',
errorResponse: '{\n "success": false,\n "msg": "a token with that name already exists"\n}',
},
{
method: 'POST',
path: '/panel/setting/apiTokens/delete/:id',
path: '/panel/api/setting/apiTokens/delete/:id',
summary: 'Permanently delete a token. Any caller using it stops authenticating immediately.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -986,7 +995,7 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/setting/apiTokens/setEnabled/:id',
path: '/panel/api/setting/apiTokens/setEnabled/:id',
summary: 'Toggle a token enabled/disabled without deleting it. Disabled tokens are rejected by checkAPIAuth on the next request.',
params: [
{ name: 'id', in: 'path', type: 'number', desc: 'Token row ID.' },
@@ -1002,32 +1011,32 @@ export const sections: readonly Section[] = [
id: 'xray-settings',
title: 'Xray Settings',
description:
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/xray.',
'Xray configuration template, outbound management, Warp/Nord integration, and config testing. All endpoints under /panel/api/xray.',
endpoints: [
{
method: 'POST',
path: '/panel/xray/',
path: '/panel/api/xray/',
summary: 'Return the Xray config template (JSON string), available inbound tags, client reverse tags, and the configured outbound test URL in one response.',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"inbound-443\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
response: '{\n "success": true,\n "obj": {\n "xraySetting": "{...raw xray config...}",\n "inboundTags": "[\\"in-443-tcp\\"]",\n "clientReverseTags": "[]",\n "outboundTestUrl": "https://www.google.com/generate_204"\n }\n}',
},
{
method: 'GET',
path: '/panel/xray/getDefaultJsonConfig',
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/setting/getDefaultJsonConfig).',
path: '/panel/api/xray/getDefaultJsonConfig',
summary: 'Return the built-in default Xray config shipped with the panel (identical to /panel/api/setting/getDefaultJsonConfig).',
},
{
method: 'GET',
path: '/panel/xray/getOutboundsTraffic',
path: '/panel/api/xray/getOutboundsTraffic',
summary: 'Return traffic statistics for every outbound. Each outbound shows up/down/total counters.',
},
{
method: 'GET',
path: '/panel/xray/getXrayResult',
path: '/panel/api/xray/getXrayResult',
summary: 'Return the most recent Xray process stdout/stderr output. Useful to check for startup errors or runtime warnings.',
},
{
method: 'POST',
path: '/panel/xray/update',
path: '/panel/api/xray/update',
summary: 'Save the Xray JSON config template and optionally the outbound test URL. Both are sent as form fields.',
params: [
{ name: 'xraySetting', in: 'body (form)', type: 'string', desc: 'Full Xray JSON config template.' },
@@ -1036,7 +1045,7 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/xray/warp/:action',
path: '/panel/api/xray/warp/:action',
summary: 'Manage Cloudflare Warp integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'data — return Warp stats (quota, remaining). del — delete Warp data. config — return current Warp config. reg — register a new Warp endpoint (sends privateKey, publicKey). license — set a Warp+ license key (sends license).' },
@@ -1047,7 +1056,7 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/xray/nord/:action',
path: '/panel/api/xray/nord/:action',
summary: 'Manage NordVPN integration. The action parameter selects the operation.',
params: [
{ name: 'action', in: 'path', type: 'string', desc: 'countries — list available countries. servers — list servers in a country (sends countryId). reg — get NordVPN credentials (sends token). setKey — store NordVPN API key (sends key). data — return current NordVPN connection data. del — delete NordVPN data.' },
@@ -1058,7 +1067,7 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/xray/resetOutboundsTraffic',
path: '/panel/api/xray/resetOutboundsTraffic',
summary: 'Reset traffic counters for a specific outbound by tag.',
params: [
{ name: 'tag', in: 'body (form)', type: 'string', desc: 'Outbound tag to reset (e.g. "proxy", "direct").' },
@@ -1067,7 +1076,7 @@ export const sections: readonly Section[] = [
},
{
method: 'POST',
path: '/panel/xray/testOutbound',
path: '/panel/api/xray/testOutbound',
summary: 'Test an outbound configuration. Sends the outbound JSON (required), optionally all outbounds (to resolve sockopt.dialerProxy dependencies), and a mode flag.',
params: [
{ name: 'outbound', in: 'body (form)', type: 'string', desc: 'JSON-encoded single outbound to test (required).' },
@@ -89,6 +89,15 @@ export default function ClientBulkAddModal({
[form.inboundIds, flowCapableIds],
);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
useEffect(() => {
if (!showFlow && form.flow) {
@@ -153,7 +162,9 @@ export default function ClientBulkAddModal({
email,
subId: form.subId || RandomUtil.randomLowerAndNum(16),
id: RandomUtil.randomUUID(),
password: RandomUtil.randomLowerAndNum(16),
password: ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16),
auth: RandomUtil.randomLowerAndNum(16),
flow: showFlow ? (form.flow || '') : '',
totalGB: Math.round((form.totalGB || 0) * SizeFormatter.ONE_GB),
+25 -1
View File
@@ -228,6 +228,21 @@ export default function ClientFormModal({
return ids;
}, [inbounds]);
const ss2022Method = useMemo(() => {
for (const id of form.inboundIds || []) {
const ib = (inbounds || []).find((row) => row.id === id);
const method = ib?.ssMethod;
if (method && method.substring(0, 4) === '2022') return method;
}
return '';
}, [form.inboundIds, inbounds]);
function regeneratePassword() {
update('password', ss2022Method
? RandomUtil.randomShadowsocksPassword(ss2022Method)
: RandomUtil.randomLowerAndNum(16));
}
const showFlow = useMemo(
() => (form.inboundIds || []).some((id) => flowCapableIds.has(id)),
[form.inboundIds, flowCapableIds],
@@ -257,6 +272,15 @@ export default function ClientFormModal({
}
}, [showReverseTag, form.reverseTag]);
useEffect(() => {
if (!ss2022Method) return;
setForm((prev) => (
RandomUtil.isShadowsocks2022Password(prev.password, ss2022Method)
? prev
: { ...prev, password: RandomUtil.randomShadowsocksPassword(ss2022Method) }
));
}, [ss2022Method]);
const inboundOptions = useMemo(
() => (inbounds || [])
.filter((ib) => MULTI_CLIENT_PROTOCOLS.has(ib.protocol || ''))
@@ -433,7 +457,7 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('password', RandomUtil.randomLowerAndNum(16))} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
</Col>
@@ -875,6 +875,7 @@ export default function InboundFormModal({
colon={false}
labelCol={{ sm: { span: 8 } }}
wrapperCol={{ sm: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange}
>
<Tabs items={[
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { UTLS_FINGERPRINT } from '@/schemas/primitives';
import { validateRealityTarget } from '@/lib/xray/stream-wire-normalize';
interface RealityFormProps {
saving: boolean;
@@ -44,10 +45,24 @@ export default function RealityForm({
options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))}
/>
</Form.Item>
<Form.Item label={t('pages.inbounds.form.target')}>
<Form.Item
label={t('pages.inbounds.form.target')}
extra={t('pages.inbounds.form.realityTargetHint')}
>
<Space.Compact block>
<Form.Item name={['streamSettings', 'realitySettings', 'target']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Form.Item
name={['streamSettings', 'realitySettings', 'target']}
noStyle
rules={[
{
validator: async (_, value) => {
const errKey = validateRealityTarget(typeof value === 'string' ? value : '');
if (errKey) throw new Error(t(errKey));
},
},
]}
>
<Input style={{ width: 'calc(100% - 32px)' }} placeholder="example.com:443" />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeRealityTarget} />
</Space.Compact>
@@ -60,6 +60,7 @@ export default function SockoptForm({
<Form.Item
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
label={t('pages.inbounds.form.tcpWindowClamp')}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} />
</Form.Item>
@@ -120,7 +120,7 @@ export function useSecurityActions({ form, setSaving, messageApi, nodeId }: UseS
// node's own paths (fetched through the central panel), not this panel's.
const msg = typeof nodeId === 'number'
? await HttpUtil.get(`/panel/api/nodes/webCert/${nodeId}`, undefined, { silent: true })
: await HttpUtil.post('/panel/setting/all', undefined, { silent: true });
: await HttpUtil.post('/panel/api/setting/all', undefined, { silent: true });
if (!msg?.success) {
messageApi.warning(msg?.msg || t('pages.inbounds.setDefaultCertEmpty'));
return;
+41 -35
View File
@@ -58,13 +58,14 @@ async function fetchOnlineClients(): Promise<string[]> {
return Array.isArray(validated.obj) ? validated.obj : [];
}
// Online emails grouped by node id (local panel = key 0), used to scope the
// per-inbound online rollup so a client online on one node is not shown
// online on every node's inbounds.
async function fetchOnlineClientsByNode(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/onlinesByNode', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByNode');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByNode');
// Online emails grouped by the panelGuid of the node that physically hosts each
// client, used to scope the per-inbound online rollup so a client online on one
// node is not shown online on every node's inbounds — and a client on a
// sub-node is attributed to that sub-node, not the node it syncs through (#4983).
async function fetchOnlineClientsByGuid(): Promise<Record<string, string[]>> {
const msg = await HttpUtil.post('/panel/api/clients/onlinesByGuid', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch onlinesByGuid');
const validated = parseMsg(msg, OnlineByNodeSchema, 'clients/onlinesByGuid');
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
}
@@ -79,11 +80,11 @@ async function fetchActiveInboundsByNode(): Promise<Record<string, string[]>> {
return (validated.obj && typeof validated.obj === 'object') ? (validated.obj as Record<string, string[]>) : {};
}
function toNodeOnlineMap(data: Record<string, string[]>): Map<number, Set<string>> {
const map = new Map<number, Set<string>>();
function toGuidOnlineMap(data: Record<string, string[]>): Map<string, Set<string>> {
const map = new Map<string, Set<string>>();
for (const [key, emails] of Object.entries(data)) {
if (!Array.isArray(emails)) continue;
map.set(Number(key), new Set(emails));
map.set(key, new Set(emails));
}
return map;
}
@@ -96,7 +97,7 @@ async function fetchLastOnlineMap(): Promise<Record<string, number>> {
}
async function fetchDefaultSettings(): Promise<DefaultsPayload> {
const msg = await HttpUtil.post('/panel/setting/defaultSettings', undefined, { silent: true });
const msg = await HttpUtil.post('/panel/api/setting/defaultSettings', undefined, { silent: true });
if (!msg?.success) throw new Error(msg?.msg || 'Failed to fetch defaults');
const validated = parseMsg(msg, DefaultsPayloadSchema, 'setting/defaultSettings');
return validated.obj ?? {};
@@ -117,9 +118,9 @@ export function useInbounds() {
staleTime: Infinity,
});
const onlinesByNodeQuery = useQuery({
queryKey: keys.clients.onlinesByNode(),
queryFn: fetchOnlineClientsByNode,
const onlinesByGuidQuery = useQuery({
queryKey: keys.clients.onlinesByGuid(),
queryFn: fetchOnlineClientsByGuid,
staleTime: Infinity,
});
@@ -182,16 +183,17 @@ export function useInbounds() {
const onlineClientsRef = useRef<string[]>([]);
onlineClientsRef.current = onlineClients;
// Online emails keyed by node id (local inbounds = key 0). The rollup
// reads this so each inbound only counts clients online on its own node.
const onlineByNodeRef = useRef<Map<number, Set<string>>>(new Map());
// Online emails keyed by the hosting node's panelGuid. The rollup reads this
// so each inbound only counts clients online on the node that physically
// hosts it, attributing a sub-node's clients to that sub-node (#4983).
const onlineByGuidRef = useRef<Map<string, Set<string>>>(new Map());
// Recently-active inbound tags keyed by node id. A node missing from this
// map means "no per-inbound activity reported" (e.g. remote nodes), so the
// rollup leaves that node's inbounds ungated and falls back to the email
// signal. A present node gates: a client only counts online on an inbound
// whose tag carried traffic this window.
const activeByNodeRef = useRef<Map<number, Set<string>>>(new Map());
// Recently-active inbound tags keyed by the hosting node's panelGuid. A GUID
// missing from this map means "no per-inbound activity reported" (e.g. remote
// nodes), so the rollup leaves that node's inbounds ungated and falls back to
// the email signal. A present GUID gates: a client only counts online on an
// inbound whose tag carried traffic this window.
const activeByGuidRef = useRef<Map<string, Set<string>>>(new Map());
const [lastOnlineMap, setLastOnlineMap] = useState<Record<string, number>>({});
@@ -209,13 +211,17 @@ export function useInbounds() {
const comments = new Map<string, string>();
const now = Date.now();
const nodeId = dbInbound.nodeId ?? 0;
const nodeOnline = onlineByNodeRef.current.get(nodeId);
// Attribution key: the GUID of the node that physically hosts this
// inbound. Local inbounds carry the panel's own GUID (filled server-side);
// a node-managed inbound carries its origin node's GUID, or falls back to
// the master-local synthetic id for an old-build node without one (#4983).
const guid = dbInbound.originNodeGuid || (dbInbound.nodeId != null ? `node:${dbInbound.nodeId}` : '');
const nodeOnline = onlineByGuidRef.current.get(guid);
// A node absent from the active map reports no per-inbound activity, so
// leave its inbounds ungated. When present, only mark a client online on
// this inbound if its tag actually carried traffic — that's what stops a
// multi-inbound client lighting up every inbound it's attached to.
const activeForNode = activeByNodeRef.current.get(nodeId);
const activeForNode = activeByGuidRef.current.get(guid);
const inboundActive = activeForNode === undefined || !dbInbound.tag || activeForNode.has(dbInbound.tag);
if (dbInbound.enable) {
@@ -305,15 +311,15 @@ export function useInbounds() {
}, [onlinesQuery.data]);
useEffect(() => {
if (onlinesByNodeQuery.data) {
onlineByNodeRef.current = toNodeOnlineMap(onlinesByNodeQuery.data);
if (onlinesByGuidQuery.data) {
onlineByGuidRef.current = toGuidOnlineMap(onlinesByGuidQuery.data);
rebuildClientCount();
}
}, [onlinesByNodeQuery.data, rebuildClientCount]);
}, [onlinesByGuidQuery.data, rebuildClientCount]);
useEffect(() => {
if (activeInboundsQuery.data) {
activeByNodeRef.current = toNodeOnlineMap(activeInboundsQuery.data);
activeByGuidRef.current = toGuidOnlineMap(activeInboundsQuery.data);
rebuildClientCount();
}
}, [activeInboundsQuery.data, rebuildClientCount]);
@@ -336,7 +342,7 @@ export function useInbounds() {
await Promise.all([
queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlines() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByNode() }),
queryClient.invalidateQueries({ queryKey: keys.clients.onlinesByGuid() }),
queryClient.invalidateQueries({ queryKey: keys.clients.activeInbounds() }),
queryClient.invalidateQueries({ queryKey: keys.clients.lastOnline() }),
queryClient.invalidateQueries({ queryKey: keys.xray.config() }),
@@ -367,16 +373,16 @@ export function useInbounds() {
const applyTrafficEvent = useCallback(
(payload: unknown) => {
if (!payload || typeof payload !== 'object') return;
const p = payload as { onlineClients?: string[]; onlineByNode?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
const p = payload as { onlineClients?: string[]; onlineByGuid?: Record<string, string[]>; activeInbounds?: Record<string, string[]>; lastOnlineMap?: Record<string, number> };
if (Array.isArray(p.onlineClients)) {
onlineClientsRef.current = p.onlineClients;
setOnlineClients(p.onlineClients);
}
if (p.onlineByNode && typeof p.onlineByNode === 'object') {
onlineByNodeRef.current = toNodeOnlineMap(p.onlineByNode);
if (p.onlineByGuid && typeof p.onlineByGuid === 'object') {
onlineByGuidRef.current = toGuidOnlineMap(p.onlineByGuid);
}
if (p.activeInbounds && typeof p.activeInbounds === 'object') {
activeByNodeRef.current = toNodeOnlineMap(p.activeInbounds);
activeByGuidRef.current = toGuidOnlineMap(p.activeInbounds);
}
if (p.lastOnlineMap && typeof p.lastOnlineMap === 'object') {
setLastOnlineMap((prev) => ({ ...prev, ...p.lastOnlineMap! }));
+1 -1
View File
@@ -52,7 +52,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
}
onBusy({ busy: true, tip: `${t('pages.settings.restartPanel')}…` });
const restart = await HttpUtil.post('/panel/setting/restartPanel');
const restart = await HttpUtil.post('/panel/api/setting/restartPanel');
if (restart?.success) {
await PromiseUtil.sleep(5000);
window.location.reload();
+1 -1
View File
@@ -87,7 +87,7 @@ export default function IndexPage() {
const [loadingTip, setLoadingTip] = useState(t('loading'));
useEffect(() => {
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/setting/defaultSettings').then((msg) => {
HttpUtil.post<{ ipLimitEnable?: boolean }>('/panel/api/setting/defaultSettings').then((msg) => {
if (msg?.success && msg.obj) setIpLimitEnable(!!msg.obj.ipLimitEnable);
});
HttpUtil.get<PanelUpdateInfo>('/panel/api/server/getPanelUpdateInfo').then((msg) => {
+72 -15
View File
@@ -15,6 +15,7 @@ import {
import type { BadgeProps } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
ApartmentOutlined,
ClusterOutlined,
CloudDownloadOutlined,
DeleteOutlined,
@@ -56,7 +57,7 @@ function isUpdateEligible(n: NodeRecord): boolean {
interface NodeRow extends NodeRecord {
url: string;
key: number;
key: string | number;
}
function badgeStatus(status?: string): BadgeProps['status'] {
@@ -131,14 +132,49 @@ export default function NodeList({
const [statsNode, setStatsNode] = useState<NodeRow | null>(null);
const [expandedIds, setExpandedIds] = useState<Set<number>>(new Set());
const dataSource = useMemo<NodeRow[]>(
() => nodes.map((n) => ({
// Map a node GUID to its display name so a transitive sub-node can show which
// parent it is reached through (#4983).
const nameByGuid = useMemo(() => {
const m = new Map<string, string>();
for (const n of nodes) if (n.guid) m.set(n.guid, n.name || n.guid);
return m;
}, [nodes]);
// Order direct nodes first, each immediately followed by its transitive
// sub-nodes, so the table reads as a parent -> child tree without colliding
// with the per-row history expander (transitive nodes carry id 0).
const dataSource = useMemo<NodeRow[]>(() => {
const toRow = (n: NodeRecord): NodeRow => ({
...n,
url: `${n.scheme}://${n.address}:${n.port}${n.basePath || '/'}`,
key: n.id,
})),
[nodes],
);
key: n.transitive ? `t-${n.guid || ''}` : n.id,
});
const childrenByParent = new Map<string, NodeRecord[]>();
for (const n of nodes) {
if (n.transitive && n.parentGuid) {
const arr = childrenByParent.get(n.parentGuid) || [];
arr.push(n);
childrenByParent.set(n.parentGuid, arr);
}
}
const ordered: NodeRow[] = [];
const added = new Set<string>();
const push = (n: NodeRecord) => {
const row = toRow(n);
ordered.push(row);
added.add(String(row.key));
};
for (const n of nodes) {
if (n.transitive) continue;
push(n);
if (n.guid) for (const child of childrenByParent.get(n.guid) || []) push(child);
}
// Transitive nodes whose parent isn't in the list still get shown.
for (const n of nodes) {
if (n.transitive && !added.has(`t-${n.guid || ''}`)) push(n);
}
return ordered;
}, [nodes]);
function toggleExpanded(id: number) {
setExpandedIds((prev) => {
@@ -153,7 +189,11 @@ export default function NodeList({
title: t('pages.nodes.actions'),
align: 'center',
width: 190,
render: (_value, record) => (
render: (_value, record) => record.transitive ? (
<Tooltip title={t('pages.nodes.subNodeTip', { parent: record.parentGuid ? (nameByGuid.get(record.parentGuid) || '-') : '-' })}>
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</Tooltip>
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
@@ -177,7 +217,9 @@ export default function NodeList({
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => (
render: (_value, record) => record.transitive ? (
<span style={{ opacity: 0.4 }}>—</span>
) : (
<Switch
checked={!!record.enable}
size="small"
@@ -190,8 +232,11 @@ export default function NodeList({
dataIndex: 'name',
ellipsis: true,
render: (_value, record) => (
<div className="name-cell">
<span className="name">{record.name}</span>
<div className="name-cell" style={record.transitive ? { paddingInlineStart: 20 } : undefined}>
<span className="name">
{record.transitive && <ApartmentOutlined style={{ marginInlineEnd: 6, opacity: 0.6 }} />}
{record.name}
</span>
{record.remark && <span className="remark">{record.remark}</span>}
</div>
),
@@ -316,7 +361,7 @@ export default function NodeList({
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode]);
], [t, showAddress, relativeTime, latestVersion, onToggleEnable, onProbe, onEdit, onDelete, onUpdateNode, nameByGuid]);
return (
<Card size="small" hoverable>
@@ -340,7 +385,18 @@ export default function NodeList({
<div>{t('noData')}</div>
</div>
) : (
dataSource.map((record) => (
dataSource.map((record) => record.transitive ? (
<div key={String(record.key)} className="node-card" style={{ paddingInlineStart: 16, opacity: 0.85 }}>
<div className="card-head">
<ApartmentOutlined style={{ opacity: 0.6 }} />
<StatusDot status={record.status} />
<span className="node-name">{record.name}</span>
<div className="card-actions">
<Tag icon={<ApartmentOutlined />} style={{ margin: 0 }}>{t('pages.nodes.subNode')}</Tag>
</div>
</div>
</div>
) : (
<div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
@@ -501,8 +557,8 @@ export default function NodeList({
rowKey="id"
rowSelection={dataSource.length > 1 ? {
selectedRowKeys: selectedIds,
onChange: (keys) => onSelectionChange(keys as number[]),
getCheckboxProps: (record) => ({ disabled: !isUpdateEligible(record) }),
onChange: (keys) => onSelectionChange(keys.filter((k) => typeof k === 'number') as number[]),
getCheckboxProps: (record) => ({ disabled: !!record.transitive || !isUpdateEligible(record) }),
} : undefined}
locale={{
emptyText: (
@@ -514,6 +570,7 @@ export default function NodeList({
}}
expandable={{
expandedRowRender: (record) => <NodeHistoryPanel node={record} />,
rowExpandable: (record) => !record.transitive,
}}
/>
)}
+5 -5
View File
@@ -96,7 +96,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
const sendUpdateUser = useCallback(async () => {
setUpdating(true);
try {
const msg = await HttpUtil.post('/panel/setting/updateUser', user) as ApiMsg;
const msg = await HttpUtil.post('/panel/api/setting/updateUser', user) as ApiMsg;
if (msg?.success) {
await HttpUtil.post('/logout');
const basePath = window.X_UI_BASE_PATH || '/';
@@ -124,7 +124,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
const loadApiTokens = useCallback(async () => {
setApiTokensLoading(true);
try {
const msg = await HttpUtil.get('/panel/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
const msg = await HttpUtil.get('/panel/api/setting/apiTokens') as ApiMsg<ApiTokenRow[]>;
if (msg?.success) setApiTokens(Array.isArray(msg.obj) ? msg.obj : []);
} finally {
setApiTokensLoading(false);
@@ -156,7 +156,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
}
setCreating(true);
try {
const msg = await HttpUtil.post('/panel/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
const msg = await HttpUtil.post('/panel/api/setting/apiTokens/create', { name }) as ApiMsg<{ token?: string }>;
if (msg?.success) {
setCreateOpen(false);
await loadApiTokens();
@@ -178,7 +178,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
cancelText: t('cancel'),
okType: 'danger',
onOk: async () => {
const msg = await HttpUtil.post(`/panel/setting/apiTokens/delete/${row.id}`) as ApiMsg;
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/delete/${row.id}`) as ApiMsg;
if (msg?.success) await loadApiTokens();
},
});
@@ -186,7 +186,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
async function toggleTokenEnabled(row: ApiTokenRow) {
const target = !row.enabled;
const msg = await HttpUtil.post(`/panel/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
const msg = await HttpUtil.post(`/panel/api/setting/apiTokens/setEnabled/${row.id}`, { enabled: target }) as ApiMsg;
if (msg?.success) {
setApiTokens((prev) => prev.map((r) => (r.id === row.id ? { ...r, enabled: target } : r)));
}
+1 -1
View File
@@ -142,7 +142,7 @@ export default function SettingsPage() {
onOk: async () => {
setSpinning(true);
try {
const msg = await HttpUtil.post('/panel/setting/restartPanel') as ApiMsg;
const msg = await HttpUtil.post('/panel/api/setting/restartPanel') as ApiMsg;
if (!msg?.success) return;
await PromiseUtil.sleep(5000);
window.location.replace(rebuildUrlAfterRestart());
+1 -1
View File
@@ -120,7 +120,7 @@ export default function SubPage() {
if (!subUrl) return '';
const separator = subUrl.includes('?') ? '&' : '?';
const rawUrl = subUrl + separator + 'flag=shadowrocket';
const base64Url = btoa(rawUrl).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
const base64Url = btoa(rawUrl);
const remark = encodeURIComponent(subTitle || sId || 'Subscription');
return `shadowrocket://add/sub/${base64Url}?remark=${remark}`;
}, []);
@@ -10,6 +10,7 @@ import {
} from '@ant-design/icons';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import { HappyEyeballsSchema } from '@/schemas/protocols/stream/sockopt';
import { SettingListItem } from '@/components/ui';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { catTabLabel } from '@/pages/settings/catTabLabel';
@@ -84,6 +85,49 @@ export default function BasicsTab({
| { domainStrategy?: string }
| undefined)?.domainStrategy ?? 'AsIs';
const directFreedomOutbound = templateSettings?.outbounds?.find(
(o) => o?.protocol === 'freedom' && o?.tag === 'direct',
);
const directHappyEyeballs = (() => {
const sockopt = (directFreedomOutbound?.streamSettings as { sockopt?: { happyEyeballs?: unknown } } | undefined)
?.sockopt;
const raw = sockopt?.happyEyeballs;
if (raw == null || typeof raw !== 'object') return null;
return HappyEyeballsSchema.parse(raw);
})();
const setDirectHappyEyeballs = useCallback(
(next: ReturnType<typeof HappyEyeballsSchema.parse> | null) => {
mutate((tt) => {
if (!tt.outbounds) tt.outbounds = [];
let idx = tt.outbounds.findIndex((o) => o?.protocol === 'freedom' && o?.tag === 'direct');
if (idx < 0) {
tt.outbounds.push({ protocol: 'freedom', tag: 'direct', settings: {} });
idx = tt.outbounds.length - 1;
}
const ob = tt.outbounds[idx];
const stream = (ob.streamSettings ?? {}) as Record<string, unknown>;
const sockopt = (stream.sockopt ?? {}) as Record<string, unknown>;
if (next == null) {
delete sockopt.happyEyeballs;
} else {
sockopt.happyEyeballs = next;
}
if (Object.keys(sockopt).length === 0) {
delete stream.sockopt;
} else {
stream.sockopt = sockopt;
}
if (Object.keys(stream).length === 0) {
delete ob.streamSettings;
} else {
ob.streamSettings = stream;
}
});
},
[mutate],
);
const routingStrategy = templateSettings?.routing?.domainStrategy ?? 'AsIs';
const log = (templateSettings?.log || {}) as Record<string, unknown>;
const policy = (templateSettings?.policy?.system || {}) as Record<string, boolean>;
@@ -124,6 +168,53 @@ export default function BasicsTab({
/>
}
/>
<SettingListItem
title={t('pages.xray.FreedomHappyEyeballs')}
description={t('pages.xray.FreedomHappyEyeballsDesc')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs != null}
onChange={(checked) => {
setDirectHappyEyeballs(checked ? HappyEyeballsSchema.parse({}) : null);
}}
/>
}
/>
{directHappyEyeballs != null && (
<>
<SettingListItem
title={t('pages.inbounds.form.tryDelayMs')}
description={t('pages.xray.FreedomHappyEyeballsTryDelayDesc')}
paddings="small"
control={
<InputNumber
min={0}
style={{ width: '100%' }}
value={directHappyEyeballs.tryDelayMs}
placeholder="150"
onChange={(v) => setDirectHappyEyeballs({
...directHappyEyeballs,
tryDelayMs: typeof v === 'number' ? v : 0,
})}
/>
}
/>
<SettingListItem
title={t('pages.inbounds.form.prioritizeIPv6')}
paddings="small"
control={
<Switch
checked={directHappyEyeballs.prioritizeIPv6}
onChange={(checked) => setDirectHappyEyeballs({
...directHappyEyeballs,
prioritizeIPv6: checked,
})}
/>
}
/>
</>
)}
<SettingListItem
title={t('pages.xray.RoutingStrategy')}
description={t('pages.xray.RoutingStrategyDesc')}
@@ -350,6 +350,7 @@ export default function OutboundFormModal({
colon={false}
labelCol={{ md: { span: 8 } }}
wrapperCol={{ md: { span: 14 } }}
labelWrap
onValuesChange={onValuesChange}
>
<Tabs
@@ -171,6 +171,7 @@ export default function SockoptForm({
<Form.Item
label={t('pages.inbounds.form.tcpWindowClamp')}
name={['streamSettings', 'sockopt', 'tcpWindowClamp']}
tooltip={t('pages.inbounds.form.tcpWindowClampHint')}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
@@ -88,14 +88,14 @@ export default function NordModal({
}, [filteredServers]);
const fetchCountries = useCallback(async () => {
const msg = await HttpUtil.post<string>('/panel/xray/nord/countries');
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/countries');
if (msg?.success && msg.obj) setCountries(JSON.parse(msg.obj));
}, []);
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/data');
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/data');
if (msg?.success) {
const next = msg.obj ? JSON.parse(msg.obj) : null;
setNordData(next);
@@ -113,7 +113,7 @@ export default function NordModal({
async function login() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/reg', { token });
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/reg', { token });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -126,7 +126,7 @@ export default function NordModal({
async function saveKey() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/setKey', { key: manualKey });
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/setKey', { key: manualKey });
if (msg?.success && msg.obj) {
setNordData(JSON.parse(msg.obj));
await fetchCountries();
@@ -139,7 +139,7 @@ export default function NordModal({
async function logout() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/nord/del');
const msg = await HttpUtil.post('/panel/api/xray/nord/del');
if (msg?.success) {
onRemoveOutbound(nordOutboundIndex);
onRemoveRoutingRules({ prefix: 'nord-' });
@@ -166,7 +166,7 @@ export default function NordModal({
setServerId(null);
setCityId(null);
try {
const msg = await HttpUtil.post<string>('/panel/xray/nord/servers', { countryId: newCountryId });
const msg = await HttpUtil.post<string>('/panel/api/xray/nord/servers', { countryId: newCountryId });
if (!msg?.success || !msg.obj) return;
const data = JSON.parse(msg.obj);
const locations = data.locations || [];
@@ -111,7 +111,7 @@ export default function WarpModal({
const fetchData = useCallback(async () => {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/data');
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/data');
if (msg?.success) {
const raw = msg.obj;
setWarpData(raw && raw.length > 0 ? JSON.parse(raw) : null);
@@ -133,7 +133,7 @@ export default function WarpModal({
setLoading(true);
try {
const keys = Wireguard.generateKeypair();
const msg = await HttpUtil.post<string>('/panel/xray/warp/reg', keys);
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/reg', keys);
if (msg?.success && msg.obj) {
const resp = JSON.parse(msg.obj);
setWarpData(resp.data);
@@ -148,7 +148,7 @@ export default function WarpModal({
async function getConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/config');
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/config');
if (msg?.success && msg.obj) {
const parsed = JSON.parse(msg.obj);
setWarpConfig(parsed);
@@ -164,7 +164,7 @@ export default function WarpModal({
setLoading(true);
setLicenseError('');
try {
const msg = await HttpUtil.post<string>('/panel/xray/warp/license', { license: warpPlus });
const msg = await HttpUtil.post<string>('/panel/api/xray/warp/license', { license: warpPlus });
if (msg?.success && msg.obj) {
setWarpData(JSON.parse(msg.obj));
setWarpConfig(null);
@@ -180,7 +180,7 @@ export default function WarpModal({
async function delConfig() {
setLoading(true);
try {
const msg = await HttpUtil.post('/panel/xray/warp/del');
const msg = await HttpUtil.post('/panel/api/xray/warp/del');
if (msg?.success) {
setWarpData(null);
setWarpConfig(null);
+1
View File
@@ -43,6 +43,7 @@ export const InboundOptionSchema = z.object({
protocol: z.string().optional(),
port: z.number().optional(),
tlsFlowCapable: z.boolean().optional(),
ssMethod: z.string().optional(),
}).loose();
export const InboundOptionsSchema = z.array(InboundOptionSchema);
+5
View File
@@ -26,6 +26,11 @@ export const NodeRecordSchema = z.object({
allowPrivateAddress: z.boolean().optional(),
tlsVerifyMode: z.enum(['verify', 'skip', 'pin']).optional(),
pinnedCertSha256: z.string().optional(),
// Multi-hop node tree (#4983): a node's stable GUID, its parent's GUID, and
// whether it's a read-only transitive sub-node surfaced from a downstream node.
guid: z.string().optional(),
parentGuid: z.string().optional(),
transitive: z.boolean().optional(),
}).loose();
export const NodeListSchema = z.array(NodeRecordSchema);
@@ -57,14 +57,18 @@ export const SockoptStreamSettingsSchema = z.object({
tcpMptcp: z.boolean().default(false),
penetrate: z.boolean().default(false),
domainStrategy: SockoptDomainStrategySchema.default('AsIs'),
tcpMaxSeg: z.number().int().min(0).default(1440),
// 0 = omit on the wire; xray-core skips sockopt fields <= 0 and uses OS defaults.
// Non-zero defaults here previously came from the xray docs *example* (clamp 600,
// maxSeg 1440, userTimeout 10000) and were written into every config when the
// panel sockopt switch was enabled, throttling long-haul links.
tcpMaxSeg: z.number().int().min(0).default(0),
dialerProxy: z.string().default(''),
tcpKeepAliveInterval: z.number().int().min(0).default(45),
tcpKeepAliveIdle: z.number().int().min(0).default(45),
tcpUserTimeout: z.number().int().min(0).default(10000),
tcpKeepAliveInterval: z.number().int().min(0).default(0),
tcpKeepAliveIdle: z.number().int().min(0).default(0),
tcpUserTimeout: z.number().int().min(0).default(0),
tcpcongestion: TcpCongestionSchema.default('bbr'),
V6Only: z.boolean().default(false),
tcpWindowClamp: z.number().int().min(0).default(600),
tcpWindowClamp: z.number().int().min(0).default(0),
interface: z.string().default(''),
trustedXForwardedFor: z.array(z.string()).default([]),
addressPortStrategy: AddressPortStrategySchema.default('none'),
@@ -12,12 +12,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses defaults byte-stably 1`]
"mark": 0,
"penetrate": false,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "off",
"trustedXForwardedFor": [],
@@ -87,12 +87,12 @@ exports[`SockoptStreamSettingsSchema fixtures > parses tproxy byte-stably 1`] =
"mark": 255,
"penetrate": true,
"tcpFastOpen": false,
"tcpKeepAliveIdle": 45,
"tcpKeepAliveInterval": 45,
"tcpMaxSeg": 1440,
"tcpKeepAliveIdle": 0,
"tcpKeepAliveInterval": 0,
"tcpMaxSeg": 0,
"tcpMptcp": false,
"tcpUserTimeout": 10000,
"tcpWindowClamp": 600,
"tcpUserTimeout": 0,
"tcpWindowClamp": 0,
"tcpcongestion": "bbr",
"tproxy": "tproxy",
"trustedXForwardedFor": [],
@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import type { ZodType } from 'zod';
import { EXAMPLES } from '@/generated/examples';
import * as zodSchemas from '@/generated/zod';
const registry = zodSchemas as unknown as Record<string, ZodType>;
const names = Object.keys(EXAMPLES);
describe('generated response examples', () => {
it('has at least one example to validate', () => {
expect(names.length).toBeGreaterThan(0);
});
it('pairs every example with a generated zod schema', () => {
const missing = names.filter((name) => typeof registry[`${name}Schema`]?.safeParse !== 'function');
expect(missing).toEqual([]);
});
it.each(names)('EXAMPLES.%s satisfies its generated zod schema', (name) => {
const schema = registry[`${name}Schema`];
const result = schema.safeParse(EXAMPLES[name]);
if (!result.success) {
throw new Error(
`EXAMPLES.${name} does not match ${name}Schema:\n${JSON.stringify(result.error.issues, null, 2)}`,
);
}
expect(result.success).toBe(true);
});
});
@@ -0,0 +1,243 @@
/// <reference types="vite/client" />
import { describe, expect, it } from 'vitest';
import { formValuesToWirePayload } from '@/lib/xray/inbound-form-adapter';
import { formValuesToWirePayload as outboundToWire } from '@/lib/xray/outbound-form-adapter';
import {
normalizeSockoptForWire,
normalizeStreamSettingsForWire,
normalizeXhttpForWire,
validateRealityTarget,
} from '@/lib/xray/stream-wire-normalize';
import type { InboundFormValues } from '@/schemas/forms/inbound-form';
describe('validateRealityTarget', () => {
it('accepts host:port and bare port', () => {
expect(validateRealityTarget('play.google.com:443')).toBeUndefined();
expect(validateRealityTarget('443')).toBeUndefined();
});
it('rejects host without port', () => {
expect(validateRealityTarget('play.google.com')).toBe('pages.inbounds.form.realityTargetNeedsPort');
expect(validateRealityTarget('')).toBe('pages.inbounds.form.realityTargetRequired');
});
});
describe('normalizeXhttpForWire stream-one', () => {
it('drops packet-up and stream-up-only fields on inbound', () => {
const out = normalizeXhttpForWire({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
scMaxBufferedPosts: 30,
scStreamUpServerSecs: '20-80',
enableXmux: false,
headers: {},
}, 'inbound');
expect(out).toMatchObject({
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
});
expect(out).not.toHaveProperty('scMaxEachPostBytes');
expect(out).not.toHaveProperty('scMinPostsIntervalMs');
expect(out).not.toHaveProperty('scMaxBufferedPosts');
expect(out).not.toHaveProperty('scStreamUpServerSecs');
expect(out).not.toHaveProperty('enableXmux');
expect(out).not.toHaveProperty('headers');
});
it('keeps xmux on outbound stream-one', () => {
const out = normalizeXhttpForWire({
path: '/app',
mode: 'stream-one',
xPaddingBytes: '100-1000',
xmux: { maxConcurrency: '16-32' },
scMaxEachPostBytes: '1000000',
}, 'outbound');
expect(out.xmux).toEqual({ maxConcurrency: '16-32' });
expect(out).not.toHaveProperty('scMaxEachPostBytes');
});
});
describe('normalizeSockoptForWire', () => {
it('omits doc-example defaults that throttle throughput', () => {
const out = normalizeSockoptForWire({
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
domainStrategy: 'AsIs',
tproxy: 'off',
mark: 0,
});
expect(out).toEqual({
tcpFastOpen: true,
tcpcongestion: 'bbr',
});
});
it('preserves happyEyeballs on freedom-style outbound', () => {
const out = normalizeSockoptForWire({
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
});
expect(out?.happyEyeballs).toMatchObject({
tryDelayMs: 150,
prioritizeIPv6: true,
});
expect(out?.domainStrategy).toBe('UseIP');
});
});
describe('normalizeStreamSettingsForWire reality', () => {
it('preserves the nested client settings on inbound (share links read publicKey from there)', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['abcd'],
settings: {
publicKey: 'pub',
fingerprint: 'chrome',
spiderX: '/',
},
},
}, { side: 'inbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.target).toBe('play.google.com:443');
expect(reality.privateKey).toBe('priv');
const settings = reality.settings as Record<string, unknown>;
expect(settings.publicKey).toBe('pub');
expect(settings.spiderX).toBe('/');
});
it('passes client realitySettings through unchanged on outbound', () => {
const out = normalizeStreamSettingsForWire({
network: 'xhttp',
security: 'reality',
realitySettings: {
publicKey: 'pub',
fingerprint: 'chrome',
serverName: 'play.google.com',
shortId: 'abcd',
spiderX: '/x',
},
}, { side: 'outbound' });
const reality = out.realitySettings as Record<string, unknown>;
expect(reality.publicKey).toBe('pub');
expect(reality.serverName).toBe('play.google.com');
expect(reality.spiderX).toBe('/x');
});
});
describe('inbound formValuesToWirePayload integration', () => {
it('emits lean stream-one xhttp + sockopt on save', () => {
const values = {
remark: 't',
enable: true,
port: 443,
listen: '0.0.0.0',
tag: 'in-443',
expiryTime: 0,
sniffing: { enabled: false },
up: 0,
down: 0,
total: 0,
trafficReset: 'never',
lastTrafficResetTime: 0,
nodeId: null,
protocol: 'vless',
settings: { clients: [{ id: '7eeb09ed-ae97-400d-a1ce-2485fb904407', email: 'n' }], decryption: 'none' },
streamSettings: {
network: 'xhttp',
security: 'reality',
realitySettings: {
target: 'play.google.com:443',
privateKey: 'priv',
serverNames: ['play.google.com'],
shortIds: ['44003d86dc1e'],
settings: { publicKey: 'pub', fingerprint: 'chrome', spiderX: '/' },
},
xhttpSettings: {
path: '/app',
host: 'play.google.com',
mode: 'stream-one',
xPaddingBytes: '100-1000',
scMaxEachPostBytes: '1000000',
scMinPostsIntervalMs: '30',
enableXmux: false,
},
sockopt: {
tcpWindowClamp: 0,
tcpMaxSeg: 0,
tcpUserTimeout: 0,
tcpFastOpen: true,
tcpcongestion: 'bbr',
},
},
} as InboundFormValues;
const payload = formValuesToWirePayload(values);
const stream = JSON.parse(payload.streamSettings) as Record<string, unknown>;
const xhttp = stream.xhttpSettings as Record<string, unknown>;
const sockopt = stream.sockopt as Record<string, unknown>;
const reality = stream.realitySettings as Record<string, unknown>;
expect(xhttp).not.toHaveProperty('scMaxEachPostBytes');
expect(sockopt).not.toHaveProperty('tcpWindowClamp');
expect(sockopt.tcpFastOpen).toBe(true);
const realitySettings = reality.settings as Record<string, unknown>;
expect(realitySettings.publicKey).toBe('pub');
});
});
describe('freedom outbound sockopt wire payload', () => {
it('preserves happyEyeballs on direct freedom outbound', () => {
const wire = outboundToWire({
protocol: 'freedom',
tag: 'direct',
settings: { domainStrategy: 'UseIP' },
streamSettings: {
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
interleave: 1,
maxConcurrentTry: 4,
},
},
},
} as Parameters<typeof outboundToWire>[0]);
expect(wire.streamSettings).toMatchObject({
sockopt: {
domainStrategy: 'UseIP',
happyEyeballs: {
tryDelayMs: 150,
prioritizeIPv6: true,
},
},
});
});
});
+11 -4
View File
@@ -183,15 +183,22 @@ export class RandomUtil {
}
static randomShadowsocksPassword(method: string = '2022-blake3-aes-256-gcm'): string {
let length = 32;
if (method === '2022-blake3-aes-128-gcm') {
length = 16;
}
const length = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
return Base64.alternativeEncode(String.fromCharCode(...array));
}
static isShadowsocks2022Password(password: string, method: string): boolean {
if (!method || method.substring(0, 4) !== '2022') return true;
const expected = method === '2022-blake3-aes-128-gcm' ? 16 : 32;
try {
return window.atob(password).length === expected;
} catch {
return false;
}
}
static randomBase64(length: number = 16): string {
const array = new Uint8Array(length);
window.crypto.getRandomValues(array);
+1 -1
View File
@@ -22,7 +22,7 @@ function resolveDBPath() {
return '/etc/x-ui/x-ui.db';
}
const PANEL_API_PREFIXES = ['panel/api/', 'panel/setting/', 'panel/xray/', 'panel/csrf-token'];
const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
let cachedBasePath = '/';
+7 -3
View File
@@ -573,10 +573,14 @@ func mergeClashRulesYAML(base map[string]any, raw string) error {
case []any:
mergeClashRules(base, typed)
case map[string]any:
if rules, ok := typed["rules"]; ok {
if ruleList, ok := asAnySlice(rules); ok {
mergeClashRules(base, ruleList)
for key, value := range typed {
if key == "rules" {
if ruleList, ok := asAnySlice(value); ok {
mergeClashRules(base, ruleList)
}
continue
}
base[key] = value
}
default:
mergeClashRules(base, linesToClashRules(raw))
+26
View File
@@ -90,6 +90,22 @@ func isLoopbackHost(host string) bool {
return ip != nil && ip.IsLoopback()
}
// listenIsInternalOnly reports whether a bind address is reachable only from
// the same host — a loopback IP or a unix-domain socket. Such an inbound can't
// be dialed directly by a remote client, so when it is the child side of a
// fallback its share link must be projected through the master. A public or
// wildcard listen (""/0.0.0.0/::) is reachable on its own port and advertises
// itself.
func listenIsInternalOnly(listen string) bool {
if listen == "" {
return false
}
if listen[0] == '@' || listen[0] == '/' {
return true
}
return isLoopbackHost(listen)
}
// GetSubs retrieves subscription links for a given subscription ID and host.
func (s *SubService) GetSubs(subId string, host string) ([]string, []string, int64, xray.ClientTraffic, error) {
s.PrepareForRequest(host)
@@ -260,10 +276,20 @@ func (s *SubService) getInboundsBySubId(subId string) ([]*model.Inbound, error)
// Returns true when a projection happened; sub services call this before
// generating links so a child VLESS-WS bound to 127.0.0.1 emits the
// master's :443 + TLS state instead of its own loopback endpoint.
//
// Projection only applies to a child that is not directly reachable on its
// own listen (loopback or a unix-domain socket). An inbound on a public or
// wildcard listen is reachable on its own port, so it advertises its own
// port + security even when a stale fallback rule still names it as a child —
// otherwise its share link would leak the master's port and Reality/TLS
// settings (#4987).
func (s *SubService) projectThroughFallbackMaster(inbound *model.Inbound) bool {
if inbound == nil {
return false
}
if !listenIsInternalOnly(inbound.Listen) {
return false
}
db := database.GetDB()
var master *model.Inbound
+19
View File
@@ -61,6 +61,25 @@ func TestIsRoutableHost(t *testing.T) {
}
}
func TestListenIsInternalOnly(t *testing.T) {
// Reachable only from the same host -> a fallback child here must be
// projected through its master.
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
for _, v := range internalOnly {
if !listenIsInternalOnly(v) {
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
}
}
// Directly reachable on its own port -> never projected, even if a stale
// fallback rule names it as a child (#4987).
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
for _, v := range reachable {
if listenIsInternalOnly(v) {
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
}
}
}
func TestResolveInboundAddress(t *testing.T) {
const reqHost = "sub.example.com"
+171
View File
@@ -0,0 +1,171 @@
package main
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
)
func emitExamples(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &exampleGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.forSchema(s, map[string]bool{})
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const EXAMPLES: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type exampleGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *exampleGen) forSchema(s Schema, visited map[string]bool) map[string]any {
obj := make(map[string]any, len(s.Fields))
for _, f := range s.Fields {
obj[f.JSONName] = g.forField(f, visited)
}
return obj
}
func (g *exampleGen) forField(f Field, visited map[string]bool) any {
if f.Example != "" {
return coerceExample(f.Example, baseKind(f.Type))
}
if v, ok := firstOneOf(f.Validate); ok {
return v
}
bk := baseKind(f.Type)
if bk.Kind == KindInt || bk.Kind == KindNumber {
if v, ok := numericFloor(bk.Kind, f.Validate); ok {
return v
}
}
return g.forType(f.Type, visited)
}
func (g *exampleGen) forType(t TypeRef, visited map[string]bool) any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return "2025-01-01T00:00:00Z"
}
return ""
case KindInt, KindNumber:
return 0
case KindBool:
return false
case KindArray:
if isVisitedRef(*t.Element, visited) {
return []any{}
}
return []any{g.forType(*t.Element, visited)}
case KindMap:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
return nil
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.forType(alias.Underlying, visited)
}
schema, ok := g.byName[t.Name]
if !ok || visited[t.Name] {
return map[string]any{}
}
next := cloneVisited(visited)
next[t.Name] = true
return g.forSchema(schema, next)
}
return nil
}
func baseKind(t TypeRef) TypeRef {
if t.Kind == KindRef && t.Name == "nullable" && t.Inner != nil {
return *t.Inner
}
return t
}
func isVisitedRef(t TypeRef, visited map[string]bool) bool {
return t.Kind == KindRef && t.Name != "nullable" && visited[t.Name]
}
func cloneVisited(in map[string]bool) map[string]bool {
out := make(map[string]bool, len(in)+1)
for k, v := range in {
out[k] = v
}
return out
}
func numericFloor(kind TypeKind, rules []ValidateRule) (any, bool) {
for _, r := range rules {
if (r.Name == "gte" || r.Name == "min") && r.Param != "" {
return coerceExample(r.Param, TypeRef{Kind: kind}), true
}
}
return nil, false
}
func firstOneOf(rules []ValidateRule) (string, bool) {
for _, r := range rules {
if r.Name == "oneof" {
fields := strings.Fields(r.Param)
if len(fields) > 0 {
return fields[0], true
}
}
}
return "", false
}
func coerceExample(ex string, t TypeRef) any {
switch t.Kind {
case KindInt:
if n, err := strconv.ParseInt(ex, 10, 64); err == nil {
return n
}
return 0
case KindNumber:
if n, err := strconv.ParseFloat(ex, 64); err == nil {
return n
}
return 0
case KindBool:
return ex == "true"
case KindString:
return ex
default:
var parsed any
if err := json.Unmarshal([]byte(ex), &parsed); err == nil {
return parsed
}
return ex
}
}
const examplesHeader = `// Code generated by tools/openapigen. DO NOT EDIT.`
+190
View File
@@ -0,0 +1,190 @@
package main
import (
"encoding/json"
"fmt"
"io"
"sort"
"strconv"
"strings"
)
func emitJSONSchema(w io.Writer, schemas []Schema, aliases []Alias) error {
byName := make(map[string]Schema, len(schemas))
for _, s := range schemas {
byName[s.Name] = s
}
aliasByName := make(map[string]Alias, len(aliases))
for _, a := range aliases {
aliasByName[a.Name] = a
}
gen := &schemaGen{byName: byName, aliasByName: aliasByName}
out := make(map[string]any, len(schemas))
for _, s := range schemas {
out[s.Name] = gen.objectSchema(s)
}
payload, err := json.MarshalIndent(out, "", " ")
if err != nil {
return err
}
if _, err := fmt.Fprintln(w, examplesHeader); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "export const SCHEMAS: Record<string, unknown> = %s;\n", payload); err != nil {
return err
}
return nil
}
type schemaGen struct {
byName map[string]Schema
aliasByName map[string]Alias
}
func (g *schemaGen) objectSchema(s Schema) map[string]any {
props := make(map[string]any, len(s.Fields))
var required []string
for _, f := range s.Fields {
props[f.JSONName] = g.fieldSchema(f)
if !f.Optional {
required = append(required, f.JSONName)
}
}
obj := map[string]any{"type": "object", "properties": props}
if len(required) > 0 {
sort.Strings(required)
obj["required"] = required
}
if s.Doc != "" {
obj["description"] = s.Doc
}
return obj
}
func (g *schemaGen) fieldSchema(f Field) map[string]any {
sch := g.typeSchema(f.Type)
if ref, ok := sch["$ref"]; ok {
if f.Doc == "" && f.Example == "" {
return sch
}
wrap := map[string]any{"allOf": []any{map[string]any{"$ref": ref}}}
if f.Doc != "" {
wrap["description"] = f.Doc
}
if f.Example != "" {
wrap["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return wrap
}
applyConstraints(sch, f.Type, f.Validate)
if f.Doc != "" {
sch["description"] = f.Doc
}
if f.Example != "" {
sch["example"] = coerceExample(f.Example, baseKind(f.Type))
}
return sch
}
func (g *schemaGen) typeSchema(t TypeRef) map[string]any {
switch t.Kind {
case KindString:
if t.Name == "datetime" {
return map[string]any{"type": "string", "format": "date-time"}
}
return map[string]any{"type": "string"}
case KindInt:
return map[string]any{"type": "integer"}
case KindNumber:
return map[string]any{"type": "number"}
case KindBool:
return map[string]any{"type": "boolean"}
case KindArray:
return map[string]any{"type": "array", "items": g.typeSchema(*t.Element)}
case KindMap:
return map[string]any{"type": "object", "additionalProperties": g.typeSchema(*t.Value)}
case KindAny, KindUnknown, KindRaw:
return map[string]any{}
case KindRef:
if t.Name == "nullable" {
inner := g.typeSchema(*t.Inner)
if ref, ok := inner["$ref"]; ok {
return map[string]any{"nullable": true, "allOf": []any{map[string]any{"$ref": ref}}}
}
inner["nullable"] = true
return inner
}
if alias, ok := g.aliasByName[t.Name]; ok {
return g.typeSchema(alias.Underlying)
}
if _, ok := g.byName[t.Name]; ok {
return map[string]any{"$ref": "#/components/schemas/" + t.Name}
}
return map[string]any{}
}
return map[string]any{}
}
func applyConstraints(sch map[string]any, t TypeRef, rules []ValidateRule) {
base := baseKind(t)
numeric := base.Kind == KindInt || base.Kind == KindNumber
str := base.Kind == KindString
for _, r := range rules {
switch r.Name {
case "gte":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
}
case "lte":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
}
case "gt":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
sch["exclusiveMinimum"] = true
}
case "lt":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
sch["exclusiveMaximum"] = true
}
case "min":
if numeric {
sch["minimum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["minLength"] = n
}
}
case "max":
if numeric {
sch["maximum"] = coerceExample(r.Param, base)
} else if str {
if n, err := strconv.Atoi(r.Param); err == nil {
sch["maxLength"] = n
}
}
case "oneof":
vals := strings.Fields(r.Param)
if len(vals) > 0 {
enum := make([]any, len(vals))
for i, v := range vals {
enum[i] = v
}
sch["enum"] = enum
}
case "email":
if str {
sch["format"] = "email"
}
case "url":
if str {
sch["format"] = "uri"
}
}
}
}
+22
View File
@@ -69,6 +69,14 @@ func run(root, outDir string) error {
"ClientTraffic",
),
},
{
Path: resolveRel(root, "web/service"),
StructAllow: setOf(
"InboundOption",
"ApiTokenView",
"ProbeResultUI",
),
},
}
schemas, aliases, err := walkPackages(requests)
@@ -94,6 +102,14 @@ func run(root, outDir string) error {
if err := emitTypes(typesBuf, schemas, aliases); err != nil {
return err
}
examplesBuf := &bytes.Buffer{}
if err := emitExamples(examplesBuf, schemas, aliases); err != nil {
return err
}
schemasBuf := &bytes.Buffer{}
if err := emitJSONSchema(schemasBuf, schemas, aliases); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "zod.ts"), zodBuf.Bytes(), 0o644); err != nil {
return err
@@ -101,6 +117,12 @@ func run(root, outDir string) error {
if err := os.WriteFile(filepath.Join(target, "types.ts"), typesBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "examples.ts"), examplesBuf.Bytes(), 0o644); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(target, "schemas.ts"), schemasBuf.Bytes(), 0o644); err != nil {
return err
}
fmt.Printf("openapigen: wrote %d schemas to %s\n", len(schemas), target)
return nil
+3 -1
View File
@@ -27,6 +27,7 @@ type Field struct {
Skip bool
Validate []ValidateRule
Doc string
Example string
}
type TypeRef struct {
@@ -59,10 +60,11 @@ type ValidateRule struct {
Param string
}
func parseStructTag(raw string) (json string, validate string, gormHasDash bool) {
func parseStructTag(raw string) (json string, validate string, example string, gormHasDash bool) {
tag := reflect.StructTag(strings.Trim(raw, "`"))
json = tag.Get("json")
validate = tag.Get("validate")
example = tag.Get("example")
if g := tag.Get("gorm"); g != "" {
for part := range strings.SplitSeq(g, ";") {
if strings.TrimSpace(part) == "-" {
+3 -1
View File
@@ -102,7 +102,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
if fld.Tag != nil {
tag = fld.Tag.Value
}
jsonTag, validateTag, gormDash := parseStructTag(tag)
jsonTag, validateTag, exampleTag, gormDash := parseStructTag(tag)
if gormDash && jsonTag == "" {
return nil
}
@@ -132,6 +132,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
Example: exampleTag,
})
}
@@ -154,6 +155,7 @@ func buildFields(fld *ast.Field, overrides []walkOverride) []Field {
Optional: omitempty || isPointer(fld.Type),
Validate: validate,
Doc: doc,
Example: exampleTag,
})
}
+39 -2
View File
@@ -582,12 +582,14 @@ prompt_and_setup_ssl() {
echo -e "${green}1.${plain} Let's Encrypt for Domain (90-day validity, auto-renews)"
echo -e "${green}2.${plain} Let's Encrypt for IP Address (6-day validity, auto-renews)"
echo -e "${green}3.${plain} Custom SSL Certificate (Path to existing files)"
echo -e "${green}4.${plain} Skip SSL (advanced — behind reverse proxy / SSH tunnel only)"
echo -e "${blue}Note:${plain} Options 1 & 2 require port 80 open. Option 3 requires manual paths."
echo -e "${blue}Note:${plain} Option 4 serves the panel over plain HTTP — only safe behind nginx/Caddy or an SSH tunnel."
read -rp "Choose an option (default 2 for IP): " ssl_choice
ssl_choice="${ssl_choice// /}" # Trim whitespace
# Default to 2 (IP cert) if input is empty or invalid (not 1 or 3)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" ]]; then
# Default to 2 (IP cert) if input is empty or invalid (not 1, 3 or 4)
if [[ "$ssl_choice" != "1" && "$ssl_choice" != "3" && "$ssl_choice" != "4" ]]; then
ssl_choice="2"
fi
@@ -706,6 +708,41 @@ prompt_and_setup_ssl() {
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
;;
4)
echo ""
echo -e "${red}⚠ Panel will be installed WITHOUT SSL/TLS.${plain}"
echo -e "${yellow}Login credentials and cookies will travel as plain HTTP.${plain}"
echo -e "${yellow}Only safe when:${plain}"
echo -e "${yellow} • A reverse proxy (nginx, Caddy, Traefik) terminates TLS for you, or${plain}"
echo -e "${yellow} • You access the panel exclusively via SSH tunnel${plain}"
echo ""
SSL_SCHEME="http"
SSL_HOST="${server_ip}"
local bind_local=""
read -rp "Bind the panel to 127.0.0.1 only? (recommended — forces SSH tunnel / reverse-proxy access) [y/N]: " bind_local
if [[ "$bind_local" == "y" || "$bind_local" == "Y" ]]; then
${xui_folder}/x-ui setting -listenIP "127.0.0.1" > /dev/null 2>&1
SSL_HOST="127.0.0.1"
echo -e "${green}✓ Panel bound to 127.0.0.1 only. It is now unreachable from the public internet.${plain}"
echo ""
echo -e "${green}SSH Port Forwarding — open the panel from your local machine via:${plain}"
echo -e " Standard SSH command:"
echo -e " ${yellow}ssh -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " If using an SSH key:"
echo -e " ${yellow}ssh -i <sshkeypath> -L 2222:127.0.0.1:${panel_port} root@${server_ip}${plain}"
echo -e " Then open in your browser:"
echo -e " ${yellow}http://localhost:2222/${web_base_path}${plain}"
echo ""
echo -e "${yellow}Alternative: point a reverse proxy (nginx/Caddy) at 127.0.0.1:${panel_port} and let it terminate TLS.${plain}"
else
echo -e "${yellow}Panel will listen on all interfaces over plain HTTP. Make sure something else is terminating TLS in front of it.${plain}"
fi
systemctl restart x-ui > /dev/null 2>&1 || rc-service x-ui restart > /dev/null 2>&1
echo -e "${green}✓ SSL setup skipped.${plain}"
;;
*)
echo -e "${red}Invalid option. Skipping SSL setup.${plain}"
SSL_HOST="${server_ip}"
+15 -7
View File
@@ -14,13 +14,15 @@ import (
// APIController handles the main API routes for the 3x-ui panel, including inbounds and server management.
type APIController struct {
BaseController
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
settingService service.SettingService
userService service.UserService
apiTokenService service.ApiTokenService
Tgbot service.Tgbot
inboundController *InboundController
serverController *ServerController
nodeController *NodeController
settingController *SettingController
xraySettingController *XraySettingController
settingService service.SettingService
userService service.UserService
apiTokenService service.ApiTokenService
Tgbot service.Tgbot
}
// NewAPIController creates a new APIController instance and initializes its routes.
@@ -79,6 +81,12 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
NewCustomGeoController(api.Group("/custom-geo"), customGeo)
// Settings + Xray config management live under the API surface too, so the
// same API token drives them. Paths are /panel/api/setting/* and
// /panel/api/xray/*.
a.settingController = NewSettingController(api)
a.xraySettingController = NewXraySettingController(api)
// Extra routes
api.POST("/backuptotgbot", a.BackuptoTgbot)
}
+2 -2
View File
@@ -96,9 +96,9 @@ func TestAPIRoutesDocumented(t *testing.T) {
case "node.go":
basePath = "/panel/api/nodes"
case "setting.go":
basePath = "/panel/setting"
basePath = "/panel/api/setting"
case "xray_setting.go":
basePath = "/panel/xray"
basePath = "/panel/api/xray"
case "custom_geo.go":
basePath = "/panel/api/custom-geo"
case "websocket.go":
+4 -4
View File
@@ -72,7 +72,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
g.POST("/ips/:email", a.getIps)
g.POST("/clearIps/:email", a.clearIps)
g.POST("/onlines", a.onlines)
g.POST("/onlinesByNode", a.onlinesByNode)
g.POST("/onlinesByGuid", a.onlinesByGuid)
g.POST("/activeInbounds", a.activeInbounds)
g.POST("/lastOnline", a.lastOnline)
}
@@ -417,12 +417,12 @@ func (a *ClientController) onlines(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClients(), nil)
}
func (a *ClientController) onlinesByNode(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByNode(), nil)
func (a *ClientController) onlinesByGuid(c *gin.Context) {
jsonObj(c, a.inboundService.GetOnlineClientsByGuid(), nil)
}
func (a *ClientController) activeInbounds(c *gin.Context) {
jsonObj(c, a.inboundService.GetActiveInboundsByNode(), nil)
jsonObj(c, a.inboundService.GetActiveInboundsByGuid(), nil)
}
func (a *ClientController) lastOnline(c *gin.Context) {
+33
View File
@@ -3,6 +3,7 @@ package controller
import (
"bytes"
"embed"
"encoding/json"
htmlpkg "html"
"net/http"
"strings"
@@ -34,10 +35,42 @@ func ServeOpenAPISpec(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"success": false, "msg": "openapi.json not found"})
return
}
// The embedded spec ships with `servers: [{url: "/"}]`. When the panel runs
// under a non-root web base path, Swagger UI "Try it out" and external
// generators must target that prefix, so rewrite the single server entry to
// the runtime base path before serving.
if basePath := c.GetString("base_path"); basePath != "" && basePath != "/" {
if rebuilt, err := withServerBasePath(body, basePath); err != nil {
logger.Warning("openapi.json: could not inject base path:", err)
} else {
body = rebuilt
}
}
c.Header("Cache-Control", "public, max-age=300")
c.Data(http.StatusOK, "application/json; charset=utf-8", body)
}
// withServerBasePath rewrites the spec's `servers` entry so requests target the
// panel's configured web base path. Only the top-level `servers` field is
// replaced; every other field is preserved verbatim via json.RawMessage.
func withServerBasePath(spec []byte, basePath string) ([]byte, error) {
var doc map[string]json.RawMessage
if err := json.Unmarshal(spec, &doc); err != nil {
return nil, err
}
servers, err := json.Marshal([]map[string]string{{
"url": strings.TrimSuffix(basePath, "/"),
"description": "Current panel",
}})
if err != nil {
return nil, err
}
doc["servers"] = servers
return json.Marshal(doc)
}
func serveDistPage(c *gin.Context, name string) {
body, err := distFS.ReadFile("dist/" + name)
if err != nil {
+42
View File
@@ -0,0 +1,42 @@
package controller
import (
"encoding/json"
"testing"
)
func TestWithServerBasePath(t *testing.T) {
spec := []byte(`{"openapi":"3.0.3","info":{"title":"x"},"servers":[{"url":"/","description":"old"}],"paths":{"/p":{"get":{"summary":"s"}}}}`)
out, err := withServerBasePath(spec, "/test/")
if err != nil {
t.Fatalf("withServerBasePath: %v", err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
servers, ok := doc["servers"].([]any)
if !ok || len(servers) != 1 {
t.Fatalf("servers = %v, want one entry", doc["servers"])
}
srv, _ := servers[0].(map[string]any)
if srv["url"] != "/test" {
t.Errorf("server url = %v, want /test (trailing slash trimmed)", srv["url"])
}
if doc["openapi"] != "3.0.3" {
t.Errorf("openapi field not preserved: %v", doc["openapi"])
}
if _, ok := doc["paths"].(map[string]any)["/p"]; !ok {
t.Errorf("paths content not preserved verbatim")
}
}
func TestWithServerBasePathInvalidJSON(t *testing.T) {
if _, err := withServerBasePath([]byte("not json"), "/test/"); err == nil {
t.Errorf("expected error on invalid spec, got nil")
}
}
+1 -1
View File
@@ -43,7 +43,7 @@ func (a *NodeController) initRouter(g *gin.RouterGroup) {
}
func (a *NodeController) list(c *gin.Context) {
nodes, err := a.nodeService.GetAll()
nodes, err := a.nodeService.GetNodeTree()
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.list"), err)
return
+9
View File
@@ -56,6 +56,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.GET("/getMigration", a.getMigration)
g.GET("/getNewUUID", a.getNewUUID)
g.GET("/getWebCertFiles", a.getWebCertFiles)
g.GET("/descendants", a.descendants)
g.GET("/getNewX25519Cert", a.getNewX25519Cert)
g.GET("/getNewmldsa65", a.getNewmldsa65)
g.GET("/getNewmlkem768", a.getNewmlkem768)
@@ -334,6 +335,14 @@ func (a *ServerController) importDB(c *gin.Context) {
jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil)
}
// descendants publishes read-only summaries of the nodes this panel manages so
// a parent panel can surface them as transitive sub-nodes in a chained
// topology. Called by the parent via the node's API token (#4983).
func (a *ServerController) descendants(c *gin.Context) {
data, err := (&service.NodeService{}).LocalDescendants()
jsonObj(c, data, err)
}
// getWebCertFiles returns this panel's own web TLS certificate and key file
// paths. The central panel calls it on a node (via the node's API token) so
// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist
+1 -7
View File
@@ -10,12 +10,9 @@ import (
"github.com/gin-gonic/gin"
)
// XUIController is the main controller for the X-UI panel, managing sub-controllers.
// XUIController is the main controller for the X-UI panel, serving the SPA shell.
type XUIController struct {
BaseController
settingController *SettingController
xraySettingController *XraySettingController
}
// NewXUIController creates a new XUIController and initializes its routes.
@@ -49,9 +46,6 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
// so they fetch the session token via this endpoint at startup and replay it
// on subsequent unsafe requests through axios.
g.GET("/csrf-token", a.csrfToken)
a.settingController = NewSettingController(g)
a.xraySettingController = NewXraySettingController(g)
}
// panelSPA serves the React SPA shell. Every GET under /panel/ that isn't an
+29 -8
View File
@@ -17,6 +17,8 @@ import (
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
"github.com/mhsanaei/3x-ui/v3/xray"
"gorm.io/gorm"
)
// IPWithTimestamp tracks an IP address with its last seen timestamp
@@ -184,6 +186,22 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
shouldCleanLog := false
for email, ipTimestamps := range inboundClientIps {
// The access log can still reference a client that was just renamed
// or deleted; its email no longer matches any inbound. Skip it (and
// drop any orphaned tracking row) instead of recreating a row and
// logging an ERROR every run until the log rotates out the old email
// (#4963).
inbound, err := j.getInboundByEmail(email)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
logger.Debugf("[LimitIP] skipping stale access-log email %q (renamed or deleted)", email)
j.delInboundClientIps(email)
} else {
j.checkError(err)
}
continue
}
// Convert to IPWithTimestamp slice
ipsWithTime := make([]IPWithTimestamp, 0, len(ipTimestamps))
for ip, timestamp := range ipTimestamps {
@@ -196,7 +214,7 @@ func (j *CheckClientIpJob) processLogFile(enforce bool) bool {
continue
}
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, email, ipsWithTime, enforce) || shouldCleanLog
shouldCleanLog = j.updateInboundClientIps(clientIpsRecord, inbound, email, ipsWithTime, enforce) || shouldCleanLog
}
return shouldCleanLog
@@ -311,14 +329,17 @@ func (j *CheckClientIpJob) addInboundClientIps(clientEmail string, ipsWithTime [
return nil
}
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
// Get the inbound configuration
inbound, err := j.getInboundByEmail(clientEmail)
if err != nil {
logger.Errorf("failed to fetch inbound settings for email %s: %s", clientEmail, err)
return false
// delInboundClientIps drops the inbound_client_ips tracking row for an email
// that no longer maps to any inbound (a renamed or deleted client), so stale
// access-log entries don't keep a ghost row alive (#4963).
func (j *CheckClientIpJob) delInboundClientIps(clientEmail string) {
db := database.GetDB()
if err := db.Where("client_email = ?", clientEmail).Delete(&model.InboundClientIps{}).Error; err != nil {
j.checkError(err)
}
}
func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.InboundClientIps, inbound *model.Inbound, clientEmail string, newIpsWithTime []IPWithTimestamp, enforce bool) bool {
if inbound.Settings == "" {
logger.Debug("wrong data:", inbound)
return false
@@ -411,7 +432,7 @@ func (j *CheckClientIpJob) updateInboundClientIps(inboundClientIps *model.Inboun
inboundClientIps.Ips = string(jsonIps)
db := database.GetDB()
err = db.Save(inboundClientIps).Error
err := db.Save(inboundClientIps).Error
if err != nil {
logger.Error("failed to save inboundClientIps:", err)
return false
@@ -195,7 +195,11 @@ func TestUpdateInboundClientIps_LiveIpNotBannedByStillFreshHistoricals(t *testin
{IP: "128.71.1.1", Timestamp: now},
}
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if shouldCleanLog {
t.Fatalf("shouldCleanLog must be false, nothing should have been banned with 1 live ip under limit 3")
@@ -244,7 +248,11 @@ func TestUpdateInboundClientIps_ExcessLiveIpIsStillBanned(t *testing.T) {
{IP: "192.0.2.9", Timestamp: now},
}
shouldCleanLog := j.updateInboundClientIps(row, email, live, true)
inbound, err := j.getInboundByEmail(email)
if err != nil {
t.Fatalf("getInboundByEmail: %v", err)
}
shouldCleanLog := j.updateInboundClientIps(row, inbound, email, live, true)
if !shouldCleanLog {
t.Fatalf("shouldCleanLog must be true when the live set exceeds the limit")
@@ -321,6 +329,32 @@ func TestRun_CollectsIpsWithoutLimit(t *testing.T) {
}
}
// #4963: a stale access-log entry for a renamed/deleted client (its email no
// longer maps to any inbound) must not create or resurrect an
// inbound_client_ips row, and must drop any orphan left behind — instead of
// spamming "failed to fetch inbound settings" every run.
func TestRun_StaleAccessLogEmailIsSkippedAndOrphanDropped(t *testing.T) {
setupIntegrationDB(t)
t.Setenv("XUI_ENABLE_FAIL2BAN", "true")
fakeFail2BanClient(t)
const staleEmail = "renamed-away"
// No inbound references staleEmail. Pre-seed an orphan tracking row to
// confirm the job removes it rather than leaving it to error forever.
seedClientIps(t, staleEmail, []IPWithTimestamp{{IP: "203.0.113.5", Timestamp: time.Now().Unix()}})
writeXrayAccessLog(t, staleEmail, "203.0.113.5")
NewCheckClientIpJob().Run()
var count int64
if err := database.GetDB().Model(&model.InboundClientIps{}).Where("client_email = ?", staleEmail).Count(&count).Error; err != nil {
t.Fatalf("count InboundClientIps: %v", err)
}
if count != 0 {
t.Fatalf("stale-email orphan row should be deleted, got %d row(s)", count)
}
}
// readIpLimitLogPath reads the 3xipl.log path the same way the job
// does via xray.GetIPLimitLogPath but without importing xray here
// just for the path helper (which would pull a lot more deps into the
+11 -1
View File
@@ -59,7 +59,7 @@ func (j *NodeHeartbeatJob) Run() {
if !websocket.HasClients() {
return
}
updated, err := j.nodeService.GetAll()
updated, err := j.nodeService.GetNodeTree()
if err != nil {
logger.Warning("node heartbeat: load nodes for broadcast failed:", err)
return
@@ -79,4 +79,14 @@ func (j *NodeHeartbeatJob) probeOne(n *model.Node) {
if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil {
logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr)
}
// Learn the nodes this node manages so the panel can surface them as
// transitive sub-nodes (#4983). Fresh context — the probe budget above may
// be spent. Drop them when the node is unreachable.
if patch.Status == "online" {
dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout)
j.nodeService.RefreshDescendants(dctx, n)
dcancel()
} else {
j.nodeService.ClearDescendants(n.Id)
}
}
+2 -2
View File
@@ -125,8 +125,8 @@ func (j *NodeTrafficSyncJob) Run() {
}
websocket.BroadcastTraffic(map[string]any{
"onlineClients": online,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnline,
})
+2 -2
View File
@@ -107,8 +107,8 @@ func (j *XrayTrafficJob) Run() {
"traffics": traffics,
"clientTraffics": clientTraffics,
"onlineClients": onlineClients,
"onlineByNode": j.inboundService.GetOnlineClientsByNode(),
"activeInbounds": j.inboundService.GetActiveInboundsByNode(),
"onlineByGuid": j.inboundService.GetOnlineClientsByGuid(),
"activeInbounds": j.inboundService.GetActiveInboundsByGuid(),
"lastOnlineMap": lastOnlineMap,
})
+39 -7
View File
@@ -370,6 +370,24 @@ func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) {
return &files, nil
}
// GetDescendants fetches the node's read-only summaries of the nodes IT
// manages, so this panel can surface them as transitive sub-nodes in a chained
// topology (#4983). Best-effort: an old-build node without the endpoint returns
// an error the caller ignores.
func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) {
env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil)
if err != nil {
return nil, err
}
var out []model.NodeSummary
if len(env.Obj) > 0 {
if err := json.Unmarshal(env.Obj, &out); err != nil {
return nil, fmt.Errorf("decode descendants: %w", err)
}
}
return out, nil
}
func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error {
_, err := r.do(ctx, http.MethodPost,
"panel/api/clients/resetTraffic/"+url.PathEscape(email), nil)
@@ -382,8 +400,14 @@ func (r *Remote) ResetAllTraffics(ctx context.Context) error {
}
type TrafficSnapshot struct {
Inbounds []*model.Inbound
OnlineEmails []string
Inbounds []*model.Inbound
OnlineEmails []string
// OnlineTree is the node's GUID-keyed online subtree (its own clients under
// its panelGuid plus every descendant under theirs). Preferred over the flat
// OnlineEmails so the master can attribute deeply nested clients to the real
// node across a chain (#4983). Empty when the node is an old build without
// the per-GUID endpoint — OnlineEmails is the fallback then.
OnlineTree map[string][]string
LastOnlineMap map[string]int64
}
@@ -398,11 +422,19 @@ func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, er
return nil, fmt.Errorf("decode inbound list: %w", err)
}
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
// Prefer the GUID-keyed subtree; fall back to the flat list only when the
// node is an old build without the per-GUID endpoint (#4983).
envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil)
if err == nil && len(envTree.Obj) > 0 {
_ = json.Unmarshal(envTree.Obj, &snap.OnlineTree)
}
if len(snap.OnlineTree) == 0 {
envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil)
if err != nil {
logger.Warning("remote", r.node.Name, "onlines fetch failed:", err)
} else if len(envOnlines.Obj) > 0 {
_ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails)
}
}
envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil)
+5 -5
View File
@@ -17,11 +17,11 @@ type ApiTokenService struct{}
const apiTokenLength = 48
type ApiTokenView struct {
Id int `json:"id"`
Name string `json:"name"`
Token string `json:"token,omitempty"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"createdAt"`
Id int `json:"id" example:"2"`
Name string `json:"name" example:"central-panel-a"`
Token string `json:"token,omitempty" example:"new-token-string"`
Enabled bool `json:"enabled" example:"true"`
CreatedAt int64 `json:"createdAt" example:"1736000000"`
}
// toView builds the metadata view returned by List. It never carries the
+155 -17
View File
@@ -231,9 +231,30 @@ func (s *InboundService) GetInbounds(userId int) ([]*model.Inbound, error) {
}
s.enrichClientStats(db, inbounds)
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
return inbounds, nil
}
// annotateLocalOriginGuid fills OriginNodeGuid for this panel's OWN inbounds
// (NodeID == nil) with the panel's stable GUID; inbounds synced from a node
// already carry the originating node's GUID. Read-time only (not persisted) so
// the per-inbound online view can scope by GUID uniformly across a chain of
// nodes (#4983).
func (s *InboundService) annotateLocalOriginGuid(inbounds []*model.Inbound) {
if len(inbounds) == 0 {
return
}
guid := s.panelGuid()
if guid == "" {
return
}
for _, ib := range inbounds {
if ib.OriginNodeGuid == "" && ib.NodeID == nil {
ib.OriginNodeGuid = guid
}
}
}
// GetInboundsSlim returns the same list of inbounds as GetInbounds but
// strips every per-client field other than email / enable / comment from
// settings.clients and skips UUID/SubId enrichment on ClientStats. The
@@ -252,6 +273,7 @@ func (s *InboundService) GetInboundsSlim(userId int) ([]*model.Inbound, error) {
return nil, err
}
s.annotateFallbackParents(db, inbounds)
s.annotateLocalOriginGuid(inbounds)
for _, ib := range inbounds {
ib.Settings = slimSettingsClients(ib.Settings)
}
@@ -334,12 +356,13 @@ func (s *InboundService) annotateFallbackParents(db *gorm.DB, inbounds []*model.
}
type InboundOption struct {
Id int `json:"id"`
Remark string `json:"remark"`
Tag string `json:"tag"`
Protocol string `json:"protocol"`
Port int `json:"port"`
TlsFlowCapable bool `json:"tlsFlowCapable"`
Id int `json:"id" example:"1"`
Remark string `json:"remark" example:"VLESS-443"`
Tag string `json:"tag" example:"in-443-tcp"`
Protocol string `json:"protocol" example:"vless"`
Port int `json:"port" example:"443"`
TlsFlowCapable bool `json:"tlsFlowCapable" example:"true"`
SsMethod string `json:"ssMethod"`
}
func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error) {
@@ -351,9 +374,10 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Protocol string `gorm:"column:protocol"`
Port int `gorm:"column:port"`
StreamSettings string `gorm:"column:stream_settings"`
Settings string `gorm:"column:settings"`
}
err := db.Table("inbounds").
Select("id, remark, tag, protocol, port, stream_settings").
Select("id, remark, tag, protocol, port, stream_settings, settings").
Where("user_id = ?", userId).
Order("id ASC").
Scan(&rows).Error
@@ -369,11 +393,28 @@ func (s *InboundService) GetInboundOptions(userId int) ([]InboundOption, error)
Protocol: r.Protocol,
Port: r.Port,
TlsFlowCapable: inboundCanEnableTlsFlow(r.Protocol, r.StreamSettings),
SsMethod: inboundShadowsocksMethod(r.Protocol, r.Settings),
})
}
return out, nil
}
// inboundShadowsocksMethod extracts settings.method for Shadowsocks inbounds so
// the client UI can generate a valid PSK (base64 of the method's key length)
// for Shadowsocks 2022 ciphers. Returns "" for non-Shadowsocks inbounds.
func inboundShadowsocksMethod(protocol, settings string) string {
if protocol != string(model.Shadowsocks) || settings == "" {
return ""
}
var s struct {
Method string `json:"method"`
}
if err := json.Unmarshal([]byte(settings), &s); err != nil {
return ""
}
return s.Method
}
// inboundCanEnableTlsFlow mirrors Inbound.canEnableTlsFlow() from the frontend:
// XTLS Vision is only valid for VLESS on TCP with tls or reality.
func inboundCanEnableTlsFlow(protocol, streamSettings string) bool {
@@ -1193,6 +1234,11 @@ func (s *InboundService) updateClientTraffics(tx *gorm.DB, oldInbound *model.Inb
if err := s.DelClientStat(tx, email); err != nil {
return err
}
// Keep inbound_client_ips in sync when the inbound edit drops an
// email, so the IP-limit job doesn't keep a ghost tracking row (#4963).
if err := s.DelClientIPs(tx, email); err != nil {
return err
}
}
for i := range newClients {
email := newClients[i].Email
@@ -1448,6 +1494,21 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
db := database.GetDB()
now := time.Now().UnixMilli()
// originGuidFor attributes a synced inbound to the panel that physically
// hosts it: inbounds the node forwards from its own sub-nodes already carry
// a non-empty OriginNodeGuid (kept as-is across hops); the node's own local
// inbounds report empty, so they are attributed to the node's own GUID. An
// empty result (old-build node with no GUID yet) leaves attribution to the
// node_id fallback downstream (#4983).
var nodeRow model.Node
db.Select("guid").Where("id = ?", nodeID).First(&nodeRow)
originGuidFor := func(snapIb *model.Inbound) string {
if snapIb.OriginNodeGuid != "" {
return snapIb.OriginNodeGuid
}
return nodeRow.Guid
}
var central []model.Inbound
if err := db.Model(model.Inbound{}).
Where("node_id = ?", nodeID).
@@ -1593,6 +1654,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
newIb := model.Inbound{
UserId: defaultUserId,
NodeID: &nodeID,
OriginNodeGuid: originGuidFor(snapIb),
Tag: chosenTag,
Listen: snapIb.Listen,
Port: snapIb.Port,
@@ -1640,6 +1702,12 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
updates["up"] = snapIb.Up
updates["down"] = snapIb.Down
}
// Physical-home attribution is independent of config-dirty state, so
// keep it current even while the node has pending offline edits. Writes
// once to backfill an existing row, then stays equal (#4983).
if og := originGuidFor(snapIb); c.OriginNodeGuid != og {
updates["origin_node_guid"] = og
}
if !dirty && (c.Settings != snapIb.Settings ||
c.Remark != snapIb.Remark ||
@@ -1760,7 +1828,7 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
// from the node arriving after a central disable would otherwise
// overwrite enable=false back to true, letting the client accumulate
// far more traffic than their limit before being disabled again.
enableExpr := "CASE WHEN ? = 0 THEN 0 ELSE enable END"
enableExpr := "enable AND ?"
if err := tx.Exec(
fmt.Sprintf(
`UPDATE client_traffics
@@ -1928,7 +1996,17 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
committed = true
if p != nil {
p.SetNodeOnlineClients(nodeID, snap.OnlineEmails)
tree := snap.OnlineTree
if len(tree) == 0 && len(snap.OnlineEmails) > 0 {
// Old-build node (no GUID tree): key its flat online list under its
// own effective identity so attribution still works for that branch.
effectiveGuid := nodeRow.Guid
if effectiveGuid == "" {
effectiveGuid = synthNodeGuid(nodeID)
}
tree = map[string][]string{effectiveGuid: snap.OnlineEmails}
}
p.SetNodeOnlineTree(nodeID, tree)
}
return structuralChange, nil
@@ -3629,23 +3707,46 @@ func (s *InboundService) GetOnlineClients() []string {
return p.GetOnlineClients()
}
func (s *InboundService) GetOnlineClientsByNode() map[int][]string {
// GetOnlineClientsByGuid returns online emails keyed by the panelGuid of the
// node that physically hosts each set: this panel's own clients under its own
// GUID, plus every node in the tree under its GUID (#4983). Replaces the old
// node-id keying so a client three hops down is attributed to its real node,
// not the intermediate one it was synced through.
func (s *InboundService) GetOnlineClientsByGuid() map[string][]string {
if p == nil {
return map[int][]string{}
return map[string][]string{}
}
return p.GetOnlineClientsByNode()
out := p.GetMergedNodeTrees()
if local := p.GetLocalOnlineClients(); len(local) > 0 {
if guid := s.panelGuid(); guid != "" {
out[guid] = mergeEmails(out[guid], local)
}
}
return out
}
func (s *InboundService) GetActiveInboundsByNode() map[int][]string {
// GetActiveInboundsByGuid returns the inbound tags that carried traffic within
// the grace window for THIS panel, under its own GUID. Remote nodes don't
// report per-inbound activity, so a GUID missing from the map means "don't
// gate" for that node's inbounds.
func (s *InboundService) GetActiveInboundsByGuid() map[string][]string {
if p == nil {
return map[int][]string{}
return map[string][]string{}
}
return p.GetActiveInboundsByNode()
active := p.GetLocalActiveInbounds()
if len(active) == 0 {
return map[string][]string{}
}
guid := s.panelGuid()
if guid == "" {
return map[string][]string{}
}
return map[string][]string{guid: active}
}
func (s *InboundService) SetNodeOnlineClients(nodeID int, emails []string) {
func (s *InboundService) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
if p != nil {
p.SetNodeOnlineClients(nodeID, emails)
p.SetNodeOnlineTree(nodeID, tree)
}
}
@@ -3655,6 +3756,43 @@ func (s *InboundService) ClearNodeOnlineClients(nodeID int) {
}
}
// panelGuid returns this panel's stable self-identifier, used to key the local
// panel's own clients in the per-node online maps (#4983).
func (s *InboundService) panelGuid() string {
guid, _ := (&SettingService{}).GetPanelGuid()
return guid
}
// synthNodeGuid is the stable per-node fallback identity for a directly-attached
// node whose panel hasn't reported a panelGuid yet (old build). Node ids are
// master-local, so this only composes for direct nodes — exactly the pre-#4983
// flat-topology case where an old-build node appears.
func synthNodeGuid(nodeID int) string {
return fmt.Sprintf("node:%d", nodeID)
}
// mergeEmails returns the deduped union of two email slices.
func mergeEmails(a, b []string) []string {
if len(a) == 0 {
return b
}
seen := make(map[string]struct{}, len(a)+len(b))
out := make([]string, 0, len(a)+len(b))
for _, e := range a {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
for _, e := range b {
if _, ok := seen[e]; !ok {
seen[e] = struct{}{}
out = append(out, e)
}
}
return out
}
func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) {
db := database.GetDB()
var rows []xray.ClientTraffic
+35 -22
View File
@@ -30,6 +30,7 @@ type HeartbeatPatch struct {
LatencyMs int
XrayVersion string
PanelVersion string
Guid string
CpuPct float64
MemPct float64
UptimeSecs uint64
@@ -224,9 +225,7 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
Select("inbound_id, email, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", inboundIDs).
Scan(&trafficRows).Error; err == nil {
onlineByNodeSet := s.onlineEmailsByNode()
depletedByNode := make(map[int]int)
onlineByNode := make(map[int]int)
for _, row := range trafficRows {
nodeID, ok := nodeByInbound[row.InboundID]
if !ok {
@@ -237,38 +236,45 @@ func (s *NodeService) GetAll() ([]*model.Node, error) {
if expired || exhausted || !row.Enable {
depletedByNode[nodeID]++
}
// Scope online by the node the inbound lives on: a client online
// on one node must not count as online on another.
if set, ok := onlineByNodeSet[nodeID]; ok {
if _, isOnline := set[row.Email]; isOnline {
onlineByNode[nodeID]++
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
n.InboundCount = len(inboundsByNode[n.Id])
n.DepletedCount = depletedByNode[n.Id]
n.OnlineCount = onlineByNode[n.Id]
// Online is attributed to the node that physically hosts the client
// (by GUID): a client on a sub-node counts under the sub-node, not
// the intermediate node it syncs through (#4983).
n.OnlineCount = len(onlineByGuid[effectiveNodeGuid(n)])
}
}
return nodes, nil
}
func (s *NodeService) onlineEmailsByNode() map[int]map[string]struct{} {
func (s *NodeService) onlineEmailsByGuid() map[string]map[string]struct{} {
svc := InboundService{}
byNode := svc.GetOnlineClientsByNode()
out := make(map[int]map[string]struct{}, len(byNode))
for nodeID, emails := range byNode {
byGuid := svc.GetOnlineClientsByGuid()
out := make(map[string]map[string]struct{}, len(byGuid))
for guid, emails := range byGuid {
set := make(map[string]struct{}, len(emails))
for _, email := range emails {
set[email] = struct{}{}
}
out[nodeID] = set
out[guid] = set
}
return out
}
// effectiveNodeGuid is a node's stable online-attribution key: its reported
// panelGuid, or a master-local synthetic id when the node is an old build that
// hasn't reported one yet (#4983).
func effectiveNodeGuid(n *model.Node) string {
if n.Guid != "" {
return n.Guid
}
return synthNodeGuid(n.Id)
}
func (s *NodeService) GetById(id int) (*model.Node, error) {
db := database.GetDB()
n := &model.Node{}
@@ -469,6 +475,11 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
"uptime_secs": p.UptimeSecs,
"last_error": p.LastError,
}
// Only learn the GUID; never clear a known one if an old-build node (or a
// failed probe) reports none, so the stable identity survives blips.
if p.Guid != "" {
updates["guid"] = p.Guid
}
if err := db.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
@@ -599,6 +610,7 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
Version string `json:"version"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
} `json:"obj"`
}
@@ -617,18 +629,19 @@ func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch,
}
patch.XrayVersion = o.Xray.Version
patch.PanelVersion = o.PanelVersion
patch.Guid = o.PanelGuid
patch.UptimeSecs = o.Uptime
return patch, nil
}
type ProbeResultUI struct {
Status string `json:"status"`
LatencyMs int `json:"latencyMs"`
XrayVersion string `json:"xrayVersion"`
PanelVersion string `json:"panelVersion"`
CpuPct float64 `json:"cpuPct"`
MemPct float64 `json:"memPct"`
UptimeSecs uint64 `json:"uptimeSecs"`
Status string `json:"status" example:"online"`
LatencyMs int `json:"latencyMs" example:"42"`
XrayVersion string `json:"xrayVersion" example:"25.10.31"`
PanelVersion string `json:"panelVersion" example:"v3.x.x"`
CpuPct float64 `json:"cpuPct" example:"12.5"`
MemPct float64 `json:"memPct" example:"45.2"`
UptimeSecs uint64 `json:"uptimeSecs" example:"86400"`
Error string `json:"error"`
}
+71
View File
@@ -0,0 +1,71 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
// #4983: a synced inbound's OriginNodeGuid must point at the panel that
// physically hosts it. A node's own local inbound (empty origin in its
// snapshot) is attributed to the node's own GUID; an inbound the node forwards
// from its own sub-node (non-empty origin) keeps that deeper GUID across the
// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3.
func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
const nodeID = 1
if err := db.Create(&model.Node{
Id: nodeID,
Name: "node2",
Address: "10.0.0.2",
Port: 2053,
ApiToken: "t",
Guid: "node2-guid",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
snap := &runtime.TrafficSnapshot{
Inbounds: []*model.Inbound{
{ // node2's own local inbound — reports no origin
Tag: "in-443-tcp",
Enable: true,
Port: 443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
},
{ // forwarded from node2's sub-node (node3) — carries node3's guid
Tag: "in-8443-tcp",
Enable: true,
Port: 8443,
Protocol: model.VLESS,
Settings: `{"clients":[]}`,
OriginNodeGuid: "node3-guid",
},
},
}
svc := InboundService{}
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
t.Fatalf("setRemoteTrafficLocked: %v", err)
}
origin := func(tag string) string {
var ib model.Inbound
if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil {
t.Fatalf("load inbound %q: %v", tag, err)
}
return ib.OriginNodeGuid
}
if og := origin("in-443-tcp"); og != "node2-guid" {
t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og)
}
if og := origin("in-8443-tcp"); og != "node3-guid" {
t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og)
}
}
+226
View File
@@ -0,0 +1,226 @@
package service
import (
"context"
"sync"
"time"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/web/runtime"
)
// LocalDescendants returns this panel's read-only summaries of the nodes it
// directly manages, so a parent panel can surface them as transitive sub-nodes
// (#4983). Only nodes with a known GUID are included — a stable identity is
// required to attribute them one hop up. Not recursive: each panel reports its
// own direct nodes, and a master walks one level via each direct node's
// endpoint, which covers the Node1 -> Node2 -> Node3 case.
func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) {
selfGuid, _ := (&SettingService{}).GetPanelGuid()
db := database.GetDB()
var nodes []*model.Node
if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil {
return nil, err
}
out := make([]model.NodeSummary, 0, len(nodes))
for _, n := range nodes {
if n.Guid == "" {
continue
}
out = append(out, model.NodeSummary{
Guid: n.Guid,
ParentGuid: selfGuid,
Name: n.Name,
Address: n.Address,
Scheme: n.Scheme,
Port: n.Port,
Status: n.Status,
LastHeartbeat: n.LastHeartbeat,
LatencyMs: n.LatencyMs,
PanelVersion: n.PanelVersion,
XrayVersion: n.XrayVersion,
})
}
return out, nil
}
var (
nodeDescendantsMu sync.RWMutex
nodeDescendantsCache = map[int][]model.NodeSummary{}
)
// RefreshDescendants pulls a direct node's published sub-node summaries and
// caches them keyed by node id. Best-effort: a fetch error keeps the last good
// set (the node may be briefly unreachable). Called from the heartbeat job.
func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) {
if n == nil {
return
}
mgr := runtime.GetManager()
if mgr == nil {
return
}
rt, err := mgr.RemoteFor(n)
if err != nil {
return
}
summaries, err := rt.GetDescendants(ctx)
if err != nil {
return
}
nodeDescendantsMu.Lock()
if len(summaries) == 0 {
delete(nodeDescendantsCache, n.Id)
} else {
nodeDescendantsCache[n.Id] = summaries
}
nodeDescendantsMu.Unlock()
}
// ClearDescendants drops a node's cached sub-node summaries (its probe failed).
func (s *NodeService) ClearDescendants(nodeID int) {
nodeDescendantsMu.Lock()
delete(nodeDescendantsCache, nodeID)
nodeDescendantsMu.Unlock()
}
func cachedDescendants() []model.NodeSummary {
nodeDescendantsMu.RLock()
defer nodeDescendantsMu.RUnlock()
out := make([]model.NodeSummary, 0)
for _, list := range nodeDescendantsCache {
out = append(out, list...)
}
return out
}
// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned
// from them, with per-GUID counts so each node shows only the inbounds/online
// it physically hosts (#4983). Direct nodes carry the master's own GUID as
// ParentGuid; a transitive node carries its parent node's GUID. Transitive
// nodes are read-only projections (Id == 0). Used by the Nodes page and the
// heartbeat broadcast — never for probing/syncing, which stay on GetAll.
func (s *NodeService) GetNodeTree() ([]*model.Node, error) {
nodes, err := s.GetAll()
if err != nil {
return nodes, err
}
selfGuid, _ := (&SettingService{}).GetPanelGuid()
directGuids := make(map[string]struct{}, len(nodes))
for _, n := range nodes {
n.ParentGuid = selfGuid
if n.Guid != "" {
directGuids[n.Guid] = struct{}{}
}
}
seen := make(map[string]struct{})
var transitive []*model.Node
for _, sum := range cachedDescendants() {
if sum.Guid == "" {
continue
}
if _, ok := directGuids[sum.Guid]; ok {
continue // already shown as a direct node
}
if _, ok := seen[sum.Guid]; ok {
continue
}
seen[sum.Guid] = struct{}{}
transitive = append(transitive, &model.Node{
Guid: sum.Guid,
ParentGuid: sum.ParentGuid,
Name: sum.Name,
Address: sum.Address,
Scheme: sum.Scheme,
Port: sum.Port,
Status: sum.Status,
LastHeartbeat: sum.LastHeartbeat,
LatencyMs: sum.LatencyMs,
PanelVersion: sum.PanelVersion,
XrayVersion: sum.XrayVersion,
Transitive: true,
})
}
if len(transitive) == 0 {
return nodes, nil
}
all := make([]*model.Node, 0, len(nodes)+len(transitive))
all = append(all, nodes...)
all = append(all, transitive...)
s.recountByGuid(all, selfGuid)
return all, nil
}
// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node
// in the tree, keyed by the GUID that physically hosts each inbound, so a direct
// node shows only its own inbounds and each transitive node shows its own
// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so
// this only changes behaviour once a transitive node exists.
func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) {
db := database.GetDB()
type ibRow struct {
Id int
NodeID *int `gorm:"column:node_id"`
OriginNodeGuid string `gorm:"column:origin_node_guid"`
}
var ibRows []ibRow
if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil {
return
}
effByInbound := make(map[int]string, len(ibRows))
inboundCountByGuid := make(map[string]int)
ids := make([]int, 0, len(ibRows))
for _, r := range ibRows {
guid := r.OriginNodeGuid
if guid == "" {
if r.NodeID != nil {
guid = synthNodeGuid(*r.NodeID)
} else {
guid = selfGuid
}
}
effByInbound[r.Id] = guid
inboundCountByGuid[guid]++
ids = append(ids, r.Id)
}
now := time.Now().UnixMilli()
depletedByGuid := make(map[string]int)
if len(ids) > 0 {
type tRow struct {
InboundID int `gorm:"column:inbound_id"`
Enable bool
Total int64
Up int64
Down int64
ExpiryTime int64 `gorm:"column:expiry_time"`
}
var tRows []tRow
if err := db.Table("client_traffics").
Select("inbound_id, enable, total, up, down, expiry_time").
Where("inbound_id IN ?", ids).Scan(&tRows).Error; err == nil {
for _, row := range tRows {
guid, ok := effByInbound[row.InboundID]
if !ok {
continue
}
expired := row.ExpiryTime > 0 && row.ExpiryTime <= now
exhausted := row.Total > 0 && row.Up+row.Down >= row.Total
if expired || exhausted || !row.Enable {
depletedByGuid[guid]++
}
}
}
}
onlineByGuid := s.onlineEmailsByGuid()
for _, n := range nodes {
guid := effectiveNodeGuid(n)
n.InboundCount = inboundCountByGuid[guid]
n.OnlineCount = len(onlineByGuid[guid])
n.DepletedCount = depletedByGuid[guid]
}
}
+81
View File
@@ -0,0 +1,81 @@
package service
import (
"testing"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
)
// #4983: a transitive sub-node learned from a direct node must surface as its
// own read-only entry nested under its parent, and per-GUID counts must split a
// direct node's own inbounds from its sub-nodes'.
func TestGetNodeTree_SurfacesTransitiveNodeNestedUnderParent(t *testing.T) {
setupConflictDB(t)
db := database.GetDB()
svc := NodeService{}
selfGuid, _ := (&SettingService{}).GetPanelGuid()
if err := db.Create(&model.Node{
Id: 1, Name: "Node2", Address: "10.0.0.2", Port: 2053,
ApiToken: "t", Guid: "node2-guid", Status: "online",
}).Error; err != nil {
t.Fatalf("create node: %v", err)
}
// Node2's own inbound and a transitive inbound physically on Node3
// (managed through Node2, so node_id = Node2 but origin = Node3).
nid := 1
if err := db.Create(&model.Inbound{Tag: "n1-own", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node2-guid"}).Error; err != nil {
t.Fatalf("create own inbound: %v", err)
}
if err := db.Create(&model.Inbound{Tag: "n1-via", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node3-guid"}).Error; err != nil {
t.Fatalf("create transitive inbound: %v", err)
}
// The heartbeat learned that Node2 manages Node3.
nodeDescendantsMu.Lock()
nodeDescendantsCache[1] = []model.NodeSummary{{
Guid: "node3-guid", ParentGuid: "node2-guid", Name: "Node3", Address: "10.0.0.3", Status: "online",
}}
nodeDescendantsMu.Unlock()
t.Cleanup(func() {
nodeDescendantsMu.Lock()
nodeDescendantsCache = map[int][]model.NodeSummary{}
nodeDescendantsMu.Unlock()
})
tree, err := svc.GetNodeTree()
if err != nil {
t.Fatalf("GetNodeTree: %v", err)
}
var node2, node3 *model.Node
for _, n := range tree {
switch n.Guid {
case "node2-guid":
node2 = n
case "node3-guid":
node3 = n
}
}
if node2 == nil || node3 == nil {
t.Fatalf("expected Node2 + transitive Node3, got %d nodes", len(tree))
}
if node2.ParentGuid != selfGuid {
t.Errorf("Node2 parent = %q, want this panel's GUID %q", node2.ParentGuid, selfGuid)
}
if !node3.Transitive || node3.ParentGuid != "node2-guid" {
t.Errorf("Node3 should be transitive under node2-guid, got transitive=%v parent=%q", node3.Transitive, node3.ParentGuid)
}
if node3.Id != 0 {
t.Errorf("transitive node must be a read-only projection (Id 0), got Id=%d", node3.Id)
}
if node2.InboundCount != 1 {
t.Errorf("Node2 should host only its own inbound, got InboundCount=%d", node2.InboundCount)
}
if node3.InboundCount != 1 {
t.Errorf("transitive Node3 should host its 1 inbound, got %d", node3.InboundCount)
}
}
+4
View File
@@ -81,6 +81,7 @@ type Status struct {
Version string `json:"version"`
} `json:"xray"`
PanelVersion string `json:"panelVersion"`
PanelGuid string `json:"panelGuid"`
Uptime uint64 `json:"uptime"`
Loads []float64 `json:"loads"`
TcpCount int `json:"tcpCount"`
@@ -532,6 +533,9 @@ func (s *ServerService) GetStatus(lastStatus *Status) *Status {
}
status.Xray.Version = s.xrayService.GetXrayVersion()
status.PanelVersion = config.GetVersion()
if guid, err := s.settingService.GetPanelGuid(); err == nil {
status.PanelGuid = guid
}
// Application stats
var rtm runtime.MemStats
+20
View File
@@ -12,6 +12,7 @@ import (
"strings"
"time"
"github.com/google/uuid"
"github.com/mhsanaei/3x-ui/v3/database"
"github.com/mhsanaei/3x-ui/v3/database/model"
"github.com/mhsanaei/3x-ui/v3/logger"
@@ -34,6 +35,7 @@ var defaultValueMap = map[string]string{
"webCertFile": "",
"webKeyFile": "",
"secret": random.Seq(32),
"panelGuid": uuid.NewString(),
"apiToken": "",
"webBasePath": "/",
"sessionMaxAge": "360",
@@ -508,6 +510,24 @@ func (s *SettingService) GetSecret() ([]byte, error) {
return []byte(secret), err
}
// GetPanelGuid returns this panel's stable self-identifier, persisting a
// freshly generated UUID on first read. It is the globally stable node
// identity used to attribute online clients and inbounds to the physical
// node that hosts them across a chain of nodes (#4983), where per-panel
// autoincrement node ids are meaningless one hop away.
func (s *SettingService) GetPanelGuid() (string, error) {
guid, err := s.getString("panelGuid")
if err != nil {
return "", err
}
if guid == defaultValueMap["panelGuid"] {
if saveErr := s.saveSetting("panelGuid", guid); saveErr != nil {
logger.Warning("save panelGuid failed:", saveErr)
}
}
return guid, nil
}
func (s *SettingService) SetBasePath(basePath string) error {
if !strings.HasPrefix(basePath, "/") {
basePath = "/" + basePath
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "اتركها 0 لاستخدام الإعداد الافتراضي لنظام التشغيل. القيم غير الصفرية تحدّ من نافذة استقبال TCP المُعلَنة؛ وقيم مثل 600 (من مثال وثائق Xray) قد تنهار معها سرعة النقل على الوصلات عالية زمن الاستجابة.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "أدنى إصدار للعميل",
"maxClientVer": "أقصى إصدار للعميل",
"shortIds": "Short IDs",
"realityTargetHint": "مطلوب. يجب أن يتضمّن منفذًا (مثل example.com:443). بدون منفذ يرفض Xray-core البدء.",
"realityTargetRequired": "هدف REALITY مطلوب",
"realityTargetNeedsPort": "يجب أن يتضمّن هدف REALITY منفذًا (مثل example.com:443)",
"realityTargetInvalidPort": "هدف REALITY يحتوي على منفذ غير صالح",
"spiderX": "SpiderX",
"getNewCert": "احصل على شهادة جديدة",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "فشل الاتصال",
"never": "أبدًا",
"justNow": "دلوقتي",
"subNode": "نود فرعي",
"subNodeTip": "للقراءة فقط: نود تابع يتم الوصول إليه عبر {parent}. تتم إدارته من لوحة {parent} نفسها.",
"deleteConfirmTitle": "تحذف النود \"{name}\"؟",
"deleteConfirmContent": "ده هيوقّف مراقبة النود. البانل البعيد نفسه مش هيتأثر.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "ملف إعدادات Xray النهائي هيتولد بناءً على القالب ده.",
"FreedomStrategy": "استراتيجية بروتوكول الحرية",
"FreedomStrategyDesc": "اختار استراتيجية المخرجات للشبكة في بروتوكول الحرية.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "اتصال ثنائي المكدّس للمخرج المباشر (freedom) — مفيد على خوادم الخروج التي تدعم IPv4 وIPv6 معًا.",
"FreedomHappyEyeballsTryDelayDesc": "عدد المللي ثانية قبل تجربة عائلة العناوين البديلة. 150–250 مللي ثانية نقطة بداية جيدة.",
"RoutingStrategy": "استراتيجية التوجيه العامة",
"RoutingStrategyDesc": "حدد استراتيجية التوجيه الإجمالية لحل كل الطلبات.",
"outboundTestUrl": "رابط اختبار المخرج",
+10
View File
@@ -560,6 +560,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Leave 0 to use the OS default. Non-zero values cap the advertised TCP receive window; values like 600 (from the Xray docs example) can collapse throughput on high-latency links.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -598,6 +599,10 @@
"minClientVer": "Min Client Ver",
"maxClientVer": "Max Client Ver",
"shortIds": "Short IDs",
"realityTargetHint": "Required. Must include a port (e.g. example.com:443). Without a port Xray-core refuses to start.",
"realityTargetRequired": "REALITY target is required",
"realityTargetNeedsPort": "REALITY target must include a port (e.g. example.com:443)",
"realityTargetInvalidPort": "REALITY target has an invalid port",
"spiderX": "SpiderX",
"getNewCert": "Get New Cert",
"mldsa65Seed": "mldsa65 Seed",
@@ -882,6 +887,8 @@
"connectionFailed": "Connection failed",
"never": "never",
"justNow": "just now",
"subNode": "Sub-node",
"subNodeTip": "Read-only: a downstream node reached through {parent}. Manage it from {parent}'s own panel.",
"deleteConfirmTitle": "Delete node \"{name}\"?",
"deleteConfirmContent": "This stops monitoring the node. The remote panel itself is unaffected.",
"statusValues": {
@@ -1178,6 +1185,9 @@
"TemplateDesc": "The final Xray config file will be generated based on this template.",
"FreedomStrategy": "Freedom Protocol Strategy",
"FreedomStrategyDesc": "Set the output strategy for the network in the Freedom Protocol.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Dual-stack dialing for the direct (freedom) outbound — useful on exit servers with both IPv4 and IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Milliseconds before trying the alternate address family. 150–250 ms is a good starting point.",
"RoutingStrategy": "Overall Routing Strategy",
"RoutingStrategyDesc": "Set the overall traffic routing strategy for resolving all requests.",
"outboundTestUrl": "Outbound Test URL",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Deja 0 para usar el valor predeterminado del sistema. Los valores distintos de cero limitan la ventana de recepción TCP anunciada; valores como 600 (del ejemplo de la documentación de Xray) pueden hundir el rendimiento en enlaces de alta latencia.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "Mín. versión cliente",
"maxClientVer": "Máx. versión cliente",
"shortIds": "Short IDs",
"realityTargetHint": "Obligatorio. Debe incluir un puerto (p. ej. example.com:443). Sin puerto, Xray-core no arranca.",
"realityTargetRequired": "El destino REALITY es obligatorio",
"realityTargetNeedsPort": "El destino REALITY debe incluir un puerto (p. ej. example.com:443)",
"realityTargetInvalidPort": "El destino REALITY tiene un puerto no válido",
"spiderX": "SpiderX",
"getNewCert": "Obtener nuevo cert",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Conexión fallida",
"never": "nunca",
"justNow": "ahora mismo",
"subNode": "Subnodo",
"subNodeTip": "Solo lectura: un nodo descendente al que se llega a través de {parent}. Gestiónalo desde el propio panel de {parent}.",
"deleteConfirmTitle": "¿Eliminar el nodo \"{name}\"?",
"deleteConfirmContent": "Esto detiene la monitorización del nodo. El panel remoto en sí no se ve afectado.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "Genera el archivo de configuración final de Xray basado en esta plantilla.",
"FreedomStrategy": "Configurar Estrategia para el Protocolo Freedom",
"FreedomStrategyDesc": "Establece la estrategia de salida de la red en el Protocolo Freedom.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Marcado de doble pila para la salida directa (freedom): útil en servidores de salida con IPv4 e IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Milisegundos antes de probar la otra familia de direcciones. 150–250 ms es un buen punto de partida.",
"RoutingStrategy": "Configurar Estrategia de Enrutamiento de Dominios",
"RoutingStrategyDesc": "Establece la estrategia general de enrutamiento para la resolución de DNS.",
"outboundTestUrl": "URL de prueba de outbound",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "برای استفاده از پیش‌فرض سیستم‌عامل، مقدار را 0 بگذارید. مقادیر غیرصفر پنجرهٔ دریافت TCP اعلام‌شده را محدود می‌کنند؛ مقادیری مانند 600 (از مثال مستندات Xray) می‌توانند نرخ عبور را روی لینک‌های با تأخیر بالا به‌شدت کاهش دهند.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "حداقل نسخه کلاینت",
"maxClientVer": "حداکثر نسخه کلاینت",
"shortIds": "Short IDها",
"realityTargetHint": "الزامی است. باید شامل پورت باشد (مثلاً example.com:443). بدون پورت، Xray-core اجرا نمی‌شود.",
"realityTargetRequired": "هدف REALITY الزامی است",
"realityTargetNeedsPort": "هدف REALITY باید شامل پورت باشد (مثلاً example.com:443)",
"realityTargetInvalidPort": "پورت هدف REALITY نامعتبر است",
"spiderX": "SpiderX",
"getNewCert": "دریافت گواهی جدید",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "اتصال ناموفق",
"never": "هرگز",
"justNow": "هم‌اکنون",
"subNode": "نود فرعی",
"subNodeTip": "فقط‌خواندنی: یک نود پایین‌دستی که از طریق {parent} در دسترس است. آن را از پنل خودِ {parent} مدیریت کنید.",
"deleteConfirmTitle": "نود «{name}» حذف شود؟",
"deleteConfirmContent": "نظارت روی این نود متوقف می‌شود. خود پنل ریموت تغییری نمی‌کند.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "فایل پیکربندی نهایی ایکس‌ری بر اساس این الگو ایجاد می‌شود",
"FreedomStrategy": "Freedom استراتژی پروتکل",
"FreedomStrategyDesc": "تعیین می‌کند Freedom استراتژی خروجی شبکه را برای پروتکل",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "اتصال دو‌پشته‌ای برای خروجی مستقیم (freedom) — برای سرورهای خروج دارای هر دو IPv4 و IPv6 مفید است.",
"FreedomHappyEyeballsTryDelayDesc": "تعداد میلی‌ثانیه پیش از امتحان خانوادهٔ آدرس دیگر. مقدار 150 تا 250 میلی‌ثانیه نقطهٔ شروع خوبی است.",
"RoutingStrategy": "استراتژی کلی مسیریابی",
"RoutingStrategyDesc": "استراتژی کلی مسیریابی برای حل تمام درخواست‌ها را تعیین می‌کند",
"outboundTestUrl": "آدرس تست خروجی",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Biarkan 0 untuk memakai bawaan OS. Nilai bukan nol membatasi jendela penerimaan TCP yang diiklankan; nilai seperti 600 (dari contoh dokumentasi Xray) bisa menjatuhkan throughput pada tautan berlatensi tinggi.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "Min. versi klien",
"maxClientVer": "Maks. versi klien",
"shortIds": "Short IDs",
"realityTargetHint": "Wajib. Harus menyertakan port (mis. example.com:443). Tanpa port, Xray-core menolak untuk mulai.",
"realityTargetRequired": "Target REALITY wajib diisi",
"realityTargetNeedsPort": "Target REALITY harus menyertakan port (mis. example.com:443)",
"realityTargetInvalidPort": "Target REALITY memiliki port yang tidak valid",
"spiderX": "SpiderX",
"getNewCert": "Dapatkan sertifikat baru",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Koneksi gagal",
"never": "tidak pernah",
"justNow": "baru saja",
"subNode": "Sub-node",
"subNodeTip": "Hanya-baca: node turunan yang dijangkau melalui {parent}. Kelola dari panel {parent} sendiri.",
"deleteConfirmTitle": "Hapus node \"{name}\"?",
"deleteConfirmContent": "Ini menghentikan pemantauan node. Panel jarak jauh itu sendiri tidak terpengaruh.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "File konfigurasi Xray akhir akan dibuat berdasarkan template ini.",
"FreedomStrategy": "Strategi Protokol Freedom",
"FreedomStrategyDesc": "Atur strategi output untuk jaringan dalam Protokol Freedom.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Panggilan dual-stack untuk outbound langsung (freedom) — berguna pada server keluar dengan IPv4 dan IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Milidetik sebelum mencoba keluarga alamat lainnya. 150–250 ms adalah titik awal yang baik.",
"RoutingStrategy": "Strategi Pengalihan Keseluruhan",
"RoutingStrategyDesc": "Atur strategi pengalihan lalu lintas keseluruhan untuk menyelesaikan semua permintaan.",
"outboundTestUrl": "URL tes outbound",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "OS のデフォルトを使うには 0 のままにします。0 以外の値は通知される TCP 受信ウィンドウを制限し、600(Xray ドキュメントの例)のような値は高遅延リンクでスループットを大きく低下させることがあります。",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "最小クライアントバージョン",
"maxClientVer": "最大クライアントバージョン",
"shortIds": "Short IDs",
"realityTargetHint": "必須です。ポートを含める必要があります(例: example.com:443)。ポートがないと Xray-core は起動しません。",
"realityTargetRequired": "REALITY ターゲットは必須です",
"realityTargetNeedsPort": "REALITY ターゲットにはポートを含める必要があります(例: example.com:443)",
"realityTargetInvalidPort": "REALITY ターゲットのポートが無効です",
"spiderX": "SpiderX",
"getNewCert": "新しい証明書を取得",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "接続に失敗しました",
"never": "なし",
"justNow": "たった今",
"subNode": "サブノード",
"subNodeTip": "読み取り専用: {parent} を経由して到達する下位ノードです。{parent} 自身のパネルから管理してください。",
"deleteConfirmTitle": "ノード「{name}」を削除しますか?",
"deleteConfirmContent": "ノードの監視を停止します。リモートパネル自体には影響しません。",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "最終的なXray設定ファイルはこのテンプレートに基づいて生成されます",
"FreedomStrategy": "Freedom プロトコル戦略",
"FreedomStrategyDesc": "Freedomプロトコル内のネットワークの出力戦略を設定する",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "直接(freedom)アウトバウンドのデュアルスタック接続。IPv4 と IPv6 の両方を持つ出口サーバーで便利です。",
"FreedomHappyEyeballsTryDelayDesc": "別のアドレスファミリを試すまでのミリ秒。150〜250 ms が目安です。",
"RoutingStrategy": "ルーティングドメイン戦略設定",
"RoutingStrategyDesc": "DNS解決の全体的なルーティング戦略を設定する",
"outboundTestUrl": "アウトバウンドテスト URL",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Deixe 0 para usar o padrão do sistema. Valores diferentes de zero limitam a janela de recepção TCP anunciada; valores como 600 (do exemplo da documentação do Xray) podem derrubar a taxa de transferência em enlaces de alta latência.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "Mín. versão cliente",
"maxClientVer": "Máx. versão cliente",
"shortIds": "Short IDs",
"realityTargetHint": "Obrigatório. Deve incluir uma porta (ex.: example.com:443). Sem porta, o Xray-core não inicia.",
"realityTargetRequired": "O alvo REALITY é obrigatório",
"realityTargetNeedsPort": "O alvo REALITY deve incluir uma porta (ex.: example.com:443)",
"realityTargetInvalidPort": "O alvo REALITY tem uma porta inválida",
"spiderX": "SpiderX",
"getNewCert": "Obter novo certificado",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Falha na conexão",
"never": "nunca",
"justNow": "agora mesmo",
"subNode": "Subnó",
"subNodeTip": "Somente leitura: um nó descendente acessado através de {parent}. Gerencie-o pelo próprio painel de {parent}.",
"deleteConfirmTitle": "Excluir o nó \"{name}\"?",
"deleteConfirmContent": "Isso interrompe o monitoramento do nó. O painel remoto em si não é afetado.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "O arquivo final de configuração do Xray será gerado com base neste modelo.",
"FreedomStrategy": "Estratégia do Protocolo Freedom",
"FreedomStrategyDesc": "Definir a estratégia de saída para a rede no Protocolo Freedom.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Discagem dual-stack para a saída direta (freedom) — útil em servidores de saída com IPv4 e IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Milissegundos antes de tentar a outra família de endereços. 150–250 ms é um bom ponto de partida.",
"RoutingStrategy": "Estratégia Geral de Roteamento",
"RoutingStrategyDesc": "Definir a estratégia geral de roteamento de tráfego para resolver todas as solicitações.",
"outboundTestUrl": "URL de teste de outbound",
+11 -1
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Оставьте 0, чтобы использовать значение по умолчанию ОС. Ненулевые значения ограничивают объявляемое окно приёма TCP; значения вроде 600 (из примера в документации Xray) могут обрушить пропускную способность на каналах с высокой задержкой.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -585,7 +586,7 @@
"buildChain": "Build Chain",
"echKey": "ECH key",
"echConfig": "ECH config",
"pinnedPeerCertSha256": "Закреплённый SHA-256 сертификата пира",
"pinnedPeerCertSha256": "SHA-256 сертификата пира",
"pinnedPeerCertSha256Tip": "SHA-256-хеши сертификата пира в виде шестнадцатеричной строки (напр. e8e2d3…), через запятую. Только для панели — не записывается в конфиг xray сервера, но включается в ссылки-приглашения, чтобы клиенты могли закрепить сертификат.",
"pinnedPeerCertSha256Placeholder": "шестнадцатеричный хеш(и), через запятую",
"generateRandomPin": "Сгенерировать случайный хеш",
@@ -597,6 +598,10 @@
"minClientVer": "Мин. версия клиента",
"maxClientVer": "Макс. версия клиента",
"shortIds": "Short IDs",
"realityTargetHint": "Обязательно. Должно содержать порт (например, example.com:443). Без порта Xray-core не запускается.",
"realityTargetRequired": "Цель REALITY обязательна",
"realityTargetNeedsPort": "Цель REALITY должна содержать порт (например, example.com:443)",
"realityTargetInvalidPort": "У цели REALITY указан недопустимый порт",
"spiderX": "SpiderX",
"getNewCert": "Получить новый сертификат",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Не удалось подключиться",
"never": "никогда",
"justNow": "только что",
"subNode": "Подузел",
"subNodeTip": "Только для чтения: подчинённый узел, доступный через {parent}. Управляйте им из собственной панели {parent}.",
"deleteConfirmTitle": "Удалить узел \"{name}\"?",
"deleteConfirmContent": "Это остановит мониторинг узла. Сама удалённая панель не будет затронута.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "На основе шаблона создаётся конфигурационный файл Xray.",
"FreedomStrategy": "Настройка стратегии протокола Freedom",
"FreedomStrategyDesc": "Установка стратегии вывода сети в протоколе Freedom",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Двухстековый набор для прямого (freedom) исходящего — полезно на выходных серверах с IPv4 и IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Миллисекунды перед попыткой другого семейства адресов. 150–250 мс — хорошая отправная точка.",
"RoutingStrategy": "Настройка маршрутизации доменов",
"RoutingStrategyDesc": "Установка общей стратегии маршрутизации разрешения DNS",
"outboundTestUrl": "URL для теста исходящего",
+222 -212
View File
@@ -36,19 +36,19 @@
"unlimited": "Sınırsız",
"none": "Yok",
"qrCode": "QR Kod",
"info": "Daha Fazla Bilgi",
"info": "Bilgi",
"edit": "Düzenle",
"delete": "Sil",
"reset": "Sıfırla",
"noData": "Veri yok.",
"copySuccess": "Başarıyla Kopyalandı",
"sure": "Emin misiniz",
"sure": "Emin misiniz?",
"encryption": "Şifreleme",
"useIPv4ForHost": "Ana bilgisayar için IPv4 kullan",
"transmission": "İletim",
"host": "Host",
"path": "Yol",
"camouflage": "Karartma",
"camouflage": "Maskeleme",
"status": "Durum",
"enabled": "Etkin",
"disabled": "Devre Dışı",
@@ -62,10 +62,10 @@
"fail": "Başarısız",
"comment": "Yorum",
"success": "Başarılı",
"lastOnline": "Son çevrimiçi",
"lastOnline": "Son Çevrimiçi",
"getVersion": "Sürümü Al",
"install": "Yükle",
"clients": "Müşteriler",
"clients": "Kullanıcılar",
"usage": "Kullanım",
"twoFactorCode": "Kod",
"remained": "Kalan",
@@ -102,8 +102,8 @@
"dark": "Koyu",
"ultraDark": "Ultra Koyu",
"dashboard": "Genel Bakış",
"inbounds": "Gelenler",
"clients": "İstemciler",
"inbounds": "Bağlantı Noktaları",
"clients": "Kullanıcılar",
"groups": "Gruplar",
"nodes": "Düğümler",
"settings": "Panel Ayarları",
@@ -160,7 +160,7 @@
"historyTitleNetwork": "Ağ Bant Genişliği",
"historyTitlePackets": "Ağ Paketleri",
"historyTitleDisk": "Disk G/Ç",
"historyTitleOnline": "Çevrimiçi İstemciler",
"historyTitleOnline": "Çevrimiçi Kullanıcılar",
"historyTitleLoad": "Sistem Yük Ortalaması (1d / 5d / 15d)",
"historyTitleConnections": "Etkin Bağlantılar (TCP / UDP)",
"historyTitleDiskUsage": "Disk Alanı Kullanımı",
@@ -283,10 +283,10 @@
"migrationDownloadPgDesc": "PostgreSQL verilerinizden oluşturulan ve bu paneli SQLite üzerinde çalıştırmaya hazır bir .db SQLite veritabanı indirmek için tıklayın."
},
"inbounds": {
"title": "Gelenler",
"title": "Bağlantı Noktaları",
"totalDownUp": "Toplam Gönderilen/Alınan",
"totalUsage": "Toplam Kullanım",
"inboundCount": "Toplam Gelen",
"inboundCount": "Toplam Bağlantı Noktası",
"operate": "Menü",
"enable": "Etkin",
"remark": "Açıklama",
@@ -316,61 +316,61 @@
"portMap": "Port eşlemesi",
"traffic": "Trafik",
"details": "Detaylar",
"transportConfig": "Taşıma",
"transportConfig": "Aktarım",
"expireDate": "Süre",
"createdAt": "Oluşturuldu",
"updatedAt": "Güncellendi",
"resetTraffic": "Trafiği sıfırla",
"addInbound": "Gelen Ekle",
"addInbound": "Bağlantı Noktası Ekle",
"generalActions": "Genel Eylemler",
"modifyInbound": "Geleni Düzenle",
"deleteInbound": "Geleni Sil",
"deleteInboundContent": "Geleni silmek istediğinizden emin misiniz?",
"modifyInbound": "Bağlantı Noktasını Düzenle",
"deleteInbound": "Bağlantı Noktasını Sil",
"deleteInboundContent": "Bağlantı noktasını silmek istediğinizden emin misiniz?",
"deleteConfirmTitle": "\"{remark}\" inbound silinsin mi?",
"deleteConfirmContent": "Bu işlem inbound'u ve tüm istemcilerini siler. Geri alınamaz.",
"deleteConfirmContent": "Bu işlem inbound'u ve tüm kullanıcılarıni siler. Geri alınamaz.",
"resetConfirmTitle": "\"{remark}\" trafiği sıfırlansın mı?",
"resetConfirmContent": "Bu inbound için gönderme/alma sayaçlarını 0'a sıfırlar.",
"selectedCount": "{count} seçildi",
"selectAll": "Tümünü seç",
"bulkDeleteConfirmTitle": "{count} inbound silinsin mi?",
"bulkDeleteConfirmContent": "Bu işlem seçili inbound'ları ve tüm istemcilerini siler. Geri alınamaz.",
"bulkDeleteConfirmContent": "Bu işlem seçili inbound'ları ve tüm kullanıcılarıni siler. Geri alınamaz.",
"cloneConfirmTitle": "\"{remark}\" inbound klonlansın mı?",
"cloneConfirmContent": "Yeni bir port ve boş istemci listesiyle bir kopya oluşturur.",
"delAllClients": "Tüm istemcileri sil",
"delAllClientsConfirmTitle": "\"{remark}\" içindeki {count} istemcinin tamamı silinsin mi?",
"delAllClientsConfirmContent": "Bu inbound'a ait tüm istemcileri ve trafik kayıtlarını siler. Inbound'un kendisi korunur. Bu işlem geri alınamaz.",
"attachClients": "İstemcileri şuna bağla…",
"addClientsToGroup": "İstemcileri gruba ekle…",
"attachClientsTitle": "«{remark}» gelenindeki istemcileri bağla",
"attachClientsDesc": "Aynı {count} istemciyi (aynı UUID/parola ve paylaşılan trafik) seçilen gelenlere bağlar. Bu gelende de kalırlar.",
"attachClientsTargets": "Hedef gelenler",
"attachClientsNoTargets": "Bağlanacak uyumlu başka gelen yok.",
"cloneConfirmContent": "Yeni bir port ve boş kullanıcı listesiyle bir kopya oluşturur.",
"delAllClients": "Tüm kullanıcıları sil",
"delAllClientsConfirmTitle": "\"{remark}\" içindeki {count} kullanıcının tamamı silinsin mi?",
"delAllClientsConfirmContent": "Bu inbound'a ait tüm kullanıcıları ve trafik kayıtlarını siler. Inbound'un kendisi korunur. Bu işlem geri alınamaz.",
"attachClients": "Kullanıcıları şuna bağla…",
"addClientsToGroup": "Kullanıcıları gruba ekle…",
"attachClientsTitle": "«{remark}» bağlantı noktasındaki kullanıcıları bağla",
"attachClientsDesc": "Aynı {count} kullanıcıyı (aynı UUID/parola ve paylaşılan trafik) seçilen bağlantı noktalarıe bağlar. Bu bağlantı noktasında de kalırlar.",
"attachClientsTargets": "Hedef bağlantı noktaları",
"attachClientsNoTargets": "Bağlanacak uyumlu başka bağlantı noktası yok.",
"attachClientsResult": "Bağlandı {attached}, atlandı {skipped}.",
"attachClientsResultMixed": "Bağlandı {attached}, atlandı {skipped}, hata {errors}.",
"attachClientsSelectLabel": "Bağlanacak istemciler",
"attachClientsSelectLabel": "Bağlanacak kullanıcılar",
"attachClientsSearchPlaceholder": "Email veya yorum ara",
"attachClientsStatusDisabled": "Devre dışı",
"attachClientsSelectedCount": "{total} içinden {selected} seçildi",
"attachExistingClients": "Mevcut istemcileri bağla…",
"attachExistingTitle": "«{remark}» gelenine mevcut istemcileri bağla",
"attachExistingDesc": "Mevcut istemcileri ({count} uygun) bu gelene bağlar — aynı UUID/parola ve paylaşılan trafik. Zaten bu gelende olan istemciler atlanır.",
"attachExistingNoClients": "Henüz istemci yok. Önce istemci oluşturun, ardından buraya bağlayın.",
"attachExistingClients": "Mevcut kullanıcıları bağla…",
"attachExistingTitle": "«{remark}» bağlantı noktasına mevcut kullanıcıları bağla",
"attachExistingDesc": "Mevcut kullanıcıları ({count} uygun) bu bağlantı noktasına bağlar — aynı UUID/parola ve paylaşılan trafik. Zaten bu bağlantı noktasında olan kullanıcılar atlanır.",
"attachExistingNoClients": "Henüz kullanıcı yok. Önce kullanıcı oluşturun, ardından buraya bağlayın.",
"attachExistingStatusAttached": "Zaten bağlı",
"detachClients": "İstemcileri çöz",
"detachClientsTitle": "«{remark}» gelenindeki istemcileri çöz",
"detachClientsDesc": "Seçilen istemcileri yalnızca bu gelenden kaldırır. İstemci kayıtları korunur (tamamen kaldırmak için Delete kullanın). Kaynakta toplam {count} istemci var.",
"detachClients": "Kullanıcıları çöz",
"detachClientsTitle": "«{remark}» bağlantı noktasındaki kullanıcıları çöz",
"detachClientsDesc": "Seçilen kullanıcıları yalnızca bu bağlantı noktasından kaldırır. Kullanıcı kayıtları korunur (tamamen kaldırmak için Delete kullanın). Kaynakta toplam {count} kullanıcı var.",
"detachClientsResult": "Çözüldü {detached}, atlandı {skipped}.",
"detachClientsResultMixed": "Çözüldü {detached}, atlandı {skipped}, hata {errors}.",
"detachClientsSelectLabel": "Çözülecek istemciler",
"detachClientsSelectLabel": "Çözülecek kullanıcılar",
"exportLinksTitle": "Inbound bağlantılarını dışa aktar",
"exportSubsTitle": "Abonelik bağlantılarını dışa aktar",
"exportAllLinksTitle": "Tüm inbound bağlantılarını dışa aktar",
"exportAllSubsTitle": "Tüm abonelik bağlantılarını dışa aktar",
"exportAllLinksFileName": "Tum-Gelenler",
"exportAllSubsFileName": "Tum-Gelenler-Subs",
"inboundJsonTitle": "Gelen JSON",
"deleteClient": "Müşteriyi Sil",
"deleteClientContent": "Müşteriyi silmek istediğinizden emin misiniz?",
"exportAllLinksFileName": "Tum-Bağlantı Noktaları",
"exportAllSubsFileName": "Tum-Bağlantı Noktaları-Subs",
"inboundJsonTitle": "Bağlantı Noktası JSON",
"deleteClient": "Kullanıcıyı Sil",
"deleteClientContent": "Kullanıcıyı silmek istediğinizden emin misiniz?",
"resetTrafficContent": "Trafiği sıfırlamak istediğinizden emin misiniz?",
"copyLink": "URL'yi Kopyala",
"address": "Adres",
@@ -387,28 +387,28 @@
"publicKey": "Genel Anahtar",
"privatekey": "Özel Anahtar",
"clickOnQRcode": "Kopyalamak için QR Kodu Tıklayın",
"client": "Müşteri",
"client": "Kullanıcı",
"export": "Tüm URL'leri Dışa Aktar",
"clone": "Klonla",
"cloneInbound": "Klonla",
"cloneInboundContent": "Bu gelenin tüm ayarları, Port, Dinleme IP ve Müşteriler hariç, klona uygulanacaktır.",
"cloneInboundContent": "Bu bağlantı noktasının tüm ayarları, Port, Dinleme IP ve Kullanıcılar hariç, klona uygulanacaktır.",
"cloneInboundOk": "Klonla",
"resetAllTraffic": "Tüm Gelen Trafiğini Sıfırla",
"resetAllTrafficTitle": "Tüm Gelen Trafiğini Sıfırla",
"resetAllTrafficContent": "Tüm gelenlerin trafiğini sıfırlamak istediğinizden emin misiniz?",
"resetInboundClientTraffics": "Müşteri Trafiklerini Sıfırla",
"resetInboundClientTrafficTitle": "Müşteri Trafiklerini Sıfırla",
"resetInboundClientTrafficContent": "Bu gelenin müşterilerinin trafiğini sıfırlamak istediğinizden emin misiniz?",
"resetAllClientTraffics": "Tüm Müşteri Trafiklerini Sıfırla",
"resetAllClientTrafficTitle": "Tüm Müşteri Trafiklerini Sıfırla",
"resetAllClientTrafficContent": "Tüm müşterilerin trafiğini sıfırlamak istediğinizden emin misiniz?",
"delDepletedClients": "Bitmiş Müşterileri Sil",
"delDepletedClientsTitle": "Bitmiş Müşterileri Sil",
"delDepletedClientsContent": "Tüm bitmiş müşterileri silmek istediğinizden emin misiniz?",
"resetAllTrafficContent": "Tüm bağlantı noktalarıin trafiğini sıfırlamak istediğinizden emin misiniz?",
"resetInboundClientTraffics": "Kullanıcı Trafiklerini Sıfırla",
"resetInboundClientTrafficTitle": "Kullanıcı Trafiklerini Sıfırla",
"resetInboundClientTrafficContent": "Bu bağlantı noktasının kullanıcılarınin trafiğini sıfırlamak istediğinizden emin misiniz?",
"resetAllClientTraffics": "Tüm Kullanıcı Trafiklerini Sıfırla",
"resetAllClientTrafficTitle": "Tüm Kullanıcı Trafiklerini Sıfırla",
"resetAllClientTrafficContent": "Tüm kullanıcıların trafiğini sıfırlamak istediğinizden emin misiniz?",
"delDepletedClients": "Bitmiş Kullanıcıları Sil",
"delDepletedClientsTitle": "Bitmiş Kullanıcıları Sil",
"delDepletedClientsContent": "Tüm bitmiş kullanıcıları silmek istediğinizden emin misiniz?",
"email": "Email",
"emailDesc": "Lütfen benzersiz bir e-posta adresi sağlayın.",
"IPLimit": "IP Limiti",
"IPLimitDesc": "Sayının aşılması durumunda gelen devre dışı bırakılır. (0 = devre dışı)",
"IPLimitDesc": "Sayının aşılması durumunda bağlantı noktası devre dışı bırakılır. (0 = devre dışı)",
"IPLimitlog": "IP Günlüğü",
"IPLimitlogDesc": "IP geçmiş günlüğü. (devre dışı bırakıldıktan sonra gelini etkinleştirmek için günlüğü temizleyin)",
"IPLimitlogclear": "Günlüğü Temizle",
@@ -441,12 +441,12 @@
"jsonErrorPrefix": "Gelişmiş JSON"
},
"telegramDesc": "Lütfen Telegram Sohbet Kimliği sağlayın. (botta '/id' komutunu kullanın) veya ({'@'}userinfobot)",
"subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla müşteri için kullanabilirsiniz.",
"subscriptionDesc": "Abonelik URL'inizi bulmak için 'Detaylar'a gidin. Ayrıca, aynı adı birden fazla kullanıcı için kullanabilirsiniz.",
"same": "Aynı",
"inboundInfo": "Gelen Bilgileri",
"exportInbound": "Geleni Dışa Aktar",
"inboundInfo": "Bağlantı Noktası Bilgileri",
"exportInbound": "Bağlantı Noktasını Dışa Aktar",
"import": "İçe Aktar",
"importInbound": "Bir Gelen İçe Aktar",
"importInbound": "Bağlantı Noktası İçe Aktar",
"periodicTrafficResetTitle": "Trafik Sıfırlama",
"periodicTrafficResetDesc": "Belirtilen aralıklarla trafik sayacını otomatik olarak sıfırla",
"lastReset": "Son Sıfırlama",
@@ -461,17 +461,17 @@
"obtain": "Elde Et",
"updateSuccess": "Güncelleme başarılı oldu",
"logCleanSuccess": "Günlük temizlendi",
"inboundsUpdateSuccess": "Gelen bağlantılar başarıyla güncellendi",
"inboundUpdateSuccess": "Gelen bağlantı başarıyla güncellendi",
"inboundCreateSuccess": "Gelen bağlantı başarıyla oluşturuldu",
"inboundsUpdateSuccess": "Bağlantı noktaları başarıyla güncellendi",
"inboundUpdateSuccess": "Bağlantı noktası başarıyla güncellendi",
"inboundCreateSuccess": "Bağlantı noktası başarıyla oluşturuldu",
"bulkDeleted": "{count} inbound silindi",
"bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
"inboundDeleteSuccess": "Gelen bağlantı başarıyla silindi",
"inboundClientAddSuccess": "Gelen bağlantı istemci(leri) eklendi",
"inboundClientDeleteSuccess": "Gelen bağlantı istemcisi silindi",
"inboundClientUpdateSuccess": "Gelen bağlantı istemcisi güncellendi",
"delDepletedClientsSuccess": "Tüm tükenmiş istemciler silindi",
"resetAllClientTrafficSuccess": "İstemcinin tüm trafiği sıfırlandı",
"inboundDeleteSuccess": "Bağlantı noktası başarıyla silindi",
"inboundClientAddSuccess": "Bağlantı noktası kullanıcı(leri) eklendi",
"inboundClientDeleteSuccess": "Bağlantı noktası kullanıcısı silindi",
"inboundClientUpdateSuccess": "Bağlantı noktası kullanıcısı güncellendi",
"delDepletedClientsSuccess": "Tüm tükenmiş kullanıcılar silindi",
"resetAllClientTrafficSuccess": "Kullanıcının tüm trafiği sıfırlandı",
"resetAllTrafficSuccess": "Tüm trafik sıfırlandı",
"resetInboundClientTrafficSuccess": "Trafik sıfırlandı",
"resetInboundTrafficSuccess": "Gelen trafik sıfırlandı",
@@ -479,7 +479,7 @@
"getNewX25519CertError": "X25519 sertifikası alınırken hata oluştu.",
"getNewmldsa65Error": "mldsa65 sertifikası alınırken hata oluştu.",
"getNewVlessEncError": "VlessEnc sertifikası alınırken hata oluştu.",
"invalidClientField": "Müşteri {client}: alan {field} — {reason}",
"invalidClientField": "Kullanıcı {client}: alan {field} — {reason}",
"invalidField": "{field} — {reason}",
"moreIssues": "{message} (+{count} tane daha)"
},
@@ -487,7 +487,7 @@
"moveUp": "Yukarı",
"moveDown": "Aşağı",
"addAll": "Tümünü ekle",
"addAllFallbackTooltip": "Henüz bağlanmamış her uygun gelen için bir fallback satırı ekler",
"addAllFallbackTooltip": "Henüz bağlanmamış her uygun bağlantı noktası için bir fallback satırı ekler",
"peers": "Peers",
"addPeer": "Peer ekle",
"keepAlive": "Keep-alive",
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "İşletim sistemi varsayılanını kullanmak için 0 bırakın. Sıfır olmayan değerler ilan edilen TCP alım penceresini sınırlar; 600 gibi değerler (Xray belgelerindeki örnek) yüksek gecikmeli bağlantılarda verimi çökertebilir.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -586,7 +587,7 @@
"echKey": "ECH key",
"echConfig": "ECH yapılandırması",
"pinnedPeerCertSha256": "Sabitlenmiş Peer Sertifikası SHA-256",
"pinnedPeerCertSha256Tip": "Peer sertifikasının SHA-256 hash'leri onaltılık (hex) dizge olarak (örn. e8e2d3…), virgülle ayrılmış. Sadece panel — sunucunun xray yapılandırmasına yazılmaz, ancak istemcilerin sertifikayı sabitleyebilmesi için paylaşım bağlantılarına eklenir.",
"pinnedPeerCertSha256Tip": "Peer sertifikasının SHA-256 hash'leri onaltılık (hex) dizge olarak (örn. e8e2d3…), virgülle ayrılmış. Sadece panel — sunucunun xray yapılandırmasına yazılmaz, ancak kullanıcıların sertifikayı sabitleyebilmesi için paylaşım bağlantılarına eklenir.",
"pinnedPeerCertSha256Placeholder": "onaltılık (hex) hash(ler), virgülle ayrılmış",
"generateRandomPin": "Rastgele hash üret",
"getNewEchCert": "Yeni ECH sertifikası al",
@@ -594,9 +595,13 @@
"xver": "Xver",
"target": "Hedef",
"maxTimeDiff": "Maks. zaman farkı (ms)",
"minClientVer": "Min. istemci sürümü",
"maxClientVer": "Maks. istemci sürümü",
"minClientVer": "Min. kullanıcı sürümü",
"maxClientVer": "Maks. kullanıcı sürümü",
"shortIds": "Short IDs",
"realityTargetHint": "Zorunlu. Bir bağlantı noktası içermeli (ör. example.com:443). Bağlantı noktası olmadan Xray-core başlamaz.",
"realityTargetRequired": "REALITY hedefi zorunludur",
"realityTargetNeedsPort": "REALITY hedefi bir bağlantı noktası içermelidir (ör. example.com:443)",
"realityTargetInvalidPort": "REALITY hedefinde geçersiz bir bağlantı noktası var",
"spiderX": "SpiderX",
"getNewCert": "Yeni sertifika al",
"mldsa65Seed": "mldsa65 Seed",
@@ -640,24 +645,24 @@
}
},
"clients": {
"add": "İstemci ekle",
"edit": "İstemciyi düzenle",
"submitAdd": "İstemci ekle",
"add": "Kullanıcı ekle",
"edit": "Kullanıcıyı düzenle",
"submitAdd": "Kullanıcı ekle",
"submitEdit": "Değişiklikleri kaydet",
"clientCount": "İstemci sayısı",
"clientCount": "Kullanıcı sayısı",
"bulk": "Toplu ekle",
"copyFromInbound": "Inbound'dan istemcileri kopyala",
"copyToInbound": "İstemcileri kopyalanacak yer",
"copyFromInbound": "Inbound'dan kullanıcıları kopyala",
"copyToInbound": "Kullanıcıları kopyalanacak yer",
"copySelected": "Seçileni kopyala",
"copySource": "Kaynak",
"copyEmailPreview": "Oluşacak e-posta önizlemesi",
"copySelectSourceFirst": "Önce bir kaynak inbound seçin.",
"copyResult": "Kopya sonucu",
"copyResultSuccess": "Başarıyla kopyalandı",
"copyResultNone": "Kopyalanacak bir şey yok: istemci seçilmemiş veya kaynak boş",
"copyResultNone": "Kopyalanacak bir şey yok: kullanıcı seçilmemiş veya kaynak boş",
"copyResultErrors": "Kopyalama hataları",
"copyFlowLabel": "Yeni istemciler için Flow (VLESS)",
"copyFlowHint": "Kopyalanan tüm istemcilere uygulanır. Atlamak için boş bırakın.",
"copyFlowLabel": "Yeni kullanıcılar için Flow (VLESS)",
"copyFlowHint": "Kopyalanan tüm kullanıcılara uygulanır. Atlamak için boş bırakın.",
"selectAll": "Tümünü seç",
"clearAll": "Tümünü temizle",
"method": "Yöntem",
@@ -672,7 +677,7 @@
"renew": "Otomatik yenileme",
"renewDesc": "Süre dolduktan sonra otomatik yenileme. (0 = devre dışı) (birim: gün)",
"searchPlaceholder": "Email, yorum, sub ID, UUID, parola, auth ara…",
"filterTitle": "İstemcileri filtrele",
"filterTitle": "Kullanıcıları filtrele",
"clearAllFilters": "Tümünü temizle",
"showingCount": "{total} içinden {shown} gösteriliyor",
"sortOldest": "Önce en eski",
@@ -686,11 +691,11 @@
"sortExpiringSoonest": "Yakında biten",
"has": "Var",
"hasNot": "Yok",
"title": "İstemciler",
"title": "Kullanıcılar",
"actions": "Eylemler",
"totalGB": "Toplam Gönderilen/Alınan (GB)",
"expiryTime": "Son kullanma",
"addClients": "İstemci ekle",
"addClients": "Kullanıcı ekle",
"limitIp": "IP limiti",
"password": "Şifre",
"subId": "Abonelik ID'si",
@@ -699,60 +704,60 @@
"emailInvalidChars": "E-posta boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"subIdInvalidChars": "Abonelik kimliği boşluk, '/', '\\' veya kontrol karakterleri içeremez",
"group": "Grup",
"groupDesc": "İlgili istemcileri gruplamak için mantıksal etiket (ekip, müşteri, bölge). Araç çubuğundan filtrelenebilir.",
"groupDesc": "İlgili kullanıcıları gruplamak için mantıksal etiket (ekip, kullanıcı, bölge). Araç çubuğundan filtrelenebilir.",
"groupPlaceholder": "örn. customer-a",
"comment": "Yorum",
"traffic": "Trafik",
"offline": "Çevrimdışı",
"addClient": "İstemci ekle",
"addClient": "Kullanıcı ekle",
"qrCode": "QR kodu",
"clientInfo": "İstemci Bilgileri",
"clientInfo": "Kullanıcı Bilgileri",
"delete": "Sil",
"reset": "Trafiği sıfırla",
"editClient": "İstemciyi düzenle",
"client": "İstemci",
"editClient": "Kullanıcıyı düzenle",
"client": "Kullanıcı",
"enabled": "Etkin",
"remaining": "Kalan",
"duration": "Süre",
"attachedInbounds": "Bağlı inbound'lar",
"selectInbound": "Bir veya daha fazla inbound seçin",
"noSubId": "Bu istemcinin subId'si yok, paylaşılabilir bağlantı yok.",
"noLinks": "Paylaşılabilir bağlantı yok — önce bu istemciyi protokol destekli bir inbound'a bağlayın.",
"noSubId": "Bu kullanıcının subId'si yok, paylaşılabilir bağlantı yok.",
"noLinks": "Paylaşılabilir bağlantı yok — önce bu kullanıcıyı protokol destekli bir inbound'a bağlayın.",
"link": "Bağlantı",
"resetNotPossible": "Önce bu istemciyi bir inbound'a bağlayın.",
"resetNotPossible": "Önce bu kullanıcıyı bir inbound'a bağlayın.",
"general": "Genel",
"resetAllTraffics": "Tüm istemcilerin trafiğini sıfırla",
"resetAllTrafficsTitle": "Tüm istemcilerin trafiği sıfırlansın mı?",
"resetAllTrafficsContent": "Her istemcinin yükleme/indirme sayaçları sıfırlanır. Kotalar ve son kullanma tarihleri etkilenmez. Geri alınamaz.",
"deleteConfirmTitle": "{email} istemcisi silinsin mi?",
"deleteConfirmContent": "Bu işlem istemciyi bağlı tüm inbound'lardan kaldırır ve trafik kaydını siler. Geri alınamaz.",
"resetAllTraffics": "Tüm kullanıcıların trafiğini sıfırla",
"resetAllTrafficsTitle": "Tüm kullanıcıların trafiği sıfırlansın mı?",
"resetAllTrafficsContent": "Her kullanıcının yükleme/indirme sayaçları sıfırlanır. Kotalar ve son kullanma tarihleri etkilenmez. Geri alınamaz.",
"deleteConfirmTitle": "{email} kullanıcısı silinsin mi?",
"deleteConfirmContent": "Bu işlem kullanıcıyı bağlı tüm inbound'lardan kaldırır ve trafik kaydını siler. Geri alınamaz.",
"deleteSelected": "Sil ({count})",
"adjustSelected": "Ayarla ({count})",
"subLinksSelected": "Abonelik bağlantıları ({count})",
"addToGroupTitle": "{count} istemciyi bir gruba ekle",
"addToGroupTooltip": "Mevcut bir grubu seçin veya yeni ad girin. İstemcileri mevcut gruplarından çıkarmak için Ungroup'u kullanın.",
"addToGroupTitle": "{count} kullanıcıyı bir gruba ekle",
"addToGroupTooltip": "Mevcut bir grubu seçin veya yeni ad girin. Kullanıcıları mevcut gruplarından çıkarmak için Ungroup'u kullanın.",
"groupName": "Grup adı",
"addToGroupSuccessToast": "{count} istemci {group} grubuna eklendi",
"ungroupSuccessToast": "{count} istemcinin grubu temizlendi",
"addToGroupSuccessToast": "{count} kullanıcı {group} grubuna eklendi",
"ungroupSuccessToast": "{count} kullanıcının grubu temizlendi",
"ungroup": "Gruptan çıkar",
"ungroupConfirmTitle": "{count} istemciyi gruptan çıkar?",
"ungroupConfirmContent": "Seçilen her istemcinin grup etiketini temizler. İstemciler korunur (tamamen kaldırmak için Delete kullanın).",
"ungroupConfirmTitle": "{count} kullanıcıyı gruptan çıkar?",
"ungroupConfirmContent": "Seçilen her kullanıcının grup etiketini temizler. Kullanıcılar korunur (tamamen kaldırmak için Delete kullanın).",
"addToGroup": "Gruba ekle",
"attach": "Bağla",
"adjust": "Ayarla",
"subLinks": "Abonelik bağlantıları",
"selectedCount": "{count} seçildi",
"attachSelected": "Bağla ({count})",
"attachToInboundsTitle": "{count} istemciyi gelen(ler)e bağla",
"attachToInboundsDesc": "Seçilen {count} istemciyi (aynı UUID/parola ve paylaşılan trafik) seçilen gelene bağlar. Mevcut bağlantılar korunur.",
"attachToInboundsTargets": "Hedef gelenler",
"attachToInboundsNoTargets": "Bağlanacak çoklu kullanıcılı gelen yok.",
"attachToInboundsTitle": "{count} kullanıcıyı bağlantı nokta(ları)sına bağla",
"attachToInboundsDesc": "Seçilen {count} kullanıcıyı (aynı UUID/parola ve paylaşılan trafik) seçilen bağlantı noktasına bağlar. Mevcut bağlantılar korunur.",
"attachToInboundsTargets": "Hedef bağlantı noktaları",
"attachToInboundsNoTargets": "Bağlanacak çoklu kullanıcılı bağlantı noktası yok.",
"detachSelected": "Çöz ({count})",
"detach": "Çöz",
"detachFromInboundsTitle": "{count} istemciyi gelen(ler)den çöz",
"detachFromInboundsDesc": "Seçilen {count} istemciyi seçilen gelenden kaldırır. İstemcinin bağlı olmadığı çiftler sessizce atlanır. İstemci kayıtları korunur (tamamen kaldırmak için Delete kullanın).",
"detachFromInboundsTargets": "Çözülecek gelenler",
"detachFromInboundsNoTargets": "Çoklu kullanıcılı gelen yok.",
"detachFromInboundsTitle": "{count} kullanıcıyı bağlantı nokta(ları)sından çöz",
"detachFromInboundsDesc": "Seçilen {count} kullanıcıyı seçilen bağlantı noktasından kaldırır. Kullanıcının bağlı olmadığı çiftler sessizce atlanır. Kullanıcı kayıtları korunur (tamamen kaldırmak için Delete kullanın).",
"detachFromInboundsTargets": "Çözülecek bağlantı noktaları",
"detachFromInboundsNoTargets": "Çoklu kullanıcılı bağlantı noktası yok.",
"detachFromInboundsResult": "Çözüldü {detached}, atlandı {skipped}.",
"detachFromInboundsResultMixed": "Çözüldü {detached}, atlandı {skipped}, hata {errors}.",
"subLinksTitle": "Abonelik bağlantıları ({count})",
@@ -760,19 +765,19 @@
"subJsonLinkColumn": "Abonelik JSON URL",
"subLinksCopyAll": "Tümünü kopyala",
"subLinksCopiedAll": "{count} bağlantı kopyalandı",
"subLinksEmpty": "Seçilen istemcilerin hiçbirinin abonelik ID'si yok.",
"subLinksEmpty": "Seçilen kullanıcıların hiçbirinin abonelik ID'si yok.",
"subLinksDisabled": "Abonelik hizmeti devre dışı.",
"subLinksDisabledHint": "Bağlantı oluşturmak için Panel Ayarları → Abonelik'ten etkinleştirin.",
"bulkDeleteConfirmTitle": "{count} istemci silinsin mi?",
"bulkDeleteConfirmContent": "Seçili her istemci bağlı tüm inbound'lardan kaldırılır ve trafik kaydı silinir. Geri alınamaz.",
"bulkAdjustTitle": "{count} istemciyi ayarla",
"bulkAdjustHint": "Pozitif değerler ekler, negatif değerler azaltır. Sınırsız süreli veya trafikli istemciler ilgili alan için atlanır.",
"bulkDeleteConfirmTitle": "{count} kullanıcı silinsin mi?",
"bulkDeleteConfirmContent": "Seçili her kullanıcı bağlı tüm inbound'lardan kaldırılır ve trafik kaydı silinir. Geri alınamaz.",
"bulkAdjustTitle": "{count} kullanıcıyı ayarla",
"bulkAdjustHint": "Pozitif değerler ekler, negatif değerler azaltır. Sınırsız süreli veya trafikli kullanıcılar ilgili alan için atlanır.",
"bulkAdjustNothing": "Uygulamadan önce gün veya trafik belirleyin.",
"addDays": "Gün ekle",
"addTrafficGB": "Trafik ekle (GB)",
"delDepleted": "Tükenmişleri sil",
"delDepletedConfirmTitle": "Tükenmiş istemciler silinsin mi?",
"delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm istemciler silinir. Geri alınamaz.",
"delDepletedConfirmTitle": "Tükenmiş kullanıcılar silinsin mi?",
"delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm kullanıcılar silinir. Geri alınamaz.",
"auth": "Auth",
"hysteriaAuth": "Hysteria Auth",
"uuid": "UUID",
@@ -786,55 +791,55 @@
"updated": "Güncellendi",
"ipLimit": "IP limiti",
"toasts": {
"deleted": "İstemci silindi",
"deleted": "Kullanıcı silindi",
"trafficReset": "Trafik sıfırlandı",
"allTrafficsReset": "Tüm istemcilerin trafiği sıfırlandı",
"bulkDeleted": "{count} istemci silindi",
"allTrafficsReset": "Tüm kullanıcıların trafiği sıfırlandı",
"bulkDeleted": "{count} kullanıcı silindi",
"bulkDeletedMixed": "{ok} silindi, {failed} başarısız",
"bulkCreated": "{count} istemci oluşturuldu",
"bulkCreated": "{count} kullanıcı oluşturuldu",
"bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
"bulkAdjusted": "{count} istemci ayarlandı",
"bulkAdjusted": "{count} kullanıcı ayarlandı",
"bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
"delDepleted": "{count} tükenmiş istemci silindi"
"delDepleted": "{count} tükenmiş kullanıcı silindi"
}
},
"groups": {
"title": "Gruplar",
"name": "İsim",
"clientCount": "Gruptaki istemciler",
"clientCount": "Gruptaki kullanıcılar",
"totalGroups": "Toplam grup",
"totalGroupedClients": "Grubu olan istemciler",
"totalGroupedClients": "Grubu olan kullanıcılar",
"emptyGroups": "Boş gruplar",
"addGroup": "Grup ekle",
"createSuccess": "«{name}» grubu oluşturuldu.",
"rename": "Yeniden adlandır",
"renameTitle": "{name} yeniden adlandır",
"renameCollision": "«{name}» adında bir grup zaten var.",
"renameSuccess": "{count} istemcinin grubu yeniden adlandırıldı.",
"renameSuccess": "{count} kullanıcının grubu yeniden adlandırıldı.",
"deleteConfirmTitle": "{name} grubunu sil?",
"deleteConfirmContent": "Bu, grubu siler ve etiketini {count} istemciden temizler. İstemciler silinmez.",
"deleteSuccess": "{count} istemcinin grubu temizlendi.",
"deleteConfirmContent": "Bu, grubu siler ve etiketini {count} kullanıcıdan temizler. Kullanıcılar silinmez.",
"deleteSuccess": "{count} kullanıcının grubu temizlendi.",
"resetTraffic": "Trafiği sıfırla",
"resetConfirmTitle": "{name} grubunun trafiğini sıfırla?",
"resetConfirmContent": "Bu, bu gruptaki tüm {count} istemcinin yukarı/aşağı trafiğini sıfırlar.",
"resetSuccess": "{count} istemcinin trafiği sıfırlandı.",
"adjustSuccess": "{name} içinde {count} istemci ayarlandı.",
"emptyForAction": "Bu grupta henüz istemci yok.",
"deleteGroupOnly": "Grubu sil (istemcileri tut)",
"deleteClients": "Gruptaki istemcileri sil",
"deleteClientsConfirmTitle": "{name} içindeki tüm istemcileri sil?",
"deleteClientsConfirmContent": "Bu, {count} istemciyi trafik kayıtlarıyla birlikte kalıcı olarak siler. Grup etiketi de temizlenir. Geri alınamaz.",
"deleteClientsSuccess": "{count} istemci silindi.",
"resetConfirmContent": "Bu, bu gruptaki tüm {count} kullanıcının yukarı/aşağı trafiğini sıfırlar.",
"resetSuccess": "{count} kullanıcının trafiği sıfırlandı.",
"adjustSuccess": "{name} içinde {count} kullanıcı ayarlandı.",
"emptyForAction": "Bu grupta henüz kullanıcı yok.",
"deleteGroupOnly": "Grubu sil (kullanıcıları tut)",
"deleteClients": "Gruptaki kullanıcıları sil",
"deleteClientsConfirmTitle": "{name} içindeki tüm kullanıcıları sil?",
"deleteClientsConfirmContent": "Bu, {count} kullanıcıyı trafik kayıtlarıyla birlikte kalıcı olarak siler. Grup etiketi de temizlenir. Geri alınamaz.",
"deleteClientsSuccess": "{count} kullanıcı silindi.",
"deleteClientsMixed": "{ok} silindi, {failed} atlandı",
"addToGroup": "İstemci ekle…",
"addToGroupTitle": "«{name}» grubuna istemci ekle",
"addToGroupDesc": "Bu gruba eklemek için istemcileri seçin. Mevcut gelen bağlantıları korunur; yalnızca grup etiketi değişir. Halihazırda bu grupta olan istemciler listelenmez.",
"addToGroupEmpty": "Eklenecek başka istemci yok.",
"addToGroupResult": "{count} istemci {name} grubuna eklendi.",
"removeFromGroup": "İstemci çıkar…",
"removeFromGroupTitle": "«{name}» grubundan istemci çıkar",
"removeFromGroupDesc": "Bu gruptan çıkarılacak üyeleri seçin. İstemciler korunur (tamamen kaldırmak için «Gruptaki istemcileri sil» kullanın).",
"removeFromGroupResult": "{name} grubundan {count} istemci çıkarıldı."
"addToGroup": "Kullanıcı ekle…",
"addToGroupTitle": "«{name}» grubuna kullanıcı ekle",
"addToGroupDesc": "Bu gruba eklemek için kullanıcıları seçin. Mevcut gelen bağlantıları korunur; yalnızca grup etiketi değişir. Halihazırda bu grupta olan kullanıcılar listelenmez.",
"addToGroupEmpty": "Eklenecek başka kullanıcı yok.",
"addToGroupResult": "{count} kullanıcı {name} grubuna eklendi.",
"removeFromGroup": "Kullanıcı çıkar…",
"removeFromGroupTitle": "«{name}» grubundan kullanıcı çıkar",
"removeFromGroupDesc": "Bu gruptan çıkarılacak üyeleri seçin. Kullanıcılar korunur (tamamen kaldırmak için «Gruptaki kullanıcıları sil» kullanın).",
"removeFromGroupResult": "{name} grubundan {count} kullanıcı çıkarıldı."
},
"nodes": {
"title": "Düğümler",
@@ -881,6 +886,8 @@
"connectionFailed": "Bağlantı başarısız",
"never": "asla",
"justNow": "şimdi",
"subNode": "Alt düğüm",
"subNodeTip": "Salt okunur: {parent} üzerinden erişilen bir alt düğüm. Bunu {parent} panelinden yönetin.",
"deleteConfirmTitle": "\"{name}\" düğümü silinsin mi?",
"deleteConfirmContent": "Bu, düğüm izlemeyi durdurur. Uzak panelin kendisi etkilenmez.",
"statusValues": {
@@ -947,9 +954,9 @@
"panelUrlPath": "URI yolu",
"panelUrlPathDesc": "Web paneli için URI yolu. ('/' ile başlar ve '/' ile biter)",
"pageSize": "Sayfa Boyutu",
"pageSizeDesc": "Gelenler tablosu için sayfa boyutunu belirleyin. (0 = devre dışı)",
"pageSizeDesc": "Bağlantı Noktaları tablosu için sayfa boyutunu belirleyin. (0 = devre dışı)",
"panelProxy": "Panel ağ proxy'si",
"panelProxyDesc": "Panelin kendi giden istekleri (geo güncellemeleri, Xray/panel sürüm kontrolleri, Telegram) bu proxy üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini atlatmak için. socks5:// veya http(s):// kabul eder, örn. yerel bir Xray SOCKS geleni. Doğrudan bağlantı için boş bırakın.",
"panelProxyDesc": "Panelin kendi giden istekleri (geo güncellemeleri, Xray/panel sürüm kontrolleri, Telegram) bu proxy üzerinden yönlendirir; sunucu tarafındaki GitHub/Telegram filtrelemesini atlatmak için. socks5:// veya http(s):// kabul eder, örn. yerel bir Xray SOCKS bağlantı noktası. Doğrudan bağlantı için boş bırakın.",
"remarkModel": "Açıklama Modeli & Ayırma Karakteri",
"datepicker": "Takvim Türü",
"datepickerPlaceholder": "Tarih Seçin",
@@ -1020,13 +1027,13 @@
"subDomain": "Dinleme Alan Adı",
"subDomainDesc": "Abonelik hizmeti için alan adı. (tüm alan adlarını ve IP'leri dinlemek için boş bırakın)",
"subUpdates": "Güncelleme Aralıkları",
"subUpdatesDesc": "Müşteri uygulamalarındaki abonelik URL'sinin güncelleme aralıkları. (birim: saat)",
"subUpdatesDesc": "Kullanıcı uygulamalarındaki abonelik URL'sinin güncelleme aralıkları. (birim: saat)",
"subEncrypt": "Kodla",
"subEncryptDesc": "Abonelik hizmetinin döndürülen içeriği Base64 ile şifrelenir.",
"subShowInfo": "Kullanım Bilgisini Göster",
"subShowInfoDesc": "Kalan trafik ve tarih müşteri uygulamalarında görüntülenir.",
"subShowInfoDesc": "Kalan trafik ve tarih istemci uygulamalarında görüntülenir.",
"subEmailInRemark": "Ada Email Ekle",
"subEmailInRemarkDesc": "Abonelik profil adına istemcinin e-postasını dahil edin.",
"subEmailInRemarkDesc": "Abonelik profil adına kullanıcının e-postasını dahil edin.",
"subURI": "Ters Proxy URI",
"subURIDesc": "Proxy arkasında kullanılacak abonelik URL'sinin URI yolu.",
"externalTrafficInformEnable": "Harici Trafik Bilgisi",
@@ -1034,7 +1041,7 @@
"externalTrafficInformURI": "Harici Trafik Bilgisi URI'si",
"externalTrafficInformURIDesc": "Trafik güncellemeleri bu URI'ye gönderildi.",
"restartXrayOnClientDisable": "Otomatik Devre Dışı Sonrası Xray'i Yeniden Başlat",
"restartXrayOnClientDisableDesc": "Bir istemci süre dolumu veya trafik limiti nedeniyle otomatik devre dışı bırakıldığında Xray'i yeniden başlat.",
"restartXrayOnClientDisableDesc": "Bir kullanıcı süre dolumu veya trafik limiti nedeniyle otomatik devre dışı bırakıldığında Xray'i yeniden başlat.",
"fragment": "Parçalama",
"fragmentDesc": "TLS merhaba paketinin parçalanmasını etkinleştir.",
"fragmentSett": "Parçalama Ayarları",
@@ -1063,11 +1070,11 @@
"invertFlagDesc": "Öznitelik «devre dışı» anlamına geldiğinde etkinleştirin (örn. shadowInactive).",
"syncSchedule": "Senkronizasyon programı",
"syncScheduleDesc": "cron benzeri dize, örn. @every 1m",
"inboundTags": "Gelen etiketleri",
"inboundTagsDesc": "LDAP senkronizasyonunun istemci otomatik oluşturup/silebileceği gelenler.",
"noInbounds": "Gelen bulunamadı. Önce Gelenler'de bir tane oluşturun.",
"autoCreate": "İstemcileri otomatik oluştur",
"autoDelete": "İstemcileri otomatik sil",
"inboundTags": "Bağlantı noktası etiketleri",
"inboundTagsDesc": "LDAP senkronizasyonunun istemci otomatik oluşturup/silebileceği bağlantı noktaları.",
"noInbounds": "Bağlantı noktası bulunamadı. Önce Bağlantı Noktaları'nda bir tane oluşturun.",
"autoCreate": "Kullanıcıları otomatik oluştur",
"autoDelete": "Kullanıcıları otomatik sil",
"defaultTotalGb": "Varsayılan toplam (GB)",
"defaultExpiryDays": "Varsayılan son kullanma (gün)",
"defaultIpLimit": "Varsayılan IP limiti"
@@ -1127,7 +1134,7 @@
"apiTokenNamePlaceholder": "örn. central-panel-a",
"apiTokenNameRequired": "Ad zorunludur",
"apiTokenEmpty": "Henüz token yok — bot veya uzak panelleri doğrulamak için bir tane oluşturun.",
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm istemciler anında kimlik doğrulamasını kaybeder.",
"apiTokenDeleteWarning": "Bu tokenı kullanan tüm kullanıcılar anında kimlik doğrulamasını kaybeder.",
"apiTokenCreatedTitle": "Belirteç oluşturuldu",
"apiTokenCreatedNotice": "Bu belirteci şimdi kopyalayın. Güvenlik nedeniyle okunabilir biçimde saklanmaz ve tekrar gösterilmez."
},
@@ -1177,17 +1184,20 @@
"TemplateDesc": "Nihai Xray yapılandırma dosyası bu şablona göre oluşturulacaktır.",
"FreedomStrategy": "Freedom Protokol Stratejisi",
"FreedomStrategyDesc": "Freedom Protokolünde ağın çıkış stratejisini ayarlayın.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Doğrudan (freedom) çıkış için çift yığınlı arama — hem IPv4 hem IPv6 olan çıkış sunucularında kullanışlıdır.",
"FreedomHappyEyeballsTryDelayDesc": "Diğer adres ailesini denemeden önceki milisaniye. 150–250 ms iyi bir başlangıç noktasıdır.",
"RoutingStrategy": "Genel Yönlendirme Stratejisi",
"RoutingStrategyDesc": "Tüm istekleri çözmek için genel trafik yönlendirme stratejisini ayarlayın.",
"outboundTestUrl": "Outbound test URL",
"outboundTestUrlDesc": "Outbound bağlantı testinde kullanılan URL",
"Torrent": "BitTorrent Protokolünü Engelle",
"Inbounds": "Gelenler",
"InboundsDesc": "Belirli müşterileri kabul eder.",
"Outbounds": "Gidenler",
"Inbounds": "Bağlantı Noktaları",
"InboundsDesc": "Belirli kullanıcıları kabul eder.",
"Outbounds": "Çıkış Noktaları",
"Balancers": "Dengeler",
"balancerTagRequired": "Etiket gereklidir",
"balancerSelectorRequired": "En az bir giden seçin",
"balancerSelectorRequired": "En az bir çıkış noktası seçin",
"OutboundsDesc": "Giden trafiğin yolunu ayarlayın.",
"Routings": "Yönlendirme Kuralları",
"RoutingsDesc": "Her kuralın önceliği önemlidir!",
@@ -1226,8 +1236,8 @@
"down": "Aşağı",
"source": "Kaynak",
"dest": "Hedef",
"inbound": "Gelen",
"outbound": "Giden",
"inbound": "Bağlantı Noktası",
"outbound": "Çıkış Noktası",
"balancer": "Dengeler",
"info": "Bilgi",
"add": "Kural Ekle",
@@ -1244,25 +1254,25 @@
"attributes": "Öznitelikler",
"value": "Değer",
"user": "Kullanıcı",
"inboundTags": "Gelen etiketleri",
"outboundTag": "Giden etiketi",
"inboundTags": "Bağlantı noktası etiketleri",
"outboundTag": "Çıkış noktası etiketi",
"balancerTag": "Dengeleyici etiketi",
"balancerTagTooltip": "Trafiği yapılandırılmış yük dengeleyicilerden biri üzerinden yönlendirir"
},
"outboundForm": {
"tagDuplicate": "Etiket başka bir giden tarafından kullanılıyor",
"tagDuplicate": "Etiket başka bir çıkış noktası tarafından kullanılıyor",
"tagRequired": "Etiket gereklidir",
"tagPlaceholder": "benzersiz-etiket",
"localIpPlaceholder": "yerel IP",
"dialerProxyPlaceholder": "Zincirlemek için bir giden seçin",
"dialerProxyHint": "Bir proxy zinciri oluşturmak için bu gideni başka bir giden üzerinden (etikete göre) bağlayın. Doğrudan bağlanmak için boş bırakın.",
"dialerProxyPlaceholder": "Zincirlemek için bir çıkış noktası seçin",
"dialerProxyHint": "Bir proxy zinciri oluşturmak için bu çıkış noktasını başka bir çıkış noktası üzerinden (etikete göre) bağlayın. Doğrudan bağlanmak için boş bırakın.",
"addressRequired": "Adres gereklidir",
"portRequired": "Port gereklidir",
"optional": "opsiyonel",
"udpOverTcp": "UDP over TCP",
"uotVersion": "UoT sürümü",
"inboundTag": "Gelen etiketi",
"inboundTagPlaceholder": "yönlendirme kurallarında kullanılan gelen etiketi",
"inboundTag": "Bağlantı noktası etiketi",
"inboundTagPlaceholder": "yönlendirme kurallarında kullanılan bağlantı noktası etiketi",
"responseType": "Yanıt tipi",
"rewriteNetwork": "Ağı yeniden yaz",
"unchanged": "(değişmedi)",
@@ -1305,9 +1315,9 @@
"tcpKeepAliveIdleS": "TCP keep-alive idle (s)"
},
"outbound": {
"addOutbound": "Giden Ekle",
"addOutbound": "Çıkış Noktası Ekle",
"addReverse": "Ters Ekle",
"editOutbound": "Gideni Düzenle",
"editOutbound": "Çıkış Noktasını Düzenle",
"editReverse": "Tersi Düzenle",
"reverseTag": "Ters Etiket",
"reverseTagDesc": "VLESS basit ters proxy çıkış etiketi. Devre dışı bırakmak için boş bırakın.",
@@ -1324,14 +1334,14 @@
"intercon": "Bağlantı",
"settings": "Ayarlar",
"accountInfo": "Hesap Bilgileri",
"outboundStatus": "Giden Durumu",
"outboundStatus": "Çıkış Noktası Durumu",
"sendThrough": "Üzerinden Gönder",
"test": "Test",
"testResult": "Test Sonucu",
"testing": "Bağlantı test ediliyor...",
"testSuccess": "Test başarılı",
"testFailed": "Test başarısız",
"testError": "Giden test edilemedi",
"testError": "Çıkış noktası test edilemedi",
"testModeTooltip": "TCP: hızlı dial-only probe. HTTP: xray üzerinden tam istek.",
"testAll": "Tümünü test et",
"nordvpn": "NordVPN",
@@ -1360,7 +1370,7 @@
"tolerance": "Tolerans",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Dengeleyici Etiketi ve Giden Etiketi aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden etiketi çalışır."
"balancerDesc": "Dengeleyici Etiketi ve Giden Etiketi aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca çıkış noktası etiketi çalışır."
},
"wireguard": {
"secretKey": "Gizli Anahtar",
@@ -1381,8 +1391,8 @@
"privateKey": "Özel anahtar",
"noServers": "Seçilen ülke için sunucu bulunamadı",
"noPublicKey": "Seçilen sunucu NordLynx genel anahtarı yayınlamıyor.",
"outboundAdded": "NordVPN giden eklendi",
"outboundUpdated": "NordVPN giden güncellendi"
"outboundAdded": "NordVPN çıkış noktası eklendi",
"outboundUpdated": "NordVPN çıkış noktası güncellendi"
},
"warp": {
"licenseError": "WARP lisansı ayarlanamadı.",
@@ -1406,13 +1416,13 @@
"warpPlusData": "WARP+ veri",
"quota": "Kota",
"usage": "Kullanım",
"addOutbound": "Giden ekle"
"addOutbound": "Çıkış noktası ekle"
},
"dns": {
"enable": "DNS'yi Etkinleştir",
"enableDesc": "Dahili DNS sunucusunu etkinleştir",
"tag": "DNS Gelen Etiketi",
"tagDesc": "Bu etiket, yönlendirme kurallarında Gelen etiketi olarak kullanılabilir.",
"tag": "DNS Bağlantı Noktası Etiketi",
"tagDesc": "Bu etiket, yönlendirme kurallarında Bağlantı noktası etiketi olarak kullanılabilir.",
"clientIp": "İstemci IP",
"clientIpDesc": "DNS sorguları sırasında belirtilen IP konumunu sunucuya bildirmek için kullanılır",
"disableCache": "Önbelleği devre dışı bırak",
@@ -1465,8 +1475,8 @@
"noQuery": "❌ Sorgu bulunamadı! Lütfen komutu tekrar kullanın!",
"wentWrong": "❌ Bir şeyler yanlış gitti!",
"noIpRecord": "❗ IP Kaydı Yok!",
"noInbounds": "❗ Gelen bağlantı bulunamadı!",
"unlimited": "♾ Sınırsız (Sıfırla)",
"noInbounds": "❗ Bağlantı noktası bulunamadı!",
"unlimited": "♾ Sınırsız",
"add": "Ekle",
"month": "Ay",
"months": "Aylar",
@@ -1475,8 +1485,8 @@
"hours": "Saatler",
"minutes": "Dakika",
"unknown": "Bilinmeyen",
"inbounds": "Gelenler",
"clients": "İstemciler",
"inbounds": "Bağlantı Noktaları",
"clients": "Kullanıcılar",
"offline": "🔴 Çevrimdışı",
"online": "🟢 Çevrimiçi",
"commands": {
@@ -1488,8 +1498,8 @@
"status": "✅ Bot çalışıyor!",
"usage": "❗ Lütfen aramak için bir metin sağlayın!",
"getID": "🆔 Kimliğiniz: <code>{{ .ID }}</code>",
"helpAdminCommands": "Xray Core'u yeniden başlatmak için:\r\n<code>/restart</code>\r\n\r\nBir müşteri e-postasını aramak için:\r\n<code>/usage [E-posta]</code>\r\n\r\nGelenleri aramak için (müşteri istatistikleri ile):\r\n<code>/inbound [Açıklama]</code>\r\n\r\nTelegram Sohbet Kimliği:\r\n<code>/id</code>",
"helpClientCommands": "İstatistikleri aramak için şu komutu kullanın:\r\n\r\n<code>/usage [E-posta]</code>\r\n\r\nTelegram Sohbet Kimliği:\r\n<code>/id</code>",
"helpAdminCommands": "Xray Core'u yeniden başlatmak için:\r\n<code>/restart</code>\r\n\r\nBir kullanıcının istatistiklerini aramak için:\r\n<code>/usage [E-posta]</code>\r\n\r\nBağlantı noktalarını aramak için (kullanıcı istatistikleri ile):\r\n<code>/inbound [Açıklama]</code>\r\n\r\nTelegram Sohbet Kimliği (Chat ID):\r\n<code>/id</code>",
"helpClientCommands": "İstatistiklerinizi görmek için şu komutu kullanın:\r\n\r\n<code>/usage [E-posta]</code>\r\n\r\nTelegram Sohbet Kimliği:\r\n<code>/id</code>",
"restartUsage": "\r\n\r\n<code>/restart</code>",
"restartSuccess": "✅ İşlem başarılı!",
"restartFailed": "❗ İşlem hatası.\r\n\r\n<code>Hata: {{ .Error }}</code>.",
@@ -1525,46 +1535,46 @@
"username": "👤 Kullanıcı Adı: {{ .Username }}\r\n",
"reason": "❗️ Sebep: {{ .Reason }}\r\n",
"time": "⏰ Zaman: {{ .Time }}\r\n",
"inbound": "📍 Gelen: {{ .Remark }}\r\n",
"inbound": "📍 Bağlantı Noktası: {{ .Remark }}\r\n",
"port": "🔌 Port: {{ .Port }}\r\n",
"expire": "📅 Son Kullanma Tarihi: {{ .Time }}\r\n",
"expireIn": "📅 Sona Erecek: {{ .Time }}\r\n",
"active": "💡 Aktif: {{ .Enable }}\r\n",
"enabled": "🚨 Etkin: {{ .Enable }}\r\n",
"online": "🌐 Bağlantı durumu: {{ .Status }}\r\n",
"lastOnline": "🔙 Son çevrimiçi: {{ .Time }}\r\n",
"lastOnline": "🔙 Son Çevrimiçi: {{ .Time }}\r\n",
"email": "📧 Email: {{ .Email }}\r\n",
"upload": "🔼 Yükleme: ↑{{ .Upload }}\r\n",
"download": "🔽 İndirme: ↓{{ .Download }}\r\n",
"total": "📊 Toplam: ↑↓{{ .UpDown }} / {{ .Total }}\r\n",
"TGUser": "👤 Telegram Kullanıcısı: {{ .TelegramID }}\r\n",
"exhaustedMsg": "🚨 Tükenmiş {{ .Type }}:\r\n",
"exhaustedCount": "🚨 Tükenmiş {{ .Type }} sayısı:\r\n",
"onlinesCount": "🌐 Çevrimiçi Müşteriler: {{ .Count }}\r\n",
"exhaustedMsg": "🚨 Limiti Dolan {{ .Type }}:\r\n",
"exhaustedCount": "🚨 Limiti Dolan {{ .Type }} sayısı:\r\n",
"onlinesCount": "🌐 Çevrimiçi Kullanıcılar: {{ .Count }}\r\n",
"disabled": "🛑 Devre Dışı: {{ .Disabled }}\r\n",
"depleteSoon": "🔜 Yakında Tükenecek: {{ .Deplete }}\r\n\r\n",
"depleteSoon": "🔜 Kotası Dolmak Üzere: {{ .Deplete }}\r\n\r\n",
"backupTime": "🗄 Yedekleme Zamanı: {{ .Time }}\r\n",
"refreshedOn": "\r\n📋🔄 Yenilendi: {{ .Time }}\r\n\r\n",
"yes": "✅ Evet",
"no": "❌ Hayır",
"received_id": "🔑📥 Kimlik güncellendi.",
"received_id": "🔑📥 UUID güncellendi.",
"received_password": "🔑📥 Şifre güncellendi.",
"received_email": "📧📥 E-posta güncellendi.",
"received_comment": "💬📥 Yorum güncellendi.",
"id_prompt": "🔑 Varsayılan Kimlik: {{ .ClientId }}\n\nKimliğinizi girin.",
"id_prompt": "🔑 Mevcut UUID: {{ .ClientId }}\n\nYeni UUID'nizi girin.",
"pass_prompt": "🔑 Varsayılan Şifre: {{ .ClientPassword }}\n\nŞifrenizi girin.",
"email_prompt": "📧 Varsayılan E-posta: {{ .ClientEmail }}\n\nE-postanızı girin.",
"comment_prompt": "💬 Varsayılan Yorum: {{ .ClientComment }}\n\nYorumunuzu girin.",
"inbound_client_data_id": "🔄 Giriş: {{ .InboundRemark }}\n\n🔑 Kimlik: {{ .ClientId }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Trafik: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Yorum: {{ .ClientComment }}\n\nArtık bu müşteriyi girişe ekleyebilirsin!",
"inbound_client_data_pass": "🔄 Giriş: {{ .InboundRemark }}\n\n🔑 Şifre: {{ .ClientPass }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Trafik: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Yorum: {{ .ClientComment }}\n\nArtık bu müşteriyi girişe ekleyebilirsin!",
"inbound_client_data_id": "🔄 Bağlantı Noktası: {{ .InboundRemark }}\n\n🔑 UUID: {{ .ClientId }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Kota: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Açıklama: {{ .ClientComment }}\n\nArtık bu kullanıcıyı bağlantı noktasına ekleyebilirsiniz!",
"inbound_client_data_pass": "🔄 Bağlantı Noktası: {{ .InboundRemark }}\n\n🔑 Şifre: {{ .ClientPass }}\n📧 E-posta: {{ .ClientEmail }}\n📊 Kota: {{ .ClientTraffic }}\n📅 Bitiş Tarihi: {{ .ClientExp }}\n🌐 IP Sınırı: {{ .IpLimit }}\n💬 Açıklama: {{ .ClientComment }}\n\nArtık bu kullanıcıyı bağlantı noktasına ekleyebilirsiniz!",
"cancel": "❌ İşlem iptal edildi! \n\nİstediğiniz zaman /start ile yeniden başlayabilirsiniz. 🔄",
"error_add_client": "⚠️ Hata:\n\n {{ .error }}",
"using_default_value": "Tamam, varsayılan değeri kullanacağım. 😊",
"incorrect_input": "Girdiğiniz değer geçerli değil.\nKelime öbekleri boşluk olmadan devam etmelidir.\nDoğru örnek: aaaaaa\nYanlış örnek: aaa aaa 🚫",
"AreYouSure": "Emin misin? 🤔",
"AreYouSure": "Emin misiniz? 🤔",
"SuccessResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ✅ Başarılı",
"FailedResetTraffic": "📧 E-posta: {{ .ClientEmail }}\n🏁 Sonuç: ❌ Başarısız \n\n🛠️ Hata: [ {{ .ErrorMessage }} ]",
"FinishProcess": "🔚 Tüm müşteriler için trafik sıfırlama işlemi tamamlandı."
"FinishProcess": "🔚 Tüm kullanıcılar için trafik sıfırlama işlemi tamamlandı."
},
"buttons": {
"closeKeyboard": "❌ Klavyeyi Kapat",
@@ -1577,10 +1587,10 @@
"confirmToggle": "✅ Kullanıcıyı Etkinleştirme/Devre Dışı Bırakmayı Onayla?",
"dbBackup": "Veritabanı Yedeği Al",
"serverUsage": "Sunucu Kullanımı",
"getInbounds": "Gelenleri Al",
"depleteSoon": "Yakında Tükenecek",
"clientUsage": "Kullanımı Al",
"onlines": "Çevrimiçi Müşteriler",
"getInbounds": "Bağlantı Noktalarını Al",
"depleteSoon": "Kotası Dolmak Üzere",
"clientUsage": "Kullanıcı İstatistikleri",
"onlines": "Çevrimiçi Kullanıcılar",
"commands": "Komutlar",
"refresh": "🔄 Yenile",
"clearIPs": "❌ IP'leri Temizle",
@@ -1598,8 +1608,8 @@
"confirmNumberAdd": "✅ Ekleme onayı: {{ .Num }}",
"limitTraffic": "🚧 Trafik Sınırı",
"getBanLogs": "Yasak Günlüklerini Al",
"allClients": "Tüm Müşteriler",
"addClient": "Müşteri Ekle",
"allClients": "Tüm Kullanıcılar",
"addClient": "Kullanıcı Ekle",
"submitDisable": "Devre Dışı Olarak Gönder ☑️",
"submitEnable": "Etkin Olarak Gönder ✅",
"use_default": "🏷️ Varsayılanı Kullan",
@@ -1614,12 +1624,12 @@
"answers": {
"successfulOperation": "✅ İşlem başarılı!",
"errorOperation": "❗ İşlemde hata.",
"getInboundsFailed": "❌ Gelenler alınamadı.",
"getClientsFailed": "❌ Müşteriler alınamadı.",
"getInboundsFailed": "❌ Bağlantı Noktaları alınamadı.",
"getClientsFailed": "❌ Kullanıcılar alınamadı.",
"canceled": "❌ {{ .Email }}: İşlem iptal edildi.",
"clientRefreshSuccess": "✅ {{ .Email }}: Müşteri başarıyla yenilendi.",
"clientRefreshSuccess": "✅ {{ .Email }}: Kullanıcı başarıyla yenilendi.",
"IpRefreshSuccess": "✅ {{ .Email }}: IP'ler başarıyla yenilendi.",
"TGIdRefreshSuccess": "✅ {{ .Email }}: Müşterinin Telegram Kullanıcısı başarıyla yenilendi.",
"TGIdRefreshSuccess": "✅ {{ .Email }}: Kullanıcının Telegram Kullanıcısı başarıyla yenilendi.",
"resetTrafficSuccess": "✅ {{ .Email }}: Trafik başarıyla sıfırlandı.",
"setTrafficLimitSuccess": "✅ {{ .Email }}: Trafik limiti başarıyla kaydedildi.",
"expireResetSuccess": "✅ {{ .Email }}: Son kullanma günleri başarıyla sıfırlandı.",
@@ -1631,8 +1641,8 @@
"enableSuccess": "✅ {{ .Email }}: Başarıyla etkinleştirildi.",
"disableSuccess": "✅ {{ .Email }}: Başarıyla devre dışı bırakıldı.",
"askToAddUserId": "Yapılandırmanız bulunamadı!\r\nLütfen yöneticinizden yapılandırmalarınıza Telegram ChatID'nizi eklemesini isteyin.\r\n\r\nKullanıcı ChatID'niz: <code>{{ .TgUserID }}</code>",
"chooseClient": "Gelen {{ .Inbound }} için bir Müşteri Seçin",
"chooseInbound": "Bir Gelen Seçin"
"chooseClient": "Bağlantı Noktası {{ .Inbound }} için bir Kullanıcı Seçin",
"chooseInbound": "Bir Bağlantı Noktası Seçin"
}
}
}
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Залиште 0, щоб використовувати значення за умовчанням ОС. Ненульові значення обмежують оголошуване вікно приймання TCP; значення на кшталт 600 (з прикладу в документації Xray) можуть обвалити пропускну здатність на каналах із високою затримкою.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "Мін. версія клієнта",
"maxClientVer": "Макс. версія клієнта",
"shortIds": "Short IDs",
"realityTargetHint": "Обов'язково. Має містити порт (напр., example.com:443). Без порту Xray-core не запускається.",
"realityTargetRequired": "Ціль REALITY обов'язкова",
"realityTargetNeedsPort": "Ціль REALITY має містити порт (напр., example.com:443)",
"realityTargetInvalidPort": "Ціль REALITY має недійсний порт",
"spiderX": "SpiderX",
"getNewCert": "Отримати новий сертифікат",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Помилка з'єднання",
"never": "ніколи",
"justNow": "щойно",
"subNode": "Підвузол",
"subNodeTip": "Лише для читання: підлеглий вузол, доступний через {parent}. Керуйте ним із власної панелі {parent}.",
"deleteConfirmTitle": "Видалити вузол \"{name}\"?",
"deleteConfirmContent": "Це зупинить моніторинг вузла. Сама віддалена панель не зазнає змін.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "Остаточний конфігураційний файл Xray буде створено на основі цього шаблону.",
"FreedomStrategy": "Стратегія протоколу свободи",
"FreedomStrategyDesc": "Установити стратегію виведення для мережі в протоколі свободи.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Двостековий набір для прямого (freedom) вихідного — корисно на вихідних серверах із IPv4 та IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Мілісекунди перед спробою іншої родини адрес. 150–250 мс — добра початкова точка.",
"RoutingStrategy": "Загальна стратегія маршрутизації",
"RoutingStrategyDesc": "Установити загальну стратегію маршрутизації трафіку для вирішення всіх запитів.",
"outboundTestUrl": "URL тесту outbound",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "Để 0 để dùng mặc định của hệ điều hành. Giá trị khác 0 sẽ giới hạn cửa sổ nhận TCP được quảng bá; các giá trị như 600 (theo ví dụ tài liệu Xray) có thể làm sụp thông lượng trên các liên kết độ trễ cao.",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "Phiên bản client tối thiểu",
"maxClientVer": "Phiên bản client tối đa",
"shortIds": "Short IDs",
"realityTargetHint": "Bắt buộc. Phải bao gồm cổng (ví dụ example.com:443). Không có cổng, Xray-core sẽ không khởi động.",
"realityTargetRequired": "Mục tiêu REALITY là bắt buộc",
"realityTargetNeedsPort": "Mục tiêu REALITY phải bao gồm cổng (ví dụ example.com:443)",
"realityTargetInvalidPort": "Mục tiêu REALITY có cổng không hợp lệ",
"spiderX": "SpiderX",
"getNewCert": "Lấy chứng chỉ mới",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "Kết nối thất bại",
"never": "chưa bao giờ",
"justNow": "vừa xong",
"subNode": "Nút con",
"subNodeTip": "Chỉ đọc: một nút phía dưới được kết nối qua {parent}. Quản lý nó từ bảng điều khiển của chính {parent}.",
"deleteConfirmTitle": "Xóa nút \"{name}\"?",
"deleteConfirmContent": "Việc này dừng giám sát nút. Panel từ xa không bị ảnh hưởng.",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "Tạo tệp cấu hình Xray cuối cùng dựa trên mẫu này.",
"FreedomStrategy": "Cấu hình Chiến lược cho Giao thức Freedom",
"FreedomStrategyDesc": "Đặt chiến lược đầu ra của mạng trong Giao thức Freedom.",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "Quay số dual-stack cho outbound trực tiếp (freedom) — hữu ích trên máy chủ thoát có cả IPv4 và IPv6.",
"FreedomHappyEyeballsTryDelayDesc": "Số mili-giây trước khi thử họ địa chỉ còn lại. 150–250 ms là điểm khởi đầu tốt.",
"RoutingStrategy": "Cấu hình Chiến lược Định tuyến Tên miền",
"RoutingStrategyDesc": "Đặt chiến lược định tuyến tổng thể cho việc giải quyết DNS.",
"outboundTestUrl": "URL kiểm tra outbound",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "留 0 使用操作系统默认值。非零值会限制通告的 TCP 接收窗口;像 600 这样的值(来自 Xray 文档示例)在高延迟链路上可能导致吞吐量骤降。",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "最小客户端版本",
"maxClientVer": "最大客户端版本",
"shortIds": "Short IDs",
"realityTargetHint": "必填。必须包含端口(例如 example.com:443)。没有端口时 Xray-core 将无法启动。",
"realityTargetRequired": "REALITY 目标为必填项",
"realityTargetNeedsPort": "REALITY 目标必须包含端口(例如 example.com:443)",
"realityTargetInvalidPort": "REALITY 目标的端口无效",
"spiderX": "SpiderX",
"getNewCert": "获取新证书",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "连接失败",
"never": "从未",
"justNow": "刚刚",
"subNode": "子节点",
"subNodeTip": "只读:通过 {parent} 接入的下游节点。请在 {parent} 自己的面板中管理。",
"deleteConfirmTitle": "删除节点 \"{name}\"?",
"deleteConfirmContent": "这将停止监控该节点。远程面板本身不受影响。",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "最终的 Xray 配置文件将基于此模板生成",
"FreedomStrategy": "Freedom 协议策略",
"FreedomStrategyDesc": "设置 Freedom 协议中网络的输出策略",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "为直连(freedom)出站启用双栈拨号——在同时具备 IPv4 和 IPv6 的出口服务器上很有用。",
"FreedomHappyEyeballsTryDelayDesc": "尝试备用地址族之前等待的毫秒数。150–250 毫秒是不错的起点。",
"RoutingStrategy": "配置路由域策略",
"RoutingStrategyDesc": "设置 DNS 解析的整体路由策略",
"outboundTestUrl": "出站测试 URL",
+10
View File
@@ -559,6 +559,7 @@
"tcpMaxSeg": "TCP Max Seg",
"tcpUserTimeout": "TCP User Timeout",
"tcpWindowClamp": "TCP Window Clamp",
"tcpWindowClampHint": "留 0 使用作業系統預設值。非零值會限制通告的 TCP 接收視窗;像 600 這樣的值(來自 Xray 文件範例)在高延遲連結上可能導致吞吐量驟降。",
"tcpFastOpen": "TCP Fast Open",
"multipathTcp": "Multipath TCP",
"penetrate": "Penetrate",
@@ -597,6 +598,10 @@
"minClientVer": "最小客戶端版本",
"maxClientVer": "最大客戶端版本",
"shortIds": "Short IDs",
"realityTargetHint": "必填。必須包含連接埠(例如 example.com:443)。沒有連接埠時 Xray-core 將無法啟動。",
"realityTargetRequired": "REALITY 目標為必填項",
"realityTargetNeedsPort": "REALITY 目標必須包含連接埠(例如 example.com:443)",
"realityTargetInvalidPort": "REALITY 目標的連接埠無效",
"spiderX": "SpiderX",
"getNewCert": "取得新憑證",
"mldsa65Seed": "mldsa65 Seed",
@@ -881,6 +886,8 @@
"connectionFailed": "連線失敗",
"never": "從未",
"justNow": "剛剛",
"subNode": "子節點",
"subNodeTip": "唯讀:透過 {parent} 連接的下游節點。請在 {parent} 自己的面板中管理。",
"deleteConfirmTitle": "刪除節點「{name}」?",
"deleteConfirmContent": "這將停止監控該節點。遠端面板本身不受影響。",
"statusValues": {
@@ -1177,6 +1184,9 @@
"TemplateDesc": "最終的 Xray 配置檔案將基於此模板生成",
"FreedomStrategy": "Freedom 協議策略",
"FreedomStrategyDesc": "設定 Freedom 協議中網路的輸出策略",
"FreedomHappyEyeballs": "Freedom Happy Eyeballs (IPv4/IPv6)",
"FreedomHappyEyeballsDesc": "為直連(freedom)出站啟用雙協定堆疊撥號——在同時具備 IPv4 與 IPv6 的出口伺服器上很有用。",
"FreedomHappyEyeballsTryDelayDesc": "嘗試備用位址族之前等待的毫秒數。150–250 毫秒是不錯的起點。",
"RoutingStrategy": "配置路由域策略",
"RoutingStrategyDesc": "設定 DNS 解析的整體路由策略",
"outboundTestUrl": "出站測試 URL",
+102
View File
@@ -2795,6 +2795,7 @@ postgresql_menu() {
echo -e "${green}\t6.${plain} Restart PostgreSQL"
echo -e "${green}\t7.${plain} ${green}Enable${plain} Autostart on boot"
echo -e "${green}\t8.${plain} View PostgreSQL Log"
echo -e "${green}\t9.${plain} Convert SQLite ${green}.db <-> .dump${plain}"
echo -e "${green}\t0.${plain} Back to Main Menu"
read -rp "Choose an option: " choice
case "$choice" in
@@ -2833,6 +2834,10 @@ postgresql_menu() {
postgresql_log
postgresql_menu
;;
9)
migrate_db_prompt
postgresql_menu
;;
*)
echo -e "${red}Invalid option. Please select a valid number.${plain}\n"
postgresql_menu
@@ -2840,6 +2845,99 @@ postgresql_menu() {
esac
}
# Convert between the panel's SQLite database and a portable .dump (SQL text)
# file using the bundled x-ui binary. With no arguments it dumps the installed
# panel database; an optional second argument overrides the output path.
# x-ui migrateDB [file.db|file.dump] [output]
migrate_db() {
local input="$1" output="$2"
local default_db="/etc/x-ui/x-ui.db"
local bin="${xui_folder}/x-ui"
[[ -z "$input" ]] && input="$default_db"
if [[ ! -x "$bin" ]]; then
LOGE "x-ui binary not found at ${bin}. Is the panel installed?"
return 1
fi
if ! "$bin" migrate-db -h 2>&1 | grep -q -- '-dump'; then
LOGE "This x-ui build does not support .db <-> .dump conversion yet."
LOGE "Update the panel first (x-ui update) to a version with 'migrate-db --dump/--restore'."
return 1
fi
if [[ ! -f "$input" ]]; then
LOGE "Input file not found: ${input}"
echo -e "Usage: ${green}x-ui migrateDB [file.db|file.dump] [output]${plain}"
return 1
fi
local mode
case "$input" in
*.db | *.sqlite | *.sqlite3)
mode="dump"
;;
*.dump | *.sql)
mode="restore"
;;
*)
if head -c 16 "$input" | grep -q "SQLite format 3"; then
mode="dump"
else
mode="restore"
fi
;;
esac
if [[ "$mode" == "dump" ]]; then
[[ -z "$output" ]] && output="${input%.*}.dump"
if [[ -f "$output" ]]; then
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
fi
LOGI "Dumping SQLite database to SQL text:"
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
if "$bin" migrate-db --src "$input" --dump "$output"; then
LOGI "Done. Wrote ${output}."
else
LOGE "Dump failed."
return 1
fi
else
[[ -z "$output" ]] && output="${input%.*}.db"
if [[ "$output" == "$default_db" ]] && check_status > /dev/null 2>&1; then
LOGE "Refusing to restore into the live database (${default_db}) while x-ui is running."
LOGE "Stop the panel first (x-ui stop) or choose a different output path."
return 1
fi
if [[ -f "$output" ]]; then
confirm "Output ${output} already exists and will be overwritten. Continue?" "n" || return 0
rm -f "$output"
fi
LOGI "Rebuilding SQLite database from SQL text:"
echo -e " ${green}${input}${plain} -> ${green}${output}${plain}"
if "$bin" migrate-db --restore "$input" --out "$output"; then
LOGI "Done. Created ${output}."
else
LOGE "Restore failed."
rm -f "$output"
return 1
fi
fi
}
# Interactive wrapper around migrate_db for the menu: prompts for the paths and
# lets migrate_db auto-detect the direction.
migrate_db_prompt() {
local default_db="/etc/x-ui/x-ui.db"
local input output
echo -e "Convert between a SQLite ${green}.db${plain} and a portable ${green}.dump${plain} (direction auto-detected)."
read -rp "Input file [${default_db}]: " input
input="${input:-$default_db}"
read -rp "Output file (leave empty to auto-name next to input): " output
migrate_db "$input" "$output"
}
show_usage() {
echo -e "┌────────────────────────────────────────────────────────────────┐
│ ${blue}x-ui control menu usages (subcommands):${plain} │
@@ -2857,6 +2955,7 @@ show_usage() {
│ ${blue}x-ui banlog${plain} - Check Fail2ban ban logs │
│ ${blue}x-ui update${plain} - Update │
│ ${blue}x-ui update-all-geofiles${plain} - Update all geo files │
│ ${blue}x-ui migrateDB [file]${plain} - Convert .db <-> .dump (SQLite) │
│ ${blue}x-ui legacy${plain} - Legacy version │
│ ${blue}x-ui install${plain} - Install │
│ ${blue}x-ui uninstall${plain} - Uninstall │
@@ -3045,6 +3144,9 @@ if [[ $# > 0 ]]; then
"update-all-geofiles")
check_install 0 && update_all_geofiles 0 && restart 0
;;
"migrateDB")
migrate_db "$2" "$3"
;;
*) show_usage ;;
esac
else
+12 -12
View File
@@ -3,16 +3,16 @@ package xray
// ClientTraffic represents traffic statistics and limits for a specific client.
// It tracks upload/download usage, expiry times, and online status for inbound clients.
type ClientTraffic struct {
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement"`
InboundId int `json:"inboundId" form:"inboundId"`
Enable bool `json:"enable" form:"enable"`
Email string `json:"email" form:"email" gorm:"unique"`
UUID string `json:"uuid" form:"uuid" gorm:"-"`
SubId string `json:"subId" form:"subId" gorm:"-"`
Up int64 `json:"up" form:"up"`
Down int64 `json:"down" form:"down"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime"`
Total int64 `json:"total" form:"total"`
Reset int `json:"reset" form:"reset" gorm:"default:0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0"`
Id int `json:"id" form:"id" gorm:"primaryKey;autoIncrement" example:"14825"`
InboundId int `json:"inboundId" form:"inboundId" example:"1"`
Enable bool `json:"enable" form:"enable" example:"true"`
Email string `json:"email" form:"email" gorm:"unique" example:"user1"`
UUID string `json:"uuid" form:"uuid" gorm:"-" example:"e18c9a96-71bf-48d4-933f-8b9a46d4290c"`
SubId string `json:"subId" form:"subId" gorm:"-" example:"i7tvdpeffi0hvvf1"`
Up int64 `json:"up" form:"up" example:"1048576"`
Down int64 `json:"down" form:"down" example:"2097152"`
ExpiryTime int64 `json:"expiryTime" form:"expiryTime" example:"1735689600000"`
Total int64 `json:"total" form:"total" example:"10737418240"`
Reset int `json:"reset" form:"reset" gorm:"default:0" example:"0"`
LastOnline int64 `json:"lastOnline" form:"lastOnline" gorm:"default:0" example:"1735680000000"`
}
+47 -52
View File
@@ -20,51 +20,50 @@ func assertSameSet(t *testing.T, label string, got, want []string) {
}
}
// TestGetOnlineClientsByNodeScopesPerNode pins the fix for issue #4809: a
// client online on one node must not be reported online on any other node.
func TestGetOnlineClientsByNodeScopesPerNode(t *testing.T) {
// TestMergedNodeTreesScopesPerGuid pins #4983/#4809: each node's clients stay
// under that node's GUID, so a client on one node is never attributed to
// another — and a sub-node's clients (reported under their own GUID inside a
// parent's tree) compose upward without collapsing onto the parent.
func TestMergedNodeTreesScopesPerGuid(t *testing.T) {
p := newOnlineTestProcess()
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
p.SetNodeOnlineClients(5, []string{"user3"})
// Node A (direct) reports its own clients plus sub-node B's tree.
p.SetNodeOnlineTree(1, map[string][]string{
"guid-a": {"user1", "user2"},
"guid-b": {"user3"}, // B is behind A; still keyed by B's own GUID
})
p.SetNodeOnlineTree(2, map[string][]string{
"guid-c": {"user4"},
})
byNode := p.GetOnlineClientsByNode()
merged := p.GetMergedNodeTrees()
assertSameSet(t, "guid-a", merged["guid-a"], []string{"user1", "user2"})
assertSameSet(t, "guid-b", merged["guid-b"], []string{"user3"})
assertSameSet(t, "guid-c", merged["guid-c"], []string{"user4"})
assertSameSet(t, "local (key 0)", byNode[localNodeKey], []string{"user1"})
assertSameSet(t, "node 3", byNode[3], []string{"user1", "user2"})
assertSameSet(t, "node 5", byNode[5], []string{"user3"})
if slices.Contains(byNode[5], "user1") {
t.Errorf("user1 leaked onto node 5: %v", byNode[5])
}
if slices.Contains(byNode[localNodeKey], "user3") || slices.Contains(byNode[3], "user3") {
t.Errorf("user3 leaked off node 5: local=%v node3=%v", byNode[localNodeKey], byNode[3])
if slices.Contains(merged["guid-a"], "user3") {
t.Errorf("user3 (on sub-node B) leaked onto node A: %v", merged["guid-a"])
}
}
// TestGetOnlineClientsByNodeOmitsEmptyGroups keeps the payload small: a node
// with no online clients (e.g. just cleared) must not appear as an empty key.
func TestGetOnlineClientsByNodeOmitsEmptyGroups(t *testing.T) {
// TestMergedNodeTreesOmitsEmpty keeps the payload small: empty GUID sets don't
// appear as keys.
func TestMergedNodeTreesOmitsEmpty(t *testing.T) {
p := newOnlineTestProcess()
p.SetNodeOnlineClients(3, []string{"user1"})
p.SetNodeOnlineClients(7, []string{})
byNode := p.GetOnlineClientsByNode()
if _, ok := byNode[7]; ok {
t.Errorf("node 7 has no online clients but is present: %v", byNode)
}
if _, ok := byNode[localNodeKey]; ok {
t.Errorf("no local clients online but key 0 is present: %v", byNode)
p.SetNodeOnlineTree(1, map[string][]string{
"guid-a": {"user1"},
"guid-x": {},
})
if _, ok := p.GetMergedNodeTrees()["guid-x"]; ok {
t.Errorf("empty GUID set should be omitted: %v", p.GetMergedNodeTrees())
}
}
// TestGetOnlineClientsUnionDedupes confirms the flat union (used by the
// client-centric / total-count views) still merges every node and dedupes.
// TestGetOnlineClientsUnionDedupes confirms the flat union (client-centric /
// total-count views) merges local + every node and dedupes.
func TestGetOnlineClientsUnionDedupes(t *testing.T) {
p := newOnlineTestProcess()
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, 20000)
p.SetNodeOnlineClients(3, []string{"user1", "user2"})
p.SetNodeOnlineTree(1, map[string][]string{"guid-a": {"user1", "user2"}})
assertSameSet(t, "union", p.GetOnlineClients(), []string{"user1", "user2"})
}
@@ -77,18 +76,18 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
const grace = 20000
p.RefreshLocalOnline([]string{"user1"}, nil, 1000, grace)
if got := p.GetOnlineClientsByNode()[localNodeKey]; !slices.Contains(got, "user1") {
if got := p.GetLocalOnlineClients(); !slices.Contains(got, "user1") {
t.Fatalf("user1 should be online right after activity, got %v", got)
}
p.RefreshLocalOnline([]string{"user2"}, nil, 11000, grace)
got := p.GetOnlineClientsByNode()[localNodeKey]
got := p.GetLocalOnlineClients()
if !slices.Contains(got, "user1") || !slices.Contains(got, "user2") {
t.Fatalf("both within grace window, got %v", got)
}
p.RefreshLocalOnline(nil, nil, 22000, grace)
got = p.GetOnlineClientsByNode()[localNodeKey]
got = p.GetLocalOnlineClients()
if slices.Contains(got, "user1") {
t.Errorf("user1 (idle 21s, past grace) should have aged out, got %v", got)
}
@@ -97,40 +96,36 @@ func TestRefreshLocalOnlineGraceWindow(t *testing.T) {
}
}
// TestGetActiveInboundsByNodeTracksGraceWindow pins the fix for issue #4859: a
// multi-inbound client must only count as online on inbounds that actually
// carried traffic. The active-inbound signal honours the same grace window as
// the online-email signal, and only this panel's tags report under key 0.
func TestGetActiveInboundsByNodeTracksGraceWindow(t *testing.T) {
// TestGetLocalActiveInboundsTracksGraceWindow pins #4859: a multi-inbound
// client only counts online on inbounds that actually carried traffic, and the
// active-inbound signal honours the same grace window as the online signal.
func TestGetLocalActiveInboundsTracksGraceWindow(t *testing.T) {
p := newOnlineTestProcess()
const grace = 20000
p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-a"}, 1000, grace)
got := p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "active after first poll", got, []string{"inbound-a"})
assertSameSet(t, "active after first poll", p.GetLocalActiveInbounds(), []string{"inbound-a"})
p.RefreshLocalOnline([]string{"alice"}, []string{"inbound-b"}, 11000, grace)
got = p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "both within grace", got, []string{"inbound-a", "inbound-b"})
assertSameSet(t, "both within grace", p.GetLocalActiveInbounds(), []string{"inbound-a", "inbound-b"})
p.RefreshLocalOnline(nil, nil, 22000, grace)
got = p.GetActiveInboundsByNode()[localNodeKey]
assertSameSet(t, "inbound-a (idle 21s, past grace) aged out, inbound-b kept", got, []string{"inbound-b"})
assertSameSet(t, "inbound-a (idle 21s) aged out, inbound-b kept", p.GetLocalActiveInbounds(), []string{"inbound-b"})
p.RefreshLocalOnline(nil, nil, 40000, grace)
if _, ok := p.GetActiveInboundsByNode()[localNodeKey]; ok {
t.Errorf("all inbounds idle past grace, key 0 should be absent: %v", p.GetActiveInboundsByNode())
if got := p.GetLocalActiveInbounds(); len(got) != 0 {
t.Errorf("all inbounds idle past grace, want empty, got %v", got)
}
}
// TestClearNodeOnlineClientsDropsNode mirrors a failed node probe: the node's
// clients must disappear from the per-node map immediately.
// whole subtree contribution disappears immediately.
func TestClearNodeOnlineClientsDropsNode(t *testing.T) {
p := newOnlineTestProcess()
p.SetNodeOnlineClients(3, []string{"user1"})
p.SetNodeOnlineTree(3, map[string][]string{"guid-a": {"user1"}})
p.ClearNodeOnlineClients(3)
if _, ok := p.GetOnlineClientsByNode()[3]; ok {
t.Errorf("node 3 should be absent after ClearNodeOnlineClients")
if _, ok := p.GetMergedNodeTrees()["guid-a"]; ok {
t.Errorf("node 3's subtree should be absent after ClearNodeOnlineClients")
}
}
+96 -73
View File
@@ -23,7 +23,11 @@ import (
// GetBinaryName returns the Xray binary filename for the current OS and architecture.
func GetBinaryName() string {
return fmt.Sprintf("xray-%s-%s", runtime.GOOS, runtime.GOARCH)
arch := runtime.GOARCH
if arch == "arm" {
arch = "arm32"
}
return fmt.Sprintf("xray-%s-%s", runtime.GOOS, arch)
}
// GetBinaryPath returns the full path to the Xray binary executable.
@@ -155,13 +159,18 @@ type process struct {
// two signals stay aligned — an email within grace always has the
// inbound it used within grace too.
localInboundLastActive map[string]int64
// nodeOnlineClients holds the online-emails list reported by each
// remote node, keyed by node id. NodeTrafficSyncJob populates entries
// per cron tick and clears them when a node's probe fails. The mutex
// guards this map, onlineClients, and localLastOnline above so the
// nodeOnlineTrees holds, per direct remote node (keyed by that node's
// panel-local id), the GUID-keyed online-emails subtree that node
// reported — its own clients under its panelGuid plus every descendant
// under theirs. Keying the stored value by GUID (not node id) lets the
// master attribute a deeply nested client to the node that physically
// hosts it across a chain (#4983); the outer node-id key is only so a
// failed probe can drop that whole branch's contribution. NodeTrafficSyncJob
// populates entries per cron tick and clears them when a probe fails. The
// mutex guards this map, onlineClients, and localLastOnline above so the
// online getters never see a torn read.
nodeOnlineClients map[int][]string
onlineMu sync.RWMutex
nodeOnlineTrees map[int]map[string][]string
onlineMu sync.RWMutex
config *Config
configPath string // if set, use this path instead of GetConfigPath() and remove on Stop
@@ -177,12 +186,6 @@ var (
xrayForceStopTimeout = 2 * time.Second
)
// localNodeKey is the GetOnlineClientsByNode key under which this panel's
// own (non-node-managed) inbounds report their online clients. Node ids
// autoincrement from 1, so 0 is a safe sentinel that never collides with a
// real node. The frontend mirrors this contract (nodeId ?? 0).
const localNodeKey = 0
// newProcess creates a new internal process struct for Xray.
func newProcess(config *Config) *process {
return &process{
@@ -255,7 +258,7 @@ func (p *Process) GetOnlineClients() []string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.nodeOnlineClients) == 0 {
if len(p.nodeOnlineTrees) == 0 {
// Hot path for single-panel deployments: avoid the map+dedupe
// work entirely and return the local slice as-is.
return p.onlineClients
@@ -263,15 +266,8 @@ func (p *Process) GetOnlineClients() []string {
seen := make(map[string]struct{}, len(p.onlineClients))
out := make([]string, 0, len(p.onlineClients))
for _, email := range p.onlineClients {
if _, dup := seen[email]; dup {
continue
}
seen[email] = struct{}{}
out = append(out, email)
}
for _, list := range p.nodeOnlineClients {
for _, email := range list {
add := func(emails []string) {
for _, email := range emails {
if _, dup := seen[email]; dup {
continue
}
@@ -279,53 +275,79 @@ func (p *Process) GetOnlineClients() []string {
out = append(out, email)
}
}
return out
}
// GetOnlineClientsByNode returns online emails grouped by the node that
// reported them: this panel's own xray clients under localNodeKey (0), and
// each remote node's clients under that node's id. Unlike GetOnlineClients
// (which flattens everything into one deduped union), this preserves node
// attribution so per-inbound/per-node online counts don't bleed a client
// connected to one node onto every other node. Empty groups are omitted.
func (p *Process) GetOnlineClientsByNode() map[int][]string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
out := make(map[int][]string, len(p.nodeOnlineClients)+1)
if len(p.onlineClients) > 0 {
local := make([]string, len(p.onlineClients))
copy(local, p.onlineClients)
out[localNodeKey] = local
}
for nodeID, list := range p.nodeOnlineClients {
if len(list) == 0 {
continue
add(p.onlineClients)
for _, tree := range p.nodeOnlineTrees {
for _, emails := range tree {
add(emails)
}
cp := make([]string, len(list))
copy(cp, list)
out[nodeID] = cp
}
return out
}
// GetActiveInboundsByNode returns the inbound tags that carried traffic within
// the grace window, grouped by node. Only this panel's own xray reports
// per-inbound activity (under localNodeKey); remote-node snapshots don't carry
// it, so their nodes are simply absent — the per-inbound view reads "node
// missing" as "don't gate" and falls back to the email-only signal there.
// Empty groups are omitted, mirroring GetOnlineClientsByNode.
func (p *Process) GetActiveInboundsByNode() map[int][]string {
// GetLocalOnlineClients returns a copy of the emails online on THIS panel's own
// xray within the grace window. The service layer keys these under the panel's
// own GUID when assembling the per-node online view.
func (p *Process) GetLocalOnlineClients() []string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.localActiveInbounds) == 0 {
return map[int][]string{}
if len(p.onlineClients) == 0 {
return nil
}
out := make(map[int][]string, 1)
local := make([]string, len(p.localActiveInbounds))
copy(local, p.localActiveInbounds)
out[localNodeKey] = local
out := make([]string, len(p.onlineClients))
copy(out, p.onlineClients)
return out
}
// GetMergedNodeTrees returns the union of every direct node's reported subtree,
// keyed by the panelGuid of the node that physically hosts each client set.
// Because each child already reports its descendants under their own GUIDs,
// merging the direct children yields the whole tree at any depth (#4983), so a
// client three hops down is attributed to its real node, not the intermediate
// one. GUIDs are globally unique, but a set reported under the same GUID by more
// than one path is deduped per key; empty sets are omitted.
func (p *Process) GetMergedNodeTrees() map[string][]string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.nodeOnlineTrees) == 0 {
return map[string][]string{}
}
out := make(map[string][]string)
seen := make(map[string]map[string]struct{})
for _, tree := range p.nodeOnlineTrees {
for guid, emails := range tree {
if guid == "" || len(emails) == 0 {
continue
}
dedup := seen[guid]
if dedup == nil {
dedup = make(map[string]struct{}, len(emails))
seen[guid] = dedup
}
for _, email := range emails {
if _, ok := dedup[email]; ok {
continue
}
dedup[email] = struct{}{}
out[guid] = append(out[guid], email)
}
}
}
return out
}
// GetLocalActiveInbounds returns a copy of THIS panel's inbound tags that
// carried traffic within the grace window. Only the local xray reports
// per-inbound activity; remote-node snapshots don't carry it, so the service
// layer keys these under the panel's own GUID and a node missing from the
// active-inbounds map means "don't gate" (fall back to the email-only signal).
func (p *Process) GetLocalActiveInbounds() []string {
p.onlineMu.RLock()
defer p.onlineMu.RUnlock()
if len(p.localActiveInbounds) == 0 {
return nil
}
out := make([]string, len(p.localActiveInbounds))
copy(out, p.localActiveInbounds)
return out
}
@@ -371,25 +393,26 @@ func (p *Process) RefreshLocalOnline(activeEmails, activeInboundTags []string, n
p.localActiveInbounds = activeInbounds
}
// SetNodeOnlineClients records the online-emails set for one remote
// node. Replaces any previous entry for that node — NodeTrafficSyncJob
// always sends the full list per tick.
func (p *Process) SetNodeOnlineClients(nodeID int, emails []string) {
// SetNodeOnlineTree records the GUID-keyed online subtree one direct remote
// node reported (its own clients under its panelGuid plus every descendant
// under theirs). Replaces any previous entry for that node — NodeTrafficSyncJob
// always sends the full subtree per tick.
func (p *Process) SetNodeOnlineTree(nodeID int, tree map[string][]string) {
p.onlineMu.Lock()
defer p.onlineMu.Unlock()
if p.nodeOnlineClients == nil {
p.nodeOnlineClients = map[int][]string{}
if p.nodeOnlineTrees == nil {
p.nodeOnlineTrees = map[int]map[string][]string{}
}
p.nodeOnlineClients[nodeID] = emails
p.nodeOnlineTrees[nodeID] = tree
}
// ClearNodeOnlineClients drops a node's contribution to the online set.
// Called when a probe fails so a downed node doesn't keep its clients
// listed as "online" until the next successful probe.
// ClearNodeOnlineClients drops a direct node's whole subtree contribution.
// Called when a probe fails so a downed node — and everything behind it — doesn't
// keep its clients listed as "online" until the next successful probe.
func (p *Process) ClearNodeOnlineClients(nodeID int) {
p.onlineMu.Lock()
defer p.onlineMu.Unlock()
delete(p.nodeOnlineClients, nodeID)
delete(p.nodeOnlineTrees, nodeID)
}
// GetUptime returns the uptime of the Xray process in seconds.